File size: 16,310 Bytes
7c611dc
 
 
 
 
 
475860e
7c611dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475860e
7c611dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a4e505
 
 
 
7c611dc
 
 
3a4e505
7c611dc
3a4e505
7c611dc
3a4e505
 
 
 
7c611dc
3a4e505
7c611dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a4e505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c611dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3a4e505
7c611dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
// Segment Lens: published tifxyz segments as 3D surfaces with kit tifxyz check's flags, beside the Segment Atlas
// numbers. Reads atlas.jsonl and lens/*.slg.gz from the Hugging Face dataset SharpMeow/vesuvius-segment-atlas at
// run time (format: docs/tools/atlas.md in ChaseHendrick/Scrolls, "Lens files").
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

const VERSION = '1.1.0';
// ?data=URL/ reads another copy of the atlas (a local build, for testing); the default is the published dataset
const DATA = new URLSearchParams(location.search).get('data') || 'https://huggingface.co/datasets/SharpMeow/vesuvius-segment-atlas/resolve/main/';
const S3 = 'https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/';
const COLORS = { base: [0.80, 0.84, 0.86], hole: [0.35, 0.65, 1.0], fold: [1.0, 0.36, 0.36], str: [1.0, 0.65, 0.2] };
const $ = (id) => document.getElementById(id);

let rows = [];
let current = null;
let mesh = null;
let wire = null;
let lens = null;
let sortKey = 'area_cm2';
let sortDir = -1;

// ---------------------------------------------------------------- 3D

const canvas = $('gl');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x05080b);
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
camera.position.set(0, 0, 4.0);   // opens a little zoomed out, so the whole segment and some margin show
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
scene.add(new THREE.HemisphereLight(0xe8f4ff, 0x1a2228, 0.9));
const sun = new THREE.DirectionalLight(0xffffff, 1.6);
sun.position.set(2, 3, 4);
scene.add(sun);
const back = new THREE.DirectionalLight(0x9fe3ff, 0.5);
back.position.set(-3, -2, -4);
scene.add(back);

