update Tue 07/14/2026 11:38:16.92

This commit is contained in:
2026-07-14 11:38:17 +10:00
commit b2b555ef16
38 changed files with 7348 additions and 0 deletions
+46
View File
@@ -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>`;
}
+92
View File
@@ -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>
+31
View File
@@ -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>
+237
View File
@@ -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>
+24
View File
@@ -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>
+99
View File
@@ -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 (01)</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>
+56
View File
@@ -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>
+65
View File
@@ -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>
+22
View File
@@ -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;
}
+88
View File
@@ -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 };
}
}
+93
View File
@@ -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));
}
+193
View File
@@ -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); });
+52
View File
@@ -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;
}
+110
View File
@@ -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;
}
+51
View File
@@ -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`;
}
+468
View File
File diff suppressed because one or more lines are too long
+737
View File
@@ -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;
};
File diff suppressed because one or more lines are too long
+495
View File
@@ -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;
};
+284
View File
@@ -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);
};