SharpMeow commited on
Commit
cf20894
·
1 Parent(s): 235aa03

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 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 opts = REG.scrolls.map((x) => [x.id, x.name || x.id]).concat(LOCALS.map((x) => [x.key, `${x.name} (local)`]));
174
- for (const [k, n] of opts) { const o = document.createElement('option'); o.value = k; o.textContent = n; sel.appendChild(o); }
175
- sel.value = SCROLL_KEY;
 
 
 
 
 
 
 
 
 
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
- $('layerWhy').hidden = !(L_OV && (D?.layerWhy || L_OV.why));
783
- if (!$('layerWhy').hidden) $('layerWhy').querySelector('p').textContent = D?.layerWhy || L_OV.why;
 
 
 
 
 
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.us-east-1.amazonaws.com"
32
- VOX_MM = 0.009362 # 9.362 um scan voxel
 
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 # atlas pixels per mm
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("9.362um")][0]
51
- base = f"{B}/PHerc0139/segments/{seg}/surface-volumes/{sv}/3"
 
 
 
 
 
 
 
 
 
 
 
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
- return ij, a[CT_LAYERS[0]:CT_LAYERS[1]].astype(np.float32).mean(0)
 
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}/w*"))
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
- ink = Image.open(f"{d}/ink2um_ds8.jpg").convert("L")
152
- ink_tiles[w] = local_contrast(ink.resize((tw, th), Image.LANCZOS), 2.2 * PPMM)
153
- hi = ink.copy()
154
- hi.thumbnail((HI_MAX, HI_MAX), Image.LANCZOS)
155
- hi.save(f"{OUT}/hi/{w}.jpg", quality=84, optimize=True, progressive=True)
 
 
 
 
 
 
 
 
 
 
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 = {"ink": Image.new("L", (ATLAS, atlas_h), 0), "ct": Image.new("L", (ATLAS, atlas_h), 0)}
 
 
168
  for w in names:
169
- atlases["ink"].paste(ink_tiles[w], pos[w])
170
- atlases["ct"].paste(ct_tiles[w], pos[w])
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
- arr = np.stack([px, py, pz], -1).round().astype(np.int16)
 
 
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": "PHerc0139",
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.us-east-1.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 = "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
- m = re.search(r"-(w\d{3})_", s["seg"])
78
- meshes = [x for x in s.get("mesh", []) if "9.362um" in x]
79
- if not m or not meshes:
 
80
  continue
81
- w, mesh = m.group(1), meshes[0]
 
 
 
 
 
 
 
 
 
 
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
- for f in s.get("ink_ds", []):
88
- tag = "ink1um" if "1.129um" in f else "ink2um"
89
- jobs.append((f"{base}/ink-detection/downsampled/{f}", f"{d}/{tag}_ds8.jpg"))
90
- json.dump({"seg": s["seg"], "w": w, "mesh": mesh, "sv": s.get("sv"), "ink": s.get("ink")},
 
 
 
 
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])