update Tue 07/14/2026 11:38:16.92
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
/* admin.js — shared auth + API helpers for admin pages. */
|
||||
export async function api(path, method = 'GET', body) {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-exhibit-token': localStorage.getItem('exhibitToken') || '',
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (res.status === 401) { location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname); throw new Error('unauthorized'); }
|
||||
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error || res.statusText);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function requireAuth() {
|
||||
const info = await (await fetch('/api/info')).json();
|
||||
if (!info.authRequired) return info;
|
||||
// probe a guarded endpoint
|
||||
const res = await fetch('/api/client-errors', { headers: { 'x-exhibit-token': localStorage.getItem('exhibitToken') || '' } });
|
||||
if (res.status === 401) location.href = '/admin/login.html?next=' + encodeURIComponent(location.pathname);
|
||||
return info;
|
||||
}
|
||||
|
||||
export const styles = `
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; background:#0a0d18; color:#e8ecff; font-family:system-ui,sans-serif; }
|
||||
header { display:flex; gap:14px; align-items:center; padding:10px 16px; background:#111730; }
|
||||
header a { color:#8fb8ff; text-decoration:none; font-size:.9rem; }
|
||||
header a.active { color:#51eaf1; }
|
||||
h1 { font-size:1.05rem; margin:0 auto 0 0; }
|
||||
button { background:#2a3a6e; color:#fff; border:0; border-radius:6px; padding:6px 14px; cursor:pointer; }
|
||||
button.primary { background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:600; }
|
||||
button.danger { background:#7a2437; }
|
||||
input, select { background:#141a33; color:#fff; border:1px solid #2a3a6e; border-radius:5px; padding:4px 8px; }
|
||||
label { font-size:.8rem; opacity:.85; }
|
||||
`;
|
||||
|
||||
export function nav(active) {
|
||||
return `<header>
|
||||
<h1>Newbury Exhibit — Admin</h1>
|
||||
${['layout', 'playlist', 'print', 'calibrate', 'errors'].map(p =>
|
||||
`<a href="/admin/${p}.html" class="${p === active ? 'active' : ''}">${p}</a>`).join('')}
|
||||
<a href="/">viewer ↗</a>
|
||||
</header>`;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Calibrate — Newbury Exhibit</title>
|
||||
<style>
|
||||
html,body{margin:0;height:100%;overflow:hidden;background:#000;color:#fff;font-family:monospace}
|
||||
#cam{position:fixed;inset:0;width:100%;height:100%;object-fit:cover;opacity:.85}
|
||||
#ov{position:fixed;inset:0;pointer-events:none}
|
||||
#out{position:fixed;top:0;left:0;right:0;background:rgba(0,0,0,.65);padding:8px 10px;font-size:12px;white-space:pre;line-height:1.5}
|
||||
</style></head>
|
||||
<body>
|
||||
<video id="cam" playsinline muted></video>
|
||||
<canvas id="ov"></canvas>
|
||||
<div id="out">Starting camera…</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 { createDetector, detectMarkers, quadArea } from '/js/ar/detect.js';
|
||||
import { PoseEstimator } from '/js/ar/pose.js';
|
||||
import { WorldFuser } from '/js/ar/fuse.js';
|
||||
|
||||
const scene = await (await fetch('/api/scene')).json();
|
||||
const fuser = new WorldFuser(); fuser.setScene(scene);
|
||||
const detector = createDetector();
|
||||
|
||||
const video = document.getElementById('cam');
|
||||
const ov = document.getElementById('ov'); const octx = ov.getContext('2d');
|
||||
const out = document.getElementById('out');
|
||||
const cv2 = document.createElement('canvas'); const ctx = cv2.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment', width: { ideal: 1280 } }, audio: false });
|
||||
video.srcObject = stream; await video.play();
|
||||
|
||||
let poseEst = null;
|
||||
const jitterBuf = new Map(); // markerId -> recent positions
|
||||
|
||||
function loop() {
|
||||
requestAnimationFrame(loop);
|
||||
if (video.readyState < 2) return;
|
||||
const W = 640, H = Math.round(W * video.videoHeight / video.videoWidth);
|
||||
if (cv2.width !== W) { cv2.width = W; cv2.height = H; }
|
||||
ov.width = innerWidth; ov.height = innerHeight;
|
||||
ctx.drawImage(video, 0, 0, W, H);
|
||||
const img = ctx.getImageData(0, 0, W, H);
|
||||
if (!poseEst) poseEst = new PoseEstimator(W / (2 * Math.tan(30 * Math.PI / 180)));
|
||||
|
||||
const markers = detectMarkers(detector, img, fuser.knownIds());
|
||||
const sx = innerWidth / W, sy = innerHeight / H;
|
||||
octx.clearRect(0, 0, ov.width, ov.height);
|
||||
|
||||
const lines = [];
|
||||
const estimates = [];
|
||||
for (const m of markers) {
|
||||
// outline
|
||||
octx.strokeStyle = '#51eaf1'; octx.lineWidth = 3; octx.beginPath();
|
||||
m.corners.forEach((c, i) => i ? octx.lineTo(c.x * sx, c.y * sy) : octx.moveTo(c.x * sx, c.y * sy));
|
||||
octx.closePath(); octx.stroke();
|
||||
|
||||
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
|
||||
if (!e) continue;
|
||||
estimates.push({ markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) });
|
||||
|
||||
const distMM = e.position.length();
|
||||
const eul = new THREE.Euler().setFromQuaternion(e.quaternion, 'YXZ');
|
||||
const buf = jitterBuf.get(m.id) || []; buf.push(e.position.clone()); if (buf.length > 30) buf.shift(); jitterBuf.set(m.id, buf);
|
||||
const mean = buf.reduce((a, p) => a.add(p), new THREE.Vector3()).multiplyScalar(1 / buf.length);
|
||||
const jit = Math.sqrt(buf.reduce((a, p) => a + p.distanceToSquared(mean), 0) / buf.length);
|
||||
|
||||
lines.push(`crest #${String(m.id).padStart(3)} dist ${(distMM / 10).toFixed(1)} cm` +
|
||||
` yaw ${THREE.MathUtils.radToDeg(eul.y).toFixed(0).padStart(4)}°` +
|
||||
` pitch ${THREE.MathUtils.radToDeg(eul.x).toFixed(0).padStart(4)}°` +
|
||||
` jitter ${jit.toFixed(1)} mm err ${e.error.toFixed(1)}`);
|
||||
}
|
||||
|
||||
const fused = fuser.fuse(estimates);
|
||||
if (fused) lines.push('', `FUSED cam @ ${fused.position.toArray().map(v => v.toFixed(3)).join(', ')} m (${fused.markerCount} crest${fused.markerCount === 1 ? '' : 's'})`);
|
||||
out.textContent = lines.join('\n') || 'No crests in view — show a printed Newbury Crest to the camera.';
|
||||
}
|
||||
loop();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Client Errors — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
main{max-width:900px;margin:16px auto;padding:0 16px}
|
||||
.err{background:#111730;border-radius:8px;padding:10px 12px;margin:8px 0;font:12px monospace;white-space:pre-wrap}
|
||||
.err .meta{opacity:.55;margin-bottom:4px}
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
const app = document.getElementById('app');
|
||||
async function render() {
|
||||
const errs = await api('/api/client-errors');
|
||||
app.innerHTML = `${nav('errors')}<main>
|
||||
<div style="display:flex;align-items:center;gap:12px;margin:12px 0">
|
||||
<h2 style="margin:0;font-size:1rem">Client errors (${errs.length})</h2>
|
||||
<button id="refresh">Refresh</button>
|
||||
</div>
|
||||
${errs.slice().reverse().map(e => `<div class="err">
|
||||
<div class="meta">${new Date(e.t).toLocaleString()} · ${e.page || '?'} · ${(e.ua || '').slice(0, 80)}</div>
|
||||
[${e.kind}] ${e.msg}${e.src ? ` (${e.src}:${e.line})` : ''}</div>`).join('') ||
|
||||
'<em style="opacity:.6">No errors reported. Lovely.</em>'}
|
||||
</main>`;
|
||||
document.getElementById('refresh').onclick = render;
|
||||
}
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,237 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Layout Editor — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
#wrap { display:grid; grid-template-columns: 1fr 300px; height: calc(100vh - 44px); }
|
||||
#cv { background:#0d1226; width:100%; height:100%; display:block; touch-action:none; }
|
||||
#side { background:#111730; padding:12px; overflow:auto; }
|
||||
#side h2 { font-size:.9rem; margin:14px 0 6px; color:#8fb8ff; }
|
||||
.row { display:flex; gap:6px; align-items:center; margin:4px 0; }
|
||||
.row label { width:70px; }
|
||||
.row input, .row select { flex:1; min-width:0; }
|
||||
.tools { display:flex; gap:6px; padding:8px 12px; background:#0d1226; flex-wrap:wrap; }
|
||||
.badge { font-size:.7rem; opacity:.6; }
|
||||
</style>`);
|
||||
|
||||
await requireAuth();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
app.innerHTML = `${nav('layout')}
|
||||
<div class="tools">
|
||||
<button data-add="anchor">+ Anchor</button>
|
||||
<button data-add="building">+ Building</button>
|
||||
<button data-add="spawn">+ Spawn point</button>
|
||||
<span style="flex:1"></span>
|
||||
<button id="reset" class="danger">Reset to seed</button>
|
||||
<button id="save" class="primary">Save layout</button>
|
||||
<span id="status" class="badge"></span>
|
||||
</div>
|
||||
<div id="wrap">
|
||||
<canvas id="cv"></canvas>
|
||||
<div id="side"><em style="opacity:.6">Select an item to edit it. Drag on the canvas to move. Scroll to zoom.</em></div>
|
||||
</div>`;
|
||||
|
||||
let scene = await api('/api/scene');
|
||||
scene.anchors ||= []; scene.buildings ||= []; scene.spawns ||= [];
|
||||
|
||||
const cv = document.getElementById('cv');
|
||||
const ctx = cv.getContext('2d');
|
||||
let view = { x: 0, z: 0, scale: 120 }; // px per metre
|
||||
let sel = null; // { kind, index }
|
||||
let drag = null;
|
||||
|
||||
function fit() {
|
||||
cv.width = cv.clientWidth * devicePixelRatio;
|
||||
cv.height = cv.clientHeight * devicePixelRatio;
|
||||
draw();
|
||||
}
|
||||
new ResizeObserver(fit).observe(cv);
|
||||
|
||||
const w2s = (x, z) => [cv.width / 2 + (x - view.x) * view.scale * devicePixelRatio,
|
||||
cv.height / 2 + (z - view.z) * view.scale * devicePixelRatio];
|
||||
const s2w = (px, py) => [(px * devicePixelRatio - cv.width / 2) / (view.scale * devicePixelRatio) + view.x,
|
||||
(py * devicePixelRatio - cv.height / 2) / (view.scale * devicePixelRatio) + view.z];
|
||||
|
||||
function draw() {
|
||||
ctx.clearRect(0, 0, cv.width, cv.height);
|
||||
// grid (0.25 m)
|
||||
ctx.strokeStyle = '#1a2244'; ctx.lineWidth = 1;
|
||||
const step = 0.25 * view.scale * devicePixelRatio;
|
||||
const ox = (cv.width / 2 - view.x * view.scale * devicePixelRatio) % step;
|
||||
const oz = (cv.height / 2 - view.z * view.scale * devicePixelRatio) % step;
|
||||
for (let x = ox; x < cv.width; x += step) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, cv.height); ctx.stroke(); }
|
||||
for (let y = oz; y < cv.height; y += step) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(cv.width, y); ctx.stroke(); }
|
||||
|
||||
// buildings (occluders)
|
||||
for (const [i, b] of scene.buildings.entries()) {
|
||||
const [sx, sy] = w2s(b.position[0], b.position[2]);
|
||||
const w = b.size[0] * view.scale * devicePixelRatio, d = b.size[2] * view.scale * devicePixelRatio;
|
||||
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(b.yawDeg || 0) * Math.PI / 180);
|
||||
ctx.fillStyle = isSel('building', i) ? 'rgba(82,158,255,.45)' : 'rgba(82,158,255,.22)';
|
||||
ctx.strokeStyle = '#529eff';
|
||||
ctx.fillRect(-w / 2, -d / 2, w, d); ctx.strokeRect(-w / 2, -d / 2, w, d);
|
||||
ctx.restore();
|
||||
label(b.name || `bldg ${i}`, sx, sy, '#9cc4ff');
|
||||
}
|
||||
// spawns
|
||||
for (const [i, s] of scene.spawns.entries()) {
|
||||
const [sx, sy] = w2s(s.position[0], s.position[2]);
|
||||
ctx.beginPath(); ctx.arc(sx, sy, 9 * devicePixelRatio, 0, 7);
|
||||
ctx.fillStyle = isSel('spawn', i) ? '#fff35d' : (s.enabled === false ? '#665' : '#b57f0b');
|
||||
ctx.fill();
|
||||
label(s.id || `spawn ${i}`, sx, sy - 14 * devicePixelRatio, '#ffe9a3');
|
||||
}
|
||||
// anchors
|
||||
for (const [i, a] of scene.anchors.entries()) {
|
||||
const [sx, sy] = w2s(a.position[0], a.position[2]);
|
||||
ctx.save(); ctx.translate(sx, sy); ctx.rotate(-(a.yawDeg || 0) * Math.PI / 180);
|
||||
const r = Math.max(8, (a.sizeMM / 1000) * view.scale) * devicePixelRatio;
|
||||
ctx.fillStyle = isSel('anchor', i) ? '#51eaf1' : (a.enabled === false ? '#456' : (a.mount === 'wall' ? '#f65151' : '#3bc9a7'));
|
||||
if (a.mount === 'wall') { ctx.fillRect(-r, -2.5 * devicePixelRatio, 2 * r, 5 * devicePixelRatio); // edge-on line
|
||||
ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, -r); ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 2; ctx.stroke(); }
|
||||
else ctx.fillRect(-r, -r, 2 * r, 2 * r);
|
||||
ctx.restore();
|
||||
label(`#${a.markerId}${a.mount === 'wall' ? ' ⊥' : ''}`, sx, sy + 16 * devicePixelRatio, '#a6fff0');
|
||||
}
|
||||
}
|
||||
function label(txt, x, y, col) {
|
||||
ctx.fillStyle = col; ctx.font = `${11 * devicePixelRatio}px system-ui`; ctx.textAlign = 'center';
|
||||
ctx.fillText(txt, x, y - 12 * devicePixelRatio);
|
||||
}
|
||||
const isSel = (k, i) => sel && sel.kind === k && sel.index === i;
|
||||
|
||||
function hit(px, py) {
|
||||
const [wx, wz] = s2w(px, py);
|
||||
const near = (x, z, r) => Math.hypot(wx - x, wz - z) < r;
|
||||
for (const [i, a] of scene.anchors.entries()) if (near(a.position[0], a.position[2], 0.12)) return { kind: 'anchor', index: i };
|
||||
for (const [i, s] of scene.spawns.entries()) if (near(s.position[0], s.position[2], 0.12)) return { kind: 'spawn', index: i };
|
||||
for (const [i, b] of scene.buildings.entries())
|
||||
if (Math.abs(wx - b.position[0]) < b.size[0] / 2 + 0.05 && Math.abs(wz - b.position[2]) < b.size[2] / 2 + 0.05)
|
||||
return { kind: 'building', index: i };
|
||||
return null;
|
||||
}
|
||||
const objOf = (s) => s && ({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[s.kind][s.index];
|
||||
|
||||
cv.addEventListener('pointerdown', (e) => {
|
||||
cv.setPointerCapture(e.pointerId);
|
||||
const h = hit(e.offsetX, e.offsetY);
|
||||
sel = h;
|
||||
drag = h ? { kind: 'move' } : { kind: 'pan', sx: e.offsetX, sy: e.offsetY, vx: view.x, vz: view.z };
|
||||
panel(); draw();
|
||||
});
|
||||
cv.addEventListener('pointermove', (e) => {
|
||||
if (!drag) return;
|
||||
if (drag.kind === 'move' && sel) {
|
||||
const [wx, wz] = s2w(e.offsetX, e.offsetY);
|
||||
const o = objOf(sel);
|
||||
o.position[0] = Math.round(wx * 200) / 200; // 5 mm snap
|
||||
o.position[2] = Math.round(wz * 200) / 200;
|
||||
panel(false); draw();
|
||||
} else if (drag.kind === 'pan') {
|
||||
view.x = drag.vx - (e.offsetX - drag.sx) / view.scale;
|
||||
view.z = drag.vz - (e.offsetY - drag.sy) / view.scale;
|
||||
draw();
|
||||
}
|
||||
});
|
||||
cv.addEventListener('pointerup', () => drag = null);
|
||||
cv.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
view.scale = Math.min(600, Math.max(30, view.scale * (e.deltaY < 0 ? 1.12 : 0.89)));
|
||||
draw();
|
||||
}, { passive: false });
|
||||
|
||||
// ---------- add / delete ----------
|
||||
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => {
|
||||
const [wx, wz] = [view.x, view.z];
|
||||
if (b.dataset.add === 'anchor') {
|
||||
const used = new Set(scene.anchors.map(a => a.markerId));
|
||||
let id = 0; while (used.has(id)) id++;
|
||||
scene.anchors.push({ markerId: id, position: [wx, 0, wz], mount: 'flat', yawDeg: 0, pitchDeg: 0, rollDeg: 0, sizeMM: 60, enabled: true });
|
||||
sel = { kind: 'anchor', index: scene.anchors.length - 1 };
|
||||
} else if (b.dataset.add === 'building') {
|
||||
scene.buildings.push({ name: 'building', position: [wx, 0, wz], size: [0.4, 0.3, 0.3], yawDeg: 0 });
|
||||
sel = { kind: 'building', index: scene.buildings.length - 1 };
|
||||
} else {
|
||||
scene.spawns.push({ id: 'spawn-' + (scene.spawns.length + 1), position: [wx, 0.25, wz], enabled: true });
|
||||
sel = { kind: 'spawn', index: scene.spawns.length - 1 };
|
||||
}
|
||||
panel(); draw();
|
||||
});
|
||||
|
||||
// ---------- property panel ----------
|
||||
const side = document.getElementById('side');
|
||||
function field(label, val, oncommit, opts) {
|
||||
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(id);
|
||||
el.onchange = () => { oncommit(opts?.select ? el.value : parseFloat(el.value)); panel(false); draw(); };
|
||||
});
|
||||
if (opts?.select)
|
||||
return `<div class="row"><label>${label}</label><select id="${id}">${opts.select.map(o => `<option ${o === val ? 'selected' : ''}>${o}</option>`).join('')}</select></div>`;
|
||||
return `<div class="row"><label>${label}</label><input id="${id}" type="number" step="${opts?.step ?? 0.01}" value="${val}"></div>`;
|
||||
}
|
||||
function textField(label, val, oncommit) {
|
||||
const id = 'f' + Math.random().toString(36).slice(2, 8);
|
||||
setTimeout(() => { const el = document.getElementById(id); el.onchange = () => { oncommit(el.value); panel(false); draw(); }; });
|
||||
return `<div class="row"><label>${label}</label><input id="${id}" value="${val}"></div>`;
|
||||
}
|
||||
function panel(rebuild = true) {
|
||||
if (!rebuild) return;
|
||||
const o = objOf(sel);
|
||||
if (!o) { side.innerHTML = '<em style="opacity:.6">Select an item to edit it.</em>'; return; }
|
||||
let h = `<h2>${sel.kind} ${sel.index}</h2>`;
|
||||
if (sel.kind === 'anchor') {
|
||||
h += field('marker ID', o.markerId, v => o.markerId = v | 0, { step: 1 });
|
||||
h += field('x (m)', o.position[0], v => o.position[0] = v);
|
||||
h += field('y (m)', o.position[1], v => o.position[1] = v);
|
||||
h += field('z (m)', o.position[2], v => o.position[2] = v);
|
||||
h += field('mount', o.mount, v => o.mount = v, { select: ['flat', 'wall', 'custom'] });
|
||||
h += field('yaw °', o.yawDeg, v => o.yawDeg = v, { step: 1 });
|
||||
h += field('pitch °', o.pitchDeg, v => o.pitchDeg = v, { step: 1 });
|
||||
h += field('roll °', o.rollDeg, v => o.rollDeg = v, { step: 1 });
|
||||
h += field('size mm', o.sizeMM, v => o.sizeMM = v, { step: 1 });
|
||||
h += field('enabled', o.enabled ? 'yes' : 'no', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
|
||||
h += `<p class="badge">flat = face-up on a surface · wall = vertical, yaw sets facing · custom = full yaw/pitch/roll</p>`;
|
||||
} else if (sel.kind === 'building') {
|
||||
h += textField('name', o.name || '', v => o.name = v);
|
||||
h += field('x (m)', o.position[0], v => o.position[0] = v);
|
||||
h += field('z (m)', o.position[2], v => o.position[2] = v);
|
||||
h += field('width', o.size[0], v => o.size[0] = v);
|
||||
h += field('height', o.size[1], v => o.size[1] = v);
|
||||
h += field('depth', o.size[2], v => o.size[2] = v);
|
||||
h += field('yaw °', o.yawDeg || 0, v => o.yawDeg = v, { step: 1 });
|
||||
} else {
|
||||
h += textField('id', o.id, v => o.id = v);
|
||||
h += field('x (m)', o.position[0], v => o.position[0] = v);
|
||||
h += field('y (m)', o.position[1], v => o.position[1] = v);
|
||||
h += field('z (m)', o.position[2], v => o.position[2] = v);
|
||||
h += field('enabled', o.enabled === false ? 'no' : 'yes', v => o.enabled = v === 'yes', { select: ['yes', 'no'] });
|
||||
}
|
||||
h += `<div class="row" style="margin-top:12px"><button id="del" class="danger">Delete</button></div>`;
|
||||
side.innerHTML = h;
|
||||
document.getElementById('del').onclick = () => {
|
||||
({ anchor: scene.anchors, building: scene.buildings, spawn: scene.spawns })[sel.kind].splice(sel.index, 1);
|
||||
sel = null; panel(); draw();
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- save / reset ----------
|
||||
const status = document.getElementById('status');
|
||||
document.getElementById('save').onclick = async () => {
|
||||
try { scene = await api('/api/scene', 'PUT', scene); status.textContent = 'Saved ' + new Date().toLocaleTimeString(); }
|
||||
catch (e) { status.textContent = 'Save failed: ' + e.message; }
|
||||
};
|
||||
document.getElementById('reset').onclick = async () => {
|
||||
if (!confirm('Reset layout to committed seed? Live edits will be lost.')) return;
|
||||
scene = await api('/api/scene/reset', 'POST');
|
||||
sel = null; panel(); draw();
|
||||
};
|
||||
|
||||
fit(); panel();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Admin Login — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div style="max-width:340px;margin:18vh auto;text-align:center">
|
||||
<h1 style="font-size:1.3rem">Exhibit Admin</h1>
|
||||
<input id="pw" type="password" placeholder="Admin password" style="width:100%;padding:10px;margin:12px 0;font-size:1rem">
|
||||
<button id="go" class="primary" style="width:100%;padding:10px;font-size:1rem">Sign in</button>
|
||||
<div id="err" style="color:#ff8b8b;margin-top:10px;font-size:.85rem"></div>
|
||||
</div>
|
||||
<script type="module">
|
||||
import { styles } from './admin.js';
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}</style>`);
|
||||
const go = async () => {
|
||||
const res = await fetch('/api/login', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: document.getElementById('pw').value }) });
|
||||
if (!res.ok) { document.getElementById('err').textContent = 'Wrong password.'; return; }
|
||||
localStorage.setItem('exhibitToken', (await res.json()).token);
|
||||
location.href = new URLSearchParams(location.search).get('next') || '/admin/layout.html';
|
||||
};
|
||||
document.getElementById('go').onclick = go;
|
||||
document.getElementById('pw').onkeydown = (e) => e.key === 'Enter' && go();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,99 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Playlist — Newbury Exhibit</title></head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module">
|
||||
import { api, requireAuth, styles, nav } from './admin.js';
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>${styles}
|
||||
main { max-width:640px; margin:20px auto; padding:0 16px; }
|
||||
.card { background:#111730; border-radius:10px; padding:16px; margin-bottom:16px; }
|
||||
.row { display:flex; gap:10px; align-items:center; margin:8px 0; }
|
||||
.row label { width:170px; }
|
||||
.chips { display:flex; flex-wrap:wrap; gap:6px; }
|
||||
.chip { padding:4px 12px; border-radius:999px; background:#1a2244; cursor:pointer; font-size:.85rem; user-select:none; }
|
||||
.chip.on { background:#2a6ea0; }
|
||||
table { width:100%; font-size:.85rem; }
|
||||
</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const app = document.getElementById('app');
|
||||
let cfg = await api('/api/playlist');
|
||||
const { ghosts } = await api('/api/ghosts');
|
||||
|
||||
const colors = ['Red', 'Yellow', 'Blue'];
|
||||
const rarities = ['Common', 'Rare', 'Epic', 'Legendary'];
|
||||
|
||||
function chips(list, selected, onToggle) {
|
||||
return list.map(v => `<span class="chip ${selected.includes(v) ? 'on' : ''}" data-v="${v}">${v}</span>`).join('');
|
||||
}
|
||||
|
||||
function rosterCount() {
|
||||
const inc = cfg.include || {};
|
||||
return ghosts.filter(g =>
|
||||
(!inc.colors?.length || inc.colors.includes(g.color)) &&
|
||||
(!inc.rarities?.length || inc.rarities.includes(g.rarity))).length;
|
||||
}
|
||||
|
||||
function render() {
|
||||
app.innerHTML = `${nav('playlist')}<main>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Rotation</h2>
|
||||
<div class="row"><label>Concurrent ghosts (slots)</label><input id="slots" type="number" min="1" max="20" value="${cfg.slots}"></div>
|
||||
<div class="row"><label>Dwell time (seconds)</label><input id="dwell" type="number" min="10" value="${cfg.dwellSeconds}"></div>
|
||||
<div class="row"><label>Crossfade (seconds)</label><input id="fade" type="number" min="0" value="${cfg.crossfadeSeconds}"></div>
|
||||
<div class="row"><label>Order</label>
|
||||
<select id="order"><option ${cfg.order === 'shuffle' ? 'selected' : ''}>shuffle</option><option ${cfg.order === 'roster' ? 'selected' : ''}>roster</option></select></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Roster filter <span style="opacity:.6;font-size:.8rem">(${rosterCount()} ghosts match — empty = all)</span></h2>
|
||||
<div class="row"><label>Colors</label><div class="chips" id="colors">${chips(colors, cfg.include?.colors || [])}</div></div>
|
||||
<div class="row"><label>Rarities</label><div class="chips" id="rarities">${chips(rarities, cfg.include?.rarities || [])}</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Behavior</h2>
|
||||
<div class="row"><label>Static chance (0–1)</label><input id="staticChance" type="number" step="0.05" min="0" max="1" value="${cfg.behaviors.staticChance}"></div>
|
||||
<div class="row"><label>Wander radius (m)</label><input id="wanderRadius" type="number" step="0.05" value="${cfg.behaviors.wanderRadius}"></div>
|
||||
<div class="row"><label>Wander speed</label><input id="wanderSpeed" type="number" step="0.05" value="${cfg.behaviors.wanderSpeed}"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 style="margin-top:0">Time windows <span style="opacity:.6;font-size:.8rem">(ghosts only appear inside these; empty = always on)</span></h2>
|
||||
<div id="windows">${(cfg.timeWindows || []).map((w, i) =>
|
||||
`<div class="row"><input type="time" value="${w.start}" data-i="${i}" data-k="start"> →
|
||||
<input type="time" value="${w.end}" data-i="${i}" data-k="end">
|
||||
<button data-del="${i}" class="danger">✕</button></div>`).join('') || '<em style="opacity:.6">Always on</em>'}</div>
|
||||
<button id="addWin" style="margin-top:8px">+ Add window</button>
|
||||
</div>
|
||||
<button id="save" class="primary" style="width:100%;padding:12px;font-size:1rem">Apply playlist (restarts rotation)</button>
|
||||
<div id="status" style="margin:10px 0;opacity:.7;font-size:.85rem"></div>
|
||||
</main>`;
|
||||
|
||||
for (const grp of ['colors', 'rarities']) {
|
||||
document.getElementById(grp).querySelectorAll('.chip').forEach(c => c.onclick = () => {
|
||||
const arr = cfg.include[grp] = cfg.include[grp] || [];
|
||||
const i = arr.indexOf(c.dataset.v);
|
||||
i >= 0 ? arr.splice(i, 1) : arr.push(c.dataset.v);
|
||||
render();
|
||||
});
|
||||
}
|
||||
document.getElementById('addWin').onclick = () => { (cfg.timeWindows ||= []).push({ start: '09:00', end: '17:00' }); render(); };
|
||||
document.querySelectorAll('[data-del]').forEach(b => b.onclick = () => { cfg.timeWindows.splice(+b.dataset.del, 1); render(); });
|
||||
document.querySelectorAll('#windows input').forEach(inp => inp.onchange = () => cfg.timeWindows[+inp.dataset.i][inp.dataset.k] = inp.value);
|
||||
|
||||
document.getElementById('save').onclick = async () => {
|
||||
cfg.slots = +document.getElementById('slots').value;
|
||||
cfg.dwellSeconds = +document.getElementById('dwell').value;
|
||||
cfg.crossfadeSeconds = +document.getElementById('fade').value;
|
||||
cfg.order = document.getElementById('order').value;
|
||||
cfg.behaviors = {
|
||||
staticChance: +document.getElementById('staticChance').value,
|
||||
wanderRadius: +document.getElementById('wanderRadius').value,
|
||||
wanderSpeed: +document.getElementById('wanderSpeed').value,
|
||||
};
|
||||
try { cfg = await api('/api/playlist', 'PUT', cfg); document.getElementById('status').textContent = 'Applied ' + new Date().toLocaleTimeString(); }
|
||||
catch (e) { document.getElementById('status').textContent = 'Failed: ' + e.message; }
|
||||
};
|
||||
}
|
||||
render();
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Print Crests — Newbury Exhibit</title>
|
||||
<style>
|
||||
body { font-family:system-ui,sans-serif; margin:0; }
|
||||
.sheet { display:flex; flex-wrap:wrap; gap:8mm; padding:10mm; }
|
||||
.crest { text-align:center; page-break-inside:avoid; }
|
||||
.crest canvas { image-rendering:pixelated; border:0.5mm solid #000; }
|
||||
.crest .cap { font-size:9pt; margin-top:2mm; }
|
||||
@media print { header { display:none } }
|
||||
</style></head>
|
||||
<body>
|
||||
<div id="hdr"></div>
|
||||
<div class="sheet" id="sheet"></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 { requireAuth, styles, nav } from './admin.js';
|
||||
document.getElementById('hdr').innerHTML = nav('print');
|
||||
document.head.insertAdjacentHTML('beforeend', `<style>@media screen {${styles}}</style>`);
|
||||
await requireAuth();
|
||||
|
||||
const scene = await (await fetch('/api/scene')).json();
|
||||
const dic = new AR.Dictionary('ARUCO_4X4_1000');
|
||||
const MM2PX = 96 / 25.4; // CSS px per mm
|
||||
|
||||
const sheet = document.getElementById('sheet');
|
||||
for (const a of scene.anchors) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'crest';
|
||||
const cv = document.createElement('canvas');
|
||||
const bits = 4 + 2; // 4x4 payload + 1-cell black border each side
|
||||
const px = Math.round(a.sizeMM * MM2PX);
|
||||
cv.width = cv.height = bits * 10;
|
||||
cv.style.width = cv.style.height = px + 'px';
|
||||
const ctx = cv.getContext('2d');
|
||||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, cv.width, cv.height);
|
||||
const code = dic.codeList[a.markerId]; // 16-char '0'/'1' string, row-major
|
||||
ctx.fillStyle = '#fff';
|
||||
for (let y = 0; y < 4; y++) for (let x = 0; x < 4; x++) {
|
||||
if (code[y * 4 + x] === '1') ctx.fillRect((x + 1) * 10, (y + 1) * 10, 10, 10);
|
||||
}
|
||||
div.appendChild(cv);
|
||||
div.insertAdjacentHTML('beforeend',
|
||||
`<div class="cap"><b>Crest #${a.markerId}</b> · ${a.sizeMM} mm · ${a.mount}` +
|
||||
`${a.mount === 'wall' ? ` · yaw ${a.yawDeg}°` : ''}<br>` +
|
||||
`pos ${a.position.map(v => v.toFixed(2)).join(', ')} m</div>`);
|
||||
sheet.appendChild(div);
|
||||
}
|
||||
if (!scene.anchors.length) sheet.innerHTML = '<p style="padding:20px">No anchors in the scene yet — add some in the Layout editor.</p>';
|
||||
</script>
|
||||
</body></html>
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Newbury Exhibit</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { margin:0; height:100%; overflow:hidden; background:#06080f; font-family:system-ui, sans-serif; color:#e8ecff; }
|
||||
#cam { position:fixed; inset:0; width:100%; height:100%; object-fit:cover; }
|
||||
#gl { position:fixed; inset:0; width:100%; height:100%; }
|
||||
.hidden { display:none !important; }
|
||||
|
||||
#startScreen { position:fixed; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
|
||||
background:radial-gradient(ellipse at 50% 30%, #12203f 0%, #06080f 70%); text-align:center; padding:24px; z-index:10; }
|
||||
#startScreen h1 { font-size:2rem; margin:0 0 4px; letter-spacing:.06em; }
|
||||
#startScreen .sub { opacity:.7; margin-bottom:28px; }
|
||||
#start { font-size:1.2rem; padding:14px 42px; border-radius:999px; border:0; cursor:pointer;
|
||||
background:linear-gradient(135deg,#51eaf1,#529eff); color:#04121a; font-weight:700; }
|
||||
.disclaimer { position:absolute; bottom:14px; left:16px; right:16px; font-size:.68rem; opacity:.55; line-height:1.4; }
|
||||
|
||||
#hud { position:fixed; top:0; left:0; right:0; display:flex; justify-content:space-between; padding:10px 14px;
|
||||
pointer-events:none; z-index:5; text-shadow:0 1px 3px #000; }
|
||||
#hud span { background:rgba(6,8,15,.55); border-radius:8px; padding:5px 10px; font-size:.85rem; }
|
||||
#trk.warn { color:#ffd166; }
|
||||
|
||||
#toast { position:fixed; bottom:32px; left:50%; transform:translateX(-50%) translateY(20px);
|
||||
background:rgba(20,28,55,.9); padding:10px 20px; border-radius:999px; opacity:0; transition:all .3s; z-index:6; }
|
||||
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<video id="cam" playsinline muted></video>
|
||||
<canvas id="gl"></canvas>
|
||||
|
||||
<div id="startScreen">
|
||||
<h1>NEWBURY EXHIBIT</h1>
|
||||
<div class="sub">Point your phone at the Newbury Crests to reveal the hidden side of the town.</div>
|
||||
<button id="start">Start Ghost Watching</button>
|
||||
<div class="disclaimer">Fan-made tribute experience. Not affiliated with, sponsored, or endorsed by the LEGO Group.
|
||||
LEGO® and Hidden Side™ are trademarks of the LEGO Group.</div>
|
||||
</div>
|
||||
|
||||
<div id="hud" class="hidden">
|
||||
<span id="trk">—</span>
|
||||
<span id="cnt">—</span>
|
||||
</div>
|
||||
<div id="toast"></div>
|
||||
|
||||
<!-- js-aruco2 vendor chain — load order matters: cv -> svd -> posit -> aruco -> dictionary -->
|
||||
<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" src="/js/exhibit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,22 @@
|
||||
/* detect.js — tuned ArUco 4x4 detection.
|
||||
* Phantom-ID fix: reject any marker decoded with hamming distance > 0 (maxHamming: 0).
|
||||
*/
|
||||
export function createDetector() {
|
||||
return new AR.Detector({ dictionaryName: 'ARUCO_4X4_1000', maxHammingDistance: 0 });
|
||||
}
|
||||
|
||||
export function detectMarkers(detector, imageData, knownIds) {
|
||||
const markers = detector.detect(imageData);
|
||||
// keep only markers that exist in the scene, with sane geometry
|
||||
return markers.filter(m => knownIds.has(m.id) && quadArea(m.corners) > 100);
|
||||
}
|
||||
|
||||
export function quadArea(c) {
|
||||
// shoelace
|
||||
let a = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const p = c[i], q = c[(i + 1) % 4];
|
||||
a += p.x * q.y - q.x * p.y;
|
||||
}
|
||||
return Math.abs(a) / 2;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* fuse.js — fuseWorld: combine per-marker camera pose estimates into one world pose.
|
||||
*
|
||||
* Each detected marker yields a camera-in-world estimate:
|
||||
* cameraWorld = anchorWorld * inverse(markerPoseInCamera)
|
||||
* Estimates are fused by confidence-weighted quaternion slerp (incremental
|
||||
* weighted average) and weighted position mean. Confidence = marker screen area
|
||||
* (bigger/closer markers dominate). A light temporal smooth removes residual jitter.
|
||||
*
|
||||
* Requiring >= 2 visible markers is handled upstream by anchor placement density;
|
||||
* fuseWorld itself works with 1..N.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { anchorWorldMatrix } from './pose.js';
|
||||
|
||||
const _inv = new THREE.Matrix4();
|
||||
const _m = new THREE.Matrix4();
|
||||
const _p = new THREE.Vector3();
|
||||
const _q = new THREE.Quaternion();
|
||||
const _s = new THREE.Vector3();
|
||||
|
||||
export class WorldFuser {
|
||||
constructor() {
|
||||
this.anchorMats = new Map(); // markerId -> Matrix4
|
||||
this.smoothPos = null;
|
||||
this.smoothQuat = null;
|
||||
this.posAlpha = 0.35; // smoothing factors (higher = snappier)
|
||||
this.quatAlpha = 0.35;
|
||||
this.lastFuseT = 0;
|
||||
}
|
||||
|
||||
setScene(scene) {
|
||||
this.anchorMats.clear();
|
||||
for (const a of scene.anchors || []) {
|
||||
if (a.enabled === false) continue;
|
||||
this.anchorMats.set(a.markerId, { mat: anchorWorldMatrix(a), sizeMM: a.sizeMM || 60 });
|
||||
}
|
||||
}
|
||||
|
||||
sizeFor(markerId) { return this.anchorMats.get(markerId)?.sizeMM || 60; }
|
||||
knownIds() { return new Set(this.anchorMats.keys()); }
|
||||
|
||||
/** estimates: [{ markerId, position(mm), quaternion, area }] */
|
||||
fuse(estimates) {
|
||||
const MM = 0.001;
|
||||
const cams = [];
|
||||
for (const e of estimates) {
|
||||
const entry = this.anchorMats.get(e.markerId);
|
||||
if (!entry) continue;
|
||||
// marker pose in camera space -> matrix (translate mm->m)
|
||||
_m.compose(_p.copy(e.position).multiplyScalar(MM), e.quaternion, _s.set(1, 1, 1));
|
||||
_inv.copy(_m).invert(); // camera in marker space
|
||||
const camWorld = new THREE.Matrix4().multiplyMatrices(entry.mat, _inv);
|
||||
const pos = new THREE.Vector3();
|
||||
const quat = new THREE.Quaternion();
|
||||
camWorld.decompose(pos, quat, _s);
|
||||
cams.push({ pos, quat, w: Math.max(1, e.area) });
|
||||
}
|
||||
if (!cams.length) return null;
|
||||
|
||||
// weighted position mean + incremental weighted slerp for orientation
|
||||
let wSum = cams[0].w;
|
||||
const pos = cams[0].pos.clone().multiplyScalar(cams[0].w);
|
||||
const quat = cams[0].quat.clone();
|
||||
for (let i = 1; i < cams.length; i++) {
|
||||
const c = cams[i];
|
||||
// hemisphere alignment before slerp (quaternion double-cover)
|
||||
if (quat.dot(c.quat) < 0) c.quat.set(-c.quat.x, -c.quat.y, -c.quat.z, -c.quat.w);
|
||||
const t = c.w / (wSum + c.w);
|
||||
quat.slerp(c.quat, t);
|
||||
pos.add(c.pos.clone().multiplyScalar(c.w));
|
||||
wSum += c.w;
|
||||
}
|
||||
pos.multiplyScalar(1 / wSum);
|
||||
|
||||
// temporal smoothing
|
||||
const now = performance.now();
|
||||
if (this.smoothPos && now - this.lastFuseT < 500) {
|
||||
this.smoothPos.lerp(pos, this.posAlpha);
|
||||
if (this.smoothQuat.dot(quat) < 0) quat.set(-quat.x, -quat.y, -quat.z, -quat.w);
|
||||
this.smoothQuat.slerp(quat, this.quatAlpha);
|
||||
} else {
|
||||
this.smoothPos = pos.clone();
|
||||
this.smoothQuat = quat.clone();
|
||||
}
|
||||
this.lastFuseT = now;
|
||||
return { position: this.smoothPos.clone(), quaternion: this.smoothQuat.clone(), markerCount: cams.length };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* pose.js — marker pose estimation with the confirmed fixes:
|
||||
* 1. POS-IT rotation used AS-IS; translation Y and Z negated (-t[1], -t[2]).
|
||||
* 2. POS-IT planar ambiguity resolved by temporal consistency:
|
||||
* pick the solution (bestError vs alternativeError) closest to the previous frame.
|
||||
*
|
||||
* Requires vendor chain loaded in order: cv -> svd -> posit1 -> aruco -> dictionary.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
const _m = new THREE.Matrix4();
|
||||
const _q = new THREE.Quaternion();
|
||||
|
||||
export class PoseEstimator {
|
||||
constructor(focalLength) {
|
||||
this.focal = focalLength;
|
||||
this.posits = new Map(); // sizeMM -> POS.Posit
|
||||
this.prev = new Map(); // markerId -> { quat, pos, t }
|
||||
this.prevTTL = 1500; // ms before history is considered stale
|
||||
}
|
||||
|
||||
positFor(sizeMM) {
|
||||
if (!this.posits.has(sizeMM)) this.posits.set(sizeMM, new POS.Posit(sizeMM, this.focal));
|
||||
return this.posits.get(sizeMM);
|
||||
}
|
||||
|
||||
/** corners: aruco marker corners, image-space; cx/cy: image center.
|
||||
* Returns { position: THREE.Vector3 (mm, marker->camera), quaternion, error } */
|
||||
estimate(markerId, corners, cx, cy, sizeMM) {
|
||||
const centered = corners.map(c => ({ x: c.x - cx, y: (cy - c.y) }));
|
||||
const pose = this.positFor(sizeMM).pose(centered);
|
||||
if (!pose) return null;
|
||||
|
||||
const cand = [
|
||||
this.candidate(pose.bestRotation, pose.bestTranslation, pose.bestError),
|
||||
this.candidate(pose.alternativeRotation, pose.alternativeTranslation, pose.alternativeError),
|
||||
];
|
||||
|
||||
// Temporal consistency: prefer the solution nearest the previous frame's quat.
|
||||
const prev = this.prev.get(markerId);
|
||||
let pick;
|
||||
if (prev && (performance.now() - prev.t) < this.prevTTL) {
|
||||
const d0 = Math.abs(cand[0].quaternion.dot(prev.quat));
|
||||
const d1 = Math.abs(cand[1].quaternion.dot(prev.quat));
|
||||
// only override error-order if the alternative is clearly more consistent
|
||||
pick = (d1 > d0 + 0.05) ? cand[1] : (d0 > d1 + 0.05 ? cand[0] : (cand[0].error <= cand[1].error ? cand[0] : cand[1]));
|
||||
} else {
|
||||
pick = cand[0].error <= cand[1].error ? cand[0] : cand[1];
|
||||
}
|
||||
|
||||
this.prev.set(markerId, { quat: pick.quaternion.clone(), pos: pick.position.clone(), t: performance.now() });
|
||||
return pick;
|
||||
}
|
||||
|
||||
candidate(rot, t, error) {
|
||||
// Rotation as-is (row-major 3x3 -> Matrix4)
|
||||
_m.set(
|
||||
rot[0][0], rot[0][1], rot[0][2], 0,
|
||||
rot[1][0], rot[1][1], rot[1][2], 0,
|
||||
rot[2][0], rot[2][1], rot[2][2], 0,
|
||||
0, 0, 0, 1
|
||||
);
|
||||
const quaternion = new THREE.Quaternion().setFromRotationMatrix(_m);
|
||||
// Translation: negate Y and Z only (confirmed fix)
|
||||
const position = new THREE.Vector3(t[0], -t[1], -t[2]);
|
||||
return { position, quaternion, error };
|
||||
}
|
||||
}
|
||||
|
||||
/** Build the marker->world transform for an anchor.
|
||||
* mount 'flat': marker printed face-up on a horizontal surface.
|
||||
* mount 'wall': marker on a vertical surface; yawDeg = facing direction.
|
||||
* mount 'custom': explicit yaw/pitch/roll (deg) applied in YXZ order.
|
||||
* All mounts additionally honour yaw/pitch/roll offsets for fine trim.
|
||||
*/
|
||||
export function anchorWorldMatrix(anchor) {
|
||||
const pos = new THREE.Vector3(...anchor.position);
|
||||
const yaw = THREE.MathUtils.degToRad(anchor.yawDeg || 0);
|
||||
const pitch = THREE.MathUtils.degToRad(anchor.pitchDeg || 0);
|
||||
const roll = THREE.MathUtils.degToRad(anchor.rollDeg || 0);
|
||||
|
||||
// Base orientation by mount:
|
||||
// flat: marker face-up — marker +Z (out of print face) -> world +Y
|
||||
// wall: marker vertical — marker +Z faces world +Z when yawDeg = 0
|
||||
const base = new THREE.Quaternion();
|
||||
if (anchor.mount !== 'wall' && anchor.mount !== 'custom') {
|
||||
base.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
|
||||
}
|
||||
|
||||
// Trim (fully editable): yaw about world Y, then pitch/roll fine adjustment
|
||||
const trim = new THREE.Quaternion().setFromEuler(new THREE.Euler(pitch, yaw, roll, 'YXZ'));
|
||||
const q = trim.multiply(base);
|
||||
return new THREE.Matrix4().compose(pos, q, new THREE.Vector3(1, 1, 1));
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/* exhibit.js — main viewer.
|
||||
* Pipeline: camera video -> ArUco detect (maxHamming 0) -> per-marker POS-IT pose
|
||||
* (rotation as-is, -t[1] -t[2]) -> fuseWorld (confidence-weighted slerp) -> Three.js
|
||||
* camera placed in world -> server-driven ghosts + building occlusion meshes.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { createDetector, detectMarkers, quadArea } from './ar/detect.js';
|
||||
import { PoseEstimator } from './ar/pose.js';
|
||||
import { WorldFuser } from './ar/fuse.js';
|
||||
import { buildGhost } from './ghosts/loader.js';
|
||||
import { ghostTransform } from './ghosts/behavior.js';
|
||||
import { ExhibitNet, installErrorReporter } from './net.js';
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
let info = { mode: 'dev' };
|
||||
let scene3, camera3, renderer, video, canvas2d, ctx2d;
|
||||
let detector, poseEst, fuser;
|
||||
let gradients = {}, modelManifest = null;
|
||||
const activeGhosts = new Map(); // uid -> { rec, group }
|
||||
let tracking = { markers: 0, lastSeen: 0 };
|
||||
const clockOffsetSamples = [];
|
||||
let clockOffset = 0; // serverNow - clientNow
|
||||
|
||||
async function boot() {
|
||||
info = await (await fetch('/api/info')).json();
|
||||
installErrorReporter(info.mode === 'dev');
|
||||
|
||||
const g = await (await fetch('/api/ghosts')).json();
|
||||
gradients = g.gradients.gradients || g.gradients;
|
||||
modelManifest = await (await fetch('/api/models')).json();
|
||||
|
||||
$('#start').addEventListener('click', start, { once: true });
|
||||
}
|
||||
|
||||
async function start() {
|
||||
$('#startScreen').classList.add('hidden');
|
||||
$('#hud').classList.remove('hidden');
|
||||
|
||||
// camera
|
||||
video = $('#cam');
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: 'environment', width: { ideal: 1280 }, height: { ideal: 720 } },
|
||||
audio: false,
|
||||
});
|
||||
video.srcObject = stream;
|
||||
await video.play();
|
||||
|
||||
// detection canvas
|
||||
canvas2d = document.createElement('canvas');
|
||||
ctx2d = canvas2d.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
// three.js
|
||||
renderer = new THREE.WebGLRenderer({ canvas: $('#gl'), alpha: true, antialias: true });
|
||||
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
||||
scene3 = new THREE.Scene();
|
||||
camera3 = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.02, 50);
|
||||
scene3.add(new THREE.AmbientLight(0xffffff, 1.2));
|
||||
onResize(); addEventListener('resize', onResize);
|
||||
|
||||
detector = createDetector();
|
||||
fuser = new WorldFuser();
|
||||
|
||||
// network
|
||||
const net = new ExhibitNet();
|
||||
net.on('scene', (m) => { fuser.setScene(m.scene); buildOcclusion(m.scene); })
|
||||
.on('active', async (m) => {
|
||||
for (const uid of [...activeGhosts.keys()]) removeGhost(uid);
|
||||
for (const rec of m.ghosts) await addGhost(rec);
|
||||
})
|
||||
.on('spawn', (m) => addGhost(m.ghost))
|
||||
.on('despawn', (m) => scheduleRemove(m.uid))
|
||||
.on('pong', (m) => {
|
||||
const rtt = performance.now() - m.t;
|
||||
clockOffsetSamples.push(m.server + rtt / 2 - Date.now());
|
||||
if (clockOffsetSamples.length > 5) clockOffsetSamples.shift();
|
||||
clockOffset = clockOffsetSamples.reduce((a, b) => a + b, 0) / clockOffsetSamples.length;
|
||||
});
|
||||
net.connect();
|
||||
setInterval(() => { try { net.ws.send(JSON.stringify({ type: 'ping', t: performance.now() })); } catch {} }, 5000);
|
||||
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function onResize() {
|
||||
renderer.setSize(innerWidth, innerHeight);
|
||||
camera3.aspect = innerWidth / innerHeight;
|
||||
camera3.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// ---------- occlusion: invisible depth-only building volumes ----------
|
||||
const occlusionGroup = new THREE.Group();
|
||||
function buildOcclusion(scene) {
|
||||
occlusionGroup.clear();
|
||||
const mat = new THREE.MeshBasicMaterial({ colorWrite: false }); // depth-only
|
||||
for (const b of scene.buildings || []) {
|
||||
const geo = new THREE.BoxGeometry(b.size[0], b.size[1], b.size[2]);
|
||||
const mesh = new THREE.Mesh(geo, mat);
|
||||
mesh.position.set(b.position[0], b.position[1] + b.size[1] / 2, b.position[2]);
|
||||
mesh.rotation.y = THREE.MathUtils.degToRad(b.yawDeg || 0);
|
||||
mesh.renderOrder = -1;
|
||||
occlusionGroup.add(mesh);
|
||||
}
|
||||
if (!occlusionGroup.parent) scene3.add(occlusionGroup);
|
||||
}
|
||||
|
||||
// ---------- ghosts ----------
|
||||
async function addGhost(rec) {
|
||||
if (activeGhosts.has(rec.uid)) return;
|
||||
const group = await buildGhost(rec, gradients, modelManifest);
|
||||
scene3.add(group);
|
||||
activeGhosts.set(rec.uid, { rec, group });
|
||||
toast(`${rec.name} appeared`);
|
||||
}
|
||||
function removeGhost(uid) {
|
||||
const e = activeGhosts.get(uid);
|
||||
if (!e) return;
|
||||
scene3.remove(e.group);
|
||||
activeGhosts.delete(uid);
|
||||
}
|
||||
function scheduleRemove(uid) {
|
||||
const e = activeGhosts.get(uid);
|
||||
if (!e) return removeGhost(uid);
|
||||
// let the client-side crossfade (driven by rec.until) finish, then remove
|
||||
const wait = Math.max(0, e.rec.until - (Date.now() + clockOffset)) + (e.rec.crossfade || 3) * 1000;
|
||||
setTimeout(() => removeGhost(uid), Math.min(wait, 8000));
|
||||
}
|
||||
|
||||
// ---------- main loop ----------
|
||||
const _tmp = { position: new THREE.Vector3(), rotationY: 0, opacity: 1 };
|
||||
let frameCount = 0;
|
||||
|
||||
function loop(t) {
|
||||
requestAnimationFrame(loop);
|
||||
if (video && video.readyState >= 2) {
|
||||
// downscale for detection speed
|
||||
const W = 640;
|
||||
const H = Math.round(W * video.videoHeight / video.videoWidth);
|
||||
if (canvas2d.width !== W) { canvas2d.width = W; canvas2d.height = H; }
|
||||
ctx2d.drawImage(video, 0, 0, W, H);
|
||||
const img = ctx2d.getImageData(0, 0, W, H);
|
||||
|
||||
if (!poseEst) {
|
||||
// focal length in detection-canvas pixels from camera FOV assumption (~60° h-fov)
|
||||
poseEst = new PoseEstimator(W / (2 * Math.tan(THREE.MathUtils.degToRad(60) / 2)));
|
||||
}
|
||||
|
||||
const markers = detectMarkers(detector, img, fuser.knownIds());
|
||||
tracking.markers = markers.length;
|
||||
if (markers.length) {
|
||||
tracking.lastSeen = performance.now();
|
||||
const estimates = markers.map(m => {
|
||||
const e = poseEst.estimate(m.id, m.corners, W / 2, H / 2, fuser.sizeFor(m.id));
|
||||
return e && { markerId: m.id, position: e.position, quaternion: e.quaternion, area: quadArea(m.corners) };
|
||||
}).filter(Boolean);
|
||||
const fused = fuser.fuse(estimates);
|
||||
if (fused) {
|
||||
camera3.position.copy(fused.position);
|
||||
camera3.quaternion.copy(fused.quaternion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ghosts: deterministic motion on synced clock
|
||||
const now = Date.now() + clockOffset;
|
||||
for (const { rec, group } of activeGhosts.values()) {
|
||||
ghostTransform(rec, now, _tmp);
|
||||
group.position.copy(_tmp.position);
|
||||
group.rotation.y = _tmp.rotationY;
|
||||
group.userData.setOpacity(_tmp.opacity * 0.92);
|
||||
group.userData.tick(t / 1000);
|
||||
}
|
||||
|
||||
// HUD
|
||||
if ((frameCount++ & 15) === 0) {
|
||||
const stale = performance.now() - tracking.lastSeen > 1500;
|
||||
$('#trk').textContent = stale ? 'Point at a Newbury Crest' : `Tracking ${tracking.markers} crest${tracking.markers === 1 ? '' : 's'}`;
|
||||
$('#trk').classList.toggle('warn', stale || tracking.markers < 2);
|
||||
$('#cnt').textContent = `${activeGhosts.size} ghost${activeGhosts.size === 1 ? '' : 's'} nearby`;
|
||||
}
|
||||
|
||||
renderer.render(scene3, camera3);
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg) {
|
||||
const el = $('#toast');
|
||||
el.textContent = msg;
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(() => el.classList.remove('show'), 2500);
|
||||
}
|
||||
|
||||
boot().catch(e => { console.error(e); alert('Failed to start: ' + e.message); });
|
||||
@@ -0,0 +1,52 @@
|
||||
/* behavior.js — deterministic ghost motion so all viewers see the same movement.
|
||||
* Motion is a pure function of (server spawn record, wall-clock time): no per-client
|
||||
* randomness, so phones stay in sync without streaming positions.
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
|
||||
function hashNoise(seed, k) {
|
||||
// cheap deterministic pseudo-noise in [-1, 1]
|
||||
const x = Math.sin(seed * 127.1 + k * 311.7) * 43758.5453;
|
||||
return (x - Math.floor(x)) * 2 - 1;
|
||||
}
|
||||
|
||||
export function ghostTransform(rec, nowMs, out) {
|
||||
const t = (nowMs - rec.spawnedAt) / 1000;
|
||||
const base = new THREE.Vector3(...rec.pos);
|
||||
const b = rec.behavior || { type: 'static' };
|
||||
|
||||
if (b.type === 'wander') {
|
||||
const s = b.seed || 1;
|
||||
const R = b.radius ?? 0.6;
|
||||
const v = b.speed ?? 0.15;
|
||||
// smooth pseudo-random orbit-drift: two incommensurate sines per axis
|
||||
const ph = t * v;
|
||||
out.position.set(
|
||||
base.x + R * 0.9 * Math.sin(ph * 1.0 + hashNoise(s, 1) * 6.28) * 0.7
|
||||
+ R * 0.4 * Math.sin(ph * 2.3 + hashNoise(s, 2) * 6.28) * 0.3,
|
||||
base.y + 0.06 * Math.sin(t * 1.7 + hashNoise(s, 3) * 6.28),
|
||||
base.z + R * 0.9 * Math.cos(ph * 0.8 + hashNoise(s, 4) * 6.28) * 0.7
|
||||
+ R * 0.4 * Math.cos(ph * 1.9 + hashNoise(s, 5) * 6.28) * 0.3
|
||||
);
|
||||
// face travel direction (finite difference)
|
||||
const eps = 0.05;
|
||||
const ahead = (t2) => new THREE.Vector3(
|
||||
base.x + R * 0.9 * Math.sin(t2 * v + hashNoise(s, 1) * 6.28) * 0.7,
|
||||
0,
|
||||
base.z + R * 0.9 * Math.cos(t2 * v * 0.8 + hashNoise(s, 4) * 6.28) * 0.7);
|
||||
const dir = ahead(t + eps).sub(ahead(t));
|
||||
out.rotationY = Math.atan2(dir.x, dir.z);
|
||||
} else {
|
||||
const amp = b.bobAmp ?? 0.06;
|
||||
const hz = b.bobHz ?? 0.4;
|
||||
out.position.set(base.x, base.y + amp * Math.sin(t * hz * Math.PI * 2), base.z);
|
||||
out.rotationY = 0.25 * Math.sin(t * 0.3); // slow idle sway
|
||||
}
|
||||
|
||||
// crossfade opacity: fade in on spawn, fade out approaching `until`
|
||||
const fade = (rec.crossfade || 3) * 1000;
|
||||
const inA = Math.min(1, (nowMs - rec.spawnedAt) / fade);
|
||||
const outA = Math.min(1, Math.max(0, (rec.until - nowMs) / fade));
|
||||
out.opacity = Math.min(inA, outA);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/* loader.js — ghost visuals.
|
||||
* Multi-part OBJ ghosts: { legs|wisp, torso, head, headpiece } assembled into one Group,
|
||||
* every part rendered with the recovered Hidden Side gradient shader (top->bottom tint
|
||||
* by GhostColor: Red / Yellow / Blue). Until models exist, a procedural wisp fallback
|
||||
* is used so the exhibit always has something to show.
|
||||
*
|
||||
* Model manifest (data/models.json, editable via /api/models):
|
||||
* { "models": [ { "id": "classic", "scale": 1.0,
|
||||
* "parts": { "legs": "/models/classic/legs.obj", "torso": "...", "head": "...", "headpiece": "..." } } ] }
|
||||
*/
|
||||
import * as THREE from 'three';
|
||||
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
|
||||
|
||||
const objLoader = new OBJLoader();
|
||||
const objCache = new Map();
|
||||
|
||||
function gradientMaterial(top, bottom, opacity = 0.92) {
|
||||
return new THREE.ShaderMaterial({
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
uniforms: {
|
||||
topColor: { value: new THREE.Color(top) },
|
||||
bottomColor: { value: new THREE.Color(bottom) },
|
||||
opacity: { value: opacity },
|
||||
uMinY: { value: 0 },
|
||||
uMaxY: { value: 1 },
|
||||
uTime: { value: 0 },
|
||||
},
|
||||
vertexShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
uniform float uMinY, uMaxY;
|
||||
void main() {
|
||||
vY = clamp((position.y - uMinY) / max(uMaxY - uMinY, 0.001), 0.0, 1.0);
|
||||
vNormal = normalize(normalMatrix * normal);
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}`,
|
||||
fragmentShader: `
|
||||
varying float vY;
|
||||
varying vec3 vNormal;
|
||||
uniform vec3 topColor, bottomColor;
|
||||
uniform float opacity, uTime;
|
||||
void main() {
|
||||
vec3 c = mix(bottomColor, topColor, vY);
|
||||
float rim = pow(1.0 - abs(dot(vNormal, vec3(0.0, 0.0, 1.0))), 2.0);
|
||||
c += rim * 0.35;
|
||||
float pulse = 0.92 + 0.08 * sin(uTime * 2.2);
|
||||
gl_FragColor = vec4(c, opacity * pulse);
|
||||
}`,
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOBJ(url) {
|
||||
if (objCache.has(url)) return objCache.get(url).clone();
|
||||
const obj = await objLoader.loadAsync(url);
|
||||
objCache.set(url, obj);
|
||||
return obj.clone();
|
||||
}
|
||||
|
||||
function proceduralWisp() {
|
||||
const g = new THREE.Group();
|
||||
const body = new THREE.Mesh(new THREE.SphereGeometry(0.09, 20, 16));
|
||||
body.scale.set(1, 1.35, 1);
|
||||
body.position.y = 0.14;
|
||||
const tail = new THREE.Mesh(new THREE.ConeGeometry(0.07, 0.16, 16));
|
||||
tail.rotation.x = Math.PI;
|
||||
tail.position.y = 0.0;
|
||||
g.add(body, tail);
|
||||
return g;
|
||||
}
|
||||
|
||||
export async function buildGhost(ghost, gradients, manifest) {
|
||||
const grad = gradients[ghost.color] || gradients.Blue;
|
||||
const mat = gradientMaterial(grad.top, grad.bottom);
|
||||
|
||||
let group;
|
||||
const model = (manifest?.models || [])[0]; // default model; per-ghost mapping can extend later
|
||||
if (model && model.parts) {
|
||||
group = new THREE.Group();
|
||||
try {
|
||||
for (const key of ['legs', 'wisp', 'torso', 'head', 'headpiece']) {
|
||||
const url = model.parts[key];
|
||||
if (!url) continue;
|
||||
group.add(await loadOBJ(url));
|
||||
}
|
||||
if (model.scale) group.scale.setScalar(model.scale);
|
||||
if (!group.children.length) group = proceduralWisp();
|
||||
} catch (e) {
|
||||
console.warn('ghost model load failed, using wisp fallback', e);
|
||||
group = proceduralWisp();
|
||||
}
|
||||
} else {
|
||||
group = proceduralWisp();
|
||||
}
|
||||
|
||||
// apply gradient material to every mesh; compute Y-range for gradient mapping
|
||||
const box = new THREE.Box3().setFromObject(group);
|
||||
group.traverse(o => {
|
||||
if (o.isMesh) {
|
||||
o.material = mat;
|
||||
o.material.uniforms.uMinY.value = box.min.y;
|
||||
o.material.uniforms.uMaxY.value = box.max.y;
|
||||
}
|
||||
});
|
||||
|
||||
group.userData.material = mat;
|
||||
group.userData.setOpacity = (v) => { mat.uniforms.opacity.value = v; };
|
||||
group.userData.tick = (t) => { mat.uniforms.uTime.value = t; };
|
||||
return group;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/* net.js — WebSocket sync client + dev-mode error pipeline. */
|
||||
export class ExhibitNet {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
this.ws = null;
|
||||
this.retry = 1000;
|
||||
}
|
||||
on(type, fn) { this.handlers.set(type, fn); return this; }
|
||||
connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
this.ws = new WebSocket(`${proto}://${location.host}/ws`);
|
||||
this.ws.onopen = () => { this.retry = 1000; };
|
||||
this.ws.onmessage = (ev) => {
|
||||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||
const h = this.handlers.get(m.type);
|
||||
if (h) h(m);
|
||||
};
|
||||
this.ws.onclose = () => {
|
||||
setTimeout(() => this.connect(), this.retry);
|
||||
this.retry = Math.min(this.retry * 2, 15000);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/* Dev-mode error checking: capture JS errors + unhandled rejections, show an
|
||||
* on-screen overlay, and report to the server for the admin error log. */
|
||||
export function installErrorReporter(devMode) {
|
||||
const report = (payload) => {
|
||||
fetch('/api/client-error', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ua: navigator.userAgent, page: location.pathname, ...payload }),
|
||||
}).catch(() => {});
|
||||
if (devMode) showOverlay(payload);
|
||||
};
|
||||
window.addEventListener('error', (e) =>
|
||||
report({ kind: 'error', msg: String(e.message), src: e.filename, line: e.lineno }));
|
||||
window.addEventListener('unhandledrejection', (e) =>
|
||||
report({ kind: 'rejection', msg: String(e.reason && e.reason.message || e.reason) }));
|
||||
return report;
|
||||
}
|
||||
|
||||
let overlayEl = null;
|
||||
function showOverlay(p) {
|
||||
if (!overlayEl) {
|
||||
overlayEl = document.createElement('div');
|
||||
overlayEl.style.cssText = 'position:fixed;bottom:0;left:0;right:0;max-height:35vh;overflow:auto;' +
|
||||
'background:rgba(120,0,0,.88);color:#fff;font:12px monospace;padding:8px;z-index:99999;white-space:pre-wrap';
|
||||
document.body.appendChild(overlayEl);
|
||||
}
|
||||
overlayEl.textContent += `[${p.kind}] ${p.msg}${p.src ? ` (${p.src}:${p.line})` : ''}\n`;
|
||||
}
|
||||
Vendored
+468
File diff suppressed because one or more lines are too long
Vendored
+737
@@ -0,0 +1,737 @@
|
||||
/*
|
||||
Copyright (c) 2011 Juan Mellado
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/*
|
||||
References:
|
||||
- "OpenCV: Open Computer Vision Library"
|
||||
http://sourceforge.net/projects/opencvlibrary/
|
||||
- "Stack Blur: Fast But Goodlooking"
|
||||
http://incubator.quasimondo.com/processing/fast_blur_deluxe.php
|
||||
*/
|
||||
|
||||
var CV = CV || {};
|
||||
this.CV = CV;
|
||||
|
||||
CV.Image = function(width, height, data){
|
||||
this.width = width || 0;
|
||||
this.height = height || 0;
|
||||
this.data = data || [];
|
||||
};
|
||||
|
||||
CV.grayscale = function(imageSrc, imageDst){
|
||||
var src = imageSrc.data, dst = imageDst.data, len = src.length,
|
||||
i = 0, j = 0;
|
||||
|
||||
for (; i < len; i += 4){
|
||||
dst[j ++] =
|
||||
(src[i] * 0.299 + src[i + 1] * 0.587 + src[i + 2] * 0.114 + 0.5) & 0xff;
|
||||
}
|
||||
|
||||
imageDst.width = imageSrc.width;
|
||||
imageDst.height = imageSrc.height;
|
||||
|
||||
return imageDst;
|
||||
};
|
||||
|
||||
CV.threshold = function(imageSrc, imageDst, threshold){
|
||||
var src = imageSrc.data, dst = imageDst.data,
|
||||
len = src.length, tab = [], i;
|
||||
|
||||
for (i = 0; i < 256; ++ i){
|
||||
tab[i] = i <= threshold? 0: 255;
|
||||
}
|
||||
|
||||
for (i = 0; i < len; ++ i){
|
||||
dst[i] = tab[ src[i] ];
|
||||
}
|
||||
|
||||
imageDst.width = imageSrc.width;
|
||||
imageDst.height = imageSrc.height;
|
||||
|
||||
return imageDst;
|
||||
};
|
||||
|
||||
CV.adaptiveThreshold = function(imageSrc, imageDst, kernelSize, threshold){
|
||||
var src = imageSrc.data, dst = imageDst.data, len = src.length, tab = [], i;
|
||||
|
||||
CV.stackBoxBlur(imageSrc, imageDst, kernelSize);
|
||||
|
||||
for (i = 0; i < 768; ++ i){
|
||||
tab[i] = (i - 255 <= -threshold)? 255: 0;
|
||||
}
|
||||
|
||||
for (i = 0; i < len; ++ i){
|
||||
dst[i] = tab[ src[i] - dst[i] + 255 ];
|
||||
}
|
||||
|
||||
imageDst.width = imageSrc.width;
|
||||
imageDst.height = imageSrc.height;
|
||||
|
||||
return imageDst;
|
||||
};
|
||||
|
||||
CV.otsu = function(imageSrc){
|
||||
var src = imageSrc.data, len = src.length, hist = [],
|
||||
threshold = 0, sum = 0, sumB = 0, wB = 0, wF = 0, max = 0,
|
||||
mu, between, i;
|
||||
|
||||
for (i = 0; i < 256; ++ i){
|
||||
hist[i] = 0;
|
||||
}
|
||||
|
||||
for (i = 0; i < len; ++ i){
|
||||
hist[ src[i] ] ++;
|
||||
}
|
||||
|
||||
for (i = 0; i < 256; ++ i){
|
||||
sum += hist[i] * i;
|
||||
}
|
||||
|
||||
for (i = 0; i < 256; ++ i){
|
||||
wB += hist[i];
|
||||
if (0 !== wB){
|
||||
|
||||
wF = len - wB;
|
||||
if (0 === wF){
|
||||
break;
|
||||
}
|
||||
|
||||
sumB += hist[i] * i;
|
||||
|
||||
mu = (sumB / wB) - ( (sum - sumB) / wF );
|
||||
|
||||
between = wB * wF * mu * mu;
|
||||
|
||||
if (between > max){
|
||||
max = between;
|
||||
threshold = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return threshold;
|
||||
};
|
||||
|
||||
CV.stackBoxBlurMult =
|
||||
[1, 171, 205, 293, 57, 373, 79, 137, 241, 27, 391, 357, 41, 19, 283, 265];
|
||||
|
||||
CV.stackBoxBlurShift =
|
||||
[0, 9, 10, 11, 9, 12, 10, 11, 12, 9, 13, 13, 10, 9, 13, 13];
|
||||
|
||||
CV.BlurStack = function(){
|
||||
this.color = 0;
|
||||
this.next = null;
|
||||
};
|
||||
|
||||
CV.stackBoxBlur = function(imageSrc, imageDst, kernelSize){
|
||||
var src = imageSrc.data, dst = imageDst.data,
|
||||
height = imageSrc.height, width = imageSrc.width,
|
||||
heightMinus1 = height - 1, widthMinus1 = width - 1,
|
||||
size = kernelSize + kernelSize + 1, radius = kernelSize + 1,
|
||||
mult = CV.stackBoxBlurMult[kernelSize],
|
||||
shift = CV.stackBoxBlurShift[kernelSize],
|
||||
stack, stackStart, color, sum, pos, start, p, x, y, i;
|
||||
|
||||
stack = stackStart = new CV.BlurStack();
|
||||
for (i = 1; i < size; ++ i){
|
||||
stack = stack.next = new CV.BlurStack();
|
||||
}
|
||||
stack.next = stackStart;
|
||||
|
||||
pos = 0;
|
||||
|
||||
for (y = 0; y < height; ++ y){
|
||||
start = pos;
|
||||
|
||||
color = src[pos];
|
||||
sum = radius * color;
|
||||
|
||||
stack = stackStart;
|
||||
for (i = 0; i < radius; ++ i){
|
||||
stack.color = color;
|
||||
stack = stack.next;
|
||||
}
|
||||
for (i = 1; i < radius; ++ i){
|
||||
stack.color = src[pos + i];
|
||||
sum += stack.color;
|
||||
stack = stack.next;
|
||||
}
|
||||
|
||||
stack = stackStart;
|
||||
for (x = 0; x < width; ++ x){
|
||||
dst[pos ++] = (sum * mult) >>> shift;
|
||||
|
||||
p = x + radius;
|
||||
p = start + (p < widthMinus1? p: widthMinus1);
|
||||
sum -= stack.color - src[p];
|
||||
|
||||
stack.color = src[p];
|
||||
stack = stack.next;
|
||||
}
|
||||
}
|
||||
|
||||
for (x = 0; x < width; ++ x){
|
||||
pos = x;
|
||||
start = pos + width;
|
||||
|
||||
color = dst[pos];
|
||||
sum = radius * color;
|
||||
|
||||
stack = stackStart;
|
||||
for (i = 0; i < radius; ++ i){
|
||||
stack.color = color;
|
||||
stack = stack.next;
|
||||
}
|
||||
for (i = 1; i < radius; ++ i){
|
||||
stack.color = dst[start];
|
||||
sum += stack.color;
|
||||
stack = stack.next;
|
||||
|
||||
start += width;
|
||||
}
|
||||
|
||||
stack = stackStart;
|
||||
for (y = 0; y < height; ++ y){
|
||||
dst[pos] = (sum * mult) >>> shift;
|
||||
|
||||
p = y + radius;
|
||||
p = x + ( (p < heightMinus1? p: heightMinus1) * width );
|
||||
sum -= stack.color - dst[p];
|
||||
|
||||
stack.color = dst[p];
|
||||
stack = stack.next;
|
||||
|
||||
pos += width;
|
||||
}
|
||||
}
|
||||
|
||||
return imageDst;
|
||||
};
|
||||
|
||||
CV.gaussianBlur = function(imageSrc, imageDst, imageMean, kernelSize){
|
||||
var kernel = CV.gaussianKernel(kernelSize);
|
||||
|
||||
imageDst.width = imageSrc.width;
|
||||
imageDst.height = imageSrc.height;
|
||||
|
||||
imageMean.width = imageSrc.width;
|
||||
imageMean.height = imageSrc.height;
|
||||
|
||||
CV.gaussianBlurFilter(imageSrc, imageMean, kernel, true);
|
||||
CV.gaussianBlurFilter(imageMean, imageDst, kernel, false);
|
||||
|
||||
return imageDst;
|
||||
};
|
||||
|
||||
CV.gaussianBlurFilter = function(imageSrc, imageDst, kernel, horizontal){
|
||||
var src = imageSrc.data, dst = imageDst.data,
|
||||
height = imageSrc.height, width = imageSrc.width,
|
||||
pos = 0, limit = kernel.length >> 1,
|
||||
cur, value, i, j, k;
|
||||
|
||||
for (i = 0; i < height; ++ i){
|
||||
|
||||
for (j = 0; j < width; ++ j){
|
||||
value = 0.0;
|
||||
|
||||
for (k = -limit; k <= limit; ++ k){
|
||||
|
||||
if (horizontal){
|
||||
cur = pos + k;
|
||||
if (j + k < 0){
|
||||
cur = pos;
|
||||
}
|
||||
else if (j + k >= width){
|
||||
cur = pos;
|
||||
}
|
||||
}else{
|
||||
cur = pos + (k * width);
|
||||
if (i + k < 0){
|
||||
cur = pos;
|
||||
}
|
||||
else if (i + k >= height){
|
||||
cur = pos;
|
||||
}
|
||||
}
|
||||
|
||||
value += kernel[limit + k] * src[cur];
|
||||
}
|
||||
|
||||
dst[pos ++] = horizontal? value: (value + 0.5) & 0xff;
|
||||
}
|
||||
}
|
||||
|
||||
return imageDst;
|
||||
};
|
||||
|
||||
CV.gaussianKernel = function(kernelSize){
|
||||
var tab =
|
||||
[ [1],
|
||||
[0.25, 0.5, 0.25],
|
||||
[0.0625, 0.25, 0.375, 0.25, 0.0625],
|
||||
[0.03125, 0.109375, 0.21875, 0.28125, 0.21875, 0.109375, 0.03125] ],
|
||||
kernel = [], center, sigma, scale2X, sum, x, i;
|
||||
|
||||
if ( (kernelSize <= 7) && (kernelSize % 2 === 1) ){
|
||||
kernel = tab[kernelSize >> 1];
|
||||
}else{
|
||||
center = (kernelSize - 1.0) * 0.5;
|
||||
sigma = 0.8 + (0.3 * (center - 1.0) );
|
||||
scale2X = -0.5 / (sigma * sigma);
|
||||
sum = 0.0;
|
||||
for (i = 0; i < kernelSize; ++ i){
|
||||
x = i - center;
|
||||
sum += kernel[i] = Math.exp(scale2X * x * x);
|
||||
}
|
||||
sum = 1 / sum;
|
||||
for (i = 0; i < kernelSize; ++ i){
|
||||
kernel[i] *= sum;
|
||||
}
|
||||
}
|
||||
|
||||
return kernel;
|
||||
};
|
||||
|
||||
CV.findContours = function(imageSrc, binary){
|
||||
var width = imageSrc.width, height = imageSrc.height, contours = [],
|
||||
src, deltas, pos, pix, nbd, outer, hole, i, j;
|
||||
|
||||
src = CV.binaryBorder(imageSrc, binary);
|
||||
|
||||
deltas = CV.neighborhoodDeltas(width + 2);
|
||||
|
||||
pos = width + 3;
|
||||
nbd = 1;
|
||||
|
||||
for (i = 0; i < height; ++ i, pos += 2){
|
||||
|
||||
for (j = 0; j < width; ++ j, ++ pos){
|
||||
pix = src[pos];
|
||||
|
||||
if (0 !== pix){
|
||||
outer = hole = false;
|
||||
|
||||
if (1 === pix && 0 === src[pos - 1]){
|
||||
outer = true;
|
||||
}
|
||||
else if (pix >= 1 && 0 === src[pos + 1]){
|
||||
hole = true;
|
||||
}
|
||||
|
||||
if (outer || hole){
|
||||
++ nbd;
|
||||
|
||||
contours.push( CV.borderFollowing(src, pos, nbd, {x: j, y: i}, hole, deltas) );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contours;
|
||||
};
|
||||
|
||||
CV.borderFollowing = function(src, pos, nbd, point, hole, deltas){
|
||||
var contour = [], pos1, pos3, pos4, s, s_end, s_prev;
|
||||
|
||||
contour.hole = hole;
|
||||
|
||||
s = s_end = hole? 0: 4;
|
||||
do{
|
||||
s = (s - 1) & 7;
|
||||
pos1 = pos + deltas[s];
|
||||
if (src[pos1] !== 0){
|
||||
break;
|
||||
}
|
||||
}while(s !== s_end);
|
||||
|
||||
if (s === s_end){
|
||||
src[pos] = -nbd;
|
||||
contour.push( {x: point.x, y: point.y} );
|
||||
|
||||
}else{
|
||||
pos3 = pos;
|
||||
s_prev = s ^ 4;
|
||||
|
||||
while(true){
|
||||
s_end = s;
|
||||
|
||||
do{
|
||||
pos4 = pos3 + deltas[++ s];
|
||||
}while(src[pos4] === 0);
|
||||
|
||||
s &= 7;
|
||||
|
||||
if ( ( (s - 1) >>> 0) < (s_end >>> 0) ){
|
||||
src[pos3] = -nbd;
|
||||
}
|
||||
else if (src[pos3] === 1){
|
||||
src[pos3] = nbd;
|
||||
}
|
||||
|
||||
contour.push( {x: point.x, y: point.y} );
|
||||
|
||||
s_prev = s;
|
||||
|
||||
point.x += CV.neighborhood[s][0];
|
||||
point.y += CV.neighborhood[s][1];
|
||||
|
||||
if ( (pos4 === pos) && (pos3 === pos1) ){
|
||||
break;
|
||||
}
|
||||
|
||||
pos3 = pos4;
|
||||
s = (s + 4) & 7;
|
||||
}
|
||||
}
|
||||
|
||||
return contour;
|
||||
};
|
||||
|
||||
CV.neighborhood =
|
||||
[ [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1], [0, 1], [1, 1] ];
|
||||
|
||||
CV.neighborhoodDeltas = function(width){
|
||||
var deltas = [], len = CV.neighborhood.length, i = 0;
|
||||
|
||||
for (; i < len; ++ i){
|
||||
deltas[i] = CV.neighborhood[i][0] + (CV.neighborhood[i][1] * width);
|
||||
}
|
||||
|
||||
return deltas.concat(deltas);
|
||||
};
|
||||
|
||||
CV.approxPolyDP = function(contour, epsilon){
|
||||
var slice = {start_index: 0, end_index: 0},
|
||||
right_slice = {start_index: 0, end_index: 0},
|
||||
poly = [], stack = [], len = contour.length,
|
||||
pt, start_pt, end_pt, dist, max_dist, le_eps,
|
||||
dx, dy, i, j, k;
|
||||
|
||||
epsilon *= epsilon;
|
||||
|
||||
k = 0;
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
max_dist = 0;
|
||||
|
||||
k = (k + right_slice.start_index) % len;
|
||||
start_pt = contour[k];
|
||||
if (++ k === len) {k = 0;}
|
||||
|
||||
for (j = 1; j < len; ++ j){
|
||||
pt = contour[k];
|
||||
if (++ k === len) {k = 0;}
|
||||
|
||||
dx = pt.x - start_pt.x;
|
||||
dy = pt.y - start_pt.y;
|
||||
dist = dx * dx + dy * dy;
|
||||
|
||||
if (dist > max_dist){
|
||||
max_dist = dist;
|
||||
right_slice.start_index = j;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (max_dist <= epsilon){
|
||||
poly.push( {x: start_pt.x, y: start_pt.y} );
|
||||
|
||||
}else{
|
||||
slice.start_index = k;
|
||||
slice.end_index = (right_slice.start_index += slice.start_index);
|
||||
|
||||
right_slice.start_index -= right_slice.start_index >= len? len: 0;
|
||||
right_slice.end_index = slice.start_index;
|
||||
if (right_slice.end_index < right_slice.start_index){
|
||||
right_slice.end_index += len;
|
||||
}
|
||||
|
||||
stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} );
|
||||
stack.push( {start_index: slice.start_index, end_index: slice.end_index} );
|
||||
}
|
||||
|
||||
while(stack.length !== 0){
|
||||
slice = stack.pop();
|
||||
|
||||
end_pt = contour[slice.end_index % len];
|
||||
start_pt = contour[k = slice.start_index % len];
|
||||
if (++ k === len) {k = 0;}
|
||||
|
||||
if (slice.end_index <= slice.start_index + 1){
|
||||
le_eps = true;
|
||||
|
||||
}else{
|
||||
max_dist = 0;
|
||||
|
||||
dx = end_pt.x - start_pt.x;
|
||||
dy = end_pt.y - start_pt.y;
|
||||
|
||||
for (i = slice.start_index + 1; i < slice.end_index; ++ i){
|
||||
pt = contour[k];
|
||||
if (++ k === len) {k = 0;}
|
||||
|
||||
dist = Math.abs( (pt.y - start_pt.y) * dx - (pt.x - start_pt.x) * dy);
|
||||
|
||||
if (dist > max_dist){
|
||||
max_dist = dist;
|
||||
right_slice.start_index = i;
|
||||
}
|
||||
}
|
||||
|
||||
le_eps = max_dist * max_dist <= epsilon * (dx * dx + dy * dy);
|
||||
}
|
||||
|
||||
if (le_eps){
|
||||
poly.push( {x: start_pt.x, y: start_pt.y} );
|
||||
|
||||
}else{
|
||||
right_slice.end_index = slice.end_index;
|
||||
slice.end_index = right_slice.start_index;
|
||||
|
||||
stack.push( {start_index: right_slice.start_index, end_index: right_slice.end_index} );
|
||||
stack.push( {start_index: slice.start_index, end_index: slice.end_index} );
|
||||
}
|
||||
}
|
||||
|
||||
return poly;
|
||||
};
|
||||
|
||||
CV.warp = function(imageSrc, imageDst, contour, warpSize){
|
||||
var src = imageSrc.data, dst = imageDst.data,
|
||||
width = imageSrc.width, height = imageSrc.height,
|
||||
pos = 0,
|
||||
sx1, sx2, dx1, dx2, sy1, sy2, dy1, dy2, p1, p2, p3, p4,
|
||||
m, r, s, t, u, v, w, x, y, i, j;
|
||||
|
||||
m = CV.getPerspectiveTransform(contour, warpSize - 1);
|
||||
|
||||
r = m[8];
|
||||
s = m[2];
|
||||
t = m[5];
|
||||
|
||||
for (i = 0; i < warpSize; ++ i){
|
||||
r += m[7];
|
||||
s += m[1];
|
||||
t += m[4];
|
||||
|
||||
u = r;
|
||||
v = s;
|
||||
w = t;
|
||||
|
||||
for (j = 0; j < warpSize; ++ j){
|
||||
u += m[6];
|
||||
v += m[0];
|
||||
w += m[3];
|
||||
|
||||
x = v / u;
|
||||
y = w / u;
|
||||
|
||||
sx1 = x >>> 0;
|
||||
sx2 = (sx1 === width - 1)? sx1: sx1 + 1;
|
||||
dx1 = x - sx1;
|
||||
dx2 = 1.0 - dx1;
|
||||
|
||||
sy1 = y >>> 0;
|
||||
sy2 = (sy1 === height - 1)? sy1: sy1 + 1;
|
||||
dy1 = y - sy1;
|
||||
dy2 = 1.0 - dy1;
|
||||
|
||||
p1 = p2 = sy1 * width;
|
||||
p3 = p4 = sy2 * width;
|
||||
|
||||
dst[pos ++] =
|
||||
(dy2 * (dx2 * src[p1 + sx1] + dx1 * src[p2 + sx2]) +
|
||||
dy1 * (dx2 * src[p3 + sx1] + dx1 * src[p4 + sx2]) ) & 0xff;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
imageDst.width = warpSize;
|
||||
imageDst.height = warpSize;
|
||||
|
||||
return imageDst;
|
||||
};
|
||||
|
||||
CV.getPerspectiveTransform = function(src, size){
|
||||
var rq = CV.square2quad(src);
|
||||
|
||||
rq[0] /= size;
|
||||
rq[1] /= size;
|
||||
rq[3] /= size;
|
||||
rq[4] /= size;
|
||||
rq[6] /= size;
|
||||
rq[7] /= size;
|
||||
|
||||
return rq;
|
||||
};
|
||||
|
||||
CV.square2quad = function(src){
|
||||
var sq = [], px, py, dx1, dx2, dy1, dy2, den;
|
||||
|
||||
px = src[0].x - src[1].x + src[2].x - src[3].x;
|
||||
py = src[0].y - src[1].y + src[2].y - src[3].y;
|
||||
|
||||
if (0 === px && 0 === py){
|
||||
sq[0] = src[1].x - src[0].x;
|
||||
sq[1] = src[2].x - src[1].x;
|
||||
sq[2] = src[0].x;
|
||||
sq[3] = src[1].y - src[0].y;
|
||||
sq[4] = src[2].y - src[1].y;
|
||||
sq[5] = src[0].y;
|
||||
sq[6] = 0;
|
||||
sq[7] = 0;
|
||||
sq[8] = 1;
|
||||
|
||||
}else{
|
||||
dx1 = src[1].x - src[2].x;
|
||||
dx2 = src[3].x - src[2].x;
|
||||
dy1 = src[1].y - src[2].y;
|
||||
dy2 = src[3].y - src[2].y;
|
||||
den = dx1 * dy2 - dx2 * dy1;
|
||||
|
||||
sq[6] = (px * dy2 - dx2 * py) / den;
|
||||
sq[7] = (dx1 * py - px * dy1) / den;
|
||||
sq[8] = 1;
|
||||
sq[0] = src[1].x - src[0].x + sq[6] * src[1].x;
|
||||
sq[1] = src[3].x - src[0].x + sq[7] * src[3].x;
|
||||
sq[2] = src[0].x;
|
||||
sq[3] = src[1].y - src[0].y + sq[6] * src[1].y;
|
||||
sq[4] = src[3].y - src[0].y + sq[7] * src[3].y;
|
||||
sq[5] = src[0].y;
|
||||
}
|
||||
|
||||
return sq;
|
||||
};
|
||||
|
||||
CV.isContourConvex = function(contour){
|
||||
var orientation = 0, convex = true,
|
||||
len = contour.length, i = 0, j = 0,
|
||||
cur_pt, prev_pt, dxdy0, dydx0, dx0, dy0, dx, dy;
|
||||
|
||||
prev_pt = contour[len - 1];
|
||||
cur_pt = contour[0];
|
||||
|
||||
dx0 = cur_pt.x - prev_pt.x;
|
||||
dy0 = cur_pt.y - prev_pt.y;
|
||||
|
||||
for (; i < len; ++ i){
|
||||
if (++ j === len) {j = 0;}
|
||||
|
||||
prev_pt = cur_pt;
|
||||
cur_pt = contour[j];
|
||||
|
||||
dx = cur_pt.x - prev_pt.x;
|
||||
dy = cur_pt.y - prev_pt.y;
|
||||
dxdy0 = dx * dy0;
|
||||
dydx0 = dy * dx0;
|
||||
|
||||
orientation |= dydx0 > dxdy0? 1: (dydx0 < dxdy0? 2: 3);
|
||||
|
||||
if (3 === orientation){
|
||||
convex = false;
|
||||
break;
|
||||
}
|
||||
|
||||
dx0 = dx;
|
||||
dy0 = dy;
|
||||
}
|
||||
|
||||
return convex;
|
||||
};
|
||||
|
||||
CV.perimeter = function(poly){
|
||||
var len = poly.length, i = 0, j = len - 1,
|
||||
p = 0.0, dx, dy;
|
||||
|
||||
for (; i < len; j = i ++){
|
||||
dx = poly[i].x - poly[j].x;
|
||||
dy = poly[i].y - poly[j].y;
|
||||
|
||||
p += Math.sqrt(dx * dx + dy * dy) ;
|
||||
}
|
||||
|
||||
return p;
|
||||
};
|
||||
|
||||
CV.minEdgeLength = function(poly){
|
||||
var len = poly.length, i = 0, j = len - 1,
|
||||
min = Infinity, d, dx, dy;
|
||||
|
||||
for (; i < len; j = i ++){
|
||||
dx = poly[i].x - poly[j].x;
|
||||
dy = poly[i].y - poly[j].y;
|
||||
|
||||
d = dx * dx + dy * dy;
|
||||
|
||||
if (d < min){
|
||||
min = d;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.sqrt(min);
|
||||
};
|
||||
|
||||
CV.countNonZero = function(imageSrc, square){
|
||||
var src = imageSrc.data, height = square.height, width = square.width,
|
||||
pos = square.x + (square.y * imageSrc.width),
|
||||
span = imageSrc.width - width,
|
||||
nz = 0, i, j;
|
||||
|
||||
for (i = 0; i < height; ++ i){
|
||||
|
||||
for (j = 0; j < width; ++ j){
|
||||
|
||||
if ( 0 !== src[pos ++] ){
|
||||
++ nz;
|
||||
}
|
||||
}
|
||||
|
||||
pos += span;
|
||||
}
|
||||
|
||||
return nz;
|
||||
};
|
||||
|
||||
CV.binaryBorder = function(imageSrc, dst){
|
||||
var src = imageSrc.data, height = imageSrc.height, width = imageSrc.width,
|
||||
posSrc = 0, posDst = 0, i, j;
|
||||
|
||||
for (j = -2; j < width; ++ j){
|
||||
dst[posDst ++] = 0;
|
||||
}
|
||||
|
||||
for (i = 0; i < height; ++ i){
|
||||
dst[posDst ++] = 0;
|
||||
|
||||
for (j = 0; j < width; ++ j){
|
||||
dst[posDst ++] = (0 === src[posSrc ++]? 0: 1);
|
||||
}
|
||||
|
||||
dst[posDst ++] = 0;
|
||||
}
|
||||
|
||||
for (j = -2; j < width; ++ j){
|
||||
dst[posDst ++] = 0;
|
||||
}
|
||||
|
||||
return dst;
|
||||
};
|
||||
+39
File diff suppressed because one or more lines are too long
Vendored
+495
@@ -0,0 +1,495 @@
|
||||
/*
|
||||
Copyright (c) 2012 Juan Mellado
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/*
|
||||
References:
|
||||
- "Iterative Pose Estimation using Coplanar Feature Points"
|
||||
Denis Oberkampf, Daniel F. DeMenthon, Larry S. Davis
|
||||
http://www.cfar.umd.edu/~daniel/daniel_papersfordownload/CoplanarPts.pdf
|
||||
*/
|
||||
|
||||
var POS = POS || {};
|
||||
this.POS = POS;
|
||||
|
||||
var SVD = this.SVD || require('./svd').SVD;
|
||||
|
||||
POS.Posit = function(modelSize, focalLength){
|
||||
this.objectPoints = this.buildModel(modelSize);
|
||||
this.focalLength = focalLength;
|
||||
|
||||
this.objectVectors = [];
|
||||
this.objectNormal = [];
|
||||
this.objectMatrix = [[],[],[]];
|
||||
|
||||
this.init();
|
||||
};
|
||||
|
||||
POS.Posit.prototype.buildModel = function(modelSize){
|
||||
var half = modelSize / 2.0;
|
||||
|
||||
return [
|
||||
[-half, half, 0.0],
|
||||
[ half, half, 0.0],
|
||||
[ half, -half, 0.0],
|
||||
[-half, -half, 0.0] ];
|
||||
};
|
||||
|
||||
POS.Posit.prototype.init = function(){
|
||||
var np = this.objectPoints.length,
|
||||
vectors = [], n = [], len = 0.0, row = 2, i;
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
this.objectVectors[i] = [this.objectPoints[i][0] - this.objectPoints[0][0],
|
||||
this.objectPoints[i][1] - this.objectPoints[0][1],
|
||||
this.objectPoints[i][2] - this.objectPoints[0][2]];
|
||||
|
||||
vectors[i] = [this.objectVectors[i][0],
|
||||
this.objectVectors[i][1],
|
||||
this.objectVectors[i][2]];
|
||||
}
|
||||
|
||||
while(0.0 === len){
|
||||
n[0] = this.objectVectors[1][1] * this.objectVectors[row][2] -
|
||||
this.objectVectors[1][2] * this.objectVectors[row][1];
|
||||
n[1] = this.objectVectors[1][2] * this.objectVectors[row][0] -
|
||||
this.objectVectors[1][0] * this.objectVectors[row][2];
|
||||
n[2] = this.objectVectors[1][0] * this.objectVectors[row][1] -
|
||||
this.objectVectors[1][1] * this.objectVectors[row][0];
|
||||
|
||||
len = Math.sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]);
|
||||
|
||||
++ row;
|
||||
}
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
this.objectNormal[i] = n[i] / len;
|
||||
}
|
||||
|
||||
POS.pseudoInverse(vectors, np, this.objectMatrix);
|
||||
};
|
||||
|
||||
POS.Posit.prototype.pose = function(imagePoints){
|
||||
var posRotation1 = [[],[],[]], posRotation2 = [[],[],[]], posTranslation = [],
|
||||
rotation1 = [[],[],[]], rotation2 = [[],[],[]], translation1 = [], translation2 = [],
|
||||
error1, error2, valid1, valid2, i, j;
|
||||
|
||||
this.pos(imagePoints, posRotation1, posRotation2, posTranslation);
|
||||
|
||||
valid1 = this.isValid(posRotation1, posTranslation);
|
||||
if (valid1){
|
||||
error1 = this.iterate(imagePoints, posRotation1, posTranslation, rotation1, translation1);
|
||||
}else{
|
||||
error1 = {euclidean: -1.0, pixels: -1, maximum: -1.0};
|
||||
}
|
||||
|
||||
valid2 = this.isValid(posRotation2, posTranslation);
|
||||
if (valid2){
|
||||
error2 = this.iterate(imagePoints, posRotation2, posTranslation, rotation2, translation2);
|
||||
}else{
|
||||
error2 = {euclidean: -1.0, pixels: -1, maximum: -1.0};
|
||||
}
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < 3; ++ j){
|
||||
if (valid1){
|
||||
translation1[i] -= rotation1[i][j] * this.objectPoints[0][j];
|
||||
}
|
||||
if (valid2){
|
||||
translation2[i] -= rotation2[i][j] * this.objectPoints[0][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return error1.euclidean < error2.euclidean?
|
||||
new POS.Pose(error1.pixels, rotation1, translation1, error2.pixels, rotation2, translation2):
|
||||
new POS.Pose(error2.pixels, rotation2, translation2, error1.pixels, rotation1, translation1);
|
||||
};
|
||||
|
||||
POS.Posit.prototype.pos = function(imagePoints, rotation1, rotation2, translation){
|
||||
var np = this.objectPoints.length, imageVectors = [],
|
||||
i0 = [], j0 = [], ivec = [], jvec = [], row1 = [], row2 = [], row3 = [],
|
||||
i0i0, j0j0, i0j0, delta, q, lambda, mu, scale, i, j;
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
imageVectors[i] = [imagePoints[i].x - imagePoints[0].x,
|
||||
imagePoints[i].y - imagePoints[0].y];
|
||||
}
|
||||
|
||||
//i0 and j0
|
||||
for (i = 0; i < 3; ++ i){
|
||||
i0[i] = 0.0;
|
||||
j0[i] = 0.0;
|
||||
for (j = 0; j < np; ++ j){
|
||||
i0[i] += this.objectMatrix[i][j] * imageVectors[j][0];
|
||||
j0[i] += this.objectMatrix[i][j] * imageVectors[j][1];
|
||||
}
|
||||
}
|
||||
|
||||
i0i0 = i0[0] * i0[0] + i0[1] * i0[1] + i0[2] * i0[2];
|
||||
j0j0 = j0[0] * j0[0] + j0[1] * j0[1] + j0[2] * j0[2];
|
||||
i0j0 = i0[0] * j0[0] + i0[1] * j0[1] + i0[2] * j0[2];
|
||||
|
||||
//Lambda and mu
|
||||
delta = (j0j0 - i0i0) * (j0j0 - i0i0) + 4.0 * (i0j0 * i0j0);
|
||||
|
||||
if (j0j0 - i0i0 >= 0.0){
|
||||
q = (j0j0 - i0i0 + Math.sqrt(delta) ) / 2.0;
|
||||
}else{
|
||||
q = (j0j0 - i0i0 - Math.sqrt(delta) ) / 2.0;
|
||||
}
|
||||
|
||||
if (q >= 0.0){
|
||||
lambda = Math.sqrt(q);
|
||||
if (0.0 === lambda){
|
||||
mu = 0.0;
|
||||
}else{
|
||||
mu = -i0j0 / lambda;
|
||||
}
|
||||
}else{
|
||||
lambda = Math.sqrt( -(i0j0 * i0j0) / q);
|
||||
if (0.0 === lambda){
|
||||
mu = Math.sqrt(i0i0 - j0j0);
|
||||
}else{
|
||||
mu = -i0j0 / lambda;
|
||||
}
|
||||
}
|
||||
|
||||
//First rotation
|
||||
for (i = 0; i < 3; ++ i){
|
||||
ivec[i] = i0[i] + lambda * this.objectNormal[i];
|
||||
jvec[i] = j0[i] + mu * this.objectNormal[i];
|
||||
}
|
||||
|
||||
scale = Math.sqrt(ivec[0] * ivec[0] + ivec[1] * ivec[1] + ivec[2] * ivec[2]);
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
row1[i] = ivec[i] / scale;
|
||||
row2[i] = jvec[i] / scale;
|
||||
}
|
||||
|
||||
row3[0] = row1[1] * row2[2] - row1[2] * row2[1];
|
||||
row3[1] = row1[2] * row2[0] - row1[0] * row2[2];
|
||||
row3[2] = row1[0] * row2[1] - row1[1] * row2[0];
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
rotation1[0][i] = row1[i];
|
||||
rotation1[1][i] = row2[i];
|
||||
rotation1[2][i] = row3[i];
|
||||
}
|
||||
|
||||
//Second rotation
|
||||
for (i = 0; i < 3; ++ i){
|
||||
ivec[i] = i0[i] - lambda * this.objectNormal[i];
|
||||
jvec[i] = j0[i] - mu * this.objectNormal[i];
|
||||
}
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
row1[i] = ivec[i] / scale;
|
||||
row2[i] = jvec[i] / scale;
|
||||
}
|
||||
|
||||
row3[0] = row1[1] * row2[2] - row1[2] * row2[1];
|
||||
row3[1] = row1[2] * row2[0] - row1[0] * row2[2];
|
||||
row3[2] = row1[0] * row2[1] - row1[1] * row2[0];
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
rotation2[0][i] = row1[i];
|
||||
rotation2[1][i] = row2[i];
|
||||
rotation2[2][i] = row3[i];
|
||||
}
|
||||
|
||||
//Translation
|
||||
translation[0] = imagePoints[0].x / scale;
|
||||
translation[1] = imagePoints[0].y / scale;
|
||||
translation[2] = this.focalLength / scale;
|
||||
};
|
||||
|
||||
POS.Posit.prototype.isValid = function(rotation, translation){
|
||||
var np = this.objectPoints.length, zmin = Infinity, i = 0, zi;
|
||||
|
||||
for (; i < np; ++ i){
|
||||
zi = translation[2] +
|
||||
(rotation[2][0] * this.objectVectors[i][0] +
|
||||
rotation[2][1] * this.objectVectors[i][1] +
|
||||
rotation[2][2] * this.objectVectors[i][2]);
|
||||
if (zi < zmin){
|
||||
zmin = zi;
|
||||
}
|
||||
}
|
||||
|
||||
return zmin >= 0.0;
|
||||
};
|
||||
|
||||
POS.Posit.prototype.iterate = function(imagePoints, posRotation, posTranslation, rotation, translation){
|
||||
var np = this.objectPoints.length,
|
||||
oldSopImagePoints = [], sopImagePoints = [],
|
||||
rotation1 = [[],[],[]], rotation2 = [[],[],[]],
|
||||
translation1 = [], translation2 = [],
|
||||
converged = false, iteration = 0,
|
||||
oldImageDifference, imageDifference, factor,
|
||||
error, error1, error2, delta, i, j;
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
oldSopImagePoints[i] = {x: imagePoints[i].x,
|
||||
y: imagePoints[i].y};
|
||||
}
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < 3; ++ j){
|
||||
rotation[i][j] = posRotation[i][j];
|
||||
}
|
||||
translation[i] = posTranslation[i];
|
||||
}
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
factor = 0.0;
|
||||
for (j = 0; j < 3; ++ j){
|
||||
factor += this.objectVectors[i][j] * rotation[2][j] / translation[2];
|
||||
}
|
||||
sopImagePoints[i] = {x: (1.0 + factor) * imagePoints[i].x,
|
||||
y: (1.0 + factor) * imagePoints[i].y};
|
||||
}
|
||||
|
||||
imageDifference = 0.0;
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x);
|
||||
imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y);
|
||||
}
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
translation1[i] = translation[i] -
|
||||
(rotation[i][0] * this.objectPoints[0][0] +
|
||||
rotation[i][1] * this.objectPoints[0][1] +
|
||||
rotation[i][2] * this.objectPoints[0][2]);
|
||||
}
|
||||
|
||||
error = error1 = this.error(imagePoints, rotation, translation1);
|
||||
|
||||
//Convergence
|
||||
converged = (0.0 === error1.pixels) || (imageDifference < 0.01);
|
||||
|
||||
while( iteration ++ < 100 && !converged ){
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
oldSopImagePoints[i].x = sopImagePoints[i].x;
|
||||
oldSopImagePoints[i].y = sopImagePoints[i].y;
|
||||
}
|
||||
|
||||
this.pos(sopImagePoints, rotation1, rotation2, translation);
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
translation1[i] = translation[i] -
|
||||
(rotation1[i][0] * this.objectPoints[0][0] +
|
||||
rotation1[i][1] * this.objectPoints[0][1] +
|
||||
rotation1[i][2] * this.objectPoints[0][2]);
|
||||
|
||||
translation2[i] = translation[i] -
|
||||
(rotation2[i][0] * this.objectPoints[0][0] +
|
||||
rotation2[i][1] * this.objectPoints[0][1] +
|
||||
rotation2[i][2] * this.objectPoints[0][2]);
|
||||
}
|
||||
|
||||
error1 = this.error(imagePoints, rotation1, translation1);
|
||||
error2 = this.error(imagePoints, rotation2, translation2);
|
||||
|
||||
if ( (error1.euclidean >= 0.0) && (error2.euclidean >= 0.0) ){
|
||||
if (error2.euclidean < error1.euclidean){
|
||||
error = error2;
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < 3; ++ j){
|
||||
rotation[i][j] = rotation2[i][j];
|
||||
}
|
||||
}
|
||||
}else{
|
||||
error = error1;
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < 3; ++ j){
|
||||
rotation[i][j] = rotation1[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( (error1.euclidean < 0.0) && (error2.euclidean >= 0.0) ){
|
||||
error = error2;
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < 3; ++ j){
|
||||
rotation[i][j] = rotation2[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( (error2.euclidean < 0.0) && (error1.euclidean >= 0.0) ){
|
||||
error = error1;
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < 3; ++ j){
|
||||
rotation[i][j] = rotation1[i][j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
factor = 0.0;
|
||||
for (j = 0; j < 3; ++ j){
|
||||
factor += this.objectVectors[i][j] * rotation[2][j] / translation[2];
|
||||
}
|
||||
sopImagePoints[i].x = (1.0 + factor) * imagePoints[i].x;
|
||||
sopImagePoints[i].y = (1.0 + factor) * imagePoints[i].y;
|
||||
}
|
||||
|
||||
oldImageDifference = imageDifference;
|
||||
imageDifference = 0.0;
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
imageDifference += Math.abs(sopImagePoints[i].x - oldSopImagePoints[i].x);
|
||||
imageDifference += Math.abs(sopImagePoints[i].y - oldSopImagePoints[i].y);
|
||||
}
|
||||
|
||||
delta = Math.abs(imageDifference - oldImageDifference);
|
||||
|
||||
converged = (0.0 === error.pixels) || (delta < 0.01);
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
POS.Posit.prototype.error = function(imagePoints, rotation, translation){
|
||||
var np = this.objectPoints.length,
|
||||
move = [], projection = [], errorvec = [],
|
||||
euclidean = 0.0, pixels = 0.0, maximum = 0.0,
|
||||
i, j, k;
|
||||
|
||||
if ( !this.isValid(rotation, translation) ){
|
||||
return {euclidean: -1.0, pixels: -1, maximum: -1.0};
|
||||
}
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
move[i] = [];
|
||||
for (j = 0; j < 3; ++ j){
|
||||
move[i][j] = translation[j];
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
for (j = 0; j < 3; ++ j){
|
||||
for (k = 0; k < 3; ++ k){
|
||||
move[i][j] += rotation[j][k] * this.objectPoints[i][k];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
projection[i] = [];
|
||||
for (j = 0; j < 2; ++ j){
|
||||
projection[i][j] = this.focalLength * move[i][j] / move[i][2];
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
errorvec[i] = [projection[i][0] - imagePoints[i].x,
|
||||
projection[i][1] - imagePoints[i].y];
|
||||
}
|
||||
|
||||
for (i = 0; i < np; ++ i){
|
||||
euclidean += Math.sqrt(errorvec[i][0] * errorvec[i][0] +
|
||||
errorvec[i][1] * errorvec[i][1]);
|
||||
|
||||
pixels += Math.abs( Math.round(projection[i][0]) - Math.round(imagePoints[i].x) ) +
|
||||
Math.abs( Math.round(projection[i][1]) - Math.round(imagePoints[i].y) );
|
||||
|
||||
if (Math.abs(errorvec[i][0]) > maximum){
|
||||
maximum = Math.abs(errorvec[i][0]);
|
||||
}
|
||||
if (Math.abs(errorvec[i][1]) > maximum){
|
||||
maximum = Math.abs(errorvec[i][1]);
|
||||
}
|
||||
}
|
||||
|
||||
return {euclidean: euclidean / np, pixels: pixels, maximum: maximum};
|
||||
};
|
||||
|
||||
POS.pseudoInverse = function(a, n, b){
|
||||
var w = [], v = [[],[],[]], s = [[],[],[]],
|
||||
wmax = 0.0, cn = 0,
|
||||
i, j, k;
|
||||
|
||||
SVD.svdcmp(a, n, 3, w, v);
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
if (w[i] > wmax){
|
||||
wmax = w[i];
|
||||
}
|
||||
}
|
||||
|
||||
wmax *= 0.01;
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
if (w[i] < wmax){
|
||||
w[i] = 0.0;
|
||||
}
|
||||
}
|
||||
|
||||
for (j = 0; j < 3; ++ j){
|
||||
if (0.0 === w[j]){
|
||||
++ cn;
|
||||
for (k = j; k < 2; ++ k){
|
||||
for (i = 0; i < n; ++ i){
|
||||
a[i][k] = a[i][k + 1];
|
||||
}
|
||||
for (i = 0; i < 3; ++ i){
|
||||
v[i][k] = v[i][k + 1];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (j = 0; j < 2; ++ j){
|
||||
if (0.0 === w[j]){
|
||||
w[j] = w[j + 1];
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < 3 - cn; ++ j){
|
||||
s[i][j] = v[i][j] / w[j];
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < 3; ++ i){
|
||||
for (j = 0; j < n; ++ j){
|
||||
b[i][j] = 0.0;
|
||||
for (k = 0; k < 3 - cn; ++ k){
|
||||
b[i][j] += s[i][k] * a[j][k];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
POS.Pose = function(error1, rotation1, translation1, error2, rotation2, translation2){
|
||||
this.bestError = error1;
|
||||
this.bestRotation = rotation1;
|
||||
this.bestTranslation = translation1;
|
||||
this.alternativeError = error2;
|
||||
this.alternativeRotation = rotation2;
|
||||
this.alternativeTranslation = translation2;
|
||||
};
|
||||
Vendored
+284
@@ -0,0 +1,284 @@
|
||||
/*
|
||||
Copyright (c) 2012 Juan Mellado
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
*/
|
||||
|
||||
/*
|
||||
References:
|
||||
- "Numerical Recipes in C - Second Edition"
|
||||
http://www.nr.com/
|
||||
*/
|
||||
|
||||
var SVD = SVD || {};
|
||||
this.SVD = SVD;
|
||||
|
||||
SVD.svdcmp = function(a, m, n, w, v){
|
||||
var flag, i, its, j, jj, k, l, nm,
|
||||
anorm = 0.0, c, f, g = 0.0, h, s, scale = 0.0, x, y, z, rv1 = [];
|
||||
|
||||
//Householder reduction to bidiagonal form
|
||||
for (i = 0; i < n; ++ i){
|
||||
l = i + 1;
|
||||
rv1[i] = scale * g;
|
||||
g = s = scale = 0.0;
|
||||
if (i < m){
|
||||
for (k = i; k < m; ++ k){
|
||||
scale += Math.abs( a[k][i] );
|
||||
}
|
||||
if (0.0 !== scale){
|
||||
for (k = i; k < m; ++ k){
|
||||
a[k][i] /= scale;
|
||||
s += a[k][i] * a[k][i];
|
||||
}
|
||||
f = a[i][i];
|
||||
g = -SVD.sign( Math.sqrt(s), f );
|
||||
h = f * g - s;
|
||||
a[i][i] = f - g;
|
||||
for (j = l; j < n; ++ j){
|
||||
for (s = 0.0, k = i; k < m; ++ k){
|
||||
s += a[k][i] * a[k][j];
|
||||
}
|
||||
f = s / h;
|
||||
for (k = i; k < m; ++ k){
|
||||
a[k][j] += f * a[k][i];
|
||||
}
|
||||
}
|
||||
for (k = i; k < m; ++ k){
|
||||
a[k][i] *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
w[i] = scale * g;
|
||||
g = s = scale = 0.0;
|
||||
if ( (i < m) && (i !== n - 1) ){
|
||||
for (k = l; k < n; ++ k){
|
||||
scale += Math.abs( a[i][k] );
|
||||
}
|
||||
if (0.0 !== scale){
|
||||
for (k = l; k < n; ++ k){
|
||||
a[i][k] /= scale;
|
||||
s += a[i][k] * a[i][k];
|
||||
}
|
||||
f = a[i][l];
|
||||
g = -SVD.sign( Math.sqrt(s), f );
|
||||
h = f * g - s;
|
||||
a[i][l] = f - g;
|
||||
for (k = l; k < n; ++ k){
|
||||
rv1[k] = a[i][k] / h;
|
||||
}
|
||||
for (j = l; j < m; ++ j){
|
||||
for (s = 0.0, k = l; k < n; ++ k){
|
||||
s += a[j][k] * a[i][k];
|
||||
}
|
||||
for (k = l; k < n; ++ k){
|
||||
a[j][k] += s * rv1[k];
|
||||
}
|
||||
}
|
||||
for (k = l; k < n; ++ k){
|
||||
a[i][k] *= scale;
|
||||
}
|
||||
}
|
||||
}
|
||||
anorm = Math.max(anorm, ( Math.abs( w[i] ) + Math.abs( rv1[i] ) ) );
|
||||
}
|
||||
|
||||
//Acumulation of right-hand transformation
|
||||
for (i = n - 1; i >= 0; -- i){
|
||||
if (i < n - 1){
|
||||
if (0.0 !== g){
|
||||
for (j = l; j < n; ++ j){
|
||||
v[j][i] = ( a[i][j] / a[i][l] ) / g;
|
||||
}
|
||||
for (j = l; j < n; ++ j){
|
||||
for (s = 0.0, k = l; k < n; ++ k){
|
||||
s += a[i][k] * v[k][j];
|
||||
}
|
||||
for (k = l; k < n; ++ k){
|
||||
v[k][j] += s * v[k][i];
|
||||
}
|
||||
}
|
||||
}
|
||||
for (j = l; j < n; ++ j){
|
||||
v[i][j] = v[j][i] = 0.0;
|
||||
}
|
||||
}
|
||||
v[i][i] = 1.0;
|
||||
g = rv1[i];
|
||||
l = i;
|
||||
}
|
||||
|
||||
//Acumulation of left-hand transformation
|
||||
for (i = Math.min(n, m) - 1; i >= 0; -- i){
|
||||
l = i + 1;
|
||||
g = w[i];
|
||||
for (j = l; j < n; ++ j){
|
||||
a[i][j] = 0.0;
|
||||
}
|
||||
if (0.0 !== g){
|
||||
g = 1.0 / g;
|
||||
for (j = l; j < n; ++ j){
|
||||
for (s = 0.0, k = l; k < m; ++ k){
|
||||
s += a[k][i] * a[k][j];
|
||||
}
|
||||
f = (s / a[i][i]) * g;
|
||||
for (k = i; k < m; ++ k){
|
||||
a[k][j] += f * a[k][i];
|
||||
}
|
||||
}
|
||||
for (j = i; j < m; ++ j){
|
||||
a[j][i] *= g;
|
||||
}
|
||||
}else{
|
||||
for (j = i; j < m; ++ j){
|
||||
a[j][i] = 0.0;
|
||||
}
|
||||
}
|
||||
++ a[i][i];
|
||||
}
|
||||
|
||||
//Diagonalization of the bidiagonal form
|
||||
for (k = n - 1; k >= 0; -- k){
|
||||
for (its = 1; its <= 30; ++ its){
|
||||
flag = true;
|
||||
for (l = k; l >= 0; -- l){
|
||||
nm = l - 1;
|
||||
if ( Math.abs( rv1[l] ) + anorm === anorm ){
|
||||
flag = false;
|
||||
break;
|
||||
}
|
||||
if ( Math.abs( w[nm] ) + anorm === anorm ){
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (flag){
|
||||
c = 0.0;
|
||||
s = 1.0;
|
||||
for (i = l; i <= k; ++ i){
|
||||
f = s * rv1[i];
|
||||
if ( Math.abs(f) + anorm === anorm ){
|
||||
break;
|
||||
}
|
||||
g = w[i];
|
||||
h = SVD.pythag(f, g);
|
||||
w[i] = h;
|
||||
h = 1.0 / h;
|
||||
c = g * h;
|
||||
s = -f * h;
|
||||
for (j = 1; j <= m; ++ j){
|
||||
y = a[j][nm];
|
||||
z = a[j][i];
|
||||
a[j][nm] = y * c + z * s;
|
||||
a[j][i] = z * c - y * s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Convergence
|
||||
z = w[k];
|
||||
if (l === k){
|
||||
if (z < 0.0){
|
||||
w[k] = -z;
|
||||
for (j = 0; j < n; ++ j){
|
||||
v[j][k] = -v[j][k];
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (30 === its){
|
||||
return false;
|
||||
}
|
||||
|
||||
//Shift from bottom 2-by-2 minor
|
||||
x = w[l];
|
||||
nm = k - 1;
|
||||
y = w[nm];
|
||||
g = rv1[nm];
|
||||
h = rv1[k];
|
||||
f = ( (y - z) * (y + z) + (g - h) * (g + h) ) / (2.0 * h * y);
|
||||
g = SVD.pythag( f, 1.0 );
|
||||
f = ( (x - z) * (x + z) + h * ( (y / (f + SVD.sign(g, f) ) ) - h) ) / x;
|
||||
|
||||
//Next QR transformation
|
||||
c = s = 1.0;
|
||||
for (j = l; j <= nm; ++ j){
|
||||
i = j + 1;
|
||||
g = rv1[i];
|
||||
y = w[i];
|
||||
h = s * g;
|
||||
g = c * g;
|
||||
z = SVD.pythag(f, h);
|
||||
rv1[j] = z;
|
||||
c = f / z;
|
||||
s = h / z;
|
||||
f = x * c + g * s;
|
||||
g = g * c - x * s;
|
||||
h = y * s;
|
||||
y *= c;
|
||||
for (jj = 0; jj < n; ++ jj){
|
||||
x = v[jj][j];
|
||||
z = v[jj][i];
|
||||
v[jj][j] = x * c + z * s;
|
||||
v[jj][i] = z * c - x * s;
|
||||
}
|
||||
z = SVD.pythag(f, h);
|
||||
w[j] = z;
|
||||
if (0.0 !== z){
|
||||
z = 1.0 / z;
|
||||
c = f * z;
|
||||
s = h * z;
|
||||
}
|
||||
f = c * g + s * y;
|
||||
x = c * y - s * g;
|
||||
for (jj = 0; jj < m; ++ jj){
|
||||
y = a[jj][j];
|
||||
z = a[jj][i];
|
||||
a[jj][j] = y * c + z * s;
|
||||
a[jj][i] = z * c - y * s;
|
||||
}
|
||||
}
|
||||
rv1[l] = 0.0;
|
||||
rv1[k] = f;
|
||||
w[k] = x;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
SVD.pythag = function(a, b){
|
||||
var at = Math.abs(a), bt = Math.abs(b), ct;
|
||||
|
||||
if (at > bt){
|
||||
ct = bt / at;
|
||||
return at * Math.sqrt(1.0 + ct * ct);
|
||||
}
|
||||
|
||||
if (0.0 === bt){
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
ct = at / bt;
|
||||
return bt * Math.sqrt(1.0 + ct * ct);
|
||||
};
|
||||
|
||||
SVD.sign = function(a, b){
|
||||
return b >= 0.0? Math.abs(a): -Math.abs(a);
|
||||
};
|
||||
Reference in New Issue
Block a user