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
(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,13 +214,18 @@ 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
if is_calibrated:
# YOLO detection — only after calibration
results = model(frame, verbose=False, classes=[BALL_CLASS_ID], conf=conf_threshold)
for r in results:
for box in r.boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0])
@@ -259,10 +245,8 @@ def detection_loop(cam_readers, model, conf_threshold, ring_buffer):
diameter = max(x2 - x1, y2 - y1)
best_detection = (cx, cy, diameter, conf)
# Update trajectory if we have calibration and a detection
if best_detection and sensor_id in calibrators:
cal = calibrators[sensor_id]
if cal.calibrated:
# 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:

View File

@@ -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'])

View File

@@ -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; }
</style>
</head>
<body>
@@ -299,8 +354,8 @@
<div class="logo">Pickle Vision</div>
<div class="tabs">
<button class="tab active" data-tab="detection">Detection</button>
<button class="tab" data-tab="court">Court</button>
<button class="tab" data-tab="trajectory">Trajectory</button>
<button class="tab" data-tab="court" id="tabBtnCourt" disabled>Court</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>
@@ -312,6 +367,25 @@
<!-- Tab 1: 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="cam-box">
<img id="det-cam1" alt="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;