SharpMeow commited on
Commit
68de75e
·
verified ·
1 Parent(s): 998873d

Umbilici 1.13.0: hide line ends outside the scroll; lines from center lines v2.9 (from Scrolls main 48e9346)

Browse files
Files changed (6) hide show
  1. README.md +21 -5
  2. app.js +81 -22
  3. data/lines.json +0 -0
  4. index.html +15 -7
  5. tools/build_lines.py +51 -3
  6. tools/lineends.py +480 -0
README.md CHANGED
@@ -114,6 +114,21 @@ villa's source (`main`, read 23 Oct 2026):
114
  Because VC3D holds the ends and spiral-fitting extrapolates them, check a line's usable ends before relying on
115
  it near the top or bottom of a scan.
116
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  ## X-ray
118
 
119
  CT is not stored on this Space. Each visitor's browser reads coarse levels of the OME-Zarr volumes straight from
@@ -140,19 +155,20 @@ The page is driven from the console or a headless browser through `window.umbili
140
  | Call | Returns or does |
141
  | --- | --- |
142
  | `scans()` | every scan: scroll, scan id, volume, voxel size, shape, prize lists, sources, end grade |
143
- | `lines(scroll)` | that scan's lines with control points `[x, y, z]` in level-0 voxels |
144
  | `at(scroll, z)` | each line's `[x, y]` at height `z` (linear between control points) |
145
  | `select(scroll, mode)`, `mode('array' or 'scan' or 'lasagna')`, `setZ(z)` | change the view |
146
- | `state()` | mode, scan, height, CT streaming progress |
 
147
  | `image()`, `slice()` | PNG data URLs of the 3D view and the slice |
148
- | `stats(scroll)` | every statistic of each line (points, z range, coverage, steps, stray, tilt, wiggle, path, stamp) and the pair gaps |
149
  | `table('lines' or 'scans')`, `csv('lines' or 'scans' or 'points')` | the Data tables as objects or CSV text |
150
  | `guide(tab)`, `data(tab)` | open the guide (`basics`, `vc3d`, `research`, `data`, `credits`) or the Data tables |
151
  | `bench(n)` | renders `n` frames and returns milliseconds per frame and the pixel ratio |
152
- | `show(kind, name, on)` | shows or hides lines: `kind` `'source'` with a maker (`bucket`, `drobkov`, `tauil`, `dopico`, `finder`, `finder-polished`) or `'line'` with `SCROLL:SOURCE`; `on` true or false, omitted toggles; returns `{off}`, also kept in the URL as `off=` |
153
  | `settings(obj)` | read or change the viewer settings: `glow` (0 to 3), `radius` (0 to 1), `quality` (`auto`, `high`, `low`), `film`, `spin`, `inset` (the slice panel shown), `turn` (the slice turns with the 3D view) |
154
 
155
- URL parameters: `?mode=scan&scan=PHerc0191&z=10400`, `?mode=lasagna&scan=PHerc0175A&z=6400`, `?ex=5`,
156
  `?guide=vc3d`, `?data=lines`, `?glow=1.5`. The gear button holds the settings (glow strength and spread, quality,
157
  film grain, spin), saved in the browser.
158
  `data/lines.json` is the line table built at deploy by `tools/build_lines.py` from a copy of the dataset. On load the page reads the dataset's newest revision from huggingface.co and shows it instead, so new releases appear without a redeploy; the built table is the fallback.
 
114
  Because VC3D holds the ends and spiral-fitting extrapolates them, check a line's usable ends before relying on
115
  it near the top or bottom of a scan.
116
 
117
+ ## Ends outside the scroll
118
+
119
+ Since 1.13.0 each line is drawn from its first to its last point inside the scroll. `tools/build_lines.py` tests
120
+ every line's end points against the scan's masked CT at level 5 (32 times down; voxels outside the scroll are zero):
121
+ a point lies outside when, in its own axial slice, it sits more than 4 mm outside the scroll's outline (the convex
122
+ hull of the nonzero voxels) or the slice holds no scroll at all. At each end the hidden run reaches from the end to
123
+ the innermost outside point among the outer quarter of the line's points; the middle half is never hidden. Two
124
+ checks make the test able to fail: the line's middle point must be inside and the slice corner farthest from it
125
+ outside, or the line is drawn whole and marked untested. Each card says per end how many points are hidden and how
126
+ far past the scroll they lie, the Data tables have `low out` and `high out` columns, and the drift statistics
127
+ (mean, stray, tilt, wiggle, path, pair gaps) use the inside part. **show ends outside the scroll** at bottom left
128
+ (`outside=1` in the view link) draws the hidden runs faded and dotted. Some Center Finder v2 lines run tens of mm
129
+ sideways at an end, where their start line was never trimmed to the papyrus; their files are unchanged here, and
130
+ the downloads, `lines()` and the copied URLs keep every point.
131
+
132
  ## X-ray
133
 
134
  CT is not stored on this Space. Each visitor's browser reads coarse levels of the OME-Zarr volumes straight from
 
155
  | Call | Returns or does |
156
  | --- | --- |
157
  | `scans()` | every scan: scroll, scan id, volume, voxel size, shape, prize lists, sources, end grade |
158
+ | `lines(scroll)` | that scan's lines with control points `[x, y, z]` in level-0 voxels, every point of the file, and `outside` (points hidden at each end, or null with `outside_why`) |
159
  | `at(scroll, z)` | each line's `[x, y]` at height `z` (linear between control points) |
160
  | `select(scroll, mode)`, `mode('array' or 'scan' or 'lasagna')`, `setZ(z)` | change the view |
161
+ | `state()` | mode, scan, height, whether the ends outside the scroll are shown, CT streaming progress |
162
+ | `outside(on)` | shows (`true`) or hides (`false`) the line ends that lie outside the scroll in the CT, omitted toggles; returns `{outside}`, also kept in the URL as `outside=1` |
163
  | `image()`, `slice()` | PNG data URLs of the 3D view and the slice |
164
+ | `stats(scroll)` | every statistic of each line (points, z range, coverage, steps, points hidden at each end, stray, tilt, wiggle, path, stamp; the drift statistics on the inside part) and the pair gaps |
165
  | `table('lines' or 'scans')`, `csv('lines' or 'scans' or 'points')` | the Data tables as objects or CSV text |
166
  | `guide(tab)`, `data(tab)` | open the guide (`basics`, `vc3d`, `research`, `data`, `credits`) or the Data tables |
167
  | `bench(n)` | renders `n` frames and returns milliseconds per frame and the pixel ratio |
168
+ | `show(kind, name, on)` | shows or hides lines: `kind` `'source'` with a maker (`bucket`, `drobkov`, `tauil`, `dopico`, `finder`, `finder-polished`, `finder-v2`) or `'line'` with `SCROLL:SOURCE`; `on` true or false, omitted toggles; returns `{off}`, also kept in the URL as `off=` |
169
  | `settings(obj)` | read or change the viewer settings: `glow` (0 to 3), `radius` (0 to 1), `quality` (`auto`, `high`, `low`), `film`, `spin`, `inset` (the slice panel shown), `turn` (the slice turns with the 3D view) |
170
 
171
+ URL parameters: `?mode=scan&scan=PHerc0191&z=10400`, `?mode=lasagna&scan=PHerc0175A&z=6400`, `?ex=5`, `?outside=1`,
172
  `?guide=vc3d`, `?data=lines`, `?glow=1.5`. The gear button holds the settings (glow strength and spread, quality,
173
  film grain, spin), saved in the browser.
174
  `data/lines.json` is the line table built at deploy by `tools/build_lines.py` from a copy of the dataset. On load the page reads the dataset's newest revision from huggingface.co and shows it instead, so new releases appear without a redeploy; the built table is the fallback.
app.js CHANGED
@@ -7,7 +7,7 @@ import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
7
  import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
8
  import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
9
 
10
- const VERSION = '1.12.0';
11
  const DATASET = 'https://huggingface.co/datasets/SharpMeow/vesuvius-center-lines';
12
  const LASAGNA_SCROLL = 'PHerc0175A';
13
  const ICE = new THREE.Color('#89f0ff');
@@ -27,6 +27,7 @@ const $ = (id) => document.getElementById(id);
27
  const S = {
28
  data: null, las: null, mode: 'array', sel: null, hot: null, ex: 3, z: null,
29
  ct: true, render: 'xray', gain: 44, sheets: true, needles: true, ct2: false, ori: false, blend: 70,
 
30
  dirty: true, status: '',
31
  };
32
 
@@ -90,16 +91,25 @@ const mm = (s, v) => v * s.voxel_um / 1000;
90
  const fmt = (v, d = 1) => Number(v).toFixed(d);
91
  async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(`${r.status} ${url}`); return r.json(); }
92
  function linePts(l) { const p = l.points, out = []; for (let i = 0; i < p.length; i += 3) out.push([p[i], p[i + 1], p[i + 2]]); return out; }
93
- function lineAt(l, z) {
94
- const p = linePts(l);
95
- if (z < p[0][2] || z > p[p.length - 1][2]) return null;
 
 
 
 
 
 
 
 
96
  for (let i = 1; i < p.length; i++) {
97
  if (p[i][2] >= z) { const a = p[i - 1], b = p[i], t = b[2] === a[2] ? 0 : (z - a[2]) / (b[2] - a[2]); return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]; }
98
  }
99
  return null;
100
  }
101
- function lineMean(l) { const p = linePts(l); let x = 0, y = 0; for (const q of p) { x += q[0]; y += q[1]; } return [x / p.length, y / p.length]; }
102
- function drift(s, l) { const [mx, my] = lineMean(l); let d = 0; for (const q of linePts(l)) d = Math.max(d, Math.hypot(q[0] - mx, q[1] - my)); return mm(s, d); }
 
103
  const isFinder = (l) => l.source === 'finder';
104
  // an older version (superseded_by names the newer line's file) or a withdrawn line is drawn faded
105
  const isOld = (l) => !!l.superseded_by || l.note.includes('withdrawn');
@@ -155,8 +165,10 @@ const RAW = (l) => `${DATASET}/resolve/main/${l.file}`;
155
  const lic = (l) => l.licence;
156
  const median = (a) => { if (!a.length) return null; const b = [...a].sort((u, v) => u - v); const k = b.length >> 1; return b.length % 2 ? b[k] : (b[k - 1] + b[k]) / 2; };
157
  function lineStats(s, l) {
158
- const p = linePts(l), n = p.length;
159
- const dz = []; for (let i = 1; i < n; i++) dz.push(p[i][2] - p[i - 1][2]);
 
 
160
  const [mx, my] = lineMean(l);
161
  // least-squares x(z), y(z)
162
  let sz = 0, szz = 0, sx = 0, sy = 0, sxz = 0, syz = 0;
@@ -167,18 +179,21 @@ function lineStats(s, l) {
167
  for (let i = 0; i < n; i++) { const [x, y, z] = p[i]; r2 += (x - ax - bx * z) ** 2 + (y - ay - by * z) ** 2; if (i) path += Math.hypot(x - p[i - 1][0], y - p[i - 1][1], z - p[i - 1][2]); }
168
  const st = l.stamp || {};
169
  const stamped = st.volume_width === s.shape[2] && st.volume_height === s.shape[1] && st.volume_slices === s.shape[0];
170
- const z0 = p[0][2], z1 = p[n - 1][2];
171
  return {
172
- points: n, z_start: z0, z_end: z1, height_mm: mm(s, z1 - z0), coverage: (z1 - z0) / s.shape[0],
 
173
  median_step_vox: median(dz), max_step_vox: dz.length ? Math.max(...dz) : null,
174
  mean_x: mx, mean_y: my, off_center_mm: mm(s, Math.hypot(mx - s.shape[2] / 2, my - s.shape[1] / 2)),
175
  stray_mm: drift(s, l), tilt_deg: Math.atan(Math.hypot(bx, by)) * 180 / Math.PI, wiggle_mm: mm(s, Math.sqrt(r2 / n)),
176
- path_mm: mm(s, path), path_over_height: path / Math.max(1, z1 - z0), stamped,
177
  };
178
  }
 
179
  function pairStats(s, a, b) {
180
- const lo = Math.max(a.z[0], b.z[0]), hi = Math.min(a.z[1], b.z[1]); const g = [];
181
- for (let z = lo; z <= hi; z += 64) { const p = lineAt(a, z), q = lineAt(b, z); if (p && q) g.push(Math.hypot(p[0] - q[0], p[1] - q[1])); }
 
182
  if (!g.length) return null;
183
  return { a: a.source, b: b.source, overlap_mm: mm(s, hi - lo), samples: g.length, median_mm: mm(s, median(g)), min_mm: mm(s, Math.min(...g)), max_mm: mm(s, Math.max(...g)) };
184
  }
@@ -190,7 +205,7 @@ function lineRows() {
190
  const t = statsOf(s, l);
191
  return {
192
  scroll: s.scroll, scan: s.scan, volume: s.volume, voxel_um: s.voxel_um, source: l.source, maker: S.data.sources[l.source].label,
193
- kind: l.kind, grade: l.grade || '', ct_untested: !!l.untested, ct_end_low: endText(l, 'low'), ct_end_high: endText(l, 'high'), usable_ends: isFinder(l) ? (usable(l) || '') : '', withdrawn: l.note.includes('withdrawn'), superseded: !!l.superseded_by,
194
  ...t, license: lic(l), sha256: l.sha256, file: RAW(l), upstream: l.upstream, note: l.note,
195
  };
196
  }));
@@ -242,6 +257,25 @@ function tube(points3, color, radius, opacity = 1) {
242
  const m = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.35), transparent: true, opacity, depthWrite: true, toneMapped: false });
243
  const mesh = new THREE.Mesh(g, m); mesh.renderOrder = 3; mesh.userData.base = opacity; return mesh;
244
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  function ring(r, y, color, opacity, seg = 96) {
246
  const pts = []; for (let i = 0; i <= seg; i++) { const a = i / seg * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * r, y, Math.sin(a) * r)); }
247
  const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color, transparent: true, opacity, toneMapped: false }));
@@ -268,7 +302,7 @@ function buildArray() {
268
  for (const l of s.lines) {
269
  if (!shown(s, l)) continue;
270
  const pts = linePts(l).map(([x, y, z]) => [mm(s, x - cx) * S.ex, mm(s, z), mm(s, y - cy) * S.ex]);
271
- g.add(tube(pts, colOf(l), 0.9, isOld(l) ? 0.45 : 1));
272
  }
273
  const tint = hasFinder(s) ? AMBER : ICE;
274
  g.add(ring(26, 0, tint, 0.28)); g.add(ring(26, H, tint, 0.12));
@@ -455,7 +489,7 @@ function toWorld(s, x, y, z) { const [ox, oy] = scanOrigin(s); return [mm(s, x -
455
  const scanLines = new THREE.Group();
456
  function buildScanLines(s) {
457
  scanLines.clear();
458
- for (const l of s.lines) if (shown(s, l)) scanLines.add(tube(linePts(l).map(([x, y, z]) => toWorld(s, x, y, z)), colOf(l), 0.55, isOld(l) ? 0.5 : 1));
459
  S.dirty = true;
460
  }
461
  function buildScan(s) {
@@ -563,12 +597,15 @@ function drawMarks(sx, s, z, a = 0) {
563
  for (const l of s.lines) {
564
  if (!shown(s, l)) continue;
565
  const p = lineAt(l, z); if (!p) continue;
 
566
  const u = p[0] * kx - c, v = p[1] * ky - c; const x = c + u * ca - v * sa, y = c + u * sa + v * ca; const col = hexOf(l);
 
567
  sx.strokeStyle = col; sx.lineWidth = 1.6; sx.shadowColor = col; sx.shadowBlur = 10;
568
  sx.beginPath(); sx.arc(x, y, 9, 0, Math.PI * 2); sx.stroke();
569
  sx.beginPath(); sx.moveTo(x - 22, y); sx.lineTo(x - 12, y); sx.moveTo(x + 12, y); sx.lineTo(x + 22, y); sx.moveTo(x, y - 22); sx.lineTo(x, y - 12); sx.moveTo(x, y + 12); sx.lineTo(x, y + 22); sx.stroke();
570
  sx.fillStyle = col; sx.beginPath(); sx.arc(x, y, 1.6, 0, Math.PI * 2); sx.fill();
571
  sx.shadowBlur = 0; sx.font = '500 12px JetBrains Mono, monospace'; sx.fillText(S.data.sources[l.source].short, x + 14, y - 13);
 
572
  }
573
  }
574
  async function drawSlice() {
@@ -694,6 +731,19 @@ function buildPick() {
694
  $('c-pick').innerHTML = `<optgroup label="Prize page">${on.map(opt).join('')}</optgroup>` + (off.length ? `<optgroup label="Not on the prize page">${off.map(opt).join('')}</optgroup>` : '');
695
  }
696
  $('c-pick').addEventListener('change', (e) => { const name = e.target.value; select(name, S.mode === 'lasagna' && name !== LASAGNA_SCROLL ? 'scan' : S.mode === 'array' ? 'scan' : S.mode); e.target.blur(); });
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  function buildCard(s) {
698
  $('c-pick').value = s.scroll;
699
  $('c-kick').textContent = `SCAN ${s.scan} · ${s.voxel_um} µm · ${prizeWords(s)}`;
@@ -710,12 +760,13 @@ function buildCard(s) {
710
  const row = (k, v) => `<tr><th>${k}</th><td>${v}</td></tr>`;
711
  const k = lineKey(s, l);
712
  return `<div class="ln${shown(s, l) ? '' : ' off'}"><div class="who"><span class="name"><label class="show" title="show or hide this line"><input type="checkbox" data-line="${k}"${OFF.line.has(k) ? '' : ' checked'}${OFF.src.has(l.source) ? ' disabled' : ''}></label><b style="color:${hexOf(l)}">${src.label}</b></span><span class="tags">${ver}${grade}</span></div>
