From e12edab19b89ed2fa89c60dca47513d6a1350fc1 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Sun, 22 Mar 2026 14:27:53 +0700 Subject: [PATCH] Remove all fallbacks: show errors, draw debug lines on calibration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- jetson/main.py | 158 +++++++++++++++++------- src/calibration/camera_calibrator.py | 64 +++++----- src/web/app.py | 9 +- src/web/templates/index.html | 176 +++++---------------------- 4 files changed, 178 insertions(+), 229 deletions(-) diff --git a/jetson/main.py b/jetson/main.py index d7ccd75..68b9d4c 100644 --- a/jetson/main.py +++ b/jetson/main.py @@ -39,11 +39,9 @@ def auto_calibrate(): compute camera pose, save to config. Each camera sees one half of the court from the net position. - We use the court lines visible in each frame to build correspondences. - - For now: use a simple approach — detect the 4 most prominent lines - (baseline, two sidelines, kitchen line) and map to known 3D coords. - Returns error if court lines cannot be detected (no fallbacks). + Detects court lines via Hough transform, finds 4 corners, + then uses solvePnP to determine camera position. + Returns debug images with detected lines drawn on them. """ results = {} @@ -55,51 +53,93 @@ def auto_calibrate(): h, w = frame.shape[:2] side = 'left' if sensor_id == 0 else 'right' + debug_frame = frame.copy() - # Try to detect court lines using edge detection + Hough - corners_pixel = _detect_court_corners(frame, side) + # Detect court lines — returns corners + debug info + 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: + error_detail = detection.get('error', 'Unknown') if detection else 'No lines detected at all' results[str(sensor_id)] = { 'ok': False, - 'error': f'Could not detect court lines for CAM {sensor_id}. ' - 'Ensure court lines are clearly visible.' + 'error': f'CAM {sensor_id}: {error_detail}', + 'debug_image': debug_b64, } 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 corners_3d = get_half_court_3d_points(side) - # Calibrate + # Calibrate — no try/except, let errors propagate cal = CameraCalibrator() - ok = cal.calibrate( + cal.calibrate( np.array(corners_pixel, dtype=np.float32), corners_3d, w, h ) - if ok: - # Save to config - cal_path = os.path.join(_args.calibration_dir, - f'cam{sensor_id}_calibration.json') - os.makedirs(os.path.dirname(cal_path), exist_ok=True) - cal.save(cal_path) + # Save to config + cal_path = os.path.join(_args.calibration_dir, + f'cam{sensor_id}_calibration.json') + os.makedirs(os.path.dirname(cal_path), exist_ok=True) + cal.save(cal_path) - state['calibrators'][sensor_id] = cal + state['calibrators'][sensor_id] = cal - # Get camera position for 3D scene - cam_pos = (-cal.rotation_matrix.T @ cal.translation_vec).flatten() + # Get camera position for 3D scene + cam_pos = (-cal.rotation_matrix.T @ cal.translation_vec).flatten() - results[str(sensor_id)] = { - 'ok': True, - 'camera_position': cam_pos.tolist(), - 'corners_pixel': corners_pixel.tolist() if isinstance(corners_pixel, np.ndarray) - else corners_pixel, - } - print(f"[CAM {sensor_id}] Calibrated! Camera at " - f"({cam_pos[0]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})") - else: - results[str(sensor_id)] = {'ok': False, 'error': 'Calibration failed'} + results[str(sensor_id)] = { + 'ok': True, + 'camera_position': cam_pos.tolist(), + 'debug_image': debug_b64, + } + print(f"[CAM {sensor_id}] Calibrated! Camera at " + f"({cam_pos[0]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})") return results @@ -107,7 +147,13 @@ def auto_calibrate(): def _detect_court_corners(frame, side): """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) blur = cv2.GaussianBlur(gray, (5, 5), 0) @@ -118,7 +164,13 @@ def _detect_court_corners(frame, side): minLineLength=100, maxLineGap=20) 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 horizontals = [] @@ -134,20 +186,28 @@ def _detect_court_corners(frame, side): verticals.append(line[0]) 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 h_positions = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 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] bottom_line = h_positions[-1] - - # Take the most separated vertical pair (left and right sidelines) left_line = v_positions[0] 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 def line_intersection(l1, l2): x1, y1, x2, y2 = l1 @@ -168,9 +228,22 @@ def _detect_court_corners(frame, side): ] 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) - # Load calibrations if available + # Start with empty calibrators — user must calibrate via UI os.makedirs(args.calibration_dir, exist_ok=True) for sensor_id in [0, 1]: - cal = 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 + state['calibrators'][sensor_id] = CameraCalibrator() # Start camera readers cam_readers = {} diff --git a/src/calibration/camera_calibrator.py b/src/calibration/camera_calibrator.py index 613b35f..0dc5984 100644 --- a/src/calibration/camera_calibrator.py +++ b/src/calibration/camera_calibrator.py @@ -50,31 +50,27 @@ class CameraCalibrator: [0, 0, 1] ], dtype=np.float32) - try: - success, self.rotation_vec, self.translation_vec = cv2.solvePnP( - court_corners_3d.astype(np.float32), - court_corners_pixel.astype(np.float32), - self.camera_matrix, - None, - flags=cv2.SOLVEPNP_ITERATIVE - ) - if not success: - return False + success, self.rotation_vec, self.translation_vec = cv2.solvePnP( + court_corners_3d.astype(np.float32), + court_corners_pixel.astype(np.float32), + self.camera_matrix, + None, + flags=cv2.SOLVEPNP_ITERATIVE + ) + if not success: + raise RuntimeError("solvePnP failed to find camera pose") - self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec) + self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec) - # Build ground plane homography (Z=0) - ground_2d = court_corners_3d[:, :2].astype(np.float32) - self.homography = cv2.getPerspectiveTransform( - court_corners_pixel[:4].astype(np.float32), - ground_2d[:4] - ) + # Build ground plane homography (Z=0) + ground_2d = court_corners_3d[:, :2].astype(np.float32) + self.homography = cv2.getPerspectiveTransform( + court_corners_pixel[:4].astype(np.float32), + ground_2d[:4] + ) - self.calibrated = True - return True - except Exception as e: - print(f"Calibration failed: {e}") - return False + self.calibrated = True + return True def pixel_to_ground(self, px: float, py: float) -> Optional[Tuple[float, float]]: """Project pixel to ground plane (Z=0) using homography.""" @@ -150,20 +146,16 @@ class CameraCalibrator: json.dump(data, f, indent=2) def load(self, filepath: str) -> bool: - try: - with open(filepath, 'r') as f: - data = json.load(f) - self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32) - self.rotation_vec = np.array(data['rotation_vector'], dtype=np.float32).reshape(3, 1) - self.translation_vec = np.array(data['translation_vector'], dtype=np.float32).reshape(3, 1) - self.rotation_matrix = np.array(data['rotation_matrix'], dtype=np.float32) - if data.get('homography'): - self.homography = np.array(data['homography'], dtype=np.float32) - self.calibrated = True - return True - except Exception as e: - print(f"Failed to load calibration: {e}") - return False + with open(filepath, 'r') as f: + data = json.load(f) + self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32) + self.rotation_vec = np.array(data['rotation_vector'], dtype=np.float32).reshape(3, 1) + self.translation_vec = np.array(data['translation_vector'], dtype=np.float32).reshape(3, 1) + self.rotation_matrix = np.array(data['rotation_matrix'], dtype=np.float32) + if data.get('homography'): + self.homography = np.array(data['homography'], dtype=np.float32) + self.calibrated = True + return True def get_half_court_3d_points(side: str) -> np.ndarray: diff --git a/src/web/app.py b/src/web/app.py index d5ba1c3..9b92456 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -112,11 +112,16 @@ def api_calibration_trigger(): if fn is None: return jsonify({'ok': False, 'error': 'Calibration not available'}), 500 + import traceback try: 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: - 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') diff --git a/src/web/templates/index.html b/src/web/templates/index.html index cdb8936..55e03eb 100644 --- a/src/web/templates/index.html +++ b/src/web/templates/index.html @@ -332,19 +332,15 @@
Camera 1
Camera 0
-
Base Setup
-
Distance 1.0m
-
Height 1.0m
-
Stereo 6cm
-
Rotation 15°
-
HFOV 128°
-
Sensor IMX219
-
+
Calibration
Not calibrated
+
+ + @@ -411,15 +407,34 @@ if (activeTab !== 'camera') switchTab(activeTab); // ===================== Calibration ===================== function doCalibrate() { var btn = document.getElementById('btnCalibrate'); + var errEl = document.getElementById('calError'); btn.disabled = true; btn.textContent = 'Calibrating...'; + errEl.style.display = 'none'; fetch('/api/calibration/trigger', { method: 'POST' }) .then(function(r) { return r.json(); }) .then(function(data) { 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) { btn.textContent = 'Re-calibrate'; + errEl.style.display = 'none'; updateCalibrationStatus(); fetch('/api/calibration/data') @@ -427,19 +442,23 @@ function doCalibrate() { .then(function(camData) { addCamerasToScene(camData); }); } else { btn.textContent = 'Calibrate'; + // Show 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); + 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) { btn.disabled = false; 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)); - // Physical camera marker: 1m from net, center, 1m height - addStereocameras(courtScene, getCamParams()); - // Load existing calibration cameras fetch('/api/calibration/data') .then(function(r) { return r.json(); }) @@ -683,9 +699,6 @@ function initTrajectoryScene() { trajScene.add(new THREE.AmbientLight(0xffffff, 0.8)); - // Physical camera marker: 1m from net, center, 1m height - addStereocameras(trajScene, getCamParams()); - function animateTraj() { requestAnimationFrame(animateTraj); 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 ===================== function drawCourtLines(scene) { @@ -983,26 +887,6 @@ setInterval(function() { }).catch(function() {}); }, 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); -}