From 83976205d695a263b0646b2a1310f4ee48bb1e6f Mon Sep 17 00:00:00 2001 From: jessikitty Date: Mon, 24 Aug 2026 11:47:50 +1000 Subject: [PATCH] =?UTF-8?q?fix:=20reject=20the=20mirrored=20coplanar-ambig?= =?UTF-8?q?uity=20pose=20=E2=80=94=20camera-below-sheet=20and=20teleport?= =?UTF-8?q?=20gates,=20reproj=20gate=208px=20->=204px,=20rejection=20reaso?= =?UTF-8?q?n=20reported?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/ar/cv-worker.js | 61 ++++++++++++++++++++++++++++++++------- 1 file changed, 51 insertions(+), 10 deletions(-) diff --git a/public/js/ar/cv-worker.js b/public/js/ar/cv-worker.js index 83ccde9..dc4322f 100644 --- a/public/js/ar/cv-worker.js +++ b/public/js/ar/cv-worker.js @@ -8,8 +8,8 @@ * * Protocol (main <-> worker): * -> { type:'init' } <- { type:'ready' } | { type:'failed', msg } - * -> { type:'solve', id, obj, img, n, W,H,f } <- { type:'pose', id, ok, C, m3, reproj, n } - * -> { type:'reset' } (drop temporal warm start) + * -> { type:'solve', id, obj, img, n, W,H,f } <- { type:'pose', id, ok, C, m3, reproj, n, why } + * -> { type:'reset' } (drop temporal warm start + gate state) * obj/img are plain Float64Array payloads (transferred, so no copy). * The worker never touches three.js: it returns the camera position and the * 3x3 world-from-camera rotation, and the client builds the quaternion. @@ -17,8 +17,28 @@ let cv = null; let prev = null; // { r:[3], t:[3], at } temporal warm start +let lastGood = null; // { C:[3], at } last ACCEPTED camera position +let rejectStreak = 0; const PREV_TTL = 1500; -const MAX_REPROJ_PX = 8; + +/* Sanity gates. Every crest on a printed sheet is coplanar, and a coplanar point + * set has TWO poses that reproject almost equally well — the true one and a + * mirrored twin. With only one or two crests in frame, solvePnP intermittently + * returns the twin, which on device looks like ghosts snapping to the wrong side + * and vanishing. Reprojection error alone can't separate them (the twin fits the + * pixels), so we reject on physics instead: + * - the camera is always ABOVE the sheet (world +Y); the twin usually isn't + * - a phone can't teleport 20cm between frames 50ms apart + * The streak escape hatch stops a bad lastGood from locking tracking out. + * + * These gates SUPPRESS the symptom. The cure is non-coplanar geometry: one crest + * standing vertical removes the ambiguity outright, because no mirrored pose can + * fit points that don't share a plane. */ +const MAX_REPROJ_PX = 4; // was 8 — that accepted visibly wrong poses +const MIN_CAM_Y_CM = 1; // camera below the sheet is never real +const MAX_JUMP_CM = 20; // per accepted-pose gap, scaled by elapsed time +const JUMP_GRACE_MS = 500; +const MAX_REJECT_STREAK = 12; // reusable Mats — allocating 7 Mats per frame was a measurable cost let bufN = 0; @@ -57,7 +77,7 @@ function solve(msg) { let ok = false; try { ok = cv.solvePnP(objM, imgM, K, dist, rvec, tvec, false, cv.SOLVEPNP_SQPNP); } catch { ok = false; } if (!ok) { try { ok = cv.solvePnP(objM, imgM, K, dist, rvec, tvec, false, cv.SOLVEPNP_IPPE); } catch { ok = false; } } - if (!ok) return { ok: false }; + if (!ok) return reject('nosolve', 0); cv.solvePnP(objM, imgM, K, dist, rvec, tvec, true, cv.SOLVEPNP_ITERATIVE); } @@ -68,9 +88,7 @@ function solve(msg) { err += Math.hypot(proj.data64F[2 * i] - img[2 * i], proj.data64F[2 * i + 1] - img[2 * i + 1]); } err /= n; - if (err > MAX_REPROJ_PX) { prev = null; return { ok: false, reproj: err }; } - - prev = { r: [...rvec.data64F], t: [...tvec.data64F], at: Date.now() }; + if (err > MAX_REPROJ_PX) { prev = null; return reject('reproj', err); } cv.Rodrigues(rvec, R); const d = R.data64F; // row-major world->cvCam @@ -81,9 +99,32 @@ function solve(msg) { -(d[1] * t[0] + d[4] * t[1] + d[7] * t[2]), -(d[2] * t[0] + d[5] * t[1] + d[8] * t[2]), ]; + + // ---- gates ---- + const now = Date.now(); + const forced = rejectStreak >= MAX_REJECT_STREAK; // escape hatch + if (!forced) { + if (C[1] < MIN_CAM_Y_CM) { prev = null; return reject('below', err); } + if (lastGood && (now - lastGood.at) < JUMP_GRACE_MS) { + const dt = Math.max(1, now - lastGood.at) / 1000; + const jump = Math.hypot(C[0] - lastGood.C[0], C[1] - lastGood.C[1], C[2] - lastGood.C[2]); + // allowance grows with elapsed time so real movement is never blocked + if (jump > MAX_JUMP_CM * Math.max(1, dt * 4)) { prev = null; return reject('jump', err); } + } + } + + rejectStreak = 0; + prev = { r: [...rvec.data64F], t: [...tvec.data64F], at: now }; + lastGood = { C, at: now }; + // world-from-threeCam = R^T * diag(1,-1,-1), row-major for Matrix4.set const m3 = [d[0], -d[3], -d[6], d[1], -d[4], -d[7], d[2], -d[5], -d[8]]; - return { ok: true, C, m3, reproj: err }; + return { ok: true, C, m3, reproj: err, forced }; +} + +function reject(why, reproj) { + rejectStreak++; + return { ok: false, why, reproj }; } self.onmessage = (ev) => { @@ -119,13 +160,13 @@ self.onmessage = (ev) => { return; } - if (msg.type === 'reset') { prev = null; return; } + if (msg.type === 'reset') { prev = null; lastGood = null; rejectStreak = 0; return; } if (msg.type === 'solve') { if (!cv) return self.postMessage({ type: 'pose', id: msg.id, ok: false }); let out; try { out = solve(msg); } - catch (e) { prev = null; out = { ok: false }; } + catch (e) { prev = null; out = { ok: false, why: 'throw' }; } self.postMessage({ type: 'pose', id: msg.id, n: msg.n, ...out }); } };