Spaces:
Running
Running
| <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 & Run on CPU</button> | |
| <button id="btnGpu" class="btn btn-gpu" onclick="downloadAndLoad('webgpu')">Download & 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">📷</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">°</span>`; | |
| $('paramFovV').innerHTML = `${fovV.toFixed(1)}<span class="param-unit">°</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}×${SIZE}`, | |
| ].join(' | '); | |
| // ββ 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> | |