/* cv-worker.js — OpenCV board solve, off the main thread. * * WHY A WORKER: the opencv.js build embeds its ~8MB WASM as a base64 data URI * inside a 10.8MB script. Loading it on the main thread means parsing that * script, decoding the base64, and compiling the WASM synchronously — seconds * of frozen UI and a "page unresponsive" prompt on mobile. In a worker the * whole cost is off-thread, and per-frame solvePnP goes with it. * * 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, 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. */ 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; /* 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; let objM = null, imgM = null, rvec = null, tvec = null, R = null, proj = null, jac = null, K = null, dist = null; let kW = 0, kH = 0, kF = 0; function ensureBuffers(n) { if (bufN === n) return; for (const m of [objM, imgM]) if (m) m.delete(); objM = new cv.Mat(n, 3, cv.CV_64F); imgM = new cv.Mat(n, 2, cv.CV_64F); bufN = n; } function ensureIntrinsics(W, H, f) { if (K && kW === W && kH === H && kF === f) return; if (K) { K.delete(); dist.delete(); } K = cv.matFromArray(3, 3, cv.CV_64F, [f, 0, W / 2, 0, f, H / 2, 0, 0, 1]); dist = cv.Mat.zeros(4, 1, cv.CV_64F); kW = W; kH = H; kF = f; } function solve(msg) { const { obj, img, n, W, H, f } = msg; ensureIntrinsics(W, H, f); ensureBuffers(n); objM.data64F.set(obj); imgM.data64F.set(img); const fresh = prev && (Date.now() - prev.at) < PREV_TTL; if (fresh) { // warm start: iterative LM from the last frame. Fast, and it resolves // single-marker planar flips by temporal continuity. rvec.data64F.set(prev.r); tvec.data64F.set(prev.t); cv.solvePnP(objM, imgM, K, dist, rvec, tvec, true, cv.SOLVEPNP_ITERATIVE); } else { 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 reject('nosolve', 0); cv.solvePnP(objM, imgM, K, dist, rvec, tvec, true, cv.SOLVEPNP_ITERATIVE); } // reprojection gate — rejects poisoned frames before they reach the filter cv.projectPoints(objM, rvec, tvec, K, dist, proj, jac); let err = 0; for (let i = 0; i < n; i++) { 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 reject('reproj', err); } cv.Rodrigues(rvec, R); const d = R.data64F; // row-major world->cvCam const t = tvec.data64F; // camera position in world: C = -R^T t const C = [ -(d[0] * t[0] + d[3] * t[1] + d[6] * t[2]), -(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, forced }; } function reject(why, reproj) { rejectStreak++; return { ok: false, why, reproj }; } self.onmessage = (ev) => { const msg = ev.data; if (msg.type === 'init') { try { importScripts('/vendor/opencv.js'); } catch (e) { self.postMessage({ type: 'failed', msg: 'importScripts: ' + (e && e.message || e) }); return; } const mod = self.cv; const finish = (m) => { cv = m; rvec = new cv.Mat(3, 1, cv.CV_64F); tvec = new cv.Mat(3, 1, cv.CV_64F); R = new cv.Mat(); proj = new cv.Mat(); jac = new cv.Mat(); self.postMessage({ type: 'ready' }); }; if (!mod) return self.postMessage({ type: 'failed', msg: 'cv missing after importScripts' }); if (mod.Mat) return finish(mod); if (typeof mod.then === 'function') return mod.then(finish, (e) => self.postMessage({ type: 'failed', msg: String(e) })); mod.onRuntimeInitialized = () => finish(self.cv || mod); // safety poll: some builds consume onRuntimeInitialized before we attach const t0 = Date.now(); (function poll() { if (cv) return; const m = self.cv; if (m && m.Mat) return finish(m); if (Date.now() - t0 > 60000) return self.postMessage({ type: 'failed', msg: 'init timeout' }); setTimeout(poll, 100); })(); 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, why: 'throw' }; } self.postMessage({ type: 'pose', id: msg.id, n: msg.n, ...out }); } };