218 lines
12 KiB
HTML
218 lines
12 KiB
HTML
<!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>
|