// Snapped Labels: the snapped-label dataset on live X-ray CT. Everything is read in the browser: the dataset's index // and per-segment cross sections from Hugging Face, the CT slice from the Vesuvius Challenge open-data bucket by HTTP // range reads (uint8, 128^3 chunks, no compressor). ?data=URL reads another copy of the dataset; ?seg=ID&z=Z opens a // segment at a plane. 'use strict'; const params = new URLSearchParams(location.search); const DATA = (params.get('data') || 'https://huggingface.co/datasets/SharpMeow/vesuvius-snapped-labels/resolve/main').replace(/\/+$/, ''); const $ = (id) => document.getElementById(id); const fmt = (n) => (n == null ? 'n/a' : Number(n).toLocaleString('en-US')); const pct = (a, b) => (b ? `${(100 * a / b).toFixed(1)}%` : 'n/a'); const S = { index: [], seg: null, view: null, rows: null, plane: 0, cx: 0, cy: 0, win: 192, order: [], at: 0, abort: null, tween: null }; // tween: null, or 0 (published) to 1 (snapped) while the snap animation plays async function getJSON(url) { const r = await fetch(url, { credentials: 'omit' }); if (!r.ok) throw new Error(`${r.status} ${url}`); return r.json(); } // ---------------------------------------------------------------- CT slices const zarrays = new Map(); function zarray(vol, L) { const k = `${vol}/${L}`; if (!zarrays.has(k)) zarrays.set(k, getJSON(`${k}/.zarray`)); return zarrays.get(k); } async function rangeGet(url, off, len, signal) { const r = await fetch(url, { headers: { Range: `bytes=${off}-${off + len - 1}` }, signal, credentials: 'omit' }); if (r.status === 404 || r.status === 403) return null; // a chunk the masked scan leaves out if (!r.ok) throw new Error(`${r.status} ${url}`); return new Uint8Array(await r.arrayBuffer()); } // A level-0 window [y0, y0+n) x [x0, x0+n) of plane z, as a Uint8Array of n*n (0 outside the scan). async function ctWindow(vol, z, y0, x0, n, signal) { const m = await zarray(vol, 0); const [Z, Y, X] = m.shape, [cz, cy, cx] = m.chunks; if (m.compressor) throw new Error('compressed scan: the viewer reads uncompressed chunks only'); const out = new Uint8Array(n * n); if (z < 0 || z >= Z) return out; const jobs = []; for (let by = Math.floor(Math.max(0, y0) / cy); by * cy < Math.min(Y, y0 + n); by++) { for (let bx = Math.floor(Math.max(0, x0) / cx); bx * cx < Math.min(X, x0 + n); bx++) { const url = `${vol}/0/${Math.floor(z / cz)}/${by}/${bx}`; jobs.push(rangeGet(url, (z % cz) * cy * cx, cy * cx, signal).then((b) => { if (!b) return; for (let j = 0; j < cy; j++) { const yy = by * cy + j - y0; if (yy < 0 || yy >= n) continue; for (let i = 0; i < cx; i++) { const xx = bx * cx + i - x0; if (xx >= 0 && xx < n) out[yy * n + xx] = b[j * cx + i]; } } })); } } await Promise.all(jobs); return out; } // ---------------------------------------------------------------- drawing // A plane's rows are line pieces, float32: published ax ay bx by, then snapped ax ay bx by. const R = 8; const cv = $('cv'), cx2d = cv.getContext('2d'); let ctImage = null; // {key, img} function planeRows() { const p = S.view.planes[S.plane]; return p ? S.rows.subarray(p.first * R, (p.first + p.count) * R) : new Float32Array(0); } function ringOrder() { // the plane's line pieces in order around their centroid: Next and Previous walk along the label const r = planeRows(), n = r.length / R; if (!n) { S.order = []; return; } let mx = 0, my = 0; for (let i = 0; i < n; i++) { mx += r[R * i]; my += r[R * i + 1]; } mx /= n; my /= n; const idx = Array.from({ length: n }, (_, i) => i); idx.sort((a, b) => Math.atan2(r[R * a + 1] - my, r[R * a] - mx) - Math.atan2(r[R * b + 1] - my, r[R * b] - mx)); S.order = idx; } function centerOn(k) { const r = planeRows(); if (!S.order.length) return; S.at = ((k % S.order.length) + S.order.length) % S.order.length; const i = S.order[S.at]; S.cx = r[R * i]; S.cy = r[R * i + 1]; } function middle() { // the position in S.order of the piece nearest the plane's centroid: a start on the label, away from its ends const r = planeRows(), n = r.length / R; if (!n) return 0; let mx = 0, my = 0; for (let i = 0; i < n; i++) { mx += r[R * i]; my += r[R * i + 1]; } mx /= n; my /= n; let best = Infinity, bi = 0; for (let i = 0; i < n; i++) { const d = (r[R * i] - mx) ** 2 + (r[R * i + 1] - my) ** 2; if (d < best) { best = d; bi = i; } } return S.order.indexOf(bi); } async function draw() { if (!S.view) return; const p = S.view.planes[S.plane]; $('z-out').textContent = p ? p.z : ''; const n = S.win, x0 = Math.round(S.cx - n / 2), y0 = Math.round(S.cy - n / 2), z = p ? p.z : 0; const key = `${S.view.volume}|${z}|${y0}|${x0}|${n}`; const W = cv.width, k = W / n; if (!ctImage || ctImage.key !== key) { if (S.abort) S.abort.abort(); S.abort = new AbortController(); msg('Reading CT…'); let buf; try { buf = await ctWindow(S.view.volume, z, y0, x0, n, S.abort.signal); } catch (e) { if (e.name === 'AbortError') return; msg(`CT read failed: ${e.message}`); return; } // contrast: 1st to 99.5th percentile of the window's nonzero voxels const h = new Uint32Array(256); let c = 0; for (const v of buf) if (v) { h[v]++; c++; } let lo = 0, hi = 255, acc = 0; for (let v = 0; v < 256; v++) { acc += h[v]; if (acc >= 0.01 * c) { lo = v; break; } } acc = 0; for (let v = 0; v < 256; v++) { acc += h[v]; if (acc >= 0.995 * c) { hi = v; break; } } const img = new ImageData(n, n), s = 255 / Math.max(1, hi - lo); for (let i = 0; i < n * n; i++) { const g = Math.max(0, Math.min(255, (buf[i] - lo) * s)); img.data[4 * i] = img.data[4 * i + 1] = img.data[4 * i + 2] = g; img.data[4 * i + 3] = 255; } const off = document.createElement('canvas'); off.width = n; off.height = n; off.getContext('2d').putImageData(img, 0, 0); ctImage = { key, img: off }; msg(''); } cx2d.imageSmoothingEnabled = false; cx2d.drawImage(ctImage.img, 0, 0, W, W); const r = planeRows(), m = r.length / R; const X = (x) => (x - x0 + 0.5) * k, Y = (y) => (y - y0 + 0.5) * k; const css = getComputedStyle(document.documentElement); const lw = Math.max(1.5, Math.min(3, k * 0.4)); if (S.tween != null) { tweenLine(r, m, X, Y, lw, css); scaleBar(W, k); return; } if ($('l-move').checked) { // gray strokes from the published line to the snapped one, at each piece's midpoint cx2d.strokeStyle = css.getPropertyValue('--move').trim() || 'rgba(200,200,200,.6)'; cx2d.lineWidth = Math.max(1, lw * 0.45); cx2d.beginPath(); for (let i = 0; i < m; i++) { const a = R * i, px = (r[a] + r[a + 2]) / 2, py = (r[a + 1] + r[a + 3]) / 2, qx = (r[a + 4] + r[a + 6]) / 2, qy = (r[a + 5] + r[a + 7]) / 2; if (Math.abs(px - qx) + Math.abs(py - qy) < 0.05) continue; cx2d.moveTo(X(px), Y(py)); cx2d.lineTo(X(qx), Y(qy)); } cx2d.stroke(); } const line = (o, color) => { cx2d.strokeStyle = color; cx2d.lineWidth = lw; cx2d.lineCap = 'round'; cx2d.beginPath(); for (let i = 0; i < m; i++) { const a = R * i + o; cx2d.moveTo(X(r[a]), Y(r[a + 1])); cx2d.lineTo(X(r[a + 2]), Y(r[a + 3])); } cx2d.stroke(); }; if ($('l-pub').checked) line(0, css.getPropertyValue('--pub').trim() || '#f08a24'); if ($('l-snap').checked) line(4, css.getPropertyValue('--snap').trim() || '#2f7bf6'); scaleBar(W, k); const keep = {}; for (const k of ['data', 'vx', 'tab']) if (params.get(k)) keep[k] = params.get(k); history.replaceState(null, '', `?${new URLSearchParams({ ...keep, seg: S.seg.segment, z: String(z) })}`); } function scaleBar(W, k) { // scale bar: 10 voxels const um = S.view.voxel_um || null; cx2d.fillStyle = '#fff'; cx2d.fillRect(16, W - 22, 10 * k, 4); cx2d.font = '13px system-ui, sans-serif'; cx2d.fillText(um ? `10 voxels (${Math.round(10 * um)} µm)` : '10 voxels', 16, W - 30); } // ---------------------------------------------------------------- snap animation // Each line piece moves in a straight line from its published position to its snapped one, its color going from the // published orange to the snapped blue. Filled and left-in-place vertices are drawn at the published position in both // (the legend's rule), so they do not move. One loop: hold 1.2 s on the published line, 2.4 s to the snapped line, // hold 1.6 s, 2.4 s back. With prefers-reduced-motion it never plays by itself: each click steps published, halfway, // snapped. const TW = { hold0: 1200, move: 2400, hold1: 1600 }; const anim = { on: false, t0: 0, raf: 0, step: -1 }; const mix = (a, b, t) => { const h = (s) => { const m = s.trim().replace('#', ''); return [0, 2, 4].map((i) => parseInt(m.slice(i, i + 2), 16)); }; const A = h(a), B = h(b); return `rgb(${A.map((v, i) => Math.round(v + (B[i] - v) * t)).join(',')})`; }; function tweenLine(r, m, X, Y, lw, css) { const t = S.tween, pub = css.getPropertyValue('--pub').trim() || '#f08a24', snap = css.getPropertyValue('--snap').trim() || '#2f7bf6'; // the published line stays, faint, so the motion reads against it cx2d.globalAlpha = 0.35; cx2d.strokeStyle = pub; cx2d.lineWidth = lw; cx2d.lineCap = 'round'; cx2d.beginPath(); for (let i = 0; i < m; i++) { const a = R * i; cx2d.moveTo(X(r[a]), Y(r[a + 1])); cx2d.lineTo(X(r[a + 2]), Y(r[a + 3])); } cx2d.stroke(); cx2d.globalAlpha = 1; cx2d.strokeStyle = mix(pub, snap, t); cx2d.lineWidth = lw; cx2d.beginPath(); for (let i = 0; i < m; i++) { const a = R * i, L = (p, q) => p + (q - p) * t; cx2d.moveTo(X(L(r[a], r[a + 4])), Y(L(r[a + 1], r[a + 5]))); cx2d.lineTo(X(L(r[a + 2], r[a + 6])), Y(L(r[a + 3], r[a + 7]))); } cx2d.stroke(); const W = cv.width, word = t <= 0.001 ? 'Published label' : t >= 0.999 ? 'Snapped label' : 'Snapping along the normal'; cx2d.font = '600 15px system-ui, sans-serif'; cx2d.fillStyle = 'rgba(0,0,0,.55)'; cx2d.fillRect(12, 12, cx2d.measureText(word).width + 20, 28); cx2d.fillStyle = '#fff'; cx2d.fillText(word, 22, 31); void W; } function tweenAt(ms) { const cyc = 2 * TW.move + TW.hold0 + TW.hold1, u = ms % cyc; if (u < TW.hold0) return 0; if (u < TW.hold0 + TW.move) return (u - TW.hold0) / TW.move; if (u < TW.hold0 + TW.move + TW.hold1) return 1; return 1 - (u - TW.hold0 - TW.move - TW.hold1) / TW.move; } const reduced = () => window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches; function animate(on) { if (reduced() && on !== false) { // three still frames, one per click anim.step = (anim.step + 1) % 4; S.tween = anim.step === 3 ? null : [0, 0.5, 1][anim.step]; $('b-anim').textContent = anim.step === 3 ? 'Step the snap' : ['Published (1 of 3)', 'Halfway (2 of 3)', 'Snapped (3 of 3)'][anim.step]; $('b-anim').setAttribute('aria-pressed', String(S.tween != null)); draw(); return S.tween != null; } anim.on = on == null ? !anim.on : !!on; cancelAnimationFrame(anim.raf); $('b-anim').setAttribute('aria-pressed', String(anim.on)); $('b-anim').textContent = anim.on ? 'Stop' : 'Animate snap'; if (!anim.on) { S.tween = null; draw(); return false; } anim.t0 = performance.now(); const tick = (now) => { if (!anim.on) return; S.tween = tweenAt(now - anim.t0); draw(); anim.raf = requestAnimationFrame(tick); }; anim.raf = requestAnimationFrame(tick); return true; } function msg(t) { const e = $('cv-msg'); e.textContent = t; e.hidden = !t; } // ---------------------------------------------------------------- segments function card(seg) { const v = seg.vertices_released, sn = seg.snapped, ab = seg.abstained, held = seg.vertices_held_back; const fl = seg.filled != null ? seg.filled : 0, lip = seg.left_in_place != null ? seg.left_in_place : ab - fl; const pb = seg.placed_by; const mv = seg.move_voxels || {}; const fine = pb ? `
hf download SharpMeow/vesuvius-snapped-labels --repo-type dataset --include "${seg.path}/*" --local-dir snapped-labels
Mesh: ${seg.path}/snapped.tifxyz · record: release.json · CT: ${S.view ? S.view.volume.split('/').pop() : ''}
`; } async function openSeg(seg, z) { S.seg = seg; document.querySelectorAll('#seg-list li').forEach((li) => li.classList.toggle('on', li.dataset.id === seg.segment)); $('v-title').textContent = `${seg.scroll} · ${seg.segment} · ${seg.method}`; msg('Loading cross sections…'); const base = `${DATA}/viewer/${seg.scroll}/${seg.segment}`; const [view, bin] = await Promise.all([getJSON(`${base}.json`), fetch(`${base}.bin`, { credentials: 'omit' }).then((r) => { if (!r.ok) throw new Error(`${r.status}`); return r.arrayBuffer(); })]); S.view = view; S.rows = new Float32Array(bin); const zr = $('z-range'); zr.max = String(Math.max(0, view.planes.length - 1)); let pi = Math.floor(view.planes.length / 2); if (z != null) { let best = Infinity; view.planes.forEach((p, i) => { const d = Math.abs(p.z - z); if (d < best) { best = d; pi = i; } }); } // start on the busiest plane near the middle, so the first view shows the label if (z == null) { let best = -1; for (let i = Math.floor(pi * 0.6); i <= Math.min(view.planes.length - 1, Math.ceil(pi * 1.4)); i++) if (view.planes[i].count > best) { best = view.planes[i].count; pi = i; } } S.plane = pi; zr.value = String(pi); ringOrder(); centerOn(middle()); card(seg); ctImage = null; await draw(); } // The segment list and the table grow with the page, so a pick far down them can leave the image out of view: // bring the viewer back when less than a fifth of the image shows. function showStage() { const r = document.querySelector('.canvaswrap').getBoundingClientRect(); const seen = Math.min(r.bottom, innerHeight) - Math.max(r.top, 0); if (seen < 0.2 * Math.min(r.height, innerHeight)) document.querySelector('.stage').scrollIntoView({ block: 'start', behavior: 'smooth' }); } function listSegs() { const sc = $('f-scroll').value, q = $('f-find').value.trim().toLowerCase(); const ul = $('seg-list'); ul.innerHTML = ''; for (const s of S.index) { if (sc && s.scroll !== sc) continue; if (q && !s.segment.toLowerCase().includes(q)) continue; const li = document.createElement('li'); li.dataset.id = s.segment; const nm = s.segment.replace(/^\d{14}-/, ''); li.innerHTML = `${nm}${pct(s.snapped, s.vertices_released)}`; li.title = s.segment; li.onclick = () => { showStage(); openSeg(s).catch((e) => msg(e.message)); }; if (S.seg && S.seg.segment === s.segment) li.classList.add('on'); ul.appendChild(li); } } function table() { const tb = document.querySelector('#tbl tbody'); tb.innerHTML = ''; $('th-claim').hidden = !(window.vxClaim && window.vxClaim('') != null); for (const s of S.index) { const tr = document.createElement('tr'); const claim = window.vxClaim ? window.vxClaim(s.scroll) : null; tr.innerHTML = `