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
+22
-1
@@ -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;
|
||||
|
||||
@@ -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)})`;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user