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>