docs: document the tower and why coplanar crests flip the pose; add HUD reading guide

This commit is contained in:
2026-08-24 15:33:54 +10:00
parent 87eee945fc
commit 12d30b3b8f
+65 -28
View File
@@ -26,7 +26,7 @@ Open in `EXHIBIT_MODE=dev`; password-protected in `EXHIBIT_MODE=final`
| `/admin/characters.html` | Characters | Per-ghost visuals: model, part placement, gradient colours, opacity, height, scale, face/torso textures. |
| `/admin/landing.html` | Landing Page | Visitor landing screen: title, logo, background, accent colour, about text, disclaimer. |
| `/admin/preview.html` | Virtual Viewport | Live orbit view of whatever ghosts are currently active, straight off the same WebSocket feed as the exhibit. Ground truth when the AR view disagrees. |
| `/admin/testsheet.html` | A3 Test Rig | Printable A3 calibration/evaluation sheet, and a one-click load of the matching scene + playlist. See below. |
| `/admin/testsheet.html` | A3 Test Rig | Two printable A3 pages — calibration sheet plus a foldable marker tower — and a one-click load of the matching scene + playlist. See below. |
| `/admin/plan.html` | Placement Plan | Measured plan for physically positioning crests and models on the real table. |
| `/admin/print.html` | Print Crests | Generates printable ArUco markers ("Newbury Crests") at the configured `sizeMM`. |
| `/admin/calibrate.html` | Calibrate | Camera/marker calibration helper. Detects crests **without** filtering against the scene, so it's the quickest way to prove a printed crest is readable at all. |
@@ -36,28 +36,45 @@ Open in `EXHIBIT_MODE=dev`; password-protected in `EXHIBIT_MODE=final`
## The A3 test rig
`/admin/testsheet.html` is a self-contained test environment for evaluating
tracking, occlusion and lighting without the LEGO town in the way.
tracking, occlusion and lighting without the LEGO town in the way. Two pages,
both A3 landscape.
- **Print A3 landscape at 100% ("Actual size"), never "Fit to page."** Check the
100 mm scale bar first: if it doesn't measure 100 mm, every crest is the wrong
size and every pose is scaled with it.
- The sheet *is* the table — A3 landscape is exactly 42.0 × 29.7 cm. Origin is
the front-left corner, matching the exhibit's world frame.
- **6 crests** at 60 mm, spread 38.7 cm corner to corner (the spread is what
makes the joint solve well-conditioned).
- **Cylinder occluder**: stand an 85 mm ⌀ × 160 mm tall object on the dashed
circle at centre.
- **Static ghost** at (21, 22) cm, deliberately *behind* the cylinder from the
front — walk around it and the ghost should disappear behind the post.
- **Walking ghost** on an 18 × 12 cm rectangular loop around the cylinder;
60 cm perimeter, ~40 s per lap at the default 1.5 cm/s.
- "Load this layout onto the server" writes both the scene and the playlist,
built from the same constants that drew the sheet, so print and config can
never drift apart. It **replaces** the live scene and playlist — save your
current layout first.
**Print at 100% ("Actual size"), never "Fit to page."** Check the 100 mm scale
bar on page 1 first: if it doesn't measure 100 mm, every crest is the wrong size
and every pose is scaled with it.
Known gap: the layout editor doesn't understand cylinder occluders, so opening
and saving this layout there will drop the cylinder.
**Page 1 — the ground sheet.** The sheet *is* the table: A3 landscape is exactly
42.0 × 29.7 cm, origin at the front-left corner, matching the exhibit's world
frame. Six 60 mm crests (ids 05) spread 38.7 cm corner to corner. Marked on it:
the tower footprint at centre, the walker's loop, and the static ghost's spot.
**Page 2 — the tower.** Folds into an 85 mm square × 160 mm tall prism with a
60 mm crest on each of its four sides (ids 69, centred 100 mm up) plus one on
top (id 10). Fold printed side OUT, tape the glue tab, fold the top flap over,
and stand it on the dashed square with the FRONT panel toward the front edge.
Why the tower exists, and why it isn't optional: **crests lying flat all share
one plane, and a coplanar point set has two poses that reproject almost equally
well** — the true one and a mirrored twin. With few crests in frame the solver
intermittently returns the twin, which looks like ghosts snapping to the wrong
side of the table and vanishing. Reprojection error can't tell them apart (the
twin fits the pixels). A crest 100 mm above the sheet makes the geometry properly
3D, and no mirrored pose can fit it. `cv-worker.js` also gates poses on physics
(camera must be above the sheet, can't teleport) but that only suppresses the
symptom — the tower is the cure.
The tower doubles as the occluder. The **static ghost** sits at (21, 22) cm,
deliberately *behind* it from the front: walk around and the ghost should
disappear behind the tower. The **walking ghost** runs an 18 × 12 cm loop around
it, 60 cm perimeter, ~40 s per lap at the default 1.5 cm/s.
"Load this layout onto the server" writes both the scene and the playlist, built
from the same constants that drew the sheets, so print and config can never
drift apart. It **replaces** the live scene and playlist — save your current
layout first.
Known gap: the layout editor doesn't understand cylinder occluders or wall-mounted
anchors well, so opening and saving this layout there may mangle the tower.
## Useful API endpoints
@@ -77,18 +94,38 @@ the rest need auth in final mode.
| `/api/client-errors` | Reported client errors (auth in final mode) |
| `/ws` | WebSocket: `scene`, `active`, `spawn`, `despawn`, `characters`, `pong` |
## Reading the dev HUD
```
ws open rx active,pong WebSocket state + recent message types
scene 11 anchors ghost recs 2 scene loaded, ghosts held client-side
60 fps detect 20Hz@960 fov 58 render rate, detect rate/resolution, camera fov
REJ:jump (only when the solver refused a pose)
eng opencv moving engine; FROZEN = ghost motion paused
axis std cv:ready engine mode + OpenCV worker state
markers 3/4 seen [0,4,6] +1dup solved/raw crests, ids, duplicate decodes
raw / view / up / pos solved camera pose
m0 px(0.02,-0.05) proj(0.03,-0.04) AIM CHECK — see below
ghost ndc(-0.10,0.20) ON-SCREEN first ghost's screen position
```
**Aim check.** `px` is where the crest actually sits on screen (`0,0` = centre);
`proj` is where the solved pose projects it. Point straight at a crest so `px` is
near `(0,0)`: if `proj` matches, the camera model is right. A sign flip means a
mirrored axis; a constant ratio means the projection doesn't match the lens (tune
`localStorage.nbx.hfovDeg`); an intermittent horizontal flip with correct vertical
is the coplanar ambiguity — stand the tower up.
## Gotchas worth remembering
**Detection filters against the active scene.** A crest that isn't an anchor in
the current layout is detected and then discarded, which used to look exactly
like "the camera can't see anything".
like "the camera can't see anything". The scene is fetched over HTTP at start so
a flapping WebSocket can't break tracking, and the HUD distinguishes the cases.
- The scene is fetched over HTTP at start, so a down or flapping WebSocket no
longer breaks tracking.
- The HUD reports `scene N anchors` (or `NOT LOADED`) and `markers 1/2 seen
[0,7]` — usable over raw — so a filtered-out crest is visible at a glance.
- The tracking chip says "Crest N not in this layout" rather than "Point at a
Newbury Crest" when this happens.
**The video stream may be portrait.** iOS often hands back 720×1280, so any FOV
assumption must apply to the image's LONG axis, not `videoWidth`. Getting that
wrong made the focal length 1.78× too short.
**Motion amounts live on the server.** `playlist.js` sends explicit `bobAmp`,
`radius`, `speed` values with every spawn, so the client's defaults never apply