Remove all fallbacks: show errors, draw debug lines on calibration

- Remove try-except in CameraCalibrator — errors propagate to UI
- Remove auto-load of saved calibrations — always start uncalibrated
- Remove hardcoded "Base Setup" card with fake values
- Remove addStereocameras/getCamParams dead code
- Draw all detected Hough lines + corners on debug frame during calibration
- Show debug images in calibration tab after attempt
- Show error messages in UI instead of swallowing them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-03-22 14:27:53 +07:00
parent ee73aa80d8
commit e12edab19b
4 changed files with 178 additions and 229 deletions

View File

@@ -39,11 +39,9 @@ def auto_calibrate():
compute camera pose, save to config. compute camera pose, save to config.
Each camera sees one half of the court from the net position. Each camera sees one half of the court from the net position.
We use the court lines visible in each frame to build correspondences. Detects court lines via Hough transform, finds 4 corners,
then uses solvePnP to determine camera position.
For now: use a simple approach — detect the 4 most prominent lines Returns debug images with detected lines drawn on them.
(baseline, two sidelines, kitchen line) and map to known 3D coords.
Returns error if court lines cannot be detected (no fallbacks).
""" """
results = {} results = {}
@@ -55,30 +53,75 @@ def auto_calibrate():
h, w = frame.shape[:2] h, w = frame.shape[:2]
side = 'left' if sensor_id == 0 else 'right' side = 'left' if sensor_id == 0 else 'right'
debug_frame = frame.copy()
# Try to detect court lines using edge detection + Hough # Detect court lines — returns corners + debug info
corners_pixel = _detect_court_corners(frame, side) detection = _detect_court_corners(frame, side)
# Draw all detected Hough lines on debug frame
if detection and detection.get('all_lines') is not None:
for line in detection['all_lines']:
x1, y1, x2, y2 = line[0]
cv2.line(debug_frame, (x1, y1), (x2, y2), (50, 50, 50), 1)
# Draw classified lines
if detection and detection.get('horizontals'):
for line in detection['horizontals']:
x1, y1, x2, y2 = line
cv2.line(debug_frame, (x1, y1), (x2, y2), (0, 255, 255), 2) # yellow = horizontal
if detection and detection.get('verticals'):
for line in detection['verticals']:
x1, y1, x2, y2 = line
cv2.line(debug_frame, (x1, y1), (x2, y2), (255, 0, 255), 2) # magenta = vertical
# Draw selected 4 lines (top/bottom/left/right)
if detection and detection.get('selected_lines'):
sel = detection['selected_lines']
colors = {'top': (0, 255, 0), 'bottom': (0, 200, 0),
'left': (255, 128, 0), 'right': (200, 100, 0)}
for name, line in sel.items():
x1, y1, x2, y2 = line
cv2.line(debug_frame, (x1, y1), (x2, y2), colors.get(name, (255, 255, 255)), 3)
cv2.putText(debug_frame, name, (x1, y1 - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors.get(name, (255, 255, 255)), 1)
# Encode debug frame
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
debug_b64 = base64.b64encode(jpeg.tobytes()).decode('ascii')
corners_pixel = detection.get('corners') if detection else None
if corners_pixel is None: if corners_pixel is None:
error_detail = detection.get('error', 'Unknown') if detection else 'No lines detected at all'
results[str(sensor_id)] = { results[str(sensor_id)] = {
'ok': False, 'ok': False,
'error': f'Could not detect court lines for CAM {sensor_id}. ' 'error': f'CAM {sensor_id}: {error_detail}',
'Ensure court lines are clearly visible.' 'debug_image': debug_b64,
} }
continue continue
# Draw corners on debug frame
for i, corner in enumerate(corners_pixel):
pt = (int(corner[0]), int(corner[1]))
cv2.circle(debug_frame, pt, 8, (0, 0, 255), -1)
cv2.putText(debug_frame, f'C{i}', (pt[0] + 10, pt[1]),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
# Re-encode with corners
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
debug_b64 = base64.b64encode(jpeg.tobytes()).decode('ascii')
# 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)
# Calibrate # Calibrate — no try/except, let errors propagate
cal = CameraCalibrator() cal = CameraCalibrator()
ok = cal.calibrate( cal.calibrate(
np.array(corners_pixel, dtype=np.float32), np.array(corners_pixel, dtype=np.float32),
corners_3d, corners_3d,
w, h w, h
) )
if ok:
# Save to config # Save to config
cal_path = os.path.join(_args.calibration_dir, cal_path = os.path.join(_args.calibration_dir,
f'cam{sensor_id}_calibration.json') f'cam{sensor_id}_calibration.json')
@@ -93,13 +136,10 @@ def auto_calibrate():
results[str(sensor_id)] = { results[str(sensor_id)] = {
'ok': True, 'ok': True,
'camera_position': cam_pos.tolist(), 'camera_position': cam_pos.tolist(),
'corners_pixel': corners_pixel.tolist() if isinstance(corners_pixel, np.ndarray) 'debug_image': debug_b64,
else corners_pixel,
} }
print(f"[CAM {sensor_id}] Calibrated! Camera at " print(f"[CAM {sensor_id}] Calibrated! Camera at "
f"({cam_pos[0]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})") f"({cam_pos[0]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})")
else:
results[str(sensor_id)] = {'ok': False, 'error': 'Calibration failed'}
return results return results
@@ -107,7 +147,13 @@ def auto_calibrate():
def _detect_court_corners(frame, side): def _detect_court_corners(frame, side):
"""Detect court corners from frame using edge detection. """Detect court corners from frame using edge detection.
Returns 4 corner points as numpy array, or None if detection fails. Returns dict with:
corners: 4x2 numpy array or None
all_lines: raw Hough lines
horizontals: classified horizontal lines
verticals: classified vertical lines
selected_lines: the 4 lines used (top/bottom/left/right)
error: description if detection failed
""" """
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0) blur = cv2.GaussianBlur(gray, (5, 5), 0)
@@ -118,7 +164,13 @@ def _detect_court_corners(frame, side):
minLineLength=100, maxLineGap=20) minLineLength=100, maxLineGap=20)
if lines is None or len(lines) < 4: if lines is None or len(lines) < 4:
return None n = 0 if lines is None else len(lines)
return {
'corners': None, 'all_lines': lines,
'horizontals': [], 'verticals': [],
'selected_lines': {},
'error': f'Only {n} Hough lines found (need >= 4)',
}
# Classify lines into horizontal and vertical # Classify lines into horizontal and vertical
horizontals = [] horizontals = []
@@ -134,20 +186,28 @@ def _detect_court_corners(frame, side):
verticals.append(line[0]) verticals.append(line[0])
if len(horizontals) < 2 or len(verticals) < 2: if len(horizontals) < 2 or len(verticals) < 2:
return None return {
'corners': None, 'all_lines': lines,
'horizontals': [h.tolist() for h in horizontals],
'verticals': [v.tolist() for v in verticals],
'selected_lines': {},
'error': f'{len(horizontals)} horizontal, {len(verticals)} vertical lines (need >= 2 each)',
}
# Cluster lines by position to find the dominant ones # Cluster lines by position to find the dominant ones
h_positions = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 2) h_positions = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 2)
v_positions = sorted(verticals, key=lambda l: (l[0] + l[2]) / 2) v_positions = sorted(verticals, key=lambda l: (l[0] + l[2]) / 2)
# Take the most separated horizontal pair (top and bottom court lines)
top_line = h_positions[0] top_line = h_positions[0]
bottom_line = h_positions[-1] bottom_line = h_positions[-1]
# Take the most separated vertical pair (left and right sidelines)
left_line = v_positions[0] left_line = v_positions[0]
right_line = v_positions[-1] right_line = v_positions[-1]
selected = {
'top': top_line.tolist(), 'bottom': bottom_line.tolist(),
'left': left_line.tolist(), 'right': right_line.tolist(),
}
# Find intersections as corner points # Find intersections as corner points
def line_intersection(l1, l2): def line_intersection(l1, l2):
x1, y1, x2, y2 = l1 x1, y1, x2, y2 = l1
@@ -168,9 +228,22 @@ def _detect_court_corners(frame, side):
] ]
if any(c is None for c in corners): if any(c is None for c in corners):
return None return {
'corners': None, 'all_lines': lines,
'horizontals': [h.tolist() for h in horizontals],
'verticals': [v.tolist() for v in verticals],
'selected_lines': selected,
'error': 'Lines are parallel — could not find all 4 corner intersections',
}
return np.array(corners, dtype=np.float32) return {
'corners': np.array(corners, dtype=np.float32),
'all_lines': lines,
'horizontals': [h.tolist() for h in horizontals],
'verticals': [v.tolist() for v in verticals],
'selected_lines': selected,
'error': None,
}
@@ -324,15 +397,10 @@ def main():
ring_buffer = FrameRingBuffer(max_seconds=args.buffer_seconds, fps=args.fps) ring_buffer = FrameRingBuffer(max_seconds=args.buffer_seconds, fps=args.fps)
# Load calibrations if available # Start with empty calibrators — user must calibrate via UI
os.makedirs(args.calibration_dir, exist_ok=True) os.makedirs(args.calibration_dir, exist_ok=True)
for sensor_id in [0, 1]: for sensor_id in [0, 1]:
cal = CameraCalibrator() state['calibrators'][sensor_id] = CameraCalibrator()
cal_path = os.path.join(args.calibration_dir, f'cam{sensor_id}_calibration.json')
if os.path.exists(cal_path):
if cal.load(cal_path):
print(f"[CAM {sensor_id}] Loaded calibration from {cal_path}")
state['calibrators'][sensor_id] = cal
# Start camera readers # Start camera readers
cam_readers = {} cam_readers = {}

