Spaces:
Running on Zero
Running on Zero
Download app.py from SharpMeow/crackle-check: direct link, hf CLI and curl.
- Browser
- Download file 49.8 kB
-
https://huggingface.co/spaces/SharpMeow/crackle-check/resolve/main/app.py
- Command line
-
hf download hf://spaces/SharpMeow/crackle-check/app.py
-
curl -L -o app.py https://huggingface.co/spaces/SharpMeow/crackle-check/resolve/main/app.py
49.8 kB
| """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} | |
| 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>. | |
| HEAD = """<style>@media (max-width: 600px) { .gradio-container { padding-left: 0 !important; padding-right: 0 !important; } | |
| .gradio-container > .main { padding-left: 16px !important; padding-right: 16px !important; } }</style>""" | |
| # 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 = """<div class="cc-hero"> | |
| <h1>Crackle Check</h1> | |
| <p>Is there ink in this patch of a Herculaneum scroll? Give it a small region of a segment as a <b>stack of surface | |
| layers</b>. 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.</p> | |
| <div class="cc-chips"><span>Yes / Maybe / No signal found</span><span>Control-checked</span> | |
| <span>Reader V3 and d9v2 (MIT)</span><span>API, MCP and VC3D plugin</span><span>Model output, not a reading</span></div> | |
| </div>""" | |
| IDLE = ('<div class="cc-verdict cc-idle"><div class="word">Ready</div><div class="sub">Try a public example, ' | |
| 'upload your own layers, or name a spot on a released segment.</div></div>') | |
| 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'<figure class="{cls}"><img src="{uri}" alt="{html.escape(alt)}" loading="lazy">' | |
| f'<figcaption>{caption}</figcaption></figure>') | |
| 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'<div class="cc-verdict cc-idle"><div class="cc-kicker">What an answer looks like</div>' | |
| f'<div class="word">{ex["answer"]}</div><div class="sub">{ex["title"]}, a public example read by ' | |
| f'{_readers_text(ex)}: <b>{ex["excess"]:.1f} x</b> the control. Press <b>Check this example</b> to run it live, or ' | |
| 'upload your own layers.</div>' | |
| f'<div class="cc-pair">{pair}</div><div class="cc-meter">{figures.gauge_svg(ex["excess"])}</div>' | |
| '<div class="cc-cap">Saved from an earlier run. Model output, not a reading. Data: Vesuvius Challenge ' | |
| 'open data, CC BY-NC 4.0.</div></div>') | |
| 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'<div class="cc-figs">{figures.stack_svg()}</div>', "answer": ""} | |
| inked, plain = _asset_uri("strip-inked.webp"), _asset_uri("strip-plain.webp") | |
| if inked and plain: | |
| out["crackle"] = ('<div class="cc-figs">' | |
| + _figure(inked, "Five gray CT tiles of papyrus at increasing depth", | |
| "<b>Where the model sees ink</b> (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", | |
| "<b>Plain papyrus</b> nearby, where it sees none, at the same depths", "cc-wide") | |
| + '<div class="cc-cap">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.</div></div>') | |
| 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"<b>{ex['title']}</b>: {ex['answer']}, {ex['excess']:.1f} x the control. Left to " | |
| "right: CT, kept signal, your order, control", "cc-wide")) | |
| out["answer"] = (f'<div class="cc-figs">{figures.gauge_svg()}' + "".join(tiles) | |
| + ('<div class="cc-cap">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.</div>' if tiles else "") + "</div>") | |
| 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'<div class="cc-verdict cc-stop"><div class="word">Not checked</div><div class="sub">{text}</div></div>' | |
| word = result["answer"] | |
| g = result["gate"] | |
| sub = MEANING[word] | |
| meter = "" | |
| if g.get("excess") is not None: | |
| sub += f"<br><b>{g['excess']:.1f} x</b> the control (Yes from 3 x, Maybe from 1.5 x)" | |
| meter = f'<div class="cc-meter">{figures.gauge_svg(g["excess"])}</div>' | |
| if result.get("hint"): | |
| sub += f"<br><i>{html.escape(result['hint'])}</i>" | |
| model = " and ".join(TITLES.get(n, n).split(" (")[0] for n in (result.get("readers") or [])) | |
| if model: | |
| sub += f'<br><span style="opacity:.8">Model: {html.escape(model)}</span>' | |
| return f'<div class="cc-verdict {STYLE[word]}"><div class="word">{word}</div><div class="sub">{sub}</div>{meter}</div>' | |
| 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): | |
| 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('<div class="cc-foot">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</div>', | |
| 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()) | |