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 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-03-22 14:05:47 +07:00
parent 43f11a7e40
commit 1294747af2
3 changed files with 163 additions and 68 deletions

View File

@@ -43,7 +43,7 @@ def auto_calibrate():
For now: use a simple approach — detect the 4 most prominent lines For now: use a simple approach — detect the 4 most prominent lines
(baseline, two sidelines, kitchen line) and map to known 3D coords. (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 = {} results = {}
@@ -60,8 +60,12 @@ def auto_calibrate():
corners_pixel = _detect_court_corners(frame, side) corners_pixel = _detect_court_corners(frame, side)
if corners_pixel is None: if corners_pixel is None:
# Fallback: estimate corners from typical camera-at-net perspective results[str(sensor_id)] = {
corners_pixel = _estimate_corners_from_net(w, h, side) '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 # Get known 3D coordinates for this half
corners_3d = get_half_court_3d_points(side) 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) 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): 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 # Store raw frame in ring buffer for VAR
ring_buffer.push(frame, now, sensor_id) ring_buffer.push(frame, now, sensor_id)
# YOLO detection # Only run detection if camera is calibrated
results = model(frame, verbose=False, classes=[BALL_CLASS_ID], conf=conf_threshold) cal = calibrators.get(sensor_id)
is_calibrated = cal and cal.calibrated
det_count = 0 det_count = 0
best_detection = None best_detection = None
best_conf = 0 best_conf = 0
for r in results: if is_calibrated:
for box in r.boxes: # YOLO detection — only after calibration
x1, y1, x2, y2 = map(int, box.xyxy[0]) results = model(frame, verbose=False, classes=[BALL_CLASS_ID], conf=conf_threshold)
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 conf > best_conf: for r in results:
best_conf = conf for box in r.boxes:
cx = (x1 + x2) / 2 x1, y1, x2, y2 = map(int, box.xyxy[0])
cy = (y1 + y2) / 2 conf = float(box.conf[0])
diameter = max(x2 - x1, y2 - y1) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 3)
best_detection = (cx, cy, diameter, conf) 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 conf > best_conf:
if best_detection and sensor_id in calibrators: best_conf = conf
cal = calibrators[sensor_id] cx = (x1 + x2) / 2
if cal.calibrated: 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 px, py, diam, conf = best_detection
pos_3d = cal.pixel_to_3d(px, py, diam) pos_3d = cal.pixel_to_3d(px, py, diam)
if pos_3d: if pos_3d:

View File

@@ -100,7 +100,9 @@ def api_calibration_status():
cals = {} cals = {}
for sid, cal in state.get('calibrators', {}).items(): for sid, cal in state.get('calibrators', {}).items():
cals[str(sid)] = cal.calibrated 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']) @app.route('/api/calibration/trigger', methods=['POST'])

View File

