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

View File

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

View File

@@ -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-report-card">
<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 class="cal-debug-card">
<div class="cal-debug-label">CAM 1 RESULT</div>
<img id="calDebugImg1" alt="CAM 1 calibration result">
</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;