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:
@@ -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:
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user