165 lines
7.1 KiB
JavaScript
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 };
|
|
}
|
|
}
|