Pose Debug

This commit is contained in:
2026-07-08 13:35:39 +10:00
parent ca9307b2fd
commit ef8ffd8d88
+252
View File
@@ -0,0 +1,252 @@
<!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 debug</title>
<style>
:root { --ink:#e8eaf2; --dim:#8a93ad; --cyan:#51eaf1; --green:#4ade80; --amber:#fbbf24; --red:#f65151;
--panel:rgba(8,10,16,0.9); }
* { 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%); }
#hud { position:fixed; left:10px; top:calc(10px + env(safe-area-inset-top)); padding:11px 13px;
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.65; backdrop-filter:blur(8px);
max-width:80vw; }
#hud .k { color:var(--dim); }
#hud .flip { color:var(--red); font-weight:700; }
#hud .ok { color:var(--green); }
#hud h3 { margin:0 0 6px; font-family:'Inter',sans-serif; font-size:12px; letter-spacing:0.03em; }
#legend { position:fixed; right: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-size:11px; line-height:1.7; backdrop-filter:blur(8px); }
#legend b { font-weight:700; }
.xr{color:#ff6b6b}.yg{color:#7CFF6b}.zb{color:#6b9cff}
#controls { position:fixed; left:50%; bottom:calc(14px + env(safe-area-inset-bottom));
transform:translateX(-50%); display:flex; gap:8px; }
#controls button { border:0; border-radius:10px; padding:11px 16px; font-size:13px; font-weight:600;
cursor:pointer; background:#1a2033; color:var(--ink); }
#controls button.on { background:linear-gradient(135deg,#529eff,#51eaf1); color:#06121f; }
#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="hud" class="hidden">
<h3>Pose debug</h3>
<div><span class="k">marker</span> <span id="dId"></span></div>
<div><span class="k">winding</span> <span id="dWind"></span></div>
<div><span class="k">best err</span> <span id="dBest"></span></div>
<div><span class="k">alt err</span> <span id="dAlt"></span></div>
<div><span class="k">ratio</span> <span id="dRatio"></span> <span id="dFlag"></span></div>
<div><span class="k">picked</span> <span id="dPick"></span></div>
<div><span class="k">yaw</span> <span id="dYaw"></span> <span class="k">pitch</span> <span id="dPitch"></span> <span class="k">roll</span> <span id="dRoll"></span></div>
</div>
<div id="legend">
axes on marker:<br />
<span class="xr">■ X</span> <span class="yg">■ Y</span> <span class="zb">■ Z (up-normal)</span><br />
corners: <b>0</b><b>1</b><b>2</b><b>3</b><br />
<span style="color:var(--amber)">amber</span> = alt solution
</div>
<div id="controls" class="hidden">
<button id="axesBtn" class="on">axes</button>
<button id="altBtn" class="on">show alt</button>
<button id="stableBtn">stabilize</button>
</div>
<div id="start">
<h1>AR pose debug</h1>
<p>Shows the raw marker corners and both POS-IT pose solutions with their errors, so we can see the ambiguity flip when you rotate or move around a crest.</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'), ctx = overlay.getContext('2d');
const grab = document.createElement('canvas'), gctx = grab.getContext('2d', { willReadFrequently: true });
const MARKER_SIZE_MM = 96;
let detector, tracker, posit, focalPx = 0, running = false;
let showAxes = true, showAlt = true, stabilize = false;
let prevR = null; // previous frame rotation (flattened) for stabilization choice
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);
}
// project a 3D point (POS-IT camera frame: X right, Y up, Z toward viewer)
// to pixel coords. Camera model: x_px = f * X/Z_cam ... but POS-IT Z is toward
// viewer, so the point is at depth t; use pinhole with center at image middle.
function project(p, tImgW, tImgH) {
// p in POS-IT frame (mm). Pinhole: u = f * X / Z, but here Z is "toward viewer"
// so distance along optical axis is p.z. Guard against divide-by-zero.
const z = p.z === 0 ? 0.0001 : p.z;
const u = (focalPx * p.x / z) + tImgW / 2;
const v = tImgH / 2 - (focalPx * p.y / z); // y up -> screen down
return { u, v };
}
// apply a POS-IT rotation R (3x3 array) + translation t to a local point
function xform(R, t, lp) {
return {
x: R[0][0]*lp.x + R[0][1]*lp.y + R[0][2]*lp.z + t[0],
y: R[1][0]*lp.x + R[1][1]*lp.y + R[1][2]*lp.z + t[1],
z: R[2][0]*lp.x + R[2][1]*lp.y + R[2][2]*lp.z + t[2],
};
}
function frob(R, S) { // frobenius distance between two 3x3
let s = 0; for (let i=0;i<3;i++) for (let j=0;j<3;j++) s += (R[i][j]-S[i][j])**2; return Math.sqrt(s);
}
function eulerish(R) {
// rough yaw/pitch/roll from rotation for readout (deg)
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 };
}
function drawAxes(R, t, color, len) {
const O = project(xform(R, t, {x:0,y:0,z:0}), overlay.width, overlay.height);
const axes = [
{ l:{x:len,y:0,z:0}, c:'#ff6b6b' },
{ l:{x:0,y:len,z:0}, c:'#7CFF6b' },
{ l:{x:0,y:0,z:len}, c:'#6b9cff' }, // marker normal = up out of face
];
for (const a of axes) {
const P = project(xform(R, t, a.l), overlay.width, overlay.height);
ctx.strokeStyle = color || a.c; ctx.lineWidth = 3;
ctx.beginPath(); ctx.moveTo(O.u, O.v); ctx.lineTo(P.u, P.v); ctx.stroke();
}
}
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: 120, smooth: true });
await new Promise((r) => { if (video.readyState >= 2) r(); else video.onloadeddata = () => r(); });
sizeAll(); addEventListener('resize', sizeAll);
$('start').classList.add('hidden'); $('hud').classList.remove('hidden'); $('controls').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);
let raw = []; try { raw = detector.detect(img, { width: grab.width, height: grab.height }); } catch (_) {}
const markers = tracker.update(raw, now);
ctx.clearRect(0, 0, overlay.width, overlay.height);
// pick the biggest marker to analyse in detail
let main = null, area = 0;
for (const m of markers) { const a = polyArea(m.corners); if (a > area) { area = a; main = m; } }
// draw all outlines + numbered corners
for (const m of markers) {
ctx.strokeStyle = m === main ? '#51eaf1' : 'rgba(81,234,241,0.4)';
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();
if (m === main) {
m.corners.forEach((p, i) => {
const x = p.x*sx(), y = p.y*sy();
ctx.fillStyle = ['#fff','#ffd24a','#ff8a3a','#ff5a5a'][i];
ctx.beginPath(); ctx.arc(x, y, 6, 0, 7); ctx.fill();
ctx.fillStyle = '#000'; ctx.font = 'bold 10px monospace'; ctx.fillText(i, x-3, y+3);
});
}
}
if (main) {
// centred corners for POS-IT (its expected coordinate frame)
const c = main.corners.map((p) => ({ x: p.x - grab.width/2, y: grab.height/2 - p.y }));
const pose = posit.pose(c);
if (pose) {
const bR = pose.bestRotation, bT = pose.bestTranslation, bE = pose.bestError;
const aR = pose.alternativeRotation, aT = pose.alternativeTranslation, aE = pose.alternativeError;
const ratio = bE > 0 ? (aE / bE) : 99;
// choose solution: default best, or the one closest to previous frame if stabilizing
let useR = bR, useT = bT, pick = 'best';
if (stabilize && prevR && aR) {
const db = frob(bR, prevR), da = frob(aR, prevR);
if (da < db) { useR = aR; useT = aT; pick = 'alt (stable)'; }
}
if (showAxes) {
drawAxes(bR, bT, null, MARKER_SIZE_MM); // best in RGB
if (showAlt && aR) drawAxes(aR, aT, 'rgba(251,191,36,0.7)', MARKER_SIZE_MM); // alt in amber
}
const e = eulerish(useR);
$('dId').textContent = 'id ' + main.id + (main.coasting ? ' (coast)' : '');
$('dWind').textContent = signedArea(main.corners) > 0 ? 'CW' : 'CCW';
$('dBest').textContent = bE.toFixed(2);
$('dAlt').textContent = aE != null ? aE.toFixed(2) : '—';
$('dRatio').textContent = ratio.toFixed(2);
// when alt error is close to best (ratio near 1), pose is ambiguous -> flips
const amb = ratio < 1.5;
$('dFlag').innerHTML = amb ? '<span class="flip">AMBIGUOUS — will flip</span>' : '<span class="ok">stable</span>';
$('dPick').textContent = pick;
$('dYaw').textContent = e.yaw.toFixed(0)+'°';
$('dPitch').textContent = e.pitch.toFixed(0)+'°';
$('dRoll').textContent = e.roll.toFixed(0)+'°';
prevR = useR;
}
} else {
$('dId').textContent = 'none'; $('dFlag').textContent = '';
}
}
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 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;}
$('axesBtn').addEventListener('click', () => { showAxes = !showAxes; $('axesBtn').classList.toggle('on', showAxes); });
$('altBtn').addEventListener('click', () => { showAlt = !showAlt; $('altBtn').classList.toggle('on', showAlt); });
$('stableBtn').addEventListener('click', () => { stabilize = !stabilize; $('stableBtn').classList.toggle('on', stabilize); });
$('go').addEventListener('click', start);
</script>
</body>
</html>