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

+57 -7
View File
@@ -10,7 +10,10 @@
* on-table A/B until the new engine is confirmed. Delete later.
*/
import * as THREE from 'three';
import { createDetector, detectMarkers, quadArea } from './ar/detect.js';
import { createDetector, detectRaw, finaliseMarkers, quadArea } from './ar/detect.js';
import { refineMarkers } from './ar/subpix.js';
import { RoiScanner } from './ar/roi.js';
import { describePlanarity } from './ar/geom.js';
import { PoseEstimator, ROT_MODES, setRotMode, getRotMode } from './ar/pose.js';
import { WorldFuser } from './ar/fuse.js';
import { CvBoardSolver, CV_AXIS_MODES } from './ar/solve-cv.js';
@@ -95,6 +98,19 @@ const DETECT_HZ = parseFloat(localStorage.getItem('nbx.detectHz')) || 20;
const DETECT_LONG = parseFloat(localStorage.getItem('nbx.detectLong')) || 960;
const DETECT_INTERVAL_MS = 1000 / DETECT_HZ;
let lastDetectAt = 0;
/* Sub-pixel corner refinement (subpix.js). js-aruco2 returns corners quantised
* to the detection canvas lattice; everything downstream inherits that error.
* Measured on synthetic markers it cuts RMS corner error ~12x. Off: 'nbx.subpix'=0. */
const SUBPIX = (localStorage.getItem('nbx.subpix') ?? '1') !== '0';
/* ROI-first detection (roi.js). Crop around the last known crest positions and
* decode those at native video resolution instead of rescanning the whole
* downscaled frame. Falls back to a full scan whenever the crop pass comes back
* short, so it cannot lose a crest. Off: 'nbx.roi'=0. */
const ROI_ON = (localStorage.getItem('nbx.roi') ?? '1') !== '0';
const roiScanner = new RoiScanner();
let lastSubpix = { refined: 0, total: 0, meanShiftPx: 0 };
let fps = 0, fpsFrames = 0, fpsAt = 0;
// Debug pose readout (dev mode)
@@ -107,7 +123,11 @@ function dbgStatus() {
return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` +
`scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n` +
`${fps} fps detect ${DETECT_HZ}Hz@${DETECT_LONG} fov ${camera3 ? camera3.fov.toFixed(0) : '?'}` +
`${cvSolver && cvSolver.lastReject ? ' REJ:' + cvSolver.lastReject : ''}\n`;
`${cvSolver && cvSolver.lastReject ? ' REJ:' + cvSolver.lastReject : ''}\n` +
`scan ${roiScanner.stats.mode}` +
`${roiScanner.stats.mode.startsWith('roi') ? ` ${roiScanner.stats.rois}x @${roiScanner.stats.upscale.toFixed(1)}x ${(roiScanner.stats.pixels / 1000).toFixed(0)}kpx` : ''}` +
` subpix ${SUBPIX ? `${lastSubpix.refined}/${lastSubpix.total} ${lastSubpix.meanShiftPx.toFixed(2)}px` : 'off'}\n` +
`${describePlanarity(cvSolver && cvSolver.planarity)}\n`;
}
function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; }
@@ -396,6 +416,7 @@ function applyScene(scene) {
anchorCount = (scene.anchors || []).filter(a => a.enabled !== false).length;
fuser.setScene(scene);
cvSolver.setScene(scene);
roiScanner.reset();
buildOcclusion(scene);
ghostHeight = scene.ghostHeightCm || 4;
for (const e of activeGhosts.values()) e.group.userData.setHeight(ghostHeight);
@@ -487,14 +508,43 @@ function loop(t) {
const LONG = DETECT_LONG;
const W = vw >= vh ? LONG : Math.round(LONG * vw / vh);
const H = vw >= vh ? Math.round(LONG * vh / vw) : LONG;
if (canvas2d.width !== W || canvas2d.height !== H) { canvas2d.width = W; canvas2d.height = H; }
ctx2d.drawImage(video, 0, 0, W, H);
const img = ctx2d.getImageData(0, 0, W, H);
const focal = focalPxFor(W, H);
if (!poseEst) poseEst = new PoseEstimator(focal);
const markers = detectMarkers(detector, img, fuser.knownIds());
/* ROI first, full frame as the fallback. Both paths end in finaliseMarkers,
* so dedupe, scene filtering and diagnostics are identical either way and
* nothing below here needs to know which ran.
*
* Sub-pixel refinement runs on whichever image was actually decoded. In ROI
* mode that is the crop, at native resolution — so its precision is divided
* by the crop's upscale factor on the way back into detection space, and
* refinement gets BETTER the closer you are, which is the opposite of how
* the raw detector behaves. */
let sRef = 0, sTot = 0, sShift = 0;
const refine = SUBPIX ? (im, ms) => {
const r = refineMarkers(im, ms);
sRef += r.refined; sTot += r.total; sShift += r.meanShiftPx * r.refined;
} : null;
let markers = null;
if (ROI_ON && !roiScanner.wantsFull(t, W, H)) {
const r = roiScanner.scan(video, { W, H, vw, vh }, im => detectRaw(detector, im), refine);
if (r.complete) markers = finaliseMarkers(r.markers, fuser.knownIds());
}
if (!markers) {
if (canvas2d.width !== W || canvas2d.height !== H) { canvas2d.width = W; canvas2d.height = H; }
ctx2d.drawImage(video, 0, 0, W, H);
const img = ctx2d.getImageData(0, 0, W, H);
const raw = detectRaw(detector, img);
if (refine && raw.length) refine(img, raw);
markers = finaliseMarkers(raw, fuser.knownIds());
roiScanner.noteFull(t);
}
roiScanner.update(markers, t);
const up = roiScanner.stats.mode.startsWith('roi') ? (roiScanner.stats.upscale || 1) : 1;
lastSubpix = { refined: sRef, total: sTot, meanShiftPx: sRef ? (sShift / sRef) / up : 0 };
tracking.markers = markers.length;
tracking.raw = markers.rawCount || 0;
tracking.rawIds = markers.rawIds || [];