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
|
||||
(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:
|
||||
|
||||
Reference in New Issue
Block a user