Download live.html from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 15.8 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/live.html
- Command line
-
hf download hf://spaces/SharpMeow/unroll/live.html
-
curl -L -o live.html https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/live.html
15.8 kB
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Unroll Live Sheet</title> | |
| <meta name="description" content="Stream a traced papyrus sheet and a scan volume (CT or a 3D ink prediction) from the Vesuvius Challenge open data and see the volume on the sheet in 3D, in the browser."> | |
| <style> | |
| :root { --bg: #0d0b09; --panel: #18140f; --line: #3a3026; --text: #efe6d8; --muted: #b4a690; --accent: #e3a24a; } | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; } | |
| #app { display: grid; grid-template-columns: 340px 1fr; height: 100%; } | |
| aside { background: var(--panel); border-right: 1px solid var(--line); padding: 16px; overflow-y: auto; } | |
| h1 { font-size: 18px; margin: 0 0 4px; } | |
| .sub { color: var(--muted); margin: 0 0 14px; } | |
| label { display: block; color: var(--muted); font-size: 12px; margin: 10px 0 3px; } | |
| input, select { width: 100%; background: #0f0c09; color: var(--text); border: 1px solid var(--line); border-radius: 6px; padding: 6px 8px; font: inherit; font-size: 12px; } | |
| .row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } | |
| .row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; } | |
| button, a.btn { display: inline-block; margin: 12px 6px 0 0; padding: 7px 12px; border-radius: 6px; border: 1px solid var(--line); background: #241d15; color: var(--text); font: inherit; cursor: pointer; text-decoration: none; } | |
| button.primary { background: var(--accent); color: #1a1208; border-color: var(--accent); font-weight: 600; } | |
| button:disabled { opacity: .5; cursor: default; } | |
| #status { margin-top: 12px; color: var(--muted); font-size: 12px; white-space: pre-wrap; } | |
| #view { position: relative; } | |
| canvas { display: block; width: 100%; height: 100%; } | |
| #legend { position: absolute; left: 12px; bottom: 12px; background: rgba(13,11,9,.8); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; font-size: 12px; color: var(--muted); max-width: 70%; } | |
| #contrast { position: absolute; right: 12px; bottom: 12px; background: rgba(13,11,9,.8); border: 1px solid var(--line); border-radius: 6px; padding: 8px 10px; width: 220px; font-size: 12px; } | |
| #contrast input { padding: 0; } | |
| .note { color: var(--muted); font-size: 12px; margin-top: 14px; } | |
| @media (max-width: 760px) { #app { grid-template-columns: 1fr; grid-template-rows: auto 60vh; } aside { border-right: 0; border-bottom: 1px solid var(--line); } } | |
| </style> | |
| <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> | |
| </head> | |
| <body> | |
| <div id="app"> | |
| <aside> | |
| <h1>Live sheet</h1> | |
| <p class="sub">Stream one traced sheet and a scan volume straight from the open data bucket, and see the volume on the sheet in 3D. Nothing to install; your browser does the work.</p> | |
| <label for="preset">Start from</label> | |
| <select id="preset"></select> | |
| <label for="seg">Segment (tifxyz folder URL)</label> | |
| <input id="seg" spellcheck="false"> | |
| <label for="vol">Volume (OME-Zarr URL, in the segment's voxels: CT or a 3D ink prediction)</label> | |
| <input id="vol" spellcheck="false"> | |
| <label>Crop of the segment grid: rows R0 R1, columns C0 C1</label> | |
| <div class="row"><input id="r0" type="number"><input id="r1" type="number"><input id="c0" type="number"><input id="c1" type="number"></div> | |
| <div class="row2"> | |
| <div><label for="level">Volume level</label><input id="level" type="number" min="0" value="1"></div> | |
| <div><label for="depth">Depth (voxels each side)</label><input id="depth" type="number" min="0" value="4"></div> | |
| <div><label for="up">Refine grid</label><input id="up" type="number" min="1" max="4" value="2"></div> | |
| <div><label for="reduce">Along the normal</label><select id="reduce"><option value="mean">mean</option><option value="max">max</option></select></div> | |
| </div> | |
| <label for="palette">Colors</label> | |
| <select id="palette"><option value="gray">gray (CT)</option><option value="ink">ink: warm on dark papyrus</option></select> | |
| <button id="go" class="primary">Load</button> | |
| <button id="vtk" disabled>Download VTK</button> | |
| <a id="ng" class="btn" target="_blank" rel="noopener" href="#">Volume in neuroglancer</a> | |
| <div id="status">Ready.</div> | |
| <p class="note">The sheet's values are the volume's voxels along each point's normal (max or mean), the same rule as <code>kit sheetview</code> in ChaseHendrick/Scrolls, which also makes a neuroglancer mesh. The VTK opens in neuroglancer (served with CORS) and ParaView. An ink prediction shown here is model output, not a reading. Data: Vesuvius Challenge open data, CC BY-NC 4.0.</p> | |
| </aside> | |
| <div id="view"> | |
| <canvas id="c"></canvas> | |
| <div id="legend">Drag to turn, scroll to zoom, right-drag to pan.</div> | |
| <div id="contrast"> | |
| <div>Black point <span id="lov"></span></div><input id="lo" type="range" min="0" max="255" step="0.5"> | |
| <div>White point <span id="hiv"></span></div><input id="hi" type="range" min="0" max="255" step="0.5"> | |
| </div> | |
| </div> | |
| </div> | |
| <script type="module"> | |
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| import { readSegment, upsample, openVolume, sampleSheet, sheetMesh, toVTK, percentiles } from './lib/livesheet.js'; | |
| const B = 'https://vesuvius-challenge-open-data.s3.amazonaws.com'; | |
| // Presets are public scans that are on none of the prize lists. | |
| const PRESETS = [ | |
| { name: 'PHerc0139 w045, CT 2.399 um', seg: `${B}/PHerc0139/segments/20260126000000-w045_2026012619/mesh/20260126000000-on-20260102150214-2.399um.tifxyz`, | |
| vol: `${B}/PHerc0139/volumes/20260102150214-2.399um-0.2m-78keV-masked.zarr`, crop: [500, 650, 700, 900], level: 1, depth: 4, up: 2, reduce: 'mean', palette: 'gray' }, | |
| { name: 'PHerc0139 w045, wider piece, CT level 2', seg: `${B}/PHerc0139/segments/20260126000000-w045_2026012619/mesh/20260126000000-on-20260102150214-2.399um.tifxyz`, | |
| vol: `${B}/PHerc0139/volumes/20260102150214-2.399um-0.2m-78keV-masked.zarr`, crop: [350, 750, 500, 1100], level: 2, depth: 8, up: 1, reduce: 'mean', palette: 'gray' }, | |
| ]; | |
| const $ = (id) => document.getElementById(id); | |
| // "PHerc0139 20260126000000-w045_2026012619" from a bucket segment URL, else the tifxyz folder name | |
| const segName = (u) => { const a = u.replace(/\/$/, '').split('/'), i = a.lastIndexOf('segments'); | |
| return i > 0 && a[i + 1] ? `${a[i - 1]} ${a[i + 1]}` : a[a.length - 1]; }; | |
| const status = (t) => { $('status').textContent = t; }; | |
| const fields = ['seg', 'vol', 'r0', 'r1', 'c0', 'c1', 'level', 'depth', 'up', 'reduce', 'palette']; | |
| const PAL = { gray: [[0, 0, 0], [1, 1, 1]], ink: [[0.165, 0.125, 0.09], [1, 0.824, 0.478]] }; | |
| function setForm(p) { | |
| $('seg').value = p.seg; $('vol').value = p.vol; | |
| [$('r0').value, $('r1').value, $('c0').value, $('c1').value] = p.crop; | |
| $('level').value = p.level; $('depth').value = p.depth; $('up').value = p.up; $('reduce').value = p.reduce; $('palette').value = p.palette; | |
| } | |
| function getForm() { | |
| return { seg: $('seg').value.trim(), vol: $('vol').value.trim(), crop: ['r0', 'r1', 'c0', 'c1'].map((k) => +$(k).value), | |
| level: +$('level').value, depth: +$('depth').value, up: +$('up').value, reduce: $('reduce').value, palette: $('palette').value }; | |
| } | |
| PRESETS.forEach((p, i) => $('preset').add(new Option(p.name, i))); | |
| $('preset').onchange = () => setForm(PRESETS[+$('preset').value]); | |
| // URL parameters (for links and agents): seg, vol, crop=r0,r1,c0,c1, level, depth, up, reduce, palette, autoload=1 | |
| const q = new URLSearchParams(location.search); | |
| setForm(PRESETS[0]); | |
| if (q.get('seg')) $('seg').value = q.get('seg'); | |
| if (q.get('vol')) $('vol').value = q.get('vol'); | |
| if (q.get('crop')) [$('r0').value, $('r1').value, $('c0').value, $('c1').value] = q.get('crop').split(','); | |
| for (const k of ['level', 'depth', 'up', 'reduce', 'palette']) if (q.get(k)) $(k).value = q.get(k); | |
| // three.js | |
| const canvas = $('c'), renderer = new THREE.WebGLRenderer({ canvas, antialias: true, preserveDrawingBuffer: true }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); | |
| const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0d0b09); | |
| const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 1000); | |
| const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; | |
| scene.add(new THREE.HemisphereLight(0xffffff, 0x302418, 1.4)); | |
| const sun = new THREE.DirectionalLight(0xffffff, 1.6); scene.add(sun); | |
| let mesh = null, last = null; | |
| function resize() { | |
| const r = canvas.parentElement.getBoundingClientRect(); | |
| renderer.setSize(r.width, r.height, false); camera.aspect = r.width / Math.max(r.height, 1); camera.updateProjectionMatrix(); | |
| } | |
| addEventListener('resize', resize); resize(); | |
| (function loop() { controls.update(); sun.position.copy(camera.position); renderer.render(scene, camera); requestAnimationFrame(loop); })(); | |
| function recolor() { | |
| if (!mesh || !last) return; | |
| const lo = +$('lo').value, hi = +$('hi').value, [a, b] = PAL[$('palette').value]; | |
| $('lov').textContent = lo.toFixed(1); $('hiv').textContent = hi.toFixed(1); | |
| const col = mesh.geometry.attributes.color.array, v = last.mesh.values; | |
| for (let k = 0; k < v.length; k++) { | |
| const t = Math.min(1, Math.max(0, (v[k] - lo) / Math.max(hi - lo, 1e-6))); | |
| for (let c = 0; c < 3; c++) col[3 * k + c] = a[c] + (b[c] - a[c]) * t; | |
| } | |
| mesh.geometry.attributes.color.needsUpdate = true; | |
| } | |
| $('lo').oninput = recolor; $('hi').oninput = recolor; $('palette').onchange = recolor; | |
| // Face on: grid columns to the right, rows down, looking along column step cross row step (kit sheetview's view). | |
| function faceOn(geo, du, dv, size) { | |
| const ex = du.clone().normalize(), ey = dv.clone().sub(ex.clone().multiplyScalar(dv.dot(ex))).normalize(); | |
| const ez = new THREE.Vector3().crossVectors(ex, ey); | |
| camera.up.copy(ey.clone().negate()); | |
| camera.position.copy(ez.clone().multiplyScalar(-size * 1.9)); | |
| controls.target.set(0, 0, 0); camera.lookAt(0, 0, 0); controls.update(); | |
| } | |
| function ngLink(p, center) { | |
| const um = last?.seg?.voxelUM || 1, s = [um * 1e-6, 'm']; | |
| const st = { dimensions: { x: [1e-6, 'm'], y: [1e-6, 'm'], z: [1e-6, 'm'] }, position: center.map((v) => v * um), crossSectionScale: 2, | |
| layers: [{ type: 'image', name: 'volume', source: { url: 'zarr2://' + p.vol, transform: { | |
| inputDimensions: { x: s, y: s, z: s }, outputDimensions: { x: [1e-6, 'm'], y: [1e-6, 'm'], z: [1e-6, 'm'] } } } }], layout: '4panel' }; | |
| return 'https://neuroglancer-demo.appspot.com/#!' + encodeURIComponent(JSON.stringify(st)); | |
| } | |
| async function load(p = getForm()) { | |
| $('go').disabled = true; $('vtk').disabled = true; | |
| const t0 = performance.now(); | |
| try { | |
| status('Reading the segment rows...'); | |
| let seg = await readSegment(p.seg, { crop: p.crop }); | |
| seg = upsample(seg, Math.max(1, Math.min(4, p.up | 0))); | |
| status(`Segment: ${seg.h} x ${seg.w} nodes. Opening the volume...`); | |
| const vol = await openVolume(p.vol, p.level); | |
| const r = await sampleSheet(seg, vol, { depth: p.depth, reduce: p.reduce, | |
| onProgress: (d, n) => status(`Sampling: chunk ${d} of ${n} (${(vol.stats.bytes / 1e6).toFixed(0)} MB)`) }); | |
| const m = sheetMesh(seg, r.values); | |
| // center and scale to millimeters | |
| const um = seg.voxelUM || 1, pos = new Float32Array(m.positions.length), c = [0, 0, 0]; | |
| for (let k = 0; k < m.vertices; k++) for (let t = 0; t < 3; t++) c[t] += m.positions[3 * k + t] / m.vertices; | |
| for (let k = 0; k < m.vertices; k++) for (let t = 0; t < 3; t++) pos[3 * k + t] = (m.positions[3 * k + t] - c[t]) * um / 1000; | |
| const geo = new THREE.BufferGeometry(); | |
| geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); | |
| geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(pos.length), 3)); | |
| geo.setIndex(new THREE.BufferAttribute(m.indices, 1)); geo.computeVertexNormals(); geo.computeBoundingSphere(); | |
| if (mesh) { scene.remove(mesh); mesh.geometry.dispose(); } | |
| mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({ vertexColors: true, side: THREE.DoubleSide, roughness: 0.9, metalness: 0 })); | |
| scene.add(mesh); | |
| last = { params: p, seg, vol: { root: vol.root, level: vol.level, factor: vol.factor, stats: { ...vol.stats } }, mesh: m, chunks: r.chunks, samplesPerVertex: r.samplesPerVertex }; | |
| const [p1, p995] = percentiles(m.values, [1, 99.5]); | |
| const top = vol.dtype.includes('u1') ? 255 : Math.max(p995 * 1.5, 1); | |
| for (const id of ['lo', 'hi']) { $(id).max = top; $(id).step = top / 510; } | |
| $('lo').value = p1; $('hi').value = p995 > p1 ? p995 : p1 + 1; recolor(); | |
| // mean steps along columns and rows of the grid, for the face-on view | |
| const du = new THREE.Vector3(), dv = new THREE.Vector3(); let nu = 0, nv = 0; | |
| const X = (i, j) => seg.xyz.subarray(3 * (i * seg.w + j), 3 * (i * seg.w + j) + 3), V = (i, j) => seg.valid[i * seg.w + j]; | |
| for (let i = 0; i < seg.h; i++) for (let j = 0; j < seg.w; j++) { | |
| if (j + 1 < seg.w && V(i, j) && V(i, j + 1)) { const a = X(i, j), b = X(i, j + 1); du.x += b[0] - a[0]; du.y += b[1] - a[1]; du.z += b[2] - a[2]; nu++; } | |
| if (i + 1 < seg.h && V(i, j) && V(i + 1, j)) { const a = X(i, j), b = X(i + 1, j); dv.x += b[0] - a[0]; dv.y += b[1] - a[1]; dv.z += b[2] - a[2]; nv++; } | |
| } | |
| faceOn(geo, du, dv, geo.boundingSphere.radius); | |
| $('ng').href = ngLink(p, c); | |
| $('vtk').disabled = false; | |
| const secs = ((performance.now() - t0) / 1000).toFixed(1); | |
| status(`${m.vertices.toLocaleString()} points, ${m.triangles.toLocaleString()} triangles; ${r.chunks} chunks (${(vol.stats.bytes / 1e6).toFixed(0)} MB), ${r.samplesPerVertex} samples per point, ${secs} s.`); | |
| $('legend').textContent = `${segName(p.seg)} · ${(geo.boundingSphere.radius * 2).toFixed(1)} mm across · drag to turn, scroll to zoom`; | |
| const u = new URL(location.href); | |
| u.search = new URLSearchParams({ seg: p.seg, vol: p.vol, crop: p.crop.join(','), level: p.level, depth: p.depth, up: p.up, reduce: p.reduce, palette: p.palette }).toString(); | |
| history.replaceState(null, '', u); | |
| return { ok: true, vertices: m.vertices, triangles: m.triangles, chunks: r.chunks, bytes: vol.stats.bytes, seconds: +secs, contrast: [p1, p995] }; | |
| } catch (e) { | |
| status('Could not load: ' + (e.message || e)); | |
| return { ok: false, error: String(e.message || e) }; | |
| } finally { $('go').disabled = false; } | |
| } | |
| $('go').onclick = () => load(); | |
| $('vtk').onclick = () => { | |
| if (!last) return; | |
| const a = document.createElement('a'); | |
| a.href = URL.createObjectURL(new Blob([toVTK(last.mesh)], { type: 'text/plain' })); a.download = 'sheet.vtk'; a.click(); | |
| }; | |
| // for agents: await liveSheet.load({...}); liveSheet.state(); liveSheet.turn(deg); liveSheet.screenshot() | |
| window.liveSheet = { | |
| load: (p) => load(p ? { ...getForm(), ...p } : getForm()), | |
| state: () => last && { params: last.params, vertices: last.mesh.vertices, triangles: last.mesh.triangles, chunks: last.chunks, | |
| samplesPerVertex: last.samplesPerVertex, volume: last.vol }, | |
| values: () => last && Array.from(last.mesh.values), | |
| turn: (deg, axis = 'up') => { | |
| const ax = axis === 'up' ? camera.up.clone() : new THREE.Vector3().crossVectors(camera.up, camera.position).normalize(); | |
| camera.position.applyAxisAngle(ax.normalize(), deg * Math.PI / 180); camera.lookAt(controls.target); controls.update(); return true; | |
| }, | |
| screenshot: () => { renderer.render(scene, camera); return canvas.toDataURL('image/png'); }, | |
| }; | |
| if (q.get('autoload') === '1') load(); | |
| </script> | |
| </body> | |
| </html> | |