292 lines
14 KiB
HTML
292 lines
14 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 — orientation 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%); }
|
||
#three,#overlay { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); }
|
||
#overlay { pointer-events:none; }
|
||
|
||
#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:11px; line-height:1.65; backdrop-filter:blur(8px); max-width:88vw; }
|
||
#hud h3 { margin:0 0 6px; font-family:'Inter',sans-serif; font-size:12px; }
|
||
#hud .row { display:flex; gap:8px; }
|
||
#hud .lab { color:var(--dim); width:66px; display:inline-block; }
|
||
#hud .v { font-weight:600; }
|
||
.rgb-x{color:#ff6b6b}.rgb-y{color:#7CFF6b}.rgb-z{color:#6b9cff}
|
||
|
||
#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:88px; 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 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="three"></canvas>
|
||
<canvas id="overlay"></canvas>
|
||
|
||
<div id="hud" class="hidden">
|
||
<h3>Orientation debug</h3>
|
||
<div class="row"><span class="lab">marker</span><span class="v" id="dId">—</span></div>
|
||
<div class="row"><span class="lab">RAW posit</span><span class="v" id="dRaw">—</span></div>
|
||
<div class="row"><span class="lab">RENDER</span><span class="v" id="dRender">—</span></div>
|
||
<div class="row"><span class="lab">gyro</span><span class="v" id="dGyro">—</span></div>
|
||
<div class="row"><span class="lab" style="color:var(--amber)">watch</span><span id="dHint" style="color:var(--amber)">tilt phone up = pitch should ↑</span></div>
|
||
</div>
|
||
|
||
<div id="count" class="hidden">not recording</div>
|
||
<div id="controls" class="hidden">
|
||
<button id="recBtn">● Record</button>
|
||
<button id="markBtn">Mark</button>
|
||
<button id="viewBtn" class="primary">Copy log</button>
|
||
</div>
|
||
|
||
<div id="logout">
|
||
<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>Orientation debug</h1>
|
||
<p>Shows raw POS-IT angles vs what we render, plus a 3D gizmo on the marker. Tilt & rotate the phone; the RGB axes should stay glued to the marker. Record and send me the log.</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="importmap">
|
||
{ "imports": {
|
||
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
|
||
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
|
||
} }
|
||
</script>
|
||
<script type="module">
|
||
import * as THREE from 'three';
|
||
import { TunedDetector, MarkerTracker } from './js/detect-tuned.js?v=1';
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const video = $('video'), canvas = $('three'), overlay = $('overlay');
|
||
const octx = 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 recording = false, log = [], frameN = 0;
|
||
let gyro = { alpha: null, beta: null, gamma: null };
|
||
|
||
// ---- three.js: mirror ar-head EXACTLY, but draw an AXIS GIZMO not a ghost ----
|
||
const renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true });
|
||
renderer.setClearColor(0x000000, 0);
|
||
const scene = new THREE.Scene();
|
||
scene.add(new THREE.AmbientLight(0xffffff, 1.2));
|
||
let camera = new THREE.PerspectiveCamera(50, 1, 1, 5000);
|
||
|
||
// anchor object driven by pose (exactly like ar-head)
|
||
const anchor = new THREE.Object3D();
|
||
anchor.matrixAutoUpdate = false;
|
||
scene.add(anchor);
|
||
|
||
// A big RGB axis gizmo + an "UP" arrow, parented to anchor.
|
||
const gizmo = new THREE.AxesHelper(MARKER_SIZE_MM * 1.2);
|
||
anchor.add(gizmo);
|
||
// a distinct arrow pointing along marker normal (local +Z) so we see "up off table"
|
||
const arrowMat = new THREE.MeshBasicMaterial({ color: 0x51eaf1 });
|
||
const arrow = new THREE.Mesh(new THREE.ConeGeometry(10, 40, 12), arrowMat);
|
||
arrow.position.set(0, 0, MARKER_SIZE_MM); // +Z = marker normal
|
||
arrow.rotation.x = Math.PI / 2;
|
||
anchor.add(arrow);
|
||
anchor.visible = false;
|
||
|
||
const flip = new THREE.Matrix4().makeScale(1, -1, -1);
|
||
const poseMatrix = new THREE.Matrix4();
|
||
|
||
function centredCorners(m, w, h) { return m.corners.map((c) => ({ x: c.x - w / 2, y: h / 2 - c.y })); }
|
||
|
||
function applyPose(m) {
|
||
const pose = posit.pose(centredCorners(m, grab.width, grab.height));
|
||
if (!pose) return null;
|
||
const R = pose.bestRotation, t = pose.bestTranslation;
|
||
poseMatrix.set(
|
||
R[0][0], R[0][1], R[0][2], t[0],
|
||
R[1][0], R[1][1], R[1][2], t[1],
|
||
R[2][0], R[2][1], R[2][2], t[2],
|
||
0, 0, 0, 1
|
||
);
|
||
poseMatrix.premultiply(flip); // F*M (ar-head convention)
|
||
anchor.matrix.copy(poseMatrix);
|
||
anchor.matrixWorldNeedsUpdate = true;
|
||
|
||
// raw posit euler (before flip) and rendered euler (after flip)
|
||
const rawM = new THREE.Matrix4().set(
|
||
R[0][0], R[0][1], R[0][2], 0, R[1][0], R[1][1], R[1][2], 0, R[2][0], R[2][1], R[2][2], 0, 0,0,0,1);
|
||
const rawE = new THREE.Euler().setFromRotationMatrix(rawM, 'YXZ');
|
||
const renM = poseMatrix.clone(); renM.setPosition(0,0,0);
|
||
const renE = new THREE.Euler().setFromRotationMatrix(renM, 'YXZ');
|
||
return {
|
||
dist: Math.hypot(t[0], t[1], t[2]),
|
||
raw: { yaw: deg(rawE.y), pitch: deg(rawE.x), roll: deg(rawE.z) },
|
||
ren: { yaw: deg(renE.y), pitch: deg(renE.x), roll: deg(renE.z) },
|
||
};
|
||
}
|
||
const deg = (r) => Math.round(r * 180 / Math.PI);
|
||
|
||
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, canvas, overlay]) { el.style.width = dw + 'px'; el.style.height = dh + 'px'; }
|
||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); renderer.setSize(dw, dh, false);
|
||
overlay.width = dw; overlay.height = dh;
|
||
focalPx = vw;
|
||
const fovY = 2 * Math.atan((vh / 2) / focalPx) * (180 / Math.PI);
|
||
camera.fov = fovY; camera.aspect = dw / dh; camera.updateProjectionMatrix();
|
||
posit = new POS.Posit(MARKER_SIZE_MM, focalPx);
|
||
}
|
||
|
||
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); return; }
|
||
detector = new TunedDetector({ dictionaryName: 'ARUCO_4X4_1000' }).setPreset('forgiving');
|
||
tracker = new MarkerTracker({ coastMs: 150, smooth: true });
|
||
// device orientation (gyro) — needs permission on iOS
|
||
if (typeof DeviceOrientationEvent !== 'undefined' && DeviceOrientationEvent.requestPermission) {
|
||
try { await DeviceOrientationEvent.requestPermission(); } catch (_) {}
|
||
}
|
||
addEventListener('deviceorientation', (e) => { gyro = { alpha: e.alpha, beta: e.beta, gamma: e.gamma }; });
|
||
await new Promise((r) => { if (video.readyState >= 2) r(); else video.onloadeddata = () => r(); });
|
||
sizeAll(); addEventListener('resize', sizeAll);
|
||
$('start').classList.add('hidden');
|
||
for (const id of ['hud','count','controls']) $(id).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);
|
||
octx.clearRect(0, 0, overlay.width, overlay.height);
|
||
|
||
let main = markers[0] || null;
|
||
if (main) {
|
||
// draw outline + corner numbers
|
||
octx.strokeStyle = '#51eaf1'; octx.lineWidth = 2; octx.beginPath();
|
||
main.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();
|
||
main.corners.forEach((p, i) => {
|
||
const x = p.x*sx(), y = p.y*sy();
|
||
octx.fillStyle = ['#fff','#ffd24a','#ff8a3a','#ff5a5a'][i];
|
||
octx.beginPath(); octx.arc(x, y, 6, 0, 7); octx.fill();
|
||
octx.fillStyle = '#000'; octx.font = 'bold 10px monospace'; octx.fillText(i, x-3, y+3);
|
||
});
|
||
|
||
const res = applyPose(main);
|
||
if (res) {
|
||
anchor.visible = true;
|
||
$('dId').textContent = 'id ' + main.id + (main.coasting ? ' coast' : '');
|
||
$('dRaw').innerHTML = fmt(res.raw);
|
||
$('dRender').innerHTML = fmt(res.ren);
|
||
$('dGyro').textContent = gyro.beta != null
|
||
? `β${Math.round(gyro.beta)} γ${Math.round(gyro.gamma)} α${Math.round(gyro.alpha)}` : 'n/a';
|
||
|
||
if (recording) {
|
||
frameN++;
|
||
if (frameN % 3 === 0) {
|
||
log.push({ t: Math.round(now), id: main.id, dist: Math.round(res.dist),
|
||
raw: res.raw, ren: res.ren, gyro: { b: r1(gyro.beta), g: r1(gyro.gamma), a: r1(gyro.alpha) } });
|
||
$('count').textContent = `● REC · ${log.length}`;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
anchor.visible = false; $('dId').textContent = 'none';
|
||
}
|
||
renderer.render(scene, camera);
|
||
}
|
||
function r1(v){ return v==null?null:Math.round(v); }
|
||
function fmt(a){ return `<span class="rgb-y">P${a.pitch}</span> <span class="rgb-z">R${a.roll}</span> <span class="rgb-x">Y${a.yaw}</span>`; }
|
||
|
||
function buildLog() {
|
||
const L = [];
|
||
L.push('=== NEWBURY ORIENTATION LOG ===');
|
||
L.push('conversion: F*M (premultiply flip) marker:' + MARKER_SIZE_MM + 'mm cam:' + grab.width + 'x' + grab.height);
|
||
L.push('cols: t,id,dist, RAW(pitch,roll,yaw), RENDER(pitch,roll,yaw), GYRO(beta,gamma,alpha)');
|
||
L.push('gyro beta=front/back tilt, gamma=left/right tilt, alpha=compass');
|
||
L.push('samples:' + log.length);
|
||
L.push('');
|
||
for (const r of log) {
|
||
L.push([r.t, r.id, r.dist,
|
||
'RAW', r.raw.pitch, r.raw.roll, r.raw.yaw,
|
||
'REN', r.ren.pitch, r.ren.roll, r.ren.yaw,
|
||
'GYRO', r.gyro.b, r.gyro.g, r.gyro.a].join(','));
|
||
}
|
||
return L.join('\n');
|
||
}
|
||
|
||
$('recBtn').addEventListener('click', () => {
|
||
recording = !recording;
|
||
$('recBtn').classList.toggle('rec', recording);
|
||
$('recBtn').textContent = recording ? '■ Stop' : '● Record';
|
||
if (recording) { log = []; $('count').textContent = '● REC · 0'; }
|
||
});
|
||
$('markBtn').addEventListener('click', () => { log.push({ t: Math.round(performance.now()), mark: true, raw:{}, ren:{}, gyro:{} }); });
|
||
$('viewBtn').addEventListener('click', () => { $('logText').value = buildLog(); $('logout').classList.add('show'); });
|
||
$('copyBtn').addEventListener('click', async () => {
|
||
try { await navigator.clipboard.writeText($('logText').value); $('copied').textContent = 'Copied!'; }
|
||
catch (_) { $('logText').select(); $('copied').textContent = 'Long-press → Copy'; }
|
||
});
|
||
$('closeBtn').addEventListener('click', () => $('logout').classList.remove('show'));
|
||
$('go').addEventListener('click', start);
|
||
</script>
|
||
</body>
|
||
</html>
|