"""Crackle Check: upload a region of surface layers, learn whether an ink model sees ink (crackle) above its control. The page and its API. Uploads are read in memory (upload.py), checked and judged by crackle.py, and run by Ink Lens's engine (engine.py) in one ZeroGPU call that always runs the stored and the reversed layer order together. When ZeroGPU refuses (quota, no GPU free) a region of at most CPU_SIDE px runs on the Space's CPU with one depth window. The Space keeps nothing. python3 app.py serve the page (on a Space, or on any computer: CUDA if present, else CPU) """ from __future__ import annotations import functools import os import sys import tempfile import threading import time from pathlib import Path try: import spaces # first, before torch: on a ZeroGPU Space it attaches the GPU to spaces.GPU functions except ImportError: spaces = None HERE = Path(__file__).resolve().parent if str(HERE) not in sys.path: sys.path.insert(0, str(HERE)) import crackle # noqa: E402 import engine # noqa: E402 import figures # noqa: E402 import gate # noqa: E402 import koine_engine # noqa: E402 import link # noqa: E402 import upload # noqa: E402 ON_ZERO_GPU = bool(spaces) and os.environ.get("SPACES_ZERO_GPU", "").lower() in ("1", "true") # The readers a visitor can pick: Ink Lens engine readers (128 px) and koine readers (koine_engine.KOINE_READERS). # A new reader is one entry in either table plus its name here (or in CRACKLE_READERS). READERS = tuple(r.strip() for r in os.environ.get("CRACKLE_READERS", "readerv3,d9v2").split(",") if r.strip()) # The default is the released reader that ranked first on the fair PHerc0841 crops (Chase, 23 Oct 2026: "have it # default to the current top ink model"): Reader V3, mean AUC 0.890 against 0.862 for Reader v2 and 0.861 for d9v2 # (docs/prereg/2026-10-21-reader-ranking-a1.md, docs/FINDINGS.md). Change it here when a ranking changes. DEFAULT_READER = os.environ.get("CRACKLE_READER", "readerv3") DEFAULT_WHY = ("Reader V3 is the default: it ranked first among the released ink models we scored on public PHerc0841 " "crops (mean AUC 0.890; d9v2 0.861; 21 Oct 2026). Both averages the two.") TITLES = {**{n: r["title"] for n, r in engine.READERS.items()}, **{n: r["title"] for n, r in koine_engine.KOINE_READERS.items()}} KOINE_PATCH_COST = 9 # one 384 px koine patch against one 128 px Ink Lens patch, for the GPU-time request CPU_SIDE = 640 OUT = Path(tempfile.gettempdir()) / "crackle-check-out" ASSETS = HERE / "assets" # pictures for the landing card and the guide (scripts/media/crackle_space_assets.py) BAKED = HERE / "baked" # saved per-reader maps of the w00 and ag896 examples (on the Space only, not in git) EXAMPLES = ("0841-w00", "0841-ag896", "0841-ag405") # public PHerc0841 crops, not prize-eligible (samples.json) EXAMPLE_CACHE = {} SEGMENT_CACHE = {} # (scroll, segment, volume, x, y, size, reader) -> result; regions of open data only SEGMENT_CACHE_MAX = 32 ENGINE, KOINE, ENGINE_ERROR = None, {}, None READY = threading.Event() CPU_SLOT = threading.Semaphore(1) GPU_REFUSALS = ("ZeroGPU quota exceeded", "ZeroGPU illegal duration", "ZeroGPU pending credits exceeded", "ZeroGPU queue timeout", "ZeroGPU client error") def log(message): print(f"[crackle-check {time.strftime('%H:%M:%S')}] {message}", flush=True) def _load(): global ENGINE, ENGINE_ERROR try: t0 = time.time() # On ZeroGPU the GPU exists only inside gpu_run; a .to("cuda") in this thread is not intercepted and # kills start-up, so the models stay on the CPU here and each run moves them to the call's device. flat = tuple(n for n in READERS if n in engine.READERS) if flat: ENGINE = engine.Engine(readers=flat, place_on_cuda=False) for n in READERS: if n in koine_engine.KOINE_READERS: KOINE[n] = koine_engine.KoineReader(n) unknown = [n for n in READERS if n not in engine.READERS and n not in koine_engine.KOINE_READERS] if unknown: raise crackle.CheckError(f"unknown readers {unknown}") log(f"{', '.join(READERS)} ready in {time.time() - t0:.1f} s") except Exception as exc: # noqa: BLE001 ENGINE_ERROR = f"{type(exc).__name__}: {exc}" log(f"readers failed to load: {ENGINE_ERROR}") finally: READY.set() def startup(): import shutil shutil.rmtree(OUT, ignore_errors=True) OUT.mkdir(parents=True, exist_ok=True) threading.Thread(target=_load, name="load-reader", daemon=True).start() def eng(wait_s=5.0): if not READY.wait(wait_s): raise crackle.CheckError("the models are still loading (about a minute after the Space wakes up); try again " "shortly") if ENGINE_ERROR: raise crackle.CheckError(f"the models failed to load: {ENGINE_ERROR}") return ENGINE def names_of(reader): names = list(READERS) if reader == "both" else [reader] bad = [n for n in names if n not in READERS] if bad: raise crackle.CheckError(f"reader {bad[0]!r} is not offered here; pick one of {', '.join(READERS)} or both") return names def _duration(volume, windows, reader): n = 0 for name in names_of(reader): if name in KOINE: n += KOINE_PATCH_COST * koine_engine.patches(volume.shape[1], volume.shape[2], KOINE[name].info["patch"]) else: n += engine.patches(volume.shape[1], volume.shape[2], windows, name) return engine.gpu_seconds(n) def _on_gpu(fn): return spaces.GPU(duration=_duration)(fn) if ON_ZERO_GPU else fn def run_readers(volume, windows, reader, device): """Every chosen reader in both layer orders on one device; maps averaged over readers, as Ink Lens does.""" import numpy as np t0 = time.perf_counter() per, n = {}, 0 for name in names_of(reader): if name in KOINE: out = KOINE[name].run(volume, device=device) else: out = ENGINE.run(volume, reader=name, windows=windows, device=device) per[name] = {"forward": out["forward"], "reverse": out["reverse"]} n += out["patches"] F = np.mean([m["forward"] for m in per.values()], axis=0).astype(np.float32) R = np.mean([m["reverse"] for m in per.values()], axis=0).astype(np.float32) import torch dev = torch.device(device) name = torch.cuda.get_device_name(dev) if dev.type == "cuda" else "CPU" return {"forward": F, "reverse": R, "per_reader": per, "readers": list(per), "patches": n, "seconds": round(time.perf_counter() - t0, 3), "device_name": name} @_on_gpu def gpu_run(volume, windows, reader): """Both layer orders in one call, so a map never runs without its control.""" return run_readers(volume, windows, reader, "cuda") def _is_refusal(exc): return (str(getattr(exc, "title", "") or "") in GPU_REFUSALS or "GPU task aborted" in str(exc) or "No GPU was available" in str(exc)) def run_engine(volume, windows, reader): eng() import torch if ON_ZERO_GPU: try: return gpu_run(volume, windows, reader) except Exception as exc: # noqa: BLE001 if not _is_refusal(exc): raise note = "ZeroGPU is not available right now" elif torch.cuda.is_available(): return run_readers(volume, windows, reader, "cuda") else: note = "No GPU here" if max(volume.shape[1:]) > CPU_SIDE: raise crackle.CheckError(f"{note}, and the CPU fallback takes regions up to {CPU_SIDE} px a side; crop the " "region or try again later") if not CPU_SLOT.acquire(blocking=False): raise crackle.CheckError(f"{note}, and the CPU is busy with another check; try again in a minute") try: torch.set_num_threads(max(1, (os.cpu_count() or 2) - 1)) return run_readers(volume, 1, reader, "cpu") finally: CPU_SLOT.release() def _save(out): """The maps of one check as files a person or a script can keep: an .npz (forward, reverse, kept) and the overlay PNG and, when the gate set a threshold, the four-panel PNG (CT, overlay, your order, control). Returns the paths, the .npz first.""" import numpy as np from PIL import Image OUT.mkdir(parents=True, exist_ok=True) stem = OUT / f"crackle-{int(time.time() * 1000)}" np.savez_compressed(f"{stem}.npz", forward=out["forward"], reverse=out["reverse"], kept=out["kept"]) Image.fromarray(out["preview"]).save(f"{stem}-overlay.png") files = [f"{stem}.npz", f"{stem}-overlay.png"] if out.get("panels") is not None: Image.fromarray(out["panels"]).save(f"{stem}-panels.png") files.append(f"{stem}-panels.png") return files def _checked(array, kind, reader, um_per_px=None, flip=False, notes=(), run=None): reader = str(reader or DEFAULT_READER) names_of(reader) out = crackle.run_check(array, kind, run or (lambda st, w: run_engine(st, w, reader)), um_per_px=um_per_px or None, flip=bool(flip), notes=list(notes)) out["files"] = _save(out) out["npz"], out["png"] = out["files"][:2] return out def check_file(path, um_per_px=None, flip=False, reader=None): """Crackle check of one uploaded region of surface layers. path: a ZIP of layer images, a multi-page TIFF, or an .npz with array 'volume' (layers first, 17 to 65 layers centered on the papyrus surface, 128 to 2048 px a side). A single image is refused with the reason (one layer is not enough). um_per_px: the pixel size of the upload in micrometers (blank or 0: about 9 um, used as it is). flip: reverse the layer order first. reader: readerv3 (Reader V3, the default), d9v2 or both (maps averaged). Returns (answer as Markdown, result dict, overlay preview, maps .npz with float32 forward and reverse and uint8 kept). Model output, not a reading.""" if not path: raise crackle.CheckError("upload a file first") p = path if isinstance(path, str) else path.get("path") try: array, kind, note = upload.read(p) finally: try: Path(p).unlink() except OSError: pass out = _checked(array, kind, reader, um_per_px, flip, [note]) return out["markdown"], out["result"], out["preview"], out["npz"] def baked_maps(sample_id): """The saved maps of a public example, per reader, as {reader: {"forward", "reverse"}} with their manifest entry, or (None, None) when there are none or their SHA-256 does not match assets/baked.json (then the GPU runs).""" import hashlib import json import numpy as np try: entry = json.loads((ASSETS / "baked.json").read_text())["examples"][str(sample_id)] raw = (BAKED / entry["file"]).read_bytes() except (OSError, ValueError, KeyError): return None, None if hashlib.sha256(raw).hexdigest() != entry["sha256"]: log(f"baked maps of {sample_id} do not match their SHA-256; running the models instead") return None, None import io z = np.load(io.BytesIO(raw)) scale = float(entry["scale"]) return ({r: {k: z[f"{r}_{k}"].astype(np.float32) / scale for k in ("forward", "reverse")} for r in entry["readers"]}, entry) def _baked_run(maps, names, entry): """A run function for crackle.run_check that returns saved maps, averaged over readers as run_readers does.""" import numpy as np def run(stack, windows): F = np.mean([maps[n]["forward"] for n in names], axis=0).astype(np.float32) R = np.mean([maps[n]["reverse"] for n in names], axis=0).astype(np.float32) if F.shape != stack.shape[1:]: raise crackle.CheckError(f"the saved maps {F.shape} do not fit the example {stack.shape[1:]}") return {"forward": F, "reverse": R, "per_reader": {n: maps[n] for n in names}, "readers": list(names), "patches": 0, "seconds": 0.0, "device_name": f"saved {entry['device']} run of {entry['made'][:10]}"} return run def _example(sample_id, reader): import inklens # Ink Lens's client: public samples from the open-data bucket key = (str(sample_id), str(reader or DEFAULT_READER)) if key not in EXAMPLE_CACHE: if key[0] not in EXAMPLES: raise crackle.CheckError(f"no example {key[0]!r}; pick one of {', '.join(EXAMPLES)}") s = inklens.sample_by(key[0]) volume, _ = inklens.read_sample(s, inklens.sample_box(s, 640)) notes = [f"public example {s['title']}, crop {list(s['crop'])}"] maps, entry = baked_maps(key[0]) names = names_of(key[1]) run = None if maps and all(n in maps for n in names): run = _baked_run(maps, names, entry) notes.append(f"maps saved from a ZeroGPU run of the same models and crop ({entry['made'][:10]}), so no GPU " "is needed; your own layers always run live") out = _checked(volume, "stack", key[1], notes=notes, run=run) out["result"]["example"] = {"id": s["id"], "scroll": s["scroll"], "segment": s["segment"], "crop": list(s["crop"]), "role": s["role"]} EXAMPLE_CACHE[key] = out return EXAMPLE_CACHE[key] def check_example(sample_id="0841-w00", reader=None): """Crackle check of a public example: a 640 px crop of a labeled PHerc0841 segment (not prize-eligible), read from the Vesuvius Challenge open-data bucket. sample_id: 0841-w00, 0841-ag896 or 0841-ag405. reader: readerv3 (default), d9v2 or both. Same returns as /check. Results are cached per sample and reader. Model output.""" out = _example(sample_id, reader) return out["markdown"], out["result"], out["preview"], out["npz"] def _segment(segment, volume, x, y, size, reader): scroll, seg = link.parse(segment) if not volume: usable = [v for v in link.volumes(scroll, seg) if v["usable"]] if not usable: raise link.LinkError(f"{scroll}/segments/{seg} has no surface volume with enough layers for the check") volume = usable[0]["name"] key = (scroll, seg, str(volume), int(round(float(x or 0))), int(round(float(y or 0))), int(size or link.SIDE), str(reader or DEFAULT_READER)) if key not in SEGMENT_CACHE: stack, um, note = link.region(scroll, seg, key[2], key[3], key[4], key[5]) out = _checked(stack, "stack", key[6], um_per_px=um, notes=[note]) out["result"]["segment"] = {"scroll": scroll, "segment": seg, "volume": key[2], "x": key[3], "y": key[4], "size": key[5]} if len(SEGMENT_CACHE) >= SEGMENT_CACHE_MAX: SEGMENT_CACHE.pop(next(iter(SEGMENT_CACHE))) SEGMENT_CACHE[key] = out return SEGMENT_CACHE[key] def check_segment(segment, volume="", x=0, y=0, size=384, reader=None): """Crackle check of a spot on a released segment, for people who have only a screenshot: the Space reads that region's layers from the Vesuvius Challenge open-data bucket itself. segment: SCROLL/segments/SEGMENT (or its open-data URL). volume: one of the segment's surface volumes (blank: the cheapest usable one, see /list_volumes). x, y: the center, in that volume's pixels (column, row). size: 320 to 640 px on the readers' 9.4 um grid. reader: readerv3 (default), d9v2 or both. Same returns as /check. Model output.""" out = _segment(segment, volume, x, y, size, reader) return out["markdown"], out["result"], out["preview"], out["npz"] def list_volumes(segment): """The surface volumes of a released segment in the open data that /check_segment can read, cheapest first, with pixel size, shape and the MB one check reads.""" scroll, seg = link.parse(segment) return {"segment": f"{scroll}/segments/{seg}", "volumes": link.volumes(scroll, seg)} def list_readers(): """The readers this Space offers, with license, source and what each was held out from, and the examples.""" info = {} for n in READERS: r = engine.READERS.get(n) or koine_engine.KOINE_READERS[n] info[n] = {k: r[k] for k in ("title", "license", "source", "held_out") if k in r} info[n]["sha256"] = r["sha256"] return {"readers": info, "default": DEFAULT_READER, "examples": list(EXAMPLES), "tool": f"Crackle Check {crackle.VERSION}", "rule": gate.RULE_ID} # ---------------------------------------------------------------- the page CSS = """ :root, .light { --cc-yes: #1f7a4d; --cc-yes-bg: #e6f4ec; --cc-maybe: #8a5a00; --cc-maybe-bg: #fdf1d8; --cc-no: #4a5563; --cc-no-bg: #eef0f3; --cc-stop: #9b2c2c; --cc-stop-bg: #fbeaea; --cc-amber: #f59e1a; } .dark { --cc-yes: #7fd8a6; --cc-yes-bg: #13301f; --cc-maybe: #f5c46b; --cc-maybe-bg: #33270d; --cc-no: #c3c9d2; --cc-no-bg: #23272e; --cc-stop: #f2a3a3; --cc-stop-bg: #3a1717; } .gradio-container { --cc-gap: 16px; } .cc-plain, .cc-plain > .html-container { padding: 0 !important; border: 0 !important; background: none !important; box-shadow: none !important; } .cc-hero h1 { font-size: 2.1rem; margin: 0 0 .4rem; letter-spacing: -.01em; line-height: 1.15; } .cc-hero p { margin: 0 0 .8rem; opacity: .85; max-width: 62rem; line-height: 1.55; } .cc-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: .4rem; } .cc-chips span { padding: .22rem .7rem; border-radius: 999px; font-size: .82rem; line-height: 1.4; white-space: nowrap; border: 1px solid color-mix(in srgb, currentColor 25%, transparent); } .cc-verdict { border-radius: 12px; padding: 16px 18px; border: 1px solid transparent; } .cc-verdict .word { font-size: 2rem; font-weight: 700; line-height: 1.1; } .cc-verdict .sub { margin-top: .5rem; font-size: .98rem; line-height: 1.55; } .cc-yes { background: var(--cc-yes-bg); --cc-fg: var(--cc-yes); } .cc-maybe { background: var(--cc-maybe-bg); --cc-fg: var(--cc-maybe); } .cc-no { background: var(--cc-no-bg); --cc-fg: var(--cc-no); } .cc-stop { background: var(--cc-stop-bg); --cc-fg: var(--cc-stop); } .cc-verdict .word { color: var(--cc-fg, inherit) !important; } .cc-idle { border: 1px dashed color-mix(in srgb, currentColor 30%, transparent); } .cc-idle .word { opacity: .75; } .cc-kicker { font-size: .78rem; text-transform: uppercase; letter-spacing: .06em; opacity: .65; margin-bottom: .2rem; } .cc-meter { margin-top: .9rem; color: inherit; } .cc-figs { margin-top: 6px; } .cc-figs svg { display: block; margin: 4px 0 2px; } .cc-h { font-weight: 600; font-size: .92rem; margin: 16px 0 6px; } .cc-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: .8rem; } .cc-pair figure, .cc-figs figure { margin: 0; } .cc-pair img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; display: block; } .cc-figs figure.cc-wide { margin: 10px 0; } .cc-figs figure.cc-wide img { width: 100%; max-width: 980px; border-radius: 8px; display: block; } .cc-pair figcaption, .cc-figs figcaption { font-size: .85rem; margin-top: 4px; opacity: .85; line-height: 1.4; } .cc-cap { font-size: .82rem; opacity: .7; margin-top: 6px; line-height: 1.45; max-width: 62rem; } .cc-caption { font-size: .85rem; opacity: .75; margin: 0; } .cc-caption p { margin: 0 !important; } .cc-note p { margin: 0 !important; line-height: 1.55; } #cc-model .wrap { display: grid !important; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; } #cc-model .wrap label { justify-content: center; min-height: 40px; padding: 10px 14px; margin: 0; text-align: center; } #cc-model .wrap label input { display: none; } #cc-go { margin-top: 8px; min-height: 46px; } .cc-acc > button.label-wrap { padding: 0 !important; min-height: 20px; } .cc-acc .form, .cc-acc .block:not(.cc-acc) { border: 0 !important; box-shadow: none !important; padding-left: 0 !important; padding-right: 0 !important; } .prose th { white-space: nowrap; } .cc-foot { opacity: .65; font-size: .85rem; line-height: 1.6; padding-top: 8px; } @media (min-width: 900px) { #cc-right { padding-top: 48px; } } @media (max-width: 600px) { [role=tab] { padding-left: 10px !important; padding-right: 10px !important; } .cc-hero h1 { font-size: 1.7rem; } .cc-chips { display: none; } #cc-model .wrap label { padding: 10px 6px; white-space: nowrap; font-size: .9rem; } } """ # The page's outer padding sits outside the block Gradio scopes css= to, so the phone gutter goes in . HEAD = """""" # One page scroll bar: frames fit their content and the page scrolls (copied from public Scrolls' # spaces/connect/crackle-check/, made by kit spaceconnect render; see VENDORED.md). ONESCROLL = HERE / "onescroll.html" HEAD = HEAD + (ONESCROLL.read_text() if ONESCROLL.exists() else "") INTRO = """

