update
This commit is contained in:
@@ -78,6 +78,65 @@ a procedural blue "Sad" wisp.
|
||||
|
||||
---
|
||||
|
||||
## Forgiving scanning (`js/detect-tuned.js`)
|
||||
|
||||
Raw js-aruco can feel finicky — slow to acquire, quick to drop on blur, jittery once locked.
|
||||
`detect-tuned.js` wraps it with three tolerances, used by `ar-head.html`:
|
||||
|
||||
1. **Looser detector params** — smaller min marker size, looser polygon fit, adjustable light
|
||||
threshold. Three presets: `strict` / `forgiving` (default) / `greedy`. Switch live from the
|
||||
"scan:" dropdown in `ar-head.html`.
|
||||
2. **Multi-scale pass** — also detects on a half-res copy, catching distant / motion-blurred
|
||||
markers that fail at full res (corners scaled back up).
|
||||
3. **Coast + smooth tracker** — `MarkerTracker` keeps the last good pose for ~280 ms through a
|
||||
dropout (no more snapping off when a hand passes or the image blurs), and smooths corners with
|
||||
an adaptive 1€ filter: heavy smoothing when you hold still (kills jitter), light when you move
|
||||
fast (stays responsive).
|
||||
|
||||
Tuning knobs: `DETECT_PRESETS` (params per preset), `MarkerTracker({ coastMs, smooth })`.
|
||||
Verified that the looser presets don't introduce false negatives — all presets still cleanly
|
||||
detect a known marker, including small + blurred.
|
||||
|
||||
> The jitter meter (`jitter.html`) deliberately does **not** smooth — it reports raw pose truth so
|
||||
> the numbers are honest. `ar-head.html` smooths because there the goal is a stable-feeling ghost.
|
||||
|
||||
## 3D LEGO-head ghost: `ar-head.html`
|
||||
|
||||
The real-3D successor to the flat-sprite PoC. Camera → ArUco → **full 6-DoF pose** → a Three.js
|
||||
ghost shaped like a classic minifig head (cylinder body, domed top, the stud) in a translucent
|
||||
Hidden Side gradient with a fresnel rim-glow. Because it's driven by the marker's solved
|
||||
rotation+translation, the head holds its orientation in world space — **walk around the crest and
|
||||
it turns**, showing its sides. Eyes on the front (+Z) face; simple original ghost eyes, no
|
||||
reproduced LEGO face print. Tap the swatches to switch lure colour (Blue/Red/Yellow).
|
||||
|
||||
Geometry lives in `js/lego-head-ghost.js` (`buildLegoHeadGhost(THREE, colorKey)`), built from
|
||||
primitives so there's no model file to load. Set `MARKER_SIZE_MM` in `js/ar-head.js` to match your
|
||||
marker.
|
||||
|
||||
> Pose convention: POS-IT gives the marker pose in a camera frame (X right, Y up, Z toward viewer);
|
||||
> we flip Y/Z into Three.js's (Y up, looking down −Z). Verified: anchor lands in front of camera at
|
||||
> unit scale.
|
||||
|
||||
## Pose jitter meter: `jitter.html`
|
||||
|
||||
Measures how *stable* the solved camera pose is per marker while you hold still — the number
|
||||
that predicts whether occlusion will look clean. "Jitter" = standard deviation of the marker's
|
||||
solved position (mm) and orientation (deg) over a ~1s rolling window.
|
||||
|
||||
**Workflow for the en-masse test:**
|
||||
1. Open `jitter.html` on the phone (HTTPS).
|
||||
2. Label the run in the text box (e.g. `1 marker, 30cm, room light`).
|
||||
3. Hold steady on a crest, **Record** ~10s, **Stop**.
|
||||
4. Repeat for: closer / further, sharper angle, 1 marker vs several crests in frame, bright vs dim.
|
||||
5. **Export → Download JSON** and send the file back.
|
||||
|
||||
The HUD shows live pos-jitter (green <0.5 mm, amber, red >1.5 mm), angle jitter, distance, and
|
||||
view angle. `distMeanMm`, `markersPerFrame`, and per-marker `posJitMedMm` in the export are the
|
||||
key numbers — they'll tell us how much multi-marker fusion is worth before we build occlusion.
|
||||
|
||||
> Set `MARKER_SIZE_MM` at the top of `public/js/jitter.js` to the outer black-square size of the
|
||||
> marker you actually test with (default 96 mm = 6 cells × 16 mm). Distance readings depend on it.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
@@ -97,7 +156,11 @@ Env: `PORT` (default 34034), `TICK_HZ` (default 20).
|
||||
- [x] God-view desktop viewer
|
||||
- [x] Scene + anchor + spawn data model (mm, corner origin)
|
||||
- [x] ArUco 4×4 marker system + LEGO build maps ("Newbury Crests")
|
||||
- [x] AR PoC: camera + detection + ghost locked to marker
|
||||
- [x] AR PoC: camera + detection + ghost locked to marker (flat sprite)
|
||||
- [x] 3D LEGO-head ghost holding world orientation (`ar-head.html`)
|
||||
- [x] Pose jitter meter (`jitter.html`) with session logging + JSON export
|
||||
- [ ] Multi-marker pose fusion (one combined camera pose from all visible crests)
|
||||
- [ ] Occluder geometry: ghosts vanish behind premapped buildings (depth-only boxes)
|
||||
- [ ] Phone AR client: render anchored ghosts through the camera using marker pose
|
||||
- [ ] Calibration page: scan each crest, record its measured world position
|
||||
- [ ] Per-ghost colour/sprite wiring from the enriched roster
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" />
|
||||
<title>Newbury Exhibit — 3D LEGO-head ghost</title>
|
||||
<style>
|
||||
:root { --red:#f65151; --yellow:#fff35d; --blue:#529eff; --cyan:#51eaf1;
|
||||
--ink:#e8eaf2; --dim:#8a93ad; --panel:rgba(10,12,20,0.84); }
|
||||
* { box-sizing:border-box; }
|
||||
html,body { margin:0; height:100%; background:#05060a; color:var(--ink);
|
||||
font-family:'Segoe UI',system-ui,-apple-system,sans-serif; overflow:hidden;
|
||||
-webkit-user-select:none; user-select:none; }
|
||||
#stage { position:fixed; inset:0; }
|
||||
#video,#three { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }
|
||||
#three { pointer-events:none; }
|
||||
|
||||
#hud { position:fixed; left:10px; top:calc(10px + env(safe-area-inset-top));
|
||||
background:var(--panel); border:1px solid rgba(255,255,255,0.08); border-radius:12px;
|
||||
padding:9px 12px; font-size:12.5px; line-height:1.5; backdrop-filter:blur(8px); }
|
||||
.tag { padding:1px 8px; border-radius:999px; font-size:11px; font-weight:600; }
|
||||
.tag.on { background:rgba(81,234,241,0.16); color:var(--cyan); }
|
||||
.tag.off { background:rgba(246,81,81,0.16); color:var(--red); }
|
||||
#hud b { color:var(--blue); }
|
||||
|
||||
/* colour switcher */
|
||||
#picker { position:fixed; right:12px; top:calc(10px + env(safe-area-inset-top));
|
||||
display:flex; gap:8px; }
|
||||
.swatch { width:30px; height:30px; border-radius:50%; border:2px solid rgba(255,255,255,0.25);
|
||||
cursor:pointer; }
|
||||
.swatch.sel { border-color:#fff; box-shadow:0 0 0 2px rgba(255,255,255,0.3); }
|
||||
|
||||
#start { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; gap:18px; text-align:center; padding:24px;
|
||||
background:radial-gradient(circle at 50% 38%,#161a2b,#05060a 70%); }
|
||||
#start h1 { font-size:24px; margin:0; }
|
||||
#start h1 span { background:linear-gradient(90deg,var(--red),var(--yellow),var(--blue));
|
||||
-webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
#start p { color:var(--dim); max-width:32ch; margin:0; line-height:1.6; font-size:14px; }
|
||||
#go { appearance:none; border:0; cursor:pointer; font-size:16px; font-weight:600; color:#06121f;
|
||||
background:linear-gradient(135deg,var(--blue),var(--cyan)); padding:14px 30px; border-radius:999px; }
|
||||
#err { color:var(--red); font-size:12.5px; max-width:34ch; }
|
||||
.hidden { display:none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<video id="video" playsinline muted autoplay></video>
|
||||
<canvas id="three"></canvas>
|
||||
</div>
|
||||
|
||||
<div id="hud" class="hidden">
|
||||
Ghost <span id="lock" class="tag off">searching…</span><br />
|
||||
id <b id="mid">—</b> · dist <b id="dist">—</b>mm · fps <b id="fps">0</b><br />
|
||||
<span style="color:var(--dim);font-size:11px">scan:</span>
|
||||
<select id="preset" style="background:rgba(255,255,255,0.08);color:var(--ink);border:1px solid rgba(255,255,255,0.15);border-radius:6px;font-size:11px;padding:1px 4px">
|
||||
<option value="strict">strict</option>
|
||||
<option value="forgiving" selected>forgiving</option>
|
||||
<option value="greedy">greedy</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="picker" class="hidden">
|
||||
<div class="swatch sel" data-c="Blue" style="background:#529eff"></div>
|
||||
<div class="swatch" data-c="Red" style="background:#f65151"></div>
|
||||
<div class="swatch" data-c="Yellow" style="background:#fff35d"></div>
|
||||
</div>
|
||||
|
||||
<div id="start">
|
||||
<h1>Newbury <span>LEGO-head ghost</span></h1>
|
||||
<p>Point at a crest. A 3D ghost head locks onto it — walk around and it turns to face you from every side.</p>
|
||||
<button id="go">Start camera</button>
|
||||
<div id="err"></div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/cv.js"></script>
|
||||
<script src="vendor/svd.js"></script>
|
||||
<script src="vendor/posit1.js"></script>
|
||||
<script src="vendor/aruco.js"></script>
|
||||
<script src="vendor/dictionaries/aruco_4x4_1000.js"></script>
|
||||
<script type="module" src="js/ar-head.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,149 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1" />
|
||||
<title>Newbury Exhibit — pose jitter meter</title>
|
||||
<style>
|
||||
:root {
|
||||
--red:#f65151; --yellow:#fff35d; --blue:#529eff; --cyan:#51eaf1;
|
||||
--ink:#e8eaf2; --dim:#8a93ad; --panel:rgba(10,12,20,0.86);
|
||||
--good:#51eaf1; --warn:#fff35d; --bad:#f65151;
|
||||
}
|
||||
* { box-sizing:border-box; }
|
||||
html,body { margin:0; height:100%; background:#05060a; color:var(--ink);
|
||||
font-family:'Segoe UI',system-ui,-apple-system,sans-serif; overflow:hidden;
|
||||
-webkit-user-select:none; user-select:none; }
|
||||
#stage { position:fixed; inset:0; }
|
||||
#video,#overlay { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }
|
||||
#overlay { pointer-events:none; }
|
||||
|
||||
.card { background:var(--panel); border:1px solid rgba(255,255,255,0.08);
|
||||
border-radius:14px; backdrop-filter:blur(10px); }
|
||||
|
||||
#hud { position:fixed; left:10px; right:10px; top:calc(8px + env(safe-area-inset-top));
|
||||
padding:10px 12px; font-size:12.5px; line-height:1.45; }
|
||||
#hud .top { display:flex; align-items:center; gap:8px; margin-bottom:6px; }
|
||||
#hud .title { font-weight:700; font-size:13px; letter-spacing:-0.01em; }
|
||||
.tag { padding:1px 8px; border-radius:999px; font-size:11px; font-weight:600; }
|
||||
.tag.on { background:rgba(81,234,241,0.16); color:var(--cyan); }
|
||||
.tag.off { background:rgba(246,81,81,0.16); color:var(--bad); }
|
||||
.grid { display:grid; grid-template-columns:repeat(4,1fr); gap:6px; margin-top:4px; }
|
||||
.stat { background:rgba(255,255,255,0.04); border-radius:9px; padding:6px 8px; }
|
||||
.stat .k { color:var(--dim); font-size:10px; text-transform:uppercase; letter-spacing:0.04em; }
|
||||
.stat .v { font-size:16px; font-weight:700; font-variant-numeric:tabular-nums; }
|
||||
.stat .u { color:var(--dim); font-size:10px; font-weight:400; }
|
||||
.jitter-good { color:var(--good); } .jitter-warn { color:var(--warn); } .jitter-bad { color:var(--bad); }
|
||||
|
||||
#markersList { margin-top:6px; font-size:11px; color:var(--dim); min-height:14px; }
|
||||
#markersList b { color:var(--blue); }
|
||||
|
||||
/* bottom controls */
|
||||
#controls { position:fixed; left:10px; right:10px; bottom:calc(10px + env(safe-area-inset-bottom));
|
||||
padding:10px 12px; display:flex; flex-direction:column; gap:8px; }
|
||||
#controls .row { display:flex; gap:8px; align-items:center; }
|
||||
#note { flex:1; background:rgba(255,255,255,0.06); border:1px solid rgba(255,255,255,0.1);
|
||||
color:var(--ink); border-radius:9px; padding:8px 10px; font-size:13px; font-family:inherit; }
|
||||
#note::placeholder { color:var(--dim); }
|
||||
button { appearance:none; border:0; cursor:pointer; font-family:inherit; font-weight:600;
|
||||
border-radius:9px; padding:9px 12px; font-size:13px; }
|
||||
.btn-rec { background:linear-gradient(135deg,var(--red),#ff2678); color:#fff; }
|
||||
.btn-rec.recording { background:linear-gradient(135deg,#2a2f44,#1a1d2b); color:var(--bad);
|
||||
box-shadow:0 0 0 1px var(--bad) inset; }
|
||||
.btn-ghost { background:rgba(255,255,255,0.08); color:var(--ink); }
|
||||
.btn-go { background:linear-gradient(135deg,var(--blue),var(--cyan)); color:#06121f; }
|
||||
.counter { font-variant-numeric:tabular-nums; font-weight:700; color:var(--cyan); min-width:54px; text-align:center; }
|
||||
|
||||
#recBadge { position:fixed; top:calc(8px + env(safe-area-inset-top)); right:14px;
|
||||
display:none; align-items:center; gap:6px; z-index:5; font-size:12px; font-weight:700; color:var(--bad); }
|
||||
#recBadge.show { display:flex; }
|
||||
#recDot { width:9px; height:9px; border-radius:50%; background:var(--bad); animation:pulse 1s infinite; }
|
||||
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.3;} }
|
||||
|
||||
#start { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center;
|
||||
justify-content:center; gap:18px; text-align:center; padding:24px;
|
||||
background:radial-gradient(circle at 50% 38%,#161a2b,#05060a 70%); }
|
||||
#start h1 { font-size:24px; margin:0; }
|
||||
#start h1 span { background:linear-gradient(90deg,var(--red),var(--yellow),var(--blue));
|
||||
-webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
#start p { color:var(--dim); max-width:32ch; margin:0; line-height:1.6; font-size:14px; }
|
||||
#err { color:var(--bad); font-size:12.5px; max-width:34ch; }
|
||||
.hidden { display:none !important; }
|
||||
|
||||
/* export modal */
|
||||
#modal { position:fixed; inset:0; background:rgba(2,3,8,0.8); z-index:20;
|
||||
display:none; align-items:center; justify-content:center; padding:18px; }
|
||||
#modal.show { display:flex; }
|
||||
#modal .card { width:100%; max-width:520px; max-height:80vh; padding:16px; display:flex; flex-direction:column; }
|
||||
#modal h2 { margin:0 0 8px; font-size:16px; }
|
||||
#modal .summary { font-size:12.5px; color:var(--dim); line-height:1.6; margin-bottom:10px;
|
||||
white-space:pre-wrap; font-family:ui-monospace,Menlo,Consolas,monospace; background:rgba(255,255,255,0.04);
|
||||
border-radius:8px; padding:10px; overflow:auto; }
|
||||
#modal .row { display:flex; gap:8px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="stage">
|
||||
<video id="video" playsinline muted autoplay></video>
|
||||
<canvas id="overlay"></canvas>
|
||||
</div>
|
||||
|
||||
<div id="recBadge"><span id="recDot"></span>REC <span id="recTime">0.0s</span></div>
|
||||
|
||||
<div id="hud" class="card hidden">
|
||||
<div class="top">
|
||||
<span class="title">Pose jitter</span>
|
||||
<span id="lock" class="tag off">no marker</span>
|
||||
<span style="margin-left:auto;color:var(--dim);font-size:11px">fps <b id="fps" style="color:var(--ink)">0</b></span>
|
||||
</div>
|
||||
<div class="grid">
|
||||
<div class="stat"><div class="k">Pos jitter</div><div class="v"><span id="posJit">—</span><span class="u"> mm</span></div></div>
|
||||
<div class="stat"><div class="k">Ang jitter</div><div class="v"><span id="angJit">—</span><span class="u"> °</span></div></div>
|
||||
<div class="stat"><div class="k">Distance</div><div class="v"><span id="dist">—</span><span class="u"> mm</span></div></div>
|
||||
<div class="stat"><div class="k">View angle</div><div class="v"><span id="ang">—</span><span class="u"> °</span></div></div>
|
||||
</div>
|
||||
<div id="markersList">point at a crest…</div>
|
||||
</div>
|
||||
|
||||
<div id="controls" class="card hidden">
|
||||
<div class="row">
|
||||
<input id="note" type="text" placeholder="label this run (e.g. '1 marker, 30cm, room light)" />
|
||||
</div>
|
||||
<div class="row">
|
||||
<button id="rec" class="btn-rec">● Record</button>
|
||||
<span class="counter" id="sampleCount">0</span>
|
||||
<button id="markPoint" class="btn-ghost" title="tag a moment in the log">Flag</button>
|
||||
<button id="export" class="btn-go" style="margin-left:auto">Export ▼</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="modal">
|
||||
<div class="card">
|
||||
<h2>Session results</h2>
|
||||
<div class="summary" id="summary">—</div>
|
||||
<div class="row">
|
||||
<button class="btn-go" id="downloadJson" style="flex:1">Download JSON</button>
|
||||
<button class="btn-ghost" id="copyText" style="flex:1">Copy summary</button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:8px">
|
||||
<button class="btn-ghost" id="closeModal" style="flex:1">Close</button>
|
||||
<button class="btn-rec" id="clearAll" style="flex:1">Clear all runs</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="start">
|
||||
<h1>Newbury <span>jitter meter</span></h1>
|
||||
<p>Hold the phone steady on a crest, hit Record for ~10s, then move closer / further / to a sharper angle and record again. Export and send me the file.</p>
|
||||
<button id="go" class="btn-go" style="padding:14px 30px;border-radius:999px;font-size:16px">Start camera</button>
|
||||
<div id="err"></div>
|
||||
</div>
|
||||
|
||||
<script src="vendor/cv.js"></script>
|
||||
<script src="vendor/svd.js"></script>
|
||||
<script src="vendor/posit1.js"></script>
|
||||
<script src="vendor/aruco.js"></script>
|
||||
<script src="vendor/dictionaries/aruco_4x4_1000.js"></script>
|
||||
<script src="js/jitter.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,191 @@
|
||||
/* Newbury Exhibit — 3D LEGO-head ghost AR driver
|
||||
*
|
||||
* Camera frame -> ArUco detection -> POS-IT 6-DoF pose -> Three.js transform.
|
||||
* The ghost is placed in a Three.js scene whose camera sits at the origin; we
|
||||
* apply the marker's solved rotation+translation to the ghost so it stays fixed
|
||||
* in world space. Walk around the marker and you see the head's sides — it turns.
|
||||
*
|
||||
* This is the real-3D successor to ar-test.js (which was a flat sprite).
|
||||
*/
|
||||
import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js';
|
||||
import { buildLegoHeadGhost } from './lego-head-ghost.js?v=1';
|
||||
import { TunedDetector, MarkerTracker } from './detect-tuned.js?v=1';
|
||||
|
||||
const MARKER_SIZE_MM = 96; // outer black square; match what you build/print.
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const video = $('video');
|
||||
const canvas = $('three');
|
||||
const startScreen = $('start'), goBtn = $('go'), errEl = $('err');
|
||||
const hud = $('hud'), picker = $('picker');
|
||||
const lockTag = $('lock'), midEl = $('mid'), distEl = $('dist'), fpsEl = $('fps');
|
||||
|
||||
const grab = document.createElement('canvas');
|
||||
const gctx = grab.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
let detector = null, tracker = null, posit = null, focalPx = 0, running = false;
|
||||
|
||||
// ---- Three.js setup -------------------------------------------------------
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||||
renderer.setClearColor(0x000000, 0); // transparent over video
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.add(new THREE.AmbientLight(0xb8c4e0, 1.1));
|
||||
const key = new THREE.DirectionalLight(0xffffff, 0.8);
|
||||
key.position.set(0.3, 1, 0.6);
|
||||
scene.add(key);
|
||||
|
||||
// Camera at origin looking down -Z (we move the ghost, not the camera).
|
||||
let camera = new THREE.PerspectiveCamera(50, 1, 1, 5000); // units: mm
|
||||
camera.position.set(0, 0, 0);
|
||||
|
||||
// The ghost head, parented to an "anchor" object we drive from pose.
|
||||
const anchor = new THREE.Object3D();
|
||||
scene.add(anchor);
|
||||
let ghost = buildLegoHeadGhost(THREE, 'Blue');
|
||||
ghost.userData.setBaseY(MARKER_SIZE_MM * 0.9); // float above the plaque
|
||||
anchor.add(ghost);
|
||||
anchor.visible = false;
|
||||
|
||||
// tether line marker->ghost so it reads as bound to the crest
|
||||
const tetherMat = new THREE.LineBasicMaterial({ color: 0x51eaf1, transparent: true, opacity: 0.35 });
|
||||
const tetherGeo = new THREE.BufferGeometry().setFromPoints([
|
||||
new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, MARKER_SIZE_MM * 0.65, 0),
|
||||
]);
|
||||
const tether = new THREE.Line(tetherGeo, tetherMat);
|
||||
anchor.add(tether);
|
||||
|
||||
// ---- Pose -> matrix -------------------------------------------------------
|
||||
// js-aruco POS-IT gives rotation (3x3) + translation (mm) of the marker in a
|
||||
// camera frame with X right, Y up, Z toward viewer. Three.js camera looks down
|
||||
// -Z with Y up, so we flip Y and Z to convert. Marker plane is the anchor's XZ.
|
||||
const poseMatrix = new THREE.Matrix4();
|
||||
const flip = new THREE.Matrix4().makeScale(1, -1, -1);
|
||||
|
||||
function centredCorners(m, w, h) {
|
||||
return m.corners.map((c) => ({ x: c.x - w / 2, y: h / 2 - c.y }));
|
||||
}
|
||||
|
||||
function applyPose(m) {
|
||||
const pose = posit.pose(centredCorners(m, grab.width, grab.height));
|
||||
if (!pose) return false;
|
||||
const R = pose.bestRotation, t = pose.bestTranslation;
|
||||
// Build matrix: rows of R, translation in last column.
|
||||
poseMatrix.set(
|
||||
R[0][0], R[0][1], R[0][2], t[0],
|
||||
R[1][0], R[1][1], R[1][2], t[1],
|
||||
R[2][0], R[2][1], R[2][2], t[2],
|
||||
0, 0, 0, 1
|
||||
);
|
||||
// Convert axis convention.
|
||||
poseMatrix.premultiply(flip);
|
||||
anchor.matrixAutoUpdate = false;
|
||||
anchor.matrix.copy(poseMatrix);
|
||||
// The marker's own plane is XY in POS-IT; we want the ghost to stand UP off
|
||||
// the table, i.e. along the marker normal. Rotate the head so its +Y aligns
|
||||
// with the marker normal (marker's local +Z).
|
||||
ghost.rotation.x = -Math.PI / 2; // stand head up out of the marker plane
|
||||
tether.rotation.x = -Math.PI / 2;
|
||||
return { dist: Math.hypot(t[0], t[1], t[2]) };
|
||||
}
|
||||
|
||||
// ---- sizing ---------------------------------------------------------------
|
||||
function sizeAll() {
|
||||
const vw = video.videoWidth, vh = video.videoHeight;
|
||||
if (!vw || !vh) return;
|
||||
grab.width = vw; grab.height = vh;
|
||||
const scale = Math.max(window.innerWidth / vw, window.innerHeight / vh);
|
||||
const dw = vw * scale, dh = vh * scale;
|
||||
for (const el of [video, canvas]) { el.style.width = dw + 'px'; el.style.height = dh + 'px'; }
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.setSize(dw, dh, false);
|
||||
camera.aspect = dw / dh;
|
||||
// Approximate focal length; vertical FOV from focal & sensor height in px.
|
||||
focalPx = vw;
|
||||
const fovY = 2 * Math.atan((vh / 2) / focalPx) * (180 / Math.PI);
|
||||
camera.fov = fovY * (dh / vh) / (dw / vw < 1 ? 1 : 1); // keep simple
|
||||
camera.fov = fovY;
|
||||
camera.updateProjectionMatrix();
|
||||
posit = new POS.Posit(MARKER_SIZE_MM, focalPx);
|
||||
}
|
||||
|
||||
// ---- start ----------------------------------------------------------------
|
||||
async function start() {
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false,
|
||||
});
|
||||
video.srcObject = stream; await video.play();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Camera unavailable: ' + (e.message || e.name) + '. iOS needs HTTPS + camera permission.';
|
||||
return;
|
||||
}
|
||||
detector = new TunedDetector({ dictionaryName: 'ARUCO_4X4_1000' }).setPreset('forgiving');
|
||||
tracker = new MarkerTracker({ coastMs: 280, smooth: true });
|
||||
await new Promise((res) => { if (video.readyState >= 2) res(); else video.onloadeddata = () => res(); });
|
||||
sizeAll();
|
||||
window.addEventListener('resize', sizeAll);
|
||||
startScreen.classList.add('hidden');
|
||||
hud.classList.remove('hidden'); picker.classList.remove('hidden');
|
||||
running = true; requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
// ---- loop -----------------------------------------------------------------
|
||||
let frames = 0, fpsStamp = performance.now();
|
||||
|
||||
function loop(now) {
|
||||
if (!running) return;
|
||||
requestAnimationFrame(loop);
|
||||
if (video.readyState < 2 || !grab.width) return;
|
||||
|
||||
gctx.drawImage(video, 0, 0, grab.width, grab.height);
|
||||
const img = gctx.getImageData(0, 0, grab.width, grab.height);
|
||||
let raw = [];
|
||||
try { raw = detector.detect(img, { width: grab.width, height: grab.height }); } catch (_) { raw = []; }
|
||||
|
||||
// coast through dropouts + smooth corners
|
||||
const markers = tracker.update(raw, now);
|
||||
|
||||
if (markers.length) {
|
||||
// nearest-ish: smallest marker id-agnostic; just take first track
|
||||
const m = markers[0];
|
||||
const res = applyPose(m);
|
||||
if (res) {
|
||||
anchor.visible = true;
|
||||
if (m.coasting) {
|
||||
lockTag.textContent = 'holding…'; lockTag.className = 'tag on';
|
||||
} else {
|
||||
lockTag.textContent = 'locked'; lockTag.className = 'tag on';
|
||||
}
|
||||
midEl.textContent = m.id;
|
||||
distEl.textContent = Math.round(res.dist);
|
||||
}
|
||||
} else {
|
||||
anchor.visible = false;
|
||||
lockTag.textContent = 'searching…'; lockTag.className = 'tag off';
|
||||
midEl.textContent = '—'; distEl.textContent = '—';
|
||||
}
|
||||
|
||||
ghost.userData.update(now / 1000);
|
||||
renderer.render(scene, camera);
|
||||
|
||||
frames++;
|
||||
if (now - fpsStamp > 500) { fpsEl.textContent = Math.round((frames * 1000) / (now - fpsStamp)); frames = 0; fpsStamp = now; }
|
||||
}
|
||||
|
||||
// ---- colour picker --------------------------------------------------------
|
||||
picker.addEventListener('click', (e) => {
|
||||
const sw = e.target.closest('.swatch'); if (!sw) return;
|
||||
picker.querySelectorAll('.swatch').forEach((s) => s.classList.remove('sel'));
|
||||
sw.classList.add('sel');
|
||||
ghost.userData.setColor(sw.dataset.c);
|
||||
});
|
||||
|
||||
goBtn.addEventListener('click', start);
|
||||
|
||||
// live sensitivity switch
|
||||
const presetSel = document.getElementById('preset');
|
||||
if (presetSel) presetSel.addEventListener('change', () => {
|
||||
if (detector) detector.setPreset(presetSel.value);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/* Newbury Exhibit — forgiving detection + tracking
|
||||
*
|
||||
* Wraps js-aruco2 to make marker scanning more tolerant:
|
||||
* 1. Looser detector params (find smaller / dimmer / sharper-angle markers).
|
||||
* 2. Optional multi-scale pass (re-detect on a downscaled frame to catch
|
||||
* distant or motion-blurred markers that fail at full res).
|
||||
* 3. A stateful Tracker that COASTS through brief dropouts (keeps the last
|
||||
* good pose for a short window instead of snapping off) and SMOOTHS the
|
||||
* corners with an adaptive 1€-style filter (steady when still, responsive
|
||||
* when moving).
|
||||
*
|
||||
* Usage:
|
||||
* const td = new TunedDetector({ dictionaryName:'ARUCO_4X4_1000' });
|
||||
* const markers = td.detect(imageData, { width, height }); // raw, tuned
|
||||
* const tracker = new MarkerTracker();
|
||||
* const tracked = tracker.update(markers, nowMs); // coasted+smoothed
|
||||
*
|
||||
* Each tracked marker: { id, corners:[{x,y}*4], coasting:bool, age:ms }.
|
||||
* Corners are in the SAME pixel space js-aruco returns (full-res frame).
|
||||
*/
|
||||
|
||||
/* global AR, CV */
|
||||
|
||||
export const DETECT_PRESETS = {
|
||||
// crisp, fast — good light, marker fills a decent part of frame
|
||||
strict: { minSizeRatio: 0.04, epsilon: 0.05, threshKernel: 2, threshBias: 7, warp: 49, multiScale: false },
|
||||
// the sensible default for an exhibit — noticeably more forgiving
|
||||
forgiving:{ minSizeRatio: 0.015, epsilon: 0.06, threshKernel: 2, threshBias: 7, warp: 49, multiScale: true },
|
||||
// last resort — distant/dim/awkward; costs more CPU
|
||||
greedy: { minSizeRatio: 0.008, epsilon: 0.08, threshKernel: 3, threshBias: 9, warp: 49, multiScale: true },
|
||||
};
|
||||
|
||||
export class TunedDetector {
|
||||
constructor(config) {
|
||||
this.det = new AR.Detector(config);
|
||||
this.preset = DETECT_PRESETS.forgiving;
|
||||
// scratch image objects for the downscaled pass
|
||||
this._small = null;
|
||||
this._smallCanvas = null;
|
||||
}
|
||||
|
||||
setPreset(name) { this.preset = DETECT_PRESETS[name] || this.preset; return this; }
|
||||
|
||||
// One detection pass at the detector's native resolution, using tuned params.
|
||||
_pass(image) {
|
||||
const d = this.det;
|
||||
CV.grayscale(image, d.grey);
|
||||
// threshKernel/threshBias widen tolerance to uneven lighting.
|
||||
CV.adaptiveThreshold(d.grey, d.thres, this.preset.threshKernel, this.preset.threshBias);
|
||||
d.contours = CV.findContours(d.thres, d.binary);
|
||||
d.candidates = d.findCandidates(
|
||||
d.contours,
|
||||
image.width * this.preset.minSizeRatio, // smaller => accepts smaller markers
|
||||
this.preset.epsilon, // looser polygon approximation
|
||||
10
|
||||
);
|
||||
d.candidates = d.clockwiseCorners(d.candidates);
|
||||
d.candidates = d.notTooNear(d.candidates, 10);
|
||||
return d.findMarkers(d.grey, d.candidates, this.preset.warp);
|
||||
}
|
||||
|
||||
// Optional second pass on a half-res copy; corners scaled back to full res.
|
||||
_passDownscaled(imageData, width, height) {
|
||||
const sw = Math.round(width / 2), sh = Math.round(height / 2);
|
||||
if (!this._smallCanvas) {
|
||||
this._smallCanvas = document.createElement('canvas');
|
||||
this._smallCtx = this._smallCanvas.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
this._smallCanvas.width = sw; this._smallCanvas.height = sh;
|
||||
// draw the full-res ImageData down by putting it on a temp canvas then scaling
|
||||
if (!this._fullCanvas) {
|
||||
this._fullCanvas = document.createElement('canvas');
|
||||
this._fullCtx = this._fullCanvas.getContext('2d', { willReadFrequently: true });
|
||||
}
|
||||
this._fullCanvas.width = width; this._fullCanvas.height = height;
|
||||
this._fullCtx.putImageData(imageData, 0, 0);
|
||||
this._smallCtx.drawImage(this._fullCanvas, 0, 0, sw, sh);
|
||||
const smallImg = this._smallCtx.getImageData(0, 0, sw, sh);
|
||||
const found = this._pass(smallImg);
|
||||
for (const m of found) m.corners = m.corners.map((c) => ({ x: c.x * 2, y: c.y * 2 }));
|
||||
return found;
|
||||
}
|
||||
|
||||
detect(imageData, dims) {
|
||||
let markers = [];
|
||||
try { markers = this._pass(imageData); } catch (_) { markers = []; }
|
||||
|
||||
if (this.preset.multiScale && dims) {
|
||||
const seen = new Set(markers.map((m) => m.id));
|
||||
let extra = [];
|
||||
try { extra = this._passDownscaled(imageData, dims.width, dims.height); } catch (_) { extra = []; }
|
||||
for (const m of extra) if (!seen.has(m.id)) markers.push(m);
|
||||
}
|
||||
return markers;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 1€ filter (adaptive smoothing) ----
|
||||
* Smooths a value with a cutoff that rises with speed: still hand => heavy
|
||||
* smoothing (kills jitter); moving fast => light smoothing (stays responsive).
|
||||
*/
|
||||
class OneEuro {
|
||||
constructor(minCutoff = 1.2, beta = 0.012, dCutoff = 1.0) {
|
||||
this.minCutoff = minCutoff; this.beta = beta; this.dCutoff = dCutoff;
|
||||
this.xPrev = null; this.dxPrev = 0; this.tPrev = null;
|
||||
}
|
||||
_alpha(cutoff, dt) {
|
||||
const tau = 1 / (2 * Math.PI * cutoff);
|
||||
return 1 / (1 + tau / dt);
|
||||
}
|
||||
filter(x, tMs) {
|
||||
if (this.xPrev == null) { this.xPrev = x; this.tPrev = tMs; return x; }
|
||||
let dt = (tMs - this.tPrev) / 1000; if (dt <= 0) dt = 1 / 60;
|
||||
this.tPrev = tMs;
|
||||
const dx = (x - this.xPrev) / dt;
|
||||
const aD = this._alpha(this.dCutoff, dt);
|
||||
const dxHat = aD * dx + (1 - aD) * this.dxPrev;
|
||||
this.dxPrev = dxHat;
|
||||
const cutoff = this.minCutoff + this.beta * Math.abs(dxHat);
|
||||
const a = this._alpha(cutoff, dt);
|
||||
const xHat = a * x + (1 - a) * this.xPrev;
|
||||
this.xPrev = xHat;
|
||||
return xHat;
|
||||
}
|
||||
}
|
||||
|
||||
export class MarkerTracker {
|
||||
/* coastMs: how long to keep showing a marker after it stops being detected.
|
||||
* Higher = more stable through blur/occlusion, but a truly-gone marker lingers. */
|
||||
constructor({ coastMs = 250, smooth = true } = {}) {
|
||||
this.coastMs = coastMs;
|
||||
this.smooth = smooth;
|
||||
this.tracks = new Map(); // id -> { corners, filters:[8 OneEuro], lastSeen, firstSeen }
|
||||
}
|
||||
|
||||
update(markers, nowMs) {
|
||||
// refresh / create tracks for detected markers
|
||||
const detectedIds = new Set();
|
||||
for (const m of markers) {
|
||||
detectedIds.add(m.id);
|
||||
let tr = this.tracks.get(m.id);
|
||||
if (!tr) {
|
||||
tr = {
|
||||
id: m.id,
|
||||
filters: Array.from({ length: 8 }, () => new OneEuro()),
|
||||
firstSeen: nowMs,
|
||||
corners: m.corners.map((c) => ({ x: c.x, y: c.y })),
|
||||
};
|
||||
this.tracks.set(m.id, tr);
|
||||
}
|
||||
// smooth each corner coordinate
|
||||
const sm = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const cx = m.corners[i].x, cy = m.corners[i].y;
|
||||
sm.push(this.smooth
|
||||
? { x: tr.filters[i * 2].filter(cx, nowMs), y: tr.filters[i * 2 + 1].filter(cy, nowMs) }
|
||||
: { x: cx, y: cy });
|
||||
}
|
||||
tr.corners = sm;
|
||||
tr.lastSeen = nowMs;
|
||||
tr.coasting = false;
|
||||
}
|
||||
|
||||
// emit detected + still-coasting tracks; drop expired ones
|
||||
const out = [];
|
||||
for (const [id, tr] of this.tracks) {
|
||||
const age = nowMs - tr.lastSeen;
|
||||
if (detectedIds.has(id)) {
|
||||
out.push({ id, corners: tr.corners, coasting: false, age: nowMs - tr.firstSeen });
|
||||
} else if (age <= this.coastMs) {
|
||||
// coast: hold last good corners so the ghost doesn't snap off
|
||||
out.push({ id, corners: tr.corners, coasting: true, age: nowMs - tr.firstSeen });
|
||||
} else {
|
||||
this.tracks.delete(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
reset() { this.tracks.clear(); }
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
/* Newbury Exhibit — pose jitter meter
|
||||
*
|
||||
* Measures how stable the solved camera pose is, per marker, while you hold still.
|
||||
* "Jitter" = standard deviation of the marker's solved position (mm) and orientation
|
||||
* (deg) over a short rolling window. Low jitter = occlusion will look clean.
|
||||
*
|
||||
* Records timestamped samples during a run and exports JSON + a human summary you
|
||||
* can hand back for analysis.
|
||||
*
|
||||
* Pose: js-aruco2 POS.Posit. Detector dictionary ARUCO_4X4_1000.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// ---- Physical constants -------------------------------------------------
|
||||
// The full printed/built marker the POS-IT model spans corner-to-corner.
|
||||
// For an ArUco 4x4 the *outer black square* is 6 cells. With 16mm cells that
|
||||
// is 96mm. If you test with a different printed size, change this and re-run.
|
||||
const MARKER_SIZE_MM = 96;
|
||||
|
||||
// ---- DOM ----------------------------------------------------------------
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const video = $('video'), overlay = $('overlay'), ctx = overlay.getContext('2d');
|
||||
const startScreen = $('start'), goBtn = $('go'), errEl = $('err');
|
||||
const hud = $('hud'), controls = $('controls');
|
||||
const lockTag = $('lock'), fpsEl = $('fps');
|
||||
const posJitEl = $('posJit'), angJitEl = $('angJit'), distEl = $('dist'), angEl = $('ang');
|
||||
const markersListEl = $('markersList');
|
||||
const recBtn = $('rec'), sampleCountEl = $('sampleCount'), exportBtn = $('export'), markPointBtn = $('markPoint');
|
||||
const noteEl = $('note');
|
||||
const recBadge = $('recBadge'), recTimeEl = $('recTime');
|
||||
const modal = $('modal'), summaryEl = $('summary');
|
||||
|
||||
const grab = document.createElement('canvas');
|
||||
const gctx = grab.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
let detector = null, posit = null, focalPx = 0;
|
||||
let running = false;
|
||||
|
||||
// ---- Rolling jitter window (per marker id) ------------------------------
|
||||
// Keep recent pose samples; jitter = stddev over the window.
|
||||
const WINDOW = 30; // ~1s at 30fps
|
||||
const histories = new Map(); // id -> { pos:[{x,y,z}], rot:[ [3x3] ], t:[] }
|
||||
|
||||
function pushHistory(id, pos, rotEuler) {
|
||||
let h = histories.get(id);
|
||||
if (!h) { h = { pos: [], rot: [] }; histories.set(id, h); }
|
||||
h.pos.push(pos); h.rot.push(rotEuler);
|
||||
if (h.pos.length > WINDOW) { h.pos.shift(); h.rot.shift(); }
|
||||
return h;
|
||||
}
|
||||
|
||||
function stddev(arr) {
|
||||
const n = arr.length; if (n < 2) return 0;
|
||||
const m = arr.reduce((a, b) => a + b, 0) / n;
|
||||
const v = arr.reduce((a, b) => a + (b - m) * (b - m), 0) / (n - 1);
|
||||
return Math.sqrt(v);
|
||||
}
|
||||
|
||||
// Position jitter: RMS of per-axis stddev (mm). Angle jitter: RMS of euler stddev (deg).
|
||||
function jitterOf(h) {
|
||||
if (h.pos.length < 4) return null;
|
||||
const sx = stddev(h.pos.map((p) => p.x));
|
||||
const sy = stddev(h.pos.map((p) => p.y));
|
||||
const sz = stddev(h.pos.map((p) => p.z));
|
||||
const posJit = Math.sqrt(sx * sx + sy * sy + sz * sz);
|
||||
const ra = stddev(h.rot.map((r) => r.x));
|
||||
const rb = stddev(h.rot.map((r) => r.y));
|
||||
const rc = stddev(h.rot.map((r) => r.z));
|
||||
const angJit = Math.sqrt(ra * ra + rb * rb + rc * rc);
|
||||
return { posJit, angJit, samples: h.pos.length };
|
||||
}
|
||||
|
||||
// ---- Pose helpers -------------------------------------------------------
|
||||
// js-aruco image points must be centred (origin = image centre, y up).
|
||||
function centredCorners(m, w, h) {
|
||||
return m.corners.map((c) => ({ x: c.x - w / 2, y: h / 2 - c.y }));
|
||||
}
|
||||
|
||||
function rotToEuler(R) {
|
||||
// R is 3x3 array. Return degrees (x=pitch,y=yaw,z=roll). Good enough for jitter.
|
||||
const sy = Math.sqrt(R[0][0] * R[0][0] + R[1][0] * R[1][0]);
|
||||
let x, y, z;
|
||||
if (sy > 1e-6) {
|
||||
x = Math.atan2(R[2][1], R[2][2]);
|
||||
y = Math.atan2(-R[2][0], sy);
|
||||
z = Math.atan2(R[1][0], R[0][0]);
|
||||
} else {
|
||||
x = Math.atan2(-R[1][2], R[1][1]); y = Math.atan2(-R[2][0], sy); z = 0;
|
||||
}
|
||||
const d = 180 / Math.PI;
|
||||
return { x: x * d, y: y * d, z: z * d };
|
||||
}
|
||||
|
||||
function poseForMarker(m, w, h) {
|
||||
const pts = centredCorners(m, w, h);
|
||||
const pose = posit.pose(pts);
|
||||
if (!pose) return null;
|
||||
const t = pose.bestTranslation; // [x,y,z] in mm, camera space
|
||||
const R = pose.bestRotation; // 3x3
|
||||
const pos = { x: t[0], y: t[1], z: t[2] };
|
||||
const dist = Math.sqrt(pos.x * pos.x + pos.y * pos.y + pos.z * pos.z);
|
||||
// View angle: angle between camera->marker axis and the marker's normal (z col of R).
|
||||
const normal = { x: R[0][2], y: R[1][2], z: R[2][2] };
|
||||
const toCam = { x: -pos.x / dist, y: -pos.y / dist, z: -pos.z / dist };
|
||||
let dot = normal.x * toCam.x + normal.y * toCam.y + normal.z * toCam.z;
|
||||
dot = Math.max(-1, Math.min(1, Math.abs(dot)));
|
||||
const viewAngle = Math.acos(dot) * 180 / Math.PI; // 0 = head-on
|
||||
return { pos, euler: rotToEuler(R), dist, viewAngle, reprojErr: pose.bestError };
|
||||
}
|
||||
|
||||
// ---- Session recording --------------------------------------------------
|
||||
const session = {
|
||||
startedAt: null,
|
||||
markerSizeMm: MARKER_SIZE_MM,
|
||||
device: navigator.userAgent,
|
||||
runs: [], // each: { label, startedAt, durationMs, samples:[...], flags:[...] }
|
||||
};
|
||||
let currentRun = null;
|
||||
let recording = false, recStart = 0;
|
||||
|
||||
function startRun() {
|
||||
currentRun = {
|
||||
label: noteEl.value.trim() || `run ${session.runs.length + 1}`,
|
||||
startedAt: new Date().toISOString(),
|
||||
videoWidth: grab.width, videoHeight: grab.height,
|
||||
focalPx: Math.round(focalPx),
|
||||
samples: [], flags: [],
|
||||
};
|
||||
recording = true; recStart = performance.now();
|
||||
recBtn.classList.add('recording'); recBtn.textContent = '■ Stop';
|
||||
recBadge.classList.add('show');
|
||||
}
|
||||
|
||||
function stopRun() {
|
||||
recording = false;
|
||||
currentRun.durationMs = Math.round(performance.now() - recStart);
|
||||
// attach computed summary for this run
|
||||
currentRun.summary = summariseRun(currentRun);
|
||||
session.runs.push(currentRun);
|
||||
recBtn.classList.remove('recording'); recBtn.textContent = '● Record';
|
||||
recBadge.classList.remove('show');
|
||||
currentRun = null;
|
||||
persist();
|
||||
}
|
||||
|
||||
function summariseRun(run) {
|
||||
// Aggregate per-marker jitter + distance across the recorded samples.
|
||||
const byId = new Map();
|
||||
for (const s of run.samples) {
|
||||
for (const mk of s.markers) {
|
||||
let g = byId.get(mk.id);
|
||||
if (!g) { g = { id: mk.id, posJit: [], angJit: [], dist: [], viewAngle: [], reproj: [] }; byId.set(mk.id, g); }
|
||||
if (mk.posJit != null) g.posJit.push(mk.posJit);
|
||||
if (mk.angJit != null) g.angJit.push(mk.angJit);
|
||||
g.dist.push(mk.dist); g.viewAngle.push(mk.viewAngle); g.reproj.push(mk.reproj);
|
||||
}
|
||||
}
|
||||
const mean = (a) => a.length ? a.reduce((x, y) => x + y, 0) / a.length : null;
|
||||
const med = (a) => { if (!a.length) return null; const b = [...a].sort((x, y) => x - y); return b[Math.floor(b.length / 2)]; };
|
||||
const out = [];
|
||||
for (const g of byId.values()) {
|
||||
out.push({
|
||||
id: g.id, frames: g.dist.length,
|
||||
posJitMeanMm: round(mean(g.posJit)), posJitMedMm: round(med(g.posJit)),
|
||||
angJitMeanDeg: round(mean(g.angJit)), angJitMedDeg: round(med(g.angJit)),
|
||||
distMeanMm: round(mean(g.dist)), viewAngleMeanDeg: round(mean(g.viewAngle)),
|
||||
reprojMeanPx: round(mean(g.reproj)),
|
||||
});
|
||||
}
|
||||
// multi-marker stat: how many frames saw N markers
|
||||
const multi = {};
|
||||
for (const s of run.samples) { const n = s.markers.length; multi[n] = (multi[n] || 0) + 1; }
|
||||
return { perMarker: out, markersPerFrame: multi };
|
||||
}
|
||||
|
||||
function round(n) { return n == null ? null : Math.round(n * 100) / 100; }
|
||||
|
||||
function persist() {
|
||||
try { localStorage.setItem('newbury-jitter-session', JSON.stringify(session)); } catch (_) {}
|
||||
}
|
||||
function restore() {
|
||||
try {
|
||||
const raw = localStorage.getItem('newbury-jitter-session');
|
||||
if (raw) { const s = JSON.parse(raw); if (s.runs) { session.runs = s.runs; } }
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ---- Camera + loop ------------------------------------------------------
|
||||
function sizeToVideo() {
|
||||
const vw = video.videoWidth, vh = video.videoHeight;
|
||||
if (!vw || !vh) return;
|
||||
grab.width = vw; grab.height = vh;
|
||||
const scale = Math.max(window.innerWidth / vw, window.innerHeight / vh);
|
||||
const dw = vw * scale, dh = vh * scale;
|
||||
for (const el of [video, overlay]) { el.style.width = dw + 'px'; el.style.height = dh + 'px'; }
|
||||
overlay.width = dw; overlay.height = dh; overlay._scale = scale;
|
||||
// Focal length estimate in px. Without calibration, ~1.0*width is a decent
|
||||
// phone-camera approximation; jitter is relative so exact focal isn't critical.
|
||||
focalPx = vw;
|
||||
posit = new POS.Posit(MARKER_SIZE_MM, focalPx);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 } }, audio: false,
|
||||
});
|
||||
video.srcObject = stream; await video.play();
|
||||
} catch (e) {
|
||||
errEl.textContent = 'Camera unavailable: ' + (e.message || e.name) + '. iOS needs HTTPS + camera permission.';
|
||||
return;
|
||||
}
|
||||
detector = new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000' });
|
||||
await new Promise((res) => { if (video.readyState >= 2) res(); else video.onloadeddata = () => res(); });
|
||||
sizeToVideo();
|
||||
window.addEventListener('resize', sizeToVideo);
|
||||
startScreen.classList.add('hidden');
|
||||
hud.classList.remove('hidden'); controls.classList.remove('hidden');
|
||||
running = true; requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
let frames = 0, fpsStamp = performance.now();
|
||||
function loop(now) {
|
||||
if (!running) return;
|
||||
requestAnimationFrame(loop);
|
||||
if (video.readyState < 2 || !grab.width) return;
|
||||
|
||||
gctx.drawImage(video, 0, 0, grab.width, grab.height);
|
||||
const img = gctx.getImageData(0, 0, grab.width, grab.height);
|
||||
let markers = []; try { markers = detector.detect(img); } catch (_) { markers = []; }
|
||||
|
||||
const s = overlay._scale || 1;
|
||||
ctx.clearRect(0, 0, overlay.width, overlay.height);
|
||||
|
||||
const frameSample = { t: Math.round(now), markers: [] };
|
||||
let primary = null;
|
||||
|
||||
for (const m of markers) {
|
||||
drawOutline(m, s, markers.length > 1);
|
||||
const p = poseForMarker(m, grab.width, grab.height);
|
||||
if (!p) continue;
|
||||
const h = pushHistory(m.id, p.pos, p.euler);
|
||||
const j = jitterOf(h);
|
||||
const rec = {
|
||||
id: m.id, dist: round(p.dist), viewAngle: round(p.viewAngle),
|
||||
reproj: round(p.reprojErr),
|
||||
posJit: j ? round(j.posJit) : null, angJit: j ? round(j.angJit) : null,
|
||||
hamming: m.hammingDistance,
|
||||
};
|
||||
frameSample.markers.push(rec);
|
||||
if (!primary || p.dist < primary.dist) primary = { ...rec, dist: p.dist };
|
||||
}
|
||||
|
||||
// HUD
|
||||
if (primary) {
|
||||
lockTag.textContent = markers.length > 1 ? `${markers.length} markers` : 'locked';
|
||||
lockTag.className = 'tag on';
|
||||
setJit(posJitEl, primary.posJit, 0.5, 1.5); // <0.5mm good, >1.5 bad
|
||||
setJit(angJitEl, primary.angJit, 0.3, 1.0); // degrees
|
||||
distEl.textContent = Math.round(primary.dist);
|
||||
angEl.textContent = primary.viewAngle != null ? Math.round(primary.viewAngle) : '—';
|
||||
markersListEl.innerHTML = markers.map((m) => {
|
||||
const r = frameSample.markers.find((x) => x.id === m.id);
|
||||
return `id <b>${m.id}</b> · ${r ? Math.round(r.dist) : '?'}mm · jit ${r && r.posJit != null ? r.posJit.toFixed(2) : '—'}mm`;
|
||||
}).join(' ');
|
||||
} else {
|
||||
lockTag.textContent = 'no marker'; lockTag.className = 'tag off';
|
||||
posJitEl.textContent = angJitEl.textContent = distEl.textContent = angEl.textContent = '—';
|
||||
posJitEl.className = angJitEl.className = '';
|
||||
markersListEl.textContent = 'point at a crest…';
|
||||
}
|
||||
|
||||
if (recording) {
|
||||
currentRun.samples.push(frameSample);
|
||||
sampleCountEl.textContent = currentRun.samples.length;
|
||||
recTimeEl.textContent = ((now - recStart) / 1000).toFixed(1) + 's';
|
||||
}
|
||||
|
||||
frames++;
|
||||
if (now - fpsStamp > 500) { fpsEl.textContent = Math.round((frames * 1000) / (now - fpsStamp)); frames = 0; fpsStamp = now; }
|
||||
}
|
||||
|
||||
function setJit(el, val, goodT, badT) {
|
||||
if (val == null) { el.textContent = '—'; el.className = ''; return; }
|
||||
el.textContent = val.toFixed(2);
|
||||
el.className = val <= goodT ? 'jitter-good' : (val >= badT ? 'jitter-bad' : 'jitter-warn');
|
||||
}
|
||||
|
||||
function drawOutline(m, s, multi) {
|
||||
const c = m.corners.map((p) => ({ x: p.x * s, y: p.y * s }));
|
||||
ctx.lineWidth = 3;
|
||||
ctx.strokeStyle = multi ? 'rgba(81,234,241,0.95)' : 'rgba(82,158,255,0.9)';
|
||||
ctx.beginPath(); ctx.moveTo(c[0].x, c[0].y);
|
||||
for (let i = 1; i < c.length; i++) ctx.lineTo(c[i].x, c[i].y);
|
||||
ctx.closePath(); ctx.stroke();
|
||||
ctx.fillStyle = '#fff35d';
|
||||
ctx.beginPath(); ctx.arc(c[0].x, c[0].y, 5, 0, Math.PI * 2); ctx.fill();
|
||||
// id label
|
||||
const cx = (c[0].x + c[2].x) / 2, cy = (c[0].y + c[2].y) / 2;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.95)'; ctx.font = 'bold 16px system-ui';
|
||||
ctx.textAlign = 'center'; ctx.fillText(String(m.id), cx, cy);
|
||||
}
|
||||
|
||||
// ---- Export -------------------------------------------------------------
|
||||
function buildSummaryText() {
|
||||
const lines = [];
|
||||
lines.push('NEWBURY EXHIBIT — pose jitter report');
|
||||
lines.push('generated: ' + new Date().toISOString());
|
||||
lines.push('marker size: ' + MARKER_SIZE_MM + 'mm · device: ' + shortUA());
|
||||
lines.push('runs: ' + session.runs.length);
|
||||
lines.push('');
|
||||
session.runs.forEach((run, i) => {
|
||||
lines.push(`── Run ${i + 1}: "${run.label}"`);
|
||||
lines.push(` ${run.videoWidth}x${run.videoHeight}px · focal≈${run.focalPx}px · ${(run.durationMs / 1000).toFixed(1)}s · ${run.samples.length} frames`);
|
||||
const mpf = run.summary.markersPerFrame || {};
|
||||
lines.push(' markers/frame: ' + Object.entries(mpf).map(([n, c]) => `${n}→${c}`).join(' '));
|
||||
for (const pm of run.summary.perMarker) {
|
||||
lines.push(` • id ${pm.id}: posJit ${pm.posJitMedMm}mm (med) / ${pm.posJitMeanMm} (mean), angJit ${pm.angJitMedDeg}° , dist ${pm.distMeanMm}mm, view ${pm.viewAngleMeanDeg}°, reproj ${pm.reprojMeanPx}px, ${pm.frames}f`);
|
||||
}
|
||||
if (run.flags.length) lines.push(' flags: ' + run.flags.map((f) => `${(f.atMs / 1000).toFixed(1)}s`).join(', '));
|
||||
lines.push('');
|
||||
});
|
||||
lines.push('Lower posJit = steadier pose = cleaner occlusion. Compare 1-marker vs multi-marker runs.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function shortUA() {
|
||||
const ua = navigator.userAgent;
|
||||
const m = ua.match(/(iPhone|iPad|Android)[^)]*/);
|
||||
return m ? m[0].slice(0, 40) : ua.slice(0, 40);
|
||||
}
|
||||
|
||||
function openExport() {
|
||||
if (recording) stopRun();
|
||||
summaryEl.textContent = session.runs.length ? buildSummaryText() : 'No runs recorded yet. Hit Record, hold on a crest ~10s, Stop, then Export.';
|
||||
modal.classList.add('show');
|
||||
}
|
||||
|
||||
function downloadJson() {
|
||||
const blob = new Blob([JSON.stringify(session, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
a.href = url; a.download = `newbury-jitter-${stamp}.json`;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
async function copyText() {
|
||||
try { await navigator.clipboard.writeText(buildSummaryText()); $('copyText').textContent = 'Copied ✓'; setTimeout(() => $('copyText').textContent = 'Copy summary', 1500); }
|
||||
catch (_) { /* clipboard may be blocked; JSON download is the reliable path */ }
|
||||
}
|
||||
|
||||
// ---- Wire up ------------------------------------------------------------
|
||||
goBtn.addEventListener('click', start);
|
||||
recBtn.addEventListener('click', () => { recording ? stopRun() : startRun(); });
|
||||
markPointBtn.addEventListener('click', () => {
|
||||
if (recording && currentRun) currentRun.flags.push({ atMs: Math.round(performance.now() - recStart) });
|
||||
});
|
||||
exportBtn.addEventListener('click', openExport);
|
||||
$('closeModal').addEventListener('click', () => modal.classList.remove('show'));
|
||||
$('downloadJson').addEventListener('click', downloadJson);
|
||||
$('copyText').addEventListener('click', copyText);
|
||||
$('clearAll').addEventListener('click', () => {
|
||||
if (confirm('Clear all recorded runs?')) { session.runs = []; persist(); summaryEl.textContent = 'Cleared.'; sampleCountEl.textContent = '0'; }
|
||||
});
|
||||
|
||||
restore();
|
||||
})();
|
||||
@@ -0,0 +1,192 @@
|
||||
/* Newbury Exhibit — LEGO-head ghost (3D)
|
||||
*
|
||||
* A classic minifig-head SILHOUETTE (cylinder body, domed top, stud) given a
|
||||
* translucent ghost treatment in a Hidden Side gradient. Built from primitives
|
||||
* so there's no external model file to load. Original eyes + wispy tail — we
|
||||
* deliberately do NOT reproduce any printed LEGO face or licensed character.
|
||||
*
|
||||
* Exports buildLegoHeadGhost(THREE, colorKey) -> THREE.Group (units: mm).
|
||||
* The group's local origin is at the centre of the head so it sits nicely on a
|
||||
* marker anchor. Call group.userData.update(tSeconds) each frame for the idle
|
||||
* bob/sway/blink animation.
|
||||
*/
|
||||
|
||||
// Hidden Side ghost-lure gradients (recovered GhostType data).
|
||||
export const GHOST_GRADIENTS = {
|
||||
Red: { top: 0xf65151, bottom: 0xff2678 },
|
||||
Yellow: { top: 0xb57f0b, bottom: 0xfff35d },
|
||||
Blue: { top: 0x529eff, bottom: 0x51eaf1 },
|
||||
};
|
||||
|
||||
// Vertical gradient + soft rim glow, applied to the head body.
|
||||
const GHOST_VERT = `
|
||||
varying vec3 vNormalW;
|
||||
varying vec3 vViewDir;
|
||||
varying float vY;
|
||||
uniform float uMinY;
|
||||
uniform float uMaxY;
|
||||
void main() {
|
||||
vec4 wp = modelMatrix * vec4(position, 1.0);
|
||||
vNormalW = normalize(mat3(modelMatrix) * normal);
|
||||
vViewDir = normalize(cameraPosition - wp.xyz);
|
||||
vY = clamp((position.y - uMinY) / (uMaxY - uMinY), 0.0, 1.0);
|
||||
gl_Position = projectionMatrix * viewMatrix * wp;
|
||||
}
|
||||
`;
|
||||
|
||||
const GHOST_FRAG = `
|
||||
varying vec3 vNormalW;
|
||||
varying vec3 vViewDir;
|
||||
varying float vY;
|
||||
uniform vec3 uTop;
|
||||
uniform vec3 uBottom;
|
||||
uniform float uOpacity;
|
||||
uniform float uTime;
|
||||
void main() {
|
||||
// body gradient (top lighter -> bottom saturated)
|
||||
vec3 base = mix(uBottom, uTop, vY);
|
||||
// fresnel rim so the silhouette edge glows like a spectre
|
||||
float fres = pow(1.0 - max(dot(normalize(vNormalW), normalize(vViewDir)), 0.0), 2.2);
|
||||
vec3 col = base + fres * vec3(0.55, 0.85, 0.95);
|
||||
// gentle internal shimmer
|
||||
float shimmer = 0.06 * sin(vY * 18.0 - uTime * 2.2);
|
||||
col += shimmer;
|
||||
float alpha = uOpacity * (0.55 + 0.45 * fres);
|
||||
gl_FragColor = vec4(col, alpha);
|
||||
}
|
||||
`;
|
||||
|
||||
function ghostBodyMaterial(THREE, colorKey, minY, maxY) {
|
||||
const g = GHOST_GRADIENTS[colorKey] || GHOST_GRADIENTS.Blue;
|
||||
return new THREE.ShaderMaterial({
|
||||
uniforms: {
|
||||
uTop: { value: new THREE.Color(g.bottom) }, // brighter accent at top
|
||||
uBottom: { value: new THREE.Color(g.top) }, // deeper hue at base
|
||||
uOpacity: { value: 0.82 },
|
||||
uMinY: { value: minY },
|
||||
uMaxY: { value: maxY },
|
||||
uTime: { value: 0 },
|
||||
},
|
||||
vertexShader: GHOST_VERT,
|
||||
fragmentShader: GHOST_FRAG,
|
||||
transparent: true,
|
||||
depthWrite: false, // translucent: don't occlude its own backfaces oddly
|
||||
side: THREE.DoubleSide,
|
||||
blending: THREE.NormalBlending,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLegoHeadGhost(THREE, colorKey = 'Blue') {
|
||||
const group = new THREE.Group();
|
||||
|
||||
// --- dimensions in mm (classic minifig head proportions, ghost-scaled) ---
|
||||
const R = 22; // head radius
|
||||
const BODY_H = 46; // cylinder height
|
||||
const DOME_H = 16; // top dome rise
|
||||
const STUD_R = 9;
|
||||
const STUD_H = 9;
|
||||
|
||||
const minY = -BODY_H / 2 - 26; // include tail reach for gradient base
|
||||
const maxY = BODY_H / 2 + DOME_H + STUD_H;
|
||||
const bodyMat = ghostBodyMaterial(THREE, colorKey, minY, maxY);
|
||||
|
||||
const mats = [bodyMat]; // track shader mats for time uniform
|
||||
|
||||
// cylinder body
|
||||
const body = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(R, R, BODY_H, 40, 1, true),
|
||||
bodyMat
|
||||
);
|
||||
group.add(body);
|
||||
|
||||
// domed top — half-sphere squashed to DOME_H
|
||||
const dome = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(R, 40, 20, 0, Math.PI * 2, 0, Math.PI / 2),
|
||||
bodyMat
|
||||
);
|
||||
dome.scale.set(1, DOME_H / R, 1);
|
||||
dome.position.y = BODY_H / 2;
|
||||
group.add(dome);
|
||||
|
||||
// bottom cap (rounded) so the base reads solid
|
||||
const cap = new THREE.Mesh(
|
||||
new THREE.SphereGeometry(R, 40, 16, 0, Math.PI * 2, Math.PI / 2, Math.PI / 2),
|
||||
bodyMat
|
||||
);
|
||||
cap.scale.set(1, 0.35, 1);
|
||||
cap.position.y = -BODY_H / 2;
|
||||
group.add(cap);
|
||||
|
||||
// the stud on top — the unmistakable LEGO tell
|
||||
const stud = new THREE.Mesh(
|
||||
new THREE.CylinderGeometry(STUD_R, STUD_R, STUD_H, 28),
|
||||
bodyMat
|
||||
);
|
||||
stud.position.y = BODY_H / 2 + DOME_H + STUD_H / 2 - 2;
|
||||
group.add(stud);
|
||||
|
||||
// --- eyes (original, simple) — solid dark ovals on the front (+Z) face ---
|
||||
const eyeMat = new THREE.MeshBasicMaterial({ color: 0x081026 });
|
||||
const glintMat = new THREE.MeshBasicMaterial({ color: 0xcfeeff });
|
||||
function eye(sign) {
|
||||
const e = new THREE.Group();
|
||||
const ball = new THREE.Mesh(new THREE.SphereGeometry(5.2, 20, 16), eyeMat);
|
||||
ball.scale.set(0.8, 1.15, 0.5);
|
||||
e.add(ball);
|
||||
const glint = new THREE.Mesh(new THREE.SphereGeometry(1.5, 10, 8), glintMat);
|
||||
glint.position.set(sign * -0.8, 2.0, 4.6);
|
||||
e.add(glint);
|
||||
e.position.set(sign * 9, 2, R - 2);
|
||||
return e;
|
||||
}
|
||||
const eyes = new THREE.Group();
|
||||
eyes.add(eye(-1), eye(1));
|
||||
group.add(eyes);
|
||||
|
||||
// --- wispy tail: a few translucent "drips" below the body ---
|
||||
const tail = new THREE.Group();
|
||||
const tailMat = bodyMat;
|
||||
const drips = 4;
|
||||
for (let i = 0; i < drips; i++) {
|
||||
const dr = R * (0.5 - i * 0.06);
|
||||
const drip = new THREE.Mesh(
|
||||
new THREE.ConeGeometry(dr * 0.6, 22 + i * 2, 18, 1, true),
|
||||
tailMat
|
||||
);
|
||||
const ang = (i / drips) * Math.PI * 2;
|
||||
drip.position.set(Math.cos(ang) * R * 0.5, -BODY_H / 2 - 8, Math.sin(ang) * R * 0.5);
|
||||
drip.userData.phase = ang;
|
||||
tail.add(drip);
|
||||
}
|
||||
group.add(tail);
|
||||
|
||||
// --- idle animation ---
|
||||
let baseY = 0;
|
||||
group.userData.colorKey = colorKey;
|
||||
group.userData.materials = mats;
|
||||
group.userData.update = function (t) {
|
||||
for (const m of mats) m.uniforms.uTime.value = t;
|
||||
// bob + sway
|
||||
group.position.y = baseY + Math.sin(t * 1.8) * 6;
|
||||
eyes.rotation.y = Math.sin(t * 0.6) * 0.15;
|
||||
// tail drips waggle
|
||||
tail.children.forEach((d, i) => {
|
||||
d.position.y = -BODY_H / 2 - 8 + Math.sin(t * 3 + d.userData.phase) * 3;
|
||||
d.rotation.z = Math.sin(t * 2 + i) * 0.12;
|
||||
});
|
||||
// occasional blink (scale eyes flat briefly)
|
||||
const blink = (Math.sin(t * 0.9) > 0.985) ? 0.1 : 1.0;
|
||||
eyes.scale.y = eyes.scale.y + (blink - eyes.scale.y) * 0.4;
|
||||
};
|
||||
group.userData.setBaseY = (y) => { baseY = y; };
|
||||
|
||||
// recolour at runtime (e.g. when server assigns a ghost's lure colour)
|
||||
group.userData.setColor = function (key) {
|
||||
const g = GHOST_GRADIENTS[key] || GHOST_GRADIENTS.Blue;
|
||||
bodyMat.uniforms.uTop.value.set(g.bottom);
|
||||
bodyMat.uniforms.uBottom.value.set(g.top);
|
||||
group.userData.colorKey = key;
|
||||
};
|
||||
|
||||
return group;
|
||||
}
|
||||
Reference in New Issue
Block a user