Make calibration report point-centric with visual overlays
This commit is contained in:
194
jetson/main.py
194
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,
|
||||
|
||||
Reference in New Issue
Block a user