fix: derive camera FOV from the video and the object-fit:cover crop (was hardcoded 60deg vertical vs the video's 36deg — measured 1.7x projection error); aim check now compares in viewport NDC

This commit is contained in:
2026-08-20 16:08:02 +10:00
parent c4709bd6f8
commit 06816c97c3
+43 -6
View File
@@ -91,7 +91,7 @@ function dbgStatus() {
const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none'; const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none';
return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` + return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` +
`scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n` + `scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n` +
`${fps} fps detect ${DETECT_HZ}Hz\n`; `${fps} fps detect ${DETECT_HZ}Hz fov ${camera3 ? camera3.fov.toFixed(0) : '?'}\n`;
} }
function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; } function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; }
@@ -99,9 +99,11 @@ function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbg
* camera frame sits where the solved pose projects it. Compares the marker's * camera frame sits where the solved pose projects it. Compares the marker's
* measured pixel centre against its projected position, and reports the ghost's * measured pixel centre against its projected position, and reports the ghost's
* on-screen NDC. |ndc| < 1 means the ghost is inside the frustum. * on-screen NDC. |ndc| < 1 means the ghost is inside the frustum.
* px = where the crest actually is in frame (-1..1, 0 = centre) * px = where the crest actually is on screen (-1..1, 0 = centre)
* proj= where the pose says it should be * proj= where the pose says it should be
* Those two disagreeing is a wrong frame convention, not tracking noise. */ * Those two disagreeing is a camera-model or frame-convention error, not
* tracking noise: a sign flip means a mirrored axis, a constant ratio means the
* projection does not match the lens. */
let dbgAim = ''; let dbgAim = '';
const _aimV = new THREE.Vector3(); const _aimV = new THREE.Vector3();
function updateAim(markers, W, H) { function updateAim(markers, W, H) {
@@ -112,8 +114,10 @@ function updateAim(markers, W, H) {
const a = cvSolver.anchors.get(m0.id); const a = cvSolver.anchors.get(m0.id);
let cxp = 0, cyp = 0; let cxp = 0, cyp = 0;
for (const c of m0.corners) { cxp += c.x; cyp += c.y; } for (const c of m0.corners) { cxp += c.x; cyp += c.y; }
cxp = (cxp / 4) / W * 2 - 1; // full-frame NDC, then rescaled into viewport NDC so it is directly
cyp = -((cyp / 4) / H * 2 - 1); // comparable with proj (the cropped sides are not on screen)
cxp = ((cxp / 4) / W * 2 - 1) / _crop.x;
cyp = -((cyp / 4) / H * 2 - 1) / _crop.y;
s += `m${m0.id} px(${cxp.toFixed(2)},${cyp.toFixed(2)})`; s += `m${m0.id} px(${cxp.toFixed(2)},${cyp.toFixed(2)})`;
if (a) { if (a) {
_aimV.setFromMatrixPosition(a.mat).project(camera3); _aimV.setFromMatrixPosition(a.mat).project(camera3);
@@ -225,6 +229,9 @@ async function start() {
}); });
video.srcObject = stream; video.srcObject = stream;
await video.play(); await video.play();
// dimensions can land after play() resolves on iOS; recompute when they do
video.addEventListener('loadedmetadata', updateCameraIntrinsics);
video.addEventListener('resize', updateCameraIntrinsics);
// detection canvas // detection canvas
canvas2d = document.createElement('canvas'); canvas2d = document.createElement('canvas');
@@ -327,7 +334,37 @@ async function start() {
function onResize() { function onResize() {
renderer.setSize(innerWidth, innerHeight); renderer.setSize(innerWidth, innerHeight);
updateCameraIntrinsics();
}
/* Match the virtual camera to the video the user can actually SEE.
*
* Two things were wrong before: the camera was hardcoded to a 60 degree
* VERTICAL fov, while a 1280x720 feed at ~60 degrees HORIZONTAL is only ~36
* degrees vertically; and #cam is `object-fit: cover`, so on a portrait screen
* the sides of the frame are cropped away and never seen. Both make world
* geometry project too far from centre — measured at 1.7x on device, which
* reads as ghosts sliding the wrong way when you tilt.
*
* So: take the focal length implied by HFOV_DEG over the full video width, work
* out how much of the frame survives the cover-crop, and set the fov from that
* visible height. Cover crops symmetrically, so the principal point stays
* centred and no lens shift is needed. */
const _crop = { x: 1, y: 1 }; // fraction of the video width/height still visible
function updateCameraIntrinsics() {
if (!camera3) return;
camera3.aspect = innerWidth / innerHeight; camera3.aspect = innerWidth / innerHeight;
if (!video || !video.videoWidth) { camera3.updateProjectionMatrix(); return; }
const vw = video.videoWidth, vh = video.videoHeight;
const focal = (vw / 2) / Math.tan(THREE.MathUtils.degToRad(HFOV_DEG) / 2);
const scale = Math.max(innerWidth / vw, innerHeight / vh); // object-fit: cover
const visW = Math.min(vw, innerWidth / scale);
const visH = Math.min(vh, innerHeight / scale);
_crop.x = visW / vw;
_crop.y = visH / vh;
camera3.fov = 2 * Math.atan((visH / 2) / focal) * 180 / Math.PI;
camera3.updateProjectionMatrix(); camera3.updateProjectionMatrix();
} }
@@ -433,7 +470,7 @@ function loop(t) {
tracking.lastSeen = performance.now(); tracking.lastSeen = performance.now();
if (engine === 'opencv' && cvState === 'ready') { if (engine === 'opencv' && cvState === 'ready') {
// v3 path: one joint solve over every visible crest (runs in the worker) // v3 path: one joint solve over every visible crest
const fused = cvSolver.solve(markers, W, H, focal); const fused = cvSolver.solve(markers, W, H, focal);
if (fused) { if (fused) {
camera3.position.copy(fused.position); camera3.position.copy(fused.position);