187 lines
7.4 KiB
JavaScript
187 lines
7.4 KiB
JavaScript
/* 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 = {
|
|
// 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 },
|
|
// the sensible default for an exhibit — noticeably more forgiving
|
|
forgiving:{ minSizeRatio: 0.015, epsilon: 0.06, threshKernel: 2, threshBias: 7, warp: 49, multiScale: true },
|
|
// last resort — distant/dim/awkward; costs more CPU
|
|
greedy: { minSizeRatio: 0.008, epsilon: 0.08, threshKernel: 3, threshBias: 9, warp: 49, multiScale: true },
|
|
};
|
|
|
|
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);
|
|
}
|
|
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(); }
|
|
}
|