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

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