unroll / live.html
SharpMeow's picture
One page scroll bar and American English
757768f verified
Raw History Blame Contribute Delete
15.8 kB
<!doctype html>
<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>