Files
newbury-exhibit/public/pose-log.html
T
2026-07-08 13:48:50 +10:00

298 lines
15 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, maximum-scale=1" />
<title>Newbury — pose logger</title>
<style>
:root { --ink:#e8eaf2; --dim:#8a93ad; --cyan:#51eaf1; --green:#4ade80; --amber:#fbbf24; --red:#f65151;
--panel:rgba(8,10,16,0.92); }
* { 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; top:50%; left:50%; transform:translate(-50%,-50%); }
#live { position:fixed; left:10px; top:calc(10px + env(safe-area-inset-top)); padding:10px 12px;
background:var(--panel); border:1px solid rgba(255,255,255,0.1); border-radius:12px;
font-family:ui-monospace,monospace; font-size:11.5px; line-height:1.6; backdrop-filter:blur(8px); max-width:82vw; }
#live .k { color:var(--dim); }
#live .bad { color:var(--red); font-weight:700; }
#live .ok { color:var(--green); }
#live .warn { color:var(--amber); }
#tagbar { position:fixed; right:10px; top:calc(10px + env(safe-area-inset-top)); }
#tagbar select { background:#141a2c; color:var(--ink); border:1px solid #26304a; border-radius:8px;
padding:8px 10px; font-size:12px; }
#controls { position:fixed; left:50%; bottom:calc(14px + env(safe-area-inset-bottom));
transform:translateX(-50%); display:flex; gap:8px; flex-wrap:wrap; justify-content:center; width:94vw; }
#controls button { border:0; border-radius:10px; padding:12px 14px; font-size:13px; font-weight:600;
cursor:pointer; background:#1a2033; color:var(--ink); flex:1; min-width:90px; max-width:150px; }
#controls button.rec { background:var(--red); color:#fff; }
#controls button.primary { background:linear-gradient(135deg,#529eff,#51eaf1); color:#06121f; }
#count { position:fixed; left:50%; bottom:calc(66px + env(safe-area-inset-bottom)); transform:translateX(-50%);
font-family:ui-monospace,monospace; font-size:12px; color:var(--dim); background:var(--panel);
padding:5px 12px; border-radius:8px; }
#logout { position:fixed; inset:0; background:#080a12; padding:16px; display:none; flex-direction:column;
gap:10px; z-index:20; }
#logout.show { display:flex; }
#logout h2 { margin:0; font-size:15px; }
#logout textarea { flex:1; width:100%; background:#0e1220; color:var(--ink); border:1px solid #26304a;
border-radius:10px; padding:12px; font-family:ui-monospace,monospace; font-size:11px; resize:none; }
#logout .row { display:flex; gap:8px; }
#logout button { flex:1; border:0; border-radius:10px; padding:13px; font-size:14px; font-weight:600; cursor:pointer; }
#copyBtn { background:linear-gradient(135deg,#529eff,#51eaf1); color:#06121f; }
#closeBtn { background:#1a2033; color:var(--ink); }
#copied { text-align:center; color:var(--green); font-size:12px; height:14px; }
#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:22px; margin:0; } #start p { color:var(--dim); max-width:34ch; line-height:1.6; font-size:13.5px; 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; }
</style>
</head>
<body>
<video id="video" playsinline muted autoplay></video>
<canvas id="overlay"></canvas>
<div id="live" class="hidden">
<div><span class="k">raw ids:</span> <span id="lIds"></span></div>
<div><span class="k">main:</span> <span id="lMain"></span> <span class="k">ham:</span> <span id="lHam"></span></div>
<div><span class="k">bestErr:</span> <span id="lBest"></span> <span class="k">ratio:</span> <span id="lRatio"></span> <span id="lFlag"></span></div>
<div><span class="k">yaw:</span> <span id="lYaw"></span> <span class="k">pitch:</span> <span id="lPitch"></span> <span class="k">roll:</span> <span id="lRoll"></span></div>
</div>
<div id="tagbar" class="hidden">
<select id="tag">
<option value="crest-0">Testing crest id 0</option>
<option value="crest-1">Testing crest id 1</option>
<option value="crest-2">Testing crest id 2</option>
<option value="crest-3">Testing crest id 3</option>
<option value="crest-4">Testing crest id 4</option>
<option value="walk-around">Walking around</option>
<option value="rotate">Rotating phone</option>
<option value="from-north">Viewing from north</option>
</select>
</div>
<div id="count" class="hidden">not recording</div>
<div id="controls" class="hidden">
<button id="recBtn">● Record</button>
<button id="markBtn">Mark event</button>
<button id="viewBtn" class="primary">View / copy log</button>
</div>
<div id="logout">
<h2>Session log</h2>
<div id="copied"></div>
<textarea id="logText" readonly></textarea>
<div class="row">
<button id="copyBtn">Copy to clipboard</button>
<button id="closeBtn">Back</button>
</div>
</div>
<div id="start">
<h1>AR pose logger</h1>
<p>Records raw detection + pose data as you move around each crest. Pick a label, hit Record, walk/rotate, then View to copy the log and paste it back.</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 } from './js/detect-tuned.js?v=1';
const $ = (id) => document.getElementById(id);
const video = $('video'), overlay = $('overlay'), ctx = overlay.getContext('2d');
const grab = document.createElement('canvas'), gctx = grab.getContext('2d', { willReadFrequently: true });
const MARKER_SIZE_MM = 96;
const KNOWN_IDS = [0, 1, 2, 3, 4]; // anchors we expect; anything else is a phantom
let detector, posit, focalPx = 0, running = false;
let recording = false, log = [], frameN = 0, sampleEvery = 3; // log every 3rd frame to keep size sane
let stats = null;
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(MARKER_SIZE_MM, focalPx);
}
function signedArea(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 a/2;}
function polyArea(c){return Math.abs(signedArea(c));}
function eulerish(R){
const pitch=Math.atan2(-R[2][0],Math.hypot(R[2][1],R[2][2]))*180/Math.PI;
const yaw=Math.atan2(R[1][0],R[0][0])*180/Math.PI;
const roll=Math.atan2(R[2][1],R[2][2])*180/Math.PI;
return {yaw,pitch,roll};
}
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');
await new Promise((r) => { if (video.readyState >= 2) r(); else video.onloadeddata = () => r(); });
sizeAll(); addEventListener('resize', sizeAll);
$('start').classList.add('hidden');
for (const el of ['live','tagbar','count','controls']) $(el).classList.remove('hidden');
running = true; requestAnimationFrame(loop);
}
const sx = () => overlay.width / grab.width, sy = () => overlay.height / grab.height;
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);
// RAW detector output (keeps id + hammingDistance; NOT tracked/smoothed)
let raw = []; try { raw = detector.detect(img, { width: grab.width, height: grab.height }); } catch (_) {}
ctx.clearRect(0, 0, overlay.width, overlay.height);
let main = null, area = 0;
for (const m of raw) { const a = polyArea(m.corners); if (a > area) { area = a; main = m; } }
// draw outlines
for (const m of raw) {
const known = KNOWN_IDS.includes(m.id);
ctx.strokeStyle = m === main ? (known ? '#51eaf1' : '#f65151') : 'rgba(255,255,255,0.35)';
ctx.lineWidth = 2; ctx.beginPath();
m.corners.forEach((p,i)=>{const x=p.x*sx(),y=p.y*sy();i?ctx.lineTo(x,y):ctx.moveTo(x,y);});
ctx.closePath(); ctx.stroke();
}
const ids = raw.map(m => m.id);
let rec = null;
if (main) {
const known = KNOWN_IDS.includes(main.id);
const wind = signedArea(main.corners) > 0 ? 'CW' : 'CCW';
const c = main.corners.map((p)=>({x:p.x-grab.width/2, y:grab.height/2-p.y}));
const pose = posit.pose(c);
let bestErr=null, ratio=null, ang=null;
if (pose) {
bestErr = pose.bestError;
ratio = pose.bestError>0 ? pose.alternativeError/pose.bestError : 99;
ang = eulerish(pose.bestRotation);
}
rec = { id: main.id, known, ham: main.hammingDistance, wind, area: Math.round(area),
bestErr, ratio, ang, ids: ids.slice() };
// live readout
$('lIds').innerHTML = ids.map(i => KNOWN_IDS.includes(i) ? i : `<span class="bad">${i}</span>`).join(', ') || '—';
$('lMain').innerHTML = known ? `<span class="ok">${main.id}</span>` : `<span class="bad">${main.id} (phantom!)</span>`;
$('lHam').innerHTML = main.hammingDistance===0 ? '<span class="ok">0</span>' : `<span class="warn">${main.hammingDistance}</span>`;
if (pose) {
$('lBest').textContent = bestErr.toFixed(1);
const amb = ratio < 1.5;
$('lRatio').textContent = ratio.toFixed(2);
$('lFlag').innerHTML = amb ? '<span class="bad">FLIP</span>' : '<span class="ok">ok</span>';
$('lYaw').textContent = ang.yaw.toFixed(0); $('lPitch').textContent = ang.pitch.toFixed(0); $('lRoll').textContent = ang.roll.toFixed(0);
}
} else {
$('lIds').textContent = '—'; $('lMain').textContent = 'none'; $('lHam').textContent='—';
$('lBest').textContent='—'; $('lRatio').textContent='—'; $('lFlag').textContent='';
}
// record
if (recording) {
frameN++;
if (rec && frameN % sampleEvery === 0) {
log.push({ t: Math.round(now), tag: $('tag').value, ...rec });
accumulate(rec);
$('count').textContent = `● REC · ${log.length} samples · ${$('tag').value}`;
}
}
}
// running aggregates so the pasted log has a clear summary
function accumulate(r) {
if (!stats) stats = { total:0, phantom:0, ham0:0, hamHi:0, flips:0, idCounts:{}, ratios:[], byTag:{} };
stats.total++;
if (!r.known) stats.phantom++;
if (r.ham === 0) stats.ham0++; else stats.hamHi++;
if (r.ratio != null && r.ratio < 1.5) stats.flips++;
stats.idCounts[r.id] = (stats.idCounts[r.id]||0) + 1;
if (r.ratio != null && r.ratio < 90) stats.ratios.push(r.ratio);
const tg = r.tag || 'na'; stats.byTag[tg] = stats.byTag[tg] || { n:0, phantom:0, flips:0 };
stats.byTag[tg].n++; if(!r.known) stats.byTag[tg].phantom++; if(r.ratio<1.5) stats.byTag[tg].flips++;
}
function buildLogText() {
const lines = [];
lines.push('=== NEWBURY POSE LOG ===');
lines.push('marker size assumed: ' + MARKER_SIZE_MM + 'mm · known ids: ' + KNOWN_IDS.join(','));
lines.push('camera: ' + grab.width + 'x' + grab.height + ' · focalPx=' + Math.round(focalPx));
lines.push('samples: ' + log.length);
if (stats) {
const avgRatio = stats.ratios.length ? (stats.ratios.reduce((a,b)=>a+b,0)/stats.ratios.length) : 0;
lines.push('');
lines.push('--- SUMMARY ---');
lines.push('phantom-id frames: ' + stats.phantom + '/' + stats.total + ' (' + pct(stats.phantom,stats.total) + ')');
lines.push('hamming==0: ' + stats.ham0 + ' · hamming>0: ' + stats.hamHi + ' (' + pct(stats.hamHi,stats.total) + ' corrected)');
lines.push('ambiguous(flip) frames: ' + stats.flips + '/' + stats.total + ' (' + pct(stats.flips,stats.total) + ')');
lines.push('avg alt/best ratio: ' + avgRatio.toFixed(2) + ' (near 1.0 = flips; want >1.5)');
lines.push('ids seen: ' + Object.entries(stats.idCounts).map(([k,v])=>`${k}:${v}${KNOWN_IDS.includes(+k)?'':'(PHANTOM)'}`).join(', '));
lines.push('');
lines.push('--- BY LABEL ---');
for (const [tg,s] of Object.entries(stats.byTag)) {
lines.push(`${tg}: ${s.n} frames · phantom ${pct(s.phantom,s.n)} · flip ${pct(s.flips,s.n)}`);
}
}
lines.push('');
lines.push('--- RAW SAMPLES (t,tag,id,known,ham,wind,area,bestErr,ratio,yaw,pitch,roll,allIds) ---');
for (const r of log) {
lines.push([
r.t, r.tag, r.id, r.known?'Y':'N', r.ham, r.wind, r.area,
r.bestErr!=null?r.bestErr.toFixed(1):'-',
r.ratio!=null?r.ratio.toFixed(2):'-',
r.ang?r.ang.yaw.toFixed(0):'-', r.ang?r.ang.pitch.toFixed(0):'-', r.ang?r.ang.roll.toFixed(0):'-',
'['+r.ids.join(' ')+']'
].join(','));
}
return lines.join('\n');
}
function pct(a,b){ return b? Math.round(a/b*100)+'%' : '0%'; }
$('recBtn').addEventListener('click', () => {
recording = !recording;
$('recBtn').classList.toggle('rec', recording);
$('recBtn').textContent = recording ? '■ Stop' : '● Record';
$('count').textContent = recording ? '● REC · 0 samples' : `stopped · ${log.length} samples`;
});
$('markBtn').addEventListener('click', () => {
log.push({ t: Math.round(performance.now()), tag: $('tag').value, event: 'MARK', ids: [] });
$('count').textContent = `marked @ ${log.length}`;
});
$('viewBtn').addEventListener('click', () => {
$('logText').value = buildLogText();
$('logout').classList.add('show');
});
$('copyBtn').addEventListener('click', async () => {
try { await navigator.clipboard.writeText($('logText').value); $('copied').textContent = 'Copied! Paste it back in the chat.'; }
catch (_) { $('logText').select(); document.execCommand('copy'); $('copied').textContent = 'Selected — long-press Copy.'; }
});
$('closeBtn').addEventListener('click', () => $('logout').classList.remove('show'));
$('go').addEventListener('click', start);
</script>
</body>
</html>