Multi-scroll support: build.py and fetch.py take any open-data scroll (per-scroll voxel size, X-ray scan, renumbering by radius, optional ink, a second 1 um ink layer), make_manifest.py, the scroll picker's prize eligible badge and eligible-only toggle (window.unrollScrolls), and a 'Why N ink layers?' box written from each manifest's ink layers
Browse files- .gitattributes +1 -0
- app.js +56 -6
- index.html +1 -1
- scrolls/PHerc0139/manifest.json +4 -2
- style.css +6 -0
- tests/e2e_scrolls.py +33 -0
- tools/build.py +78 -21
- tools/fetch.py +30 -11
- tools/make_manifest.py +118 -0
.gitattributes
CHANGED
|
@@ -148,3 +148,4 @@ data/hi/w058_gpu.jpg filter=lfs diff=lfs merge=lfs -text
|
|
| 148 |
data/gpu/w059.jpg filter=lfs diff=lfs merge=lfs -text
|
| 149 |
data/hi/w059_gpu.jpg filter=lfs diff=lfs merge=lfs -text
|
| 150 |
data/bench/*.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 148 |
data/gpu/w059.jpg filter=lfs diff=lfs merge=lfs -text
|
| 149 |
data/hi/w059_gpu.jpg filter=lfs diff=lfs merge=lfs -text
|
| 150 |
data/bench/*.png filter=lfs diff=lfs merge=lfs -text
|
| 151 |
+
data/PHerc*/**/*.jpg filter=lfs diff=lfs merge=lfs -text
|
app.js
CHANGED
|
@@ -169,12 +169,57 @@ $('loader').querySelector('.mono').textContent = D?.loaderKicker || `${MAN.name.
|
|
| 169 |
credit.innerHTML = `Data ${link(cr.text || MAN.name, cr.url)}${dt.license ? ` · ${link(dt.license, dt.url)}` : ''}${TRUSTED ? '' : ' · <span class="local-tag">local, not uploaded</span>'}`;
|
| 170 |
|
| 171 |
// the scroll picker: hidden while there is only one scroll to choose from
|
|
|
|
| 172 |
const sel = $('scrollSel');
|
| 173 |
-
const
|
| 174 |
-
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
$('scrollPick').hidden = opts.length < 2;
|
| 177 |
sel.onchange = () => switchScroll(sel.value);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
}
|
| 179 |
function switchScroll(key) {
|
| 180 |
const h = new URLSearchParams(key && key !== DEFAULT_ID ? { s: key } : {});
|
|
@@ -183,7 +228,7 @@ function switchScroll(key) {
|
|
| 183 |
}
|
| 184 |
const OPEN = setupOpenOwn({ $, onOpen: switchScroll, currentKey: SCROLL_KEY });
|
| 185 |
$('btnOpen').onclick = () => { lastOpener = $('btnOpen'); OPEN.open(); };
|
| 186 |
-
window.__unroll = { scroll: () => ({ key: SCROLL_KEY, id: MAN.id, trusted: TRUSTED, windings: IDS, layers: MAN.layers.map((l) => l.role) }), open: OPEN };
|
| 187 |
|
| 188 |
let M, binBuf = null, texInk, texCT, MESHB = null;
|
| 189 |
const blankImg = () => { const c = document.createElement('canvas'); c.width = c.height = 4; return c; };
|
|
@@ -779,8 +824,13 @@ for (const b of document.querySelectorAll('.chip[data-layer]')) {
|
|
| 779 |
};
|
| 780 |
}
|
| 781 |
$('layerHint').textContent = LAYER_HINTS[layer] || '';
|
| 782 |
-
|
| 783 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 784 |
if (L_OV && OV_IDS.size) {
|
| 785 |
$('gpuChip').hidden = false;
|
| 786 |
const band = document.createElement('div');
|
|
|
|
| 169 |
credit.innerHTML = `Data ${link(cr.text || MAN.name, cr.url)}${dt.license ? ` · ${link(dt.license, dt.url)}` : ''}${TRUSTED ? '' : ' · <span class="local-tag">local, not uploaded</span>'}`;
|
| 170 |
|
| 171 |
// the scroll picker: hidden while there is only one scroll to choose from
|
| 172 |
+
// each registry entry may carry prize: { eligible, sets }, mirroring the badge on scrollprize.org's data browser
|
| 173 |
const sel = $('scrollSel');
|
| 174 |
+
const isEligible = (k) => !!(REG.scrolls.find((x) => x.id === k)?.prize?.eligible);
|
| 175 |
+
const opts = REG.scrolls.map((x) => [x.id, `${x.name || x.id}${x.prize?.eligible ? ' 🏆' : ''}`]).concat(LOCALS.map((x) => [x.key, `${x.name} (local)`]));
|
| 176 |
+
let onlyEligible = params.get('prize') === '1';
|
| 177 |
+
const fill = () => {
|
| 178 |
+
sel.innerHTML = '';
|
| 179 |
+
for (const [k, n] of opts) {
|
| 180 |
+
if (onlyEligible && !isEligible(k) && k !== SCROLL_KEY) continue;
|
| 181 |
+
const o = document.createElement('option'); o.value = k; o.textContent = n; sel.appendChild(o);
|
| 182 |
+
}
|
| 183 |
+
sel.value = SCROLL_KEY;
|
| 184 |
+
};
|
| 185 |
+
fill();
|
| 186 |
$('scrollPick').hidden = opts.length < 2;
|
| 187 |
sel.onchange = () => switchScroll(sel.value);
|
| 188 |
+
const badge = $('prizeBadge'), cur = REG.scrolls.find((x) => x.id === SCROLL_KEY);
|
| 189 |
+
badge.hidden = !isEligible(SCROLL_KEY);
|
| 190 |
+
if (cur?.prize?.sets?.length) badge.dataset.sets = cur.prize.sets.join(', ');
|
| 191 |
+
const tog = $('prizeOnly');
|
| 192 |
+
tog.hidden = !REG.scrolls.some((x) => x.prize?.eligible);
|
| 193 |
+
tog.setAttribute('aria-pressed', String(onlyEligible));
|
| 194 |
+
const setOnly = (v) => { onlyEligible = !!v; tog.setAttribute('aria-pressed', String(onlyEligible)); tog.classList.toggle('on', onlyEligible); fill(); };
|
| 195 |
+
setOnly(onlyEligible);
|
| 196 |
+
tog.onclick = () => setOnly(!onlyEligible);
|
| 197 |
+
window.unrollScrolls = {
|
| 198 |
+
list: () => REG.scrolls.map((x) => ({ id: x.id, name: x.name, prizeEligible: !!x.prize?.eligible, prizeSets: x.prize?.sets || [] })),
|
| 199 |
+
current: () => SCROLL_KEY,
|
| 200 |
+
isPrizeEligible: (id = SCROLL_KEY) => isEligible(id),
|
| 201 |
+
setPrizeEligibleOnly: setOnly,
|
| 202 |
+
prizeEligibleOnly: () => onlyEligible,
|
| 203 |
+
visible: () => [...sel.options].map((o) => o.value),
|
| 204 |
+
open: (id) => switchScroll(id),
|
| 205 |
+
};
|
| 206 |
+
}
|
| 207 |
+
// the text for the "Why N ink layers?" box, from a list of ink layers (role ink or overlay); null for fewer than two
|
| 208 |
+
function inkLayerWhy(inks) {
|
| 209 |
+
if (inks.length < 2) return null;
|
| 210 |
+
const words = ['no', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten'];
|
| 211 |
+
const n = words[inks.length] || String(inks.length);
|
| 212 |
+
const plain = (t) => String(t || '').replace(/<[^>]*>/g, '');
|
| 213 |
+
const parts = inks.map((l) => {
|
| 214 |
+
const res = l.resolutionUM ? ` on the ${l.resolutionUM} µm scan` : '';
|
| 215 |
+
return `${plain(l.label)}: ${plain(l.why || `${l.source || 'ink detection'}${res}`)}.`.replace(/\.\.$/, '.');
|
| 216 |
+
});
|
| 217 |
+
const r = inks.map((l) => l.resolutionUM).filter((x) => x > 0);
|
| 218 |
+
let tail = '';
|
| 219 |
+
if (r.length > 1 && Math.max(...r) / Math.min(...r) >= 1.5) {
|
| 220 |
+
tail = ` The finest is ${Math.min(...r)} µm and the coarsest ${Math.max(...r)} µm, about ${Math.round(Math.max(...r) / Math.min(...r))}x apart: finer scans give sharper ink, coarser ones show what cheaper scans can recover.`;
|
| 221 |
+
}
|
| 222 |
+
return { summary: `Why ${n} ink layers?`, text: `This scroll has ${n} ink layers. ${parts.join(' ')}${tail}` };
|
| 223 |
}
|
| 224 |
function switchScroll(key) {
|
| 225 |
const h = new URLSearchParams(key && key !== DEFAULT_ID ? { s: key } : {});
|
|
|
|
| 228 |
}
|
| 229 |
const OPEN = setupOpenOwn({ $, onOpen: switchScroll, currentKey: SCROLL_KEY });
|
| 230 |
$('btnOpen').onclick = () => { lastOpener = $('btnOpen'); OPEN.open(); };
|
| 231 |
+
window.__unroll = { scroll: () => ({ key: SCROLL_KEY, id: MAN.id, trusted: TRUSTED, windings: IDS, layers: MAN.layers.map((l) => l.role), inkLayers: MAN.layers.filter((l) => l.role !== 'ct').length, prizeEligible: window.unrollScrolls?.isPrizeEligible() || false }), open: OPEN, scrolls: window.unrollScrolls, inkLayerWhy };
|
| 232 |
|
| 233 |
let M, binBuf = null, texInk, texCT, MESHB = null;
|
| 234 |
const blankImg = () => { const c = document.createElement('canvas'); c.width = c.height = 4; return c; };
|
|
|
|
| 824 |
};
|
| 825 |
}
|
| 826 |
$('layerHint').textContent = LAYER_HINTS[layer] || '';
|
| 827 |
+
// "Why N ink layers?": written from the manifest's own ink layers, so the count and the wording always match the scroll
|
| 828 |
+
{
|
| 829 |
+
const inks = MAN.layers.filter((l) => l.role === 'ink' || l.role === 'overlay');
|
| 830 |
+
const why = inkLayerWhy(inks);
|
| 831 |
+
$('layerWhy').hidden = !why;
|
| 832 |
+
if (why) { $('layerWhy').querySelector('summary').textContent = why.summary; $('layerWhy').querySelector('p').textContent = why.text; }
|
| 833 |
+
}
|
| 834 |
if (L_OV && OV_IDS.size) {
|
| 835 |
$('gpuChip').hidden = false;
|
| 836 |
const band = document.createElement('div');
|
index.html
CHANGED
|
@@ -83,7 +83,7 @@ if (!/(^|[#&])s=/.test(location.hash)) ['scrolls/index.json', 'scrolls/PHerc0139
|
|
| 83 |
<div class="mono dim kicker">PHERC. 0139 <span class="sep">/</span> HERCULANEUM <span class="sep">/</span> BURIED 79 CE</div>
|
| 84 |
<h1>Unroll a scroll nobody has opened in <em>1,947 years.</em></h1>
|
| 85 |
<p class="lede">37 of its windings have been traced through X‑ray CT by the Vesuvius Challenge, and neural networks found the ink on them. Drag to look around. Scrub to unroll.</p>
|
| 86 |
-
<div class="scroll-pick" id="scrollPick" hidden><label class="mono dim" for="scrollSel">SCROLL</label> <select id="scrollSel"></select></div>
|
| 87 |
</header>
|
| 88 |
|
| 89 |
<nav class="hud tr" aria-label="Layers">
|
|
|
|
| 83 |
<div class="mono dim kicker">PHERC. 0139 <span class="sep">/</span> HERCULANEUM <span class="sep">/</span> BURIED 79 CE</div>
|
| 84 |
<h1>Unroll a scroll nobody has opened in <em>1,947 years.</em></h1>
|
| 85 |
<p class="lede">37 of its windings have been traced through X‑ray CT by the Vesuvius Challenge, and neural networks found the ink on them. Drag to look around. Scrub to unroll.</p>
|
| 86 |
+
<div class="scroll-pick" id="scrollPick" hidden><label class="mono dim" for="scrollSel">SCROLL</label> <select id="scrollSel"></select> <a class="prize-badge" id="prizeBadge" hidden href="https://scrollprize.org/prizes" target="_blank" rel="noopener" title="An open Vesuvius Challenge prize can be won on this scroll. See the prizes page">🏆 prize eligible</a> <button class="chip prize-only" id="prizeOnly" hidden aria-pressed="false" title="Show only scrolls marked prize eligible on scrollprize.org">🏆 prize eligible only</button></div>
|
| 87 |
</header>
|
| 88 |
|
| 89 |
<nav class="hud tr" aria-label="Layers">
|
scrolls/PHerc0139/manifest.json
CHANGED
|
@@ -47,7 +47,8 @@
|
|
| 47 |
"notes": "ink: Vesuvius Challenge public detection on the 2.399 µm scan",
|
| 48 |
"source": "Vesuvius Challenge public ink detection (ink-detection/downsampled)",
|
| 49 |
"resolutionUM": 2.399,
|
| 50 |
-
"atlasFallback": "data/atlas_ink.jpg"
|
|
|
|
| 51 |
},
|
| 52 |
{
|
| 53 |
"id": "gpu",
|
|
@@ -70,7 +71,8 @@
|
|
| 70 |
"bandTitle": "Windings inked by our ink model (9 µm)",
|
| 71 |
"source": "scrollprize/ink_9um (seed 42, step 75000) run on Hugging Face ZeroGPU (w046 to w051) and Modal (the other windings); per-run receipts in data/gpu/receipts/",
|
| 72 |
"resolutionUM": 9.362,
|
| 73 |
-
"aboutHtml": "<b>Our ink model (9 µm)</b>, the cyan band, is ours. We ran the public <a href=\"https://huggingface.co/scrollprize/ink_9um\" target=\"_blank\" rel=\"noopener\">scrollprize/ink_9um</a> model over windings {windings}. The first batch was 37,141 patches in 57 s of measured GPU time on half an NVIDIA RTX PRO 6000 Blackwell. It reads the 9.362 µm scan, about four times coarser than the 2.4 µm scan behind the amber layer, so its ink map is noisier. w047 and w050 ran with patch overlap 0.5; w046, w048, w049 and w051 ran with less overlap, so their 128 px patch grid shows as faint square tiles. That grid is a stitching artifact, not ink. It is included at lower resolution on purpose: it shows what an open model can recover from cheaper, more widely available scans, as a reproducible baseline you can check against the published result. Windings other than w046 to w051 (w023 to w045 and w052 to w059) were run on Modal and agree with the ZeroGPU method to 0.9999 correlation (checked on a w046 rerun). Click a cyan winding to compare the two maps."
|
|
|
|
| 74 |
}
|
| 75 |
],
|
| 76 |
"credits": {
|
|
|
|
| 47 |
"notes": "ink: Vesuvius Challenge public detection on the 2.399 µm scan",
|
| 48 |
"source": "Vesuvius Challenge public ink detection (ink-detection/downsampled)",
|
| 49 |
"resolutionUM": 2.399,
|
| 50 |
+
"atlasFallback": "data/atlas_ink.jpg",
|
| 51 |
+
"why": "the Vesuvius Challenge result on the fine 2.4 µm scan"
|
| 52 |
},
|
| 53 |
{
|
| 54 |
"id": "gpu",
|
|
|
|
| 71 |
"bandTitle": "Windings inked by our ink model (9 µm)",
|
| 72 |
"source": "scrollprize/ink_9um (seed 42, step 75000) run on Hugging Face ZeroGPU (w046 to w051) and Modal (the other windings); per-run receipts in data/gpu/receipts/",
|
| 73 |
"resolutionUM": 9.362,
|
| 74 |
+
"aboutHtml": "<b>Our ink model (9 µm)</b>, the cyan band, is ours. We ran the public <a href=\"https://huggingface.co/scrollprize/ink_9um\" target=\"_blank\" rel=\"noopener\">scrollprize/ink_9um</a> model over windings {windings}. The first batch was 37,141 patches in 57 s of measured GPU time on half an NVIDIA RTX PRO 6000 Blackwell. It reads the 9.362 µm scan, about four times coarser than the 2.4 µm scan behind the amber layer, so its ink map is noisier. w047 and w050 ran with patch overlap 0.5; w046, w048, w049 and w051 ran with less overlap, so their 128 px patch grid shows as faint square tiles. That grid is a stitching artifact, not ink. It is included at lower resolution on purpose: it shows what an open model can recover from cheaper, more widely available scans, as a reproducible baseline you can check against the published result. Windings other than w046 to w051 (w023 to w045 and w052 to w059) were run on Modal and agree with the ZeroGPU method to 0.9999 correlation (checked on a w046 rerun). Click a cyan winding to compare the two maps.",
|
| 75 |
+
"why": "the open ink_9um model run by us on the coarser 9.4 µm scan, so it is blurrier. It shows what an open model can recover from cheaper scans, as a baseline you can check"
|
| 76 |
}
|
| 77 |
],
|
| 78 |
"credits": {
|
style.css
CHANGED
|
@@ -257,3 +257,9 @@ body.destroy.tool-water #gl { cursor: url("data:image/svg+xml,%3Csvg xmlns='http
|
|
| 257 |
.roll-up.on { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
| 258 |
@media (max-width: 720px) { .roll-up { padding: 8px; font-size: 10px; } }
|
| 259 |
@media (max-width: 720px) { body.destroy #readout { display: none; } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
.roll-up.on { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
| 258 |
@media (max-width: 720px) { .roll-up { padding: 8px; font-size: 10px; } }
|
| 259 |
@media (max-width: 720px) { body.destroy #readout { display: none; } }
|
| 260 |
+
|
| 261 |
+
.prize-badge { font-size: 11px; color: #f2c66d; border: 1px solid rgba(242,198,109,0.45); border-radius: 999px; padding: 3px 9px; text-decoration: none; white-space: nowrap; }
|
| 262 |
+
.prize-badge:hover { background: rgba(242,198,109,0.12); }
|
| 263 |
+
.scroll-pick .prize-only { font-size: 11px; padding: 3px 9px; }
|
| 264 |
+
.scroll-pick .prize-only.on { border-color: rgba(242,198,109,0.7); color: #f2c66d; }
|
| 265 |
+
.scroll-pick { flex-wrap: wrap; }
|
tests/e2e_scrolls.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Load registry scrolls in headless Chromium and screenshot each one.
|
| 2 |
+
usage: python3 tests/e2e_scrolls.py <base url> <screenshot dir> [id ...]"""
|
| 3 |
+
import json, os, sys
|
| 4 |
+
from playwright.sync_api import sync_playwright
|
| 5 |
+
base, shots, ids = sys.argv[1].rstrip('/') + '/', sys.argv[2], sys.argv[3:]
|
| 6 |
+
os.makedirs(shots, exist_ok=True)
|
| 7 |
+
fails = []
|
| 8 |
+
with sync_playwright() as pw:
|
| 9 |
+
br = pw.chromium.launch(args=['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'])
|
| 10 |
+
for sid in ids:
|
| 11 |
+
p = br.new_page(viewport={'width': 1440, 'height': 900})
|
| 12 |
+
errs = []
|
| 13 |
+
p.on('pageerror', lambda e: errs.append(str(e)))
|
| 14 |
+
p.goto(f'{base}#s={sid}', wait_until='load')
|
| 15 |
+
try:
|
| 16 |
+
p.wait_for_selector('#loader', state='detached', timeout=180000)
|
| 17 |
+
p.wait_for_timeout(2500)
|
| 18 |
+
info = p.evaluate('() => ({ s: window.__unroll.scroll(), why: document.querySelector("#layerWhy").hidden ? null : document.querySelector("#layerWhy summary").textContent, badge: !document.querySelector("#prizeBadge").hidden, opts: window.unrollScrolls.visible().length })')
|
| 19 |
+
p.evaluate('() => window.unrollScrolls.setPrizeEligibleOnly(true)')
|
| 20 |
+
only = p.evaluate('() => window.unrollScrolls.visible()')
|
| 21 |
+
p.evaluate('() => window.unrollScrolls.setPrizeEligibleOnly(false)')
|
| 22 |
+
ok = info['s']['id'] == sid and not errs
|
| 23 |
+
print(('ok ' if ok else 'FAIL ') + sid, json.dumps({**info, 's': {k: info['s'][k] for k in ('id', 'layers', 'inkLayers', 'prizeEligible')}, 'nWindings': len(info['s']['windings']), 'eligibleOnly': only}), errs[:2])
|
| 24 |
+
if not ok: fails.append(sid)
|
| 25 |
+
except Exception as e:
|
| 26 |
+
print('FAIL', sid, str(e)[:200], errs[:2]); fails.append(sid)
|
| 27 |
+
# CDP capture: the page's render loop keeps Playwright's own screenshot waiting under software GL
|
| 28 |
+
import base64
|
| 29 |
+
cdp = p.context.new_cdp_session(p)
|
| 30 |
+
open(f'{shots}/scroll_{sid}.png', 'wb').write(base64.b64decode(cdp.send('Page.captureScreenshot', {'format': 'png'})['data']))
|
| 31 |
+
p.close()
|
| 32 |
+
br.close()
|
| 33 |
+
sys.exit(1 if fails else 0)
|
tools/build.py
CHANGED
|
@@ -28,11 +28,15 @@ from PIL import Image
|
|
| 28 |
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 29 |
RAW = os.environ.get("UNROLL_RAW", f"{ROOT}/raw")
|
| 30 |
OUT = os.environ.get("UNROLL_OUT", f"{ROOT}/data")
|
| 31 |
-
B = "https://vesuvius-challenge-open-data.s3.
|
| 32 |
-
|
|
|
|
| 33 |
CELL_MM = 20 * VOX_MM # tifxyz scale 0.05: one grid cell is 20 voxels
|
|
|
|
|
|
|
|
|
|
| 34 |
STEP = 3 # grid decimation for the browser mesh
|
| 35 |
-
PPMM = 7.5
|
| 36 |
ATLAS = 4096
|
| 37 |
PAD = 6
|
| 38 |
HI_MAX = 2400
|
|
@@ -43,13 +47,25 @@ Image.MAX_IMAGE_PIXELS = None
|
|
| 43 |
|
| 44 |
def fetch_ct(w, src, shape_hw):
|
| 45 |
"""Level-3 X-ray texture (mean of the middle layers) of a winding's 9.362 um surface volume."""
|
| 46 |
-
cache = f"{RAW}/{w}/ct_l3.npy"
|
| 47 |
if os.path.exists(cache):
|
| 48 |
return np.load(cache)
|
| 49 |
seg = src["seg"]
|
| 50 |
-
sv = [s for s in src["sv"] if s.startswith("
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
meta = json.loads(urllib.request.urlopen(f"{base}/.zarray").read())
|
|
|
|
| 53 |
D, H, W = meta["shape"]
|
| 54 |
cz, ch, cw = meta["chunks"]
|
| 55 |
sep = meta.get("dimension_separator") or "."
|
|
@@ -72,7 +88,8 @@ def fetch_ct(w, src, shape_hw):
|
|
| 72 |
else:
|
| 73 |
raise OSError(f"could not fetch {url}")
|
| 74 |
a = np.frombuffer(raw, np.uint8).reshape(cz, ch, cw)
|
| 75 |
-
|
|
|
|
| 76 |
|
| 77 |
with ThreadPoolExecutor(4) as ex:
|
| 78 |
for (i, j), tile in ex.map(one, jobs):
|
|
@@ -127,7 +144,7 @@ def shelf_pack(sizes, width):
|
|
| 127 |
|
| 128 |
def main():
|
| 129 |
os.makedirs(f"{OUT}/hi", exist_ok=True)
|
| 130 |
-
dirs = sorted(glob.glob(f"{RAW}/
|
| 131 |
names = [os.path.basename(d) for d in dirs]
|
| 132 |
grids, srcs, metas = [], [], []
|
| 133 |
for d in dirs:
|
|
@@ -136,7 +153,33 @@ def main():
|
|
| 136 |
grids.append((x, y, z, v))
|
| 137 |
srcs.append(json.load(open(f"{d}/source.json")))
|
| 138 |
metas.append(json.load(open(f"{d}/meta.json")))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
ax, ay = axis_fit(grids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
allz = np.concatenate([g[2][g[3]] for g in grids])
|
| 141 |
zc = float((allz.min() + allz.max()) / 2)
|
| 142 |
cx0, cy0 = float(np.polyval(ax, zc)), float(np.polyval(ay, zc))
|
|
@@ -148,11 +191,21 @@ def main():
|
|
| 148 |
H, W = v.shape
|
| 149 |
tw, th = int(round(W * CELL_MM * PPMM)), int(round(H * CELL_MM * PPMM))
|
| 150 |
sizes[w] = (tw, th)
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
ct = fetch_ct(w, src, (H, W))
|
| 157 |
valid = ct > 0
|
| 158 |
lo, hi_ = np.percentile(ct[valid], [1.5, 99.5]) if valid.any() else (0, 1)
|
|
@@ -164,12 +217,15 @@ def main():
|
|
| 164 |
if used > ATLAS:
|
| 165 |
sys.exit(f"atlas overflow: {used} px > {ATLAS}; lower PPMM")
|
| 166 |
atlas_h = 1 << int(np.ceil(np.log2(used)))
|
| 167 |
-
atlases = {"
|
|
|
|
|
|
|
| 168 |
for w in names:
|
| 169 |
-
|
| 170 |
-
|
| 171 |
for k, im in atlases.items():
|
| 172 |
im.save(f"{OUT}/atlas_{k}.jpg", quality=88, optimize=True, progressive=True)
|
|
|
|
| 173 |
print("atlas", ATLAS, atlas_h, "used rows", used)
|
| 174 |
|
| 175 |
# meshes
|
|
@@ -187,7 +243,9 @@ def main():
|
|
| 187 |
px = np.where(vs, xs - cx0, 0)
|
| 188 |
py = np.where(vs, ys - cy0, 0)
|
| 189 |
pz = np.where(vs, zs - zc, 0)
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
arr[~vs] = -32768
|
| 192 |
b = arr.tobytes()
|
| 193 |
blobs.append(b)
|
|
@@ -202,17 +260,16 @@ def main():
|
|
| 202 |
"gridH": int(H), "gridW": int(W),
|
| 203 |
"offset": offset, "bytes": len(b),
|
| 204 |
"widthMM": round(W * CELL_MM, 2), "heightMM": round(H * CELL_MM, 2),
|
| 205 |
-
"areaCM2": round(meta["area_vx2"] * VOX_MM * VOX_MM / 100, 2),
|
| 206 |
"radiusMM": round(float(np.median(r)), 2),
|
| 207 |
"atlas": [x0 / ATLAS, y0 / atlas_h, (x0 + tw) / ATLAS, (y0 + th) / atlas_h],
|
| 208 |
-
"hi": f"hi/{w}.jpg",
|
| 209 |
-
"ink_source": [f for f, _ in src["ink"] if "2.399um" in f][0],
|
| 210 |
"mesh_source": src["mesh"],
|
| 211 |
})
|
| 212 |
offset += len(b)
|
| 213 |
open(f"{OUT}/scroll.bin", "wb").write(b"".join(blobs))
|
| 214 |
manifest = {
|
| 215 |
-
"scroll":
|
| 216 |
"voxelMM": VOX_MM, "cellMM": CELL_MM,
|
| 217 |
"axis": {"centre": [cx0, cy0, zc], "dxdz": ax[0], "dydz": ay[0]},
|
| 218 |
"atlas": {"w": ATLAS, "h": atlas_h, "ppmm": PPMM},
|
|
|
|
| 28 |
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 29 |
RAW = os.environ.get("UNROLL_RAW", f"{ROOT}/raw")
|
| 30 |
OUT = os.environ.get("UNROLL_OUT", f"{ROOT}/data")
|
| 31 |
+
B = "https://vesuvius-challenge-open-data.s3.amazonaws.com"
|
| 32 |
+
SCROLL = os.environ.get("UNROLL_SCROLL", "PHerc0139")
|
| 33 |
+
VOX_MM = float(os.environ.get("UNROLL_VOX_MM", "0.009362")) # scan voxel of the meshes (9.362 um for PHerc0139)
|
| 34 |
CELL_MM = 20 * VOX_MM # tifxyz scale 0.05: one grid cell is 20 voxels
|
| 35 |
+
CT_RES = os.environ.get("UNROLL_CT_RES", "9.362um") # surface volume for the X-ray layer (any scan: it is a UV texture)
|
| 36 |
+
NO_INK = os.environ.get("UNROLL_NO_INK") == "1"
|
| 37 |
+
RENUMBER = os.environ.get("UNROLL_RENUMBER") == "1" # name the windings w001.. by radius, innermost first
|
| 38 |
STEP = 3 # grid decimation for the browser mesh
|
| 39 |
+
PPMM = float(os.environ.get("UNROLL_PPMM", "7.5")) # atlas pixels per mm (lowered for long scrolls)
|
| 40 |
ATLAS = 4096
|
| 41 |
PAD = 6
|
| 42 |
HI_MAX = 2400
|
|
|
|
| 47 |
|
| 48 |
def fetch_ct(w, src, shape_hw):
|
| 49 |
"""Level-3 X-ray texture (mean of the middle layers) of a winding's 9.362 um surface volume."""
|
| 50 |
+
cache = f"{RAW}/{src.get('w', w)}/ct_l3.npy"
|
| 51 |
if os.path.exists(cache):
|
| 52 |
return np.load(cache)
|
| 53 |
seg = src["seg"]
|
| 54 |
+
sv = [s for s in src["sv"] if s.startswith(CT_RES) and "-L1" not in s][0]
|
| 55 |
+
root = f"{B}/{SCROLL}/segments/{seg}/surface-volumes/{sv}"
|
| 56 |
+
# the level nearest the grid's size (level 3 for 9 um scans; finer scans need a coarser level)
|
| 57 |
+
lvl = 3
|
| 58 |
+
for lv in range(3, 7):
|
| 59 |
+
try:
|
| 60 |
+
mt = json.loads(urllib.request.urlopen(f"{root}/{lv}/.zarray").read())
|
| 61 |
+
except urllib.error.HTTPError:
|
| 62 |
+
break
|
| 63 |
+
lvl = lv
|
| 64 |
+
if mt["shape"][1] <= shape_hw[0] * 8:
|
| 65 |
+
break
|
| 66 |
+
base = f"{root}/{lvl}"
|
| 67 |
meta = json.loads(urllib.request.urlopen(f"{base}/.zarray").read())
|
| 68 |
+
assert meta.get("compressor") is None, f"compressed zarr not handled: {meta.get('compressor')}"
|
| 69 |
D, H, W = meta["shape"]
|
| 70 |
cz, ch, cw = meta["chunks"]
|
| 71 |
sep = meta.get("dimension_separator") or "."
|
|
|
|
| 88 |
else:
|
| 89 |
raise OSError(f"could not fetch {url}")
|
| 90 |
a = np.frombuffer(raw, np.uint8).reshape(cz, ch, cw)
|
| 91 |
+
lo = CT_LAYERS[0] if cz >= 28 else max(0, cz // 2 - 3)
|
| 92 |
+
return ij, a[lo:lo + (CT_LAYERS[1] - CT_LAYERS[0])].astype(np.float32).mean(0)
|
| 93 |
|
| 94 |
with ThreadPoolExecutor(4) as ex:
|
| 95 |
for (i, j), tile in ex.map(one, jobs):
|
|
|
|
| 144 |
|
| 145 |
def main():
|
| 146 |
os.makedirs(f"{OUT}/hi", exist_ok=True)
|
| 147 |
+
dirs = sorted(glob.glob(f"{RAW}/[ws]*[0-9]"))
|
| 148 |
names = [os.path.basename(d) for d in dirs]
|
| 149 |
grids, srcs, metas = [], [], []
|
| 150 |
for d in dirs:
|
|
|
|
| 153 |
grids.append((x, y, z, v))
|
| 154 |
srcs.append(json.load(open(f"{d}/source.json")))
|
| 155 |
metas.append(json.load(open(f"{d}/meta.json")))
|
| 156 |
+
if RENUMBER:
|
| 157 |
+
# order by median distance from a first axis fit through all windings: the innermost becomes w001
|
| 158 |
+
allg = [np.concatenate([g[i][g[3]] for g in grids]) for i in range(3)]
|
| 159 |
+
ax0, ay0 = np.polyfit(allg[2], allg[0], 1), np.polyfit(allg[2], allg[1], 1)
|
| 160 |
+
rad = [float(np.median(np.hypot(g[0][g[3]] - np.polyval(ax0, g[2][g[3]]), g[1][g[3]] - np.polyval(ay0, g[2][g[3]]))))
|
| 161 |
+
for g in grids]
|
| 162 |
+
order = np.argsort(rad)
|
| 163 |
+
grids, srcs, metas, dirs = ([L[i] for i in order] for L in (grids, srcs, metas, dirs))
|
| 164 |
+
names = [f"w{k + 1:03d}" for k in range(len(dirs))]
|
| 165 |
+
print("renumbered by radius:", [(n, srcs[k]["seg"], round(rad[order[k]])) for k, n in enumerate(names)])
|
| 166 |
ax, ay = axis_fit(grids)
|
| 167 |
+
rad2 = [float(np.median(np.hypot(g[0][g[3]] - np.polyval(ax, g[2][g[3]]), g[1][g[3]] - np.polyval(ay, g[2][g[3]])))) * VOX_MM
|
| 168 |
+
for g in grids]
|
| 169 |
+
if RENUMBER:
|
| 170 |
+
# second pass on the refined axis, so the ids follow the radius exactly
|
| 171 |
+
order = np.argsort(rad2)
|
| 172 |
+
grids, srcs, metas, dirs = ([L[i] for i in order] for L in (grids, srcs, metas, dirs))
|
| 173 |
+
rad2 = [rad2[i] for i in order]
|
| 174 |
+
ax, ay = axis_fit(grids)
|
| 175 |
+
print("winding order check (id, median radius mm):", [(n, round(r, 1)) for n, r in zip(names, rad2)])
|
| 176 |
+
bad = sum(1 for a, b in zip(rad2, rad2[1:]) if b < a)
|
| 177 |
+
print("radius decreases between consecutive ids:", bad)
|
| 178 |
+
global PPMM
|
| 179 |
+
if "UNROLL_PPMM" not in os.environ:
|
| 180 |
+
cells = sum(g[3].shape[0] * g[3].shape[1] for g in grids) * CELL_MM * CELL_MM
|
| 181 |
+
PPMM = min(PPMM, float(np.sqrt(0.62 * ATLAS * ATLAS / cells)))
|
| 182 |
+
print("PPMM", round(PPMM, 3))
|
| 183 |
allz = np.concatenate([g[2][g[3]] for g in grids])
|
| 184 |
zc = float((allz.min() + allz.max()) / 2)
|
| 185 |
cx0, cy0 = float(np.polyval(ax, zc)), float(np.polyval(ay, zc))
|
|
|
|
| 191 |
H, W = v.shape
|
| 192 |
tw, th = int(round(W * CELL_MM * PPMM)), int(round(H * CELL_MM * PPMM))
|
| 193 |
sizes[w] = (tw, th)
|
| 194 |
+
if not NO_INK:
|
| 195 |
+
ink = Image.open(f"{d}/ink2um_ds8.jpg").convert("L")
|
| 196 |
+
ink_tiles[w] = local_contrast(ink.resize((tw, th), Image.LANCZOS), 2.2 * PPMM)
|
| 197 |
+
hi = ink.copy()
|
| 198 |
+
hi.thumbnail((HI_MAX, HI_MAX), Image.LANCZOS)
|
| 199 |
+
hi.save(f"{OUT}/hi/{w}.jpg", quality=84, optimize=True, progressive=True)
|
| 200 |
+
if SCROLL != "PHerc0139" and os.path.exists(f"{d}/ink1um_ds8.jpg"):
|
| 201 |
+
# second published ink layer (1.129 um scan): a per-winding overlay texture plus a close-up
|
| 202 |
+
os.makedirs(f"{OUT}/ink1", exist_ok=True)
|
| 203 |
+
i1 = Image.open(f"{d}/ink1um_ds8.jpg").convert("L")
|
| 204 |
+
h1 = i1.copy()
|
| 205 |
+
h1.thumbnail((HI_MAX, HI_MAX), Image.LANCZOS)
|
| 206 |
+
h1.save(f"{OUT}/hi/{w}_1um.jpg", quality=84, optimize=True, progressive=True)
|
| 207 |
+
i1.thumbnail((1200, 1200), Image.LANCZOS)
|
| 208 |
+
i1.save(f"{OUT}/ink1/{w}.jpg", quality=80, optimize=True, progressive=True)
|
| 209 |
ct = fetch_ct(w, src, (H, W))
|
| 210 |
valid = ct > 0
|
| 211 |
lo, hi_ = np.percentile(ct[valid], [1.5, 99.5]) if valid.any() else (0, 1)
|
|
|
|
| 217 |
if used > ATLAS:
|
| 218 |
sys.exit(f"atlas overflow: {used} px > {ATLAS}; lower PPMM")
|
| 219 |
atlas_h = 1 << int(np.ceil(np.log2(used)))
|
| 220 |
+
atlases = {"ct": Image.new("L", (ATLAS, atlas_h), 0)}
|
| 221 |
+
if not NO_INK:
|
| 222 |
+
atlases["ink"] = Image.new("L", (ATLAS, atlas_h), 0)
|
| 223 |
for w in names:
|
| 224 |
+
for k in atlases:
|
| 225 |
+
atlases[k].paste((ink_tiles if k == "ink" else ct_tiles)[w], pos[w])
|
| 226 |
for k, im in atlases.items():
|
| 227 |
im.save(f"{OUT}/atlas_{k}.jpg", quality=88, optimize=True, progressive=True)
|
| 228 |
+
im.save(f"{OUT}/atlas_{k}.webp", quality=82, method=6)
|
| 229 |
print("atlas", ATLAS, atlas_h, "used rows", used)
|
| 230 |
|
| 231 |
# meshes
|
|
|
|
| 243 |
px = np.where(vs, xs - cx0, 0)
|
| 244 |
py = np.where(vs, ys - cy0, 0)
|
| 245 |
pz = np.where(vs, zs - zc, 0)
|
| 246 |
+
st = np.stack([px, py, pz], -1).round()
|
| 247 |
+
assert np.abs(st).max() < 32767, "coordinates overflow int16: use a coarser mesh"
|
| 248 |
+
arr = st.astype(np.int16)
|
| 249 |
arr[~vs] = -32768
|
| 250 |
b = arr.tobytes()
|
| 251 |
blobs.append(b)
|
|
|
|
| 260 |
"gridH": int(H), "gridW": int(W),
|
| 261 |
"offset": offset, "bytes": len(b),
|
| 262 |
"widthMM": round(W * CELL_MM, 2), "heightMM": round(H * CELL_MM, 2),
|
| 263 |
+
"areaCM2": round((meta["area_vx2"] * VOX_MM * VOX_MM if "area_vx2" in meta else v.sum() * CELL_MM * CELL_MM) / 100, 2),
|
| 264 |
"radiusMM": round(float(np.median(r)), 2),
|
| 265 |
"atlas": [x0 / ATLAS, y0 / atlas_h, (x0 + tw) / ATLAS, (y0 + th) / atlas_h],
|
| 266 |
+
**({} if NO_INK else {"hi": f"hi/{w}.jpg", "ink_source": src.get("ink_ds") or [f for f, _ in src["ink"] if "2.399um" in f][0]}),
|
|
|
|
| 267 |
"mesh_source": src["mesh"],
|
| 268 |
})
|
| 269 |
offset += len(b)
|
| 270 |
open(f"{OUT}/scroll.bin", "wb").write(b"".join(blobs))
|
| 271 |
manifest = {
|
| 272 |
+
"scroll": SCROLL,
|
| 273 |
"voxelMM": VOX_MM, "cellMM": CELL_MM,
|
| 274 |
"axis": {"centre": [cx0, cy0, zc], "dxdz": ax[0], "dydz": ay[0]},
|
| 275 |
"atlas": {"w": ATLAS, "h": atlas_h, "ppmm": PPMM},
|
tools/fetch.py
CHANGED
|
@@ -14,10 +14,14 @@ import urllib.parse
|
|
| 14 |
import urllib.request
|
| 15 |
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
|
| 17 |
-
B = "https://vesuvius-challenge-open-data.s3.
|
| 18 |
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 19 |
RAW = os.environ.get("UNROLL_RAW", f"{ROOT}/raw")
|
| 20 |
-
SCROLL = "PHerc0139"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def get(url, tries=5):
|
|
@@ -73,21 +77,36 @@ def main():
|
|
| 73 |
inv = list(ex.map(segment, segs))
|
| 74 |
json.dump(inv, open(f"{RAW}/inv_{SCROLL}.json", "w"), indent=1)
|
| 75 |
jobs = []
|
| 76 |
-
for s in inv
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
|
|
|
| 80 |
continue
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
d = f"{RAW}/{w}"
|
| 83 |
os.makedirs(d, exist_ok=True)
|
| 84 |
base = f"{B}/{SCROLL}/segments/{s['seg']}"
|
| 85 |
for f in ("x.tif", "y.tif", "z.tif", "meta.json"):
|
| 86 |
jobs.append((f"{base}/mesh/{mesh}/{f}", f"{d}/{f}"))
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
open(f"{d}/source.json", "w"), indent=1)
|
| 92 |
|
| 93 |
def fetch(job):
|
|
|
|
| 14 |
import urllib.request
|
| 15 |
from concurrent.futures import ThreadPoolExecutor
|
| 16 |
|
| 17 |
+
B = "https://vesuvius-challenge-open-data.s3.amazonaws.com"
|
| 18 |
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 19 |
RAW = os.environ.get("UNROLL_RAW", f"{ROOT}/raw")
|
| 20 |
+
SCROLL = os.environ.get("UNROLL_SCROLL", "PHerc0139")
|
| 21 |
+
MESH_RES = os.environ.get("UNROLL_MESH_RES", "9.362um") # which tifxyz to take (the coarse scan keeps int16 coordinates in range)
|
| 22 |
+
MESH_VOL = os.environ.get("UNROLL_MESH_VOL", "") # optional volume id, when a segment has two meshes at one resolution
|
| 23 |
+
INK_RES = os.environ.get("UNROLL_INK_RES", "") # which ink preview to call ink2um_ds8.jpg ("" = the non-1.129 um one, newest model)
|
| 24 |
+
NO_INK = os.environ.get("UNROLL_NO_INK") == "1" # X-ray only: fetch no ink at all
|
| 25 |
|
| 26 |
|
| 27 |
def get(url, tries=5):
|
|
|
|
| 77 |
inv = list(ex.map(segment, segs))
|
| 78 |
json.dump(inv, open(f"{RAW}/inv_{SCROLL}.json", "w"), indent=1)
|
| 79 |
jobs = []
|
| 80 |
+
named = [re.search(r"-(w\d{3})_", s["seg"]) for s in inv]
|
| 81 |
+
nums = [m.group(1) for m in named if m]
|
| 82 |
+
use_w = len(nums) >= 0.8 * len(inv) and len(set(nums)) == len(nums)
|
| 83 |
+
for s, m in zip(inv, named):
|
| 84 |
+
if use_w and not m:
|
| 85 |
continue
|
| 86 |
+
meshes = [x for x in s.get("mesh", []) if x.endswith(f"-{MESH_RES}.tifxyz") and MESH_VOL in x]
|
| 87 |
+
if not meshes:
|
| 88 |
+
continue
|
| 89 |
+
inks = sorted(s.get("ink_ds", []))
|
| 90 |
+
main_ink = [f for f in inks if (INK_RES in f if INK_RES else "1.129um" not in f)]
|
| 91 |
+
if not NO_INK and not main_ink:
|
| 92 |
+
print("skip", s["seg"], "(no ink preview)")
|
| 93 |
+
continue
|
| 94 |
+
# wNNN when the segment names carry unique winding numbers; otherwise the segment timestamp (build renumbers by radius)
|
| 95 |
+
w = m.group(1) if use_w else "s" + s["seg"].split("-")[0]
|
| 96 |
+
mesh = meshes[0]
|
| 97 |
d = f"{RAW}/{w}"
|
| 98 |
os.makedirs(d, exist_ok=True)
|
| 99 |
base = f"{B}/{SCROLL}/segments/{s['seg']}"
|
| 100 |
for f in ("x.tif", "y.tif", "z.tif", "meta.json"):
|
| 101 |
jobs.append((f"{base}/mesh/{mesh}/{f}", f"{d}/{f}"))
|
| 102 |
+
if not NO_INK:
|
| 103 |
+
jobs.append((f"{base}/ink-detection/downsampled/{main_ink[-1]}", f"{d}/ink2um_ds8.jpg"))
|
| 104 |
+
if True:
|
| 105 |
+
for f in inks:
|
| 106 |
+
if "1.129um" in f:
|
| 107 |
+
jobs.append((f"{base}/ink-detection/downsampled/{f}", f"{d}/ink1um_ds8.jpg"))
|
| 108 |
+
json.dump({"seg": s["seg"], "w": w, "mesh": mesh, "sv": s.get("sv"), "ink": s.get("ink"),
|
| 109 |
+
"ink_ds": None if NO_INK else main_ink[-1]},
|
| 110 |
open(f"{d}/source.json", "w"), indent=1)
|
| 111 |
|
| 112 |
def fetch(job):
|
tools/make_manifest.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Write scrolls/<id>/manifest.json and the registry entry for a scroll built by tools/build.py.
|
| 2 |
+
|
| 3 |
+
python tools/make_manifest.py PHerc0814
|
| 4 |
+
|
| 5 |
+
Text here is our own wording: no titles, authors or work names, nothing copied from the data browser.
|
| 6 |
+
The prize field mirrors the "prize eligible" badge on scrollprize.org (see https://scrollprize.org/prizes).
|
| 7 |
+
"""
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
| 13 |
+
CITE = ("Giorgio Angelotti, Stephen Parsons, Sean Johnson, Elian Rafael Dal Prà, Johannes Rudolph, Paul Tafforeau, "
|
| 14 |
+
"Alessandro Mirone, Paul Henderson, Hendrik Schilling, Forrest McDonald, David Josey, Youssef Nader, C. Seth Parker, "
|
| 15 |
+
"W. Brent Seales. Vesuvius Challenge - CT Scans of Herculaneum Papyri. Vesuvius Challenge.")
|
| 16 |
+
EDUCELAB = ("EduceLab-Scrolls: Parsons, S., Parker, C. S., Chapman, C., Hayashida, M., & Seales, W. B. (2023). "
|
| 17 |
+
"EduceLab-Scrolls: Verifiable Recovery of Text from Herculaneum Papyri using X-ray CT. ArXiv [Cs.CV]. "
|
| 18 |
+
"https://doi.org/10.48550/arXiv.2304.02084. Data used here were obtained from the EduceLab-Scrolls dataset.")
|
| 19 |
+
CHECKED = "2026-10-16"
|
| 20 |
+
|
| 21 |
+
# per scroll: display name, holder, scan of the meshes, X-ray scan, ink layers, prize sets
|
| 22 |
+
S = {
|
| 23 |
+
"PHerc0814": dict(name="PHerc. 814", holder="Biblioteca Nazionale di Napoli", vox=9.362, ct=9.362, scan="ESRF",
|
| 24 |
+
ink=2.399, ink1=1.129),
|
| 25 |
+
"PHerc0009B": dict(name="PHerc. 9B", holder="Biblioteca Nazionale di Napoli", vox=8.64, ct=8.64, scan="ESRF", ink=2.401),
|
| 26 |
+
"PHerc0343P": dict(name="PHerc. 343P", holder="Biblioteca Nazionale di Napoli", vox=8.64, ct=8.64, scan="ESRF", ink=2.215),
|
| 27 |
+
"PHerc0841": dict(name="PHerc. 841", holder="Biblioteca Nazionale di Napoli", vox=9.366, ct=9.366, scan="ESRF", ink=2.403),
|
| 28 |
+
"PHerc0172": dict(name="PHerc. 172 (Scroll 5)", holder="Bodleian Library, Oxford", vox=7.91, ct=7.91, scan="Diamond Light Source",
|
| 29 |
+
ink=7.91),
|
| 30 |
+
"PHerc1667": dict(name="PHerc. 1667 (Scroll 4)", holder="Biblioteca Nazionale di Napoli", vox=7.91, ct=2.399, scan="ESRF",
|
| 31 |
+
ink=2.399, ink1=1.129, educelab=True),
|
| 32 |
+
"PHerc1447": dict(name="PHerc. 1447", holder="Biblioteca Nazionale di Napoli", vox=8.64, ct=8.64, scan="ESRF",
|
| 33 |
+
prize=["2027 Grand Prize"]),
|
| 34 |
+
"PHerc0800": dict(name="PHerc. 800", holder="Biblioteca Nazionale di Napoli", vox=8.64, ct=8.64, scan="ESRF",
|
| 35 |
+
prize=["2027 Grand Prize", "First Letters"]),
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def um(x):
|
| 40 |
+
return f"{x:g} µm"
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def main(sid):
|
| 44 |
+
c = S[sid]
|
| 45 |
+
d = f"data/{sid}"
|
| 46 |
+
M = json.load(open(f"{ROOT}/{d}/scroll.json"))
|
| 47 |
+
ids = [w["id"] for w in M["windings"]]
|
| 48 |
+
n = len(ids)
|
| 49 |
+
layers = [{
|
| 50 |
+
"id": "xray", "role": "ct", "label": "X‑ray", "atlas": f"{d}/atlas_ct.webp", "atlasFallback": f"{d}/atlas_ct.jpg",
|
| 51 |
+
"sizeHint": os.path.getsize(f"{ROOT}/{d}/atlas_ct.webp"),
|
| 52 |
+
"title": "Raw X-ray CT brightness on each segment. No ink detection.",
|
| 53 |
+
"hint": "Raw X-ray CT brightness, no ink detection.",
|
| 54 |
+
"source": f"Vesuvius Challenge surface volumes, {um(c['ct'])} scan", "resolutionUM": c["ct"],
|
| 55 |
+
}]
|
| 56 |
+
if c.get("ink"):
|
| 57 |
+
layers.append({
|
| 58 |
+
"id": "ink", "role": "ink", "label": f"Published ink ({um(c['ink'])})", "atlas": f"{d}/atlas_ink.webp",
|
| 59 |
+
"atlasFallback": f"{d}/atlas_ink.jpg", "sizeHint": os.path.getsize(f"{ROOT}/{d}/atlas_ink.webp"),
|
| 60 |
+
"hi": f"{d}/hi/{{id}}.jpg",
|
| 61 |
+
"title": f"Vesuvius Challenge's published ink detection on the {um(c['ink'])} scan.",
|
| 62 |
+
"hint": f"Published ink ({um(c['ink'])}): Vesuvius Challenge detection on the {um(c['ink'])} scan.",
|
| 63 |
+
"closeupLabel": f"Published ink ({um(c['ink'])})",
|
| 64 |
+
"source": "Vesuvius Challenge ink detection (ink-detection/downsampled), open data bucket",
|
| 65 |
+
"resolutionUM": c["ink"],
|
| 66 |
+
})
|
| 67 |
+
if c.get("ink1") and os.path.isdir(f"{ROOT}/{d}/ink1"):
|
| 68 |
+
cov = [i for i in ids if os.path.exists(f"{ROOT}/{d}/ink1/{i}.jpg")]
|
| 69 |
+
layers.append({
|
| 70 |
+
"id": "ink1", "role": "overlay", "label": f"Published ink ({um(c['ink1'])})",
|
| 71 |
+
"windings": cov if len(cov) < n else "all",
|
| 72 |
+
"texture": f"{d}/ink1/{{id}}.jpg", "hi": f"{d}/hi/{{id}}_1um.jpg", "sizeHint": 400000,
|
| 73 |
+
"title": f"Vesuvius Challenge's published ink detection on the finer {um(c['ink1'])} scan, on {len(cov)} of {n} segments.",
|
| 74 |
+
"hint": f"Published ink ({um(c['ink1'])}): Vesuvius Challenge detection on the {um(c['ink1'])} scan, {len(cov)} of {n} segments.",
|
| 75 |
+
"closeupLabel": f"Published ink ({um(c['ink1'])})", "readoutLabel": "INK 1 µm",
|
| 76 |
+
"bandTitle": f"Segments with published ink on the {um(c['ink1'])} scan",
|
| 77 |
+
"source": "Vesuvius Challenge ink detection (ink-detection/downsampled), open data bucket",
|
| 78 |
+
"resolutionUM": c["ink1"],
|
| 79 |
+
})
|
| 80 |
+
notes = ("Meshes, X-ray and ink images come only from the open data bucket vesuvius-challenge-open-data "
|
| 81 |
+
f"(s3://vesuvius-challenge-open-data/{sid}/), licensed CC BY-NC 4.0: attribution, non-commercial use. "
|
| 82 |
+
"This page shows images only: no transcriptions or readings of the text.")
|
| 83 |
+
if c.get("prize"):
|
| 84 |
+
notes += " This scroll is prize eligible on scrollprize.org; no ink model is run on it here."
|
| 85 |
+
credits = {"text": "Vesuvius Challenge", "url": f"https://scrollprize.org/data_browser/{sid}", "citation": CITE,
|
| 86 |
+
"holder": c["holder"]}
|
| 87 |
+
if c.get("educelab"):
|
| 88 |
+
credits["citation"] = CITE + " " + EDUCELAB
|
| 89 |
+
k = len([l for l in layers if l["role"] != "ct"])
|
| 90 |
+
kind = "X-ray only, no ink layer" if not k else ("one published ink layer" if k == 1 else f"{['', '', 'two', 'three', 'four'][k] if k < 5 else k} published ink layers")
|
| 91 |
+
man = {
|
| 92 |
+
"schema": "unroll-scroll/1", "id": sid, "name": c["name"],
|
| 93 |
+
"description": f"A carbonized Herculaneum papyrus scanned at {c['scan']}. {n} traced segments from the Vesuvius Challenge open data, {kind}.",
|
| 94 |
+
"voxelMM": c["vox"] / 1000, "base": "../../",
|
| 95 |
+
"geometry": {"format": "grid-int16", "index": f"{d}/scroll.json", "bin": f"{d}/scroll.bin",
|
| 96 |
+
"sizeHint": os.path.getsize(f"{ROOT}/{d}/scroll.bin"),
|
| 97 |
+
"source": f"Vesuvius Challenge {um(c['vox'])} tifxyz meshes, decimated by tools/build.py"},
|
| 98 |
+
"windings": ids, "outermost": "last",
|
| 99 |
+
"layers": layers,
|
| 100 |
+
"credits": credits,
|
| 101 |
+
"dataTerms": {"license": "CC BY‑NC 4.0", "url": "https://creativecommons.org/licenses/by-nc/4.0/", "notes": notes},
|
| 102 |
+
"notes": "Segment ids w001 and up are ours, ordered by median radius (innermost first); scroll.json keeps each source segment.",
|
| 103 |
+
}
|
| 104 |
+
os.makedirs(f"{ROOT}/scrolls/{sid}", exist_ok=True)
|
| 105 |
+
json.dump(man, open(f"{ROOT}/scrolls/{sid}/manifest.json", "w"), indent=1, ensure_ascii=False)
|
| 106 |
+
reg = json.load(open(f"{ROOT}/scrolls/index.json"))
|
| 107 |
+
entry = {"id": sid, "name": c["name"], "manifest": f"scrolls/{sid}/manifest.json",
|
| 108 |
+
"prize": {"eligible": bool(c.get("prize")), "sets": c.get("prize", []),
|
| 109 |
+
"source": "https://scrollprize.org/prizes", "checked": CHECKED}}
|
| 110 |
+
reg["scrolls"] = [e for e in reg["scrolls"] if e["id"] != sid] + [entry]
|
| 111 |
+
for e in reg["scrolls"]:
|
| 112 |
+
e.setdefault("prize", {"eligible": False, "sets": [], "source": "https://scrollprize.org/prizes", "checked": CHECKED})
|
| 113 |
+
json.dump(reg, open(f"{ROOT}/scrolls/index.json", "w"), indent=1, ensure_ascii=False)
|
| 114 |
+
print("wrote", sid, n, "segments,", [l["id"] for l in layers])
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
if __name__ == "__main__":
|
| 118 |
+
main(sys.argv[1])
|