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,36 +214,39 @@ 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
for r in results:
for box in r.boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0])
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 is_calibrated:
# YOLO detection — only after calibration
results = model(frame, verbose=False, classes=[BALL_CLASS_ID], conf=conf_threshold)
if conf > best_conf:
best_conf = conf
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
diameter = max(x2 - x1, y2 - y1)
best_detection = (cx, cy, diameter, conf)
for r in results:
for box in r.boxes:
x1, y1, x2, y2 = map(int, box.xyxy[0])
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
# Update trajectory if we have calibration and a detection
if best_detection and sensor_id in calibrators:
cal = calibrators[sensor_id]
if cal.calibrated:
if conf > best_conf:
best_conf = conf
cx = (x1 + x2) / 2
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
pos_3d = cal.pixel_to_3d(px, py, diam)
if pos_3d: