diff --git a/jetson/main.py b/jetson/main.py index 173c2a6..d41b4dc 100644 --- a/jetson/main.py +++ b/jetson/main.py @@ -9,6 +9,7 @@ import os import cv2 import time import base64 +import json import argparse import threading import numpy as np @@ -47,6 +48,158 @@ def _init_calibration_steps(): } +def _build_point_report(side, matched_image_points=None): + """Build report of expected template points and which were matched.""" + template = get_half_court_intersections(side) + matched_image_points = matched_image_points or {} + points = [] + found_count = 0 + + for name, world_pt in template.items(): + img_pt = matched_image_points.get(name) + is_found = img_pt is not None + if is_found: + found_count += 1 + points.append({ + 'name': name, + 'found': is_found, + 'world_xyz': [float(world_pt[0]), float(world_pt[1]), float(world_pt[2])], + 'image_xy': [float(img_pt[0]), float(img_pt[1])] if is_found else None, + }) + + target_count = len(points) + return { + 'target_count': target_count, + 'found_count': found_count, + 'missing_count': target_count - found_count, + 'points': points, + } + + +def _render_point_schematic(side, point_report): + """Render flat half-court scheme with found/missing points.""" + width = 760 + height = 460 + margin = 48 + canvas = np.zeros((height, width, 3), dtype=np.uint8) + canvas[:] = (12, 18, 32) + + # Half-court dimensions + half_len = HALF_COURT_LENGTH # 6.7m + court_w = COURT_WIDTH # 6.1m + kitchen_x = HALF_COURT_LENGTH - 2.13 # 4.57m from baseline + + draw_w = width - 2 * margin + draw_h = height - 2 * margin + + def to_px(local_x, y): + px = int(margin + (local_x / half_len) * draw_w) + py = int(margin + (y / court_w) * draw_h) + return px, py + + # Court frame + tl = to_px(0, 0) + br = to_px(half_len, court_w) + cv2.rectangle(canvas, tl, br, (200, 200, 200), 2) + + # Kitchen and center service line + p1 = to_px(kitchen_x, 0) + p2 = to_px(kitchen_x, court_w) + cv2.line(canvas, p1, p2, (180, 180, 180), 1) + c1 = to_px(0, court_w / 2) + c2 = to_px(kitchen_x, court_w / 2) + cv2.line(canvas, c1, c2, (140, 140, 140), 1) + + # Net line + n1 = to_px(half_len, 0) + n2 = to_px(half_len, court_w) + cv2.line(canvas, n1, n2, (100, 160, 240), 2) + + # Helper: convert world X to local baseline->net axis for each side + def to_local_x(world_x): + if side == 'left': + return world_x + return COURT_LENGTH - world_x + + for point in point_report.get('points', []): + world = point.get('world_xyz') or [0.0, 0.0, 0.0] + local_x = to_local_x(world[0]) + local_y = world[1] + px, py = to_px(local_x, local_y) + color = (70, 220, 130) if point.get('found') else (80, 80, 230) + cv2.circle(canvas, (px, py), 8, color, -1) + cv2.circle(canvas, (px, py), 12, color, 1) + cv2.putText(canvas, point.get('name', '?'), (px + 10, py - 8), + cv2.FONT_HERSHEY_SIMPLEX, 0.42, color, 1) + + summary = f"Target: {point_report.get('target_count', 0)} Found: {point_report.get('found_count', 0)} Missing: {point_report.get('missing_count', 0)}" + cv2.putText(canvas, summary, (margin, height - 14), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (220, 220, 220), 1) + + ok, jpg = cv2.imencode('.jpg', canvas, [cv2.IMWRITE_JPEG_QUALITY, 90]) + if not ok: + return None + return base64.b64encode(jpg.tobytes()).decode('ascii') + + +def _render_points_on_frame(frame, point_report): + """Render only detected template points on top of the camera frame.""" + overlay = frame.copy() + found_count = 0 + for point in point_report.get('points', []): + img_pt = point.get('image_xy') + if not img_pt: + continue + found_count += 1 + x = int(img_pt[0]) + y = int(img_pt[1]) + cv2.circle(overlay, (x, y), 9, (70, 220, 130), 2) + cv2.circle(overlay, (x, y), 3, (70, 220, 130), -1) + cv2.putText(overlay, point.get('name', '?'), (x + 12, y - 6), + cv2.FONT_HERSHEY_SIMPLEX, 0.45, (70, 220, 130), 1) + + title = f"Template points: {found_count}/{point_report.get('target_count', 0)}" + cv2.rectangle(overlay, (8, 8), (360, 36), (0, 0, 0), -1) + cv2.putText(overlay, title, (14, 28), + cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 255, 220), 1) + + ok, jpg = cv2.imencode('.jpg', overlay, [cv2.IMWRITE_JPEG_QUALITY, 88]) + if not ok: + return None + return base64.b64encode(jpg.tobytes()).decode('ascii') + + +def _save_calibration_report(report_payload): + """Persist last calibration report so it can be read outside UI.""" + if not _args or not getattr(_args, 'calibration_dir', None): + return + + report_dir = os.path.join(_args.calibration_dir, 'calibration_reports') + os.makedirs(report_dir, exist_ok=True) + + # Save compact JSON without large base64 payloads. + compact = { + 'timestamp': report_payload.get('timestamp'), + 'result': {}, + } + for sid, entry in (report_payload.get('result') or {}).items(): + clean = dict(entry) + clean.pop('debug_image', None) + clean.pop('points_overlay_image', None) + clean.pop('schematic_image', None) + compact['result'][sid] = clean + + latest_path = os.path.join(report_dir, 'latest.json') + ts_path = os.path.join(report_dir, f"report_{int(time.time())}.json") + + for path in [latest_path, ts_path]: + try: + with open(path, 'w') as f: + json.dump(compact, f, indent=2) + except Exception as exc: + print(f"[CALIBRATION] Failed to write report {path}: {exc}") + + def auto_calibrate(): """Calibrate cameras by detecting court line intersections and matching them to the known pickleball court template. @@ -64,9 +217,11 @@ def auto_calibrate(): results = {} for sensor_id, reader in _cam_readers.items(): + side = 'left' if sensor_id == 0 else 'right' steps = _init_calibration_steps() frame = reader.grab() if frame is None: + point_report = _build_point_report(side) steps['green_mask'] = {'status': 'fail', 'detail': 'No frame available'} steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'} steps['merged_lines'] = {'status': 'fail', 'detail': 'Calibration aborted'} @@ -78,11 +233,13 @@ def auto_calibrate(): 'ok': False, 'error': f'CAM {sensor_id}: No frame available', 'steps': steps, + 'point_report': point_report, + 'schematic_image': _render_point_schematic(side, point_report), + 'points_overlay_image': None, } continue h, w = frame.shape[:2] - side = 'left' if sensor_id == 0 else 'right' debug_frame = frame.copy() # Step 1: Detect green court mask @@ -93,6 +250,7 @@ def auto_calibrate(): green_pct = np.count_nonzero(green_mask) / (w * h) * 100 if green_pct < 5: + point_report = _build_point_report(side) steps['green_mask'] = {'status': 'fail', 'detail': f'Green area too small: {green_pct:.1f}%'} steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'} steps['merged_lines'] = {'status': 'fail', 'detail': 'Calibration aborted'} @@ -108,6 +266,9 @@ def auto_calibrate(): 'error': f'CAM {sensor_id}: Green area too small ({green_pct:.0f}%)', 'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'), 'steps': steps, + 'point_report': point_report, + 'schematic_image': _render_point_schematic(side, point_report), + 'points_overlay_image': _render_points_on_frame(debug_frame, point_report), } continue steps['green_mask'] = {'status': 'ok', 'detail': f'Green area: {green_pct:.1f}%'} @@ -153,6 +314,7 @@ def auto_calibrate(): if quad is not None: cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h) if cal is not None: + point_report = _build_point_report(side) steps['template_match'] = { 'status': 'ok', 'detail': 'Low intersections, used quad fallback mapping' @@ -170,9 +332,11 @@ def auto_calibrate(): n_lines=len(merged), n_intersections=len(intersections), n_points_matched=0, + point_report=point_report, ) continue + point_report = _build_point_report(side) steps['template_match'] = {'status': 'fail', 'detail': 'Not enough intersections for template fit'} steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'} steps['overlay'] = {'status': 'fail', 'detail': 'No calibration output'} @@ -184,6 +348,9 @@ def auto_calibrate(): 'error': f'CAM {sensor_id}: {len(intersections)} intersections (need 4+) from {len(merged)} lines', 'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'), 'steps': steps, + 'point_report': point_report, + 'schematic_image': _render_point_schematic(side, point_report), + 'points_overlay_image': _render_points_on_frame(debug_frame, point_report), } continue @@ -197,6 +364,7 @@ def auto_calibrate(): if quad is not None: cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h) if cal is not None: + point_report = _build_point_report(side) steps['template_match'] = { 'status': 'ok', 'detail': 'Template match weak, used quad fallback mapping' @@ -214,9 +382,11 @@ def auto_calibrate(): n_lines=len(merged), n_intersections=len(intersections), n_points_matched=0, + point_report=point_report, ) continue + point_report = _build_point_report(side) steps['template_match'] = {'status': 'fail', 'detail': 'Could not match intersections to template'} steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'} steps['overlay'] = {'status': 'fail', 'detail': 'No calibration output'} @@ -228,10 +398,14 @@ def auto_calibrate(): 'error': f'CAM {sensor_id}: Could not match court pattern', 'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'), 'steps': steps, + 'point_report': point_report, + 'schematic_image': _render_point_schematic(side, point_report), + 'points_overlay_image': _render_points_on_frame(debug_frame, point_report), } continue n_matched = len(match['image_points']) + point_report = _build_point_report(side, match['image_points']) steps['template_match'] = { 'status': 'ok', 'detail': f'Matched {n_matched}/{len(template)} template points' @@ -259,6 +433,9 @@ def auto_calibrate(): 'error': f'CAM {sensor_id}: solvePnP failed ({exc})', 'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'), 'steps': steps, + 'point_report': point_report, + 'schematic_image': _render_point_schematic(side, point_report), + 'points_overlay_image': _render_points_on_frame(debug_frame, point_report), } continue @@ -288,14 +465,22 @@ def auto_calibrate(): n_lines=len(merged), n_intersections=len(intersections), n_points_matched=n_matched, + point_report=point_report, ) + report_payload = { + 'timestamp': time.time(), + 'result': results, + } + state['last_calibration_report'] = report_payload + _save_calibration_report(report_payload) + return results def _finalize_calibration(cal, cam_pos, sensor_id, side, debug_frame, results, method_info, steps, n_segments, n_lines, - n_intersections, n_points_matched): + n_intersections, n_points_matched, point_report): """Save calibration and build result dict.""" steps['camera_pose'] = { 'status': 'ok', @@ -322,11 +507,16 @@ def _finalize_calibration(cal, cam_pos, sensor_id, side, debug_frame, _, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85]) matched_lines_3d = _get_half_court_lines_3d(side) + schematic_image = _render_point_schematic(side, point_report) + points_overlay_image = _render_points_on_frame(debug_frame, point_report) results[str(sensor_id)] = { 'ok': True, 'camera_position': cam_pos.tolist(), 'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'), + 'points_overlay_image': points_overlay_image, + 'schematic_image': schematic_image, + 'point_report': point_report, 'matched_lines_3d': matched_lines_3d, 'points_matched': n_points_matched, 'intersections_detected': n_intersections, diff --git a/src/web/app.py b/src/web/app.py index 9b92456..400a8eb 100644 --- a/src/web/app.py +++ b/src/web/app.py @@ -25,6 +25,7 @@ state = { 'events': [], # recent VAR events 'last_var': None, # last VAR event with snapshot 'calibrate_fn': None, # callback for calibration trigger + 'last_calibration_report': None, } @@ -147,5 +148,39 @@ def api_calibration_data(): return jsonify(result) +@app.route('/api/calibration/report') +def api_calibration_report(): + """Return last detailed calibration report (state fallback to disk).""" + full = request.args.get('full') == '1' + + report = state.get('last_calibration_report') + if report is not None: + if full: + return jsonify(report) + + compact = {'timestamp': report.get('timestamp'), 'result': {}} + for sid, entry in (report.get('result') or {}).items(): + clean = dict(entry) + clean.pop('debug_image', None) + clean.pop('points_overlay_image', None) + clean.pop('schematic_image', None) + compact['result'][sid] = clean + return jsonify(compact) + + cal_dir = state.get('calibration_dir') + if not cal_dir: + return jsonify({'ok': False, 'error': 'Calibration dir not configured'}), 404 + + report_path = Path(cal_dir) / 'calibration_reports' / 'latest.json' + if not report_path.exists(): + return jsonify({'ok': False, 'error': 'No calibration report found'}), 404 + + try: + with open(report_path, 'r') as f: + return jsonify(json.load(f)) + except Exception as exc: + return jsonify({'ok': False, 'error': str(exc)}), 500 + + def run(host='0.0.0.0', port=8080): app.run(host=host, port=port, threaded=True) diff --git a/src/web/templates/index.html b/src/web/templates/index.html index 882cc1d..4eb0c42 100644 --- a/src/web/templates/index.html +++ b/src/web/templates/index.html @@ -314,11 +314,6 @@ border-radius: 6px; background: #0d0d20; } - .flow-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - } .flow-card { border: 1px solid #2a2a4a; border-radius: 6px; @@ -333,40 +328,77 @@ text-transform: uppercase; font-weight: 700; } - .flow-steps { + .cal-report-card { + border: 1px solid #2a2a4a; + border-radius: 6px; + background: #0d0d20; + padding: 12px; display: flex; flex-direction: column; + gap: 10px; + } + .cal-point-summary { + font-size: 14px; + color: #d7d7e0; + line-height: 1.4; + } + .cal-point-summary b { + color: #4ecca3; + } + .cal-point-strip { + display: flex; + flex-wrap: wrap; gap: 6px; } - .flow-step { + .point-chip { + font-size: 11px; + border-radius: 999px; + padding: 3px 8px; border: 1px solid #2a2a4a; - border-radius: 4px; - padding: 6px 8px; - background: #10102a; - font-size: 12px; + color: #aaa; + background: #111128; } - .flow-step .step-head { - display: flex; - justify-content: space-between; + .point-chip.found { + border-color: #2e8f6e; + color: #bff1df; + background: #12392f; + } + .point-chip.missing { + border-color: #8d3b3b; + color: #ffd8d8; + background: #3c1b1b; + } + .cal-report-images { + display: grid; + grid-template-columns: 1fr; gap: 8px; } - .flow-step .step-name { - color: #ddd; - font-weight: 600; + .cal-image-box { + position: relative; + border: 1px solid #2a2a4a; + border-radius: 6px; + overflow: hidden; + padding: 8px; + background: #10102a; } - .flow-step .step-status { - color: #888; - font-size: 11px; + .cal-image-title { + font-size: 10px; + color: #7fd8be; + margin-bottom: 6px; text-transform: uppercase; + letter-spacing: 0.4px; } - .flow-step .step-detail { - margin-top: 4px; - color: #8e8eb0; - font-size: 11px; + .cal-image-box img { + width: 100%; + border-radius: 4px; + border: 1px solid #333; + display: block; + cursor: pointer; + } + .cal-image-box img[src=""], + .cal-image-box img:not([src]) { + display: none; } - .flow-step.ok .step-status { color: #4ecca3; } - .flow-step.fail .step-status { color: #ff6666; } - .flow-step.pending .step-status { color: #aaa; } .calibration-3d-wrap { border: 1px solid #2a2a4a; border-radius: 6px; @@ -376,44 +408,13 @@ .calibration-3d-wrap .viewport-3d { border-top: none; } - .cal-debug-images { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px; + .report-link { + color: #79c9ff; + text-decoration: none; + font-size: 12px; } - .cal-debug-card { - position: relative; - border: 1px solid #2a2a4a; - border-radius: 6px; - overflow: hidden; - background: #0d0d20; - padding: 8px; - } - .cal-debug-card img { - width: 100%; - border-radius: 4px; - border: 1px solid #333; - display: block; - cursor: pointer; - } - .cal-debug-card .cal-live { - opacity: 0.5; - } - .cal-debug-card img[src=""], - .cal-debug-card img:not([src]) { - display: none; - } - .cal-debug-label { - position: absolute; - top: 4px; - left: 4px; - background: rgba(0,0,0,0.7); - color: #4ecca3; - padding: 2px 6px; - border-radius: 3px; - font-size: 10px; - font-weight: 600; - z-index: 1; + .report-link:hover { + text-decoration: underline; } /* Fullscreen image overlay */ @@ -450,8 +451,7 @@ max-width: 100%; } .cal-cameras, - .flow-grid, - .cal-debug-images { + .cal-report-images { grid-template-columns: 1fr; } } @@ -512,25 +512,38 @@
- -