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