Calibration updates
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
<!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>
|
||||
Reference in New Issue
Block a user