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