Crackle Check

Is there ink in this patch of a Herculaneum scroll? Give it a small region of a segment as a stack of surface layers. An open ink model reads it twice, once in your layer order and once reversed as the control, and tells you whether the ink signal is stronger than the control.

Yes / Maybe / No signal foundControl-checked Reader V3 and d9v2 (MIT)API, MCP and VC3D pluginModel output, not a reading
""" IDLE = ('
Ready
Try a public example, ' 'upload your own layers, or name a spot on a released segment.
') def _asset_uri(name): """A picture from assets/ as a data URI, or None when it is missing (the page then falls back to text).""" import base64 p = ASSETS / name if not p.is_file(): return None kind = {"webp": "image/webp", "png": "image/png"}.get(p.suffix.lstrip("."), "application/octet-stream") return f"data:{kind};base64," + base64.b64encode(p.read_bytes()).decode("ascii") def _examples_info(): import json try: return json.loads((ASSETS / "examples.json").read_text())["examples"] except (OSError, ValueError, KeyError): return {} def _figure(uri, alt, caption, cls=""): import html return (f'
{html.escape(alt)}' f'
{caption}
') def _readers_text(ex): names = [TITLES.get(n, n).split(" (")[0] for n in ex.get("readers") or []] return " and ".join(names) if names else "the default model" def idle_html(): """The landing card: what an answer looks like, from the w00 example's saved pictures, before anyone runs one.""" ex = _examples_info().get("0841-w00") ct, ov = _asset_uri("w00-ct.webp"), _asset_uri("w00-overlay.webp") if not (ex and ct and ov): return IDLE pair = (_figure(ct, "Gray papyrus CT texture", "Middle CT layer") + _figure(ov, "The same CT with amber patches", "Ink signal above the control, in amber")) return (f'
What an answer looks like
' f'
{ex["answer"]}
{ex["title"]}, a public example read by ' f'{_readers_text(ex)}: {ex["excess"]:.1f} x the control. Press Check this example to run it live, or ' 'upload your own layers.
' f'
{pair}
{figures.gauge_svg(ex["excess"])}
' '
Saved from an earlier run. Model output, not a reading. Data: Vesuvius Challenge ' 'open data, CC BY-NC 4.0.
') def learn_figures(): """The guide's pictures as HTML blocks: crackle up close, the layer stack, and the answer with two examples.""" out = {"crackle": "", "stack": f'
{figures.stack_svg()}
', "answer": ""} inked, plain = _asset_uri("strip-inked.webp"), _asset_uri("strip-plain.webp") if inked and plain: out["crackle"] = ('
' + _figure(inked, "Five gray CT tiles of papyrus at increasing depth", "Where the model sees ink (Reader V3) on PHerc0841 w00, one spot at five depths " "(layers 4 to 24 of 28)", "cc-wide") + _figure(plain, "Five gray CT tiles of papyrus at increasing depth", "Plain papyrus nearby, where it sees none, at the same depths", "cc-wide") + '
Hard to tell apart by eye in any one layer. The texture changes ' 'through the depth, and the models compare all the layers at once, which is why one ' 'screenshot is not enough. CT only. PHerc0841, segment 20260220213127-w00, 9.366 um ' 'voxels. Data: Vesuvius Challenge, CC BY-NC 4.0.
') tiles = [] for sid, short in (("0841-w00", "w00"), ("0841-ag896", "ag896")): uri, ex = _asset_uri(f"{short}-composite.webp"), _examples_info().get(sid) if uri and ex: tiles.append(_figure(uri, "Four square papyrus panels: CT, CT with amber patches, a score map and its " "nearly gray control", f"{ex['title']}: {ex['answer']}, {ex['excess']:.1f} x the control. Left to " "right: CT, kept signal, your order, control", "cc-wide")) out["answer"] = (f'
{figures.gauge_svg()}' + "".join(tiles) + ('
Public examples read by ' f'{_readers_text(_examples_info().get("0841-w00") or {})}. Model output, not a reading. Data: ' 'Vesuvius Challenge open data, CC BY-NC 4.0.
' if tiles else "") + "
") return out STYLE = {"Yes": "cc-yes", "Maybe": "cc-maybe", "No signal found": "cc-no", "Too small": "cc-stop", "Could not judge": "cc-stop"} MEANING = { "Yes": "The model sees ink here clearly above what it sees by chance. Look at the amber marks: they are where.", "Maybe": "Some signal, but close to chance. Look at the overlay, and try a larger region or the other model.", "No signal found": "Nothing above the control here. That is not proof of no ink: many scrolls show little " "crackle, and faint ink can be missed.", "Too small": "Send a larger region: at least about 320 x 320 px of papyrus.", "Could not judge": "The run gave maps that cannot be judged. Try again or another model.", } def verdict_html(result, refusal=None): import html if refusal: text = html.escape(refusal.replace("**", "")) return f'
Not checked
{text}
' word = result["answer"] g = result["gate"] sub = MEANING[word] meter = "" if g.get("excess") is not None: sub += f"
{g['excess']:.1f} x the control (Yes from 3 x, Maybe from 1.5 x)" meter = f'
{figures.gauge_svg(g["excess"])}
' if result.get("hint"): sub += f"
{html.escape(result['hint'])}" model = " and ".join(TITLES.get(n, n).split(" (")[0] for n in (result.get("readers") or [])) if model: sub += f'
Model: {html.escape(model)}' return f'
{word}
{sub}
{meter}
' def numbers_md(result): g = result["gate"] rows = [("Answer", result["answer"]), ("Rule", g.get("rule")), ("Papyrus pixels", f"{g['papyrus_px']:,}")] if g.get("threshold") is not None: rows += [("Threshold T (control's 99th percentile)", f"{g['threshold']:.4f}"), ("Share of papyrus above T, your order", f"{g['forward_above']:.2%}"), ("Share above T, control (reversed)", f"{g['control_above']:.2%}"), ("Excess (your share / 1 %)", f"{g['excess']:.2f}")] for n, r in (result.get("per_reader") or {}).items(): rows.append((f"{TITLES.get(n, n).split(' (')[0]} alone", f"{r['answer']} ({r['excess']})")) rows += [("Region", " x ".join(str(v) for v in result["shape"]) + " (layers x rows x columns)"), ("Depth windows", result["windows"]), ("Run", f"{result.get('seconds')} s on {result.get('device')}")] if result.get("notes"): rows.append(("Input", "; ".join(result["notes"]))) return "| Number | Value |\n| --- | --- |\n" + "\n".join(f"| {k} | {v} |" for k, v in rows) LEARN_WHAT = """## What crackle is On some Herculaneum scrolls, the carbon ink left a fine cracked texture on the papyrus surface, called **crackle**. It shows up in X-ray CT, and it was how the first letters inside a closed scroll were seen. It is the clearest ink signal in the scans, but many scrolls show little or none of it, and ink can be there without visible crackle. """ LEARN_SEND = """## What you send The scans are 3D volumes. A **segment** is one sheet of papyrus traced through the volume and flattened. Its **layers** are flat images of the sheet sampled just above and below the surface (often 65 of them, `00.tif` to `64.tif`). Ink sits in depth, so the models read **17 layers at once**: one screenshot is not enough. 1. Pick a region about 3 to 10 mm across (about 320 to 1024 px at 9 um) on a segment. 2. Take the same crop from at least 17 layers centered on the surface. From a segment's `layers` folder of 65, layers 22 to 42 work, or send all of them. 3. ZIP the images, keeping their numbered names, or save them as one multi-page TIFF. Upload it on the Check tab. In VC3D, the Crackle Check plugin does all of this for the region you choose (see the Research tab). """ LEARN_READ = """## How to read the answer | Answer | Means | | --- | --- | | **Yes** | At least 3 times as many pixels pass the control's threshold as the control itself | | **Maybe** | 1.5 to 3 times: look at the overlay before trusting it | | **No signal found** | Under 1.5 times. Not proof of no ink | | **Too small** | Under about 100,000 papyrus pixels (about 3 mm square) | The **control** is the same region with the layers reversed. Ink models read ink in depth order, so the reversed run shows what a model "finds" by chance in that region. The amber marks are where your order beats the control. After a check, the page shows both maps on one scale, where their scores fall, and where the answer sits on this scale. """ LEARN_FAQ = """## Questions people ask **Can I upload a screenshot?** Not as a picture. On public labeled crops both models scored at chance from one layer. Two small models trained on single layers also failed their preregistered tests: tile AUC 0.57, and 0.48 on two scrolls the model never saw, both against a bar of 0.80. If the screenshot is from a released segment, use **From a segment** instead: give the segment and the spot, and the Space reads the real layers there from the open data. **Does Yes mean there are letters?** No. It means an ink model sees ink-like signal above its control. Everything here is model output, not a reading. **My layers run the other way.** If the reversed order shows ink and yours shows none, the page says so; tick "Flip layer order" and run again. Learn more: [scrollprize.org](https://scrollprize.org), the [data](https://scrollprize.org/data) and the [tutorials](https://scrollprize.org/tutorial1). """ def researchers_md(): rows = [] for n in READERS: r = engine.READERS.get(n) or koine_engine.KOINE_READERS[n] rows.append(f"| `{n}` | [{r['title']}]({r['source']}) | {r['license']} | {r['held_out']} | `{r['sha256'][:12]}` |") readers = "\n".join(rows) return f"""## Method Each reader runs on your stack in the order sent and with the layers reversed, in one GPU call. d9v2 runs on [Ink Lens](https://github.com/ChaseHendrick/Scrolls/blob/main/docs/tools/ink-lens.md)'s engine (128 px patches, three 17-layer depth windows when 21 or more layers are sent); Reader V3 runs the team's koine inference (center 17 layers, 384 px patches, 50 % overlap, floored Hann blend, robust normalization), reproduced patch for patch (maps match koine's own CLI, correlation 1.000). "Both" averages the readers' maps. The gate (`{gate.RULE_ID}`): papyrus is the middle layer above its 5th percentile; T is the 99th percentile of the reversed map on papyrus; excess is the share of papyrus your order puts above T divided by 1 %. Yes from 3, Maybe from 1.5, Too small under 100,000 papyrus pixels. ## Readers The default is Reader V3, the released reader with the highest mean AUC on the three public PHerc0841 crops in our preregistered ranking (0.890 against 0.862 for Reader v2 and 0.861 for d9v2, 21 Oct 2026, Model output). Under that ranking's paired rule it was level with Reader v2, and its author benchmarked on PHerc0841 before release, so the crops are not unseen by its selection. | Id | Reader | License | Held out | SHA-256 | | --- | --- | --- | --- | --- | {readers} To add a reader, add an entry with a fixed URL, SHA-256 and license to Ink Lens's `engine.READERS` (128 px villa readers) or `koine_engine.KOINE_READERS` (koine readers) and its name to `CRACKLE_READERS`. ## Measured (Model output, development measurement on public PHerc0841, 21 Oct 2026) | Test | Result | | --- | --- | | Ink tiles against plain tiles, 128 px, from the stack | tile AUC d9v2 0.96, Reader V3 0.94 | | Same from one layer copied into a stack | 0.38 to 0.50 (chance) | | Preregistered 2D probe on single layers (frozen DINOv2 features) | 0.574, FAIL against 0.80 | | Preregistered single-layer ResNet-18, trained on PHerc0841 and PHerc1667, tested on unseen PHerc0500P2 and PHerc0814 (22 Oct) | 0.476 (interval 0.39 to 0.57), FAIL against 0.80; shuffled-label control 0.588 | | w00 crop, d9v2 / Reader V3 / both | Yes 15.4 / 18.2 / 20.6 | | Same crop with layers reversed | No signal found (0.0), flip hint shown | Reader V3's author reports PHerc0841 w00 as never trained on, but tested on it before release, so its numbers there are weaker evidence than d9v2's. Other scrolls are untested. ## API ```python from gradio_client import Client, handle_file c = Client("SharpMeow/crackle-check") # add token=... (your HF token) for your own ZeroGPU quota md, result, overlay, maps = c.predict(handle_file("layers.zip"), None, False, "both", api_name="/check") result["answer"], result["gate"]["excess"], result["per_reader"] # maps: .npz with float32 forward and reverse (0 to 1) and uint8 kept (the overlay's alpha) c.predict("0841-w00", "readerv3", api_name="/check_example") # a public example c.predict("PHerc0841/segments/20260220213127-w00", "", 3008, 2944, 384, "d9v2", api_name="/check_segment") c.predict("PHerc0841/segments/20260220213127-w00", api_name="/list_volumes") # its surface volumes c.predict(api_name="/list_readers") ``` Every endpoint is also an MCP tool (streamable HTTP at `/gradio_api/mcp/`). ## VC3D The plugin `vc3d-plugin.json` in this Space's files adds "Crackle Check on a region" to VC3D's Plugins menu (villa branch `vc3d-plugins`). It renders the region with `vc_render_tifxyz`, calls this Space with your token (or runs the models locally with `--local`), and writes the amber map into the segment's `ink-detections.json`, so it shows in the Ink Detection panel. From a shell: `python3 crackle_vc3d.py check --segment SEGDIR --volume VOLUME --box X Y W H --reader both --json`. Exit 0 for Yes or Maybe, 3 for No signal found. ## Reproducibility Villa `{engine.VILLA_COMMIT[:10]}` (vesuvius) and `{koine_engine.KOINE_COMMIT[:10]}` (koine), checkpoints pinned by SHA-256 and loaded with `torch.load(weights_only=True)`. Code: Apache 2.0. Scans: Vesuvius Challenge open data, CC BY-NC 4.0. The Space keeps no upload. """ def build_demo(): import gradio as gr reader_choices = [(TITLES[n].split(" (")[0], n) for n in READERS] + ( [("Both", "both")] if len(READERS) > 1 else []) def show(out): r = out["result"] return (verdict_html(r), out["markdown"], gr.update(value=(out["middle"], out["preview"]), visible=True), numbers_md(r), r, list(out.get("files") or [out["npz"], out["png"]]), gr.update(visible=True), gr.update(value=out.get("figures") or "", visible=bool(out.get("figures")))) def refused(exc): return (verdict_html(None, str(exc)), str(exc), gr.update(value=None, visible=False), "", None, None, gr.update(visible=False), gr.update(value="", visible=False)) def guarded(fn): def run(*args): try: return show(fn(*args)) except (crackle.CheckError, upload.UploadError, engine.EngineError, gate.GateError) as exc: return refused(exc) except Exception as exc: # noqa: BLE001 import traceback log(f"check failed: {traceback.format_exc()}") raise gr.Error(f"{type(exc).__name__}: {str(exc)[:300]}") from None return run def page_upload(f, um, flip, reader): if not f: raise crackle.CheckError("Upload a ZIP or multi-page TIFF of layers first.") p = f if isinstance(f, str) else f.get("path") try: array, kind, note = upload.read(p) finally: try: Path(p).unlink() except OSError: pass return _checked(array, kind, reader, um, flip, [note]) def api_guard(fn): @functools.wraps(fn) def wrapped(*args): try: return fn(*args) except (crackle.CheckError, upload.UploadError, engine.EngineError, gate.GateError) as exc: raise gr.Error(str(exc)) from None return wrapped samples = [] try: import inklens samples = [(inklens.sample_by(i)["title"].replace(" (held out from d9v2 and ink_9um)", ""), i) for i in EXAMPLES] except Exception: # noqa: BLE001 (the page still works without the examples) samples = [(i, i) for i in EXAMPLES] theme = gr.themes.Soft(primary_hue="amber", secondary_hue="stone", neutral_hue="stone", font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"]).set( layout_gap="16px", block_padding="16px 18px", form_gap_width="0px", block_radius="12px", block_shadow="none", block_border_width="1px", block_border_color="*neutral_200", block_border_color_dark="*neutral_700", block_title_background_fill="none", block_title_background_fill_dark="none", block_title_padding="0", block_title_text_color="*neutral_700", block_title_text_color_dark="*neutral_200", block_title_text_weight="600", block_title_text_size="*text_sm", block_label_background_fill="none", block_label_background_fill_dark="none", block_label_padding="0", block_label_text_color="*neutral_600", block_label_text_color_dark="*neutral_300", block_label_margin="0", block_info_text_size="*text_sm", checkbox_label_padding="10px 14px", checkbox_label_gap="8px", button_large_padding="12px 20px", button_large_text_size="*text_md", input_padding="10px 12px") with gr.Blocks(title="Crackle Check") as demo: gr.HTML(INTRO, elem_classes="cc-plain") with gr.Tabs(): with gr.Tab("Check"): with gr.Row(equal_height=False): with gr.Column(scale=2, min_width=300): mode = gr.State("example") with gr.Tabs(): with gr.Tab("Example") as tab_ex: gr.Markdown("A 640 px crop of a public, labeled PHerc0841 segment from the " "Vesuvius Challenge open data. No upload needed.", elem_classes="cc-note") sid = gr.Dropdown(samples, value=EXAMPLES[0], label="Example") with gr.Tab("Your layers") as tab_up: gr.Markdown("A ZIP of layer images, one multi-page TIFF, or an .npz with a `volume` " "array: 17 to 65 layers centered on the papyrus surface.", elem_classes="cc-note") f = gr.File(label="Layers", show_label=False, file_types=[".zip", ".tif", ".tiff", ".npz", ".png", ".jpg", ".jpeg"], type="filepath") with gr.Accordion("Options", open=False, elem_classes="cc-acc"): um = gr.Number(value=None, label="Pixel size in um (0 or blank: about 9 um)", precision=3) flip = gr.Checkbox(False, label="Flip layer order") with gr.Tab("From a segment") as tab_seg: gr.Markdown("Only have a screenshot? If it is from a released segment, name the " "segment and the spot, and the Space reads the real layers there from " "the open data.", elem_classes="cc-note") seg = gr.Textbox(label="Segment", placeholder="PHerc0841/segments/20260220213127-w00", info="SCROLL/segments/SEGMENT, or its open-data URL") find = gr.Button("Find its surface volumes", size="md") vol = gr.Dropdown([], value=None, label="Surface volume", allow_custom_value=True, info="Blank: the cheapest one that has enough layers") with gr.Row(): sx = gr.Number(value=None, label="x (column)", precision=0, minimum=0) sy = gr.Number(value=None, label="y (row)", precision=0, minimum=0) gr.Markdown("The center of the spot, in that surface volume's pixels (a 2.4 um volume " "has about four times the pixels of a 9 um one).", elem_classes="cc-caption") with gr.Accordion("Options", open=False, elem_classes="cc-acc"): size = gr.Slider(link.MIN_SIDE, link.MAX_SIDE, value=link.SIDE, step=32, label="Region size, px on the models' 9.4 um grid") reader = gr.Radio(reader_choices, value=DEFAULT_READER, label="Model", info=DEFAULT_WHY, elem_id="cc-model") go = gr.Button("Check this example", variant="primary", size="lg", elem_id="cc-go") with gr.Column(scale=3, min_width=320, elem_id="cc-right"): badge = gr.HTML(idle_html(), elem_classes="cc-plain") slider = gr.ImageSlider(type="numpy", format="png", show_label=False, visible=False) caption = gr.Markdown("Middle layer, with ink signal above the control in amber. Drag the " "handle to compare.", elem_classes="cc-caption", visible=False) figs = gr.HTML("", visible=False, elem_classes="cc-plain") with gr.Accordion("Numbers and downloads", open=False, elem_classes="cc-acc"): nums = gr.Markdown() files = gr.File(label="Maps (.npz: forward, reverse, kept), overlay and panels (.png)", file_count="multiple") res = gr.JSON(label="Result") md = gr.Markdown(visible=False) with gr.Tab("What is crackle?"): pics = learn_figures() gr.Markdown(LEARN_WHAT) gr.HTML(pics["crackle"], elem_classes="cc-plain") gr.Markdown(LEARN_SEND) gr.HTML(pics["stack"], elem_classes="cc-plain") gr.Markdown(LEARN_READ) gr.HTML(pics["answer"], elem_classes="cc-plain") gr.Markdown(LEARN_FAQ) with gr.Tab("Research"): gr.Markdown(researchers_md()) with gr.Tab("Connect your tool"): # The data's URLs and ready snippets (gradio_client, curl, MCP, zarr, villa's vesuvius), copied from # public ChaseHendrick/Scrolls spaces/connect/crackle-check/, made there by public kit's spaceconnect gr.HTML((HERE / "connect.html").read_text() if (HERE / "connect.html").exists() else "", elem_classes="cc-plain") outs = [badge, md, slider, nums, res, files, caption, figs] tab_ex.select(lambda: ("example", gr.update(value="Check this example")), None, [mode, go], api_visibility="private", queue=False) tab_up.select(lambda: ("upload", gr.update(value="Check my layers")), None, [mode, go], api_visibility="private", queue=False) tab_seg.select(lambda: ("segment", gr.update(value="Check this spot")), None, [mode, go], api_visibility="private", queue=False) def find_volumes(segment): try: vs = list_volumes(segment)["volumes"] except crackle.CheckError as exc: raise gr.Error(str(exc)) from None usable = [v for v in vs if v["usable"]] return gr.update(choices=[(link.label(v), v["name"]) for v in vs], value=usable[0]["name"] if usable else None) find.click(find_volumes, seg, vol, api_visibility="private") def page_segment(segment, volume, x, y, size, reader): if not str(segment or "").strip(): raise crackle.CheckError("Give the segment first, for example PHerc0841/segments/20260220213127-w00.") if x is None or y is None: raise crackle.CheckError("Give the spot: x (column) and y (row) in the surface volume's pixels.") return _segment(segment, volume, x, y, size, reader) def page_check(which, sample_id, f, um, flip, segment, volume, x, y, size, reader): if which == "example": return _example(sample_id, reader) if which == "segment": return page_segment(segment, volume, x, y, size, reader) return page_upload(f, um, flip, reader) go.click(guarded(page_check), [mode, sid, f, um, flip, seg, vol, sx, sy, size, reader], outs, api_visibility="private", concurrency_id="gpu", concurrency_limit=2) with gr.Row(visible=False): a_file = gr.File(type="filepath", label="path") a_um, a_flip = gr.Number(None, label="um_per_px"), gr.Checkbox(False, label="flip") a_reader, a_sid = gr.Textbox(DEFAULT_READER, label="reader"), gr.Textbox("0841-w00", label="sample_id") a_md, a_json, a_img, a_maps, a_info = (gr.Markdown(), gr.JSON(), gr.Image(type="numpy", format="png"), gr.File(), gr.JSON()) a_seg, a_vol = gr.Textbox("", label="segment"), gr.Textbox("", label="volume") a_x, a_y = gr.Number(0, label="x", precision=0), gr.Number(0, label="y", precision=0) a_size = gr.Number(link.SIDE, label="size", precision=0) a_vols = gr.JSON() btns = [gr.Button() for _ in range(5)] for btn, fn, ins, o, conc in ( (btns[0], check_file, [a_file, a_um, a_flip, a_reader], [a_md, a_json, a_img, a_maps], "gpu"), (btns[1], check_example, [a_sid, a_reader], [a_md, a_json, a_img, a_maps], "gpu"), (btns[2], list_readers, [], a_info, None), (btns[3], check_segment, [a_seg, a_vol, a_x, a_y, a_size, a_reader], [a_md, a_json, a_img, a_maps], "gpu"), (btns[4], list_volumes, [a_seg], a_vols, None)): btn.click(api_guard(fn), ins, o, api_name=fn.__name__.replace("check_file", "check"), api_description=fn.__doc__.split("\n\n")[0], concurrency_id=conc, concurrency_limit=2 if conc else 8) gr.HTML('
Built by Chase Hendrick · readers by TAUIL-Abd-Elilah (d9v2), Domenico Russo ' '(Reader V3) and the Vesuvius Challenge (ink_9um) · data: Vesuvius Challenge open data, CC BY-NC 4.0 · ' f'Crackle Check {crackle.VERSION} · everything shown is model output, not a reading
', elem_classes="cc-plain") demo.cc_theme = theme return demo def launch(demo, **kw): return demo.queue(max_size=64, default_concurrency_limit=4).launch( theme=demo.cc_theme, css=CSS, head=HEAD, mcp_server=True, max_file_size=f"{upload.MAX_BYTES >> 20}mb", allowed_paths=[str(OUT)], **kw) if __name__ == "__main__": startup() launch(build_demo())