From bb049f684cce282973bd7882e34806c6bf028ff9 Mon Sep 17 00:00:00 2001 From: jessikitty Date: Tue, 4 Aug 2026 11:31:27 +1000 Subject: [PATCH] Stabilize multi-marker fusion (deterministic order + coarse-bucket weights) + expose inter-marker spread --- public/js/ar/fuse.js | 31 +++++++++++++++++++++++++++---- 1 file changed, 27 insertions(+), 4 deletions(-) diff --git a/public/js/ar/fuse.js b/public/js/ar/fuse.js index aed244e..86456f5 100644 --- a/public/js/ar/fuse.js +++ b/public/js/ar/fuse.js @@ -70,11 +70,26 @@ export class WorldFuser { const pos = new THREE.Vector3(); const quat = new THREE.Quaternion(); camWorld.decompose(pos, quat, _s); - cams.push({ pos, quat, w: Math.max(1, e.area) }); + cams.push({ markerId: e.markerId, pos, quat, w: Math.max(1, e.area) }); } if (!cams.length) return null; - // weighted position mean + incremental weighted slerp for orientation + /* Multi-marker fusion. Two visible markers each give an independent camera-in-world + * estimate; if they disagree (pose noise, or real-table spacing not matching the + * editor), naive area-weighting oscillates frame to frame because the areas jitter + * — this is the "steady on one marker, jittery on two" symptom. Fixes: + * 1. Deterministic order (sort by markerId) so the slerp base can't flip. + * 2. Robust weights: quantise area into coarse buckets so tiny per-frame area + * wobble doesn't shift the blend; a marker only dominates when genuinely much + * closer. Equal-ish markers then average to a STABLE midpoint, not a moving one. */ + cams.sort((a, b) => a.markerId - b.markerId); + // Quantise confidence into coarse buckets from marker size (sqrt(area) ~ linear + // size). Near-equal markers land in the same bucket => equal weight => stable + // midpoint. A marker only outweighs another when it's a full bucket closer, so + // per-frame area jitter no longer shifts the blend. + for (const c of cams) c.w = Math.max(1, Math.round(Math.sqrt(c.w) / 8)); + + // weighted position mean + incremental weighted slerp (now order-stable) let wSum = cams[0].w; const pos = cams[0].pos.clone().multiplyScalar(cams[0].w); const quat = cams[0].quat.clone(); @@ -89,6 +104,14 @@ export class WorldFuser { } pos.multiplyScalar(1 / wSum); + // spread = how far apart the individual marker estimates are (0 for one marker). + // Large spread (>~2cm) means the markers disagree — usually real-table spacing + // not matching the editor, which no fusion can fully hide; surfaced in dev HUD. + let spread = 0; + for (let i = 0; i < cams.length; i++) + for (let j = i + 1; j < cams.length; j++) + spread = Math.max(spread, cams[i].pos.distanceTo(cams[j].pos)); + // ---- 1€ filter (position + orientation) ---- const now = performance.now(); const gap = now - this.lastFuseT; @@ -100,7 +123,7 @@ export class WorldFuser { if (!this.smoothPos || gap > 1500) { this.smoothPos = pos.clone(); oe.prevPos = pos.clone(); oe.dPos.set(0, 0, 0); this.smoothQuat = quat.clone(); oe.prevQuat = quat.clone(); oe.dAngRate = 0; - return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length }; + return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length, spread }; } // POSITION: derivative -> speed -> dynamic cutoff -> low-pass @@ -123,6 +146,6 @@ export class WorldFuser { this.smoothQuat.slerp(quat, aA); oe.prevQuat.copy(quat); - return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length }; + return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length, spread }; } }