Files

165 lines
7.1 KiB
JavaScript

/* roi.js — region-of-interest detection.
*
* THE PROBLEM WITH SCANNING THE WHOLE FRAME EVERY TICK: at 960px long side a
* landscape frame is ~518k pixels, and the crests occupy a few percent of it.
* Adaptive thresholding and contour tracing run over all of it, 20 times a
* second, to re-find markers that have barely moved.
*
* WHAT THIS DOES: once a crest has been seen, the next tick crops just around
* where it was — but crops from the NATIVE video, not from the downscaled
* detection canvas. So each crop is both much smaller AND higher resolution
* than the corresponding patch of a full scan. Two wins from one change:
*
* - cost: five 192px crops is ~184k pixels against 518k, and most of that is
* a GPU blit rather than CPU work
* - decode: a 60mm crest at 1.5m fills ~140px of a 960px frame but ~190px of
* a native 1280px one, and a tight crop also gives the adaptive threshold a
* far narrower range of brightness to cope with. That is a direct candidate
* fix for the open "5 crests visible, only 2 decoded" question — the
* arithmetic said resolution was adequate, but adequate at the centre of a
* frame is not adequate at the edge of an unevenly lit one.
*
* SAFETY — ROI NEVER LOSES A CREST. If an ROI pass returns fewer markers than
* it had targets, the caller is told to fall back and a full scan runs in the
* SAME tick. A full scan also runs periodically regardless, to pick up crests
* that have newly come into view. The worst case is therefore one wasted crop
* pass, not a dropped marker.
*
* All coordinates handed back are in DETECTION SPACE (the W x H the solver's
* intrinsics are built for), so nothing downstream needs to know this ran.
*/
export const ROI_DEFAULTS = {
marginFrac: 0.40, // grow each bbox by this much — covers a tick of motion,
marginMinPx: 14, // and keeps the quad off the crop border, where
// contour tracers discard it
maxLongPx: 224, // cap a crop's long side; beyond this we're paying for
// pixels the decoder does not need
maxRois: 6, // more targets than this and a full scan is simpler
maxCoverFrac: 0.55, // if the crops would cover this much of the frame,
// a single full scan is cheaper than many small ones
fullScanMs: 600, // always re-scan the whole frame at least this often
targetTtlMs: 400, // targets older than this are not worth trusting
};
export class RoiScanner {
constructor(opts = {}) {
this.o = { ...ROI_DEFAULTS, ...opts };
this.targets = new Map(); // markerId -> { x, y, w, h } in detection space
this.targetsAt = 0;
this.lastFullAt = 0;
this.stats = { mode: 'full', rois: 0, pixels: 0, upscale: 1 };
this._cv = null;
this._cx = null;
}
_canvas(w, h) {
if (!this._cv) {
this._cv = document.createElement('canvas');
this._cx = this._cv.getContext('2d', { willReadFrequently: true });
}
if (this._cv.width < w) this._cv.width = w;
if (this._cv.height < h) this._cv.height = h;
return this._cx;
}
/** Remember where each marker was, so the next tick knows where to look. */
update(markers, now) {
this.targets.clear();
for (const m of markers) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const c of m.corners) {
if (c.x < x0) x0 = c.x; if (c.x > x1) x1 = c.x;
if (c.y < y0) y0 = c.y; if (c.y > y1) y1 = c.y;
}
this.targets.set(m.id, { x: x0, y: y0, w: x1 - x0, h: y1 - y0 });
}
this.targetsAt = now;
}
reset() { this.targets.clear(); this.targetsAt = 0; this.lastFullAt = 0; }
/** Should this tick be a full-frame scan? */
wantsFull(now, W, H) {
if (!this.targets.size) return true;
if (now - this.targetsAt > this.o.targetTtlMs) return true;
if (now - this.lastFullAt > this.o.fullScanMs) return true;
if (this.targets.size > this.o.maxRois) return true;
let cover = 0;
for (const t of this.targets.values()) {
const m = Math.max(this.o.marginMinPx, Math.max(t.w, t.h) * this.o.marginFrac);
cover += (t.w + 2 * m) * (t.h + 2 * m);
}
return cover > this.o.maxCoverFrac * W * H;
}
noteFull(now) { this.lastFullAt = now; this.stats = { mode: 'full', rois: 0, pixels: 0, upscale: 1 }; }
/* Crop each target out of the native video and detect inside it.
*
* detectRaw(imageData) -> markers with corners in that image's pixels
* refine(imageData, markers) -> optional sub-pixel pass, run at CROP
* resolution so its precision is divided by the upscale factor when mapped
* back into detection space
*
* Returns { markers, complete } — complete:false means fall back to a full
* scan right now. */
scan(video, geom, detectRaw, refine) {
const { W, H, vw, vh } = geom;
const ratio = vw / W; // detection space -> native pixels
const out = [];
let pixels = 0, upAcc = 0, upN = 0;
for (const [id, t] of this.targets) {
const m = Math.max(this.o.marginMinPx, Math.max(t.w, t.h) * this.o.marginFrac);
const dx = Math.max(0, t.x - m), dy = Math.max(0, t.y - m);
const dw = Math.min(W - dx, t.w + 2 * m), dh = Math.min(H - dy, t.h + 2 * m);
if (dw < 8 || dh < 8) continue;
const nx = dx * ratio, ny = dy * ratio;
const nw = dw * ratio, nh = dh * ratio;
/* The cap must never take a crop BELOW detection-space resolution: that
* would make a big near crest decode worse through an ROI than through a
* full scan, which is backwards. Floor the scale at 1/ratio so a crop is
* always at least as detailed as the full frame, and treat maxLongPx as a
* soft cap. A crest big enough to fight the floor will trip maxCoverFrac
* and get a full scan anyway. */
const s = Math.max(1 / ratio, Math.min(1, this.o.maxLongPx / Math.max(nw, nh)));
const cw = Math.max(8, Math.round(nw * s)), ch = Math.max(8, Math.round(nh * s));
const cx = this._canvas(cw, ch);
cx.drawImage(video, nx, ny, nw, nh, 0, 0, cw, ch);
const img = cx.getImageData(0, 0, cw, ch);
pixels += cw * ch;
upAcc += cw / dw; upN++;
let found;
try { found = detectRaw(img); } catch { found = []; }
if (refine && found.length) refine(img, found);
// crop pixels -> detection space
const kx = dw / cw, ky = dh / ch;
for (const mk of found) {
mk.corners = mk.corners.map(c => ({ x: dx + c.x * kx, y: dy + c.y * ky }));
mk.roiId = id;
out.push(mk);
}
}
/* Fewer markers than targets means a crest moved out of its crop, or the
* crop clipped it. Either way the frame is incomplete and the caller must
* do a full scan rather than solve from a shrunken marker set — dropping
* from 4 crests to 2 is exactly the condition that makes the pose
* ambiguous again. */
const complete = out.length >= this.targets.size && out.length > 0;
this.stats = {
mode: complete ? 'roi' : 'roi>full',
rois: this.targets.size,
pixels,
upscale: upN ? upAcc / upN : 1,
};
return { markers: out, complete };
}
}