AR: sub-pixel corner refinement, ROI-first detection, anchor coplanarity check
This commit is contained in:
1 parent
7947eb61a0
commit
7fc2b02e6a
13 files changed
+1160
-10
No files matched your search
@@ -0,0 +1,164 @@
|
||||
/* 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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user