713
- <div class="meta">${t.points} points · z ${t.z_start} to ${t.z_end} (${fmt(t.height_mm, 0)} mm, ${fmt(t.coverage * 100, 0)}% of the scan) · strays up to ${fmt(t.stray_mm, 1)} mm from its mean · ${lic(l)}</div>${was}${note}
714
  <div class="links"><a href="${RAW(l)}" download>download</a><button class="lk" data-copy="${RAW(l)}" data-what="file URL copied">copy URL</button><button class="lk" data-copy="python -m kit centerline get ${s.scroll} --source ${l.source} --into YOUR_PROJECT_FOLDER" data-what="kit command copied: it writes YOUR_PROJECT_FOLDER/umbilicus.json" title="A command for the Scrolls kit that downloads this line, checks its sha256 and writes it as umbilicus.json where VC3D looks">copy kit command</button><a href="${DATASET}/blob/main/${l.file}" target="_blank" rel="noopener">view</a>${up}</div>
715
  <details class="full"><summary>every number</summary><table class="kv">
716
  ${row('credit', l.credit)}${row('kind', l.kind)}${row('points', t.points)}
717
  ${row('z range', `${t.z_start} to ${t.z_end} voxels, ${fmt(mm(s, t.z_start), 2)} to ${fmt(mm(s, t.z_end), 2)} mm`)}
718
  ${row('coverage', `${fmt(t.coverage * 100, 1)}% of ${s.shape[0]} slices`)}
 
719
  ${row('step', `median ${fmt(t.median_step_vox, 0)}, max ${fmt(t.max_step_vox, 0)} voxels (${fmt(mm(s, t.max_step_vox), 1)} mm)`)}
720
  ${row('mean x, y', `${fmt(t.mean_x, 0)}, ${fmt(t.mean_y, 0)} voxels, ${fmt(t.off_center_mm, 2)} mm off grid center`)}
721
  ${row('stray', `${fmt(t.stray_mm, 2)} mm`)}${row('tilt', `${fmt(t.tilt_deg, 2)}°`)}${row('wiggle', `${fmt(t.wiggle_mm, 3)} mm RMS`)}
