/* Newbury Exhibit — forgiving detection + tracking * * Wraps js-aruco2 to make marker scanning more tolerant: * 1. Looser detector params (find smaller / dimmer / sharper-angle markers). * 2. Optional multi-scale pass (re-detect on a downscaled frame to catch * distant or motion-blurred markers that fail at full res). * 3. A stateful Tracker that COASTS through brief dropouts (keeps the last * good pose for a short window instead of snapping off) and SMOOTHS the * corners with an adaptive 1€-style filter (steady when still, responsive * when moving). * * Usage: * const td = new TunedDetector({ dictionaryName:'ARUCO_4X4_1000' }); * const markers = td.detect(imageData, { width, height }); // raw, tuned * const tracker = new MarkerTracker(); * const tracked = tracker.update(markers, nowMs); // coasted+smoothed * * Each tracked marker: { id, corners:[{x,y}*4], coasting:bool, age:ms }. * Corners are in the SAME pixel space js-aruco returns (full-res frame). */ /* 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, 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, 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, maxHamming: 1 }, }; export class TunedDetector { constructor(config) { this.det = new AR.Detector(config); this.preset = DETECT_PRESETS.forgiving; // scratch image objects for the downscaled pass this._small = null; this._smallCanvas = null; } setPreset(name) { this.preset = DETECT_PRESETS[name] || this.preset; return this; } // One detection pass at the detector's native resolution, using tuned params. _pass(image) { const d = this.det; CV.grayscale(image, d.grey); // threshKernel/threshBias widen tolerance to uneven lighting. CV.adaptiveThreshold(d.grey, d.thres, this.preset.threshKernel, this.preset.threshBias); d.contours = CV.findContours(d.thres, d.binary); d.candidates = d.findCandidates( d.contours, image.width * this.preset.minSizeRatio, // smaller => accepts smaller markers this.preset.epsilon, // looser polygon approximation 10 ); d.candidates = d.clockwiseCorners(d.candidates); d.candidates = d.notTooNear(d.candidates, 10); return d.findMarkers(d.grey, d.candidates, this.preset.warp); } // Optional second pass on a half-res copy; corners scaled back to full res. _passDownscaled(imageData, width, height) { const sw = Math.round(width / 2), sh = Math.round(height / 2); if (!this._smallCanvas) { this._smallCanvas = document.createElement('canvas'); this._smallCtx = this._smallCanvas.getContext('2d', { willReadFrequently: true }); } this._smallCanvas.width = sw; this._smallCanvas.height = sh; // draw the full-res ImageData down by putting it on a temp canvas then scaling if (!this._fullCanvas) { this._fullCanvas = document.createElement('canvas'); this._fullCtx = this._fullCanvas.getContext('2d', { willReadFrequently: true }); } this._fullCanvas.width = width; this._fullCanvas.height = height; this._fullCtx.putImageData(imageData, 0, 0); this._smallCtx.drawImage(this._fullCanvas, 0, 0, sw, sh); const smallImg = this._smallCtx.getImageData(0, 0, sw, sh); const found = this._pass(smallImg); for (const m of found) m.corners = m.corners.map((c) => ({ x: c.x * 2, y: c.y * 2 })); return found; } detect(imageData, dims) { let markers = []; try { markers = this._pass(imageData); } catch (_) { markers = []; } if (this.preset.multiScale && dims) { const seen = new Set(markers.map((m) => m.id)); let extra = []; 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; } } /* ---- 1€ filter (adaptive smoothing) ---- * Smooths a value with a cutoff that rises with speed: still hand => heavy * smoothing (kills jitter); moving fast => light smoothing (stays responsive). */ class OneEuro { // minCutoff: baseline smoothing when still (lower = smoother but laggier). // beta: how aggressively smoothing backs off as the value moves (higher = // snappier tracking when you pan the phone, so ghosts follow the camera). // Tuned for AR panning: keep jitter down when still, but track fast motion // almost 1:1 so the world doesn't lag behind the camera. constructor(minCutoff = 1.7, beta = 0.15, dCutoff = 1.0) { this.minCutoff = minCutoff; this.beta = beta; this.dCutoff = dCutoff; this.xPrev = null; this.dxPrev = 0; this.tPrev = null; } _alpha(cutoff, dt) { const tau = 1 / (2 * Math.PI * cutoff); return 1 / (1 + tau / dt); } filter(x, tMs) { if (this.xPrev == null) { this.xPrev = x; this.tPrev = tMs; return x; } let dt = (tMs - this.tPrev) / 1000; if (dt <= 0) dt = 1 / 60; this.tPrev = tMs; const dx = (x - this.xPrev) / dt; const aD = this._alpha(this.dCutoff, dt); const dxHat = aD * dx + (1 - aD) * this.dxPrev; this.dxPrev = dxHat; const cutoff = this.minCutoff + this.beta * Math.abs(dxHat); const a = this._alpha(cutoff, dt); const xHat = a * x + (1 - a) * this.xPrev; this.xPrev = xHat; return xHat; } } export class MarkerTracker { /* coastMs: how long to keep showing a marker after it stops being detected. * Higher = more stable through blur/occlusion, but a truly-gone marker lingers. */ constructor({ coastMs = 250, smooth = true } = {}) { this.coastMs = coastMs; this.smooth = smooth; this.tracks = new Map(); // id -> { corners, filters:[8 OneEuro], lastSeen, firstSeen } } update(markers, nowMs) { // refresh / create tracks for detected markers const detectedIds = new Set(); for (const m of markers) { detectedIds.add(m.id); let tr = this.tracks.get(m.id); if (!tr) { tr = { id: m.id, filters: Array.from({ length: 8 }, () => new OneEuro()), firstSeen: nowMs, corners: m.corners.map((c) => ({ x: c.x, y: c.y })), }; this.tracks.set(m.id, tr); } // smooth each corner coordinate const sm = []; for (let i = 0; i < 4; i++) { const cx = m.corners[i].x, cy = m.corners[i].y; sm.push(this.smooth ? { x: tr.filters[i * 2].filter(cx, nowMs), y: tr.filters[i * 2 + 1].filter(cy, nowMs) } : { x: cx, y: cy }); } tr.corners = sm; tr.lastSeen = nowMs; tr.coasting = false; } // emit detected + still-coasting tracks; drop expired ones const out = []; for (const [id, tr] of this.tracks) { const age = nowMs - tr.lastSeen; if (detectedIds.has(id)) { out.push({ id, corners: tr.corners, coasting: false, age: nowMs - tr.firstSeen }); } else if (age <= this.coastMs) { // coast: hold last good corners so the ghost doesn't snap off out.push({ id, corners: tr.corners, coasting: true, age: nowMs - tr.firstSeen }); } else { this.tracks.delete(id); } } return out; } reset() { this.tracks.clear(); } }