Files
newbury-exhibit/public/calibrate.html
T
2026-07-08 12:17:21 +10:00

218 lines
12 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Newbury Exhibit — calibrate</title>
<style>
:root { --ink:#e8eaf2; --dim:#8a93ad; --cyan:#51eaf1; --green:#4ade80; --amber:#fbbf24; --red:#f65151;
--panel:rgba(10,12,20,0.88); }
* { box-sizing:border-box; }
html,body { margin:0; height:100%; background:#05060a; color:var(--ink);
font-family:'Inter',system-ui,sans-serif; overflow:hidden; }
#video { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }
#overlay { position:absolute; inset:0; pointer-events:none; }
.card { position:fixed; background:var(--panel); border:1px solid rgba(255,255,255,0.1);
border-radius:14px; backdrop-filter:blur(10px); }
#readout { left:10px; top:calc(10px + env(safe-area-inset-top)); padding:12px 14px; min-width:210px; }
#readout .row { display:flex; justify-content:space-between; gap:16px; font-size:13px; margin:3px 0; }
#readout .row .v { font-family:ui-monospace,monospace; font-weight:600; }
#readout h3 { margin:0 0 8px; font-size:12px; text-transform:uppercase; letter-spacing:0.05em; color:var(--dim); }
.dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; }
.g { background:var(--green);} .a{ background:var(--amber);} .r{background:var(--red);} .o{background:#555;}
#panel { left:10px; right:10px; bottom:calc(12px + env(safe-area-inset-bottom)); padding:14px; }
#panel h3 { margin:0 0 10px; font-size:13px; }
#panel .field { display:flex; align-items:center; gap:10px; margin-bottom:10px; }
#panel label { font-size:12.5px; color:var(--dim); flex:1; }
#panel input { width:96px; background:#141a2c; border:1px solid #26304a; border-radius:8px;
color:var(--ink); padding:8px 10px; font-family:ui-monospace,monospace; font-size:13px; text-align:right; }
#panel .btns { display:flex; gap:8px; margin-top:4px; }
#panel button { flex:1; border:0; border-radius:9px; padding:11px; font-size:13px; font-weight:600; cursor:pointer; }
.primary { background:linear-gradient(135deg,#529eff,#51eaf1); color:#06121f; }
.ghost { background:#1a2033; color:var(--ink); }
#result { font-size:12.5px; line-height:1.55; margin-top:8px; color:var(--dim); }
#result b { color:var(--cyan); font-family:ui-monospace,monospace; }
#start { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:16px; text-align:center; padding:26px; background:radial-gradient(circle at 50% 40%,#141a2b,#05060a 72%); }
#start h1 { font-size:24px; margin:0; } #start p { color:var(--dim); max-width:32ch; line-height:1.6; font-size:14px; margin:0; }
#go { border:0; cursor:pointer; font-size:16px; font-weight:600; color:#06121f;
background:linear-gradient(135deg,#529eff,#51eaf1); padding:14px 32px; border-radius:999px; }
#err { color:var(--red); font-size:12.5px; max-width:34ch; }
.hidden { display:none !important; }
.help { font-size:11px; color:var(--dim); margin-top:2px; line-height:1.4; }
</style>
</head>
<body>
<video id="video" playsinline muted autoplay></video>
<canvas id="overlay"></canvas>
<div class="card hidden" id="readout">
<h3>Live pose</h3>
<div class="row"><span>marker</span><span class="v" id="rId"><span class="dot o"></span>none</span></div>
<div class="row"><span>distance</span><span class="v" id="rDist"></span></div>
<div class="row"><span>view angle</span><span class="v" id="rAngle"></span></div>
<div class="row"><span>pos jitter</span><span class="v" id="rJit"></span></div>
<div class="row"><span>size used</span><span class="v" id="rSize"></span></div>
</div>
<div class="card hidden" id="panel">
<h3>Calibrate marker size</h3>
<div class="field">
<label>Measured distance camera→crest (tape)</label>
<input id="actualMm" type="number" placeholder="mm" />
</div>
<div class="btns">
<button class="ghost" id="holdBtn">Hold sample</button>
<button class="primary" id="solveBtn">Solve size</button>
</div>
<div id="result">Point at a crest, hold steady, type the real distance you measured with a tape, then Solve. It back-calculates the true marker size so on-table distances are accurate.</div>
<div class="field" style="margin-top:12px">
<label>Marker size in use</label>
<input id="sizeMm" type="number" value="96" />
</div>
<div class="help">Default 96mm = 6 cells × 16mm (outer black square). Change it here to see distance update live, or Solve to compute it from a measurement.</div>
</div>
<div id="start">
<h1>Crest calibration</h1>
<p>Measures the marker pose so ghost scale and placement are accurate. You'll need a tape measure.</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">
import { TunedDetector, MarkerTracker } from './js/detect-tuned.js?v=1';
const $ = (id) => document.getElementById(id);
const video = $('video'), overlay = $('overlay'), octx = overlay.getContext('2d');
const grab = document.createElement('canvas'), gctx = grab.getContext('2d', { willReadFrequently: true });
let detector, tracker, posit, focalPx = 0, running = false;
let sizeMm = 96;
const jitBuf = []; // recent positions for jitter calc
let held = null; // held sample { dist, angle }
function sizeAll() {
const vw = video.videoWidth, vh = video.videoHeight; if (!vw) return;
grab.width = vw; grab.height = vh;
const scale = Math.max(innerWidth / vw, 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;
focalPx = vw;
posit = new POS.Posit(sizeMm, focalPx);
}
function poseOf(m) {
const c = m.corners.map((p) => ({ x: p.x - grab.width / 2, y: grab.height / 2 - p.y }));
const pose = posit.pose(c); if (!pose) return null;
const t = pose.bestTranslation;
const dist = Math.hypot(t[0], t[1], t[2]);
// view angle: how square-on we are. marker normal is rotation*(0,0,1)
const R = pose.bestRotation;
const nz = R[2][2]; // z-component of marker normal in cam space
const angle = Math.acos(Math.min(1, Math.abs(nz))) * 180 / Math.PI;
return { dist, angle, t, err: pose.bestError };
}
function setDot(el, cls, text) { el.innerHTML = `<span class="dot ${cls}"></span>${text}`; }
async function start() {
$('err').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) { $('err').textContent = 'Camera error: ' + (e.message || e.name) + '. iOS needs HTTPS.'; return; }
detector = new TunedDetector({ dictionaryName: 'ARUCO_4X4_1000' }).setPreset('forgiving');
tracker = new MarkerTracker({ coastMs: 200, smooth: true });
await new Promise((r) => { if (video.readyState >= 2) r(); else video.onloadeddata = () => r(); });
sizeAll(); addEventListener('resize', sizeAll);
$('start').classList.add('hidden'); $('readout').classList.remove('hidden'); $('panel').classList.remove('hidden');
running = true; requestAnimationFrame(loop);
}
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 (_) {}
const markers = tracker.update(raw, now);
octx.clearRect(0, 0, overlay.width, overlay.height);
const sx = overlay.width / grab.width, sy = overlay.height / grab.height;
// choose the largest marker in view
let main = null, mainArea = 0;
for (const m of markers) {
const a = polyArea(m.corners); if (a > mainArea) { mainArea = a; main = m; }
// draw outline
octx.strokeStyle = '#51eaf1'; octx.lineWidth = 2; octx.beginPath();
m.corners.forEach((p, i) => { const x = p.x * sx, y = p.y * sy; i ? octx.lineTo(x, y) : octx.moveTo(x, y); });
octx.closePath(); octx.stroke();
}
if (main) {
const p = poseOf(main);
if (p) {
setDot($('rId'), main.coasting ? 'a' : 'g', 'id ' + main.id + (main.coasting ? ' (coast)' : ''));
$('rDist').textContent = Math.round(p.dist) + ' mm';
$('rAngle').textContent = Math.round(p.angle) + '°';
$('rSize').textContent = sizeMm + ' mm';
// jitter over ~1s
jitBuf.push({ x: p.t[0], y: p.t[1], z: p.t[2], t: now });
while (jitBuf.length && now - jitBuf[0].t > 1000) jitBuf.shift();
const jit = stddev3(jitBuf);
$('rJit').textContent = jit.toFixed(2) + ' mm';
$('rJit').parentElement.querySelector; // noop
const jitEl = $('rJit');
jitEl.style.color = jit < 0.6 ? 'var(--green)' : jit < 1.5 ? 'var(--amber)' : 'var(--red)';
window._live = p; // for hold/solve
}
} else {
setDot($('rId'), 'o', 'none'); $('rDist').textContent = '—'; $('rAngle').textContent = '—'; $('rJit').textContent = '—';
window._live = null;
}
}
function polyArea(c) { let a = 0; for (let i = 0; i < c.length; i++) { const j = (i+1)%c.length; a += c[i].x*c[j].y - c[j].x*c[i].y; } return Math.abs(a/2); }
function stddev3(buf) {
if (buf.length < 3) return 0;
const mean = buf.reduce((a,b)=>({x:a.x+b.x,y:a.y+b.y,z:a.z+b.z}),{x:0,y:0,z:0});
mean.x/=buf.length; mean.y/=buf.length; mean.z/=buf.length;
let s=0; for (const p of buf) s += (p.x-mean.x)**2+(p.y-mean.y)**2+(p.z-mean.z)**2;
return Math.sqrt(s/buf.length);
}
$('sizeMm').addEventListener('input', () => { const v = parseFloat($('sizeMm').value); if (v>0){ sizeMm=v; posit=new POS.Posit(sizeMm,focalPx);} });
$('holdBtn').addEventListener('click', () => {
if (!window._live) { $('result').textContent = 'No marker in view to hold.'; return; }
held = { dist: window._live.dist, angle: window._live.angle };
$('result').innerHTML = `Held: measured distance <b>${Math.round(held.dist)}mm</b> at <b>${Math.round(held.angle)}°</b> using size ${sizeMm}mm. Now type the real tape distance and Solve.`;
});
$('solveBtn').addEventListener('click', () => {
const actual = parseFloat($('actualMm').value);
const sample = held || (window._live ? { dist: window._live.dist } : null);
if (!sample) { $('result').textContent = 'Hold a sample or keep a marker in view first.'; return; }
if (!actual || actual <= 0) { $('result').textContent = 'Type the real measured distance (mm) first.'; return; }
// distance scales linearly with assumed marker size: trueSize = size * actual/measured
const trueSize = sizeMm * actual / sample.dist;
$('result').innerHTML = `Set <b>MARKER_SIZE_MM = ${trueSize.toFixed(1)}</b> in exhibit.js and ar-head.js.<br>`
+ `(measured ${Math.round(sample.dist)}mm vs your ${Math.round(actual)}mm at size ${sizeMm}) `
+ `— rounding to <b>${Math.round(trueSize)}mm</b> is usually fine.`;
$('sizeMm').value = Math.round(trueSize); sizeMm = Math.round(trueSize); posit = new POS.Posit(sizeMm, focalPx);
$('rSize').textContent = sizeMm + ' mm';
});
$('go').addEventListener('click', start);
</script>
</body>
</html>