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:
@@ -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:
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user