diff --git a/public/js/ar/cv-worker.js b/public/js/ar/cv-worker.js new file mode 100644 index 0000000..83ccde9 --- /dev/null +++ b/public/js/ar/cv-worker.js @@ -0,0 +1,131 @@ +/* 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 } + * -> { type:'reset' } (drop temporal warm start) + * 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 +const PREV_TTL = 1500; +const MAX_REPROJ_PX = 8; + +// 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 { ok: false }; + 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 { ok: false, reproj: err }; } + + prev = { r: [...rvec.data64F], t: [...tvec.data64F], at: Date.now() }; + + 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]), + ]; + // 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 }; +} + +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; 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 }; } + self.postMessage({ type: 'pose', id: msg.id, n: msg.n, ...out }); + } +};