diff --git a/public/js/exhibit.js b/public/js/exhibit.js index 7169a8b..b8ab5a9 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -91,7 +91,7 @@ function dbgStatus() { const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none'; return `ws ${ws} rx ${rxLog.join(',') || '-'}\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; } @@ -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 * measured pixel centre against its projected position, and reports the ghost's * 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 - * 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 = ''; const _aimV = new THREE.Vector3(); function updateAim(markers, W, H) { @@ -112,8 +114,10 @@ function updateAim(markers, W, H) { const a = cvSolver.anchors.get(m0.id); let cxp = 0, cyp = 0; for (const c of m0.corners) { cxp += c.x; cyp += c.y; } - cxp = (cxp / 4) / W * 2 - 1; - cyp = -((cyp / 4) / H * 2 - 1); + // full-frame NDC, then rescaled into viewport NDC so it is directly + // 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)})`; if (a) { _aimV.setFromMatrixPosition(a.mat).project(camera3); @@ -225,6 +229,9 @@ async function start() { }); video.srcObject = stream; 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 canvas2d = document.createElement('canvas'); @@ -327,7 +334,37 @@ async function start() { function onResize() { 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; + 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(); } @@ -433,7 +470,7 @@ function loop(t) { tracking.lastSeen = performance.now(); 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); if (fused) { camera3.position.copy(fused.position);