@@ -733,12 +784,12 @@ function buildCard(s) {
733
  <div class="dim">every level ${s.pyramid[0].dtype === '|u1' ? 'uint8' : s.pyramid[0].dtype}, chunks ${s.pyramid[0].chunks.join(' x ')}, ${s.pyramid[0].compressor || 'no compressor'}</div>${streamable(s) ? '' : `<div class="note">This page reads one plane of an uncompressed chunk at a time, so it shows no CT for this scan; its line is drawn in the scan's frame. zarr, VC3D and the neuroglancer link read the compressed chunks.</div>`}`;
734
  $('c-vol').innerHTML = `CT <a href="${s.zarr}/.zattrs" target="_blank" rel="noopener">${s.volume}</a> · Vesuvius Challenge open data, CC BY-NC 4.0`;
735
  }
736
- function hereRows(s, z) { return s.lines.map((l) => { const p = lineAt(l, z); return p ? { source: l.source, x: p[0], y: p[1], z } : null; }).filter(Boolean); }
737
  function drawHere() {
738
  const s = S.sel; if (!s || S.z == null) return;
739
  const z = Math.round(S.z); const rows = hereRows(s, z);
740
  $('c-here').innerHTML = `<div class="mono small dim">AT z ${z} (${fmt(mm(s, z), 2)} mm), LEVEL-0 VOXELS</div>` + (rows.length
741
- ? rows.map((r) => `<div class="hr"><span class="${r.source.startsWith('finder') ? 'amber' : 'ice'}">${S.data.sources[r.source].short}</span> <code>${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${z}</code><button class="lk" data-copy="${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${z}" data-what="x, y, z copied">copy</button></div>`).join('')
742
  : '<div class="dim small">no line reaches this height</div>');
743
  }
744
  function setMode(m) {
@@ -777,6 +828,8 @@ $('b-connect').addEventListener('click', () => { markSeen(); openGuide('connect'
777
  let exQueued = false;
778
  $('ex').addEventListener('input', (e) => { S.ex = +e.target.value; $('ex-v').textContent = S.ex; if (!exQueued) { exQueued = true; requestAnimationFrame(() => { exQueued = false; buildArray(); }); } syncURLSoon(); });
779
  $('t-ct').addEventListener('change', (e) => { S.ct = e.target.checked; applyVolUniforms(); });
 
 
780
  $('t-ct2').addEventListener('change', (e) => { S.ct2 = e.target.checked; applyVolUniforms(); });
781
  $('render').addEventListener('change', (e) => { S.render = e.target.value; applyVolUniforms(); });
782
  $('gain').addEventListener('input', (e) => { S.gain = +e.target.value; applyVolUniforms(); });
@@ -851,7 +904,7 @@ $('about').querySelector('.tabs').addEventListener('click', (e) => { const b = e
851
 
852
  // data sheet
853
  const COLS = {
854
- lines: [['scroll', 'scroll'], ['maker', 'maker'], ['grade', 'grade'], ['ct_end_low', 'low end'], ['ct_end_high', 'high end'], ['points', 'pts'], ['z_start', 'z start'], ['z_end', 'z end'], ['height_mm', 'height mm'],
855
  ['coverage', 'cover'], ['median_step_vox', 'step'], ['max_step_vox', 'max step'], ['off_center_mm', 'off ctr mm'], ['stray_mm', 'stray mm'], ['tilt_deg', 'tilt °'],
856
  ['wiggle_mm', 'wiggle mm'], ['path_over_height', 'path/ht'], ['stamped', 'stamp'], ['usable_ends', 'usable ends'], ['superseded', 'superseded'], ['license', 'license'], ['sha256', 'sha256']],
857
  scans: [['scroll', 'scroll'], ['scan', 'scan'], ['voxel_um', 'µm'], ['slices', 'z'], ['height_px', 'y'], ['width_px', 'x'], ['height_mm', 'height mm'], ['width_mm', 'width mm'],
@@ -972,18 +1025,20 @@ function syncURL() {
972
  if (S.mode !== 'array') { q.set('mode', S.mode); q.set('scan', S.sel.scroll); q.set('z', Math.round(S.z)); }
973
  if (S.ex !== 3) q.set('ex', S.ex);
974
  const off = [...OFF.src, ...OFF.line]; if (off.length) q.set('off', off.join(','));
 
975
  history.replaceState(null, '', q.toString() ? `?${q}` : location.pathname);
976
  }
977
  window.umbilici = {
978
  version: () => VERSION,
979
  show: (kind, name, on) => showLines(kind, name, on),
 
980
  scans: () => S.data.scrolls.map((s) => ({ scroll: s.scroll, scan: s.scan, volume: s.volume, voxel_um: s.voxel_um, shape_zyx: s.shape, grand_prize: s.grand_prize, first_letters: s.first_letters, sources: s.lines.map((l) => l.source), grade: gradeOf(s) })),
981
  lines: (name) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? s.lines.map((l) => ({ ...l, points: linePts(l) })) : null; },
982
  at: (name, z) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? s.lines.map((l) => ({ source: l.source, xy: lineAt(l, z) })) : null; },
983
  select: (name, mode = 'scan') => select(name, mode),
984
  mode: (m) => setMode(m),
985
  setZ: (z) => setZ(z, true),
986
- state: () => ({ mode: S.mode, scan: S.sel?.scroll ?? null, z: S.z, ex: S.ex, ct: { loaded: vol?.loaded ?? 0, total: vol?.total ?? 0 }, render: S.render, pixel_ratio: renderer.getPixelRatio(), auto_level: autoLevel }),
987
  image: () => { composer.render(); return renderer.domElement.toDataURL('image/png'); },
988
  slice: () => sc.toDataURL('image/png'),
989
  stats: (name) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? { lines: s.lines.map((l) => ({ source: l.source, file: RAW(l), ...statsOf(s, l) })), pairs: pairsOf(s) } : null; },
@@ -1081,7 +1136,10 @@ async function liveLine(f, had, at) {
1081
  usable = use && typeof use === 'object' ? ['low', 'high'].filter((k) => use[k]) : null;
1082
  }
1083
  const l = Object.fromEntries(LINE_KEYS.map((k) => [k, f[k] ?? (k === 'note' ? '' : f[k])]));
1084
- return { ...l, grade, ends, untested: !!f.ct_untested_scan, usable, stamp, points, superseded_by: f.superseded_by ?? null };
 
 
 
1085
  }
1086
 
1087
  // ---------- boot ----------
@@ -1114,6 +1172,7 @@ async function liveLine(f, had, at) {
1114
  const q = new URLSearchParams(location.search);
1115
  if (q.get('ex')) { S.ex = Math.max(1, Math.min(30, +q.get('ex'))); $('ex').value = S.ex; $('ex-v').textContent = S.ex; }
1116
  for (const k of (q.get('off') || '').split(',').filter(Boolean)) (k.includes(':') ? OFF.line : OFF.src).add(k);
 
1117
  buildLegend();
1118
  applySettings(false); buildRail(); buildPick(); buildArray();
1119
  $('boot').remove();
 
7
  import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
8
  import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
9
 
10
+ const VERSION = '1.13.0';
11
  const DATASET = 'https://huggingface.co/datasets/SharpMeow/vesuvius-center-lines';
12
  const LASAGNA_SCROLL = 'PHerc0175A';
13
  const ICE = new THREE.Color('#89f0ff');
 
27
  const S = {
28
  data: null, las: null, mode: 'array', sel: null, hot: null, ex: 3, z: null,
29
  ct: true, render: 'xray', gain: 44, sheets: true, needles: true, ct2: false, ori: false, blend: 70,
30
+ outside: false, // draw the end runs that lie outside the scroll in the CT (hidden by default; URL outside=1)
31
  dirty: true, status: '',
32
  };
33
 
 
91
  const fmt = (v, d = 1) => Number(v).toFixed(d);
92
  async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(`${r.status} ${url}`); return r.json(); }
93
  function linePts(l) { const p = l.points, out = []; for (let i = 0; i < p.length; i += 3) out.push([p[i], p[i + 1], p[i + 2]]); return out; }
94
+ // The CT test of tools/build_lines.py (outside_ends): l.outside counts, per end, the control points from that end to
95
+ // the innermost one that lies outside the scroll in the CT; null when the line could not be tested (l.outside_why).
96
+ // Those end runs are hidden unless S.outside, and every drift statistic uses the inside part only. The file, its
97
+ // points and its sha256 are the dataset's, unchanged.
98
+ function cut(l) { const n = l.points.length / 3, o = l.outside; return o ? [o.low.hidden, n - o.high.hidden] : [0, n]; }
99
+ function insidePts(l) { const [a, b] = cut(l); return linePts(l).slice(a, b); }
100
+ // whether height z falls in a hidden end run of l
101
+ function outsideAt(l, z) { const o = l.outside; if (!o) return false; const p = l.points, n = p.length / 3, [a, b] = cut(l); return (a > 0 && z < p[3 * a + 2]) || (b < n && z > p[3 * (b - 1) + 2]); }
102
+ function lineAt(l, z, pts) {
103
+ const p = pts || linePts(l);
104
+ if (!p.length || z < p[0][2] || z > p[p.length - 1][2]) return null;
105
  for (let i = 1; i < p.length; i++) {
106
  if (p[i][2] >= z) { const a = p[i - 1], b = p[i], t = b[2] === a[2] ? 0 : (z - a[2]) / (b[2] - a[2]); return [a[0] + t * (b[0] - a[0]), a[1] + t * (b[1] - a[1])]; }
107
  }
108
  return null;
109
  }
110
+ // the mean and the stray use the inside part (insidePts), so an end that runs off past the scroll moves neither
111
+ function lineMean(l) { const p = insidePts(l); let x = 0, y = 0; for (const q of p) { x += q[0]; y += q[1]; } return [x / p.length, y / p.length]; }
112
+ function drift(s, l) { const [mx, my] = lineMean(l); let d = 0; for (const q of insidePts(l)) d = Math.max(d, Math.hypot(q[0] - mx, q[1] - my)); return mm(s, d); }
113
  const isFinder = (l) => l.source === 'finder';
114
  // an older version (superseded_by names the newer line's file) or a withdrawn line is drawn faded
115
  const isOld = (l) => !!l.superseded_by || l.note.includes('withdrawn');
 
165
  const lic = (l) => l.licence;
166
  const median = (a) => { if (!a.length) return null; const b = [...a].sort((u, v) => u - v); const k = b.length >> 1; return b.length % 2 ? b[k] : (b[k - 1] + b[k]) / 2; };
167
  function lineStats(s, l) {
168
+ // points, z range, coverage and steps describe the whole file; mean, stray, tilt, wiggle and path the inside part
169
+ const all = linePts(l), N = all.length;
170
+ const dz = []; for (let i = 1; i < N; i++) dz.push(all[i][2] - all[i - 1][2]);
171
+ const p = insidePts(l), n = p.length;
172
  const [mx, my] = lineMean(l);
173
  // least-squares x(z), y(z)
174
  let sz = 0, szz = 0, sx = 0, sy = 0, sxz = 0, syz = 0;
 
179
  for (let i = 0; i < n; i++) { const [x, y, z] = p[i]; r2 += (x - ax - bx * z) ** 2 + (y - ay - by * z) ** 2; if (i) path += Math.hypot(x - p[i - 1][0], y - p[i - 1][1], z - p[i - 1][2]); }
180
  const st = l.stamp || {};
181
  const stamped = st.volume_width === s.shape[2] && st.volume_height === s.shape[1] && st.volume_slices === s.shape[0];
182
+ const z0 = all[0][2], z1 = all[N - 1][2], o = l.outside;
183
  return {
184
+ points: N, z_start: z0, z_end: z1, height_mm: mm(s, z1 - z0), coverage: (z1 - z0) / s.shape[0],
185
+ outside_low: o ? o.low.hidden : null, outside_high: o ? o.high.hidden : null, inside_z_start: p[0][2], inside_z_end: p[n - 1][2],
186
  median_step_vox: median(dz), max_step_vox: dz.length ? Math.max(...dz) : null,
187
  mean_x: mx, mean_y: my, off_center_mm: mm(s, Math.hypot(mx - s.shape[2] / 2, my - s.shape[1] / 2)),
188
  stray_mm: drift(s, l), tilt_deg: Math.atan(Math.hypot(bx, by)) * 180 / Math.PI, wiggle_mm: mm(s, Math.sqrt(r2 / n)),
189
+ path_mm: mm(s, path), path_over_height: path / Math.max(1, p[n - 1][2] - p[0][2]), stamped,
190
  };
191
  }
192
+ // pair gaps over the heights where both lines' inside parts exist
193
  function pairStats(s, a, b) {
194
+ const pa = insidePts(a), pb = insidePts(b);
195
+ const lo = Math.max(pa[0][2], pb[0][2]), hi = Math.min(pa[pa.length - 1][2], pb[pb.length - 1][2]); const g = [];
196
+ for (let z = lo; z <= hi; z += 64) { const p = lineAt(a, z, pa), q = lineAt(b, z, pb); if (p && q) g.push(Math.hypot(p[0] - q[0], p[1] - q[1])); }
197
  if (!g.length) return null;
198
  return { a: a.source, b: b.source, overlap_mm: mm(s, hi - lo), samples: g.length, median_mm: mm(s, median(g)), min_mm: mm(s, Math.min(...g)), max_mm: mm(s, Math.max(...g)) };
199
  }
 
205
  const t = statsOf(s, l);
206
  return {
207
  scroll: s.scroll, scan: s.scan, volume: s.volume, voxel_um: s.voxel_um, source: l.source, maker: S.data.sources[l.source].label,
208
+ kind: l.kind, grade: l.grade || '', ct_untested: !!l.untested, ct_end_low: endText(l, 'low'), ct_end_high: endText(l, 'high'), outside_tested: !!l.outside, outside_why: l.outside_why || '', usable_ends: isFinder(l) ? (usable(l) || '') : '', withdrawn: l.note.includes('withdrawn'), superseded: !!l.superseded_by,
209
  ...t, license: lic(l), sha256: l.sha256, file: RAW(l), upstream: l.upstream, note: l.note,
210
  };
211
  }));
 
257
  const m = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.35), transparent: true, opacity, depthWrite: true, toneMapped: false });
258
  const mesh = new THREE.Mesh(g, m); mesh.renderOrder = 3; mesh.userData.base = opacity; return mesh;
259
  }
260
+ // a hidden end run when the viewer shows it: faded dots along the same curve, one draw call
261
+ function dots(points3, color, radius, opacity) {
262
+ const curve = new THREE.CatmullRomCurve3(points3.map((p) => new THREE.Vector3(...p)), false, 'centripetal');
263
+ const n = Math.max(2, Math.min(600, Math.floor(curve.getLength() / (radius * 3.2))));
264
+ const m = new THREE.MeshBasicMaterial({ color: color.clone().multiplyScalar(1.35), transparent: true, opacity, depthWrite: false, toneMapped: false });
265
+ const mesh = new THREE.InstancedMesh(new THREE.SphereGeometry(radius * 0.8, 8, 6), m, n);
266
+ const at = new THREE.Matrix4();
267
+ for (let i = 0; i < n; i++) mesh.setMatrixAt(i, at.makeTranslation(curve.getPointAt(i / (n - 1))));
268
+ mesh.renderOrder = 3; mesh.userData.base = opacity; return mesh;
269
+ }
270
+ // a line as drawn: its inside part as a tube, and, when S.outside, each hidden end run as dots from the last inside point
271
+ function lineMeshes(l, pts3, radius, opacity) {
272
+ const [a, b] = cut(l), out = [tube(pts3.slice(a, b), colOf(l), radius, opacity)];
273
+ if (S.outside) {
274
+ if (a > 0) out.push(dots(pts3.slice(0, a + 1), colOf(l), radius, opacity * 0.4));
275
+ if (b < pts3.length) out.push(dots(pts3.slice(b - 1), colOf(l), radius, opacity * 0.4));
276
+ }
277
+ return out;
278
+ }
279
  function ring(r, y, color, opacity, seg = 96) {
280
  const pts = []; for (let i = 0; i <= seg; i++) { const a = i / seg * Math.PI * 2; pts.push(new THREE.Vector3(Math.cos(a) * r, y, Math.sin(a) * r)); }
281
  const l = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color, transparent: true, opacity, toneMapped: false }));
 
302
  for (const l of s.lines) {
303
  if (!shown(s, l)) continue;
304
  const pts = linePts(l).map(([x, y, z]) => [mm(s, x - cx) * S.ex, mm(s, z), mm(s, y - cy) * S.ex]);
305
+ g.add(...lineMeshes(l, pts, 0.9, isOld(l) ? 0.45 : 1));
306
  }
307
  const tint = hasFinder(s) ? AMBER : ICE;
308
  g.add(ring(26, 0, tint, 0.28)); g.add(ring(26, H, tint, 0.12));
 
489
  const scanLines = new THREE.Group();
490
  function buildScanLines(s) {
491
  scanLines.clear();
492
+ for (const l of s.lines) if (shown(s, l)) scanLines.add(...lineMeshes(l, linePts(l).map(([x, y, z]) => toWorld(s, x, y, z)), 0.55, isOld(l) ? 0.5 : 1));
493
  S.dirty = true;
494
  }
495
  function buildScan(s) {
 
597
  for (const l of s.lines) {
598
  if (!shown(s, l)) continue;
599
  const p = lineAt(l, z); if (!p) continue;
600
+ const off = outsideAt(l, z); if (off && !S.outside) continue; // an end run outside the scroll: faded and dashed when shown
601
  const u = p[0] * kx - c, v = p[1] * ky - c; const x = c + u * ca - v * sa, y = c + u * sa + v * ca; const col = hexOf(l);
602
+ sx.globalAlpha = off ? 0.45 : 1; sx.setLineDash(off ? [3, 3] : []);
603
  sx.strokeStyle = col; sx.lineWidth = 1.6; sx.shadowColor = col; sx.shadowBlur = 10;
604
  sx.beginPath(); sx.arc(x, y, 9, 0, Math.PI * 2); sx.stroke();
605
  sx.beginPath(); sx.moveTo(x - 22, y); sx.lineTo(x - 12, y); sx.moveTo(x + 12, y); sx.lineTo(x + 22, y); sx.moveTo(x, y - 22); sx.lineTo(x, y - 12); sx.moveTo(x, y + 12); sx.lineTo(x, y + 22); sx.stroke();
606
  sx.fillStyle = col; sx.beginPath(); sx.arc(x, y, 1.6, 0, Math.PI * 2); sx.fill();
607
  sx.shadowBlur = 0; sx.font = '500 12px JetBrains Mono, monospace'; sx.fillText(S.data.sources[l.source].short, x + 14, y - 13);
608
+ sx.globalAlpha = 1; sx.setLineDash([]);
609
  }
610
  }
611
  async function drawSlice() {
 
731
  $('c-pick').innerHTML = `<optgroup label="Prize page">${on.map(opt).join('')}</optgroup>` + (off.length ? `<optgroup label="Not on the prize page">${off.map(opt).join('')}</optgroup>` : '');
732
  }
733
  $('c-pick').addEventListener('change', (e) => { const name = e.target.value; select(name, S.mode === 'lasagna' && name !== LASAGNA_SCROLL ? 'scan' : S.mode === 'array' ? 'scan' : S.mode); e.target.blur(); });
734
+ // what the CT test says about a line's ends, for its scan card
735
+ function outsideHTML(l) {
736
+ const o = l.outside;
737
+ if (!o) return `<div class="note">Ends not tested against the CT: ${l.outside_why || 'this copy of the line has no CT test'}. Drawn whole.</div>`;
738
+ const P = linePts(l), n = P.length;
739
+ const end = (k) => {
740
+ const e = o[k]; if (!e.hidden) return `${k} end: none`;
741
+ const z = k === 'low' ? [P[0][2], P[e.hidden - 1][2]] : [P[n - e.hidden][2], P[n - 1][2]];
742
+ return `${k} end: <b>${e.hidden} point${e.hidden === 1 ? '' : 's'}</b> hidden (z ${z[0]} to ${z[1]}), ${e.far} of them outside the scroll${e.max_mm != null ? `, up to ${fmt(e.max_mm, 1)} mm past it` : ''}${e.empty ? `, ${e.empty} in slices with no scroll at all` : ''}`;
743
+ };
744
+ if (!o.low.hidden && !o.high.hidden) return '<div class="note">CT: no end point lies outside the scroll.</div>';
745
+ return `<div class="note out">Outside the scroll in the CT: ${end('low')}; ${end('high')}. ${S.outside ? 'Shown faded and dotted.' : 'Hidden; tick <i>show ends outside the scroll</i> to see them.'} The file is unchanged.</div>`;
746
+ }
747
  function buildCard(s) {
748
  $('c-pick').value = s.scroll;
749
  $('c-kick').textContent = `SCAN ${s.scan} · ${s.voxel_um} µm · ${prizeWords(s)}`;
 
760
  const row = (k, v) => `<tr><th>${k}</th><td>${v}</td></tr>`;
761
  const k = lineKey(s, l);
762
  return `<div class="ln${shown(s, l) ? '' : ' off'}"><div class="who"><span class="name"><label class="show" title="show or hide this line"><input type="checkbox" data-line="${k}"${OFF.line.has(k) ? '' : ' checked'}${OFF.src.has(l.source) ? ' disabled' : ''}></label><b style="color:${hexOf(l)}">${src.label}</b></span><span class="tags">${ver}${grade}</span></div>
763
+ <div class="meta">${t.points} points · z ${t.z_start} to ${t.z_end} (${fmt(t.height_mm, 0)} mm, ${fmt(t.coverage * 100, 0)}% of the scan) · strays up to ${fmt(t.stray_mm, 1)} mm from its mean · ${lic(l)}</div>${was}${note}${outsideHTML(l)}
764
  <div class="links"><a href="${RAW(l)}" download>download</a><button class="lk" data-copy="${RAW(l)}" data-what="file URL copied">copy URL</button><button class="lk" data-copy="python -m kit centerline get ${s.scroll} --source ${l.source} --into YOUR_PROJECT_FOLDER" data-what="kit command copied: it writes YOUR_PROJECT_FOLDER/umbilicus.json" title="A command for the Scrolls kit that downloads this line, checks its sha256 and writes it as umbilicus.json where VC3D looks">copy kit command</button><a href="${DATASET}/blob/main/${l.file}" target="_blank" rel="noopener">view</a>${up}</div>
765
  <details class="full"><summary>every number</summary><table class="kv">
766
  ${row('credit', l.credit)}${row('kind', l.kind)}${row('points', t.points)}
767
  ${row('z range', `${t.z_start} to ${t.z_end} voxels, ${fmt(mm(s, t.z_start), 2)} to ${fmt(mm(s, t.z_end), 2)} mm`)}
768
  ${row('coverage', `${fmt(t.coverage * 100, 1)}% of ${s.shape[0]} slices`)}
769
+ ${row('inside the scroll', l.outside ? `z ${t.inside_z_start} to ${t.inside_z_end}; ${t.outside_low} low and ${t.outside_high} high end points hidden (CT level ${l.outside.level}, more than ${l.outside.far_mm} mm past the scroll's outline)` : `not tested: ${l.outside_why || 'no CT test'}`)}
770
  ${row('step', `median ${fmt(t.median_step_vox, 0)}, max ${fmt(t.max_step_vox, 0)} voxels (${fmt(mm(s, t.max_step_vox), 1)} mm)`)}
771
  ${row('mean x, y', `${fmt(t.mean_x, 0)}, ${fmt(t.mean_y, 0)} voxels, ${fmt(t.off_center_mm, 2)} mm off grid center`)}
772
  ${row('stray', `${fmt(t.stray_mm, 2)} mm`)}${row('tilt', `${fmt(t.tilt_deg, 2)}°`)}${row('wiggle', `${fmt(t.wiggle_mm, 3)} mm RMS`)}
 
784
  <div class="dim">every level ${s.pyramid[0].dtype === '|u1' ? 'uint8' : s.pyramid[0].dtype}, chunks ${s.pyramid[0].chunks.join(' x ')}, ${s.pyramid[0].compressor || 'no compressor'}</div>${streamable(s) ? '' : `<div class="note">This page reads one plane of an uncompressed chunk at a time, so it shows no CT for this scan; its line is drawn in the scan's frame. zarr, VC3D and the neuroglancer link read the compressed chunks.</div>`}`;
785
  $('c-vol').innerHTML = `CT <a href="${s.zarr}/.zattrs" target="_blank" rel="noopener">${s.volume}</a> · Vesuvius Challenge open data, CC BY-NC 4.0`;
786
  }
787
+ function hereRows(s, z) { return s.lines.map((l) => { const p = lineAt(l, z); return p ? { source: l.source, x: p[0], y: p[1], z, outside: outsideAt(l, z) } : null; }).filter(Boolean); }
788
  function drawHere() {
789
  const s = S.sel; if (!s || S.z == null) return;
790
  const z = Math.round(S.z); const rows = hereRows(s, z);
791
  $('c-here').innerHTML = `<div class="mono small dim">AT z ${z} (${fmt(mm(s, z), 2)} mm), LEVEL-0 VOXELS</div>` + (rows.length
792
+ ? rows.map((r) => `<div class="hr"><span class="${r.source.startsWith('finder') ? 'amber' : 'ice'}">${S.data.sources[r.source].short}</span> <code>${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${z}</code><button class="lk" data-copy="${fmt(r.x, 1)}, ${fmt(r.y, 1)}, ${z}" data-what="x, y, z copied">copy</button>${r.outside ? ' <span class="dim">outside the scroll</span>' : ''}</div>`).join('')
793
  : '<div class="dim small">no line reaches this height</div>');
794
  }
795
  function setMode(m) {
 
828
  let exQueued = false;
829
  $('ex').addEventListener('input', (e) => { S.ex = +e.target.value; $('ex-v').textContent = S.ex; if (!exQueued) { exQueued = true; requestAnimationFrame(() => { exQueued = false; buildArray(); }); } syncURLSoon(); });
830
  $('t-ct').addEventListener('change', (e) => { S.ct = e.target.checked; applyVolUniforms(); });
831
+ function setOutside(on) { S.outside = on === undefined ? !S.outside : !!on; $('t-outside').checked = S.outside; linesChanged(); return { outside: S.outside }; }
832
+ $('t-outside').addEventListener('change', (e) => setOutside(e.target.checked));
833
  $('t-ct2').addEventListener('change', (e) => { S.ct2 = e.target.checked; applyVolUniforms(); });
834
  $('render').addEventListener('change', (e) => { S.render = e.target.value; applyVolUniforms(); });
835
  $('gain').addEventListener('input', (e) => { S.gain = +e.target.value; applyVolUniforms(); });
 
904
 
905
  // data sheet
906
  const COLS = {
907
+ lines: [['scroll', 'scroll'], ['maker', 'maker'], ['grade', 'grade'], ['ct_end_low', 'low end'], ['ct_end_high', 'high end'], ['outside_low', 'low out'], ['outside_high', 'high out'], ['points', 'pts'], ['z_start', 'z start'], ['z_end', 'z end'], ['height_mm', 'height mm'],
908
  ['coverage', 'cover'], ['median_step_vox', 'step'], ['max_step_vox', 'max step'], ['off_center_mm', 'off ctr mm'], ['stray_mm', 'stray mm'], ['tilt_deg', 'tilt °'],
909
  ['wiggle_mm', 'wiggle mm'], ['path_over_height', 'path/ht'], ['stamped', 'stamp'], ['usable_ends', 'usable ends'], ['superseded', 'superseded'], ['license', 'license'], ['sha256', 'sha256']],
910
  scans: [['scroll', 'scroll'], ['scan', 'scan'], ['voxel_um', 'µm'], ['slices', 'z'], ['height_px', 'y'], ['width_px', 'x'], ['height_mm', 'height mm'], ['width_mm', 'width mm'],
 
1025
  if (S.mode !== 'array') { q.set('mode', S.mode); q.set('scan', S.sel.scroll); q.set('z', Math.round(S.z)); }
1026
  if (S.ex !== 3) q.set('ex', S.ex);
1027
  const off = [...OFF.src, ...OFF.line]; if (off.length) q.set('off', off.join(','));
1028
+ if (S.outside) q.set('outside', '1');
1029
  history.replaceState(null, '', q.toString() ? `?${q}` : location.pathname);
1030
  }
1031
  window.umbilici = {
1032
  version: () => VERSION,
1033
  show: (kind, name, on) => showLines(kind, name, on),
1034
+ outside: (on) => setOutside(on),
1035
  scans: () => S.data.scrolls.map((s) => ({ scroll: s.scroll, scan: s.scan, volume: s.volume, voxel_um: s.voxel_um, shape_zyx: s.shape, grand_prize: s.grand_prize, first_letters: s.first_letters, sources: s.lines.map((l) => l.source), grade: gradeOf(s) })),
1036
  lines: (name) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? s.lines.map((l) => ({ ...l, points: linePts(l) })) : null; },
1037
  at: (name, z) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? s.lines.map((l) => ({ source: l.source, xy: lineAt(l, z) })) : null; },
1038
  select: (name, mode = 'scan') => select(name, mode),
1039
  mode: (m) => setMode(m),
1040
  setZ: (z) => setZ(z, true),
1041
+ state: () => ({ mode: S.mode, scan: S.sel?.scroll ?? null, z: S.z, ex: S.ex, outside: S.outside, ct: { loaded: vol?.loaded ?? 0, total: vol?.total ?? 0 }, render: S.render, pixel_ratio: renderer.getPixelRatio(), auto_level: autoLevel }),
1042
  image: () => { composer.render(); return renderer.domElement.toDataURL('image/png'); },
1043
  slice: () => sc.toDataURL('image/png'),
1044
  stats: (name) => { const s = S.data.scrolls.find((q) => q.scroll === name); return s ? { lines: s.lines.map((l) => ({ source: l.source, file: RAW(l), ...statsOf(s, l) })), pairs: pairsOf(s) } : null; },
 
1136
  usable = use && typeof use === 'object' ? ['low', 'high'].filter((k) => use[k]) : null;
1137
  }
1138
  const l = Object.fromEntries(LINE_KEYS.map((k) => [k, f[k] ?? (k === 'note' ? '' : f[k])]));
1139
+ // the CT test of a line's ends runs when the page is built; a file newer than the build is drawn whole
1140
+ const outside = had ? had.outside ?? null : null;
1141
+ const outside_why = had ? had.outside_why ?? (outside ? null : 'this copy of the page was built before the CT test') : 'the file is newer than this page\'s build, which ran the CT test';
1142
+ return { ...l, grade, ends, untested: !!f.ct_untested_scan, usable, stamp, points, superseded_by: f.superseded_by ?? null, outside, outside_why };
1143
  }
1144
 
1145
  // ---------- boot ----------
 
1172
  const q = new URLSearchParams(location.search);
1173
  if (q.get('ex')) { S.ex = Math.max(1, Math.min(30, +q.get('ex'))); $('ex').value = S.ex; $('ex-v').textContent = S.ex; }
1174
  for (const k of (q.get('off') || '').split(',').filter(Boolean)) (k.includes(':') ? OFF.line : OFF.src).add(k);
1175
+ S.outside = q.get('outside') === '1'; $('t-outside').checked = S.outside;
1176
  buildLegend();
1177
  applySettings(false); buildRail(); buildPick(); buildArray();
1178
  $('boot').remove();
data/lines.json CHANGED
The diff for this file is too large to render. See raw diff
 
index.html CHANGED
@@ -191,6 +191,10 @@
191
  <label><input type="checkbox" id="t-ct2"> live CT</label>
192
  <span class="dim">villa lasagna predict3d, model output</span>
193
  </div>
 
 
 
 
194
  <div class="data" id="data-links">
195
  <span class="dim">DATA</span>
196
  <a href="https://huggingface.co/datasets/SharpMeow/vesuvius-center-lines" target="_blank" rel="noopener">vesuvius-center-lines</a>
@@ -230,6 +234,7 @@
230
  </ol>
231
  <h3>Colors and grades</h3>
232
  <p>Each maker has its own color: <span style="color:#89f0ff">Vesuvius Challenge</span>, <span style="color:#ff7eb6">Alexey Drobkov</span>, <span style="color:#b69cff">Abd Elilah</span>, <span style="color:#f2f5f7">Iyan Dopico</span>, all made by people. <span class="amber">Amber</span> lines are model output from Chase Hendrick's Center Finder, made for the nine 1.2 m prize scans nobody had traced and for nine scans not on the prize page (seven since 1.8.0, PHerc1667 and PHerc0172 since 1.10.0); no person has checked their ends on CT. <span style="color:#c6f36b">Lime</span> lines are the Center Finder lines after the coverage polish (Scrolls <code>kit axispolish</code>), marked NEWEST: the twelve on 1.2 m scans since 1.9.0, and since 1.10.0 three on 2.4 µm scans (PHerc1299, PHercMAN5, PHercMANB), where the polish ran on level 2 and is untested. The originals stay on the page, faded and marked SUPERSEDED, so a later polish can replace a line without losing the one before: on all ten scans of a frozen test the 1.2 m polished lines sat deeper in villa's traced sheet than the line before the polish, and kit takes them first, but they are jumpier and their ends were not re-checked, so the grades below are the original line's. An automatic CT end check graded each end of each Center Finder line (release v2.2, 23 Oct 2026). It compares the end with the scroll center the CT itself shows there, estimated from the scan alone (sheet orientations in three slices, never the line), 0, 1 and 2 mm inside the end, with a 4 mm bar. A planted fake tilt is caught at all 18 ends. 14 of the 18 ends pass and 4 are flagged. The same check on the seven newer lines (24 Oct 2026) passes all 14 ends, 2 after a CT recap, but four of those scans are 2.4 µm scans the check was never tuned on, so their grades are marked untested and no end there is usable. Of the two lines added in release v2.6 (25 Oct 2026), PHerc1667's (a 2.4 µm scan) passes both ends, untested, and PHerc0172's (a 7.91 µm scan) is flagged at both ends, so treat its ends with care.</p>
 
233
  <p>Scan names and grades use a traffic light: <span class="grade lt-good">green</span> when both ends passed, <span class="grade lt-caution">yellow</span> for caution (a pass that rests on a CT recap, an end the check could not judge, or an untested scan), <span class="grade lt-bad">red</span> when an end was flagged.</p>
234
  <ul>
235
  <li><span class="grade PASS">PASS</span> both ends passed.</li>
@@ -267,25 +272,28 @@
267
  <tr><th>z start, end</th><td>first and last control point height, level-0 voxels</td></tr>
268
  <tr><th>height</th><td>z end minus z start, in mm</td></tr>
269
  <tr><th>coverage</th><td>height as a share of the scan's z extent</td></tr>
 
 
270
  <tr><th>median, max step</th><td>median and largest z gap between neighboring control points, voxels; a big max step is a stretch the line interpolates across</td></tr>
271
- <tr><th>mean x, y</th><td>average position of the control points, voxels</td></tr>
272
  <tr><th>off center</th><td>distance from that mean to the middle of the scan grid, mm</td></tr>
273
- <tr><th>stray</th><td>largest distance of any control point from the mean, mm</td></tr>
274
- <tr><th>tilt</th><td>angle from vertical of a least-squares straight line through the points, degrees</td></tr>
275
- <tr><th>wiggle</th><td>RMS distance of the points from that straight line, mm</td></tr>
276
- <tr><th>path</th><td>length along the line, mm; path over height above 1 means it wanders</td></tr>
277
  <tr><th>stamped</th><td>whether the file's metadata names the voxel size and grid VC3D reads</td></tr>
278
- <tr><th>pair gap</th><td>for two lines on one scan: median, min and max horizontal distance between them every 64 voxels where both exist, mm</td></tr>
279
  </table>
280
  <h3>How to read them</h3>
281
  <ul>
282
  <li>Where a scan has two lines, the gap shows how far two independent tracings disagree. It does not say which one is closer to the true center.</li>
 
283
  <li>A line's own spread (stray, wiggle) is not an error. Scrolls are crushed and bent, so the center can wander; a straight line through a bent scroll would be wrong.</li>
284
  <li>The end check grade applies only to Center Finder lines. Person-made lines have no grade here; that is not a pass.</li>
285
  <li>The radiograph is a view, not a measurement: it uses scan level 5 (32 times coarser than level 0), with a z stride, and a display curve.</li>
286
  </ul>
287
  <h3>For agents</h3>
288
- <p>Everything is scriptable through <code>window.umbilici</code> in a browser or headless Chromium: <code>scans()</code>, <code>lines(scroll)</code>, <code>at(scroll, z)</code>, <code>stats(scroll)</code>, <code>table()</code>, <code>csv('lines' | 'scans' | 'points')</code>, <code>select(scroll, mode)</code>, <code>mode(m)</code>, <code>setZ(z)</code>, <code>state()</code>, <code>image()</code>, <code>slice()</code>. URL parameters: <code>?mode=scan&amp;scan=PHerc0191&amp;z=10400</code>, <code>?guide=vc3d</code>, <code>?data=lines</code>.</p>
289
  </section>
290
 
291
  <section data-pane="data" hidden>
 
191
  <label><input type="checkbox" id="t-ct2"> live CT</label>
192
  <span class="dim">villa lasagna predict3d, model output</span>
193
  </div>
194
+ <div class="ctl" id="ctl-out">
195
+ <label title="Line ends whose control points lie outside the scroll in the CT are hidden by default; tick to draw them faded and dotted. The files are unchanged."><input type="checkbox" id="t-outside"> show ends outside the scroll</label>
196
+ <span class="dim">hidden by default: points the CT puts outside the scroll</span>
197
+ </div>
198
  <div class="data" id="data-links">
199
  <span class="dim">DATA</span>
200
  <a href="https://huggingface.co/datasets/SharpMeow/vesuvius-center-lines" target="_blank" rel="noopener">vesuvius-center-lines</a>
 
234
  </ol>
235
  <h3>Colors and grades</h3>
236
  <p>Each maker has its own color: <span style="color:#89f0ff">Vesuvius Challenge</span>, <span style="color:#ff7eb6">Alexey Drobkov</span>, <span style="color:#b69cff">Abd Elilah</span>, <span style="color:#f2f5f7">Iyan Dopico</span>, all made by people. <span class="amber">Amber</span> lines are model output from Chase Hendrick's Center Finder, made for the nine 1.2 m prize scans nobody had traced and for nine scans not on the prize page (seven since 1.8.0, PHerc1667 and PHerc0172 since 1.10.0); no person has checked their ends on CT. <span style="color:#c6f36b">Lime</span> lines are the Center Finder lines after the coverage polish (Scrolls <code>kit axispolish</code>), marked NEWEST: the twelve on 1.2 m scans since 1.9.0, and since 1.10.0 three on 2.4 µm scans (PHerc1299, PHercMAN5, PHercMANB), where the polish ran on level 2 and is untested. The originals stay on the page, faded and marked SUPERSEDED, so a later polish can replace a line without losing the one before: on all ten scans of a frozen test the 1.2 m polished lines sat deeper in villa's traced sheet than the line before the polish, and kit takes them first, but they are jumpier and their ends were not re-checked, so the grades below are the original line's. An automatic CT end check graded each end of each Center Finder line (release v2.2, 23 Oct 2026). It compares the end with the scroll center the CT itself shows there, estimated from the scan alone (sheet orientations in three slices, never the line), 0, 1 and 2 mm inside the end, with a 4 mm bar. A planted fake tilt is caught at all 18 ends. 14 of the 18 ends pass and 4 are flagged. The same check on the seven newer lines (24 Oct 2026) passes all 14 ends, 2 after a CT recap, but four of those scans are 2.4 µm scans the check was never tuned on, so their grades are marked untested and no end there is usable. Of the two lines added in release v2.6 (25 Oct 2026), PHerc1667's (a 2.4 µm scan) passes both ends, untested, and PHerc0172's (a 7.91 µm scan) is flagged at both ends, so treat its ends with care.</p>
237
+ <p>Since 1.13.0 a line is drawn from its first to its last point inside the scroll. Where its end runs outside the scroll in the CT (more than 4 mm outside the scroll's outline in that slice, or in a slice with no scroll at all), those end points are hidden, the scan card says how many at each end, and <b>show ends outside the scroll</b> at bottom left draws them faded and dotted. The files are unchanged; For researchers has the rule.</p>
238
  <p>Scan names and grades use a traffic light: <span class="grade lt-good">green</span> when both ends passed, <span class="grade lt-caution">yellow</span> for caution (a pass that rests on a CT recap, an end the check could not judge, or an untested scan), <span class="grade lt-bad">red</span> when an end was flagged.</p>
239
  <ul>
240
  <li><span class="grade PASS">PASS</span> both ends passed.</li>
 
272
  <tr><th>z start, end</th><td>first and last control point height, level-0 voxels</td></tr>
273
  <tr><th>height</th><td>z end minus z start, in mm</td></tr>
274
  <tr><th>coverage</th><td>height as a share of the scan's z extent</td></tr>
275
+ <tr><th>low out, high out</th><td>control points hidden at the low and the high end because the line runs outside the scroll there, from the line's end to its innermost point that lies outside (blank: not tested). A point lies outside when, in its own axial slice of the scan's masked CT at level 5 (32 times coarser than level 0; voxels outside the scroll are zero), it sits more than 4 mm outside the outline (convex hull) of the scroll, or the slice holds no scroll at all. Only the outer quarter of a line's points at each end can be hidden; the middle half never is. The test runs when the page is built (<code>tools/build_lines.py</code>) and passes two checks per line, or the line is drawn whole and marked untested: its middle point must be inside, and the slice corner farthest from it outside. The files, their points and sha256 stay the dataset's.</td></tr>
276
+ <tr><th>inside part</th><td>the line without its hidden end runs; every statistic below from mean x, y on uses it, so an end that runs off past the scroll does not count as drift</td></tr>
277
  <tr><th>median, max step</th><td>median and largest z gap between neighboring control points, voxels; a big max step is a stretch the line interpolates across</td></tr>
278
+ <tr><th>mean x, y</th><td>average position of the inside part's control points, voxels</td></tr>
279
  <tr><th>off center</th><td>distance from that mean to the middle of the scan grid, mm</td></tr>
280
+ <tr><th>stray</th><td>largest distance of any control point of the inside part from the mean, mm</td></tr>
281
+ <tr><th>tilt</th><td>angle from vertical of a least-squares straight line through the inside part's points, degrees</td></tr>
282
+ <tr><th>wiggle</th><td>RMS distance of the inside part's points from that straight line, mm</td></tr>
283
+ <tr><th>path</th><td>length along the inside part, mm; path over its height above 1 means it wanders</td></tr>
284
  <tr><th>stamped</th><td>whether the file's metadata names the voxel size and grid VC3D reads</td></tr>
285
+ <tr><th>pair gap</th><td>for two lines on one scan: median, min and max horizontal distance between them every 64 voxels where both inside parts exist, mm</td></tr>
286
  </table>
287
  <h3>How to read them</h3>
288
  <ul>
289
  <li>Where a scan has two lines, the gap shows how far two independent tracings disagree. It does not say which one is closer to the true center.</li>
290
+ <li>A hidden end (low out, high out) says only that those control points lie outside the scroll in the published mask at level 5; it does not say where the center is there. Some lines start their search over the mount or cap slices, where no scroll is, and run off sideways; people's lines can also overshoot a tapering end by a few millimeters. The raw files keep every point: the download links, <code>lines()</code> and the copied URLs give the dataset's file unchanged.</li>
291
  <li>A line's own spread (stray, wiggle) is not an error. Scrolls are crushed and bent, so the center can wander; a straight line through a bent scroll would be wrong.</li>
292
  <li>The end check grade applies only to Center Finder lines. Person-made lines have no grade here; that is not a pass.</li>
293
  <li>The radiograph is a view, not a measurement: it uses scan level 5 (32 times coarser than level 0), with a z stride, and a display curve.</li>
294
  </ul>
295
  <h3>For agents</h3>
296
+ <p>Everything is scriptable through <code>window.umbilici</code> in a browser or headless Chromium: <code>scans()</code>, <code>lines(scroll)</code>, <code>at(scroll, z)</code>, <code>stats(scroll)</code>, <code>table()</code>, <code>csv('lines' | 'scans' | 'points')</code>, <code>select(scroll, mode)</code>, <code>mode(m)</code>, <code>outside(on)</code>, <code>setZ(z)</code>, <code>state()</code>, <code>image()</code>, <code>slice()</code>. URL parameters: <code>?mode=scan&amp;scan=PHerc0191&amp;z=10400</code>, <code>&amp;outside=1</code> (show the ends outside the scroll), <code>?guide=vc3d</code>, <code>?data=lines</code>.</p>
297
  </section>
298
 
299
  <section data-pane="data" hidden>
tools/build_lines.py CHANGED
@@ -1,12 +1,19 @@
1
  """Build data/lines.json for the Space from the public center-lines dataset folder.
2
 
3
- Usage: python3 tools/build_lines.py DATASET_DIR OUT.json
4
  DATASET_DIR is a copy of SharpMeow/vesuvius-center-lines (index.json plus lines/). Only the control points, the
5
  public fields of index.json (with each Center Finder line's per-end CT end check, ct_ends, ct_untested_scan
6
  as untested, and superseded_by, the newer version of a line), each file's frame
7
  stamp (voxel size, volume name and grid) and its usable_ends are copied; the rest of each file's metadata is not. Scan shapes are read from the
8
  Vesuvius Challenge open-data bucket.
 
 
 
 
 
 
9
  """
 
10
  import json
11
  import re
12
  import sys
@@ -33,8 +40,34 @@ def get_json(url):
33
  return json.load(r)
34
 
35
 
36
- def main(src, out):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  src = Path(src)
 
38
  ix = json.loads((src / "index.json").read_text())
39
  vol_of = {}
40
  for f in ix["files"]:
@@ -55,6 +88,15 @@ def main(src, out):
55
  "compressor": comp.get("id") if comp else None})
56
  zarr0 = pyramid[0]
57
  um = float(re.search(r"-([0-9.]+)um-", vol).group(1))
 
 
 
 
 
 
 
 
 
58
  lines = []
59
  for f in ix["files"]:
60
  if f["scroll"] != name:
@@ -66,6 +108,7 @@ def main(src, out):
66
  flat = []
67
  for p in sorted(pts, key=lambda p: p["z"]):
68
  flat += [round(p["x"]), round(p["y"]), round(p["z"])]
 
69
  # v2.2 and later grade each end on CT (index ct_ends); a line is FLAG if either end is. Older releases
70
  # carried one grade in the note.
71
  ends = {k: {q: e[q] for q in ("grade", "recapped", "D_mm") if q in e} for k, e in (f.get("ct_ends") or {}).items()}
@@ -87,6 +130,9 @@ def main(src, out):
87
  "stamp": stamp, "points": flat,
88
  # v2.5 and later: the file of the newer version of this line, if there is one (the page fades it)
89
  "superseded_by": f.get("superseded_by"),
 
 
 
90
  })
91
  lines.sort(key=lambda l: list(SOURCES).index(l["source"]))
92
  scrolls.append({
@@ -94,7 +140,9 @@ def main(src, out):
94
  "shape": zarr0["shape"], "levels": len(levels), "pyramid": pyramid,
95
  "first_letters": s["first_letters"], "grand_prize": s["grand_prize"], "lines": lines,
96
  })
97
- print(name, vol, zarr0["shape"], len(levels), [l["source"] for l in lines])
 
 
98
  Path(out).write_text(json.dumps({"made": ix["made"], "frame": ix["frame"], "sources": SOURCES,
99
  "scrolls": scrolls}, separators=(",", ":")))
100
 
 
1
  """Build data/lines.json for the Space from the public center-lines dataset folder.
2
 
3
+ Usage: python3 tools/build_lines.py DATASET_DIR OUT.json [--no-ct]
4
  DATASET_DIR is a copy of SharpMeow/vesuvius-center-lines (index.json plus lines/). Only the control points, the
5
  public fields of index.json (with each Center Finder line's per-end CT end check, ct_ends, ct_untested_scan
6
  as untested, and superseded_by, the newer version of a line), each file's frame
7
  stamp (voxel size, volume name and grid) and its usable_ends are copied; the rest of each file's metadata is not. Scan shapes are read from the
8
  Vesuvius Challenge open-data bucket.
9
+
10
+ Since 1.13.0 each line also gets "outside": how many control points at each end lie outside the scroll in the CT,
11
+ by the test in outside_ends() of the Center Finder's scripts/umbilicus/lineends.py (the one copy of the
12
+ test), read from level 5 of the scan's masked OME-Zarr in the same bucket (about 16 KB per range read). The page hides
13
+ those end runs by default. The test never changes a line's points, file or sha256. A line it cannot test gets
14
+ "outside": null and the reason in "outside_why"; --no-ct skips the test.
15
  """
16
+ import importlib.util
17
  import json
18
  import re
19
  import sys
 
40
  return json.load(r)
41
 
42
 
43
+ # ---------- the CT test for line ends ----------
44
+ # The test lives in the Center Finder (scripts/umbilicus/lineends.py, whose trim command cuts the same end runs from a
45
+ # line file), so the page hides exactly what a trim would cut. It is loaded from its file, not through sys.path, so
46
+ # the Center Finder's other generic module names never shadow anything: tools/lineends.py beside this file when there
47
+ # is one (the Space ships a copy there), else the repository's.
48
+ def _load_lineends():
49
+ name = "umbilicus_lineends"
50
+ if name not in sys.modules:
51
+ here = Path(__file__).resolve().parent
52
+ path = here / "lineends.py"
53
+ if not path.exists():
54
+ path = here.parents[2] / "scripts" / "umbilicus" / "lineends.py"
55
+ spec = importlib.util.spec_from_file_location(name, path)
56
+ mod = importlib.util.module_from_spec(spec)
57
+ sys.modules[name] = mod
58
+ spec.loader.exec_module(mod)
59
+ return sys.modules[name]
60
+
61
+
62
+ _le = _load_lineends()
63
+ CORNER, END_SHARE, FAR_MM, LEVEL = _le.CORNER, _le.END_SHARE, _le.FAR_MM, _le.LEVEL
64
+ Slices, Volume, hull, hull_distance = _le.Slices, _le.Volume, _le.hull, _le.hull_distance
65
+ check_line, open_slices, outside_ends = _le.check_line, _le.open_slices, _le.outside_ends
66
+
67
+
68
+ def main(src, out, *flags):
69
  src = Path(src)
70
+ ct = "--no-ct" not in flags
71
  ix = json.loads((src / "index.json").read_text())
72
  vol_of = {}
73
  for f in ix["files"]:
 
88
  "compressor": comp.get("id") if comp else None})
89
  zarr0 = pyramid[0]
90
  um = float(re.search(r"-([0-9.]+)um-", vol).group(1))
91
+ if not ct:
92
+ scan = (None, "the page was built without the CT test (--no-ct)")
93
+ else:
94
+ try:
95
+ scan = open_slices(base, pyramid, um)
96
+ except ImportError:
97
+ scan = (None, "this scan's chunks are compressed and the build had no numcodecs to read them")
98
+ except Exception as e:
99
+ scan = (None, f"the scan's CT could not be opened ({type(e).__name__})")
100
  lines = []
101
  for f in ix["files"]:
102
  if f["scroll"] != name:
 
108
  flat = []
109
  for p in sorted(pts, key=lambda p: p["z"]):
110
  flat += [round(p["x"]), round(p["y"]), round(p["z"])]
111
+ outside, why = check_line([tuple(flat[i:i + 3]) for i in range(0, len(flat), 3)], scan)
112
  # v2.2 and later grade each end on CT (index ct_ends); a line is FLAG if either end is. Older releases
113
  # carried one grade in the note.
114
  ends = {k: {q: e[q] for q in ("grade", "recapped", "D_mm") if q in e} for k, e in (f.get("ct_ends") or {}).items()}
 
130
  "stamp": stamp, "points": flat,
131
  # v2.5 and later: the file of the newer version of this line, if there is one (the page fades it)
132
  "superseded_by": f.get("superseded_by"),
133
+ # 1.13.0: the end runs that lie outside the scroll in the CT (outside_ends); null with the reason
134
+ # when the line could not be tested
135
+ "outside": outside, "outside_why": why,
136
  })
137
  lines.sort(key=lambda l: list(SOURCES).index(l["source"]))
138
  scrolls.append({
 
140
  "shape": zarr0["shape"], "levels": len(levels), "pyramid": pyramid,
141
  "first_letters": s["first_letters"], "grand_prize": s["grand_prize"], "lines": lines,
142
  })
143
+ if scan[0] is not None:
144
+ scan[0].vol.cache.clear()
145
+ print(name, vol, zarr0["shape"], len(levels), [(l["source"], l["outside"] and (l["outside"]["low"]["hidden"], l["outside"]["high"]["hidden"])) for l in lines], flush=True)
146
  Path(out).write_text(json.dumps({"made": ix["made"], "frame": ix["frame"], "sources": SOURCES,
147
  "scrolls": scrolls}, separators=(",", ":")))
148
 
tools/lineends.py ADDED
@@ -0,0 +1,480 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Center line ends against the scroll's outline in the masked CT: find the end runs that leave the papyrus and trim them.
2
+
3
+ python scripts/umbilicus/lineends.py check FILE [FILE ...] [--volume SCROLL/volumes/VOL] [--report R.json] [--json]
4
+ python scripts/umbilicus/lineends.py trim FILE [FILE ...] --out DIR [--volume SCROLL/volumes/VOL] [--report R.json]
5
+
6
+ FILE is an umbilicus file in the published format (control_points x, y, z in level-0 voxels of its scan). Its scan
7
+ is metadata.source_volume ("SCROLL/volumes/VOL.zarr", as the Center Finder writes it) unless --volume names it. The
8
+ scan must be a masked OME-Zarr in the Vesuvius Challenge open-data bucket: voxels outside the scroll are zero.
9
+
10
+ The test (outside_ends) reads level 5 of the scan (32 times down) by HTTP range requests. A control point lies
11
+ outside the scroll when, in its own axial slice, no nonzero voxel lies within FAR_MM (4 mm) of it and it sits more
12
+ than FAR_MM outside the convex hull of the slice's nonzero voxels, or the slice has no nonzero voxel at all. Only the
13
+ outer END_SHARE (a quarter) of a line's points at each end can be cut: at each end the cut reaches from the end to
14
+ the innermost outside point in that zone, so an excursion that comes back inside is cut with it, and the middle half
15
+ of a line is never cut. Two checks make the test able to fail; a line that fails either is left whole and reported
16
+ untested: its middle point must be inside (else the frame or the mask is wrong) and the slice corner farthest from
17
+ it must be outside (else the scan's air is not zero).
18
+
19
+ check prints, per file, the points and the height each end would lose; writes nothing but the --report JSON.
20
+ trim also writes DIR/<file name> for every file: the same JSON with the cut end points removed and
21
+ metadata.papyrus_trim saying what was cut and by which rule. A file with nothing to cut, or untested, is
22
+ written unchanged except for that block. Points keep their order and every key they had.
23
+
24
+ The same test hides the end runs in the Umbilici Space (spaces/umbilici/tools/build_lines.py imports it from here),
25
+ so a trimmed line shows whole there. It measures the line against the published mask, not where the center is: a
26
+ trimmed line is still model output. Standard library only (numcodecs to read a scan stored with compressed chunks).
27
+ """
28
+ import argparse
29
+ import json
30
+ import math
31
+ import re
32
+ import sys
33
+ import threading
34
+ import urllib.error
35
+ import urllib.request
36
+ from concurrent.futures import ThreadPoolExecutor
37
+ from pathlib import Path
38
+
39
+ S3 = "https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com"
40
+ VERSION = "1.0.0"
41
+
42
+ # A control point lies outside the scroll when, in its own axial slice of the scan's masked CT (voxels outside the
43
+ # scroll are zero), it sits more than FAR_MM outside the convex hull of the nonzero voxels, or the slice has no
44
+ # nonzero voxel at all. The hull, not the nearest voxel, is the bar because a scroll's ends taper and break into
45
+ # fragments: a point between two fragments is near no voxel but still inside the scroll's outline (a survey of all 57
46
+ # shown lines on 4 Nov 2026 found such gaps of up to 10.9 mm on people's lines, all inside the hull).
47
+ # Only the outer END_SHARE of a line's points at each end can be hidden: at each end the hidden run reaches from the
48
+ # end to the innermost outside point in that zone, so an excursion a few points in from an end that comes back inside
49
+ # is hidden with it. The middle half of a line is never hidden. Two built-in checks make the test able to fail, and
50
+ # a line whose checks fail is drawn whole and marked untested: the line's middle point must be inside (else the frame
51
+ # or the mask is wrong), and the slice corner farthest from it must be outside (else the scan's air is not zero).
52
+ LEVEL = 5 # 32 times down: 0.28 to 0.30 mm voxels on the 1.2 m scans, 77 um on the 2.4 um scans
53
+ FAR_MM = 4.0
54
+ END_SHARE = 0.25
55
+ CORNER = 0.02 # the corner control sits this share of the width in from each edge
56
+ RULE = ("end points cut where they leave the scroll in the masked CT (level 5): no nonzero voxel within 4 mm in "
57
+ "the point's slice and more than 4 mm outside the convex hull of the slice's nonzero voxels, or a slice "
58
+ "with no scroll; only the outer quarter of points at each end can be cut, from the end to the innermost "
59
+ "outside point; the middle point must be inside and the slice corner farthest from it outside, else nothing "
60
+ "is cut")
61
+
62
+
63
+ def get_json(url):
64
+ with urllib.request.urlopen(url, timeout=30) as r:
65
+ return json.load(r)
66
+
67
+
68
+ class Volume:
69
+ """One pyramid level of a uint8 OME-Zarr: shape (z, y, x), read one axial plane of a chunk at a time.
70
+
71
+ tile(zi, cy, cx) returns that plane of chunk (zi // cz, cy, cx) as bytes (cy_size * cx_size), or None for a
72
+ missing chunk, which reads as zeros (the masked scans' fill value)."""
73
+
74
+ def __init__(self, shape, chunks, tile, threads=1):
75
+ self.shape, self.chunks, self._tile, self.threads = list(shape), list(chunks), tile, threads
76
+ self.cache, self.lock = {}, threading.Lock()
77
+
78
+ def tiles(self, zi, y0, y1, x0, x1):
79
+ _, ch_y, ch_x = self.chunks
80
+ if not 0 <= zi < self.shape[0]:
81
+ return []
82
+ y0, y1 = max(0, y0), min(self.shape[1], y1)
83
+ x0, x1 = max(0, x0), min(self.shape[2], x1)
84
+ if y0 >= y1 or x0 >= x1:
85
+ return []
86
+ return [(zi, a, b) for a in range(y0 // ch_y, (y1 - 1) // ch_y + 1) for b in range(x0 // ch_x, (x1 - 1) // ch_x + 1)]
87
+
88
+ def prefetch(self, keys):
89
+ todo = sorted(set(keys) - set(self.cache))
90
+ if not todo:
91
+ return
92
+ def one(k):
93
+ v = self._tile(*k)
94
+ with self.lock:
95
+ self.cache[k] = v
96
+ if self.threads > 1 and len(todo) > 1:
97
+ with ThreadPoolExecutor(self.threads) as ex:
98
+ list(ex.map(one, todo))
99
+ else:
100
+ for k in todo:
101
+ one(k)
102
+
103
+ def rows(self, zi, y0, y1, x0, x1):
104
+ """Rows y0..y1-1 of slice zi between x0 and x1, as bytes, zero outside the volume."""
105
+ _, ch_y, ch_x = self.chunks
106
+ self.prefetch(self.tiles(zi, y0, y1, x0, x1))
107
+ out = []
108
+ for y in range(y0, y1):
109
+ if not (0 <= zi < self.shape[0] and 0 <= y < self.shape[1]):
110
+ out.append(bytes(x1 - x0))
111
+ continue
112
+ row = bytearray(x1 - x0)
113
+ xa, xb = max(0, x0), min(self.shape[2], x1)
114
+ for b in (range(xa // ch_x, (xb - 1) // ch_x + 1) if xa < xb else ()):
115
+ t = self.cache.get((zi, y // ch_y, b))
116
+ if t is None:
117
+ continue
118
+ tx0 = b * ch_x
119
+ a0, a1 = max(x0, tx0), min(x1, tx0 + ch_x, self.shape[2])
120
+ r = (y % ch_y) * ch_x
121
+ row[a0 - x0:a1 - x0] = t[r + a0 - tx0:r + a1 - tx0]
122
+ out.append(bytes(row))
123
+ return out
124
+
125
+
126
+ def _cross(o, a, b):
127
+ return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0])
128
+
129
+
130
+ def hull(pts):
131
+ """Convex hull, counterclockwise, of (x, y) points (Andrew's monotone chain)."""
132
+ pts = sorted(set(pts))
133
+ if len(pts) <= 2:
134
+ return pts
135
+ lo, hi = [], []
136
+ for p in pts:
137
+ while len(lo) >= 2 and _cross(lo[-2], lo[-1], p) <= 0:
138
+ lo.pop()
139
+ lo.append(p)
140
+ for p in reversed(pts):
141
+ while len(hi) >= 2 and _cross(hi[-2], hi[-1], p) <= 0:
142
+ hi.pop()
143
+ hi.append(p)
144
+ return lo[:-1] + hi[:-1]
145
+
146
+
147
+ def _seg_dist(p, a, b):
148
+ dx, dy = b[0] - a[0], b[1] - a[1]
149
+ L = dx * dx + dy * dy
150
+ t = 0.0 if L == 0 else max(0.0, min(1.0, ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L))
151
+ return math.hypot(p[0] - a[0] - t * dx, p[1] - a[1] - t * dy)
152
+
153
+
154
+ def hull_distance(h, p):
155
+ """Distance from p to the convex polygon h (0 inside or on it)."""
156
+ if len(h) >= 3 and all(_cross(h[i], h[(i + 1) % len(h)], p) >= 0 for i in range(len(h))):
157
+ return 0.0
158
+ if len(h) == 1:
159
+ return math.hypot(p[0] - h[0][0], p[1] - h[0][1])
160
+ return min(_seg_dist(p, h[i], h[(i + 1) % len(h)]) for i in range(len(h)))
161
+
162
+
163
+ class Slices:
164
+ """The CT test on one scan level: vol (a Volume), f (level-0 voxels per voxel there), vox_mm (its voxel size)."""
165
+
166
+ def __init__(self, vol, f, vox_mm):
167
+ self.vol, self.f, self.vox_mm, self.hulls = vol, f, vox_mm, {}
168
+
169
+ def _window(self, x, y, z, r):
170
+ zi, yc, xc = int(z) // self.f, y / self.f, x / self.f
171
+ return zi, yc, xc, (int(math.floor(yc - r)) - 1, int(math.ceil(yc + r)) + 1, int(math.floor(xc - r)) - 1, int(math.ceil(xc + r)) + 1)
172
+
173
+ def near_tiles(self, x, y, z):
174
+ zi, _, _, (y0, y1, x0, x1) = self._window(x, y, z, FAR_MM / self.vox_mm)
175
+ return self.vol.tiles(zi, y0, y1, x0, x1)
176
+
177
+ def slice_tiles(self, z):
178
+ return self.vol.tiles(int(z) // self.f, 0, self.vol.shape[1], 0, self.vol.shape[2])
179
+
180
+ def material_near(self, x, y, z):
181
+ """True when a nonzero voxel center lies within FAR_MM of the point in its slice."""
182
+ r = FAR_MM / self.vox_mm
183
+ zi, yc, xc, (y0, y1, x0, x1) = self._window(x, y, z, r)
184
+ for j, row in zip(range(y0, y1), self.vol.rows(zi, y0, y1, x0, x1)):
185
+ dy = j + 0.5 - yc
186
+ if abs(dy) > r:
187
+ continue
188
+ half = math.sqrt(r * r - dy * dy)
189
+ a, b = math.ceil(xc - half - 0.5), math.floor(xc + half - 0.5)
190
+ seg = row[max(0, a - x0):max(0, b - x0 + 1)]
191
+ if seg.count(0) < len(seg):
192
+ return True
193
+ return False
194
+
195
+ def hull_of(self, zi):
196
+ """Convex hull of slice zi's nonzero voxel centers, in voxels of this level (None for an empty slice)."""
197
+ if zi not in self.hulls:
198
+ pts = []
199
+ Y, X = self.vol.shape[1], self.vol.shape[2]
200
+ for y, row in enumerate(self.vol.rows(zi, 0, Y, 0, X)):
201
+ s = row.lstrip(b"\0")
202
+ if s:
203
+ pts += [(X - len(s) + 0.5, y + 0.5), (len(row.rstrip(b"\0")) - 0.5, y + 0.5)]
204
+ self.hulls[zi] = hull(pts) if pts else None
205
+ return self.hulls[zi]
206
+
207
+ def outside(self, x, y, z):
208
+ """None when the point is inside the scroll; else the distance past the slice's hull, mm, or 'empty'."""
209
+ if self.material_near(x, y, z):
210
+ return None
211
+ h = self.hull_of(int(z) // self.f)
212
+ if h is None:
213
+ return "empty"
214
+ d = hull_distance(h, (x / self.f, y / self.f)) * self.vox_mm
215
+ return d if d > FAR_MM else None
216
+
217
+
218
+ def outside_ends(points, sl):
219
+ """The CT test on one line: points as (x, y, z) level-0 voxels sorted by z, sl a Slices.
220
+
221
+ Returns (result, None) or (None, why). result holds, per end, the hidden run's length (hidden), how many of its
222
+ points lie outside (far), how many of those are in a slice with no scroll at all (empty) and the largest distance
223
+ past the hull of the others (max_mm)."""
224
+ n = len(points)
225
+ if n < 3:
226
+ return None, "the line has fewer than three points"
227
+ zone = max(1, int(n * END_SHARE))
228
+ idx = sorted(set(range(zone)) | set(range(n - zone, n)) | {n // 2})
229
+ vol = sl.vol
230
+ vol.prefetch([k for i in idx for k in sl.near_tiles(*points[i])])
231
+ mid = points[n // 2]
232
+ if sl.outside(*mid) is not None:
233
+ return None, "its middle point lies outside the scroll in the CT, so the frame or the mask is in doubt"
234
+ X, Y = vol.shape[2] * sl.f, vol.shape[1] * sl.f
235
+ cx = [CORNER * X, (1 - CORNER) * X]
236
+ cy = [CORNER * Y, (1 - CORNER) * Y]
237
+ corner = max(((a, b) for a in cx for b in cy), key=lambda c: math.hypot(c[0] - mid[0], c[1] - mid[1]))
238
+ vol.prefetch(sl.slice_tiles(mid[2]))
239
+ if sl.outside(corner[0], corner[1], mid[2]) is None:
240
+ return None, "a corner of the scan's slice reads as inside the scroll, so the scan's air is not zero"
241
+ far = {}
242
+ for i in idx:
243
+ if i == n // 2:
244
+ continue
245
+ if not sl.material_near(*points[i]):
246
+ vol.prefetch(sl.slice_tiles(points[i][2]))
247
+ far[i] = sl.outside(*points[i])
248
+ res = {}
249
+ for end, order in (("low", range(zone)), ("high", range(n - 1, n - 1 - zone, -1))):
250
+ hit = [k for k, i in enumerate(order) if far[i] is not None]
251
+ hidden = hit[-1] + 1 if hit else 0
252
+ vals = [far[i] for k, i in enumerate(order) if k < hidden and far[i] is not None]
253
+ mms = [v for v in vals if v != "empty"]
254
+ res[end] = {"hidden": hidden, "far": len(vals), "empty": sum(1 for v in vals if v == "empty"),
255
+ "max_mm": round(max(mms), 1) if mms else None}
256
+ return dict(res, level=None, far_mm=FAR_MM, end_share=END_SHARE), None
257
+
258
+
259
+ def _range_tile(url, chunks, compressor):
260
+ """tile() for a level in the bucket: one range read per plane of an uncompressed chunk; a compressed chunk is read
261
+ whole once and decoded with numcodecs."""
262
+ ch_z, ch_y, ch_x = chunks
263
+ whole, lock = {}, threading.Lock()
264
+ codec = None
265
+ if compressor:
266
+ import numcodecs # ImportError: the caller marks the line untested
267
+ codec = numcodecs.get_codec(compressor)
268
+
269
+ def get(u, rng=None):
270
+ req = urllib.request.Request(u, headers={"Range": "bytes=%d-%d" % rng} if rng else {})
271
+ for attempt in range(4):
272
+ try:
273
+ with urllib.request.urlopen(req, timeout=60) as r:
274
+ return r.read()
275
+ except urllib.error.HTTPError as e:
276
+ if e.code in (403, 404):
277
+ return None
278
+ err = e
279
+ except OSError as e:
280
+ err = e
281
+ raise err
282
+
283
+ def tile(zi, cy, cx):
284
+ key = f"{url}/{zi // ch_z}/{cy}/{cx}"
285
+ plane = ch_y * ch_x
286
+ if codec is None:
287
+ off = (zi % ch_z) * plane
288
+ return get(key, (off, off + plane - 1))
289
+ with lock:
290
+ have = key in whole
291
+ if not have:
292
+ raw = get(key)
293
+ data = bytes(codec.decode(raw)) if raw is not None else None
294
+ with lock:
295
+ whole[key] = data
296
+ data = whole[key]
297
+ return None if data is None else data[(zi % ch_z) * plane:(zi % ch_z + 1) * plane]
298
+ return tile
299
+
300
+
301
+ def open_slices(base, pyramid, um):
302
+ """The CT test's view of one scan: the Slices for level LEVEL (or the coarsest level there is) of its zarr."""
303
+ k = min(LEVEL, len(pyramid) - 1)
304
+ p = pyramid[k]
305
+ comp = None
306
+ if p["compressor"]:
307
+ comp = get_json(f"{base}/{p['path']}/.zarray")["compressor"]
308
+ tile = _range_tile(f"{base}/{p['path']}", p["chunks"], comp)
309
+ f = 2 ** k
310
+ return Slices(Volume(p["shape"], p["chunks"], tile, threads=32), f, um * f / 1000), k
311
+
312
+
313
+ def read_pyramid(base):
314
+ """Every level of the OME-Zarr at base: path, shape, chunks, dtype and compressor id."""
315
+ attrs = get_json(f"{base}/.zattrs")
316
+ out = []
317
+ for lv in [d["path"] for d in attrs["multiscales"][0]["datasets"]]:
318
+ za = get_json(f"{base}/{lv}/.zarray")
319
+ comp = za.get("compressor")
320
+ out.append({"path": lv, "shape": za["shape"], "chunks": za["chunks"], "dtype": za["dtype"],
321
+ "compressor": comp.get("id") if comp else None})
322
+ return out
323
+
324
+
325
+ def open_scan(source_volume):
326
+ """(Slices, level) for a bucket scan named "SCROLL/volumes/VOL.zarr", or (None, why) when it cannot be read."""
327
+ m = re.search(r"-([0-9.]+)um-", source_volume or "")
328
+ if not m:
329
+ return None, f"no voxel size in the volume name {source_volume!r}"
330
+ base = f"{S3}/{source_volume.strip('/')}"
331
+ try:
332
+ return open_slices(base, read_pyramid(base), float(m.group(1)))
333
+ except ImportError:
334
+ return None, "this scan's chunks are compressed and numcodecs is not installed to read them"
335
+ except Exception as e: # a scan that cannot be opened: its lines are left whole and say so
336
+ return None, f"the scan's CT could not be opened ({type(e).__name__})"
337
+
338
+
339
+ def check_line(points, scan):
340
+ """(result, why) for one line; scan is (Slices, level) or (None, why). result["level"] is the level read."""
341
+ sl, lev = scan
342
+ if sl is None:
343
+ return None, lev
344
+ try:
345
+ res, why = outside_ends(points, sl)
346
+ except Exception as e: # a read that failed: the line is left whole and says so
347
+ return None, f"the CT could not be read ({type(e).__name__})"
348
+ if res:
349
+ res["level"] = lev
350
+ return res, why
351
+
352
+
353
+ # ---------- trimming a file ----------
354
+
355
+ def _path_mm(pts, um):
356
+ return sum(math.dist(a, b) for a, b in zip(pts, pts[1:])) * um / 1000
357
+
358
+
359
+ def cut_of(n, res):
360
+ """The kept slice [a, b) of a line of n points sorted by z, from a result of outside_ends (None keeps all)."""
361
+ if not res:
362
+ return 0, n
363
+ return res["low"]["hidden"], n - res["high"]["hidden"]
364
+
365
+
366
+ def trim_doc(doc, res, why, um=None):
367
+ """A copy of an umbilicus document with the end runs res cuts removed and metadata.papyrus_trim recording it.
368
+
369
+ Points keep their file order and keys; the cut is made on their order by z (the order outside_ends tested).
370
+ res None (untested) cuts nothing and records why. um, the voxel size in micrometers, gives the cut in mm."""
371
+ cps = doc["control_points"]
372
+ order = sorted(range(len(cps)), key=lambda i: cps[i]["z"])
373
+ a, b = cut_of(len(cps), res)
374
+ keep = set(order[a:b])
375
+ pts = [(cps[i]["x"], cps[i]["y"], cps[i]["z"]) for i in order]
376
+ out = dict(doc)
377
+ out["control_points"] = [p for i, p in enumerate(cps) if i in keep]
378
+ md = dict(doc.get("metadata") or {})
379
+ um = um if um is not None else md.get("voxelsize_um")
380
+ rec = {"version": VERSION, "rule": RULE, "points_before": len(cps), "points_after": len(keep)}
381
+ if res is None:
382
+ rec.update({"tested": False, "why": why})
383
+ else:
384
+ rec.update({"tested": True, "level": res.get("level"), "far_mm": res["far_mm"], "end_share": res["end_share"]})
385
+ for end, run, edge in (("low", pts[:a + 1] if a else [], a), ("high", pts[b - 1:] if b < len(pts) else [], b - 1)):
386
+ e = res[end]
387
+ rec[end] = {"points": e["hidden"], "outside": e["far"], "empty_slices": e["empty"],
388
+ "max_mm_past_outline": e["max_mm"]}
389
+ if e["hidden"] and edge >= 0 and edge < len(pts):
390
+ dz = abs(pts[edge][2] - (pts[0][2] if end == "low" else pts[-1][2]))
391
+ rec[end]["z_voxels"] = round(dz, 2)
392
+ if um:
393
+ rec[end]["z_mm"] = round(dz * um / 1000, 2)
394
+ rec[end]["path_mm"] = round(_path_mm(run, um), 2)
395
+ md["papyrus_trim"] = rec
396
+ out["metadata"] = md
397
+ return out
398
+
399
+
400
+ def _points(doc):
401
+ return [(p["x"], p["y"], p["z"]) for p in sorted(doc["control_points"], key=lambda p: p["z"])]
402
+
403
+
404
+ def run(files, volume=None, out=None, scans=None, opener=None):
405
+ """check (out None) or trim (out a folder) each file; returns one report row per file.
406
+
407
+ scans caches (Slices, level) per volume across calls; opener(source_volume) opens one (open_scan by default)."""
408
+ scans = {} if scans is None else scans
409
+ opener = opener or open_scan
410
+ rows = []
411
+ for f in files:
412
+ f = Path(f)
413
+ doc = json.loads(f.read_text())
414
+ md = doc.get("metadata") or {}
415
+ vol = volume or md.get("source_volume")
416
+ if not vol:
417
+ res, why = None, "the file names no scan (metadata.source_volume); give --volume"
418
+ else:
419
+ if vol not in scans:
420
+ scans[vol] = opener(vol)
421
+ res, why = check_line(_points(doc), scans[vol])
422
+ m = re.search(r"-([0-9.]+)um-", vol or "")
423
+ um = md.get("voxelsize_um") or (float(m.group(1)) if m else None)
424
+ new = trim_doc(doc, res, why, um)
425
+ rec = new["metadata"]["papyrus_trim"]
426
+ rows.append(dict({"file": str(f), "volume": vol}, **rec))
427
+ if out is not None:
428
+ Path(out).mkdir(parents=True, exist_ok=True)
429
+ (Path(out) / f.name).write_text(json.dumps(new, indent=1) + "\n")
430
+ return rows
431
+
432
+
433
+ def _say(r):
434
+ if not r["tested"]:
435
+ return f"{Path(r['file']).name}: untested, {r['why']}; left whole"
436
+ parts = []
437
+ for end in ("low", "high"):
438
+ e = r[end]
439
+ if e["points"]:
440
+ parts.append(f"{end} {e['points']} points" + (f", {e['z_mm']} mm of height" if "z_mm" in e else "")
441
+ + (f", up to {e['max_mm_past_outline']} mm past the outline" if e["max_mm_past_outline"] else "")
442
+ + (f", {e['empty_slices']} in slices with no scroll" if e["empty_slices"] else ""))
443
+ return f"{Path(r['file']).name}: {r['points_before']} to {r['points_after']} points" + (": " + "; ".join(parts) if parts else ", nothing outside")
444
+
445
+
446
+ def main(argv=None):
447
+ if sys.stdout.isatty(): # the Scrolls look (AGENTS.md, terminal look rule)
448
+ try:
449
+ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
450
+ from kit import termstyle
451
+ termstyle.script()
452
+ except ImportError:
453
+ pass
454
+ ap = argparse.ArgumentParser(prog="lineends.py", description=__doc__.split("\n\n")[0])
455
+ ap.add_argument("cmd", choices=["check", "trim"])
456
+ ap.add_argument("files", nargs="+", help="umbilicus files in the published format")
457
+ ap.add_argument("--out", help="trim: the folder for the trimmed copies")
458
+ ap.add_argument("--volume", help="the scan, SCROLL/volumes/VOL.zarr in the open-data bucket, for every file "
459
+ "(default: each file's metadata.source_volume)")
460
+ ap.add_argument("--report", help="write the per-file report here as JSON")
461
+ ap.add_argument("--json", action="store_true", help="print the report as JSON")
462
+ a = ap.parse_args(argv)
463
+ if a.cmd == "trim" and not a.out:
464
+ ap.error("trim needs --out DIR")
465
+ out = Path(a.out) if a.cmd == "trim" else None
466
+ if out is not None and any(Path(f).resolve().parent == out.resolve() for f in a.files):
467
+ ap.error("--out is the folder of an input file; write the trimmed copies elsewhere")
468
+ rows = run(a.files, a.volume, out)
469
+ if a.report:
470
+ Path(a.report).write_text(json.dumps(rows, indent=1) + "\n")
471
+ if a.json:
472
+ print(json.dumps(rows, indent=1))
473
+ else:
474
+ for r in rows:
475
+ print(_say(r))
476
+ return 0
477
+
478
+
479
+ if __name__ == "__main__":
480
+ sys.exit(main())