- Debug images clickable — opens fullscreen overlay (click to close) - After calibration, detected court lines highlighted on 3D scene (blue for CAM0, pink for CAM1) - Return matched_lines_3d from calibration (baseline, kitchen, sidelines, service) - Show line names in calibration results Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1061 lines
36 KiB
HTML
1061 lines
36 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Pickle Vision - Referee System</title>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
background: #0a0a1a;
|
|
color: #e0e0e0;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
overflow: hidden;
|
|
height: 100vh;
|
|
}
|
|
|
|
/* Header */
|
|
.header {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 24px;
|
|
background: #111128;
|
|
border-bottom: 1px solid #2a2a4a;
|
|
}
|
|
.logo { font-size: 20px; font-weight: 700; color: #4ecca3; }
|
|
.tabs { display: flex; gap: 4px; }
|
|
.tab {
|
|
padding: 8px 20px;
|
|
border: 1px solid #333;
|
|
border-radius: 6px;
|
|
background: transparent;
|
|
color: #888;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
transition: all 0.2s;
|
|
}
|
|
.tab:hover:not(:disabled) { color: #ccc; border-color: #555; }
|
|
.tab:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
.tab.active {
|
|
background: #4ecca3;
|
|
color: #000;
|
|
border-color: #4ecca3;
|
|
font-weight: 600;
|
|
}
|
|
.status-bar {
|
|
display: flex;
|
|
gap: 16px;
|
|
font-size: 13px;
|
|
color: #666;
|
|
}
|
|
.status-bar .val { color: #4ecca3; font-weight: 600; }
|
|
|
|
/* Tab content */
|
|
.tab-content { display: none; }
|
|
.tab-content.active { display: block; }
|
|
|
|
/* Camera feeds (detection tab) */
|
|
.cameras {
|
|
display: flex;
|
|
gap: 8px;
|
|
padding: 8px;
|
|
justify-content: center;
|
|
height: calc(100vh - 48px);
|
|
}
|
|
.cam-box {
|
|
flex: 1;
|
|
max-width: 50%;
|
|
position: relative;
|
|
}
|
|
.cam-box img {
|
|
width: 100%;
|
|
border-radius: 6px;
|
|
border: 1px solid #222;
|
|
display: block;
|
|
}
|
|
.cam-label {
|
|
position: absolute;
|
|
top: 8px;
|
|
left: 8px;
|
|
background: rgba(0,0,0,0.7);
|
|
color: #4ecca3;
|
|
padding: 2px 8px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* 3D viewport */
|
|
.viewport-3d {
|
|
width: 100%;
|
|
height: 400px;
|
|
border-top: 1px solid #222;
|
|
position: relative;
|
|
}
|
|
.viewport-3d canvas { width: 100% !important; height: 100% !important; }
|
|
|
|
/* Full-height tab layout */
|
|
.tab-content {
|
|
padding-top: 48px;
|
|
}
|
|
.tab-full {
|
|
height: calc(100vh - 48px - 100px);
|
|
}
|
|
.tab-full .viewport-3d {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-top: none;
|
|
}
|
|
.bottom-bar {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 10;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 6px;
|
|
padding: 4px 8px;
|
|
background: #0d0d20;
|
|
border-top: 1px solid #222;
|
|
height: 100px;
|
|
}
|
|
.bottom-card {
|
|
width: 120px;
|
|
height: 90px;
|
|
flex-shrink: 0;
|
|
border-radius: 4px;
|
|
border: 1px solid #222;
|
|
overflow: hidden;
|
|
}
|
|
.bottom-card img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
/* Sidebar panel (unused, kept for compat) */
|
|
.sidebar-panel { display: none; }
|
|
.btn-calibrate {
|
|
padding: 3px 8px;
|
|
background: #4ecca3;
|
|
color: #000;
|
|
border: none;
|
|
border-radius: 4px;
|
|
font-size: 9px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
}
|
|
.btn-calibrate:hover { background: #3dbb92; }
|
|
.btn-calibrate:disabled { background: #333; color: #666; cursor: not-allowed; }
|
|
.calibrate-status {
|
|
font-size: 8px;
|
|
color: #666;
|
|
}
|
|
.calibrate-status .ok { color: #4ecca3; }
|
|
.calibrate-status .fail { color: #ff4444; }
|
|
|
|
/* VAR panel inherits from .cam-card via shared rule above */
|
|
.var-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.var-dot {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 50%;
|
|
background: #333;
|
|
transition: background 0.3s;
|
|
}
|
|
.var-dot.active {
|
|
background: #ff3333;
|
|
box-shadow: 0 0 12px rgba(255, 51, 51, 0.6);
|
|
animation: var-pulse 1.5s infinite;
|
|
}
|
|
@keyframes var-pulse {
|
|
0%, 100% { box-shadow: 0 0 8px rgba(255, 51, 51, 0.4); }
|
|
50% { box-shadow: 0 0 20px rgba(255, 51, 51, 0.8); }
|
|
}
|
|
.var-label { font-size: 14px; font-weight: 700; color: #888; }
|
|
.var-label.active { color: #ff3333; }
|
|
|
|
.var-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
font-size: 13px;
|
|
color: #888;
|
|
min-width: 200px;
|
|
}
|
|
.var-info .var-line { color: #e0e0e0; font-weight: 600; }
|
|
.var-info .var-timer { color: #ff6666; font-size: 16px; font-weight: 700; }
|
|
.var-info .var-dist { color: #aaa; }
|
|
|
|
.var-snapshot {
|
|
width: 100%;
|
|
border-radius: 4px;
|
|
border: 1px solid #333;
|
|
object-fit: cover;
|
|
background: #1a1a2e;
|
|
}
|
|
.var-snapshot.empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #444;
|
|
font-size: 11px;
|
|
height: 60px;
|
|
}
|
|
|
|
/* Info card (same size as cam cards) */
|
|
.cam-card, .var-panel-bottom {
|
|
width: 120px;
|
|
height: 90px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
gap: 1px;
|
|
padding: 4px 8px;
|
|
background: #111128;
|
|
border: 1px solid #2a2a4a;
|
|
border-radius: 4px;
|
|
font-size: 9px;
|
|
color: #888;
|
|
overflow: hidden;
|
|
}
|
|
.cc-title {
|
|
font-size: 8px;
|
|
font-weight: 700;
|
|
color: #555;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.cc-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
gap: 6px;
|
|
line-height: 1.2;
|
|
}
|
|
.cc-item b {
|
|
color: #4ecca3;
|
|
font-weight: 600;
|
|
}
|
|
.cc-divider {
|
|
border-top: 1px solid #2a2a4a;
|
|
margin: 1px 0;
|
|
}
|
|
|
|
/* Event banner */
|
|
.event-banner {
|
|
display: none;
|
|
position: fixed;
|
|
top: 60px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(255, 60, 60, 0.95);
|
|
color: white;
|
|
padding: 12px 32px;
|
|
border-radius: 8px;
|
|
font-size: 18px;
|
|
font-weight: 700;
|
|
z-index: 100;
|
|
animation: pulse 1s infinite;
|
|
}
|
|
.event-banner.show { display: block; }
|
|
@keyframes pulse {
|
|
0%, 100% { opacity: 1; }
|
|
50% { opacity: 0.7; }
|
|
}
|
|
|
|
/* Info panel */
|
|
.info-panel {
|
|
display: flex;
|
|
gap: 24px;
|
|
padding: 8px 24px;
|
|
background: #111128;
|
|
border-top: 1px solid #2a2a4a;
|
|
font-size: 13px;
|
|
justify-content: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.info-item .label { color: #666; }
|
|
.info-item .value { color: #4ecca3; font-weight: 600; margin-left: 4px; }
|
|
|
|
/* Calibration split layout */
|
|
.cal-split {
|
|
display: flex;
|
|
height: calc(100vh - 48px);
|
|
}
|
|
.cal-left {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
.cal-left .viewport-3d {
|
|
width: 100%;
|
|
height: 100%;
|
|
border-top: none;
|
|
}
|
|
.cal-right {
|
|
width: 360px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
border-left: 1px solid #2a2a4a;
|
|
background: #0d0d20;
|
|
overflow-y: auto;
|
|
}
|
|
.cal-controls {
|
|
padding: 16px;
|
|
border-bottom: 1px solid #2a2a4a;
|
|
}
|
|
.cal-debug-images {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 8px;
|
|
padding: 8px;
|
|
}
|
|
.cal-debug-card {
|
|
position: relative;
|
|
}
|
|
.cal-debug-card img {
|
|
width: 100%;
|
|
border-radius: 4px;
|
|
border: 1px solid #333;
|
|
display: block;
|
|
cursor: pointer;
|
|
}
|
|
.cal-debug-card .cal-live {
|
|
opacity: 0.5;
|
|
}
|
|
.cal-debug-card img[src=""],
|
|
.cal-debug-card img:not([src]) {
|
|
display: none;
|
|
}
|
|
.cal-debug-label {
|
|
position: absolute;
|
|
top: 4px;
|
|
left: 4px;
|
|
background: rgba(0,0,0,0.7);
|
|
color: #4ecca3;
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
z-index: 1;
|
|
}
|
|
|
|
/* Fullscreen image overlay */
|
|
.fullscreen-overlay {
|
|
display: none;
|
|
position: fixed;
|
|
top: 0; left: 0; right: 0; bottom: 0;
|
|
z-index: 1000;
|
|
background: rgba(0,0,0,0.95);
|
|
cursor: pointer;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
.fullscreen-overlay.show { display: flex; }
|
|
.fullscreen-overlay img {
|
|
max-width: 95vw;
|
|
max-height: 95vh;
|
|
object-fit: contain;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="header">
|
|
<div class="logo">Pickle Vision</div>
|
|
<div class="tabs">
|
|
<button class="tab active" data-tab="camera">Camera</button>
|
|
<button class="tab" data-tab="calibration">Calibration</button>
|
|
<button class="tab" data-tab="trajectory" id="tabBtnTrajectory" disabled>Trajectory</button>
|
|
</div>
|
|
<div class="status-bar">
|
|
<div>CAM0: <span class="val" id="fps0">--</span> fps</div>
|
|
<div>CAM1: <span class="val" id="fps1">--</span> fps</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="event-banner" id="eventBanner">VAR: Close Call</div>
|
|
<div class="fullscreen-overlay" id="fullscreenOverlay" onclick="this.classList.remove('show')">
|
|
<img id="fullscreenImg">
|
|
</div>
|
|
|
|
<!-- Tab 1: Camera -->
|
|
<div class="tab-content active" id="tab-camera">
|
|
<div class="cameras">
|
|
<div class="cam-box">
|
|
<img id="cam-cam1" alt="Camera 1">
|
|
</div>
|
|
<div class="cam-box">
|
|
<img id="cam-cam0" alt="Camera 0">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: Calibration — split: 3D left, debug right -->
|
|
<div class="tab-content" id="tab-calibration">
|
|
<div class="cal-split">
|
|
<div class="cal-left">
|
|
<div class="viewport-3d" id="calibration-3d"></div>
|
|
</div>
|
|
<div class="cal-right">
|
|
<div class="cal-controls">
|
|
<button class="btn-calibrate" id="btnCalibrate" onclick="doCalibrate()" style="padding:8px 24px;font-size:14px">Calibrate</button>
|
|
<div class="calibrate-status" id="calStatus">
|
|
<span id="calStatusText">Not calibrated</span>
|
|
</div>
|
|
<div id="calError" style="color:#ff4444;font-size:11px;word-break:break-all;display:none;margin-top:4px"></div>
|
|
<div id="calPositions" style="display:none;margin-top:6px;font-size:11px">
|
|
<div id="calPos0" style="color:#4ecca3"></div>
|
|
<div id="calPos1" style="color:#ff88cc"></div>
|
|
</div>
|
|
</div>
|
|
<div class="cal-debug-images">
|
|
<div class="cal-debug-card">
|
|
<div class="cal-debug-label">CAM 0</div>
|
|
<img id="calDebugImg0" alt="CAM 0">
|
|
<img id="cal-cam0" class="cal-live" alt="CAM 0 live">
|
|
</div>
|
|
<div class="cal-debug-card">
|
|
<div class="cal-debug-label">CAM 1</div>
|
|
<img id="calDebugImg1" alt="CAM 1">
|
|
<img id="cal-cam1" class="cal-live" alt="CAM 1 live">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: Trajectory — 3D main, cameras small bottom center -->
|
|
<div class="tab-content" id="tab-trajectory">
|
|
<div class="tab-full">
|
|
<div class="viewport-3d" id="trajectory-3d"></div>
|
|
<div class="bottom-bar">
|
|
<div class="bottom-card"><img id="traj-cam1" alt="Camera 1"></div>
|
|
<div class="bottom-card"><img id="traj-cam0" alt="Camera 0"></div>
|
|
<div class="var-panel-bottom">
|
|
<div class="var-indicator">
|
|
<div class="var-dot" id="varDot"></div>
|
|
<div class="var-label" id="varLabel">VAR</div>
|
|
</div>
|
|
<div class="var-line" id="varLine" style="color:#ccc;font-size:9px">No events</div>
|
|
<div class="var-timer" id="varTimer" style="color:#ff6666;font-size:10px;font-weight:700"></div>
|
|
<div class="var-dist" id="varDist" style="color:#888;font-size:9px"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="info-panel" id="infoPanel" style="display:none">
|
|
<div class="info-item"><span class="label">Speed:</span><span class="value" id="ball-speed">--</span></div>
|
|
<div class="info-item"><span class="label">Height:</span><span class="value" id="ball-height">--</span></div>
|
|
<div class="info-item"><span class="label">Landing:</span><span class="value" id="ball-landing">--</span></div>
|
|
<div class="info-item"><span class="label">Events:</span><span class="value" id="event-count">0</span></div>
|
|
</div>
|
|
|
|
<script>
|
|
// ===================== Tab switching =====================
|
|
var activeTab = '{{ active_tab | default("camera") }}';
|
|
|
|
function switchTab(target) {
|
|
activeTab = target;
|
|
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
|
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
|
|
document.querySelector('.tab[data-tab="' + target + '"]').classList.add('active');
|
|
document.getElementById('tab-' + target).classList.add('active');
|
|
document.getElementById('infoPanel').style.display = (target === 'trajectory') ? 'flex' : 'none';
|
|
if (target === 'calibration' && !courtSceneInitialized) initCourtScene();
|
|
if (target === 'trajectory' && !trajSceneInitialized) initTrajectoryScene();
|
|
history.pushState(null, '', '/' + target);
|
|
}
|
|
|
|
document.querySelectorAll('.tab').forEach(function(tab) {
|
|
tab.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
if (this.disabled) return;
|
|
switchTab(this.dataset.tab);
|
|
});
|
|
});
|
|
|
|
window.addEventListener('popstate', function() {
|
|
var path = location.pathname.replace('/', '') || 'camera';
|
|
switchTab(path);
|
|
});
|
|
|
|
// Init active tab from server
|
|
if (activeTab !== 'camera') switchTab(activeTab);
|
|
|
|
// ===================== Calibration =====================
|
|
function doCalibrate() {
|
|
var btn = document.getElementById('btnCalibrate');
|
|
var errEl = document.getElementById('calError');
|
|
btn.disabled = true;
|
|
btn.textContent = 'Calibrating...';
|
|
errEl.style.display = 'none';
|
|
|
|
fetch('/api/calibration/trigger', { method: 'POST' })
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
btn.disabled = false;
|
|
|
|
// Show debug images from calibration
|
|
if (data.result) {
|
|
for (var sid in data.result) {
|
|
var r = data.result[sid];
|
|
if (r.debug_image) {
|
|
var imgEl = document.getElementById('calDebugImg' + sid);
|
|
if (imgEl) imgEl.src = 'data:image/jpeg;base64,' + r.debug_image;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (data.ok) {
|
|
btn.textContent = 'Re-calibrate';
|
|
errEl.style.display = 'none';
|
|
updateCalibrationStatus();
|
|
|
|
// Show computed camera positions and line stats
|
|
var posEl = document.getElementById('calPositions');
|
|
if (posEl && data.result) {
|
|
posEl.style.display = 'block';
|
|
for (var sid in data.result) {
|
|
var r = data.result[sid];
|
|
var el = document.getElementById('calPos' + sid);
|
|
if (!el) continue;
|
|
if (r.ok && r.camera_position) {
|
|
var p = r.camera_position;
|
|
var lines = r.lines_detected || {};
|
|
el.textContent = 'CAM' + sid + ': X=' + p[0].toFixed(2) + ' Y=' + p[1].toFixed(2) + ' Z=' + p[2].toFixed(2) + 'm'
|
|
+ ' (' + (r.points_matched || 0) + 'pts, ' + (lines.across || 0) + 'a+' + (lines.along || 0) + 'l)';
|
|
} else if (r.lines_detected) {
|
|
var lines = r.lines_detected;
|
|
el.textContent = 'CAM' + sid + ': ' + (lines.across || 0) + ' across + ' + (lines.along || 0) + ' along lines';
|
|
el.style.color = '#ff4444';
|
|
}
|
|
}
|
|
}
|
|
|
|
fetch('/api/calibration/data')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(camData) { addCamerasToScene(camData); });
|
|
|
|
// Highlight detected court lines on 3D scene
|
|
if (data.result) highlightDetectedLines(data.result);
|
|
} else {
|
|
btn.textContent = 'Calibrate';
|
|
// Show errors
|
|
var errors = [];
|
|
if (data.result) {
|
|
for (var sid in data.result) {
|
|
if (!data.result[sid].ok) errors.push(data.result[sid].error);
|
|
}
|
|
}
|
|
var msg = errors.join(' | ') || data.error || 'Unknown error';
|
|
errEl.textContent = msg;
|
|
errEl.style.display = 'block';
|
|
}
|
|
})
|
|
.catch(function(e) {
|
|
btn.disabled = false;
|
|
btn.textContent = 'Calibrate';
|
|
errEl.textContent = String(e);
|
|
errEl.style.display = 'block';
|
|
});
|
|
}
|
|
|
|
// ===================== Fullscreen image viewer =====================
|
|
document.querySelectorAll('.cal-debug-card img').forEach(function(img) {
|
|
img.addEventListener('click', function() {
|
|
if (!this.src || this.src === location.href) return;
|
|
document.getElementById('fullscreenImg').src = this.src;
|
|
document.getElementById('fullscreenOverlay').classList.add('show');
|
|
});
|
|
});
|
|
|
|
var systemReady = false;
|
|
|
|
function updateCalibrationStatus() {
|
|
fetch('/api/calibration/status')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
var el = document.getElementById('calStatusText');
|
|
var ok0 = data['0'], ok1 = data['1'];
|
|
|
|
if (ok0 && ok1) {
|
|
el.textContent = 'Calibrated';
|
|
el.className = 'ok';
|
|
} else if (ok0 || ok1) {
|
|
el.textContent = 'Partially calibrated';
|
|
el.className = 'ok';
|
|
} else {
|
|
el.textContent = 'Not calibrated';
|
|
el.className = '';
|
|
}
|
|
|
|
systemReady = data.system_ready || false;
|
|
var tabTraj = document.getElementById('tabBtnTrajectory');
|
|
if (tabTraj) tabTraj.disabled = !systemReady;
|
|
});
|
|
}
|
|
// Check on load and periodically
|
|
updateCalibrationStatus();
|
|
setInterval(updateCalibrationStatus, 3000);
|
|
|
|
var cameraMeshes = [];
|
|
|
|
function addCamerasToScene(camData) {
|
|
if (!courtSceneInitialized) return;
|
|
|
|
// Remove old camera meshes
|
|
cameraMeshes.forEach(function(m) { courtScene.remove(m); });
|
|
cameraMeshes = [];
|
|
|
|
for (var sid in camData) {
|
|
var cam = camData[sid];
|
|
var pos = cam.position;
|
|
var color = sid === '0' ? 0x44aaff : 0xff44aa;
|
|
|
|
// Camera body
|
|
var geo = new THREE.ConeGeometry(0.3, 0.5, 4);
|
|
var mat = new THREE.MeshBasicMaterial({ color: color, wireframe: true });
|
|
var mesh = new THREE.Mesh(geo, mat);
|
|
mesh.position.set(pos[0], pos[1], pos[2]);
|
|
|
|
var dir = cam.look_direction;
|
|
mesh.lookAt(pos[0] + dir[0], pos[1] + dir[1], pos[2] + dir[2]);
|
|
|
|
courtScene.add(mesh);
|
|
cameraMeshes.push(mesh);
|
|
|
|
// Look direction line
|
|
var lineGeo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(pos[0], pos[1], pos[2]),
|
|
new THREE.Vector3(pos[0] + dir[0] * 3, pos[1] + dir[1] * 3, pos[2] + dir[2] * 3)
|
|
]);
|
|
var line = new THREE.Line(lineGeo, new THREE.LineBasicMaterial({ color: color }));
|
|
courtScene.add(line);
|
|
cameraMeshes.push(line);
|
|
}
|
|
}
|
|
|
|
// ===================== Highlight detected court lines on 3D =====================
|
|
var detectedLineMeshes = [];
|
|
|
|
function highlightDetectedLines(calibResult) {
|
|
if (!courtSceneInitialized) return;
|
|
|
|
// Remove old highlights
|
|
detectedLineMeshes.forEach(function(m) { courtScene.remove(m); });
|
|
detectedLineMeshes = [];
|
|
|
|
var camColors = { '0': 0x44aaff, '1': 0xff44aa };
|
|
|
|
for (var sid in calibResult) {
|
|
var r = calibResult[sid];
|
|
var lines3d = r.matched_lines_3d || [];
|
|
var color = camColors[sid] || 0xffffff;
|
|
|
|
for (var i = 0; i < lines3d.length; i++) {
|
|
var l = lines3d[i];
|
|
var z = 0.08; // slightly above court surface
|
|
var geo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(l.from[0], l.from[1], z),
|
|
new THREE.Vector3(l.to[0], l.to[1], z)
|
|
]);
|
|
var mat = new THREE.LineBasicMaterial({ color: color, linewidth: 3 });
|
|
var mesh = new THREE.Line(geo, mat);
|
|
courtScene.add(mesh);
|
|
detectedLineMeshes.push(mesh);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===================== Camera frame polling =====================
|
|
var camPrefixes = { 'camera': 'cam', 'calibration': 'cal', 'trajectory': 'traj' };
|
|
|
|
function refreshCam(tabName, camId) {
|
|
var prefix = camPrefixes[tabName];
|
|
var img = document.getElementById(prefix + '-cam' + camId);
|
|
if (!img) return;
|
|
|
|
var newImg = new Image();
|
|
newImg.onload = function() {
|
|
img.src = newImg.src;
|
|
setTimeout(function() { refreshCam(tabName, camId); }, 50);
|
|
};
|
|
newImg.onerror = function() {
|
|
setTimeout(function() { refreshCam(tabName, camId); }, 500);
|
|
};
|
|
newImg.src = '/frame/' + camId + '?' + Date.now();
|
|
}
|
|
|
|
['camera', 'calibration', 'trajectory'].forEach(function(tab) {
|
|
refreshCam(tab, 0);
|
|
refreshCam(tab, 1);
|
|
});
|
|
|
|
// ===================== Stats polling =====================
|
|
setInterval(function() {
|
|
fetch('/api/stats').then(function(r) { return r.json(); }).then(function(d) {
|
|
document.getElementById('fps0').textContent = d['0'] ? d['0'].fps.toFixed(1) : '--';
|
|
document.getElementById('fps1').textContent = d['1'] ? d['1'].fps.toFixed(1) : '--';
|
|
});
|
|
}, 2000);
|
|
|
|
// ===================== Three.js: Court Scene (Tab 2) =====================
|
|
var courtSceneInitialized = false;
|
|
var courtScene, courtCamera, courtRenderer, courtBallMesh;
|
|
|
|
function initCourtScene() {
|
|
courtSceneInitialized = true;
|
|
var container = document.getElementById('calibration-3d');
|
|
var w = container.clientWidth;
|
|
var h = container.clientHeight;
|
|
|
|
courtScene = new THREE.Scene();
|
|
courtScene.background = new THREE.Color(0x0a0a1a);
|
|
|
|
courtCamera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
|
|
courtCamera.position.set(6.7, -6, 10);
|
|
courtCamera.lookAt(6.7, 3.05, 0);
|
|
|
|
courtRenderer = new THREE.WebGLRenderer({ antialias: true });
|
|
courtRenderer.setSize(w, h);
|
|
container.appendChild(courtRenderer.domElement);
|
|
|
|
var controls = new THREE.OrbitControls(courtCamera, courtRenderer.domElement);
|
|
controls.target.set(6.7, 3.05, 0);
|
|
controls.update();
|
|
|
|
// Court surface
|
|
var courtGeo = new THREE.PlaneGeometry(13.4, 6.1);
|
|
var courtMat = new THREE.MeshBasicMaterial({ color: 0x1a5c3a, side: THREE.DoubleSide });
|
|
var courtMesh = new THREE.Mesh(courtGeo, courtMat);
|
|
courtMesh.position.set(6.7, 3.05, 0);
|
|
courtScene.add(courtMesh);
|
|
|
|
drawCourtLines(courtScene);
|
|
|
|
// Net (wide plane)
|
|
var netMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.4, side: THREE.DoubleSide });
|
|
var netWide = new THREE.PlaneGeometry(0.914, 6.1);
|
|
var netWideMesh = new THREE.Mesh(netWide, netMat);
|
|
netWideMesh.rotation.y = Math.PI / 2;
|
|
netWideMesh.position.set(6.7, 3.05, 0.457);
|
|
courtScene.add(netWideMesh);
|
|
|
|
// Ball
|
|
var ballGeo = new THREE.SphereGeometry(0.15, 16, 16);
|
|
var ballMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
|
courtBallMesh = new THREE.Mesh(ballGeo, ballMat);
|
|
courtBallMesh.visible = false;
|
|
courtScene.add(courtBallMesh);
|
|
|
|
courtScene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
|
|
|
// Load existing calibration cameras
|
|
fetch('/api/calibration/data')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(camData) { addCamerasToScene(camData); })
|
|
.catch(function() {});
|
|
|
|
function animateCourt() {
|
|
requestAnimationFrame(animateCourt);
|
|
controls.update();
|
|
courtRenderer.render(courtScene, courtCamera);
|
|
}
|
|
animateCourt();
|
|
|
|
window.addEventListener('resize', function() {
|
|
var w2 = container.clientWidth;
|
|
var h2 = container.clientHeight;
|
|
courtCamera.aspect = w2 / h2;
|
|
courtCamera.updateProjectionMatrix();
|
|
courtRenderer.setSize(w2, h2);
|
|
});
|
|
}
|
|
|
|
// ===================== Three.js: Trajectory Scene (Tab 3) =====================
|
|
var trajSceneInitialized = false;
|
|
var trajScene, trajCamera, trajRenderer, trajBallMesh, trajLine;
|
|
|
|
function initTrajectoryScene() {
|
|
trajSceneInitialized = true;
|
|
var container = document.getElementById('trajectory-3d');
|
|
var w = container.clientWidth;
|
|
var h = container.clientHeight;
|
|
|
|
trajScene = new THREE.Scene();
|
|
trajScene.background = new THREE.Color(0x0a0a1a);
|
|
|
|
trajCamera = new THREE.PerspectiveCamera(50, w / h, 0.1, 100);
|
|
trajCamera.position.set(6.7, -6, 10);
|
|
trajCamera.lookAt(6.7, 3.05, 0);
|
|
|
|
trajRenderer = new THREE.WebGLRenderer({ antialias: true });
|
|
trajRenderer.setSize(w, h);
|
|
container.appendChild(trajRenderer.domElement);
|
|
|
|
var controls = new THREE.OrbitControls(trajCamera, trajRenderer.domElement);
|
|
controls.target.set(6.7, 3.05, 0);
|
|
controls.update();
|
|
|
|
// Court surface
|
|
var courtGeo = new THREE.PlaneGeometry(13.4, 6.1);
|
|
var courtMat = new THREE.MeshBasicMaterial({ color: 0x1a5c3a, side: THREE.DoubleSide });
|
|
var courtMesh = new THREE.Mesh(courtGeo, courtMat);
|
|
courtMesh.position.set(6.7, 3.05, 0);
|
|
trajScene.add(courtMesh);
|
|
|
|
drawCourtLines(trajScene);
|
|
|
|
// Net
|
|
var netMat = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
|
|
var netMesh = new THREE.Mesh(new THREE.PlaneGeometry(0.914, 6.1), netMat);
|
|
netMesh.rotation.y = Math.PI / 2;
|
|
netMesh.position.set(6.7, 3.05, 0.457);
|
|
trajScene.add(netMesh);
|
|
|
|
// Ball
|
|
trajBallMesh = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.15, 16, 16),
|
|
new THREE.MeshBasicMaterial({ color: 0xffff00 })
|
|
);
|
|
trajBallMesh.visible = false;
|
|
trajScene.add(trajBallMesh);
|
|
|
|
// Trajectory line
|
|
trajLine = new THREE.Line(
|
|
new THREE.BufferGeometry(),
|
|
new THREE.LineBasicMaterial({ color: 0xff6600, linewidth: 2 })
|
|
);
|
|
trajScene.add(trajLine);
|
|
|
|
// Landing marker
|
|
window.landingMarker = new THREE.Mesh(
|
|
new THREE.RingGeometry(0.1, 0.2, 32),
|
|
new THREE.MeshBasicMaterial({ color: 0xff0000, side: THREE.DoubleSide, transparent: true, opacity: 0.6 })
|
|
);
|
|
window.landingMarker.position.z = 0.01;
|
|
window.landingMarker.visible = false;
|
|
trajScene.add(window.landingMarker);
|
|
|
|
// Shadow
|
|
window.ballShadow = new THREE.Mesh(
|
|
new THREE.CircleGeometry(0.1, 16),
|
|
new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.3 })
|
|
);
|
|
window.ballShadow.position.z = 0.005;
|
|
window.ballShadow.visible = false;
|
|
trajScene.add(window.ballShadow);
|
|
|
|
trajScene.add(new THREE.AmbientLight(0xffffff, 0.8));
|
|
|
|
function animateTraj() {
|
|
requestAnimationFrame(animateTraj);
|
|
controls.update();
|
|
trajRenderer.render(trajScene, trajCamera);
|
|
}
|
|
animateTraj();
|
|
|
|
window.addEventListener('resize', function() {
|
|
var w2 = container.clientWidth;
|
|
var h2 = container.clientHeight;
|
|
trajCamera.aspect = w2 / h2;
|
|
trajCamera.updateProjectionMatrix();
|
|
trajRenderer.setSize(w2, h2);
|
|
});
|
|
}
|
|
|
|
|
|
// ===================== Draw court lines =====================
|
|
function drawCourtLines(scene) {
|
|
var mat = new THREE.LineBasicMaterial({ color: 0xffffff });
|
|
|
|
function addLine(x1, y1, x2, y2) {
|
|
var geo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(x1, y1, 0.05),
|
|
new THREE.Vector3(x2, y2, 0.05)
|
|
]);
|
|
scene.add(new THREE.Line(geo, mat));
|
|
}
|
|
|
|
// Outer boundaries
|
|
addLine(0, 0, 13.4, 0);
|
|
addLine(13.4, 0, 13.4, 6.1);
|
|
addLine(13.4, 6.1, 0, 6.1);
|
|
addLine(0, 6.1, 0, 0);
|
|
|
|
// Net / center line
|
|
addLine(6.7, 0, 6.7, 6.1);
|
|
|
|
// Kitchen lines (NVZ)
|
|
addLine(6.7 - 2.13, 0, 6.7 - 2.13, 6.1);
|
|
addLine(6.7 + 2.13, 0, 6.7 + 2.13, 6.1);
|
|
|
|
// Center service lines
|
|
addLine(0, 3.05, 6.7 - 2.13, 3.05);
|
|
addLine(6.7 + 2.13, 3.05, 13.4, 3.05);
|
|
}
|
|
|
|
// ===================== Trajectory data polling =====================
|
|
setInterval(function() {
|
|
if (!systemReady) return;
|
|
if (activeTab !== 'trajectory' && activeTab !== 'calibration') return;
|
|
|
|
fetch('/api/trajectory').then(function(r) { return r.json(); }).then(function(data) {
|
|
var points = data.points || [];
|
|
var speed = data.speed;
|
|
var landing = data.landing;
|
|
|
|
document.getElementById('ball-speed').textContent =
|
|
speed !== null ? (speed * 3.6).toFixed(1) + ' km/h' : '--';
|
|
|
|
if (points.length > 0) {
|
|
var last = points[points.length - 1];
|
|
document.getElementById('ball-height').textContent =
|
|
last.z !== null ? last.z.toFixed(2) + ' m' : '--';
|
|
}
|
|
|
|
document.getElementById('ball-landing').textContent =
|
|
landing ? '(' + landing.x.toFixed(1) + ', ' + landing.y.toFixed(1) + ') ' + landing.t.toFixed(2) + 's' : '--';
|
|
|
|
if (points.length > 0) {
|
|
var last = points[points.length - 1];
|
|
|
|
if (courtBallMesh) {
|
|
courtBallMesh.visible = true;
|
|
courtBallMesh.position.set(last.x, last.y, last.z || 0.15);
|
|
}
|
|
|
|
if (trajBallMesh) {
|
|
trajBallMesh.visible = true;
|
|
trajBallMesh.position.set(last.x, last.y, last.z || 0.15);
|
|
|
|
if (window.ballShadow) {
|
|
window.ballShadow.visible = true;
|
|
window.ballShadow.position.set(last.x, last.y, 0.005);
|
|
}
|
|
|
|
var linePoints = points.map(function(p) {
|
|
return new THREE.Vector3(p.x, p.y, p.z || 0);
|
|
});
|
|
if (linePoints.length > 1) {
|
|
trajLine.geometry.dispose();
|
|
trajLine.geometry = new THREE.BufferGeometry().setFromPoints(linePoints);
|
|
}
|
|
}
|
|
|
|
if (landing && window.landingMarker) {
|
|
window.landingMarker.visible = true;
|
|
window.landingMarker.position.set(landing.x, landing.y, 0.01);
|
|
} else if (window.landingMarker) {
|
|
window.landingMarker.visible = false;
|
|
}
|
|
}
|
|
}).catch(function() {});
|
|
}, 100);
|
|
|
|
// ===================== VAR panel polling =====================
|
|
var varTimerInterval = null;
|
|
var lastVarTimestamp = null;
|
|
|
|
function updateVarTimer() {
|
|
if (!lastVarTimestamp) return;
|
|
var ago = (Date.now() / 1000) - lastVarTimestamp;
|
|
var el = document.getElementById('varTimer');
|
|
if (ago < 60) {
|
|
el.textContent = ago.toFixed(0) + 's ago';
|
|
} else if (ago < 3600) {
|
|
el.textContent = Math.floor(ago / 60) + 'm ' + Math.floor(ago % 60) + 's ago';
|
|
} else {
|
|
el.textContent = Math.floor(ago / 3600) + 'h ago';
|
|
}
|
|
|
|
// Red dot stays active for 30 seconds
|
|
var dot = document.getElementById('varDot');
|
|
var label = document.getElementById('varLabel');
|
|
if (ago < 30) {
|
|
dot.classList.add('active');
|
|
label.classList.add('active');
|
|
label.textContent = 'VAR ACTIVE';
|
|
} else {
|
|
dot.classList.remove('active');
|
|
label.classList.remove('active');
|
|
label.textContent = 'VAR';
|
|
}
|
|
}
|
|
|
|
setInterval(function() {
|
|
if (!systemReady) return;
|
|
fetch('/api/var/last')
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (!data) return;
|
|
|
|
var event = data.event;
|
|
lastVarTimestamp = event.timestamp;
|
|
|
|
document.getElementById('varLine').textContent =
|
|
'Line: ' + event.line;
|
|
document.getElementById('varDist').textContent =
|
|
'Distance: ' + (event.distance_m * 100).toFixed(0) + ' cm';
|
|
|
|
updateVarTimer();
|
|
|
|
// Show snapshot
|
|
if (data.snapshot_b64) {
|
|
var img = document.getElementById('varSnapshot');
|
|
if (img) {
|
|
img.src = 'data:image/jpeg;base64,' + data.snapshot_b64;
|
|
img.style.display = 'block';
|
|
}
|
|
var empty = document.getElementById('varSnapshotEmpty');
|
|
if (empty) empty.style.display = 'none';
|
|
}
|
|
})
|
|
.catch(function() {});
|
|
}, 1000);
|
|
|
|
// Update timer every second
|
|
setInterval(updateVarTimer, 1000);
|
|
|
|
// ===================== Event banner =====================
|
|
var lastEventCount = 0;
|
|
setInterval(function() {
|
|
if (!systemReady) return;
|
|
fetch('/api/events').then(function(r) { return r.json(); }).then(function(events) {
|
|
document.getElementById('event-count').textContent = events.length;
|
|
|
|
if (events.length > lastEventCount) {
|
|
var banner = document.getElementById('eventBanner');
|
|
var latest = events[events.length - 1];
|
|
banner.textContent = 'VAR: Close Call - ' + latest.line +
|
|
' (' + (latest.distance_m * 100).toFixed(0) + 'cm)';
|
|
banner.classList.add('show');
|
|
setTimeout(function() { banner.classList.remove('show'); }, 5000);
|
|
lastEventCount = events.length;
|
|
}
|
|
}).catch(function() {});
|
|
}, 1000);
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|