Files
newbury-exhibit-v3/tools/test-roi.mjs
T

93 lines
4.0 KiB
JavaScript

/* test-roi.mjs — does an ROI crop hand back corners in the right place?
*
* node tools/test-roi.mjs
*
* roi.js crops from the NATIVE video and decodes there, then maps the corners
* back into detection space. That is two scale changes and an origin shift per
* crop. Get it slightly wrong and nothing throws — the ghosts just sit a
* centimetre off, which on this project has historically cost days. So the
* mapping is tested directly, with a stub decoder that returns a known quad
* expressed in crop pixels; a correct roi.js must return the detection-space
* quad it came from.
*
* Canvas and video are stubbed: no pixels are needed to verify arithmetic.
*/
import { RoiScanner } from '../public/js/ar/roi.js';
// ---- stub DOM: a canvas that only records how it was asked to draw ----
let lastDraw = null;
globalThis.document = {
createElement() {
return {
width: 0, height: 0,
getContext() {
return {
drawImage(_v, sx, sy, sw, sh, dx, dy, dw, dh) { lastDraw = { sx, sy, sw, sh, dx, dy, dw, dh }; },
getImageData(x, y, w, h) { return { width: w, height: h, data: new Uint8ClampedArray(w * h * 4) }; },
};
},
};
},
};
function run(name, { W, H, vw, vh, quadDet, maxLongPx }) {
const roi = new RoiScanner(maxLongPx ? { maxLongPx } : {});
// seed the scanner as though this quad was seen last tick
roi.update([{ id: 7, corners: quadDet }], 1000);
const seen = [];
const detectRaw = () => {
/* Express the SAME physical quad in the crop's own pixels, using only the
* draw parameters roi.js chose. If roi.js maps back correctly we get
* quadDet out again. */
const { sx, sy, sw, sh, dw, dh } = lastDraw;
seen.push({ ...lastDraw });
return [{
id: 7,
corners: quadDet.map(p => ({
x: ((p.x * (vw / W)) - sx) * (dw / sw),
y: ((p.y * (vh / H)) - sy) * (dh / sh),
})),
}];
};
const r = roi.scan({}, { W, H, vw, vh }, detectRaw, null);
let worst = 0;
for (let i = 0; i < 4; i++) {
worst = Math.max(worst, Math.hypot(r.markers[0].corners[i].x - quadDet[i].x,
r.markers[0].corners[i].y - quadDet[i].y));
}
const d = seen[0];
const upscale = d.dw / (d.sw / (vw / W));
console.log(
name.padEnd(38) +
`crop ${String(d.dw).padStart(3)}x${String(d.dh).padStart(3)}` +
` upscale ${upscale.toFixed(2)}x`.padEnd(18) +
` max corner err ${worst.toFixed(6)}px` +
(worst < 1e-9 ? ' PASS' : ' *** FAIL ***')
);
return worst < 1e-9;
}
const q = (x, y, s) => [{ x, y }, { x: x + s, y }, { x: x + s, y: y + s }, { x, y: y + s }];
console.log('ROI crop -> detection space round trip\n');
let ok = true;
ok &= run('landscape 1280x720, det 960', { W: 960, H: 540, vw: 1280, vh: 720, quadDet: q(400, 220, 70) });
ok &= run('portrait 720x1280, det 960', { W: 540, H: 960, vw: 720, vh: 1280, quadDet: q(200, 500, 60) });
ok &= run('crest near left edge', { W: 960, H: 540, vw: 1280, vh: 720, quadDet: q(4, 200, 50) });
ok &= run('crest near bottom-right edge', { W: 960, H: 540, vw: 1280, vh: 720, quadDet: q(890, 470, 55) });
ok &= run('rotated quad (non-axis-aligned)', {
W: 960, H: 540, vw: 1280, vh: 720,
quadDet: [{ x: 410, y: 200 }, { x: 488, y: 228 }, { x: 460, y: 306 }, { x: 382, y: 278 }],
});
ok &= run('tiny distant crest', { W: 960, H: 540, vw: 1280, vh: 720, quadDet: q(500, 260, 18) });
ok &= run('big near crest (hits long cap)', { W: 960, H: 540, vw: 1280, vh: 720, quadDet: q(300, 120, 300) });
ok &= run('4K source, det 960', { W: 960, H: 540, vw: 3840, vh: 2160, quadDet: q(400, 220, 70) });
ok &= run('cap raised to 384', { W: 960, H: 540, vw: 1280, vh: 720, quadDet: q(400, 220, 70), maxLongPx: 384 });
console.log('\nupscale > 1 means the crop carries more pixels per crest than a full');
console.log('scan would, which is where the decode-rate gain is expected to come from.');
console.log(ok ? '\nall mappings exact' : '\nMAPPING ERRORS PRESENT');
process.exit(ok ? 0 : 1);