anycalib-wasm / index.html
SebRincon's picture
Full calibrator: corrected image, camera params, ray viz, distortion heatmap, raw JSON
b84f3ea verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AnyCalib WASM Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; padding: 1.5rem; }
h1 { margin-bottom: 0.5rem; font-size: 1.4rem; }
.subtitle { color: #94a3b8; font-size: 0.9rem; margin-bottom: 1.25rem; line-height: 1.4; }
.subtitle a { color: #60a5fa; text-decoration: none; }
.subtitle a:hover { text-decoration: underline; }
.container { max-width: 1100px; margin: 0 auto; }
.status { padding: 0.75rem 1rem; border-radius: 0.5rem; margin-bottom: 1rem; font-size: 0.9rem; display: flex; align-items: center; gap: 0.6rem; }
.status.idle { background: #1e293b; border: 1px solid #475569; }
.status.loading { background: #1e3a5f; border: 1px solid #3b82f6; }
.status.ready { background: #14532d; border: 1px solid #22c55e; }
.status.error { background: #7f1d1d; border: 1px solid #ef4444; }
.spinner { width: 16px; height: 16px; border: 2px solid #60a5fa; border-top-color: transparent; border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.progress-wrap { width: 100%; margin-bottom: 1rem; display: none; }
.progress-bar { width: 100%; height: 8px; background: #1e293b; border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); width: 0%; transition: width 0.3s; border-radius: 4px; }
.progress-text { font-size: 0.8rem; color: #94a3b8; margin-top: 0.3rem; text-align: right; }
.download-section { background: #1e293b; border-radius: 0.75rem; padding: 1.25rem; margin-bottom: 1.25rem; border: 1px solid #334155; }
.download-section h2 { font-size: 1rem; margin-bottom: 0.75rem; }
.download-row { display: flex; gap: 0.75rem; align-items: center; flex-wrap: wrap; }
.btn { padding: 0.6rem 1.2rem; border: none; border-radius: 0.5rem; font-size: 0.9rem; font-weight: 600; cursor: pointer; transition: all 0.15s; display: inline-flex; align-items: center; gap: 0.4rem; }
.btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-primary { background: #3b82f6; color: white; }
.btn-primary:hover:not(:disabled) { background: #2563eb; }
.btn-gpu { background: #8b5cf6; color: white; }
.btn-gpu:hover:not(:disabled) { background: #7c3aed; }
.btn-danger { background: #475569; color: #e2e8f0; font-size: 0.8rem; padding: 0.4rem 0.8rem; }
.btn-danger:hover:not(:disabled) { background: #ef4444; }
.cache-badge { font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: 9999px; font-weight: 500; }
.cache-badge.cached { background: #14532d; color: #4ade80; }
.cache-badge.not-cached { background: #1e293b; color: #94a3b8; }
.gpu-unavail { font-size: 0.75rem; color: #f87171; }
.controls { display: flex; gap: 0.75rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
.controls label { font-size: 0.85rem; color: #94a3b8; }
.controls select { background: #1e293b; color: #e2e8f0; border: 1px solid #475569; padding: 0.4rem 0.6rem; border-radius: 0.375rem; font-size: 0.85rem; }
.badge { font-size: 0.7rem; padding: 0.2rem 0.5rem; border-radius: 9999px; background: #1e3a5f; color: #93c5fd; }
.upload-area { border: 2px dashed #475569; border-radius: 0.75rem; padding: 2rem; text-align: center; cursor: pointer; transition: all 0.2s; margin-bottom: 1.25rem; }
.upload-area:hover:not(.disabled) { border-color: #3b82f6; background: rgba(59,130,246,0.05); }
.upload-area.active { border-color: #22c55e; background: rgba(34,197,94,0.05); }
.upload-area.disabled { opacity: 0.4; pointer-events: none; }
.upload-icon { font-size: 1.5rem; margin-bottom: 0.3rem; }
.upload-hint { font-size: 0.8rem; color: #64748b; margin-top: 0.3rem; }
/* Results */
.results-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }
.results-grid canvas, .results-grid img { width: 100%; border-radius: 0.5rem; background: #0f172a; display: block; }
.result-card { background: #1e293b; border-radius: 0.75rem; padding: 0.6rem; }
.result-label { font-size: 0.75rem; color: #94a3b8; margin-top: 0.4rem; text-align: center; font-weight: 500; }
/* Params panel */
.params-panel { background: #1e293b; border-radius: 0.75rem; padding: 1rem; margin-bottom: 0.75rem; border: 1px solid #334155; display: none; }
.params-panel h3 { font-size: 0.95rem; margin-bottom: 0.75rem; color: #f1f5f9; }
.params-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.5rem; }
.param-item { background: #0f172a; border-radius: 0.5rem; padding: 0.5rem 0.75rem; }
.param-label { font-size: 0.7rem; color: #64748b; text-transform: uppercase; letter-spacing: 0.05em; }
.param-value { font-size: 1rem; font-weight: 600; color: #e2e8f0; font-family: 'SF Mono', 'Fira Code', monospace; margin-top: 0.15rem; }
.param-unit { font-size: 0.75rem; color: #94a3b8; font-weight: 400; }
.timing-bar { font-size: 0.8rem; color: #64748b; padding: 0.5rem 0.75rem; background: #1e293b; border-radius: 0.5rem; margin-bottom: 0.75rem; display: none; }
/* Raw data accordion */
.raw-data { margin-top: 0.75rem; }
.raw-data summary { cursor: pointer; font-size: 0.85rem; color: #94a3b8; padding: 0.4rem 0; }
.raw-data pre { background: #0f172a; border-radius: 0.5rem; padding: 0.75rem; font-size: 0.75rem; color: #94a3b8; overflow-x: auto; margin-top: 0.5rem; max-height: 300px; overflow-y: auto; }
input[type="file"] { display: none; }
@media (max-width: 700px) {
.results-grid { grid-template-columns: 1fr; }
body { padding: 1rem; }
.download-row { flex-direction: column; align-items: stretch; }
}
</style>
</head>
<body>
<div class="container">
<h1>AnyCalib WASM Demo</h1>
<p class="subtitle">
Single-image camera calibration + lens undistortion running entirely in your browser via WebAssembly.<br>
Model: <a href="https://huggingface.co/SebRincon/anycalib-onnx" target="_blank">SebRincon/anycalib-onnx</a>
<span class="badge">INT8 ~311 MB</span>
</p>
<div class="download-section">
<h2>1. Load Model</h2>
<div class="download-row">
<button id="btnCpu" class="btn btn-primary" onclick="downloadAndLoad('wasm')">Download &amp; Run on CPU</button>
<button id="btnGpu" class="btn btn-gpu" onclick="downloadAndLoad('webgpu')">Download &amp; Run on GPU</button>
<span id="cacheBadge" class="cache-badge not-cached">Not cached</span>
<button id="btnClearCache" class="btn btn-danger" onclick="clearModelCache()" style="display:none;">Clear cache</button>
</div>
<div id="gpuNote" class="gpu-unavail" style="display:none; margin-top:0.5rem;"></div>
</div>
<div id="status" class="status idle"><span>Click a button above to download and load the model.</span></div>
<div class="progress-wrap" id="progressWrap">
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-text" id="progressText">0 MB / ? MB</div>
</div>
<div class="controls">
<label for="sizeSelect">Resolution:</label>
<select id="sizeSelect">
<option value="266">266px (fast)</option>
<option value="518" selected>518px (default)</option>
</select>
</div>
<div id="uploadArea" class="upload-area disabled">
<div class="upload-icon">&#128247;</div>
<p>Drop an image here or click to upload</p>
<p class="upload-hint">JPG, PNG, WebP β€” everything runs locally in your browser</p>
<input type="file" id="fileInput" accept="image/*">
</div>
<!-- Params panel -->
<div class="params-panel" id="paramsPanel">
<h3>Camera Intrinsics (estimated from rays)</h3>
<div class="params-grid">
<div class="param-item">
<div class="param-label">Focal Length</div>
<div class="param-value" id="paramF">β€”</div>
</div>
<div class="param-item">
<div class="param-label">Principal Point X</div>
<div class="param-value" id="paramCx">β€”</div>
</div>
<div class="param-item">
<div class="param-label">Principal Point Y</div>
<div class="param-value" id="paramCy">β€”</div>
</div>
<div class="param-item">
<div class="param-label">Distortion k1</div>
<div class="param-value" id="paramK1">β€”</div>
</div>
<div class="param-item">
<div class="param-label">FOV (horizontal)</div>
<div class="param-value" id="paramFovH">β€”</div>
</div>
<div class="param-item">
<div class="param-label">FOV (vertical)</div>
<div class="param-value" id="paramFovV">β€”</div>
</div>
<div class="param-item">
<div class="param-label">Distortion Type</div>
<div class="param-value" id="paramDistType">β€”</div>
</div>
<div class="param-item">
<div class="param-label">Max Distortion</div>
<div class="param-value" id="paramMaxDist">β€”</div>
</div>
</div>
</div>
<!-- Timing -->
<div class="timing-bar" id="timingBar"></div>
<!-- Results grid: 2x2 -->
<div class="results-grid" id="resultsGrid" style="display: none;">
<div class="result-card">
<img id="inputImage" alt="Input">
<div class="result-label">Input (distorted)</div>
</div>
<div class="result-card">
<canvas id="correctedCanvas"></canvas>
<div class="result-label">Corrected (undistorted)</div>
</div>
<div class="result-card">
<canvas id="heatmapCanvas"></canvas>
<div class="result-label">Distortion Magnitude</div>
</div>
<div class="result-card">
<canvas id="raysCanvas"></canvas>
<div class="result-label">Ray Directions (RGB = XYZ)</div>
</div>
</div>
<!-- Raw JSON data -->
<details class="raw-data" id="rawDataSection" style="display:none;">
<summary>Raw output data (JSON)</summary>
<pre id="rawDataPre"></pre>
</details>
</div>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.min.js"></script>
<script>
const MODEL_URL = 'https://huggingface.co/SebRincon/anycalib-onnx/resolve/main/model_int8.onnx';
const CACHE_NAME = 'anycalib-model-v1';
let session = null;
let isLoading = false;
let hasWebGPU = false;
const $ = id => document.getElementById(id);
function getInputSize() { return parseInt($('sizeSelect').value, 10); }
function setStatus(text, type) {
const spin = type === 'loading' ? '<div class="spinner"></div>' : '';
$('status').innerHTML = `${spin}<span>${text}</span>`;
$('status').className = `status ${type}`;
}
function setButtons(enabled) {
$('btnCpu').disabled = !enabled;
$('btnGpu').disabled = !enabled || !hasWebGPU;
}
// ── WebGPU ──
async function detectWebGPU() {
try { if (!navigator.gpu) return false; return !!(await navigator.gpu.requestAdapter()); }
catch { return false; }
}
// ── Cache API ──
async function isModelCached() {
try { const c = await caches.open(CACHE_NAME); return !!(await c.match(MODEL_URL)); }
catch { return false; }
}
async function updateCacheBadge() {
const cached = await isModelCached();
$('cacheBadge').textContent = cached ? 'Cached' : 'Not cached';
$('cacheBadge').className = `cache-badge ${cached ? 'cached' : 'not-cached'}`;
$('btnClearCache').style.display = cached ? 'inline-flex' : 'none';
$('btnCpu').textContent = cached ? 'Load on CPU (cached)' : 'Download & Run on CPU';
if (hasWebGPU) $('btnGpu').textContent = cached ? 'Load on GPU (cached)' : 'Download & Run on GPU';
}
async function clearModelCache() {
await caches.delete(CACHE_NAME);
await updateCacheBadge();
setStatus('Cache cleared.', 'idle');
}
async function fetchModelWithCache() {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(MODEL_URL);
if (cached) {
setStatus('Loading model from browser cache...', 'loading');
$('progressWrap').style.display = 'none';
return await cached.arrayBuffer();
}
setStatus('Downloading model...', 'loading');
$('progressWrap').style.display = 'block';
$('progressFill').style.width = '0%';
const resp = await fetch(MODEL_URL);
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const contentLength = parseInt(resp.headers.get('Content-Length') || '0', 10);
const totalMB = contentLength ? (contentLength / 1048576).toFixed(0) : '~311';
const reader = resp.body.getReader();
const chunks = [];
let received = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
received += value.length;
const pct = contentLength ? ((received / contentLength) * 100).toFixed(1) : '?';
$('progressFill').style.width = `${pct}%`;
$('progressText').textContent = `${(received / 1048576).toFixed(1)} MB / ${totalMB} MB (${pct}%)`;
}
const combined = new Uint8Array(received);
let off = 0;
for (const c of chunks) { combined.set(c, off); off += c.length; }
try {
await cache.put(MODEL_URL, new Response(combined.buffer, {
headers: { 'Content-Type': 'application/octet-stream' }
}));
} catch (e) { console.warn('[AnyCalib] Cache store failed:', e); }
$('progressWrap').style.display = 'none';
await updateCacheBadge();
return combined.buffer;
}
async function downloadAndLoad(backend) {
if (isLoading) return;
isLoading = true;
session = null;
$('uploadArea').classList.add('disabled');
setButtons(false);
try {
ort.env.logLevel = 'error';
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/';
ort.env.wasm.numThreads = 1;
const buf = await fetchModelWithCache();
setStatus(`Creating inference session (${backend})...`, 'loading');
const providers = [];
if (backend === 'webgpu' && hasWebGPU) providers.push('webgpu');
providers.push('wasm');
const t0 = performance.now();
session = await ort.InferenceSession.create(buf, { executionProviders: providers, graphOptimizationLevel: 'all' });
const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
setStatus(`Model ready (${elapsed}s). Upload an image below.`, 'ready');
$('uploadArea').classList.remove('disabled');
} catch (err) {
$('progressWrap').style.display = 'none';
setStatus(`Failed: ${err.message}`, 'error');
console.error('[AnyCalib]', err);
}
isLoading = false;
setButtons(true);
}
// ════════════════════════════════════════════════════════════════
// CALIBRATOR: fit [f, cx, cy, k1] from rays β€” JS port of
// diff_division_fit.py (Fitzgibbon division model, least-squares)
// ════════════════════════════════════════════════════════════════
function fitSimpleDivision(raysData, H, W) {
const cx = W / 2.0, cy = H / 2.0;
const N = H * W;
// Build linear system A * [f, k1/f]^T = b
// For pixel (u,v) with ray (rx,ry,rz):
// Ra = sqrt(rx^2 + ry^2)
// rc = sqrt((u-cx)^2 + (v-cy)^2)
// rca2 = (u-cx)^2 + (v-cy)^2
// Row: [Ra, Ra * rca2] * [f, k1/f] = rz * rc
let AtA00 = 0, AtA01 = 0, AtA11 = 0;
let Atb0 = 0, Atb1 = 0;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = y * W + x;
const rx = raysData[idx];
const ry = raysData[N + idx];
const rz = raysData[2 * N + idx];
const u = x + 0.5, v = y + 0.5;
const dx = u - cx, dy = v - cy;
const rca2 = dx * dx + dy * dy;
const rc = Math.sqrt(rca2);
const Ra = Math.sqrt(rx * rx + ry * ry);
const a0 = Ra;
const a1 = Ra * rca2;
const bi = rz * rc;
AtA00 += a0 * a0;
AtA01 += a0 * a1;
AtA11 += a1 * a1;
Atb0 += a0 * bi;
Atb1 += a1 * bi;
}
}
// Solve 2x2 system with regularization
const eps = 1e-9;
AtA00 += eps; AtA11 += eps;
const det = AtA00 * AtA11 - AtA01 * AtA01;
const f = (AtA11 * Atb0 - AtA01 * Atb1) / det;
const k1_over_f = (AtA00 * Atb1 - AtA01 * Atb0) / det;
const k1 = k1_over_f * f;
return { f, cx, cy, k1 };
}
// ════════════════════════════════════════════════════════════════
// UNDISTORTION: build sampling grid from [f, cx, cy, k1]
// JS port of diff_undistort.py β€” division model closed-form
// ════════════════════════════════════════════════════════════════
function buildUndistortGrid(intrinsics, H, W) {
const { f, cx, cy, k1 } = intrinsics;
const eps = 1e-12;
const grid = new Float32Array(H * W * 2); // [src_u, src_v] per output pixel
for (let oy = 0; oy < H; oy++) {
for (let ox = 0; ox < W; ox++) {
// Normalized coords in output pinhole camera
const xn = ((ox + 0.5) - cx) / Math.max(f, eps);
const yn = ((oy + 0.5) - cy) / Math.max(f, eps);
// Perspective unprojection β†’ ray on unit sphere
const r = Math.sqrt(xn * xn + yn * yn);
const theta = Math.atan(r);
const phi = Math.atan2(yn, xn);
const sinT = Math.sin(theta), cosT = Math.cos(theta);
const rays_x = sinT * Math.cos(phi);
const rays_y = sinT * Math.sin(phi);
const rays_z = cosT;
// Project through division model (closed form)
const disc = rays_z * rays_z - 4 * k1 * (rays_x * rays_x + rays_y * rays_y);
const disc_safe = Math.max(disc, 0);
const den = Math.max(rays_z + Math.sqrt(disc_safe), eps);
const src_x = 2 * rays_x / den;
const src_y = 2 * rays_y / den;
// Back to pixel coords
const src_u = f * src_x + cx;
const src_v = f * src_y + cy;
const gidx = (oy * W + ox) * 2;
grid[gidx] = src_u;
grid[gidx + 1] = src_v;
}
}
return grid;
}
function applyUndistortion(inputImageData, grid, W, H) {
const src = inputImageData.data; // RGBA
const out = new Uint8ClampedArray(W * H * 4);
for (let oy = 0; oy < H; oy++) {
for (let ox = 0; ox < W; ox++) {
const gidx = (oy * W + ox) * 2;
const su = grid[gidx], sv = grid[gidx + 1];
// Bilinear interpolation
const x0 = Math.floor(su - 0.5), y0 = Math.floor(sv - 0.5);
const fx = su - 0.5 - x0, fy = sv - 0.5 - y0;
const outIdx = (oy * W + ox) * 4;
if (x0 < 0 || x0 >= W - 1 || y0 < 0 || y0 >= H - 1) {
// Border: clamp
const cx = Math.max(0, Math.min(W - 1, Math.round(su - 0.5)));
const cy = Math.max(0, Math.min(H - 1, Math.round(sv - 0.5)));
const si = (cy * W + cx) * 4;
out[outIdx] = src[si]; out[outIdx+1] = src[si+1]; out[outIdx+2] = src[si+2]; out[outIdx+3] = 255;
continue;
}
const i00 = (y0 * W + x0) * 4;
const i10 = (y0 * W + x0 + 1) * 4;
const i01 = ((y0 + 1) * W + x0) * 4;
const i11 = ((y0 + 1) * W + x0 + 1) * 4;
const w00 = (1 - fx) * (1 - fy);
const w10 = fx * (1 - fy);
const w01 = (1 - fx) * fy;
const w11 = fx * fy;
for (let c = 0; c < 3; c++) {
out[outIdx + c] = Math.round(src[i00+c]*w00 + src[i10+c]*w10 + src[i01+c]*w01 + src[i11+c]*w11);
}
out[outIdx + 3] = 255;
}
}
return out;
}
// ════════════════════════════════════════════════════════════════
// VISUALIZATION HELPERS
// ════════════════════════════════════════════════════════════════
function renderCorrectedImage(canvasId, inputImg, intrinsics, SIZE) {
// Draw input at model resolution
const tmpCanvas = document.createElement('canvas');
tmpCanvas.width = SIZE; tmpCanvas.height = SIZE;
const tmpCtx = tmpCanvas.getContext('2d');
tmpCtx.drawImage(inputImg, 0, 0, SIZE, SIZE);
const inputData = tmpCtx.getImageData(0, 0, SIZE, SIZE);
const grid = buildUndistortGrid(intrinsics, SIZE, SIZE);
const corrected = applyUndistortion(inputData, grid, SIZE, SIZE);
const canvas = $(canvasId);
canvas.width = SIZE; canvas.height = SIZE;
const ctx = canvas.getContext('2d');
const outData = ctx.createImageData(SIZE, SIZE);
outData.data.set(corrected);
ctx.putImageData(outData, 0, 0);
}
function renderHeatmap(canvasId, raysData, H, W) {
const N = H * W;
const canvas = $(canvasId);
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
let maxDev = 0;
const devs = new Float32Array(N);
for (let i = 0; i < N; i++) {
const rx = raysData[i], ry = raysData[N + i], rz = raysData[2 * N + i];
const dev = Math.sqrt(rx*rx + ry*ry) / Math.max(Math.abs(rz), 1e-8);
devs[i] = dev;
if (dev > maxDev) maxDev = dev;
}
const scale = maxDev > 0 ? 1 / maxDev : 1;
for (let i = 0; i < N; i++) {
const t = devs[i] * scale;
const p = i * 4;
// Inferno-ish colormap
img.data[p] = Math.floor(Math.min(255, t < 0.5 ? t * 510 : 255));
img.data[p+1] = Math.floor(Math.min(255, t < 0.33 ? 0 : t < 0.66 ? (t-0.33)*770 : 255));
img.data[p+2] = Math.floor(t < 0.5 ? 128 + t*254 : 255 - (t-0.5)*510);
img.data[p+3] = 255;
}
ctx.putImageData(img, 0, 0);
return { maxDeviation: maxDev };
}
function renderRays(canvasId, raysData, H, W) {
const N = H * W;
const canvas = $(canvasId);
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let i = 0; i < N; i++) {
const p = i * 4;
// Map ray components from [-1,1] to [0,255]
img.data[p] = Math.floor((raysData[i] + 1) * 0.5 * 255); // rx β†’ R
img.data[p+1] = Math.floor((raysData[N + i] + 1) * 0.5 * 255); // ry β†’ G
img.data[p+2] = Math.floor(Math.max(0, raysData[2*N + i]) * 255); // rz β†’ B
img.data[p+3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// ════════════════════════════════════════════════════════════════
// INFERENCE PIPELINE
// ════════════════════════════════════════════════════════════════
function preprocessImage(img) {
const SIZE = getInputSize();
const canvas = document.createElement('canvas');
canvas.width = SIZE; canvas.height = SIZE;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, SIZE, SIZE);
const data = ctx.getImageData(0, 0, SIZE, SIZE).data;
const N = SIZE * SIZE;
const f32 = new Float32Array(3 * N);
for (let i = 0; i < N; i++) {
f32[i] = data[i*4] / 255;
f32[N+i] = data[i*4+1] / 255;
f32[2*N+i] = data[i*4+2] / 255;
}
return new ort.Tensor('float32', f32, [1, 3, SIZE, SIZE]);
}
async function runInference(img) {
if (!session) { setStatus('Load the model first.', 'idle'); return; }
setStatus('Running inference...', 'loading');
$('inputImage').src = img.src;
$('resultsGrid').style.display = 'grid';
$('paramsPanel').style.display = 'block';
$('rawDataSection').style.display = 'block';
await new Promise(r => setTimeout(r, 30));
try {
const SIZE = getInputSize();
const inputTensor = preprocessImage(img);
const N = SIZE * SIZE;
// ── Run neural network ──
const t0 = performance.now();
const results = await session.run({ image: inputTensor });
const tInfer = performance.now() - t0;
const raysData = results.rays.data;
const tangentData = results.tangent_coords.data;
// ── Fit camera intrinsics from rays ──
const t1 = performance.now();
const intrinsics = fitSimpleDivision(raysData, SIZE, SIZE);
const tFit = performance.now() - t1;
// ── Build undistortion grid + warp image ──
const t2 = performance.now();
renderCorrectedImage('correctedCanvas', img, intrinsics, SIZE);
const tUndistort = performance.now() - t2;
// ── Visualizations ──
const { maxDeviation } = renderHeatmap('heatmapCanvas', raysData, SIZE, SIZE);
renderRays('raysCanvas', raysData, SIZE, SIZE);
// ── Compute stats ──
let raysMin = [Infinity, Infinity, Infinity], raysMax = [-Infinity, -Infinity, -Infinity];
let raysMean = [0, 0, 0];
for (let c = 0; c < 3; c++) {
for (let i = 0; i < N; i++) {
const v = raysData[c * N + i];
if (v < raysMin[c]) raysMin[c] = v;
if (v > raysMax[c]) raysMax[c] = v;
raysMean[c] += v;
}
raysMean[c] /= N;
}
// ── FOV estimation ──
const fovH = 2 * Math.atan(SIZE / (2 * intrinsics.f)) * (180 / Math.PI);
const fovV = fovH; // square input
// ── Distortion type ──
const distType = intrinsics.k1 < -0.001 ? 'Barrel' : intrinsics.k1 > 0.001 ? 'Pincushion' : 'Negligible';
// ── Max pixel displacement ──
const grid = buildUndistortGrid(intrinsics, SIZE, SIZE);
let maxDisp = 0;
for (let oy = 0; oy < SIZE; oy++) {
for (let ox = 0; ox < SIZE; ox++) {
const gi = (oy * SIZE + ox) * 2;
const du = grid[gi] - (ox + 0.5), dv = grid[gi+1] - (oy + 0.5);
const d = Math.sqrt(du*du + dv*dv);
if (d > maxDisp) maxDisp = d;
}
}
// ── Update params panel ──
$('paramF').innerHTML = `${intrinsics.f.toFixed(2)} <span class="param-unit">px</span>`;
$('paramCx').innerHTML = `${intrinsics.cx.toFixed(2)} <span class="param-unit">px</span>`;
$('paramCy').innerHTML = `${intrinsics.cy.toFixed(2)} <span class="param-unit">px</span>`;
$('paramK1').innerHTML = `${intrinsics.k1.toFixed(6)}`;
$('paramFovH').innerHTML = `${fovH.toFixed(1)}<span class="param-unit">&deg;</span>`;
$('paramFovV').innerHTML = `${fovV.toFixed(1)}<span class="param-unit">&deg;</span>`;
$('paramDistType').textContent = distType;
$('paramMaxDist').innerHTML = `${maxDisp.toFixed(1)} <span class="param-unit">px</span>`;
// ── Timing bar ──
$('timingBar').style.display = 'block';
$('timingBar').innerHTML = [
`<strong>Neural net:</strong> ${tInfer.toFixed(0)}ms`,
`<strong>Calibration fit:</strong> ${tFit.toFixed(0)}ms`,
`<strong>Undistortion:</strong> ${tUndistort.toFixed(0)}ms`,
`<strong>Total:</strong> ${(tInfer + tFit + tUndistort).toFixed(0)}ms`,
`<strong>Resolution:</strong> ${SIZE}&times;${SIZE}`,
].join(' &nbsp;|&nbsp; ');
// ── Raw JSON ──
const rawOutput = {
intrinsics: { f: intrinsics.f, cx: intrinsics.cx, cy: intrinsics.cy, k1: intrinsics.k1 },
fov: { horizontal_deg: fovH, vertical_deg: fovV },
distortion: { type: distType, max_pixel_displacement: maxDisp },
rays_stats: {
shape: [1, 3, SIZE, SIZE],
x: { min: raysMin[0], max: raysMax[0], mean: raysMean[0] },
y: { min: raysMin[1], max: raysMax[1], mean: raysMean[1] },
z: { min: raysMin[2], max: raysMax[2], mean: raysMean[2] },
},
tangent_coords_shape: [1, 2, SIZE, SIZE],
max_ray_deviation: maxDeviation,
timing_ms: { neural_net: tInfer, calibration_fit: tFit, undistortion: tUndistort },
model_input_size: SIZE,
};
$('rawDataPre').textContent = JSON.stringify(rawOutput, null, 2);
setStatus('Done. Upload another image or adjust resolution.', 'ready');
} catch (err) {
setStatus(`Inference error: ${err.message}`, 'error');
console.error('[AnyCalib]', err);
}
}
// ── File handling ──
$('uploadArea').addEventListener('click', () => $('fileInput').click());
$('uploadArea').addEventListener('dragover', e => { e.preventDefault(); $('uploadArea').classList.add('active'); });
$('uploadArea').addEventListener('dragleave', () => $('uploadArea').classList.remove('active'));
$('uploadArea').addEventListener('drop', e => {
e.preventDefault(); $('uploadArea').classList.remove('active');
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
});
$('fileInput').addEventListener('change', () => { if ($('fileInput').files.length) handleFile($('fileInput').files[0]); });
function handleFile(file) {
const img = new Image();
img.onload = () => runInference(img);
img.src = URL.createObjectURL(file);
}
// ── Init ──
(async function init() {
hasWebGPU = await detectWebGPU();
if (!hasWebGPU) {
$('btnGpu').disabled = true;
$('btnGpu').title = 'WebGPU not available';
$('gpuNote').style.display = 'block';
$('gpuNote').textContent = 'WebGPU not available in this browser. Use CPU instead.';
}
await updateCacheBadge();
})();
</script>
</body>
</html>