diff --git a/public/js/exhibit.js b/public/js/exhibit.js index 271f1c1..c1a94bf 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -59,7 +59,7 @@ let detector, poseEst, fuser, cvSolver; let gradients = {}, modelManifest = null, characters = { defaults: {}, byId: {} }; let ghostHeight = 4; // cm, from scene.ghostHeightCm const activeGhosts = new Map(); // uid -> { rec, group } -let tracking = { markers: 0, raw: 0, rawIds: [], lastSeen: 0 }; +let tracking = { markers: 0, raw: 0, rawIds: [], dupes: 0, lastSeen: 0 }; let netRef = null; let sceneLoaded = false; let anchorCount = 0; @@ -68,9 +68,17 @@ function rxTrace(t) { rxLog.push(t); if (rxLog.length > 6) rxLog.shift(); } const clockOffsetSamples = []; let clockOffset = 0; // serverNow - clientNow -/* Horizontal FOV assumption for the focal estimate; override for a specific device - * via localStorage 'nbx.hfovDeg' (shared by both engines). */ +/* Field-of-view assumption behind every focal estimate; override per device via + * localStorage 'nbx.hfovDeg' (shared by both engines). + * + * IT APPLIES TO THE LONG AXIS OF THE IMAGE, NOT TO videoWidth. iOS can hand back + * a PORTRAIT stream (720x1280), and a phone lens has its wide field across the + * sensor's long side either way. Assuming the angle spanned videoWidth made the + * focal length 1280/720 = 1.78x too short on portrait streams — the exact factor + * measured on device, and the reason ghosts still slid around after the camera + * model was "fixed". */ const HFOV_DEG = parseFloat(localStorage.getItem('nbx.hfovDeg')) || 60; +const focalPxFor = (w, h) => (Math.max(w, h) / 2) / Math.tan(THREE.MathUtils.degToRad(HFOV_DEG) / 2); /* Detection cadence. Marker detection (getImageData + ArUco decode over a 960px * frame) is by far the most expensive thing per frame, and running it on every @@ -147,7 +155,7 @@ function updateDbg(fusedQuat, markerCount, extra) { dbgPose = `eng ${engine} ${freezeGhosts ? 'FROZEN' : 'moving'}\n` + `${modeLine}\n` + - `markers ${markerCount}/${tracking.raw} seen [${tracking.rawIds.join(',')}]${extra}\n${rawLine}\nview ${v3(view)}\nup ${v3(up)}\n` + + `markers ${markerCount}/${tracking.raw} seen [${tracking.rawIds.join(',')}]${tracking.dupes ? ' +' + tracking.dupes + 'dup' : ''}${extra}\n${rawLine}\nview ${v3(view)}\nup ${v3(up)}\n` + `pos ${v3(camera3.position)}`; paintDbg(); } @@ -346,10 +354,11 @@ function onResize() { * 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. */ + * So: take the focal length implied by HFOV_DEG over the image's LONG axis (see + * focalPxFor — the stream may be portrait), 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; @@ -357,7 +366,7 @@ function updateCameraIntrinsics() { 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 focal = focalPxFor(vw, vh); 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); @@ -463,20 +472,25 @@ function loop(t) { if (video && video.readyState >= 2 && (t - lastDetectAt) >= DETECT_INTERVAL_MS) { lastDetectAt = t; - // downscale for detection speed; corner precision scales with resolution. - const W = 960; - const H = Math.round(W * video.videoHeight / video.videoWidth); - if (canvas2d.width !== W) { canvas2d.width = W; canvas2d.height = H; } + /* Downscale for detection speed, capping the LONG side. Fixing the WIDTH at + * 960 quietly tripled the work on a portrait stream (960x1707 = 1.6M px vs + * 960x540 = 0.5M), which is most of the cost of a frame. */ + const vw = video.videoWidth, vh = video.videoHeight; + const LONG = 960; + const W = vw >= vh ? LONG : Math.round(LONG * vw / vh); + const H = vw >= vh ? Math.round(LONG * vh / vw) : LONG; + if (canvas2d.width !== W || canvas2d.height !== H) { canvas2d.width = W; canvas2d.height = H; } ctx2d.drawImage(video, 0, 0, W, H); const img = ctx2d.getImageData(0, 0, W, H); - const focal = W / (2 * Math.tan(THREE.MathUtils.degToRad(HFOV_DEG) / 2)); + const focal = focalPxFor(W, H); if (!poseEst) poseEst = new PoseEstimator(focal); const markers = detectMarkers(detector, img, fuser.knownIds()); tracking.markers = markers.length; tracking.raw = markers.rawCount || 0; tracking.rawIds = markers.rawIds || []; + tracking.dupes = markers.dupes || 0; if (dbgEl) updateAim(markers, W, H); if (markers.length) { tracking.lastSeen = performance.now();