diff --git a/public/js/detect-tuned.js b/public/js/detect-tuned.js index d20c210..04566fc 100644 --- a/public/js/detect-tuned.js +++ b/public/js/detect-tuned.js @@ -22,12 +22,16 @@ /* global AR, CV */ export const DETECT_PRESETS = { + // maxHamming: reject markers the detector had to bit-correct. Logging showed + // every phantom id (998, 692, ...) had hamming==1 while real crests read at 0, + // so hamming==0 kills phantoms with ~no cost. Bump to 1 only if a real marker + // is genuinely hard to read (dim/worn print). // crisp, fast — good light, marker fills a decent part of frame - strict: { minSizeRatio: 0.04, epsilon: 0.05, threshKernel: 2, threshBias: 7, warp: 49, multiScale: false }, + strict: { minSizeRatio: 0.04, epsilon: 0.05, threshKernel: 2, threshBias: 7, warp: 49, multiScale: false, maxHamming: 0 }, // the sensible default for an exhibit — noticeably more forgiving - forgiving:{ minSizeRatio: 0.015, epsilon: 0.06, threshKernel: 2, threshBias: 7, warp: 49, multiScale: true }, + forgiving:{ minSizeRatio: 0.015, epsilon: 0.06, threshKernel: 2, threshBias: 7, warp: 49, multiScale: true, maxHamming: 0 }, // last resort — distant/dim/awkward; costs more CPU - greedy: { minSizeRatio: 0.008, epsilon: 0.08, threshKernel: 3, threshBias: 9, warp: 49, multiScale: true }, + greedy: { minSizeRatio: 0.008, epsilon: 0.08, threshKernel: 3, threshBias: 9, warp: 49, multiScale: true, maxHamming: 1 }, }; export class TunedDetector { @@ -91,6 +95,12 @@ export class TunedDetector { try { extra = this._passDownscaled(imageData, dims.width, dims.height); } catch (_) { extra = []; } for (const m of extra) if (!seen.has(m.id)) markers.push(m); } + + // Reject bit-corrected reads: a hamming distance above the preset threshold + // means the detector guessed at the code -> phantom ids. Real crests read at 0. + const maxH = this.preset.maxHamming ?? 0; + markers = markers.filter((m) => (m.hammingDistance ?? 0) <= maxH); + return markers; } } diff --git a/public/js/exhibit.js b/public/js/exhibit.js index 734a63f..ffa0b36 100644 --- a/public/js/exhibit.js +++ b/public/js/exhibit.js @@ -77,10 +77,10 @@ function centredCorners(m, w, h) { // (right) multiply by F fixes the rotation handedness while keeping translation // correct (F*M*F's translation column is F*t, i.e. flipped exactly once). const flipYZ = new THREE.Matrix4().makeScale(1, -1, -1); -function markerToCameraMatrix(m) { - const pose = posit.pose(centredCorners(m, grab.width, grab.height)); - if (!pose) return null; - const R = pose.bestRotation, t = pose.bestTranslation; + +// Convert one POS-IT (R,t) solution into a three.js marker->camera matrix via +// the F*M*F conjugation (see note above). +function poseToMatrix(R, t) { const M = new THREE.Matrix4().set( R[0][0], R[0][1], R[0][2], t[0], R[1][0], R[1][1], R[1][2], t[1], @@ -88,8 +88,24 @@ function markerToCameraMatrix(m) { 0, 0, 0, 1 ); M.premultiply(flipYZ); // F * M - M.multiply(flipYZ); // F * M * F (conjugation: fixes pitch handedness) - return { M, err: pose.bestError, dist: Math.hypot(t[0], t[1], t[2]) }; + M.multiply(flipYZ); // F * M * F + return M; +} + +// POS-IT returns TWO solutions for a planar marker (the real pose and a mirror +// twin). Up close they have near-equal error and the solver flip-flops between +// them -> "rotate 90deg, ghost spins 180deg". We return both; alignWorld picks +// the one whose resulting world orientation is closest to the previous frame. +function markerToCameraMatrix(m) { + const pose = posit.pose(centredCorners(m, grab.width, grab.height)); + if (!pose) return null; + const best = { M: poseToMatrix(pose.bestRotation, pose.bestTranslation), err: pose.bestError }; + let alt = null; + if (pose.alternativeRotation && pose.alternativeTranslation) { + alt = { M: poseToMatrix(pose.alternativeRotation, pose.alternativeTranslation), err: pose.alternativeError }; + } + const t = pose.bestTranslation; + return { best, alt, dist: Math.hypot(t[0], t[1], t[2]) }; } // anchor world placement -> matrix mapping the MARKER's local frame (as POS-IT @@ -121,16 +137,49 @@ function anchorToWorldMatrix(anchor) { // Given a detected marker with a known anchor, compute world->camera and apply // it to the `world` group so everything in world coords renders correctly. +// +// Planar-ambiguity defeat: POS-IT gives two candidate poses. We compute the +// resulting world->camera for each, and keep whichever is closest to LAST +// frame's transform (temporal consistency). This stops the mirror-twin flip +// that made the ghost spin the wrong way. On first lock we take `best`. const tmpAnchorInv = new THREE.Matrix4(); +let lastWorldToCamera = null; // previous frame's chosen transform (for continuity) + +function worldToCameraFor(mMatrix, anchorInv) { + return new THREE.Matrix4().multiplyMatrices(mMatrix, anchorInv); +} + +// crude distance between two 4x4s: sum of squared element differences of the +// rotation part (enough to tell the real pose from its mirror twin). +function matDist(a, b) { + const ea = a.elements, eb = b.elements; + let s = 0; + for (const i of [0, 1, 2, 4, 5, 6, 8, 9, 10]) { const d = ea[i] - eb[i]; s += d * d; } + return s; +} + function alignWorld(m2c, anchor) { - // camera = markerToCamera * anchorToWorld^-1 (as a world->camera transform) - // world point -> anchor-local -> camera: - // worldToCamera = markerToCamera * (anchorToWorld)^-1 const aToW = anchorToWorldMatrix(anchor); tmpAnchorInv.copy(aToW).invert(); - const worldToCamera = new THREE.Matrix4().multiplyMatrices(m2c.M, tmpAnchorInv); - world.matrix.copy(worldToCamera); + + const candBest = worldToCameraFor(m2c.best.M, tmpAnchorInv); + let chosen = candBest; + + if (m2c.alt) { + const candAlt = worldToCameraFor(m2c.alt.M, tmpAnchorInv); + if (lastWorldToCamera) { + // pick the candidate closest to where the world was last frame + chosen = matDist(candAlt, lastWorldToCamera) < matDist(candBest, lastWorldToCamera) + ? candAlt : candBest; + } else { + // first lock: trust the lower-error (best) solution + chosen = candBest; + } + } + + world.matrix.copy(chosen); world.matrixWorldNeedsUpdate = true; + lastWorldToCamera = chosen.clone(); } // ---- Build occluders + anchor viz once scene arrives ----------------------