Make calibration report point-centric with visual overlays

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

View File

@@ -9,6 +9,7 @@ import os
import cv2
import time
import base64
import json
import argparse
import threading
import numpy as np
@@ -47,6 +48,158 @@ def _init_calibration_steps():
}
def _build_point_report(side, matched_image_points=None):
"""Build report of expected template points and which were matched."""
template = get_half_court_intersections(side)
matched_image_points = matched_image_points or {}
points = []
found_count = 0
for name, world_pt in template.items():
img_pt = matched_image_points.get(name)
is_found = img_pt is not None
if is_found:
found_count += 1
points.append({
'name': name,
'found': is_found,
'world_xyz': [float(world_pt[0]), float(world_pt[1]), float(world_pt[2])],
'image_xy': [float(img_pt[0]), float(img_pt[1])] if is_found else None,
})
target_count = len(points)
return {
'target_count': target_count,
'found_count': found_count,
'missing_count': target_count - found_count,
'points': points,
}
def _render_point_schematic(side, point_report):
"""Render flat half-court scheme with found/missing points."""
width = 760
height = 460
margin = 48
canvas = np.zeros((height, width, 3), dtype=np.uint8)
canvas[:] = (12, 18, 32)
# Half-court dimensions
half_len = HALF_COURT_LENGTH # 6.7m
court_w = COURT_WIDTH # 6.1m
kitchen_x = HALF_COURT_LENGTH - 2.13 # 4.57m from baseline
draw_w = width - 2 * margin
draw_h = height - 2 * margin
def to_px(local_x, y):
px = int(margin + (local_x / half_len) * draw_w)
py = int(margin + (y / court_w) * draw_h)
return px, py
# Court frame
tl = to_px(0, 0)
br = to_px(half_len, court_w)
cv2.rectangle(canvas, tl, br, (200, 200, 200), 2)
# Kitchen and center service line
p1 = to_px(kitchen_x, 0)
p2 = to_px(kitchen_x, court_w)
cv2.line(canvas, p1, p2, (180, 180, 180), 1)
c1 = to_px(0, court_w / 2)
c2 = to_px(kitchen_x, court_w / 2)
cv2.line(canvas, c1, c2, (140, 140, 140), 1)
# Net line
n1 = to_px(half_len, 0)
n2 = to_px(half_len, court_w)
cv2.line(canvas, n1, n2, (100, 160, 240), 2)
# Helper: convert world X to local baseline->net axis for each side
def to_local_x(world_x):
if side == 'left':
return world_x
return COURT_LENGTH - world_x
for point in point_report.get('points', []):
world = point.get('world_xyz') or [0.0, 0.0, 0.0]
local_x = to_local_x(world[0])
local_y = world[1]
px, py = to_px(local_x, local_y)
color = (70, 220, 130) if point.get('found') else (80, 80, 230)
cv2.circle(canvas, (px, py), 8, color, -1)
cv2.circle(canvas, (px, py), 12, color, 1)
cv2.putText(canvas, point.get('name', '?'), (px + 10, py - 8),
cv2.FONT_HERSHEY_SIMPLEX, 0.42, color, 1)
summary = f"Target: {point_report.get('target_count', 0)} Found: {point_report.get('found_count', 0)} Missing: {point_report.get('missing_count', 0)}"
cv2.putText(canvas, summary, (margin, height - 14),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (220, 220, 220), 1)
ok, jpg = cv2.imencode('.jpg', canvas, [cv2.IMWRITE_JPEG_QUALITY, 90])
if not ok:
return None
return base64.b64encode(jpg.tobytes()).decode('ascii')
def _render_points_on_frame(frame, point_report):
"""Render only detected template points on top of the camera frame."""
overlay = frame.copy()
found_count = 0
for point in point_report.get('points', []):
img_pt = point.get('image_xy')
if not img_pt:
continue
found_count += 1
x = int(img_pt[0])
y = int(img_pt[1])
cv2.circle(overlay, (x, y), 9, (70, 220, 130), 2)
cv2.circle(overlay, (x, y), 3, (70, 220, 130), -1)
cv2.putText(overlay, point.get('name', '?'), (x + 12, y - 6),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (70, 220, 130), 1)
title = f"Template points: {found_count}/{point_report.get('target_count', 0)}"
cv2.rectangle(overlay, (8, 8), (360, 36), (0, 0, 0), -1)
cv2.putText(overlay, title, (14, 28),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (200, 255, 220), 1)
ok, jpg = cv2.imencode('.jpg', overlay, [cv2.IMWRITE_JPEG_QUALITY, 88])
if not ok:
return None
return base64.b64encode(jpg.tobytes()).decode('ascii')
def _save_calibration_report(report_payload):
"""Persist last calibration report so it can be read outside UI."""
if not _args or not getattr(_args, 'calibration_dir', None):
return
report_dir = os.path.join(_args.calibration_dir, 'calibration_reports')
os.makedirs(report_dir, exist_ok=True)
# Save compact JSON without large base64 payloads.
compact = {
'timestamp': report_payload.get('timestamp'),
'result': {},
}
for sid, entry in (report_payload.get('result') or {}).items():
clean = dict(entry)
clean.pop('debug_image', None)
clean.pop('points_overlay_image', None)
clean.pop('schematic_image', None)
compact['result'][sid] = clean
latest_path = os.path.join(report_dir, 'latest.json')
ts_path = os.path.join(report_dir, f"report_{int(time.time())}.json")
for path in [latest_path, ts_path]:
try:
with open(path, 'w') as f:
json.dump(compact, f, indent=2)
except Exception as exc:
print(f"[CALIBRATION] Failed to write report {path}: {exc}")
def auto_calibrate():
"""Calibrate cameras by detecting court line intersections and matching
them to the known pickleball court template.
@@ -64,9 +217,11 @@ def auto_calibrate():
results = {}
for sensor_id, reader in _cam_readers.items():
side = 'left' if sensor_id == 0 else 'right'
steps = _init_calibration_steps()
frame = reader.grab()
if frame is None:
point_report = _build_point_report(side)
steps['green_mask'] = {'status': 'fail', 'detail': 'No frame available'}
steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'}
steps['merged_lines'] = {'status': 'fail', 'detail': 'Calibration aborted'}
@@ -78,11 +233,13 @@ def auto_calibrate():
'ok': False,
'error': f'CAM {sensor_id}: No frame available',
'steps': steps,
'point_report': point_report,
'schematic_image': _render_point_schematic(side, point_report),
'points_overlay_image': None,
}
continue
h, w = frame.shape[:2]
side = 'left' if sensor_id == 0 else 'right'
debug_frame = frame.copy()
# Step 1: Detect green court mask
@@ -93,6 +250,7 @@ def auto_calibrate():
green_pct = np.count_nonzero(green_mask) / (w * h) * 100
if green_pct < 5:
point_report = _build_point_report(side)
steps['green_mask'] = {'status': 'fail', 'detail': f'Green area too small: {green_pct:.1f}%'}
steps['line_segments'] = {'status': 'fail', 'detail': 'Calibration aborted'}
steps['merged_lines'] = {'status': 'fail', 'detail': 'Calibration aborted'}
@@ -108,6 +266,9 @@ def auto_calibrate():
'error': f'CAM {sensor_id}: Green area too small ({green_pct:.0f}%)',
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
'steps': steps,
'point_report': point_report,
'schematic_image': _render_point_schematic(side, point_report),
'points_overlay_image': _render_points_on_frame(debug_frame, point_report),
}
continue
steps['green_mask'] = {'status': 'ok', 'detail': f'Green area: {green_pct:.1f}%'}
@@ -153,6 +314,7 @@ def auto_calibrate():
if quad is not None:
cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h)
if cal is not None:
point_report = _build_point_report(side)
steps['template_match'] = {
'status': 'ok',
'detail': 'Low intersections, used quad fallback mapping'
@@ -170,9 +332,11 @@ def auto_calibrate():
n_lines=len(merged),
n_intersections=len(intersections),
n_points_matched=0,
point_report=point_report,
)
continue
point_report = _build_point_report(side)
steps['template_match'] = {'status': 'fail', 'detail': 'Not enough intersections for template fit'}
steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'}
steps['overlay'] = {'status': 'fail', 'detail': 'No calibration output'}
@@ -184,6 +348,9 @@ def auto_calibrate():
'error': f'CAM {sensor_id}: {len(intersections)} intersections (need 4+) from {len(merged)} lines',
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
'steps': steps,
'point_report': point_report,
'schematic_image': _render_point_schematic(side, point_report),
'points_overlay_image': _render_points_on_frame(debug_frame, point_report),
}
continue
@@ -197,6 +364,7 @@ def auto_calibrate():
if quad is not None:
cal, cam_pos, mapping_info = _calibrate_from_quad(quad, side, w, h)
if cal is not None:
point_report = _build_point_report(side)
steps['template_match'] = {
'status': 'ok',
'detail': 'Template match weak, used quad fallback mapping'
@@ -214,9 +382,11 @@ def auto_calibrate():
n_lines=len(merged),
n_intersections=len(intersections),
n_points_matched=0,
point_report=point_report,
)
continue
point_report = _build_point_report(side)
steps['template_match'] = {'status': 'fail', 'detail': 'Could not match intersections to template'}
steps['camera_pose'] = {'status': 'fail', 'detail': 'solvePnP skipped'}
steps['overlay'] = {'status': 'fail', 'detail': 'No calibration output'}
@@ -228,10 +398,14 @@ def auto_calibrate():
'error': f'CAM {sensor_id}: Could not match court pattern',
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
'steps': steps,
'point_report': point_report,
'schematic_image': _render_point_schematic(side, point_report),
'points_overlay_image': _render_points_on_frame(debug_frame, point_report),
}
continue
n_matched = len(match['image_points'])
point_report = _build_point_report(side, match['image_points'])
steps['template_match'] = {
'status': 'ok',
'detail': f'Matched {n_matched}/{len(template)} template points'
@@ -259,6 +433,9 @@ def auto_calibrate():
'error': f'CAM {sensor_id}: solvePnP failed ({exc})',
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
'steps': steps,
'point_report': point_report,
'schematic_image': _render_point_schematic(side, point_report),
'points_overlay_image': _render_points_on_frame(debug_frame, point_report),
}
continue
@@ -288,14 +465,22 @@ def auto_calibrate():
n_lines=len(merged),
n_intersections=len(intersections),
n_points_matched=n_matched,
point_report=point_report,
)
report_payload = {
'timestamp': time.time(),
'result': results,
}
state['last_calibration_report'] = report_payload
_save_calibration_report(report_payload)
return results
def _finalize_calibration(cal, cam_pos, sensor_id, side, debug_frame,
results, method_info, steps, n_segments, n_lines,
n_intersections, n_points_matched):
n_intersections, n_points_matched, point_report):
"""Save calibration and build result dict."""
steps['camera_pose'] = {
'status': 'ok',
@@ -322,11 +507,16 @@ def _finalize_calibration(cal, cam_pos, sensor_id, side, debug_frame,
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
matched_lines_3d = _get_half_court_lines_3d(side)
schematic_image = _render_point_schematic(side, point_report)
points_overlay_image = _render_points_on_frame(debug_frame, point_report)
results[str(sensor_id)] = {
'ok': True,
'camera_position': cam_pos.tolist(),
'debug_image': base64.b64encode(jpeg.tobytes()).decode('ascii'),
'points_overlay_image': points_overlay_image,
'schematic_image': schematic_image,
'point_report': point_report,
'matched_lines_3d': matched_lines_3d,
'points_matched': n_points_matched,
'intersections_detected': n_intersections,

View File

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

View File

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