Make calibration report point-centric with visual overlays

This commit is contained in:
Ruslan Bakiev
2026-03-26 09:41:09 +07:00
parent a3b57c5742
commit f6f0e16d8e
3 changed files with 379 additions and 149 deletions

View File

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