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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 @@
|
||||
<div id="calPos0" style="color:#4ecca3"></div>
|
||||
<div id="calPos1" style="color:#ff88cc"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flow-grid">
|
||||
<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 style="margin-top:8px">
|
||||
<a class="report-link" href="/api/calibration/report" target="_blank" rel="noopener">Open Last Calibration JSON Report</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-debug-images">
|
||||
<div class="cal-debug-card">
|
||||
<div class="cal-debug-label">CAM 0 RESULT</div>
|
||||
<img id="calDebugImg0" alt="CAM 0 calibration result">
|
||||
<div class="cal-report-card">
|
||||
<div class="flow-title">CAM 0 Point Report</div>
|
||||
<div id="calPointSummary0" class="cal-point-summary">Waiting for calibration...</div>
|
||||
<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 class="cal-debug-card">
|
||||
<div class="cal-debug-label">CAM 1 RESULT</div>
|
||||
<img id="calDebugImg1" alt="CAM 1 calibration result">
|
||||
</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>
|
||||
<div class="calibration-3d-wrap">
|
||||
@@ -538,11 +551,11 @@
|
||||
<div class="viewport-3d" id="calibration-3d"></div>
|
||||
</div>
|
||||
<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">
|
||||
1) detect court ROI → 2) detect line segments → 3) merge lines → 4) get intersections →
|
||||
5) match to template points → 6) solve camera pose → 7) overlay reconstructed geometry.
|
||||
Green steps are confirmed, red steps failed, gray steps are pending/skipped.
|
||||
Each camera shows a target point set and found/missing points.
|
||||
Left image: found template points on the real frame.
|
||||
Right image: flat half-court scheme with found/missing markers.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -608,60 +621,66 @@ window.addEventListener('popstate', function() {
|
||||
if (activeTab !== 'camera') switchTab(activeTab);
|
||||
|
||||
// ===================== Calibration =====================
|
||||
var CALIBRATION_STEP_ORDER = [
|
||||
'green_mask',
|
||||
'line_segments',
|
||||
'merged_lines',
|
||||
'intersections',
|
||||
'template_match',
|
||||
'camera_pose',
|
||||
'overlay'
|
||||
];
|
||||
function renderPointReport(camId, camResult) {
|
||||
var summaryEl = document.getElementById('calPointSummary' + camId);
|
||||
var stripEl = document.getElementById('calPointStrip' + camId);
|
||||
var pointsImg = document.getElementById('calPointsImg' + camId);
|
||||
var schemeImg = document.getElementById('calSchemeImg' + camId);
|
||||
if (!summaryEl || !stripEl || !pointsImg || !schemeImg) return;
|
||||
|
||||
var CALIBRATION_STEP_LABELS = {
|
||||
green_mask: '1. Court ROI',
|
||||
line_segments: '2. White Segments',
|
||||
merged_lines: '3. Merge Lines',
|
||||
intersections: '4. Intersections',
|
||||
template_match: '5. Template Match',
|
||||
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 (!camResult) {
|
||||
summaryEl.innerHTML = 'Waiting for calibration...';
|
||||
stripEl.innerHTML = '';
|
||||
pointsImg.removeAttribute('src');
|
||||
schemeImg.removeAttribute('src');
|
||||
return;
|
||||
}
|
||||
|
||||
if (fallbackError) {
|
||||
html += '<div class="flow-step fail">';
|
||||
html += ' <div class="step-head"><div class="step-name">Error</div><div class="step-status">fail</div></div>';
|
||||
html += ' <div class="step-detail">' + fallbackError + '</div>';
|
||||
html += '</div>';
|
||||
var report = camResult.point_report || {};
|
||||
var target = report.target_count || 0;
|
||||
var found = report.found_count || 0;
|
||||
var missing = report.missing_count || 0;
|
||||
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) {
|
||||
var cam0 = result && result['0'] ? result['0'] : null;
|
||||
var cam1 = result && result['1'] ? result['1'] : null;
|
||||
renderCalibrationSteps('calFlowCam0', cam0 ? cam0.steps : null, cam0 && cam0.error ? cam0.error : '');
|
||||
renderCalibrationSteps('calFlowCam1', cam1 ? cam1.steps : null, cam1 && cam1.error ? cam1.error : '');
|
||||
renderPointReport('0', cam0);
|
||||
renderPointReport('1', cam1);
|
||||
}
|
||||
|
||||
function doCalibrate() {
|
||||
@@ -670,10 +689,7 @@ function doCalibrate() {
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Calibrating...';
|
||||
errEl.style.display = 'none';
|
||||
updateCalibrationFlow({
|
||||
'0': { steps: { green_mask: { status: 'pending', detail: 'Running calibration flow...' } } },
|
||||
'1': { steps: { green_mask: { status: 'pending', detail: 'Running calibration flow...' } } }
|
||||
});
|
||||
updateCalibrationFlow(null);
|
||||
|
||||
fetch('/api/calibration/trigger', { method: 'POST' })
|
||||
.then(function(r) { return r.json(); })
|
||||
@@ -681,23 +697,12 @@ function doCalibrate() {
|
||||
btn.disabled = false;
|
||||
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) {
|
||||
btn.textContent = 'Re-run Calibration Flow';
|
||||
errEl.style.display = 'none';
|
||||
updateCalibrationStatus();
|
||||
|
||||
// Show computed camera positions and line stats
|
||||
// Show computed camera positions
|
||||
var posEl = document.getElementById('calPositions');
|
||||
if (posEl && data.result) {
|
||||
posEl.style.display = 'block';
|
||||
@@ -750,7 +755,7 @@ function doCalibrate() {
|
||||
}
|
||||
|
||||
// ===================== 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() {
|
||||
if (!this.src || this.src === location.href) return;
|
||||
document.getElementById('fullscreenImg').src = this.src;
|
||||
|
||||
Reference in New Issue
Block a user