/* 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 }; } }