Spaces:
Running
Running
File size: 56,602 Bytes
079985f 9b08343 079985f e584c2a 079985f ea92e88 079985f ea92e88 079985f 9b08343 079985f 9b08343 079985f 9b08343 e584c2a 9b08343 079985f e584c2a 079985f 9b08343 079985f e584c2a 079985f 9b08343 079985f 9b08343 079985f 9b08343 079985f 9b08343 079985f e584c2a 9b08343 079985f e584c2a 079985f 9b08343 e584c2a 079985f e584c2a 079985f 9b08343 079985f e584c2a 079985f 9b08343 079985f 9b08343 079985f e584c2a 079985f e584c2a 079985f e584c2a 079985f 9b08343 079985f 9b08343 e584c2a 079985f 9b08343 079985f e584c2a 079985f e584c2a 9b08343 079985f 9b08343 079985f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 | """Fiber Checker: are papyrus fibers visible on a traced scroll surface?
The web page and the API. Pick a traced segment of the open data (or upload your own tifxyz), and the Space renders
the CT along the surface patch by patch and asks a judge whether each render shows papyrus fibers: a small free model
that runs here on the CPU (no key), or Claude with your own Anthropic key. The checking itself is fibercheck.py (the
command line tool, also in ChaseHendrick/Scrolls scripts/fibercheck/); the segment list is fibercat.py.
python3 app.py serve the page (on a Space, or on any computer)
"""
import collections
import functools
import hashlib
import html
import json
import os
import shutil
import sys
import tempfile
import threading
import time
import types
import zipfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
for _p in (HERE, HERE.parent.parent / "scripts" / "fibercheck"): # on the Space both sit in HERE
if _p.is_dir() and str(_p) not in sys.path:
sys.path.append(str(_p))
import numpy as np # noqa: E402
import fibercat as C # noqa: E402
import fibercheck as FC # noqa: E402
from fibercat import CatalogError # noqa: E402
VERSION = "0.1.0"
WEIGHTS = HERE / "weights" / "fibercheck-local.pt"
WEIGHTS_SHA256 = "e43c25b322816f9f821a154a472eb51e0af86baa194ca5d65094083e8c4b35d7"
OUT = Path(tempfile.gettempdir()) / "fiber-checker-out"
CT_CACHE = C.CACHE / "ct"
CT_KEEP = int(8e9) # CT chunks kept between runs; the oldest go first
GAME = HERE / "assets" / "game.npz"
GUIDE = HERE / "guide" / "index.html"
CONNECT_HTML = HERE / "connect.html"
ONESCROLL = HERE / "onescroll.html" # one page scroll bar: frames fit their content (kit spaceconnect render)
PATCHES = [("Quick: 60 patches, about 2 minutes", 60), ("Thorough: 150 patches, about 5 minutes", 150),
("Whole segment: up to 400 patches, about 15 minutes", 400)]
CLAUDE_PATCHES, CLAUDE_CHECK_N = 40, 10
SETUP = {"weights": None, "error": None}
# Public scrolls on no prize list, scans from 2025 or later, the setting both held-out tests used (2.2 to 2.4 um
# scans at level 2). PHerc0139 w030 and the PHerc0009B segment are training windings of the free judge (it trained on
# windings of PHerc0139 and PHerc0009B); PHerc0814 it never saw; PHerc0500P2 and PHerc0343P were its held-out test.
EXAMPLES = [
{"label": "PHerc0500P2 · z_dbg_gen_00440", "sample": "PHerc0500P2", "segment": "20250611171318",
"volume": "20250526151718"},
{"label": "PHerc0343P · -3_b2", "sample": "PHerc0343P", "segment": "20250902170447", "volume": "20260304131111"},
{"label": "PHerc0009B · auto_grown_20250918234353 (a training winding)", "sample": "PHerc0009B", "segment": "20250919064353",
"volume": "20250820154339"},
{"label": "PHerc0814 · auto_grown_20250925161630", "sample": "PHerc0814", "segment": "20250925161630",
"volume": "20260309142202"},
{"label": "PHerc0139 · w030 (a training winding)", "sample": "PHerc0139", "segment": "20250108000005",
"volume": "20260102150214"},
]
def log(message):
print(f"[fiber-checker {time.strftime('%H:%M:%S')}] {message}", flush=True)
def startup():
for folder in (OUT,):
shutil.rmtree(folder, ignore_errors=True)
folder.mkdir(parents=True, exist_ok=True)
if not WEIGHTS.is_file():
SETUP["error"] = f"the free judge's weights are missing ({WEIGHTS.name})"
else:
sha = hashlib.sha256(WEIGHTS.read_bytes()).hexdigest()
SETUP["weights"] = sha
if sha != WEIGHTS_SHA256:
SETUP["error"] = f"the free judge's weights have SHA-256 {sha[:16]}…, not the tested {WEIGHTS_SHA256[:16]}…"
try:
log(f"{sum(len(v) for v in C.catalog().values())} traced segment meshes in the open data")
except CatalogError as exc:
log(f"segment list not loaded yet: {exc}")
log(SETUP["error"] or "free judge ready")
# ---------------------------------------------------------------- one run per visitor
RUNNING = collections.Counter()
RUN_LOCK = threading.Lock()
def client_of(request):
if request is None:
return None
user = getattr(request, "username", None)
if user:
return f"user:{user}"
headers = getattr(request, "headers", None) or {}
forwarded = (headers.get("x-forwarded-for") or "").split(",")[-1].strip()
host = getattr(getattr(request, "client", None), "host", "") or ""
return forwarded or host or getattr(request, "session_hash", None)
class one_run:
"""One check at a time per visitor or caller, so one script cannot hold the CPU for everyone."""
def __init__(self, request):
self.key = client_of(request)
def __enter__(self):
with RUN_LOCK:
if self.key and RUNNING[self.key] >= 1:
raise CatalogError("you already have a check running; wait for it to finish (one at a time)")
RUNNING[self.key] += 1
def __exit__(self, *exc):
with RUN_LOCK:
RUNNING[self.key] -= 1
if RUNNING[self.key] <= 0:
del RUNNING[self.key]
return False
# ---------------------------------------------------------------- the check
def _progress_say(progress):
def say(line):
line = str(line)
if not progress:
return
if line.startswith("PROGRESS "):
done, total = line.split()[1].split("/")
progress(0.08 + 0.6 * int(done) / max(int(total), 1), desc=f"Rendering the CT along the surface: "
f"{done} of {total} patches")
elif line.startswith("judging"):
progress(0.72, desc=line[0].upper() + line[1:])
elif line.startswith("self-check"):
progress(0.88, desc="Self-check: the judge against noised, blurred and scrambled copies")
return say
def run_check(mesh, row, patches, judge="local", api_key=None, max_usd=3.0, progress=None):
"""fibercheck on one mesh folder; returns (output folder, record)."""
if judge == "local" and SETUP["error"]:
raise CatalogError(SETUP["error"])
patches = int(patches)
if judge == "claude":
if not (api_key or "").strip():
raise CatalogError("the Claude judge needs your own Anthropic API key (it is used for this run only)")
patches = min(patches, CLAUDE_PATCHES)
out = Path(tempfile.mkdtemp(prefix="check-", dir=OUT))
a = types.SimpleNamespace(segment=mesh, ct=row["ct"].rstrip("/"), level=row["level"], p=28,
k=5 if row["tested"] else 0, stride=None, max_patches=patches, judge=judge,
weights=str(WEIGHTS), max_usd=float(max_usd), cache=CT_CACHE, out=str(out), json=False,
check_n=CLAUDE_CHECK_N if judge == "claude" else 40)
if progress:
progress(0.04, desc="Reading the segment's mesh and the CT from the open data")
try:
rec = FC.score(a, say=_progress_say(progress), api_key=(api_key or "").strip() or None)
except SystemExit as exc: # the command line tool's refusals
raise CatalogError(str(exc).replace("fibercheck: ", "")) from None
except Exception as exc: # noqa: BLE001
msg = str(exc)
if api_key:
msg = msg.replace(api_key.strip(), "[your key]")
raise CatalogError(f"{type(exc).__name__}: {msg[:400]}") from None
finally:
try:
C.prune(CT_CACHE, CT_KEEP)
except OSError:
pass
rec["source"] = {k: row.get(k) for k in ("sample", "segment", "name", "volume", "voxel_um", "level", "tested")}
(out / "fiber.json").write_text(json.dumps(rec, indent=1))
return out, rec
def bundle(out, rec):
name = f"fiber-{rec['source'].get('sample') or 'mesh'}-{rec['source'].get('segment') or 'upload'}.zip"
zpath = out / name
with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z:
for f in ("fiber.json", "fiber.tif", "fiber_map.png", "report.html"):
z.write(out / f, f)
for f in sorted((out / "tiles").glob("*.png")):
z.write(f, f"tiles/{f.name}")
return str(zpath)
def check_segment(sample, segment, volume="", patches=60, request=None, progress=None,
judge="local", api_key=None, max_usd=3.0):
"""Check one traced segment of the open data with the free judge.
Renders the CT along the segment patch by patch (28 x 28 mesh vertices each) and gives each patch a fiber
evidence from 0.5 (no sign of fibers) to 1 (clear fibers), with a self-check against noised, blurred and
scrambled copies. Returns the fiber map picture, the record (summary, self-check, every patch) and a zip with
fiber.json, fiber.tif (one value per mesh vertex, on the segment's own grid), fiber_map.png and report.html.
sample: a scroll such as PHerc0500P2; segment: its 14-digit segment id or name (list_segments); volume: the
14-digit scan id (empty: the tested scan); patches: at most this many patches (60, 150 or up to 400).
"""
with one_run(request):
row = C.find(str(sample).strip(), str(segment).strip(), str(volume or "").strip() or None)
mesh = C.fetch_mesh(row)
out, rec = run_check(mesh, row, patches, judge, api_key, max_usd, progress)
return str(out / "fiber_map.png"), rec, bundle(out, rec)
def check_upload(upload, volume, patches=60, request=None, progress=None, judge="local", api_key=None, max_usd=3.0):
"""Check your own tifxyz mesh: a zip holding x.tif, y.tif and z.tif in level-0 voxels of the scan named."""
if not upload:
raise CatalogError("upload a zip of your tifxyz folder (x.tif, y.tif and z.tif)")
sc = C.scan(volume)
with one_run(request):
work = Path(tempfile.mkdtemp(prefix="mesh-", dir=OUT))
with zipfile.ZipFile(upload) as z:
names = {Path(n).name: n for n in z.namelist() if not n.endswith("/")}
if not {"x.tif", "y.tif", "z.tif"} <= set(names):
raise CatalogError("the zip needs x.tif, y.tif and z.tif (a tifxyz folder)")
for f in ("x.tif", "y.tif", "z.tif"):
info = z.getinfo(names[f])
if info.file_size > 400e6:
raise CatalogError(f"{f} is over 400 MB")
(work / f).write_bytes(z.read(names[f]))
row = dict(sc, segment="upload", name=Path(str(upload)).stem)
out, rec = run_check(work, row, patches, judge, api_key, max_usd, progress)
return str(out / "fiber_map.png"), rec, bundle(out, rec)
def compare_results(a_json, b_json):
"""Compare two fiber.json records: summary side by side, and patch by patch when both share a mesh grid."""
ra, rb = (json.loads(Path(f).read_text()) if isinstance(f, str) and Path(f).is_file() else f
for f in (a_json, b_json))
if not (isinstance(ra, dict) and isinstance(rb, dict) and "summary" in ra and "summary" in rb):
raise CatalogError("give two fiber.json files from this Space or the command line tool")
rows = {k: [ra["summary"].get(k), rb["summary"].get(k)]
for k in ("patches", "median_evidence", "share_fibers_visible", "share_unsure")}
paired = None
if ra.get("grid") == rb.get("grid") and ra.get("patch_vertices") == rb.get("patch_vertices"):
ea = {(p["r"], p["c"]): p["evidence"] for p in ra["patches"]}
both = [(ea[(p["r"], p["c"])], p["evidence"]) for p in rb["patches"] if (p["r"], p["c"]) in ea]
if both:
d = np.array([x - y for x, y in both])
paired = {"patches": len(both), "a_better": round(float((d > 0.05).mean()), 3),
"b_better": round(float((d < -0.05).mean()), 3), "mean_diff": round(float(d.mean()), 4)}
return {"summary": rows, "paired_same_grid": paired,
"a": ra.get("source") or ra.get("segment"), "b": rb.get("source") or rb.get("segment")}
# ---------------------------------------------------------------- what the page shows
def compare_html(res):
"""compare_results' answer as two columns, with the better side of each row marked."""
def name(v):
if isinstance(v, dict):
v = " · ".join(str(v[k]) for k in ("sample", "name", "volume") if v.get(k))
v = str(v or "?").rstrip("/").rsplit("/", 1)[-1] # a mesh path: its folder name, not where it was cached
return html.escape(v.rsplit("_mesh_", 1)[-1])
rows = [("Patches checked", "patches", lambda v: f"{v}", None),
("Fibers show on", "share_fibers_visible", pct, 1),
("Median fiber evidence", "median_evidence", lambda v: f"{v:.2f}", 1),
("Unsure", "share_unsure", pct, -1)]
body = []
for label, k, fmt, better in rows:
a, b = res["summary"][k]
cells = [fmt(v) if v is not None else "n/a" for v in (a, b)]
if better and a is not None and b is not None and abs(a - b) > 0.02:
win = 0 if (a - b) * better > 0 else 1
cells[win] = f"<b>{cells[win]}</b> ◀"
body.append(f"<tr><td>{label}</td><td>{cells[0]}</td><td>{cells[1]}</td></tr>")
pr = res.get("paired_same_grid")
paired = (f"<p class='fc-meta'>Same mesh grid, {pr['patches']} patches in both: A clearly better on "
f"{pct(pr['a_better'])}, B on {pct(pr['b_better'])}, mean difference {pr['mean_diff']:+.3f} (A minus B)."
"</p>" if pr else "<p class='fc-meta'>Different mesh grids, so the summaries are compared, not patch "
"by patch.</p>")
return (f"<table class='fc-cmp'><thead><tr><th></th><th>A<span>{name(res.get('a'))}</span></th>"
f"<th>B<span>{name(res.get('b'))}</span></th></tr></thead><tbody>{''.join(body)}</tbody></table>{paired}")
def pct(v):
return "n/a" if v is None or v != v else f"{v:.0%}"
def summary_html(rec):
s, src = rec["summary"], rec.get("source") or {}
ck = s["self_check"]
name = html.escape(str(src.get("name") or src.get("segment") or ""))
setting = ("the tested setting (2.2 to 2.4 um scan, level 2)" if src.get("tested") else
f"<b>outside the tested setting</b> ({src.get('voxel_um')} um scan at level {src.get('level')}): "
"treat the numbers as a first look")
cost = f" · Claude spend {rec['usd']:.2f} USD" if "usd" in rec else ""
verdict = ("Fibers show on most of this surface." if s["share_fibers_visible"] >= 0.75 else
"Fibers show on part of this surface; the map shows where." if s["share_fibers_visible"] >= 0.3 else
"Few patches show fibers: the trace may be off the sheet, or the CT is unclear here.")
if not ck["passed"]:
verdict = "The self-check failed, so this judge's answers on this segment cannot be trusted."
jumps = len(rec.get("jump_hints") or [])
return f"""
<div class="fc-verdict">{verdict}</div>
<div class="fc-cards">
<div class="fc-card"><div class="fc-big">{pct(s['share_fibers_visible'])}</div>of patches show fibers
<span>evidence 0.75 or more</span></div>
<div class="fc-card"><div class="fc-big">{s['median_evidence']:.2f}</div>median fiber evidence
<span>0.5 none · 1 clear</span></div>
<div class="fc-card"><div class="fc-big">{s['patches']}</div>patches checked<span>{pct(s['share_unsure'])} unsure</span></div>
<div class="fc-card"><div class="fc-big {'fc-ok' if ck['passed'] else 'fc-bad'}">{'passed' if ck['passed'] else 'failed'}</div>
self-check<span>noise {pct(ck['against']['noise'])} · blur {pct(ck['against']['blur'])} ·
scramble {pct(ck['against']['scramble'])}</span></div>
</div>
<p class="fc-meta">{html.escape(str(src.get('sample') or ''))} · {name} · scan {html.escape(str(src.get('volume')))}
· {setting} · judge: {'free local model' if rec['judge'] == 'local' else 'Claude, your key'}{cost}</p>
<details class="fc-untested"><summary>Untested hints ({jumps} possible jumps)</summary>
<p>These have not passed a test of their own yet, so read them as ideas, not results.
<b>Jump hints</b>: {jumps} patches with low evidence where every neighbor is clear, which can mark a sheet jump or a
stretch off the sheet. <b>Fiber direction</b>: {pct(s['direction_share_horizontal'])} of patches have strands
running along the mesh's columns (horizontal in the map), the rest along its rows. <b>Best depth</b>: the median
patch looks most fibrous {s['depth_offset_median']:+g} voxels from the traced surface.</p></details>
"""
def tiles_of(out):
files = sorted(Path(out).glob("tiles/*.png"))
return [(str(f), f"{f.stem.split('-')[-1]} {f.stem.split('-')[1]}") for f in files]
def with_figures(text):
"""Fill the guides' [[fig NAME | caption]] slots with the pictures in assets/ (tools/build_media.py); a slot whose
picture this copy lacks is dropped, so the guides read the same without them."""
out, rest = [], text
while "[[fig " in rest:
head, _, tail = rest.partition("[[fig ")
spec, _, rest = tail.partition("]]")
name, _, caption = (x.strip() for x in spec.partition("|"))
out.append(head)
if (HERE / "assets" / name).is_file():
alt = html.escape(" ".join(caption.split()), quote=True)
out.append(f'<figure><img src="/gradio_api/file=assets/{name}" alt="{alt}" loading="lazy">'
f'<figcaption>{html.escape(" ".join(caption.split()))} Recorded on public scrolls on no prize '
'list.</figcaption></figure>')
return "".join(out) + rest
def guide_html():
"""The welcome page, its pictures read from this Space's assets/ folder (tools/build_media.py)."""
if not GUIDE.is_file():
return "<p>The welcome page is missing from this copy.</p>"
page = GUIDE.read_text()
# its pictures point at Gradio's file route (a <base> cannot do it); in-page anchors scroll the page to their
# section (the frame fits its content, onescroll.html), ?tab= links switch the Space's tabs, other links open a
# new tab (as Lasagna Fields does)
page = page.replace("'assets/", "'/gradio_api/file=assets/").replace('"assets/', '"/gradio_api/file=assets/')
fix = ('<script>document.addEventListener("click",function(e){var a=e.target.closest&&e.target.closest("a");'
'if(!a)return;var h=a.getAttribute("href")||"";if(h.charAt(0)==="#"){e.preventDefault();'
'var t=document.getElementById(h.slice(1));if(t)t.scrollIntoView({behavior:"smooth"});}'
'else if(h.indexOf("?tab=")===0){e.preventDefault();window.parent.postMessage({fcTab:h.slice(5)},"*");}'
'else if(/^https?:/.test(h)){a.target="_blank";a.rel="noopener";}},true);</script>')
page = page.replace("<head>", "<head>" + fix, 1)
return f'<iframe title="Fiber Checker" srcdoc="{html.escape(page, quote=True)}"></iframe>'
def report_frame(out):
page = (Path(out) / "report.html").read_text()
return f'<iframe class="fc-report" title="Report" srcdoc="{html.escape(page, quote=True)}"></iframe>'
def sample_choices():
try:
cat = C.catalog()
except CatalogError:
return [e["sample"] for e in EXAMPLES]
return [(f"{s} · {len(rows)} meshes" + (" · prize scroll" if rows[0]["prize"] else ""), s)
for s, rows in sorted(cat.items())]
def segment_choices(sample):
try:
rows = C.segments(sample)
except CatalogError:
return []
seen, out = set(), []
for r in sorted(rows, key=lambda r: r["name"]):
if r["segment"] in seen:
continue
seen.add(r["segment"])
tag = r["name"][len(r["segment"]) + 1:] if r["name"].startswith(r["segment"] + "-") else r["name"]
out.append((f"{r['segment']} · {tag}" if tag and tag != r["segment"] else r["segment"], r["segment"]))
return out
def volume_choices(sample, segment):
try:
rows = [r for r in C.segments(sample) if r["segment"] == segment]
except CatalogError:
return []
rows.sort(key=lambda r: (not r["tested"], r["volume"]))
return [(f"{r['volume']} · {r['voxel_um']:g} um · level {r['level']}" + (" · tested" if r["tested"] else ""),
r["volume"]) for r in rows]
def list_scrolls():
"""Every scroll with traced segments in the open data, with its segment count and whether it is a prize scroll."""
return [{"sample": s, "meshes": len(rows), "segments": len({r["segment"] for r in rows}),
"prize_scroll": rows[0]["prize"]} for s, rows in sorted(C.catalog().items())]
def list_segments(sample):
"""Every traced segment of one scroll, with the scans it has a mesh on (tested: the setting the judge passed on)."""
return [{k: r[k] for k in ("segment", "name", "volume", "voxel_um", "level", "tested", "mesh")}
for r in C.segments(str(sample).strip())]
def example_segments():
"""Ready segments of public scrolls on no prize list, in the tested setting."""
return EXAMPLES
# ---------------------------------------------------------------- the game
class Game:
"""Fiber or not? Two renders of the same patch: one as the CT shows it on the traced surface, one changed so its
strands are gone (or a few voxels off the sheet). Pick the one with fibers, then see what the free judge says."""
KINDS = {"scramble": "the same patch with its texture kept and its strands scrambled",
"shuffle": "the same patch cut into 14-pixel tiles, turned and shuffled",
"blur": "the same patch blurred",
"noise": "the same patch with strong noise",
"off": "the CT 5 voxels off the traced surface"}
def __init__(self, path):
self.ok = Path(path).is_file()
if self.ok:
z = np.load(path)
self.on, self.off, self.src = z["on"].astype(np.float64), z["off"].astype(np.float64), list(z["src"])
self.judge = None
def other(self, i, kind, rng):
on = self.on[i]
if kind == "off":
return self.off[i]
if kind == "shuffle":
from fcmodel import tile_shuffle
t = tile_shuffle(on, 14, f"game:{i}:{rng.integers(1 << 30)}") # z-scored: back to the CT's scale
return t * on.std() + on.mean()
return FC.controls(on, rng)[kind]
def judge_pair(self, a, b, ref):
"""P(the free judge picks a), both scaled by the on render's gray scale as in the checker."""
if self.judge is None:
self.judge = FC.LocalJudge(str(WEIGHTS))
p, _ = self.judge.prefer([FC.to_input(a, ref)], [FC.to_input(b, ref)])
return float(p[0])
GAME_STATE0 = {"round": 0, "you": 0, "model": 0, "answered": True, "i": None}
def png_of(a):
from PIL import Image
lo, hi = np.percentile(a, [1, 99])
b = np.clip((a - lo) / max(hi - lo, 1e-6), 0, 1)
im = Image.fromarray((b * 255).astype(np.uint8)).resize((a.shape[1] * 3, a.shape[0] * 3), Image.BICUBIC)
return np.asarray(im)
def score_line(st):
if not st["round"]:
return "Which picture shows papyrus fibers: long, thin, parallel strands? Press **New pair** to start."
return (f"Round {st['round']} · you {st['you']} of {st['round'] - (0 if st['answered'] else 1)} · "
f"the free judge {st['model']} of {st['round'] - (0 if st['answered'] else 1)}")
# ---------------------------------------------------------------- the page
INTRO = """\
Pick a traced segment and press **Check fibers**. The Space renders the CT along the surface, patch by patch, and a
judge says how clearly each render shows papyrus fibers. Fibers on the output surface are what the Progress Prize
asks entrants to show, and they are a sign the trace sits on the sheet.
"""
ABOUT_JUDGE = """\
**Free local model** (default, no key): a small network (about 40,000 weights) trained on Claude's answers to 817
patches of four public scrolls. On two scrolls it never saw it passed its preregistered test: it told the sheet from
the gap beside it on every patch (pixel measure 0.76 to 0.79), and the sheet from a tile-shuffled copy on 92 to 98% of
patches (pixel measure 0.53 to 0.57). Weights SHA-256 `{sha}`.
**Claude, your own key**: the same question, asked of Claude in both orders, as in the test where Claude's fiber
answers were valid on four scrolls. Your key is used for this run only and is never stored or logged. It checks at
most {cp} patches and stops at your spending cap.
"""
HOW_TO = """\
<p class="fc-toc">Guides: <a href="#what">What it checks</a> · <a href="#first">Your first check</a> ·
<a href="#read">Read the result</a> · <a href="#own">Check your own trace</a> · <a href="#ab">Compare two traces</a>
· <a href="#prize">For prize entries</a> · <a href="#research">For researchers</a> · <a href="#cli">Run it
yourself</a> · <a href="#limits">What is tested, what is not</a> · <a href="#wrong">When something goes wrong</a></p>
<h3 id="what">What it checks</h3>
Papyrus is made of plant strips laid in two crossing layers, so a surface traced on a sheet shows long, thin, parallel
strands in the CT. Where a trace runs through air, a gap or unclear CT, the strands fade into noise. The Progress Prize asks entrants to "show visually that papyrus fibers are visible on your
output surface" ([scrollprize.org/prizes](https://scrollprize.org/prizes)). This Space measures that, patch by patch.
For each patch it renders the CT on the surface and asks the judge one question: does this render show fibers more
clearly than a copy with the same texture but its strands scrambled? The answer is the **fiber evidence**: near 1
where fibers show, near 0.5 where the render looks no more fibrous than scrambled texture. It is model output about
texture: it never reads or finds text.
<h3 id="first">1. Your first check (two minutes)</h3>
1. Open **Check a segment**. It starts on an example from PHerc0500P2, a public scroll on no prize list.
2. Press **Check fibers**. The Space reads the mesh and about 700 MB of CT from the open data, renders 60 patches and
judges them on the CPU.
3. The map, the summary and the download appear on the right. Try the **Fiber or not?** tab while you wait.
[[fig howto-check.gif | A check from start to finish, sped up: pick an example, press Check fibers, read the map.]]
[[fig howto-game.gif | Fiber or not?: say which render shows fibers, then see what the free judge said.]]
<h3 id="read">2. Read the result</h3>
- **The map** is the segment as traced, flattened on its own mesh grid. Yellow and green: clear fibers. Purple: weak.
Gray: no sign of fibers. Slate: traced but not scored (holes nearby, masked CT, or not sampled). Black: no surface.
- **Fibers show on** is the share of patches with evidence 0.75 or more. **Median evidence** is the typical patch.
- **Self-check**: on 40 of your patches, the judge must prefer the real render over a noised, a blurred and a
scrambled copy at least 80% of the time. If it fails, do not trust that run.
- **Unsure**: the judge's answer changed when the patch was turned by a quarter turn.
- **Clearest and weakest patches** are the renders themselves, so you can see what the judge saw.
[[fig howto-result.webp | A finished check: the map, the summary with its self-check, and the download.]]
<h3 id="own">3. Check your own trace</h3>
Zip your tifxyz folder (x.tif, y.tif and z.tif; VC3D and villa write these) and upload it on **Check a segment**,
under **Your own mesh**, with the 14-digit id of the scan its coordinates are in (level-0 voxels). The Space reads the
CT of that scan from the open data. Meshes on 2.2 to 2.4 um scans are checked in the tested setting.
[[fig howto-upload.webp | Your own mesh: the zip goes in the box, the scan id below it.]]
<h3 id="ab">4. Compare two traces</h3>
Check both (two segments, or your trace before and after an edit), download each zip, and give the two
`fiber.json` files to **Compare**. When both are meshes of the same grid, it compares them patch by patch: the share
of patches where each is clearly better, and the mean difference.
[[fig howto-compare.gif | Two fiber.json files in, a side-by-side table out. These two are checks of the same segment on
two scans, whose meshes have different grids, so the summaries are compared.]]
<h3 id="prize">5. For prize entries</h3>
`report.html` in the download is a one-page summary in the prize's words: the map, the self-check, the clearest and
weakest patches and the method. Attach it, or its numbers, as evidence that fibers show on your surface. Run the free
judge first; Claude with your own key gives a second opinion on up to 40 patches. Say which judge you used.
<h3 id="research">6. For researchers</h3>
The free judge was preregistered and tested once on two public scrolls it never saw (PHerc0500P2 and PHerc0343P,
scans of 2025 and 2026 at level 2). Each test pair asks which of two renders shows fibers more clearly.
| Test pair | PHerc0500P2 (120 patches) | PHerc0343P (62 patches) | Pixel measure |
| --- | --- | --- | --- |
| Sheet vs the gap beside it | 1.00 | 1.00 | 0.79, 0.76 |
| Sheet vs a 14-pixel tile shuffle (a size never trained on) | 0.98 | 0.92 | 0.53, 0.57 |
| Sheet vs 3 voxels off | 0.87 | 0.68 | |
The pixel measure is structure-tensor coherence. A first round failed (it preferred tile-shuffled renders, because it
rewarded the edges a shuffle adds) and is kept on record; the second round added shuffles at other tile sizes to
training. Claude's own answers to the same pairs were valid on four public scrolls. All of it is model output.
<h3 id="cli">7. Run it yourself</h3>
```bash
git clone https://github.com/ChaseHendrick/Scrolls && cd Scrolls
pip install numpy scipy tifffile numcodecs pillow torch
# the weights: this Space's file weights/fibercheck-local.pt
python3 scripts/fibercheck/fibercheck.py score SEGMENT.tifxyz \\
--ct https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com/PHerc0500P2/volumes/20250526151718-2.215um-0.4m-111keV-masked.zarr \\
--level 2 --weights fibercheck-local.pt --out out/
python3 scripts/fibercheck/fibercheck.py compare out_a out_b
```
`fiber.tif` has one value per mesh vertex on the segment's own grid, the same shape as `x.tif`, so it lines up with
the segment in tools that read tifxyz channels. `--judge claude` uses `ANTHROPIC_API_KEY` from your environment.
<h3 id="limits">What is tested, what is not</h3>
- **Tested**: the free judge's answers on 2.2 to 2.4 um scans at level 2 with open-data tifxyz meshes, against built
controls (the gap beside the sheet, tile shuffles, blur, noise, 3 voxels off). Not tested on real faulty traces.
- **Outside the tested setting** (other voxel sizes, your own meshes at other spacings): it still runs, and says so.
- **Untested hints**: jump hints, fiber direction and best depth are ideas until a test of their own passes.
- **Which sheet**: each patch is rendered at the brightest depth within 5 voxels of the trace (the tested rule), and
papyrus sheets lie close together, so a trace a few voxels off, or on the neighboring sheet, can still show fibers.
A clear map says fibers show on and near your surface; it does not prove the trace stayed on one sheet.
<h3 id="wrong">When something goes wrong</h3>
| What you see | What to do |
| --- | --- |
| "no patch of the segment could be rendered" | The mesh has holes everywhere at 28 x 28 vertices, the CT is masked there, or the scan id is wrong. |
| "you already have a check running" | One check at a time per visitor; wait for it to finish. |
| "is not a scan this Space reads" | Give the 14-digit volume id from the open data. PHercParis4 is never read. |
| Self-check failed | The judge did not beat its own controls on this segment: do not use the numbers. |
| The Claude judge stops early | It reached your spending cap; unanswered patches are left out. |
"""
AGENTS = """\
Every control on this page is also an API endpoint and an **MCP tool**. MCP clients (Claude, Cursor and others) add
the server `{host}/gradio_api/mcp/`. The API uses the free judge, so it needs no key.
| Call | What it returns |
| --- | --- |
| `/list_scrolls` | every scroll with traced segments in the open data |
| `/list_segments` | one scroll's segments and the scans each has a mesh on |
| `/example_segments` | ready segments of public scrolls on no prize list |
| `/check_segment` | the fiber map, the record (summary, self-check, every patch) and the zip |
| `/compare_results` | two fiber.json records side by side, and patch by patch on a shared grid |
```python
from gradio_client import Client
c = Client("SharpMeow/fiber-checker")
ex = c.predict(api_name="/example_segments")[0]
picture, record, zip_path = c.predict(ex["sample"], ex["segment"], ex["volume"], 60, api_name="/check_segment")
print(record["summary"])
```
Plain HTTP works too (`POST {host}/gradio_api/call/check_segment` with `{{"data": [...]}}`, then read the event
stream it names). A check takes about two minutes for 60 patches.
"""
CSS = """
:root, .dark { --fc-accent:#9a5b1f; }
.gradio-container { width: 100% !important; max-width: 1600px !important; margin: 0 auto !important; }
.fc-head { display:flex; align-items:center; gap:14px; padding:6px 2px 2px; }
.fc-head .weave { width:30px; height:30px; border-radius:6px; flex:none;
background: repeating-linear-gradient(90deg, rgba(120,72,24,.55) 0 3px, transparent 3px 6px),
repeating-linear-gradient(0deg, #e8cf9c 0 3px, #c99a52 3px 6px); }
.fc-head h1 { white-space:nowrap; font-family: Fraunces, Georgia, serif; font-weight:650; font-size:30px; margin:0;
letter-spacing:-.01em; }
.fc-head span { color: var(--body-text-color-subdued); font-size:15px; }
.fc-note p { font-size:13px; color: var(--body-text-color-subdued); margin:0; }
.fc-verdict { font-size:18px; font-weight:600; margin:4px 0 12px; }
.fc-cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:10px; }
.fc-card { background: var(--block-background-fill); border:1px solid var(--border-color-primary); border-radius:12px;
padding:12px 14px; font-size:14px; line-height:1.35; }
.fc-card span { display:block; color: var(--body-text-color-subdued); font-size:12.5px; margin-top:2px; }
.fc-big { font-size:26px; font-weight:650; font-variant-numeric: tabular-nums; }
.fc-ok { color:#1f7a4d; } .dark .fc-ok { color:#5fd39a; }
.fc-bad { color:#b3261e; } .dark .fc-bad { color:#ff8a80; }
.fc-meta { font-size:13px; color: var(--body-text-color-subdued); margin:12px 0 4px; }
.fc-untested { font-size:13.5px; margin-top:8px; } .fc-untested summary { cursor:pointer; }
.fc-legend { font-size:12.5px; color: var(--body-text-color-subdued); }
.fc-legend i { display:inline-block; width:11px; height:11px; border-radius:3px; margin:0 4px -1px 10px; }
/* no fixed heights on the frames: onescroll.html fits each to its content, so only the page scrolls */
.fc-report { width:100%; border:0; border-radius:12px; }
.fc-guide iframe { width:100%; border:0; border-radius:12px; background:#f6f1e7; }
.fc-howto img { border-radius:10px; border:1px solid var(--border-color-primary); max-width:min(100%, 820px);
display:block; margin:10px 0 4px; }
.fc-howto figure { margin:10px 0 18px; } .fc-howto figcaption { font-size:13px; color: var(--body-text-color-subdued); }
.fc-strip { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; max-width:820px; margin:10px 0; }
.fc-strip img { width:100%; margin:0; aspect-ratio:1; object-fit:cover; }
.fc-strip span { display:block; font-size:12.5px; color: var(--body-text-color-subdued); margin-top:4px; }
.fc-drop .wrap { padding:14px 10px !important; gap:4px !important; line-height:1.35 !important; }
.fc-cmp { width:100%; max-width:820px; border-collapse:collapse; font-size:15px; margin-top:6px; }
.fc-cmp th, .fc-cmp td { text-align:left; padding:9px 12px; border-bottom:1px solid var(--border-color-primary); }
.fc-cmp th span { display:block; font-weight:400; font-size:12.5px; color: var(--body-text-color-subdued); }
.fc-cmp td:not(:first-child) { font-variant-numeric: tabular-nums; }
.fc-howto { max-width: 1000px; margin: 0 auto !important; }
.fc-howto h3 { scroll-margin-top: 16px; margin-top: 1.6em !important; }
.fc-toc { font-size: 14px; line-height: 1.9; padding: 10px 14px; border-radius: 10px;
background: var(--block-background-fill); border: 1px solid var(--border-color-primary); }
.fc-game img { image-rendering: auto; }
.fc-score p { font-size:15px; }
.fc-credit { text-align:center; font-size:13px; color: var(--body-text-color-subdued); padding:18px 0 6px; }
.fc-credit a { color: var(--fc-accent) !important; }
#fc-dl .empty { min-height: 64px !important; height: 64px !important; }
/* the twelve patches grow the gallery instead of scrolling inside it (Gradio's grid-wrap always has a scroll bar) */
#fc-tiles .grid-wrap { height:auto !important; min-height:0 !important; max-height:none !important;
overflow-y:visible !important; }
#fc-tiles .grid-container { grid-template-columns:repeat(6,minmax(0,1fr)) !important; grid-template-rows:none !important;
grid-auto-rows:auto !important; }
@media (max-width: 640px) {
.fc-head h1 { font-size:24px; }
.fc-head span { display:none; }
#fc-tabs .tab-container button { padding:6px 6px !important; font-size:12.5px !important; }
.fc-strip { grid-template-columns:repeat(2,minmax(0,1fr)); }
#fc-tiles .grid-container { grid-template-columns:repeat(3,minmax(0,1fr)) !important; }
}
"""
LEGEND = ('<div class="fc-legend"><i style="background:#fde725"></i>clear fibers<i style="background:#21918c"></i>'
'some<i style="background:#440154"></i>weak<i style="background:#6e6e6e"></i>no sign<i '
'style="background:#343640"></i>not scored<i style="background:#18181c"></i>no surface</div>')
HEAD = """
<meta name="description" content="Fiber Checker: are papyrus fibers visible on a traced Herculaneum scroll surface?
A free fiber map of any segment of the open data, or your own tifxyz. API and MCP server for agents.">
<meta property="og:title" content="Fiber Checker">
<meta property="og:description" content="A per-patch fiber map of traced scroll surfaces, judged by a small free
model or by Claude with your own key. Model output, not a reading.">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,650&display=swap" rel="stylesheet">
"""
HEAD += ONESCROLL.read_text() if ONESCROLL.is_file() else ""
TAB_JS = """
() => {
const pick = (name) => {
// by label: on a phone the later tabs sit in Gradio's overflow menu, as buttons without role="tab"
const labels = {home: 'Welcome', check: 'Check a segment', game: 'Fiber or not?', compare: 'Compare',
howto: 'How-to guides', agents: 'Agents and API'};
const btns = [...document.querySelectorAll('#fc-tabs button')].filter((b) => b.textContent.trim() === labels[name]);
const btn = btns.find((b) => b.getAttribute('role') === 'tab') || btns[btns.length - 1];
if (btn) btn.click();
};
window.addEventListener('message', (e) => {
if (!(e.data && e.data.fcTab)) return;
pick(e.data.fcTab);
// the welcome page grows the page (one scroll bar), so a link from it lands at the top of the new tab
const bar = document.getElementById('fc-tabs');
if (bar && bar.getBoundingClientRect().top < 0) bar.scrollIntoView({block: 'start'});
});
const q = new URLSearchParams(window.location.search).get('tab');
if (q) setTimeout(() => pick(q), 300);
}
"""
def theme():
import gradio as gr
t = gr.themes.Base(primary_hue=gr.themes.Color(c50="#fbf4ea", c100="#f5e4cc", c200="#ebc999", c300="#dfa964",
c400="#c9853c", c500="#9a5b1f", c600="#83491a", c700="#6b3b16",
c800="#552f12", c900="#3f230e", c950="#291608"),
neutral_hue="stone", radius_size="md",
font=(gr.themes.GoogleFont("Inter"), "system-ui", "sans-serif"),
font_mono=(gr.themes.GoogleFont("JetBrains Mono"), "ui-monospace", "monospace"))
return t.set(body_background_fill="#f6f1e7", body_background_fill_dark="#14120f",
block_background_fill="#fffdf8", block_background_fill_dark="#1f1c18",
block_border_color="#e3d7c3", block_border_color_dark="#332e28",
button_primary_background_fill="#9a5b1f", button_primary_background_fill_dark="#d89a52",
button_primary_background_fill_hover="#83491a", button_primary_background_fill_hover_dark="#e8b273")
def build_demo():
import gradio as gr
def guard(fn):
@functools.wraps(fn)
def wrapped(*args, **kwargs):
try:
return fn(*args, **kwargs)
except CatalogError as exc:
raise gr.Error(str(exc)) from None
except gr.Error:
raise
except Exception as exc: # noqa: BLE001
import traceback
log(f"{fn.__name__} failed: {traceback.format_exc()}")
raise gr.Error(f"{type(exc).__name__}: {str(exc)[:400]}") from None
return wrapped
host = (f"https://{os.environ['SPACE_HOST']}" if os.environ.get("SPACE_HOST")
else "https://sharpmeow-fiber-checker.hf.space")
first = EXAMPLES[0]
game = Game(GAME)
def shown(pic, rec, zpath):
out = Path(zpath).parent
return pic, summary_html(rec), tiles_of(out), zpath, report_frame(out), rec
def on_check(sample, segment, volume, patches, judge, own_key, max_usd, request: gr.Request, progress=gr.Progress()):
return shown(*check_segment(sample, segment, volume, patches, request=request, progress=progress,
judge=judge, api_key=own_key, max_usd=max_usd))
def on_upload(upload, volume, patches, judge, own_key, max_usd, request: gr.Request, progress=gr.Progress()):
return shown(*check_upload(upload, volume, patches, request=request, progress=progress,
judge=judge, api_key=own_key, max_usd=max_usd))
def on_example(i):
e = EXAMPLES[int(i or 0) % len(EXAMPLES)]
segs = segment_choices(e["sample"])
vols = volume_choices(e["sample"], e["segment"])
return (gr.update(value=e["sample"]), gr.update(choices=segs, value=e["segment"]),
gr.update(choices=vols, value=e["volume"]))
def on_sample(sample):
segs = segment_choices(sample)
seg = segs[0][1] if segs else None
vols = volume_choices(sample, seg) if seg else []
return gr.update(choices=segs, value=seg), gr.update(choices=vols, value=vols[0][1] if vols else None)
def on_segment(sample, segment):
vols = volume_choices(sample, segment)
return gr.update(choices=vols, value=vols[0][1] if vols else None)
def on_judge(judge):
return gr.update(visible=judge == "claude")
def new_pair(st):
st = dict(st or GAME_STATE0)
if not game.ok:
raise CatalogError("the game's pictures are not on this copy of the Space")
rng = np.random.default_rng()
i = int(rng.integers(len(game.on)))
kind = str(rng.choice(list(Game.KINDS)))
on, other = game.on[i], game.other(i, kind, rng)
n = min(on.shape[0], other.shape[0]) # a shuffle keeps whole tiles only
on, other = on[:n, :n], other[:n, :n]
fiber_is_a = bool(rng.integers(2))
a, b = (on, other) if fiber_is_a else (other, on)
p_a = game.judge_pair(a, b, on)
st.update(round=st["round"] + (1 if st["answered"] else 0), answered=False, i=i, kind=kind,
fiber_is_a=fiber_is_a, model_says_a=p_a > 0.5, p_a=p_a)
return png_of(a), png_of(b), "", score_line(dict(st, answered=True, round=st["round"] - 1)), st
def answer(choice, st):
st = dict(st or GAME_STATE0)
if st.get("i") is None or st["answered"]:
return gr.update(), score_line(st), st
right = (choice == "A") == st["fiber_is_a"]
model_right = st["model_says_a"] == st["fiber_is_a"]
st["you"] += int(right)
st["model"] += int(model_right)
st["answered"] = True
fiber_side = "A" if st["fiber_is_a"] else "B"
conf = st["p_a"] if st["model_says_a"] else 1 - st["p_a"]
reveal = (f"**{'Right!' if right else 'Not this time.'}** {fiber_side} is the CT on the traced surface; the "
f"other is {Game.KINDS[st['kind']]}. The free judge picked "
f"{'A' if st['model_says_a'] else 'B'} ({conf:.0%} sure){'' if model_right else ', and got it wrong'}."
f"\n\n<span class='fc-note'>From {html.escape(game.src[st['i']])}. Public scroll on no prize list; "
"CT from the Vesuvius Challenge open data, CC BY-NC 4.0.</span>")
return reveal, score_line(st), st
with gr.Blocks(title="Fiber Checker", delete_cache=(1800, 3600)) as demo:
gr.HTML('<div class="fc-head"><div class="weave"></div><h1>Fiber Checker</h1><span>Are papyrus fibers '
'visible on a traced scroll surface? A free fiber map of any segment, patch by patch.</span></div>')
with gr.Tabs(elem_id="fc-tabs"):
with gr.Tab("Welcome", id="home"):
gr.HTML(guide_html(), elem_classes="fc-guide")
with gr.Tab("Check a segment", id="check"):
gr.Markdown(INTRO)
with gr.Row(equal_height=False):
with gr.Column(scale=2, min_width=300):
ex = gr.Dropdown([(e["label"], i) for i, e in enumerate(EXAMPLES)], value=0,
label="Example (public scrolls on no prize list)")
with gr.Tabs():
with gr.Tab("Open-data segment"):
sample = gr.Dropdown(sample_choices(), value=first["sample"], label="Scroll",
filterable=True)
segment = gr.Dropdown(segment_choices(first["sample"]), value=first["segment"],
label="Segment", filterable=True)
volume = gr.Dropdown(volume_choices(first["sample"], first["segment"]),
value=first["volume"], label="Scan its mesh is on")
go = gr.Button("Check fibers", variant="primary", size="lg")
with gr.Tab("Your own mesh"):
upload = gr.File(label="Zip of your tifxyz folder (x.tif, y.tif, z.tif)",
file_types=[".zip"], type="filepath")
up_vol = gr.Textbox(label="14-digit scan id its coordinates are in (level-0 voxels)",
placeholder="20250526151718")
go_up = gr.Button("Check my mesh", variant="primary", size="lg")
patches = gr.Radio(PATCHES, value=60, label="How much to check")
judge = gr.Radio([("Free local model (no key)", "local"),
("Claude (your own Anthropic key)", "claude")], value="local", label="Judge")
with gr.Group(visible=False) as claude_box:
key = gr.Textbox(label="Your Anthropic API key (used for this run only, never stored)",
type="password", placeholder="your key")
max_usd = gr.Slider(0.5, 20, value=3, step=0.5, label="Spending cap (USD)")
gr.Markdown("Meshes are read from the open data; the CT is read live from its bucket, never "
"copied here.", elem_classes="fc-note")
with gr.Column(scale=3, min_width=320):
pic = gr.Image(None, label="Fiber map over the segment's mesh grid", type="filepath",
format="png", interactive=False, height=460)
gr.HTML(LEGEND)
summary = gr.HTML("<p class='fc-meta'>Your map and summary appear here. 60 patches take "
"about two minutes; try <b>Fiber or not?</b> while you wait.</p>")
tiles = gr.Gallery(label="Clearest and weakest patches (the renders the judge saw)",
columns=6, object_fit="contain", allow_preview=True, elem_id="fc-tiles")
dl = gr.File(label="Download: fiber.json, fiber.tif, fiber_map.png, report.html, tiles/",
elem_id="fc-dl")
with gr.Accordion("Full report (report.html)", open=False):
report = gr.HTML()
with gr.Accordion("Record (fiber.json)", open=False):
record = gr.JSON(max_height=None) # grows with the record: the page scrolls
with gr.Accordion("About the judges", open=False):
gr.Markdown(ABOUT_JUDGE.format(sha=WEIGHTS_SHA256[:16] + "…", cp=CLAUDE_PATCHES))
with gr.Tab("Fiber or not?", id="game") as game_tab:
gr.Markdown("Two renders of the same patch of a scroll. **One is the CT on the traced surface; the "
"other has lost its strands**, or sits a few voxels off the sheet. Which one shows "
"papyrus fibers? Then see if you beat the free judge.")
st = gr.State(dict(GAME_STATE0))
score_md = gr.Markdown(score_line(GAME_STATE0), elem_classes="fc-score")
with gr.Row(elem_classes="fc-game"):
img_a = gr.Image(label="A", type="numpy", interactive=False, height=420)
img_b = gr.Image(label="B", type="numpy", interactive=False, height=420)
with gr.Row():
pick_a = gr.Button("A has fibers", variant="primary")
pick_b = gr.Button("B has fibers", variant="primary")
nxt = gr.Button("New pair")
reveal = gr.Markdown()
with gr.Tab("Compare", id="compare"):
gr.Markdown("Give two `fiber.json` files (from the downloads, or the command line tool): two "
"segments, two tracers, or one trace before and after an edit.")
with gr.Row():
ca = gr.File(label="A: fiber.json", file_types=[".json"], type="filepath", elem_classes="fc-drop")
cb = gr.File(label="B: fiber.json", file_types=[".json"], type="filepath", elem_classes="fc-drop")
cgo = gr.Button("Compare", variant="primary")
cside = gr.HTML()
with gr.Accordion("Record (JSON)", open=False):
cout = gr.JSON(label="Side by side", max_height=None)
with gr.Tab("How-to guides", id="howto"):
gr.Markdown(with_figures(HOW_TO), elem_classes="fc-howto")
with gr.Tab("Agents and API", id="agents"):
gr.Markdown(AGENTS.format(host=host))
with gr.Accordion("Connect your tool: the scans and meshes by URL", open=True):
gr.HTML(CONNECT_HTML.read_text() if CONNECT_HTML.exists() else "")
gr.HTML('<footer class="fc-credit">Made by <a href="https://github.com/ChaseHendrick">Chase Hendrick</a> · '
'scans and meshes: <a href="https://scrollprize.org">Vesuvius Challenge</a> open data, CC BY-NC 4.0 · '
f'Fiber Checker {VERSION} · model output, not a reading</footer>')
# hidden: the API endpoints and MCP tools for scripts and agents
with gr.Row(visible=False):
a_sample, a_segment, a_volume = (gr.Textbox(label=k) for k in ("sample", "segment", "volume"))
a_patches = gr.Number(60, precision=0, label="patches")
a_fa, a_fb = gr.File(), gr.File()
a_json, a_img, a_file = gr.JSON(), gr.Image(type="filepath", format="png"), gr.File()
a_btns = [gr.Button() for _ in range(5)]
hidden = dict(api_visibility="private")
ex.input(on_example, ex, [sample, segment, volume], **hidden)
sample.input(on_sample, sample, [segment, volume], **hidden)
segment.input(on_segment, [sample, segment], volume, **hidden)
judge.change(on_judge, judge, claude_box, **hidden)
outs = [pic, summary, tiles, dl, report, record]
# progress on the visible outputs only: in the folded report and record it would overflow their boxes
run = dict(concurrency_id="check", concurrency_limit=2, show_progress_on=[pic, summary, tiles, dl], **hidden)
go.click(guard(on_check), [sample, segment, volume, patches, judge, key, max_usd], outs, **run)
go_up.click(guard(on_upload), [upload, up_vol, patches, judge, key, max_usd], outs, **run)
# the progress shows on the two pictures: in the one-line score and the reveal it would not fit, and they
# would scroll inside the page while the judge works
pair_progress = dict(show_progress_on=[img_a, img_b], **hidden)
nxt.click(guard(new_pair), st, [img_a, img_b, reveal, score_md, st], **pair_progress)
def first_pair(s):
if (s or {}).get("i") is not None or not game.ok:
return gr.update(), gr.update(), gr.update(), gr.update(), s
return new_pair(s)
game_tab.select(guard(first_pair), st, [img_a, img_b, reveal, score_md, st], **pair_progress)
pick_a.click(lambda s: answer("A", s), st, [reveal, score_md, st], show_progress="hidden", **hidden)
pick_b.click(lambda s: answer("B", s), st, [reveal, score_md, st], show_progress="hidden", **hidden)
def on_compare(a, b):
res = compare_results(a, b)
return compare_html(res), res
cgo.click(guard(on_compare), [ca, cb], [cside, cout], **hidden)
def api_check(sample_, segment_, volume_, patches_, request: gr.Request, progress=gr.Progress()):
return check_segment(sample_, segment_, volume_, int(patches_ or 60), request=request, progress=progress)
api_check.__doc__ = check_segment.__doc__
for btn, fn, i, o, name in ((a_btns[0], list_scrolls, [], a_json, "list_scrolls"),
(a_btns[1], list_segments, [a_sample], a_json, "list_segments"),
(a_btns[2], example_segments, [], a_json, "example_segments"),
(a_btns[3], api_check, [a_sample, a_segment, a_volume, a_patches],
[a_img, a_json, a_file], "check_segment"),
(a_btns[4], compare_results, [a_fa, a_fb], a_json, "compare_results")):
heavy = name == "check_segment"
btn.click(guard(fn), i, o, api_name=name, api_description=fn.__doc__.split("\n\n")[0],
concurrency_id="check" if heavy else None, concurrency_limit=2 if heavy else 8)
demo.load(None, None, None, js=TAB_JS)
return demo
def fix_mcp_multiple_outputs():
"""Gradio 6.28's MCP server joins a queued tool's several outputs into one text; hand them over one by one, so
check_segment's picture reaches an agent as a picture (as Lasagna Fields does)."""
try:
from gradio.mcp import GradioMCPServer
from anyio.to_thread import run_sync
except ImportError:
return
async def without_progress(self, job):
result = await run_sync(job.result)
return list(result) if isinstance(result, tuple) else [result]
GradioMCPServer._execute_tool_without_progress = without_progress
def launch_kwargs():
import gradio as gr
if (HERE / "assets").is_dir():
gr.set_static_paths(paths=[str(HERE / "assets")])
os.chdir(HERE) # the welcome page's assets/... resolve against the Space's folder
return dict(theme=theme(), css=CSS, head=HEAD, footer_links=["api", "gradio"], mcp_server=True,
max_file_size="200mb", allowed_paths=[str(OUT)])
if __name__ == "__main__":
startup()
fix_mcp_multiple_outputs()
build_demo().queue(max_size=64, default_concurrency_limit=8).launch(**launch_kwargs())
|