From 1294747af2add83bef2eb31cbd45fa4a35e88f9e Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 22 Mar 2026 14:05:47 +0700 Subject: [PATCH] Enforce calibration-first workflow: block detection and VAR until calibrated - Remove fallback corner estimation (_estimate_corners_from_net) - Gate YOLO detection behind calibration check in detection_loop - Add calibration overlay UI with prominent Calibrate button - Disable Court/Trajectory tabs until system_ready - Skip trajectory/VAR/event polling when uncalibrated - Add system_ready flag to calibration status API Co-Authored-By: Claude Opus 4.6 --- jetson/main.py | 82 ++++++++------------ src/web/app.py | 4 +- src/web/templates/index.html | 145 ++++++++++++++++++++++++++++++----- 3 files changed, 163 insertions(+), 68 deletions(-) diff --git a/jetson/main.py b/jetson/main.py index 9ebd58d..d7ccd75 100644 --- a/jetson/main.py +++ b/jetson/main.py @@ -43,7 +43,7 @@ def auto_calibrate(): For now: use a simple approach — detect the 4 most prominent lines (baseline, two sidelines, kitchen line) and map to known 3D coords. - Falls back to estimated corners based on frame geometry if detection fails. + Returns error if court lines cannot be detected (no fallbacks). """ results = {} @@ -60,8 +60,12 @@ def auto_calibrate(): corners_pixel = _detect_court_corners(frame, side) if corners_pixel is None: - # Fallback: estimate corners from typical camera-at-net perspective - corners_pixel = _estimate_corners_from_net(w, h, side) + results[str(sensor_id)] = { + 'ok': False, + 'error': f'Could not detect court lines for CAM {sensor_id}. ' + 'Ensure court lines are clearly visible.' + } + continue # Get known 3D coordinates for this half corners_3d = get_half_court_3d_points(side) @@ -169,29 +173,6 @@ def _detect_court_corners(frame, side): return np.array(corners, dtype=np.float32) -def _estimate_corners_from_net(w, h, side): - """Estimate court corner positions for a camera mounted at the net. - - Camera looks down the half-court. Perspective: far baseline is smaller - (top of frame), near net line is wider (bottom of frame). - """ - # Near edge (bottom of frame, close to camera = at the net) - near_left = [w * 0.05, h * 0.85] - near_right = [w * 0.95, h * 0.85] - - # Far edge (top of frame, baseline = far from camera) - far_left = [w * 0.20, h * 0.15] - far_right = [w * 0.80, h * 0.15] - - # For camera at net looking at court half: - # - bottom of frame = net line (near) - # - top of frame = baseline (far) - # Order must match get_half_court_3d_points output - if side == 'left': - # 3D order: baseline near-left, baseline near-right, net far-right, net far-left - return np.array([far_left, far_right, near_right, near_left], dtype=np.float32) - else: - return np.array([far_left, far_right, near_right, near_left], dtype=np.float32) def _capture_var_snapshot(frame, event): @@ -233,36 +214,39 @@ def detection_loop(cam_readers, model, conf_threshold, ring_buffer): # Store raw frame in ring buffer for VAR ring_buffer.push(frame, now, sensor_id) - # YOLO detection - results = model(frame, verbose=False, classes=[BALL_CLASS_ID], conf=conf_threshold) + # Only run detection if camera is calibrated + cal = calibrators.get(sensor_id) + is_calibrated = cal and cal.calibrated det_count = 0 best_detection = None best_conf = 0 - for r in results: - for box in r.boxes: - x1, y1, x2, y2 = map(int, box.xyxy[0]) - conf = float(box.conf[0]) - cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 3) - label = f"Ball {conf:.0%}" - (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2) - cv2.rectangle(frame, (x1, y1 - th - 10), (x1 + tw, y1), (0, 255, 0), -1) - cv2.putText(frame, label, (x1, y1 - 5), - cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2) - det_count += 1 + if is_calibrated: + # YOLO detection — only after calibration + results = model(frame, verbose=False, classes=[BALL_CLASS_ID], conf=conf_threshold) - if conf > best_conf: - best_conf = conf - cx = (x1 + x2) / 2 - cy = (y1 + y2) / 2 - diameter = max(x2 - x1, y2 - y1) - best_detection = (cx, cy, diameter, conf) + for r in results: + for box in r.boxes: + x1, y1, x2, y2 = map(int, box.xyxy[0]) + conf = float(box.conf[0]) + cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 3) + label = f"Ball {conf:.0%}" + (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.7, 2) + cv2.rectangle(frame, (x1, y1 - th - 10), (x1 + tw, y1), (0, 255, 0), -1) + cv2.putText(frame, label, (x1, y1 - 5), + cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 0), 2) + det_count += 1 - # Update trajectory if we have calibration and a detection - if best_detection and sensor_id in calibrators: - cal = calibrators[sensor_id] - if cal.calibrated: + if conf > best_conf: + best_conf = conf + cx = (x1 + x2) / 2 + cy = (y1 + y2) / 2 + diameter = max(x2 - x1, y2 - y1) + best_detection = (cx, cy, diameter, conf) + + # Update trajectory and check VAR + if best_detection: px, py, diam, conf = best_detection pos_3d = cal.pixel_to_3d(px, py, diam) if pos_3d: diff --git a/src/web/app.py b/src/web/app.py index 66df2f3..b99632f 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -100,7 +100,9 @@ def api_calibration_status(): cals = {} for sid, cal in state.get('calibrators', {}).items(): cals[str(sid)] = cal.calibrated - return jsonify(cals) + # System is ready when at least one camera is calibrated + any_calibrated = any(cals.values()) if cals else False + return jsonify({**cals, 'system_ready': any_calibrated}) @app.route('/api/calibration/trigger', methods=['POST']) diff --git a/src/web/templates/index.html b/src/web/templates/index.html index ab11e5d..92b3e15 100644 --- a/src/web/templates/index.html +++ b/src/web/templates/index.html @@ -42,7 +42,8 @@ font-size: 14px; transition: all 0.2s; } - .tab:hover { color: #ccc; border-color: #555; } + .tab:hover:not(:disabled) { color: #ccc; border-color: #555; } + .tab:disabled { opacity: 0.3; cursor: not-allowed; } .tab.active { background: #4ecca3; color: #000; @@ -291,6 +292,60 @@ } .info-item .label { color: #666; } .info-item .value { color: #4ecca3; font-weight: 600; margin-left: 4px; } + + /* Calibration overlay */ + .calibration-overlay { + position: fixed; + top: 48px; + left: 0; + right: 0; + bottom: 0; + z-index: 5; + background: rgba(10, 10, 26, 0.85); + display: flex; + align-items: center; + justify-content: center; + } + .calibration-overlay.hidden { display: none; } + .cal-overlay-content { + text-align: center; + max-width: 500px; + padding: 40px; + } + .cal-overlay-title { + font-size: 28px; + font-weight: 700; + color: #4ecca3; + margin-bottom: 16px; + } + .cal-overlay-desc { + font-size: 15px; + color: #999; + line-height: 1.6; + margin-bottom: 20px; + } + .cal-overlay-params { + display: flex; + gap: 20px; + justify-content: center; + font-size: 13px; + color: #666; + margin-bottom: 24px; + } + .cal-overlay-params b { color: #4ecca3; } + .btn-calibrate-big { + padding: 14px 48px; + background: #4ecca3; + color: #000; + border: none; + border-radius: 8px; + font-size: 18px; + font-weight: 700; + cursor: pointer; + transition: background 0.2s; + } + .btn-calibrate-big:hover { background: #3dbb92; } + .btn-calibrate-big:disabled { background: #333; color: #666; cursor: not-allowed; } @@ -299,8 +354,8 @@
- - + +
CAM0: -- fps
@@ -312,6 +367,25 @@
+ +
+
+
Calibration Required
+
+ Position cameras so that court lines are clearly visible, then press Calibrate. + The system will auto-detect court geometry and determine camera positions. +
+
+ Sensor: IMX219 + HFOV: 128° + Stereo gap: ~6 cm +
+ +
+ Waiting for calibration... +
+
+
Camera 1 @@ -393,6 +467,7 @@ function switchTab(target) { document.querySelectorAll('.tab').forEach(function(tab) { tab.addEventListener('click', function(e) { e.preventDefault(); + if (this.disabled) return; switchTab(this.dataset.tab); }); }); @@ -408,15 +483,15 @@ if (activeTab !== 'detection') switchTab(activeTab); // ===================== Calibration ===================== function doCalibrate() { var btn = document.getElementById('btnCalibrate'); - btn.disabled = true; - btn.textContent = 'Calibrating...'; + var btnBig = document.getElementById('btnCalibrateBig'); + [btn, btnBig].forEach(function(b) { if (b) { b.disabled = true; b.textContent = 'Calibrating...'; } }); fetch('/api/calibration/trigger', { method: 'POST' }) .then(function(r) { return r.json(); }) .then(function(data) { - btn.disabled = false; + [btn, btnBig].forEach(function(b) { if (b) b.disabled = false; }); if (data.ok) { - btn.textContent = 'Re-calibrate'; + [btn, btnBig].forEach(function(b) { if (b) b.textContent = 'Re-calibrate'; }); updateCalibrationStatus(); // Fetch camera positions and add to 3D scene @@ -424,37 +499,68 @@ function doCalibrate() { .then(function(r) { return r.json(); }) .then(function(camData) { addCamerasToScene(camData); }); } else { - btn.textContent = 'Calibrate Court'; - alert('Calibration failed: ' + (data.error || 'Unknown error')); + [btn, btnBig].forEach(function(b) { if (b) b.textContent = 'Calibrate'; }); + // Show per-camera errors + var errors = []; + if (data.result) { + for (var sid in data.result) { + if (!data.result[sid].ok) errors.push('CAM ' + sid + ': ' + data.result[sid].error); + } + } + alert('Calibration failed:\n' + (errors.join('\n') || data.error || 'Unknown error')); } }) .catch(function(e) { - btn.disabled = false; - btn.textContent = 'Calibrate Court'; + [btn, btnBig].forEach(function(b) { if (b) { b.disabled = false; b.textContent = 'Calibrate'; } }); alert('Error: ' + e); }); } +var systemReady = false; + function updateCalibrationStatus() { fetch('/api/calibration/status') .then(function(r) { return r.json(); }) .then(function(data) { var el = document.getElementById('calStatusText'); + var elOverlay = document.getElementById('calStatusTextOverlay'); var ok0 = data['0'], ok1 = data['1']; + var statusText, statusClass; + if (ok0 && ok1) { - el.textContent = 'Calibrated'; - el.className = 'ok'; + statusText = 'Calibrated'; + statusClass = 'ok'; } else if (ok0 || ok1) { - el.textContent = 'Partially calibrated'; - el.className = 'ok'; + statusText = 'Partially calibrated'; + statusClass = 'ok'; } else { - el.textContent = 'Not calibrated'; - el.className = ''; + statusText = 'Not calibrated'; + statusClass = ''; + } + + if (el) { el.textContent = statusText; el.className = statusClass; } + if (elOverlay) { elOverlay.textContent = statusText; elOverlay.className = statusClass; } + + // Enable/disable system based on calibration + systemReady = data.system_ready || false; + var overlay = document.getElementById('calibrationOverlay'); + var tabCourt = document.getElementById('tabBtnCourt'); + var tabTraj = document.getElementById('tabBtnTrajectory'); + + if (systemReady) { + if (overlay) overlay.classList.add('hidden'); + if (tabCourt) tabCourt.disabled = false; + if (tabTraj) tabTraj.disabled = false; + } else { + if (overlay) overlay.classList.remove('hidden'); + if (tabCourt) tabCourt.disabled = true; + if (tabTraj) tabTraj.disabled = true; } }); } -// Check on load +// Check on load and periodically updateCalibrationStatus(); +setInterval(updateCalibrationStatus, 3000); function addCamerasToScene(camData) { if (!courtSceneInitialized) return; @@ -828,6 +934,7 @@ function drawCourtLines(scene) { // ===================== Trajectory data polling ===================== setInterval(function() { + if (!systemReady) return; if (activeTab !== 'trajectory' && activeTab !== 'court') return; fetch('/api/trajectory').then(function(r) { return r.json(); }).then(function(data) { @@ -914,6 +1021,7 @@ function updateVarTimer() { } setInterval(function() { + if (!systemReady) return; fetch('/api/var/last') .then(function(r) { return r.json(); }) .then(function(data) { @@ -949,6 +1057,7 @@ 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;