update
This commit is contained in:
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user