Umbilici 1.13.0: hide line ends outside the scroll; lines from center lines v2.9 (from Scrolls main 48e9346)
Browse files- README.md +21 -5
- app.js +81 -22
- data/lines.json +0 -0
- index.html +15 -7
- tools/build_lines.py +51 -3
- 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.
|
| 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 |
-
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 102 |
-
function
|
|
|
|
| 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 |
-
|
| 159 |
-
const
|
|
|
|
|
|
|
| 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 =
|
| 171 |
return {
|
| 172 |
-
points:
|
|
|
|
| 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,
|
| 177 |
};
|
| 178 |
}
|
|
|
|
| 179 |
function pairStats(s, a, b) {
|
| 180 |
-
const
|
| 181 |
-
|
|
|
|
| 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(
|
| 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(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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&scan=PHerc0191&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&scan=PHerc0191&z=10400</code>, <code>&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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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())
|