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

@@ -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;