View File

@@ -50,7 +50,6 @@ class CameraCalibrator:
[0, 0, 1] [0, 0, 1]
], dtype=np.float32) ], dtype=np.float32)
try:
success, self.rotation_vec, self.translation_vec = cv2.solvePnP( success, self.rotation_vec, self.translation_vec = cv2.solvePnP(
court_corners_3d.astype(np.float32), court_corners_3d.astype(np.float32),
court_corners_pixel.astype(np.float32), court_corners_pixel.astype(np.float32),
@@ -59,7 +58,7 @@ class CameraCalibrator:
flags=cv2.SOLVEPNP_ITERATIVE flags=cv2.SOLVEPNP_ITERATIVE
) )
if not success: if not success:
return False raise RuntimeError("solvePnP failed to find camera pose")
self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec) self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec)
@@ -72,9 +71,6 @@ class CameraCalibrator:
self.calibrated = True self.calibrated = True
return True return True
except Exception as e:
print(f"Calibration failed: {e}")
return False
def pixel_to_ground(self, px: float, py: float) -> Optional[Tuple[float, float]]: def pixel_to_ground(self, px: float, py: float) -> Optional[Tuple[float, float]]:
"""Project pixel to ground plane (Z=0) using homography.""" """Project pixel to ground plane (Z=0) using homography."""
@@ -150,7 +146,6 @@ class CameraCalibrator:
json.dump(data, f, indent=2) json.dump(data, f, indent=2)
def load(self, filepath: str) -> bool: def load(self, filepath: str) -> bool:
try:
with open(filepath, 'r') as f: with open(filepath, 'r') as f:
data = json.load(f) data = json.load(f)
self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32) self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32)
@@ -161,9 +156,6 @@ class CameraCalibrator:
self.homography = np.array(data['homography'], dtype=np.float32) self.homography = np.array(data['homography'], dtype=np.float32)
self.calibrated = True self.calibrated = True
return True return True
except Exception as e:
print(f"Failed to load calibration: {e}")
return False
def get_half_court_3d_points(side: str) -> np.ndarray: def get_half_court_3d_points(side: str) -> np.ndarray:

