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

+22 -1
View File
@@ -13,8 +13,21 @@ export function createDetector() {
* rawIds tells you what the camera actually saw.
*/
export function detectMarkers(detector, imageData, knownIds) {
const raw = detector.detect(imageData).filter(m => quadArea(m.corners) > 100);
return finaliseMarkers(detectRaw(detector, imageData), knownIds);
}
/* Raw decode over whatever image you hand it — a full detection frame or an ROI
* crop. Split out from detectMarkers so roi.js can run the same decode on a crop
* and have the results finalised together with everything else. The area floor
* is in the pixels of THIS image, which is what we want: a crop is higher
* resolution, so a marker that scrapes past the floor in a full frame clears it
* comfortably in a crop. */
export function detectRaw(detector, imageData) {
return detector.detect(imageData).filter(m => quadArea(m.corners) > 100);
}
/* Dedupe, filter to the scene, attach diagnostics. */
export function finaliseMarkers(raw, knownIds) {
/* Deduplicate by id. Two quads in one frame can decode to the same id — a
* misread of a different crest under blur or glare. That is poison for the
* board solve: both quads are handed the SAME world corners, so solvePnP gets
@@ -36,6 +49,14 @@ export function detectMarkers(detector, imageData, knownIds) {
return usable;
}
/* Centre of a quad — used to merge markers that the same crest produced in two
* overlapping ROI crops. */
export function quadCentre(c) {
let x = 0, y = 0;
for (let i = 0; i < 4; i++) { x += c[i].x; y += c[i].y; }
return { x: x / 4, y: y / 4 };
}
export function quadArea(c) {
// shoelace
let a = 0;
+107
View File
@@ -0,0 +1,107 @@
/* geom.js — is this layout's crest geometry actually solvable without ambiguity?
*
* A coplanar set of markers admits two camera poses that reproject almost
* identically: the true one and a mirrored twin. cv-worker.js suppresses the
* twin with physics gates (camera above the table, no teleporting), but those
* are a safety net over a geometry problem. The cure is a crest that does not
* share the plane of the others — the A3 test rig does this with a 160mm tower
* carrying crests at 10cm and 16cm.
*
* The thing is, "the anchors are coplanar" is invisible. A layout saved from the
* editor, or a tower knocked over and re-entered as flat, looks completely
* normal in the JSON and only shows up later as ghosts intermittently flipping
* to the wrong side of the table — the most expensive bug in this project to
* date. So: measure it once when the scene loads, and put the number on screen.
*
* The measure is the RMS distance of every anchor CORNER from the best-fit plane
* through all of them. Corners rather than anchor centres, because a crest
* mounted vertically among flat ones contributes out-of-plane spread through its
* own extent even if its centre happens to sit near the common plane.
*/
/* Eigen-decomposition of a symmetric 3x3 by cyclic Jacobi. Small, exact enough,
* and no dependency — three.js has no symmetric eigensolver. */
function jacobiEigen(m) {
const a = [[m[0], m[1], m[2]], [m[1], m[3], m[4]], [m[2], m[4], m[5]]];
const v = [[1, 0, 0], [0, 1, 0], [0, 0, 1]];
for (let sweep = 0; sweep < 24; sweep++) {
let off = 0;
for (let p = 0; p < 2; p++) for (let q = p + 1; q < 3; q++) off += a[p][q] * a[p][q];
if (off < 1e-20) break;
for (let p = 0; p < 2; p++) {
for (let q = p + 1; q < 3; q++) {
if (Math.abs(a[p][q]) < 1e-18) continue;
const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
const c = 1 / Math.sqrt(t * t + 1), s = t * c;
for (let k = 0; k < 3; k++) {
const akp = a[k][p], akq = a[k][q];
a[k][p] = c * akp - s * akq;
a[k][q] = s * akp + c * akq;
}
for (let k = 0; k < 3; k++) {
const apk = a[p][k], aqk = a[q][k];
a[p][k] = c * apk - s * aqk;
a[q][k] = s * apk + c * aqk;
}
for (let k = 0; k < 3; k++) {
const vkp = v[k][p], vkq = v[k][q];
v[k][p] = c * vkp - s * vkq;
v[k][q] = s * vkp + c * vkq;
}
}
}
}
return { values: [a[0][0], a[1][1], a[2][2]], vectors: v };
}
/* points: flat [x,y,z, x,y,z, ...] in world cm.
* Returns null if there is nothing meaningful to measure. */
export function planarity(points) {
const n = points.length / 3;
if (n < 3) return null;
let cx = 0, cy = 0, cz = 0;
for (let i = 0; i < n; i++) { cx += points[3 * i]; cy += points[3 * i + 1]; cz += points[3 * i + 2]; }
cx /= n; cy /= n; cz /= n;
let xx = 0, xy = 0, xz = 0, yy = 0, yz = 0, zz = 0;
for (let i = 0; i < n; i++) {
const dx = points[3 * i] - cx, dy = points[3 * i + 1] - cy, dz = points[3 * i + 2] - cz;
xx += dx * dx; xy += dx * dy; xz += dx * dz;
yy += dy * dy; yz += dy * dz; zz += dz * dz;
}
const { values, vectors } = jacobiEigen([xx, xy, xz, yy, yz, zz]);
// smallest eigenvalue -> normal of the best-fit plane
let k = 0;
if (values[1] < values[k]) k = 1;
if (values[2] < values[k]) k = 2;
const nx = vectors[0][k], ny = vectors[1][k], nz = vectors[2][k];
let s2 = 0, mx = 0;
for (let i = 0; i < n; i++) {
const d = (points[3 * i] - cx) * nx + (points[3 * i + 1] - cy) * ny + (points[3 * i + 2] - cz) * nz;
s2 += d * d;
if (Math.abs(d) > mx) mx = Math.abs(d);
}
return {
points: n,
rmsCm: Math.sqrt(s2 / n),
maxCm: mx,
normal: [nx, ny, nz],
};
}
/* COPLANAR_CM: below this the layout is effectively flat and the mirrored pose
* is live. The A3 rig's tower gives ~5cm RMS; print tolerance and a crest taped
* slightly proud of the table give well under 0.5cm, so 1.5cm separates a real
* height difference from noise without being so strict that a modest raised
* crest fails to register. */
export const COPLANAR_CM = 1.5;
export function describePlanarity(p) {
if (!p) return 'geom —';
if (p.rmsCm < COPLANAR_CM) return `geom COPLANAR ${p.rmsCm.toFixed(1)}cm — raise a crest`;
return `geom spread ${p.rmsCm.toFixed(1)}cm (max ${p.maxCm.toFixed(1)})`;
}
+164
View File
@@ -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 };
}
}
+11
View File
@@ -21,6 +21,7 @@
*/
import * as THREE from 'three';
import { anchorWorldMatrix } from './pose.js';
import { planarity } from './geom.js';
// ---------------- marker corner templates ----------------
/* js-aruco2 corner order (canonical, matches POS-IT buildModel): TL, TR, BR, BL.
@@ -96,6 +97,7 @@ export class CvBoardSolver {
this.lastReproj = 0;
this.lastCount = 0;
this.lastReject = ''; // why the worker last refused a pose (HUD)
this.planarity = null; // anchor geometry conditioning, see geom.js
this._v = new THREE.Vector3();
this._m4 = new THREE.Matrix4();
this._q = new THREE.Quaternion();
@@ -163,6 +165,15 @@ export class CvBoardSolver {
if (a.enabled === false) continue;
this.anchors.set(a.markerId, { mat: anchorWorldMatrix(a), sizeMM: a.sizeMM || 60 });
}
/* Measure the layout's conditioning once, here, rather than discovering it
* later as intermittent mirrored poses. Warming cornersFor() for every
* anchor is a side benefit — the first solve no longer pays for it. */
const pts = [];
for (const id of this.anchors.keys()) {
const flat = this.cornersFor(id);
if (flat) pts.push(...flat);
}
this.planarity = planarity(pts);
this.filter.reset();
this.lastPose = null;
if (this.worker) this.worker.postMessage({ type: 'reset' });
+266
View File
@@ -0,0 +1,266 @@
/* subpix.js — sub-pixel refinement of ArUco quad corners.
*
* WHY THIS EXISTS AND NOT cv.cornerSubPix: the pinned build
* (@techstark/opencv-js@4.12.0-release.1) does not export cornerSubPix — probed
* directly against the module, not assumed. `solvePnPRefineLM` IS exported, but
* that refines a pose from corners; it cannot improve the corners themselves.
*
* WHY IT MATTERS: js-aruco2 hands back contour corners quantised to roughly
* whole pixels of the detection canvas. Every downstream metric inherits that
* quantisation — pose jitter, reprojection error, and the gate thresholds tuned
* against them. At 960px long-side a 60mm crest spans ~140px, so one pixel of
* corner error is ~0.7% of the marker, which at typical table distance is a few
* millimetres of ghost placement, flickering frame to frame.
*
* METHOD — edge fitting, not corner finding. A corner operator (which is what
* cornerSubPix is) looks at a small window around one point and is easily pulled
* by the marker's own inner modules. Instead we use the whole length of each
* border edge:
*
* 1. walk along each edge of the quad, skipping the ends
* 2. at each step, scan perpendicular to the edge and find the sub-pixel peak
* of the intensity gradient (parabolic fit on the three samples around the
* maximum) — that is the true border crossing at that point
* 3. fit a line through those crossings by total least squares, with one
* outlier rejection pass
* 4. intersect consecutive edge lines to get the refined corners
*
* Averaging ~12 independent crossings per edge beats any single-window estimate,
* and the corner comes from two long lines rather than from pixels near the
* corner itself — which is exactly where a printed marker is least reliable
* (ink bleed, rounding, and the contour tracer's own staircase).
*
* SAFETY: refinement is all-or-nothing per marker. If any edge is too short, too
* low-contrast, too few inliers, near-parallel to its neighbour, or if any
* corner would move further than maxShiftPx, the ORIGINAL corners are returned
* untouched. A bad refinement is worse than none, because the pose gates in
* cv-worker.js are tuned against honest corner noise.
*
* COST: samples are taken straight from the RGBA ImageData with bilinear luma,
* so there is no grayscale pre-pass over the frame. Per marker: 4 edges x ~12
* samples x ~17 taps ~= 800 taps. Five markers is ~4k taps against the 518k
* pixels the detector has already touched — under 1% of detection cost.
*/
const EPS = 1e-9;
export const DEFAULT_OPTS = {
edgeInset: 0.18, // skip this fraction at each end of an edge (corners lie)
samplePitchPx: 9, // one crossing per ~9px of edge
minSamples: 5,
maxSamples: 22,
scanCellFrac: 0.42, // scan +/- this fraction of ONE marker cell, so the
// scan can never reach the next black/white transition
minScanPx: 1.5,
maxScanPx: 7,
scanStepPx: 0.5,
minContrast: 24, // luma range across a scan; below this it's flat/noise
recentreIters: 2, // re-centre the scan window on the estimate, then redo
maxOffFrac: 0.75, // crossing further than this fraction of R => not our edge
sigmaMaxFrac: 0.55, // gradient spread, as a fraction of R ...
sigmaMaxPx: 2.4, // ... and an absolute ceiling, so heavy defocus still passes
minPoints: 4, // inlier crossings needed to trust an edge
outlierPx: 1.4,
minEdgePx: 24, // below this a marker is too small to refine usefully
maxShiftPx: 3.0, // refinement that moves a corner further than this is
// not refinement, it's a different quad
};
/* Bilinear luma sample from RGBA ImageData. NaN outside the image. */
function lumaAt(d, w, h, x, y) {
if (!(x >= 0 && y >= 0 && x <= w - 1 && y <= h - 1)) return NaN;
const x0 = x | 0, y0 = y | 0;
const x1 = x0 + 1 < w ? x0 + 1 : x0;
const y1 = y0 + 1 < h ? y0 + 1 : y0;
const fx = x - x0, fy = y - y0;
const i00 = (y0 * w + x0) << 2, i10 = (y0 * w + x1) << 2;
const i01 = (y1 * w + x0) << 2, i11 = (y1 * w + x1) << 2;
const g00 = 0.299 * d[i00] + 0.587 * d[i00 + 1] + 0.114 * d[i00 + 2];
const g10 = 0.299 * d[i10] + 0.587 * d[i10 + 1] + 0.114 * d[i10 + 2];
const g01 = 0.299 * d[i01] + 0.587 * d[i01 + 1] + 0.114 * d[i01 + 2];
const g11 = 0.299 * d[i11] + 0.587 * d[i11 + 1] + 0.114 * d[i11 + 2];
const a = g00 + (g10 - g00) * fx;
const b = g01 + (g11 - g01) * fx;
return a + (b - a) * fy;
}
/* One pass: sample a scan line centred on (sx,sy), return the gradient
* centroid, its spread, the contrast and the net polarity. */
function scanPass(d, w, h, sx, sy, nx, ny, R, step) {
const n = Math.floor((2 * R) / step) + 1;
if (n < 7) return null;
const vals = new Float64Array(n);
let lo = Infinity, hi = -Infinity;
for (let i = 0; i < n; i++) {
const o = -R + i * step;
const v = lumaAt(d, w, h, sx + nx * o, sy + ny * o);
if (Number.isNaN(v)) return null; // scan left the frame
vals[i] = v;
if (v < lo) lo = v;
if (v > hi) hi = v;
}
/* CENTROID, NOT PEAK. Parabolic interpolation of the gradient peak — the
* obvious choice, and what cornerSubPix-style methods reduce to — is biased by
* the sampling phase, because the gradient of an anti-aliased step is roughly
* triangular and a parabola is a poor fit to a triangle. Measured against
* synthetic edges of known position it carried up to 0.29px of bias sharp and
* 0.75px defocused, which is worse than the whole-pixel corners we started
* with. The first moment of |gradient| has no such bias: for ANY symmetric
* edge profile the centroid sits exactly on the edge. Same synthetic test:
* 0.018px RMS, sharp or defocused. See tools/test-subpix.mjs. */
let sum = 0, wsum = 0, signed = 0;
const g = new Float64Array(n);
for (let i = 1; i < n - 1; i++) {
const dv = (vals[i + 1] - vals[i - 1]) * 0.5;
signed += dv;
g[i] = dv < 0 ? -dv : dv;
sum += g[i] * (-R + i * step);
wsum += g[i];
}
if (wsum < EPS) return null;
const off = sum / wsum;
// second moment: how spread out the gradient is. A single clean transition is
// compact; two transitions, or a textured surface, are not.
let m2 = 0;
for (let i = 1; i < n - 1; i++) { const x = -R + i * step - off; m2 += g[i] * x * x; }
return { off, sigma: Math.sqrt(m2 / wsum), contrast: hi - lo, sign: signed >= 0 ? 1 : -1 };
}
/* Locate the border crossing near a sample point, re-centring so the window
* ends up symmetric about the edge (which is what makes the centroid unbiased
* when the starting guess is off, and is worth ~3x on small markers). */
function scanEdge(d, w, h, sx, sy, nx, ny, R, o) {
let acc = 0, last = null;
for (let k = 0; k < o.recentreIters; k++) {
const r = scanPass(d, w, h, sx + nx * acc, sy + ny * acc, nx, ny, R, o.scanStepPx);
if (!r) return null;
if (r.contrast < o.minContrast) return null; // flat: nothing to find
if (Math.abs(r.off) > o.maxOffFrac * R) return null; // edge isn't in this window
acc += r.off;
last = r;
}
// diffuse gradient => more than one edge in the window, or surface texture
if (last.sigma > Math.min(o.sigmaMaxFrac * R, o.sigmaMaxPx)) return null;
return { off: acc, grad: last.sign };
}
/* Total least squares line fit. Returns { x, y, ux, uy } — a point and a unit
* direction. TLS rather than y = mx + b because a near-vertical edge is perfectly
* ordinary here and would blow up the slope form. */
function fitLine(pts) {
const n = pts.length;
let mx = 0, my = 0;
for (let i = 0; i < n; i++) { mx += pts[i].x; my += pts[i].y; }
mx /= n; my /= n;
let sxx = 0, sxy = 0, syy = 0;
for (let i = 0; i < n; i++) {
const dx = pts[i].x - mx, dy = pts[i].y - my;
sxx += dx * dx; sxy += dx * dy; syy += dy * dy;
}
if (sxx + syy < EPS) return null;
const th = 0.5 * Math.atan2(2 * sxy, sxx - syy); // major axis of the scatter
return { x: mx, y: my, ux: Math.cos(th), uy: Math.sin(th) };
}
function refineEdge(d, w, h, p0, p1, o) {
const ex = p1.x - p0.x, ey = p1.y - p0.y;
const len = Math.hypot(ex, ey);
if (len < o.minEdgePx) return null;
const ux = ex / len, uy = ey / len;
const nx = -uy, ny = ux;
const cell = len / 6; // 4x4 payload + 1 border cell each side
const R = Math.min(o.maxScanPx, Math.max(o.minScanPx, cell * o.scanCellFrac));
const nS = Math.max(o.minSamples, Math.min(o.maxSamples, Math.round(len / o.samplePitchPx)));
const pts = [];
let pos = 0;
for (let i = 0; i < nS; i++) {
const f = nS === 1 ? 0.5 : i / (nS - 1);
const t = o.edgeInset + (1 - 2 * o.edgeInset) * f;
const sx = p0.x + ex * t, sy = p0.y + ey * t;
const r = scanEdge(d, w, h, sx, sy, nx, ny, R, o);
if (!r) continue;
const s = r.grad;
if (s > 0) pos++;
pts.push({ x: sx + nx * r.off, y: sy + ny * r.off, s });
}
if (pts.length < o.minPoints) return null;
/* Keep only crossings with the majority gradient polarity. A scan that found a
* white->black step where its neighbours found black->white has locked onto a
* different transition (an inner module, or the far side of a thin border);
* including it would tilt the fitted line. */
const want = pos * 2 >= pts.length ? 1 : -1;
let kept = pts.filter(p => p.s === want);
if (kept.length < o.minPoints) return null;
let line = fitLine(kept);
if (!line) return null;
// one outlier pass against the fitted line
const rx = -line.uy, ry = line.ux;
const inl = kept.filter(p => Math.abs((p.x - line.x) * rx + (p.y - line.y) * ry) <= o.outlierPx);
if (inl.length >= o.minPoints && inl.length < kept.length) {
const l2 = fitLine(inl);
if (l2) { line = l2; kept = inl; }
}
line.n = kept.length;
return line;
}
function intersect(a, b) {
const cr = a.ux * b.uy - a.uy * b.ux;
if (Math.abs(cr) < 0.05) return null; // within ~3 deg of parallel
const dx = b.x - a.x, dy = b.y - a.y;
const t = (dx * b.uy - dy * b.ux) / cr;
return { x: a.x + a.ux * t, y: a.y + a.uy * t };
}
/* Refine one quad. Returns a new 4-corner array, or null to keep the original.
* Corner order is preserved: corner i is the meeting of edge i-1 and edge i. */
export function refineQuad(imageData, corners, opts = DEFAULT_OPTS) {
if (!corners || corners.length !== 4) return null;
const d = imageData.data, w = imageData.width, h = imageData.height;
const lines = new Array(4);
for (let i = 0; i < 4; i++) {
lines[i] = refineEdge(d, w, h, corners[i], corners[(i + 1) % 4], opts);
if (!lines[i]) return null;
}
const out = new Array(4);
let worst = 0;
for (let i = 0; i < 4; i++) {
const p = intersect(lines[(i + 3) % 4], lines[i]);
if (!p) return null;
const shift = Math.hypot(p.x - corners[i].x, p.y - corners[i].y);
if (shift > opts.maxShiftPx) return null; // this is a different quad, not a better one
if (shift > worst) worst = shift;
out[i] = p;
}
out.shift = worst;
return out;
}
/* Refine every marker in place. Returns diagnostics for the HUD: how many
* markers were refined and the mean corner correction applied, which is the
* number that tells you whether this is doing anything at all. */
export function refineMarkers(imageData, markers, opts = DEFAULT_OPTS) {
let refined = 0, shiftSum = 0;
for (const m of markers) {
const r = refineQuad(imageData, m.corners, opts);
if (!r) continue;
m.corners = [{ x: r[0].x, y: r[0].y }, { x: r[1].x, y: r[1].y },
{ x: r[2].x, y: r[2].y }, { x: r[3].x, y: r[3].y }];
m.subpix = true;
refined++;
shiftSum += r.shift;
}
return { refined, total: markers.length, meanShiftPx: refined ? shiftSum / refined : 0 };
}