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 cv2
|
||||||
import time
|
import time
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
import argparse
|
import argparse
|
||||||
import threading
|
import threading
|
||||||
import numpy as np
|
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():
|
def auto_calibrate():
|
||||||
"""Calibrate cameras by detecting court line intersections and matching
|
"""Calibrate cameras by detecting court line intersections and matching
|
||||||
them to the known pickleball court template.
|
them to the known pickleball court template.
|
||||||
@@ -64,9 +217,11 @@ def auto_calibrate():
|
|||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
for sensor_id, reader in _cam_readers.items():
|
for sensor_id, reader in _cam_readers.items():
|
||||||
|
side = 'left' if sensor_id == 0 else 'right'
|
||||||
steps = _init_calibration_steps()
|
steps = _init_calibration_steps()
|
||||||
frame = reader.grab()
|
frame = reader.grab()
|
||||||
if frame is None:
|
if frame is None:
|
||||||
|
point_report = _build_point_report(side)
|
||||||
steps['green_mask'] = {'status': 'fail', 'detail': 'No frame available'}
|
steps['green_mask'] = {'status': 'fail', 'detail': 'No frame available'}
|
||||||
steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'}
|
steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'}
|
||||||
steps['merged_lines'] = {'status': 'fail', 'detail': 'Calibration aborted'}
|
steps['merged_lines'] = {'status': 'fail', 'detail': 'Calibration aborted'}
|
||||||
@@ -78,11 +233,13 @@ def auto_calibrate():
|
|||||||
'ok': False,
|
'ok': False,
|
||||||
'error': f'CAM {sensor_id}: No frame available',
|
'error': f'CAM {sensor_id}: No frame available',
|
||||||
'steps': steps,
|
'steps': steps,
|
||||||
|
'point_report': point_report,
|
||||||
|
'schematic_image': _render_point_schematic(side, point_report),
|
||||||
|
'points_overlay_image': None,
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
|
|
||||||
h, w = frame.shape[:2]
|
h, w = frame.shape[:2]
|
||||||
side = 'left' if sensor_id == 0 else 'right'
|
|
||||||
debug_frame = frame.copy()
|
debug_frame = frame.copy()
|
||||||
|
|
||||||
# Step 1: Detect green court mask
|
# Step 1: Detect green court mask
|
||||||
@@ -93,6 +250,7 @@ def auto_calibrate():
|
|||||||
|
|
||||||
green_pct = np.count_nonzero(green_mask) / (w * h) * 100
|
green_pct = np.count_nonzero(green_mask) / (w * h) * 100
|
||||||
if green_pct < 5:
|
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['green_mask'] = {'status': 'fail', 'detail': f'Green area too small: {green_pct:.1f}%'}
|
||||||
steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'}
|
steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'}
|
||||||
steps['merged_lines'] = {'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}%)',
|
'error': f'CAM {sensor_id}: Green area too small ({green_pct:.0f}%)',
|
||||||
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
||||||
'steps': steps,
|
'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
|
continue
|
||||||
steps['green_mask'] = {'status': 'ok', 'detail': f'Green area: {green_pct:.1f}%'}
|
steps['green_mask'] = {'status': 'ok', 'detail': f'Green area: {green_pct:.1f}%'}
|
||||||
@@ -153,6 +314,7 @@ def auto_calibrate():
|
|||||||
if quad is not None:
|
if quad is not None:
|
||||||
cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h)
|
cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h)
|
||||||
if cal is not None:
|
if cal is not None:
|
||||||
|
point_report = _build_point_report(side)
|
||||||
steps['template_match'] = {
|
steps['template_match'] = {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'detail': 'Low intersections, used quad fallback mapping'
|
'detail': 'Low intersections, used quad fallback mapping'
|
||||||
@@ -170,9 +332,11 @@ def auto_calibrate():
|
|||||||
n_lines=len(merged),
|
n_lines=len(merged),
|
||||||
n_intersections=len(intersections),
|
n_intersections=len(intersections),
|
||||||
n_points_matched=0,
|
n_points_matched=0,
|
||||||
|
point_report=point_report,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
point_report = _build_point_report(side)
|
||||||
steps['template_match'] = {'status': 'fail', 'detail': 'Not enough intersections for template fit'}
|
steps['template_match'] = {'status': 'fail', 'detail': 'Not enough intersections for template fit'}
|
||||||
steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'}
|
steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'}
|
||||||
steps['overlay'] = {'status': 'fail', 'detail': 'No calibration output'}
|
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',
|
'error': f'CAM {sensor_id}: {len(intersections)} intersections (need 4+) from {len(merged)} lines',
|
||||||
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
||||||
'steps': steps,
|
'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
|
continue
|
||||||
|
|
||||||
@@ -197,6 +364,7 @@ def auto_calibrate():
|
|||||||
if quad is not None:
|
if quad is not None:
|
||||||
cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h)
|
cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h)
|
||||||
if cal is not None:
|
if cal is not None:
|
||||||
|
point_report = _build_point_report(side)
|
||||||
steps['template_match'] = {
|
steps['template_match'] = {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'detail': 'Template match weak, used quad fallback mapping'
|
'detail': 'Template match weak, used quad fallback mapping'
|
||||||
@@ -214,9 +382,11 @@ def auto_calibrate():
|
|||||||
n_lines=len(merged),
|
n_lines=len(merged),
|
||||||
n_intersections=len(intersections),
|
n_intersections=len(intersections),
|
||||||
n_points_matched=0,
|
n_points_matched=0,
|
||||||
|
point_report=point_report,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
point_report = _build_point_report(side)
|
||||||
steps['template_match'] = {'status': 'fail', 'detail': 'Could not match intersections to template'}
|
steps['template_match'] = {'status': 'fail', 'detail': 'Could not match intersections to template'}
|
||||||
steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'}
|
steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'}
|
||||||
steps['overlay'] = {'status': 'fail', 'detail': 'No calibration output'}
|
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',
|
'error': f'CAM {sensor_id}: Could not match court pattern',
|
||||||
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
||||||
'steps': steps,
|
'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
|
continue
|
||||||
|
|
||||||
n_matched = len(match['image_points'])
|
n_matched = len(match['image_points'])
|
||||||
|
point_report = _build_point_report(side, match['image_points'])
|
||||||
steps['template_match'] = {
|
steps['template_match'] = {
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'detail': f'Matched {n_matched}/{len(template)} template points'
|
'detail': f'Matched {n_matched}/{len(template)} template points'
|
||||||
@@ -259,6 +433,9 @@ def auto_calibrate():
|
|||||||
'error': f'CAM {sensor_id}: solvePnP failed ({exc})',
|
'error': f'CAM {sensor_id}: solvePnP failed ({exc})',
|
||||||
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
||||||
'steps': steps,
|
'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
|
continue
|
||||||
|
|
||||||
@@ -288,14 +465,22 @@ def auto_calibrate():
|
|||||||
n_lines=len(merged),
|
n_lines=len(merged),
|
||||||
n_intersections=len(intersections),
|
n_intersections=len(intersections),
|
||||||
n_points_matched=n_matched,
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _finalize_calibration(cal, cam_pos, sensor_id, side, debug_frame,
|
def _finalize_calibration(cal, cam_pos, sensor_id, side, debug_frame,
|
||||||
results, method_info, steps, n_segments, n_lines,
|
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."""
|
"""Save calibration and build result dict."""
|
||||||
steps['camera_pose'] = {
|
steps['camera_pose'] = {
|
||||||
'status': 'ok',
|
'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])
|
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
|
||||||
|
|
||||||
matched_lines_3d = _get_half_court_lines_3d(side)
|
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)] = {
|
results[str(sensor_id)] = {
|
||||||
'ok': True,
|
'ok': True,
|
||||||
'camera_position': cam_pos.tolist(),
|
'camera_position': cam_pos.tolist(),
|
||||||
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
|
'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,
|
'matched_lines_3d': matched_lines_3d,
|
||||||
'points_matched': n_points_matched,
|
'points_matched': n_points_matched,
|
||||||
'intersections_detected': n_intersections,
|
'intersections_detected': n_intersections,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ state = {
|
|||||||
'events': [], # recent VAR events
|
'events': [], # recent VAR events
|
||||||
'last_var': None, # last VAR event with snapshot
|
'last_var': None, # last VAR event with snapshot
|
||||||
'calibrate_fn': None, # callback for calibration trigger
|
'calibrate_fn': None, # callback for calibration trigger
|
||||||
|
'last_calibration_report': None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -147,5 +148,39 @@ def api_calibration_data():
|
|||||||
return jsonify(result)
|
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):
|
def run(host='0.0.0.0', port=8080):
|
||||||
app.run(host=host, port=port, threaded=True)
|
app.run(host=host, port=port, threaded=True)
|
||||||
|
|||||||
@@ -314,11 +314,6 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
background: #0d0d20;
|
background: #0d0d20;
|
||||||
}
|
}
|
||||||
.flow-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.flow-card {
|
.flow-card {
|
||||||
border: 1px solid #2a2a4a;
|
border: 1px solid #2a2a4a;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -333,40 +328,77 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.flow-steps {
|
.cal-report-card {
|
||||||
|
border: 1px solid #2a2a4a;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0d0d20;
|
||||||
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.flow-step {
|
.point-chip {
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 3px 8px;
|
||||||
border: 1px solid #2a2a4a;
|
border: 1px solid #2a2a4a;
|
||||||
border-radius: 4px;
|
color: #aaa;
|
||||||
padding: 6px 8px;
|
background: #111128;
|
||||||
background: #10102a;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
.flow-step .step-head {
|
.point-chip.found {
|
||||||
display: flex;
|
border-color: #2e8f6e;
|
||||||
justify-content: space-between;
|
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;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.flow-step .step-name {
|
.cal-image-box {
|
||||||
color: #ddd;
|
position: relative;
|
||||||
font-weight: 600;
|
border: 1px solid #2a2a4a;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 8px;
|
||||||
|
background: #10102a;
|
||||||
}
|
}
|
||||||
.flow-step .step-status {
|
.cal-image-title {
|
||||||
color: #888;
|
font-size: 10px;
|
||||||
font-size: 11px;
|
color: #7fd8be;
|
||||||
|
margin-bottom: 6px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
}
|
}
|
||||||
.flow-step .step-detail {
|
.cal-image-box img {
|
||||||
margin-top: 4px;
|
width: 100%;
|
||||||
color: #8e8eb0;
|
border-radius: 4px;
|
||||||
font-size: 11px;
|
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 {
|
.calibration-3d-wrap {
|
||||||
border: 1px solid #2a2a4a;
|
border: 1px solid #2a2a4a;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
@@ -376,44 +408,13 @@
|
|||||||
.calibration-3d-wrap .viewport-3d {
|
.calibration-3d-wrap .viewport-3d {
|
||||||
border-top: none;
|
border-top: none;
|
||||||
}
|
}
|
||||||
.cal-debug-images {
|
.report-link {
|
||||||
display: grid;
|
color: #79c9ff;
|
||||||
grid-template-columns: 1fr 1fr;
|
text-decoration: none;
|
||||||
gap: 8px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.cal-debug-card {
|
.report-link:hover {
|
||||||
position: relative;
|
text-decoration: underline;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fullscreen image overlay */
|
/* Fullscreen image overlay */
|
||||||
@@ -450,8 +451,7 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.cal-cameras,
|
.cal-cameras,
|
||||||
.flow-grid,
|
.cal-report-images {
|
||||||
.cal-debug-images {
|
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -512,25 +512,38 @@
|
|||||||
<div id="calPos0" style="color:#4ecca3"></div>
|
<div id="calPos0" style="color:#4ecca3"></div>
|
||||||
<div id="calPos1" style="color:#ff88cc"></div>
|
<div id="calPos1" style="color:#ff88cc"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div style="margin-top:8px">
|
||||||
<div class="flow-grid">
|
<a class="report-link" href="/api/calibration/report" target="_blank" rel="noopener">Open Last Calibration JSON Report</a>
|
||||||
<div class="flow-card">
|
|
||||||
<div class="flow-title">CAM 0 Calibration Steps</div>
|
|
||||||
<div id="calFlowCam0" class="flow-steps"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flow-card">
|
|
||||||
<div class="flow-title">CAM 1 Calibration Steps</div>
|
|
||||||
<div id="calFlowCam1" class="flow-steps"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cal-debug-images">
|
<div class="cal-report-card">
|
||||||
<div class="cal-debug-card">
|
<div class="flow-title">CAM 0 Point Report</div>
|
||||||
<div class="cal-debug-label">CAM 0 RESULT</div>
|
<div id="calPointSummary0" class="cal-point-summary">Waiting for calibration...</div>
|
||||||
<img id="calDebugImg0" alt="CAM 0 calibration result">
|
<div id="calPointStrip0" class="cal-point-strip"></div>
|
||||||
|
<div class="cal-report-images">
|
||||||
|
<div class="cal-image-box">
|
||||||
|
<div class="cal-image-title">Detected points on frame</div>
|
||||||
|
<img id="calPointsImg0" alt="CAM 0 points overlay">
|
||||||
|
</div>
|
||||||
|
<div class="cal-image-box">
|
||||||
|
<div class="cal-image-title">Flat court scheme</div>
|
||||||
|
<img id="calSchemeImg0" alt="CAM 0 flat scheme">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="cal-debug-card">
|
</div>
|
||||||
<div class="cal-debug-label">CAM 1 RESULT</div>
|
<div class="cal-report-card">
|
||||||
<img id="calDebugImg1" alt="CAM 1 calibration result">
|
<div class="flow-title">CAM 1 Point Report</div>
|
||||||
|
<div id="calPointSummary1" class="cal-point-summary">Waiting for calibration...</div>
|
||||||
|
<div id="calPointStrip1" class="cal-point-strip"></div>
|
||||||
|
<div class="cal-report-images">
|
||||||
|
<div class="cal-image-box">
|
||||||
|
<div class="cal-image-title">Detected points on frame</div>
|
||||||
|
<img id="calPointsImg1" alt="CAM 1 points overlay">
|
||||||
|
</div>
|
||||||
|
<div class="cal-image-box">
|
||||||
|
<div class="cal-image-title">Flat court scheme</div>
|
||||||
|
<img id="calSchemeImg1" alt="CAM 1 flat scheme">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="calibration-3d-wrap">
|
<div class="calibration-3d-wrap">
|
||||||
@@ -538,11 +551,11 @@
|
|||||||
<div class="viewport-3d" id="calibration-3d"></div>
|
<div class="viewport-3d" id="calibration-3d"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flow-card">
|
<div class="flow-card">
|
||||||
<div class="flow-title">Workflow Legend</div>
|
<div class="flow-title">What You See</div>
|
||||||
<div style="font-size:12px;color:#aaa;line-height:1.5">
|
<div style="font-size:12px;color:#aaa;line-height:1.5">
|
||||||
1) detect court ROI → 2) detect line segments → 3) merge lines → 4) get intersections →
|
Each camera shows a target point set and found/missing points.
|
||||||
5) match to template points → 6) solve camera pose → 7) overlay reconstructed geometry.
|
Left image: found template points on the real frame.
|
||||||
Green steps are confirmed, red steps failed, gray steps are pending/skipped.
|
Right image: flat half-court scheme with found/missing markers.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -608,60 +621,66 @@ window.addEventListener('popstate', function() {
|
|||||||
if (activeTab !== 'camera') switchTab(activeTab);
|
if (activeTab !== 'camera') switchTab(activeTab);
|
||||||
|
|
||||||
// ===================== Calibration =====================
|
// ===================== Calibration =====================
|
||||||
var CALIBRATION_STEP_ORDER = [
|
function renderPointReport(camId, camResult) {
|
||||||
'green_mask',
|
var summaryEl = document.getElementById('calPointSummary' + camId);
|
||||||
'line_segments',
|
var stripEl = document.getElementById('calPointStrip' + camId);
|
||||||
'merged_lines',
|
var pointsImg = document.getElementById('calPointsImg' + camId);
|
||||||
'intersections',
|
var schemeImg = document.getElementById('calSchemeImg' + camId);
|
||||||
'template_match',
|
if (!summaryEl || !stripEl || !pointsImg || !schemeImg) return;
|
||||||
'camera_pose',
|
|
||||||
'overlay'
|
|
||||||
];
|
|
||||||
|
|
||||||
var CALIBRATION_STEP_LABELS = {
|
if (!camResult) {
|
||||||
green_mask: '1. Court ROI',
|
summaryEl.innerHTML = 'Waiting for calibration...';
|
||||||
line_segments: '2. White Segments',
|
stripEl.innerHTML = '';
|
||||||
merged_lines: '3. Merge Lines',
|
pointsImg.removeAttribute('src');
|
||||||
intersections: '4. Intersections',
|
schemeImg.removeAttribute('src');
|
||||||
template_match: '5. Template Match',
|
return;
|
||||||
camera_pose: '6. Camera Pose',
|
|
||||||
overlay: '7. Geometry Overlay'
|
|
||||||
};
|
|
||||||
|
|
||||||
function renderCalibrationSteps(containerId, steps, fallbackError) {
|
|
||||||
var el = document.getElementById(containerId);
|
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
var html = '';
|
|
||||||
for (var i = 0; i < CALIBRATION_STEP_ORDER.length; i++) {
|
|
||||||
var key = CALIBRATION_STEP_ORDER[i];
|
|
||||||
var item = steps && steps[key] ? steps[key] : { status: 'pending', detail: 'No data yet' };
|
|
||||||
var status = item.status || 'pending';
|
|
||||||
var detail = item.detail || '';
|
|
||||||
html += '<div class="flow-step ' + status + '">';
|
|
||||||
html += ' <div class="step-head">';
|
|
||||||
html += ' <div class="step-name">' + CALIBRATION_STEP_LABELS[key] + '</div>';
|
|
||||||
html += ' <div class="step-status">' + status + '</div>';
|
|
||||||
html += ' </div>';
|
|
||||||
html += ' <div class="step-detail">' + detail + '</div>';
|
|
||||||
html += '</div>';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fallbackError) {
|
var report = camResult.point_report || {};
|
||||||
html += '<div class="flow-step fail">';
|
var target = report.target_count || 0;
|
||||||
html += ' <div class="step-head"><div class="step-name">Error</div><div class="step-status">fail</div></div>';
|
var found = report.found_count || 0;
|
||||||
html += ' <div class="step-detail">' + fallbackError + '</div>';
|
var missing = report.missing_count || 0;
|
||||||
html += '</div>';
|
var method = camResult.method ? (' | ' + camResult.method) : '';
|
||||||
|
var ok = !!camResult.ok;
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
summaryEl.innerHTML =
|
||||||
|
'Target points: <b>' + target + '</b> | Found: <b>' + found + '</b> | Missing: <b>' + missing + '</b>' + method;
|
||||||
|
} else {
|
||||||
|
summaryEl.innerHTML =
|
||||||
|
'Target points: <b>' + target + '</b> | Found: <b>' + found + '</b> | Missing: <b>' + missing + '</b><br>' +
|
||||||
|
'<span style="color:#ff8080">' + (camResult.error || 'Calibration failed') + '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
el.innerHTML = html;
|
var chips = '';
|
||||||
|
var points = report.points || [];
|
||||||
|
for (var i = 0; i < points.length; i++) {
|
||||||
|
var p = points[i];
|
||||||
|
var cls = p.found ? 'found' : 'missing';
|
||||||
|
chips += '<span class="point-chip ' + cls + '">' + p.name + '</span>';
|
||||||
|
}
|
||||||
|
stripEl.innerHTML = chips;
|
||||||
|
|
||||||
|
if (camResult.points_overlay_image) {
|
||||||
|
pointsImg.src = 'data:image/jpeg;base64,' + camResult.points_overlay_image;
|
||||||
|
} else if (camResult.debug_image) {
|
||||||
|
pointsImg.src = 'data:image/jpeg;base64,' + camResult.debug_image;
|
||||||
|
} else {
|
||||||
|
pointsImg.removeAttribute('src');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (camResult.schematic_image) {
|
||||||
|
schemeImg.src = 'data:image/jpeg;base64,' + camResult.schematic_image;
|
||||||
|
} else {
|
||||||
|
schemeImg.removeAttribute('src');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCalibrationFlow(result) {
|
function updateCalibrationFlow(result) {
|
||||||
var cam0 = result && result['0'] ? result['0'] : null;
|
var cam0 = result && result['0'] ? result['0'] : null;
|
||||||
var cam1 = result && result['1'] ? result['1'] : null;
|
var cam1 = result && result['1'] ? result['1'] : null;
|
||||||
renderCalibrationSteps('calFlowCam0', cam0 ? cam0.steps : null, cam0 && cam0.error ? cam0.error : '');
|
renderPointReport('0', cam0);
|
||||||
renderCalibrationSteps('calFlowCam1', cam1 ? cam1.steps : null, cam1 && cam1.error ? cam1.error : '');
|
renderPointReport('1', cam1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function doCalibrate() {
|
function doCalibrate() {
|
||||||
@@ -670,10 +689,7 @@ function doCalibrate() {
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.textContent = 'Calibrating...';
|
btn.textContent = 'Calibrating...';
|
||||||
errEl.style.display = 'none';
|
errEl.style.display = 'none';
|
||||||
updateCalibrationFlow({
|
updateCalibrationFlow(null);
|
||||||
'0': { steps: { green_mask: { status: 'pending', detail: 'Running calibration flow...' } } },
|
|
||||||
'1': { steps: { green_mask: { status: 'pending', detail: 'Running calibration flow...' } } }
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch('/api/calibration/trigger', { method: 'POST' })
|
fetch('/api/calibration/trigger', { method: 'POST' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
@@ -681,23 +697,12 @@ function doCalibrate() {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
updateCalibrationFlow(data.result || null);
|
updateCalibrationFlow(data.result || null);
|
||||||
|
|
||||||
// Show debug images from calibration
|
|
||||||
if (data.result) {
|
|
||||||
for (var sid in data.result) {
|
|
||||||
var r = data.result[sid];
|
|
||||||
if (r.debug_image) {
|
|
||||||
var imgEl = document.getElementById('calDebugImg' + sid);
|
|
||||||
if (imgEl) imgEl.src = 'data:image/jpeg;base64,' + r.debug_image;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
btn.textContent = 'Re-run Calibration Flow';
|
btn.textContent = 'Re-run Calibration Flow';
|
||||||
errEl.style.display = 'none';
|
errEl.style.display = 'none';
|
||||||
updateCalibrationStatus();
|
updateCalibrationStatus();
|
||||||
|
|
||||||
// Show computed camera positions and line stats
|
// Show computed camera positions
|
||||||
var posEl = document.getElementById('calPositions');
|
var posEl = document.getElementById('calPositions');
|
||||||
if (posEl && data.result) {
|
if (posEl && data.result) {
|
||||||
posEl.style.display = 'block';
|
posEl.style.display = 'block';
|
||||||
@@ -750,7 +755,7 @@ function doCalibrate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===================== Fullscreen image viewer =====================
|
// ===================== Fullscreen image viewer =====================
|
||||||
document.querySelectorAll('.cal-debug-card img').forEach(function(img) {
|
document.querySelectorAll('.cal-image-box img').forEach(function(img) {
|
||||||
img.addEventListener('click', function() {
|
img.addEventListener('click', function() {
|
||||||
if (!this.src || this.src === location.href) return;
|
if (!this.src || this.src === location.href) return;
|
||||||
document.getElementById('fullscreenImg').src = this.src;
|
document.getElementById('fullscreenImg').src = this.src;
|
||||||
|
|||||||
Reference in New Issue
Block a user