@@ -42,7 +42,8 @@
font-size: 14px; font-size: 14px;
transition: all 0.2s; 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 { .tab.active {
background: #4ecca3; background: #4ecca3;
color: #000; color: #000;
@@ -291,6 +292,60 @@
} }
.info-item .label { color: #666; } .info-item .label { color: #666; }
.info-item .value { color: #4ecca3; font-weight: 600; margin-left: 4px; } .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; }
</style> </style>
</head> </head>
<body> <body>
@@ -299,8 +354,8 @@
<div class="logo">Pickle Vision</div> <div class="logo">Pickle Vision</div>
<div class="tabs"> <div class="tabs">
<button class="tab active" data-tab="detection">Detection</button> <button class="tab active" data-tab="detection">Detection</button>
<button class="tab" data-tab="court">Court</button> <button class="tab" data-tab="court" id="tabBtnCourt" disabled>Court</button>
<button class="tab" data-tab="trajectory">Trajectory</button> <button class="tab" data-tab="trajectory" id="tabBtnTrajectory" disabled>Trajectory</button>
</div> </div>
<div class="status-bar"> <div class="status-bar">
<div>CAM0: <span class="val" id="fps0">--</span> fps</div> <div>CAM0: <span class="val" id="fps0">--</span> fps</div>
@@ -312,6 +367,25 @@
<!-- Tab 1: Detection --> <!-- Tab 1: Detection -->
<div class="tab-content active" id="tab-detection"> <div class="tab-content active" id="tab-detection">
<!-- Calibration overlay — shown until system is calibrated -->
<div class="calibration-overlay" id="calibrationOverlay">
<div class="cal-overlay-content">
<div class="cal-overlay-title">Calibration Required</div>
<div class="cal-overlay-desc">
Position cameras so that court lines are clearly visible, then press Calibrate.
The system will auto-detect court geometry and determine camera positions.
</div>
<div class="cal-overlay-params">
<span>Sensor: <b>IMX219</b></span>
<span>HFOV: <b>128°</b></span>
<span>Stereo gap: <b>~6 cm</b></span>
</div>
<button class="btn-calibrate-big" id="btnCalibrateBig" onclick="doCalibrate()">Calibrate</button>
<div class="calibrate-status" id="calStatusOverlay" style="margin-top:8px">
<span id="calStatusTextOverlay">Waiting for calibration...</span>
</div>
</div>
</div>
<div class="cameras"> <div class="cameras">
<div class="cam-box"> <div class="cam-box">
<img id="det-cam1" alt="Camera 1"> <img id="det-cam1" alt="Camera 1">
@@ -393,6 +467,7 @@ function switchTab(target) {
document.querySelectorAll('.tab').forEach(function(tab) { document.querySelectorAll('.tab').forEach(function(tab) {
tab.addEventListener('click', function(e) { tab.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
if (this.disabled) return;
switchTab(this.dataset.tab); switchTab(this.dataset.tab);
}); });
}); });
@@ -408,15 +483,15 @@ if (activeTab !== 'detection') switchTab(activeTab);
// ===================== Calibration ===================== // ===================== Calibration =====================
function doCalibrate() { function doCalibrate() {
var btn = document.getElementById('btnCalibrate'); var btn = document.getElementById('btnCalibrate');
btn.disabled = true; var btnBig = document.getElementById('btnCalibrateBig');
btn.textContent = 'Calibrating...'; [btn, btnBig].forEach(function(b) { if (b) { b.disabled = true; b.textContent = 'Calibrating...'; } });
fetch('/api/calibration/trigger', { method: 'POST' }) fetch('/api/calibration/trigger', { method: 'POST' })
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
btn.disabled = false; [btn, btnBig].forEach(function(b) { if (b) b.disabled = false; });
if (data.ok) { if (data.ok) {
btn.textContent = 'Re-calibrate'; [btn, btnBig].forEach(function(b) { if (b) b.textContent = 'Re-calibrate'; });
updateCalibrationStatus(); updateCalibrationStatus();
// Fetch camera positions and add to 3D scene // Fetch camera positions and add to 3D scene
@@ -424,37 +499,68 @@ function doCalibrate() {
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(camData) { addCamerasToScene(camData); }); .then(function(camData) { addCamerasToScene(camData); });
} else { } else {
btn.textContent = 'Calibrate Court'; [btn, btnBig].forEach(function(b) { if (b) b.textContent = 'Calibrate'; });
alert('Calibration failed: ' + (data.error || 'Unknown error')); // 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) { .catch(function(e) {
btn.disabled = false; [btn, btnBig].forEach(function(b) { if (b) { b.disabled = false; b.textContent = 'Calibrate'; } });
btn.textContent = 'Calibrate Court';
alert('Error: ' + e); alert('Error: ' + e);
}); });
} }
var systemReady = false;
function updateCalibrationStatus() { function updateCalibrationStatus() {
fetch('/api/calibration/status') fetch('/api/calibration/status')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
var el = document.getElementById('calStatusText'); var el = document.getElementById('calStatusText');
var elOverlay = document.getElementById('calStatusTextOverlay');
var ok0 = data['0'], ok1 = data['1']; var ok0 = data['0'], ok1 = data['1'];
var statusText, statusClass;
if (ok0 && ok1) { if (ok0 && ok1) {
el.textContent = 'Calibrated'; statusText = 'Calibrated';
el.className = 'ok'; statusClass = 'ok';
} else if (ok0 || ok1) { } else if (ok0 || ok1) {
el.textContent = 'Partially calibrated'; statusText = 'Partially calibrated';
el.className = 'ok'; statusClass = 'ok';
} else { } else {
el.textContent = 'Not calibrated'; statusText = 'Not calibrated';
el.className = ''; 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(); updateCalibrationStatus();
setInterval(updateCalibrationStatus, 3000);
function addCamerasToScene(camData) { function addCamerasToScene(camData) {
if (!courtSceneInitialized) return; if (!courtSceneInitialized) return;
@@ -828,6 +934,7 @@ function drawCourtLines(scene) {
// ===================== Trajectory data polling ===================== // ===================== Trajectory data polling =====================
setInterval(function() { setInterval(function() {
if (!systemReady) return;
if (activeTab !== 'trajectory' && activeTab !== 'court') return; if (activeTab !== 'trajectory' && activeTab !== 'court') return;
fetch('/api/trajectory').then(function(r) { return r.json(); }).then(function(data) { fetch('/api/trajectory').then(function(r) { return r.json(); }).then(function(data) {
@@ -914,6 +1021,7 @@ function updateVarTimer() {
} }
setInterval(function() { setInterval(function() {
if (!systemReady) return;
fetch('/api/var/last') fetch('/api/var/last')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@@ -949,6 +1057,7 @@ setInterval(updateVarTimer, 1000);
// ===================== Event banner ===================== // ===================== Event banner =====================
var lastEventCount = 0; var lastEventCount = 0;
setInterval(function() { setInterval(function() {
if (!systemReady) return;
fetch('/api/events').then(function(r) { return r.json(); }).then(function(events) { fetch('/api/events').then(function(r) { return r.json(); }).then(function(events) {
document.getElementById('event-count').textContent = events.length; document.getElementById('event-count').textContent = events.length;