Remove all fallbacks: show errors, draw debug lines on calibration

- Remove try-except in CameraCalibrator — errors propagate to UI
- Remove auto-load of saved calibrations — always start uncalibrated
- Remove hardcoded "Base Setup" card with fake values
- Remove addStereocameras/getCamParams dead code
- Draw all detected Hough lines + corners on debug frame during calibration
- Show debug images in calibration tab after attempt
- Show error messages in UI instead of swallowing them

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Ruslan Bakiev
2026-03-22 14:27:53 +07:00
parent ee73aa80d8
commit e12edab19b
4 changed files with 178 additions and 229 deletions

View File

@@ -39,11 +39,9 @@ def auto_calibrate():
compute camera pose, save to config.
Each camera sees one half of the court from the net position.
We use the court lines visible in each frame to build correspondences.
For now: use a simple approach — detect the 4 most prominent lines
(baseline, two sidelines, kitchen line) and map to known 3D coords.
Returns error if court lines cannot be detected (no fallbacks).
Detects court lines via Hough transform, finds 4 corners,
then uses solvePnP to determine camera position.
Returns debug images with detected lines drawn on them.
"""
results = {}
@@ -55,51 +53,93 @@ def auto_calibrate():
h, w = frame.shape[:2]
side = 'left' if sensor_id == 0 else 'right'
debug_frame = frame.copy()
# Try to detect court lines using edge detection + Hough
corners_pixel = _detect_court_corners(frame, side)
# Detect court lines — returns corners + debug info
detection = _detect_court_corners(frame, side)
# Draw all detected Hough lines on debug frame
if detection and detection.get('all_lines') is not None:
for line in detection['all_lines']:
x1, y1, x2, y2 = line[0]
cv2.line(debug_frame, (x1, y1), (x2, y2), (50, 50, 50), 1)
# Draw classified lines
if detection and detection.get('horizontals'):
for line in detection['horizontals']:
x1, y1, x2, y2 = line
cv2.line(debug_frame, (x1, y1), (x2, y2), (0, 255, 255), 2) # yellow = horizontal
if detection and detection.get('verticals'):
for line in detection['verticals']:
x1, y1, x2, y2 = line
cv2.line(debug_frame, (x1, y1), (x2, y2), (255, 0, 255), 2) # magenta = vertical
# Draw selected 4 lines (top/bottom/left/right)
if detection and detection.get('selected_lines'):
sel = detection['selected_lines']
colors = {'top': (0, 255, 0), 'bottom': (0, 200, 0),
'left': (255, 128, 0), 'right': (200, 100, 0)}
for name, line in sel.items():
x1, y1, x2, y2 = line
cv2.line(debug_frame, (x1, y1), (x2, y2), colors.get(name, (255, 255, 255)), 3)
cv2.putText(debug_frame, name, (x1, y1 - 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors.get(name, (255, 255, 255)), 1)
# Encode debug frame
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
debug_b64 = base64.b64encode(jpeg.tobytes()).decode('ascii')
corners_pixel = detection.get('corners') if detection else None
if corners_pixel is None:
error_detail = detection.get('error', 'Unknown') if detection else 'No lines detected at all'
results[str(sensor_id)] = {
'ok': False,
'error': f'Could not detect court lines for CAM {sensor_id}. '
'Ensure court lines are clearly visible.'
'error': f'CAM {sensor_id}: {error_detail}',
'debug_image': debug_b64,
}
continue
# Draw corners on debug frame
for i, corner in enumerate(corners_pixel):
pt = (int(corner[0]), int(corner[1]))
cv2.circle(debug_frame, pt, 8, (0, 0, 255), -1)
cv2.putText(debug_frame, f'C{i}', (pt[0] + 10, pt[1]),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2)
# Re-encode with corners
_, jpeg = cv2.imencode('.jpg', debug_frame, [cv2.IMWRITE_JPEG_QUALITY, 85])
debug_b64 = base64.b64encode(jpeg.tobytes()).decode('ascii')
# Get known 3D coordinates for this half
corners_3d = get_half_court_3d_points(side)
# Calibrate
# Calibrate — no try/except, let errors propagate
cal = CameraCalibrator()
ok = cal.calibrate(
cal.calibrate(
np.array(corners_pixel, dtype=np.float32),
corners_3d,
w, h
)
if ok:
# Save to config
cal_path = os.path.join(_args.calibration_dir,
f'cam{sensor_id}_calibration.json')
os.makedirs(os.path.dirname(cal_path), exist_ok=True)
cal.save(cal_path)
# Save to config
cal_path = os.path.join(_args.calibration_dir,
f'cam{sensor_id}_calibration.json')
os.makedirs(os.path.dirname(cal_path), exist_ok=True)
cal.save(cal_path)
state['calibrators'][sensor_id] = cal
state['calibrators'][sensor_id] = cal
# Get camera position for 3D scene
cam_pos = (-cal.rotation_matrix.T @ cal.translation_vec).flatten()
# Get camera position for 3D scene
cam_pos = (-cal.rotation_matrix.T @ cal.translation_vec).flatten()
results[str(sensor_id)] = {
'ok': True,
'camera_position': cam_pos.tolist(),
'corners_pixel': corners_pixel.tolist() if isinstance(corners_pixel, np.ndarray)
else corners_pixel,
}
print(f"[CAM {sensor_id}] Calibrated! Camera at "
f"({cam_pos[0]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})")
else:
results[str(sensor_id)] = {'ok': False, 'error': 'Calibration failed'}
results[str(sensor_id)] = {
'ok': True,
'camera_position': cam_pos.tolist(),
'debug_image': debug_b64,
}
print(f"[CAM {sensor_id}] Calibrated! Camera at "
f"({cam_pos[0]:.1f}, {cam_pos[1]:.1f}, {cam_pos[2]:.1f})")
return results
@@ -107,7 +147,13 @@ def auto_calibrate():
def _detect_court_corners(frame, side):
"""Detect court corners from frame using edge detection.
Returns 4 corner points as numpy array, or None if detection fails.
Returns dict with:
corners: 4x2 numpy array or None
all_lines: raw Hough lines
horizontals: classified horizontal lines
verticals: classified vertical lines
selected_lines: the 4 lines used (top/bottom/left/right)
error: description if detection failed
"""
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (5, 5), 0)
@@ -118,7 +164,13 @@ def _detect_court_corners(frame, side):
minLineLength=100, maxLineGap=20)
if lines is None or len(lines) < 4:
return None
n = 0 if lines is None else len(lines)
return {
'corners': None, 'all_lines': lines,
'horizontals': [], 'verticals': [],
'selected_lines': {},
'error': f'Only {n} Hough lines found (need >= 4)',
}
# Classify lines into horizontal and vertical
horizontals = []
@@ -134,20 +186,28 @@ def _detect_court_corners(frame, side):
verticals.append(line[0])
if len(horizontals) < 2 or len(verticals) < 2:
return None
return {
'corners': None, 'all_lines': lines,
'horizontals': [h.tolist() for h in horizontals],
'verticals': [v.tolist() for v in verticals],
'selected_lines': {},
'error': f'{len(horizontals)} horizontal, {len(verticals)} vertical lines (need >= 2 each)',
}
# Cluster lines by position to find the dominant ones
h_positions = sorted(horizontals, key=lambda l: (l[1] + l[3]) / 2)
v_positions = sorted(verticals, key=lambda l: (l[0] + l[2]) / 2)
# Take the most separated horizontal pair (top and bottom court lines)
top_line = h_positions[0]
bottom_line = h_positions[-1]
# Take the most separated vertical pair (left and right sidelines)
left_line = v_positions[0]
right_line = v_positions[-1]
selected = {
'top': top_line.tolist(), 'bottom': bottom_line.tolist(),
'left': left_line.tolist(), 'right': right_line.tolist(),
}
# Find intersections as corner points
def line_intersection(l1, l2):
x1, y1, x2, y2 = l1
@@ -168,9 +228,22 @@ def _detect_court_corners(frame, side):
]
if any(c is None for c in corners):
return None
return {
'corners': None, 'all_lines': lines,
'horizontals': [h.tolist() for h in horizontals],
'verticals': [v.tolist() for v in verticals],
'selected_lines': selected,
'error': 'Lines are parallel — could not find all 4 corner intersections',
}
return np.array(corners, dtype=np.float32)
return {
'corners': np.array(corners, dtype=np.float32),
'all_lines': lines,
'horizontals': [h.tolist() for h in horizontals],
'verticals': [v.tolist() for v in verticals],
'selected_lines': selected,
'error': None,
}
@@ -324,15 +397,10 @@ def main():
ring_buffer = FrameRingBuffer(max_seconds=args.buffer_seconds, fps=args.fps)
# Load calibrations if available
# Start with empty calibrators — user must calibrate via UI
os.makedirs(args.calibration_dir, exist_ok=True)
for sensor_id in [0, 1]:
cal = CameraCalibrator()
cal_path = os.path.join(args.calibration_dir, f'cam{sensor_id}_calibration.json')
if os.path.exists(cal_path):
if cal.load(cal_path):
print(f"[CAM {sensor_id}] Loaded calibration from {cal_path}")
state['calibrators'][sensor_id] = cal
state['calibrators'][sensor_id] = CameraCalibrator()
# Start camera readers
cam_readers = {}

View File

@@ -50,31 +50,27 @@ class CameraCalibrator:
[0, 0, 1]
], dtype=np.float32)
try:
success, self.rotation_vec, self.translation_vec = cv2.solvePnP(
court_corners_3d.astype(np.float32),
court_corners_pixel.astype(np.float32),
self.camera_matrix,
None,
flags=cv2.SOLVEPNP_ITERATIVE
)
if not success:
return False
success, self.rotation_vec, self.translation_vec = cv2.solvePnP(
court_corners_3d.astype(np.float32),
court_corners_pixel.astype(np.float32),
self.camera_matrix,
None,
flags=cv2.SOLVEPNP_ITERATIVE
)
if not success:
raise RuntimeError("solvePnP failed to find camera pose")
self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec)
self.rotation_matrix, _ = cv2.Rodrigues(self.rotation_vec)
# Build ground plane homography (Z=0)
ground_2d = court_corners_3d[:, :2].astype(np.float32)
self.homography = cv2.getPerspectiveTransform(
court_corners_pixel[:4].astype(np.float32),
ground_2d[:4]
)
# Build ground plane homography (Z=0)
ground_2d = court_corners_3d[:, :2].astype(np.float32)
self.homography = cv2.getPerspectiveTransform(
court_corners_pixel[:4].astype(np.float32),
ground_2d[:4]
)
self.calibrated = True
return True
except Exception as e:
print(f"Calibration failed: {e}")
return False
self.calibrated = True
return True
def pixel_to_ground(self, px: float, py: float) -> Optional[Tuple[float, float]]:
"""Project pixel to ground plane (Z=0) using homography."""
@@ -150,20 +146,16 @@ class CameraCalibrator:
json.dump(data, f, indent=2)
def load(self, filepath: str) -> bool:
try:
with open(filepath, 'r') as f:
data = json.load(f)
self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32)
self.rotation_vec = np.array(data['rotation_vector'], dtype=np.float32).reshape(3, 1)
self.translation_vec = np.array(data['translation_vector'], dtype=np.float32).reshape(3, 1)
self.rotation_matrix = np.array(data['rotation_matrix'], dtype=np.float32)
if data.get('homography'):
self.homography = np.array(data['homography'], dtype=np.float32)
self.calibrated = True
return True
except Exception as e:
print(f"Failed to load calibration: {e}")
return False
with open(filepath, 'r') as f:
data = json.load(f)
self.camera_matrix = np.array(data['camera_matrix'], dtype=np.float32)
self.rotation_vec = np.array(data['rotation_vector'], dtype=np.float32).reshape(3, 1)
self.translation_vec = np.array(data['translation_vector'], dtype=np.float32).reshape(3, 1)
self.rotation_matrix = np.array(data['rotation_matrix'], dtype=np.float32)
if data.get('homography'):
self.homography = np.array(data['homography'], dtype=np.float32)
self.calibrated = True
return True
def get_half_court_3d_points(side: str) -> np.ndarray:

View File

@@ -112,11 +112,16 @@ def api_calibration_trigger():
if fn is None:
return jsonify({'ok': False, 'error': 'Calibration not available'}), 500
import traceback
try:
result = fn()
return jsonify({'ok': True, 'result': result})
# Check if any camera failed
any_ok = any(r.get('ok') for r in result.values())
return jsonify({'ok': any_ok, 'result': result})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)}), 500
tb = traceback.format_exc()
print(f"[CALIBRATION ERROR]\n{tb}")
return jsonify({'ok': False, 'error': f'{type(e).__name__}: {e}'}), 500
@app.route('/api/calibration/data')

View File

@@ -332,19 +332,15 @@
<div class="bottom-card"><img id="cal-cam1" alt="Camera 1"></div>
<div class="bottom-card"><img id="cal-cam0" alt="Camera 0"></div>
<div class="cam-card">
<div class="cc-title">Base Setup</div>
<div class="cc-item">Distance <b id="paramPosY">1.0</b>m</div>
<div class="cc-item">Height <b id="paramPosZ">1.0</b>m</div>
<div class="cc-item">Stereo <b id="paramStereo">6</b>cm</div>
<div class="cc-item">Rotation <b id="paramAngle">15</b>°</div>
<div class="cc-item">HFOV <b id="paramHfov">128</b>°</div>
<div class="cc-item">Sensor <b>IMX219</b></div>
<div class="cc-divider"></div>
<div class="cc-title">Calibration</div>
<button class="btn-calibrate" id="btnCalibrate" onclick="doCalibrate()">Calibrate</button>
<div class="calibrate-status" id="calStatus">
<span id="calStatusText">Not calibrated</span>
</div>
<div id="calError" style="color:#ff4444;font-size:8px;word-break:break-all;display:none"></div>
</div>
<div class="bottom-card" id="calDebug0" style="display:none"><img id="calDebugImg0" alt="CAM 0 debug"></div>
<div class="bottom-card" id="calDebug1" style="display:none"><img id="calDebugImg1" alt="CAM 1 debug"></div>
</div>
</div>
</div>
@@ -411,15 +407,34 @@ if (activeTab !== 'camera') switchTab(activeTab);
// ===================== Calibration =====================
function doCalibrate() {
var btn = document.getElementById('btnCalibrate');
var errEl = document.getElementById('calError');
btn.disabled = true;
btn.textContent = 'Calibrating...';
errEl.style.display = 'none';
fetch('/api/calibration/trigger', { method: 'POST' })
.then(function(r) { return r.json(); })
.then(function(data) {
btn.disabled = false;
// Show debug images from calibration
if (data.result) {
for (var sid in data.result) {
var r = data.result[sid];
if (r.debug_image) {
var dbgEl = document.getElementById('calDebug' + sid);
var imgEl = document.getElementById('calDebugImg' + sid);
if (dbgEl && imgEl) {
imgEl.src = 'data:image/jpeg;base64,' + r.debug_image;
dbgEl.style.display = 'block';
}
}
}
}
if (data.ok) {
btn.textContent = 'Re-calibrate';
errEl.style.display = 'none';
updateCalibrationStatus();
fetch('/api/calibration/data')
@@ -427,19 +442,23 @@ function doCalibrate() {
.then(function(camData) { addCamerasToScene(camData); });
} else {
btn.textContent = 'Calibrate';
// Show errors
var errors = [];
if (data.result) {
for (var sid in data.result) {
if (!data.result[sid].ok) errors.push('CAM ' + sid + ': ' + data.result[sid].error);
if (!data.result[sid].ok) errors.push(data.result[sid].error);
}
}
alert('Calibration failed:\n' + (errors.join('\n') || data.error || 'Unknown error'));
var msg = errors.join(' | ') || data.error || 'Unknown error';
errEl.textContent = msg;
errEl.style.display = 'block';
}
})
.catch(function(e) {
btn.disabled = false;
btn.textContent = 'Calibrate';
alert('Error: ' + e);
errEl.textContent = String(e);
errEl.style.display = 'block';
});
}
@@ -582,9 +601,6 @@ function initCourtScene() {
courtScene.add(new THREE.AmbientLight(0xffffff, 0.8));
// Physical camera marker: 1m from net, center, 1m height
addStereocameras(courtScene, getCamParams());
// Load existing calibration cameras
fetch('/api/calibration/data')
.then(function(r) { return r.json(); })
@@ -683,9 +699,6 @@ function initTrajectoryScene() {
trajScene.add(new THREE.AmbientLight(0xffffff, 0.8));
// Physical camera marker: 1m from net, center, 1m height
addStereocameras(trajScene, getCamParams());
function animateTraj() {
requestAnimationFrame(animateTraj);
controls.update();
@@ -702,115 +715,6 @@ function initTrajectoryScene() {
});
}
// ===================== Stereo camera rig with court coverage =====================
// Track camera overlay objects for live update
var camOverlayObjects = [];
function addStereocameras(scene, params) {
params = params || {};
var baseX = 6.7;
var baseY = -(params.posY || 1);
var baseZ = params.posZ || 1;
var stereoGap = (params.stereo || 6) / 100;
var camAngle = params.angle || 15;
var hfov = params.hfov || 128;
// Remove old overlay objects
camOverlayObjects.forEach(function(obj) { scene.remove(obj); });
camOverlayObjects = [];
function addObj(obj) {
scene.add(obj);
camOverlayObjects.push(obj);
}
var cam0x = baseX - stereoGap / 2;
var cam1x = baseX + stereoGap / 2;
// Small camera bodies
function drawCamBody(cx, color) {
var body = new THREE.Mesh(
new THREE.BoxGeometry(0.12, 0.08, 0.08),
new THREE.MeshBasicMaterial({ color: color })
);
body.position.set(cx, baseY, baseZ);
addObj(body);
}
drawCamBody(cam0x, 0x44aaff);
drawCamBody(cam1x, 0xff44aa);
// Pole
var poleGeo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(baseX, baseY, 0),
new THREE.Vector3(baseX, baseY, baseZ)
]);
addObj(new THREE.Line(poleGeo, new THREE.LineBasicMaterial({ color: 0x666666 })));
// Court boundaries for clipping
var courtMinX = 0, courtMaxX = 13.4, courtMinY = 0, courtMaxY = 6.1;
var deg2rad = Math.PI / 180;
var halfFov = hfov / 2;
function drawCoverage(cx, angleDeg, color) {
var centerAngle = 90 + angleDeg;
var leftAngle = centerAngle + halfFov;
var rightAngle = centerAngle - halfFov;
var ox = cx, oy = baseY;
// Cast ray, return farthest point on court boundary
function castRay(aDeg) {
var rad = aDeg * deg2rad;
var dx = Math.cos(rad);
var dy = Math.sin(rad);
if (dy <= 0) return null;
// t where ray enters court (Y=0)
var tEnter = (courtMinY - oy) / dy;
// t where ray exits court
var tExit = (courtMaxY - oy) / dy;
// clip X bounds
if (dx > 1e-9) tExit = Math.min(tExit, (courtMaxX - ox) / dx);
else if (dx < -1e-9) tExit = Math.min(tExit, (courtMinX - ox) / dx);
if (tExit <= tEnter) return null;
var px = ox + dx * tExit;
var py = oy + dy * tExit;
px = Math.max(courtMinX, Math.min(courtMaxX, px));
py = Math.max(courtMinY, Math.min(courtMaxY, py));
return new THREE.Vector3(px, py, 0.015);
}
// Camera actual position (behind court at Y=-1)
var camOnCourt = new THREE.Vector3(cx, oy, 0.015);
// Far edge points
var farPoints = [];
var steps = 64;
for (var i = 0; i <= steps; i++) {
var a = rightAngle + (leftAngle - rightAngle) * (i / steps);
var pt = castRay(a);
if (pt) farPoints.push(pt);
}
if (farPoints.length < 2) return;
// Draw triangle fan from camera court position to far edge
var mat = new THREE.MeshBasicMaterial({
color: color, transparent: true, opacity: 0.35,
side: THREE.DoubleSide, depthWrite: false
});
for (var i = 0; i < farPoints.length - 1; i++) {
var triGeo = new THREE.BufferGeometry().setFromPoints([
camOnCourt, farPoints[i], farPoints[i + 1]
]);
addObj(new THREE.Mesh(triGeo, mat));
}
}
drawCoverage(cam0x, -camAngle, 0x4488ff); // blue
drawCoverage(cam1x, camAngle, 0xff44aa); // pink
// overlap blends to purple naturally
}
// ===================== Draw court lines =====================
function drawCourtLines(scene) {
@@ -983,26 +887,6 @@ setInterval(function() {
}).catch(function() {});
}, 1000);
// ===================== Camera params live update =====================
function getCamParams() {
return {
posY: parseFloat(document.getElementById('paramPosY').textContent) || 1,
posZ: parseFloat(document.getElementById('paramPosZ').textContent) || 1,
stereo: parseFloat(document.getElementById('paramStereo').textContent) || 6,
angle: parseFloat(document.getElementById('paramAngle').textContent) || 15,
hfov: parseFloat(document.getElementById('paramHfov').textContent) || 128
};
}
function setCamParams(p) {
document.getElementById('paramPosY').textContent = p.posY;
document.getElementById('paramPosZ').textContent = p.posZ;
document.getElementById('paramStereo').textContent = p.stereo;
document.getElementById('paramAngle').textContent = p.angle;
document.getElementById('paramHfov').textContent = p.hfov;
if (courtSceneInitialized) addStereocameras(courtScene, p);
if (trajSceneInitialized) addStereocameras(trajScene, p);
}
</script>
</body>
</html>