This commit is contained in:
2026-06-28 12:29:26 +10:00
parent 0d1d66a85d
commit ea92821ea0
7 changed files with 1231 additions and 1 deletions
+181
View File
@@ -0,0 +1,181 @@
/* 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 {
constructor(minCutoff = 1.2, beta = 0.012, 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(); }
}