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

+68 -2
View File
@@ -6,8 +6,11 @@ Current as of 2026-09-23. Supersedes the v2 tracking design throughout.
```
camera video
-> detection canvas (long side capped, ~20Hz)
-> ArUco decode, maxHamming 0, filtered to scene anchor ids, deduped by id
-> ROI crops around last-seen crests, at NATIVE resolution (steady state)
or full detection canvas, long side capped (acquire / every 600ms / on loss)
-> ArUco decode, maxHamming 0
-> sub-pixel corner refinement (gradient centroid per border edge)
-> filtered to scene anchor ids, deduped by id
-> TRACKING ENGINE -> camera pose in world
-> three.js render: server-driven ghosts + depth-only occluder meshes
```
@@ -45,6 +48,69 @@ Pinned to `4.12.0-release.1`. Note for future work: this build lacks
`solvePnPGeneric` and `cornerSubPix`, which is why pose ambiguity is handled by
gating rather than by comparing both IPPE solutions.
## Detection: ROI first, full frame as fallback
The crests do not move; the phone does. Once a crest has been seen, the next
tick crops around where it was and decodes that, rather than rescanning 518k
pixels to re-find something that shifted by a few. Crops are taken from the
**native** video, so each is both smaller than the corresponding patch of a full
scan and higher resolution than it (`@1.3x` on a 1280 stream against a 960
canvas). Five typical crests come to ~40k pixels against 518k.
Mapping back into detection space is two scale changes and an origin shift per
crop, which is exactly the kind of arithmetic that fails silently and puts
ghosts a centimetre off. It is tested directly in `tools/test-roi.mjs`.
A full scan runs on first acquisition, when targets go stale, at least every
600ms, when there are more than 6 targets, and when the crops would cover more
than 55% of the frame. **If an ROI pass returns fewer markers than it had
targets, a full scan runs in the same tick** — dropping from 4 crests to 2 is
precisely what makes the pose ambiguous, so it is never allowed to happen
silently. Worst case is one wasted crop pass. Off: `localStorage.nbx.roi = 0`.
## Corner precision: sub-pixel refinement
js-aruco2 returns corners on the detection canvas lattice. At 960px a 60mm crest
spans ~140px, so one pixel of corner error is ~0.7% of the marker — millimetres
of ghost placement, re-rolled every frame.
`subpix.js` refines each quad by fitting its four border edges rather than by
looking at corners. Along each edge it scans perpendicular at ~12 points, takes
the **first moment of the gradient** at each (not the gradient peak), fits a line
through the crossings by total least squares with one outlier pass, and
intersects consecutive lines.
The centroid matters. Parabolic interpolation of the gradient peak — the obvious
method, and what a `cornerSubPix`-style operator reduces to — is biased by
sampling phase, because the gradient of an anti-aliased step is roughly
triangular. Measured against synthetic edges it carried up to 0.29px of bias
sharp and 0.75px defocused, i.e. **worse than the whole-pixel corners it was
meant to improve**. The gradient centroid is unbiased for any symmetric edge
profile: 0.018px on the same test. End to end on synthetic markers, RMS corner
error drops ~12x (`tools/test-subpix.mjs`).
`cv.cornerSubPix` was not an option: it is absent from the pinned build. Probed
directly rather than assumed — `solvePnPRefineLM` IS present, though it refines
a pose from corners and so cannot help here.
Refinement is all-or-nothing per marker and falls back to the raw corners if any
edge is too short, too flat, too diffuse, or if a corner would move more than
3px. A bad refinement is worse than none, because the pose gates are tuned
against honest corner noise. Off: `localStorage.nbx.subpix = 0`.
## Anchor conditioning
`geom.js` measures, once per scene load, the RMS distance of every anchor corner
from the best-fit plane through all of them, and the HUD shows it. Below 1.5cm
the layout is effectively coplanar and the mirrored pose is live — the physics
gates below become load-bearing rather than a safety net.
This exists because "the anchors are coplanar" is otherwise invisible: it looks
normal in the JSON and shows up days later as ghosts intermittently flipping
across the table. A layout re-saved by an editor that does not understand wall
anchors can silently become flat. Reference values: A3 rig with the tower ~3.5cm,
a town layout with one wall crest at 8cm ~2.7cm, any all-tabletop layout 0.0cm.
## Camera model
Everything derives from one assumption — `HFOV_DEG` (default 60, override via