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