function resize() {
  const w = window.innerWidth, h = window.innerHeight;
  renderer.setSize(w, h, false);
  camera.aspect = w / h;
  camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
(function loop() {
  controls.update();
  renderer.render(scene, camera);
  requestAnimationFrame(loop);
})();

async function gunzip(buf) {
  const ds = new DecompressionStream('gzip');
  const stream = new Blob([buf]).stream().pipeThrough(ds);
  return new Uint8Array(await new Response(stream).arrayBuffer());
}

function parseLens(raw) {
  const magic = String.fromCharCode(...raw.slice(0, 4));
  if (magic !== 'SLG1') throw new Error('not a Segment Lens grid');
  const dv = new DataView(raw.buffer, raw.byteOffset, raw.byteLength);
  const h = dv.getUint16(4, true), w = dv.getUint16(6, true), k = dv.getUint16(8, true);
  const lo = [0, 1, 2].map((i) => dv.getFloat32(10 + 4 * i, true));
  const hi = [0, 1, 2].map((i) => dv.getFloat32(22 + 4 * i, true));
  const n = h * w;
  const q = [0, 1, 2].map((i) => new Uint16Array(raw.slice(34 + 2 * n * i, 34 + 2 * n * (i + 1)).buffer));
  const flags = raw.slice(34 + 6 * n, 34 + 7 * n);
  return { h, w, k, lo, hi, q, flags };
}

function buildMesh(g) {
  const { h, w, lo, hi, q } = g;
  const n = h * w;
  const span = [0, 1, 2].map((i) => (hi[i] - lo[i]) || 1);
  const size = Math.max(...span);
  const pos = new Float32Array(3 * n);
  const ok = new Uint8Array(n);
  for (let i = 0; i < n; i++) {
    if (q[0][i] === 65535) continue;
    ok[i] = 1;
    for (let a = 0; a < 3; a++) {
      const v = lo[a] + (q[a][i] / 65534) * span[a];
      pos[3 * i + a] = (v - (lo[a] + hi[a]) / 2) / size * 2;
    }
  }
  const idx = [];
  for (let r = 0; r < h - 1; r++) {
    for (let c = 0; c < w - 1; c++) {
      const a = r * w + c, b = a + 1, d = a + w, e = d + 1;
      if (ok[a] && ok[b] && ok[d] && ok[e]) idx.push(a, b, e, a, e, d);
    }
  }
  const geo = new THREE.BufferGeometry();
  geo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
  geo.setAttribute('color', new THREE.BufferAttribute(new Float32Array(3 * n), 3));
  geo.setIndex(idx);
  geo.computeVertexNormals();
  // z (scan height) points up on screen: the scan's slices are stacked along z
  geo.rotateX(-Math.PI / 2);
  return { geo, cells: idx.length / 6 };
}

function paint() {
  if (!mesh || !lens) return;
  const col = mesh.geometry.getAttribute('color');
  const show = { 1: $('t-hole').checked, 2: $('t-fold').checked, 4: $('t-str').checked };
  const f = lens.flags;
  for (let i = 0; i < f.length; i++) {
    let c = COLORS.base;
    if (show[1] && (f[i] & 1)) c = COLORS.hole;
    if (show[4] && (f[i] & 4)) c = COLORS.str;
    if (show[2] && (f[i] & 2)) c = COLORS.fold;
    col.setXYZ(i, c[0], c[1], c[2]);
  }
  col.needsUpdate = true;
  if (wire) wire.visible = $('t-wire').checked;
}

async function show(row) {
  current = row;
  setCard(row);
  document.querySelectorAll('#list li').forEach((li) => li.classList.toggle('on', li.dataset.id === row.id));
  if (!row.lens) {
    clearMesh();
    $('c-note').textContent = row.prize_lists && row.prize_lists.length
      ? 'This scroll is on a prize list, so it has numbers here but no 3D preview (public media rule).'
      : 'No preview for this mesh (it was not measured, or has no filled nodes).';
    return;
  }
  $('c-note').textContent = 'Loading the preview…';
  try {
    const res = await fetch(DATA + 'lens/' + row.lens);
    if (!res.ok) throw new Error('HTTP ' + res.status);
    const g = parseLens(await gunzip(await res.arrayBuffer()));
    if (current !== row) return;
    clearMesh();
    lens = g;
    const { geo, cells } = buildMesh(g);
    mesh = new THREE.Mesh(geo, new THREE.MeshStandardMaterial({
      vertexColors: true, side: THREE.DoubleSide, roughness: 0.75, metalness: 0.0 }));
    scene.add(mesh);
    wire = new THREE.LineSegments(new THREE.WireframeGeometry(geo),
      new THREE.LineBasicMaterial({ color: 0x9fe3ff, transparent: true, opacity: 0.12 }));
    scene.add(wire);
    paint();
    controls.reset();
    $('c-note').textContent = `Preview: 1 in ${g.k} grid nodes along each side (${g.w} x ${g.h}, ${cells.toLocaleString()} cells); flags pooled over each ${g.k} x ${g.k} block. Drag to turn, scroll to zoom. Up on screen is the scan's z.`;
  } catch (err) {
    $('c-note').textContent = 'The preview could not be loaded: ' + err.message;
  }
}

function clearMesh() {
  for (const o of [mesh, wire]) {
    if (!o) continue;
    scene.remove(o);
    o.geometry.dispose();
    o.material.dispose();
  }
  mesh = wire = lens = null;
}

// ---------------------------------------------------------------- panels

const fmt = (v, n = 2) => (v === null || v === undefined || Number.isNaN(v) ? '-' : Number(v).toFixed(n));
const pct = (v) => (v === null || v === undefined ? '-' : (100 * v).toFixed(1) + '%');

function kitCommand(row) {
  return `python -m kit tifxyz check ${row.url.replace(S3, 's3://vesuvius-challenge-open-data/')} --png check.png`;
}

function setCard(r) {
  $('c-kicker').textContent = `${r.scroll}${r.volume ? ' · VOLUME ' + r.volume : ''}${r.voxel_um ? ' · ' + r.voxel_um + ' µm' : ''}`;
  $('c-title').textContent = r.segment + (r.kind !== 'mesh' ? ' (' + r.mesh + ')' : '');
  // one line per number, so the card fits beside the list without a scroll bar of its own
  const dd = (v, cls = '') => `<dd${cls ? ` class="${cls}"` : ''}>${esc(String(v))}</dd>`;
  const flag = (k, mm2, nodes) => `<dt>${k}</dt>${dd(fmt(mm2), 'v')}${dd((nodes || 0).toLocaleString(), 'u')}`;
  $('c-nums').innerHTML = r.error ? `<dt>not measured</dt>${dd(r.error, 'wrap')}` : [
    ['area', r.area_cm2 == null ? '-' : fmt(r.area_cm2) + ' cm²'],
    ['grid', `${r.grid_w} x ${r.grid_h}`],
    ['filled', pct(r.filled_share)],
    ['edges within 10%', pct(r.within_10pct)],
    ['median edge / step', fmt(r.edge_p50, 3)],
    ['median grid angle', fmt(r.angle_p50, 1) + '°'],
    ['area vs meta.json', r.meta_area_ratio == null ? '-' : fmt(r.meta_area_ratio, 4)],
  ].map(([k, v]) => `<dt>${k}</dt>${dd(v)}`).join('') +
    '<dt class="hd">flagged</dt><dd class="v hd">mm²</dd><dd class="u hd">nodes</dd>' +
    flag('holes', r.holes_mm2, r.holes_nodes) + flag('folds', r.folds_mm2, r.folds_nodes) +
    flag('stretched', r.stretched_mm2, r.stretched_nodes);
  $('a-bucket').href = r.url + '/meta.json';
  $('a-bucket').textContent = 'open meta.json';
}

function esc(s) {
  return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}

function toast(msg) {
  const t = $('toast');
  t.textContent = msg;
  t.hidden = false;
  clearTimeout(toast.t);
  toast.t = setTimeout(() => { t.hidden = true; }, 2600);
}

function scrollsWithLens() {
  const s = new Map();
  for (const r of rows) {
    const e = s.get(r.scroll) || { n: 0, lens: 0 };
    e.n++;
    if (r.lens) e.lens++;
    s.set(r.scroll, e);
  }
  return [...s.entries()].sort((a, b) => (b[1].lens > 0) - (a[1].lens > 0) || a[0].localeCompare(b[0]));
}

function fillScrolls() {
  const sel = $('sel-scroll');
  sel.innerHTML = scrollsWithLens().map(([s, e]) =>
    `<option value="${esc(s)}">${esc(s)} · ${e.n} meshes${e.lens ? '' : ' · numbers only'}</option>`).join('');
  const want = new URLSearchParams(location.hash.slice(1)).get('scroll');
  if (want && [...sel.options].some((o) => o.value === want)) sel.value = want;
  else if ([...sel.options].some((o) => o.value === 'PHerc0139')) sel.value = 'PHerc0139';
}

function fillList() {
  const scroll = $('sel-scroll').value;
  const top = $('only-top').checked;
  const f = $('filter').value.trim().toLowerCase();
  const list = rows.filter((r) => r.scroll === scroll && (!top || r.kind === 'mesh')
    && (!f || r.id.toLowerCase().includes(f)))
    .sort((a, b) => (b.area_cm2 || 0) - (a.area_cm2 || 0));
  $('list').innerHTML = list.map((r) => `<li tabindex="0" data-id="${esc(r.id)}">
      <span class="nm" title="${esc(r.id)}">${esc(r.segment)}${r.kind !== 'mesh' ? ' · ' + esc(r.mesh) : ''}</span>
      <span class="ar">${r.area_cm2 == null ? '' : fmt(r.area_cm2, 1) + ' cm²'}</span>
      <span class="fl">${r.voxel_um ? r.voxel_um + ' µm · ' : ''}${r.error ? 'not measured' :
        `<b class="c-hole">${fmt(r.holes_mm2, 1)}</b> / <b class="c-fold">${fmt(r.folds_mm2, 1)}</b> / <b class="c-str">${fmt(r.stretched_mm2, 1)}</b> mm²`}${r.lens ? '' : ' · numbers only'}</span>
    </li>`).join('');
  return list;
}

$('list').addEventListener('click', (e) => {
  const li = e.target.closest('li');
  if (!li) return;
  const r = rows.find((x) => x.id === li.dataset.id);
  if (r) { location.hash = 'scroll=' + encodeURIComponent(r.scroll) + '&id=' + encodeURIComponent(r.id); show(r); }
});
$('list').addEventListener('keydown', (e) => { if (e.key === 'Enter') e.target.click(); });
$('sel-scroll').addEventListener('change', () => { const l = fillList(); if (l.length) show(l[0]); });
$('only-top').addEventListener('change', fillList);
$('filter').addEventListener('input', fillList);
for (const id of ['t-hole', 't-fold', 't-str', 't-wire']) $(id).addEventListener('change', paint);
$('b-copy').addEventListener('click', async () => {
  if (!current) return;
  try { await navigator.clipboard.writeText(kitCommand(current)); toast('kit command copied'); }
  catch { toast(kitCommand(current)); }
});
$('b-about').addEventListener('click', () => { $('about').showModal(); cover(); });
$('about').addEventListener('close', cover);

// One scroll bar at a time (one-screen viewer): the segment list scrolls, unless the table or the guide is open,
// which then scrolls instead. The panels under the table step aside; under the guide the list stops scrolling, and
// so does the table when the guide is opened over it.
function cover() {
  const table = !$('table-view').hidden, guide = $('about').open;
  $('picker').hidden = $('card').hidden = table;
  document.documentElement.classList.toggle('covered', table || guide);
  document.documentElement.classList.toggle('guide', guide);
}
// On a phone the picker sits above the card and takes the height the card leaves (style.css, --card-h).
if (window.ResizeObserver) {
  new ResizeObserver(() => document.documentElement.style.setProperty('--card-h', $('card').offsetHeight + 'px'))
    .observe($('card'));
}
if (window.matchMedia && matchMedia('(max-width: 760px), (max-height: 560px)').matches) $('c-more').open = false;

// ---------------------------------------------------------------- table

function fillTable() {
  const f = $('t-filter').value.trim().toLowerCase();
  const list = rows.filter((r) => r.kind === 'mesh' && (!f || r.id.toLowerCase().includes(f)));
  list.sort((a, b) => {
    const x = a[sortKey], y = b[sortKey];
    if (x == null && y == null) return 0;
    if (x == null) return 1;
    if (y == null) return -1;
    return (typeof x === 'string' ? x.localeCompare(y) : x - y) * sortDir;
  });
  document.querySelectorAll('#tbl th').forEach((th) => th.classList.toggle('sorted', th.dataset.k === sortKey));
  $('tbl').querySelector('tbody').innerHTML = list.map((r) => `<tr class="${r.lens ? 'go' : ''}" data-id="${esc(r.id)}">
    <td>${esc(r.scroll)}</td><td title="${esc(r.mesh)}">${esc(r.segment)}</td><td class="n">${r.voxel_um ?? '-'}</td>
    <td class="n">${fmt(r.area_cm2)}</td><td class="n">${pct(r.filled_share)}</td><td class="n">${pct(r.within_10pct)}</td>
    <td class="n">${fmt(r.holes_mm2)}</td><td class="n">${fmt(r.folds_mm2)}</td><td class="n">${fmt(r.stretched_mm2)}</td>
    <td>${r.lens ? 'view' : (r.error ? 'error' : '-')}</td></tr>`).join('');
}

document.querySelectorAll('#tbl th').forEach((th) => th.addEventListener('click', () => {
  if (sortKey === th.dataset.k) sortDir = -sortDir; else { sortKey = th.dataset.k; sortDir = th.classList.contains('n') ? -1 : 1; }
  fillTable();
}));
$('t-filter').addEventListener('input', fillTable);
$('tbl').addEventListener('click', (e) => {
  const tr = e.target.closest('tr.go');
  if (!tr) return;
  const r = rows.find((x) => x.id === tr.dataset.id);
  setMode('lens');
  $('sel-scroll').value = r.scroll;
  fillList();
  show(r);
});

function setMode(m) {
  document.querySelectorAll('.chip[data-mode]').forEach((b) => b.classList.toggle('on', b.dataset.mode === m));
  $('table-view').hidden = m !== 'table';
  cover();
  if (m === 'table') fillTable();
}
document.querySelectorAll('.chip[data-mode]').forEach((b) => b.addEventListener('click', () => setMode(b.dataset.mode)));

// ---------------------------------------------------------------- start

async function start() {
  try {
    const txt = await fetch(DATA + 'atlas.jsonl').then((r) => {
      if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); });
    rows = txt.split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
  } catch (err) {
    $('c-title').textContent = 'The atlas could not be loaded';
    $('c-note').textContent = String(err.message || err);
    return;
  }
  $('k-count').textContent = rows.length.toLocaleString();
  const sum = await fetch(DATA + 'summary.json').then((r) => (r.ok ? r.json() : null)).catch(() => null);
  $('t-meta').textContent = `${rows.filter((r) => r.kind === 'mesh').length} top-level meshes` +
    (sum ? ` · built ${sum.built} from kit ${sum.kit_commit}` : '') + ` · Segment Lens ${VERSION}`;
  fillScrolls();
  const list = fillList();
  const want = new URLSearchParams(location.hash.slice(1)).get('id');
  const pick = rows.find((r) => r.id === want) || list.find((r) => r.lens) || list[0];
  if (pick) show(pick);
}
// For agents and tests (headless Chromium): the loaded rows, the selected mesh and its preview's flag counts.
window.segmentLens = {
  version: VERSION,
  state: () => ({ rows: rows.length, selected: current ? current.id : null, loaded: !!lens,
    grid: lens ? [lens.w, lens.h, lens.k] : null }),
  rows: (scroll) => rows.filter((r) => !scroll || r.scroll === scroll),
  select: (id) => { const r = rows.find((x) => x.id === id); if (r) { $('sel-scroll').value = r.scroll; fillList(); show(r); } return !!r; },
  flags: () => {
    if (!lens) return null;
    const out = { hole: 0, fold: 0, stretched: 0, nodes: lens.flags.length };
    for (const f of lens.flags) { if (f & 1) out.hole++; if (f & 2) out.fold++; if (f & 4) out.stretched++; }
    return out;
  },
  command: () => (current ? kitCommand(current) : null),
};

start();