File size: 15,796 Bytes
7391956 757768f 7391956 757768f 7391956 757768f 7391956 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | <!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>
|