View File

@@ -112,11 +112,16 @@ def api_calibration_trigger():
if fn is None: if fn is None:
return jsonify({'ok': False, 'error': 'Calibration not available'}), 500 return jsonify({'ok': False, 'error': 'Calibration not available'}), 500
import traceback
try: try:
result = fn() result = fn()
return jsonify({'ok': True, 'result': result}) # Check if any camera failed
any_ok = any(r.get('ok') for r in result.values())
return jsonify({'ok': any_ok, 'result': result})
except Exception as e: except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500 tb = traceback.format_exc()
print(f"[CALIBRATION ERROR]\n{tb}")
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {e}'}), 500
@app.route('/api/calibration/data') @app.route('/api/calibration/data')

View File

@@ -332,19 +332,15 @@
<div class="bottom-card"><img id="cal-cam1" alt="Camera 1"></div> <div class="bottom-card"><img id="cal-cam1" alt="Camera 1"></div>
<div class="bottom-card"><img id="cal-cam0" alt="Camera 0"></div> <div class="bottom-card"><img id="cal-cam0" alt="Camera 0"></div>
<div class="cam-card"> <div class="cam-card">
<div class="cc-title">Base Setup</div> <div class="cc-title">Calibration</div>
<div class="cc-item">Distance <b id="paramPosY">1.0</b>m</div>
<div class="cc-item">Height <b id="paramPosZ">1.0</b>m</div>
<div class="cc-item">Stereo <b id="paramStereo">6</b>cm</div>
<div class="cc-item">Rotation <b id="paramAngle">15</b>°</div>
<div class="cc-item">HFOV <b id="paramHfov">128</b>°</div>
<div class="cc-item">Sensor <b>IMX219</b></div>
<div class="cc-divider"></div>
<button class="btn-calibrate" id="btnCalibrate" onclick="doCalibrate()">Calibrate</button> <button class="btn-calibrate" id="btnCalibrate" onclick="doCalibrate()">Calibrate</button>
<div class="calibrate-status" id="calStatus"> <div class="calibrate-status" id="calStatus">
<span id="calStatusText">Not calibrated</span> <span id="calStatusText">Not calibrated</span>
</div> </div>
<div id="calError" style="color:#ff4444;font-size:8px;word-break:break-all;display:none"></div>
</div> </div>
<div class="bottom-card" id="calDebug0" style="display:none"><img id="calDebugImg0" alt="CAM 0 debug"></div>
<div class="bottom-card" id="calDebug1" style="display:none"><img id="calDebugImg1" alt="CAM 1 debug"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -411,15 +407,34 @@ if (activeTab !== 'camera') switchTab(activeTab);
// ===================== Calibration ===================== // ===================== Calibration =====================
function doCalibrate() { function doCalibrate() {
var btn = document.getElementById('btnCalibrate'); var btn = document.getElementById('btnCalibrate');
var errEl = document.getElementById('calError');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Calibrating...'; btn.textContent = 'Calibrating...';
errEl.style.display = 'none';
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.disabled = false;
// Show debug images from calibration
if (data.result) {
for (var sid in data.result) {
var r = data.result[sid];
if (r.debug_image) {
var dbgEl = document.getElementById('calDebug' + sid);
var imgEl = document.getElementById('calDebugImg' + sid);
if (dbgEl && imgEl) {
imgEl.src = 'data:image/jpeg;base64,' + r.debug_image;
dbgEl.style.display = 'block';
}
}
}
}
if (data.ok) { if (data.ok) {
btn.textContent = 'Re-calibrate'; btn.textContent = 'Re-calibrate';
errEl.style.display = 'none';
updateCalibrationStatus(); updateCalibrationStatus();
fetch('/api/calibration/data') fetch('/api/calibration/data')
@@ -427,19 +442,23 @@ function doCalibrate() {
.then(function(camData) { addCamerasToScene(camData); }); .then(function(camData) { addCamerasToScene(camData); });
} else { } else {
btn.textContent = 'Calibrate'; btn.textContent = 'Calibrate';
// Show errors
var errors = []; var errors = [];
if (data.result) { if (data.result) {
for (var sid in data.result) { for (var sid in data.result) {
if (!data.result[sid].ok) errors.push('CAM ' + sid + ': ' + data.result[sid].error); if (!data.result[sid].ok) errors.push(data.result[sid].error);
} }
} }
alert('Calibration failed:\n' + (errors.join('\n') || data.error || 'Unknown error')); var msg = errors.join(' | ') || data.error || 'Unknown error';
errEl.textContent = msg;
errEl.style.display = 'block';
} }
}) })
.catch(function(e) { .catch(function(e) {
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Calibrate'; btn.textContent = 'Calibrate';
alert('Error: ' + e); errEl.textContent = String(e);
errEl.style.display = 'block';
}); });
} }
@@ -582,9 +601,6 @@ function initCourtScene() {
courtScene.add(new THREE.AmbientLight(0xffffff, 0.8)); courtScene.add(new THREE.AmbientLight(0xffffff, 0.8));
// Physical camera marker: 1m from net, center, 1m height
addStereocameras(courtScene, getCamParams());
// Load existing calibration cameras // Load existing calibration cameras
fetch('/api/calibration/data') fetch('/api/calibration/data')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@@ -683,9 +699,6 @@ function initTrajectoryScene() {
trajScene.add(new THREE.AmbientLight(0xffffff, 0.8)); trajScene.add(new THREE.AmbientLight(0xffffff, 0.8));
// Physical camera marker: 1m from net, center, 1m height
addStereocameras(trajScene, getCamParams());
function animateTraj() { function animateTraj() {
requestAnimationFrame(animateTraj); requestAnimationFrame(animateTraj);
controls.update(); controls.update();
@@ -702,115 +715,6 @@ function initTrajectoryScene() {
}); });
} }
// ===================== Stereo camera rig with court coverage =====================
// Track camera overlay objects for live update
var camOverlayObjects = [];
function addStereocameras(scene, params) {
params = params || {};
var baseX = 6.7;
var baseY = -(params.posY || 1);
var baseZ = params.posZ || 1;
var stereoGap = (params.stereo || 6) / 100;
var camAngle = params.angle || 15;
var hfov = params.hfov || 128;
// Remove old overlay objects
camOverlayObjects.forEach(function(obj) { scene.remove(obj); });
camOverlayObjects = [];
function addObj(obj) {
scene.add(obj);
camOverlayObjects.push(obj);
}
var cam0x = baseX - stereoGap / 2;
var cam1x = baseX + stereoGap / 2;
// Small camera bodies
function drawCamBody(cx, color) {
var body = new THREE.Mesh(
new THREE.BoxGeometry(0.12, 0.08, 0.08),
new THREE.MeshBasicMaterial({ color: color })
);
body.position.set(cx, baseY, baseZ);
addObj(body);
}
drawCamBody(cam0x, 0x44aaff);
drawCamBody(cam1x, 0xff44aa);
// Pole
var poleGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(baseX, baseY, 0),
new THREE.Vector3(baseX, baseY, baseZ)
]);
addObj(new THREE.Line(poleGeo, new THREE.LineBasicMaterial({ color: 0x666666 })));
// Court boundaries for clipping
var courtMinX = 0, courtMaxX = 13.4, courtMinY = 0, courtMaxY = 6.1;
var deg2rad = Math.PI / 180;
var halfFov = hfov / 2;
function drawCoverage(cx, angleDeg, color) {
var centerAngle = 90 + angleDeg;
var leftAngle = centerAngle + halfFov;
var rightAngle = centerAngle - halfFov;
var ox = cx, oy = baseY;
// Cast ray, return farthest point on court boundary
function castRay(aDeg) {
var rad = aDeg * deg2rad;
var dx = Math.cos(rad);
var dy = Math.sin(rad);
if (dy <= 0) return null;
// t where ray enters court (Y=0)
var tEnter = (courtMinY - oy) / dy;
// t where ray exits court
var tExit = (courtMaxY - oy) / dy;
// clip X bounds
if (dx > 1e-9) tExit = Math.min(tExit, (courtMaxX - ox) / dx);
else if (dx < -1e-9) tExit = Math.min(tExit, (courtMinX - ox) / dx);
if (tExit <= tEnter) return null;
var px = ox + dx * tExit;
var py = oy + dy * tExit;
px = Math.max(courtMinX, Math.min(courtMaxX, px));
py = Math.max(courtMinY, Math.min(courtMaxY, py));
return new THREE.Vector3(px, py, 0.015);
}
// Camera actual position (behind court at Y=-1)
var camOnCourt = new THREE.Vector3(cx, oy, 0.015);
// Far edge points
var farPoints = [];
var steps = 64;
for (var i = 0; i <= steps; i++) {
var a = rightAngle + (leftAngle - rightAngle) * (i / steps);
var pt = castRay(a);
if (pt) farPoints.push(pt);
}
if (farPoints.length < 2) return;
// Draw triangle fan from camera court position to far edge
var mat = new THREE.MeshBasicMaterial({
color: color, transparent: true, opacity: 0.35,
side: THREE.DoubleSide, depthWrite: false
});
for (var i = 0; i < farPoints.length - 1; i++) {
var triGeo = new THREE.BufferGeometry().setFromPoints([
camOnCourt, farPoints[i], farPoints[i + 1]
]);
addObj(new THREE.Mesh(triGeo, mat));
}
}
drawCoverage(cam0x, -camAngle, 0x4488ff); // blue
drawCoverage(cam1x, camAngle, 0xff44aa); // pink
// overlap blends to purple naturally
}
// ===================== Draw court lines ===================== // ===================== Draw court lines =====================
function drawCourtLines(scene) { function drawCourtLines(scene) {
@@ -983,26 +887,6 @@ setInterval(function() {
}).catch(function() {}); }).catch(function() {});
}, 1000); }, 1000);
// ===================== Camera params live update =====================
function getCamParams() {
return {
posY: parseFloat(document.getElementById('paramPosY').textContent) || 1,
posZ: parseFloat(document.getElementById('paramPosZ').textContent) || 1,
stereo: parseFloat(document.getElementById('paramStereo').textContent) || 6,
angle: parseFloat(document.getElementById('paramAngle').textContent) || 15,
hfov: parseFloat(document.getElementById('paramHfov').textContent) || 128
};
}
function setCamParams(p) {
document.getElementById('paramPosY').textContent = p.posY;
document.getElementById('paramPosZ').textContent = p.posZ;
document.getElementById('paramStereo').textContent = p.stereo;
document.getElementById('paramAngle').textContent = p.angle;
document.getElementById('paramHfov').textContent = p.hfov;
if (courtSceneInitialized) addStereocameras(courtScene, p);
if (trajSceneInitialized) addStereocameras(trajScene, p);
}
</script> </script>
</body> </body>
</html> </html>