AR: sub-pixel corner refinement, ROI-first detection, anchor coplanarity check

This commit is contained in:
jessikitty committed 2026-10-12 13:07:46 +11:00
1 parent 7947eb61a0
commit 7fc2b02e6a
13 files changed
+1160 -10

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
/* 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);
+176
View File
@@ -0,0 +1,176 @@
/* test-subpix.mjs — does sub-pixel refinement actually help, and by how much?
*
* node tools/test-subpix.mjs
*
* Renders synthetic ArUco-like markers at known sub-pixel corner positions
* (anti-aliased by 4x4 supersampling, with optional sensor noise and defocus),
* quantises the corners the way a contour tracer does, then measures RMS corner
* error before and after refineQuad.
*
* This exists because "the ghosts look steadier" is not a measurement. If a
* tuning change in subpix.js makes this number worse, it was not an improvement.
*/
import { refineQuad, DEFAULT_OPTS } from '../public/js/ar/subpix.js';
// ---------- unit square -> quad homography (Heckbert) ----------
function squareToQuad(q) {
const [p0, p1, p2, p3] = q; // (0,0) (1,0) (1,1) (0,1)
const sx = p0.x - p1.x + p2.x - p3.x;
const sy = p0.y - p1.y + p2.y - p3.y;
let a, b, c, d, e, f, g, h;
if (Math.abs(sx) < 1e-12 && Math.abs(sy) < 1e-12) {
a = p1.x - p0.x; b = p2.x - p1.x; c = p0.x;
d = p1.y - p0.y; e = p2.y - p1.y; f = p0.y;
g = 0; h = 0;
} else {
const dx1 = p1.x - p2.x, dx2 = p3.x - p2.x;
const dy1 = p1.y - p2.y, dy2 = p3.y - p2.y;
const den = dx1 * dy2 - dx2 * dy1;
g = (sx * dy2 - dx2 * sy) / den;
h = (dx1 * sy - sx * dy1) / den;
a = p1.x - p0.x + g * p1.x; b = p3.x - p0.x + h * p3.x; c = p0.x;
d = p1.y - p0.y + g * p1.y; e = p3.y - p0.y + h * p3.y; f = p0.y;
}
return [a, b, c, d, e, f, g, h, 1];
}
function inv3(m) {
const [a, b, c, d, e, f, g, h, i] = m;
const A = e * i - f * h, B = -(d * i - f * g), C = d * h - e * g;
const det = a * A + b * B + c * C;
return [A / det, -(b * i - c * h) / det, (b * f - c * e) / det,
B / det, (a * i - c * g) / det, -(a * f - c * d) / det,
C / det, -(a * h - b * g) / det, (a * e - b * d) / det];
}
function apply(m, x, y) {
const w = m[6] * x + m[7] * y + m[8];
return { x: (m[0] * x + m[1] * y + m[2]) / w, y: (m[3] * x + m[4] * y + m[5]) / w };
}
// ---------- synthetic marker render ----------
function renderMarker(W, H, quad, { bits, noise = 0, blur = 0, black = 28, white = 226 }) {
const Hm = inv3(squareToQuad(quad));
const d = new Uint8ClampedArray(W * H * 4);
const SS = 4, inv = 1 / (SS * SS);
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
let acc = 0;
for (let sy = 0; sy < SS; sy++) {
for (let sx = 0; sx < SS; sx++) {
/* Pixel CENTRES sit on integer coordinates — the convention js-aruco2
* corners and OpenCV intrinsics both use, and the one subpix.js samples
* with. Integrating over [px, px+1] instead would bake a half-pixel
* offset into the ground truth and make a correct refiner look biased. */
const p = apply(Hm, px - 0.5 + (sx + 0.5) / SS, py - 0.5 + (sy + 0.5) / SS);
const u = p.x * 6, v = p.y * 6; // 6x6 cells: 4x4 payload + border
if (u < 0 || v < 0 || u >= 6 || v >= 6) { acc += white; continue; }
const cu = u | 0, cv = v | 0;
const border = cu === 0 || cv === 0 || cu === 5 || cv === 5;
acc += border ? black : (bits[(cv - 1) * 4 + (cu - 1)] ? white : black);
}
}
let g = acc * inv;
if (noise) g += (Math.random() + Math.random() + Math.random() - 1.5) * noise;
const i = (py * W + px) << 2;
d[i] = d[i + 1] = d[i + 2] = g; d[i + 3] = 255;
}
}
if (blur > 0) boxBlur(d, W, H, blur);
return { data: d, width: W, height: H };
}
/* Separable box blur, repeated — a cheap stand-in for lens defocus. */
function boxBlur(d, W, H, r) {
const tmp = new Float64Array(W * H);
for (let pass = 0; pass < 2; pass++) {
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
let s = 0, n = 0;
for (let k = -r; k <= r; k++) { const xx = x + k; if (xx >= 0 && xx < W) { s += d[((y * W + xx) << 2)]; n++; } }
tmp[y * W + x] = s / n;
}
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
let s = 0, n = 0;
for (let k = -r; k <= r; k++) { const yy = y + k; if (yy >= 0 && yy < H) { s += tmp[yy * W + x]; n++; } }
const i = (y * W + x) << 2;
d[i] = d[i + 1] = d[i + 2] = s / n;
}
}
}
// ---------- scenarios ----------
const rnd = (() => { let s = 1234567; return () => (s = (s * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff; })();
function quadAt(cx, cy, size, rot, persp) {
const base = [[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]];
const c = Math.cos(rot), s = Math.sin(rot);
return base.map(([x, y]) => {
const sc = size * (1 + persp * y); // fake foreshortening
return { x: cx + (x * c - y * s) * sc, y: cy + (x * s + y * c) * sc };
});
}
function rms(a, b) {
let s = 0;
for (let i = 0; i < 4; i++) s += (a[i].x - b[i].x) ** 2 + (a[i].y - b[i].y) ** 2;
return Math.sqrt(s / 4);
}
const cases = [
{ name: 'large, square-on (140px)', size: 140, rot: 0.00, persp: 0.00, noise: 0, blur: 0 },
{ name: 'large, rotated 20 deg (140px)', size: 140, rot: 0.35, persp: 0.00, noise: 0, blur: 0 },
{ name: 'oblique, foreshortened (140px)', size: 140, rot: 0.20, persp: 0.28, noise: 0, blur: 0 },
{ name: 'medium (90px)', size: 90, rot: 0.25, persp: 0.15, noise: 0, blur: 0 },
{ name: 'small (52px)', size: 52, rot: 0.18, persp: 0.10, noise: 0, blur: 0 },
{ name: 'noisy (sigma 8 luma) (110px)', size: 110, rot: 0.22, persp: 0.12, noise: 8, blur: 0 },
{ name: 'defocused (r=1 box x2) (110px)', size: 110, rot: 0.22, persp: 0.12, noise: 0, blur: 1 },
{ name: 'noisy + defocused (110px)', size: 110, rot: 0.22, persp: 0.12, noise: 6, blur: 1 },
];
const TRIALS = 24;
console.log('sub-pixel corner refinement — RMS corner error vs ground truth (px)\n');
console.log('scenario raw refined gain refined/total');
console.log('-'.repeat(74));
let totRaw = 0, totRef = 0, totN = 0, totTried = 0, totOk = 0;
for (const c of cases) {
let sumRaw = 0, sumRef = 0, n = 0, ok = 0;
for (let t = 0; t < TRIALS; t++) {
const W = 256, H = 256;
// sub-pixel placement: the centre lands on a fractional pixel every trial
const quad = quadAt(128 + rnd() - 0.5, 128 + rnd() - 0.5, c.size, c.rot + (rnd() - 0.5) * 0.1, c.persp);
const bits = Array.from({ length: 16 }, () => (rnd() > 0.45 ? 1 : 0));
const img = renderMarker(W, H, quad, { bits, noise: c.noise, blur: c.blur });
/* What a contour tracer hands you: corners on the pixel lattice. The extra
* +/-0.5 jitter stands in for the tracer picking a neighbouring lattice
* point on a staircased edge. */
const raw = quad.map(p => ({
x: Math.round(p.x + (rnd() - 0.5)),
y: Math.round(p.y + (rnd() - 0.5)),
}));
const ref = refineQuad(img, raw, DEFAULT_OPTS);
sumRaw += rms(raw, quad);
sumRef += rms(ref || raw, quad);
if (ref) ok++;
n++;
}
const r = sumRaw / n, f = sumRef / n;
totRaw += sumRaw; totRef += sumRef; totN += n; totTried += n; totOk += ok;
console.log(
c.name.padEnd(32) +
r.toFixed(3).padStart(7) +
f.toFixed(3).padStart(9) +
(r / f).toFixed(2).padStart(7) + 'x' +
`${ok}/${n}`.padStart(13)
);
}
console.log('-'.repeat(74));
const R = totRaw / totN, F = totRef / totN;
console.log('overall'.padEnd(32) + R.toFixed(3).padStart(7) + F.toFixed(3).padStart(9) +
(R / F).toFixed(2).padStart(7) + 'x' + `${totOk}/${totTried}`.padStart(13));
console.log('\nrefined/total is the accept rate: refinement that fails its guards');
console.log('returns the original corners, so a low rate costs nothing but gains nothing.');