Download app.py from SharpMeow/ink-lens: direct link, hf CLI and curl.
- Browser
- Download file 33.3 kB
-
https://huggingface.co/spaces/SharpMeow/ink-lens/resolve/main/app.py
- Command line
-
hf download hf://spaces/SharpMeow/ink-lens/app.py
-
curl -L -o app.py https://huggingface.co/spaces/SharpMeow/ink-lens/resolve/main/app.py
33.3 kB
| """Ink Lens: a control-checked ink map of one region, in one ZeroGPU call, for VC3D. | |
| The web page, the API and the MCP server. The model work is engine.py, the threshold gate.py, and the files VC3D reads | |
| are written by the client (inklens.py) on the user's own machine: the Space keeps nothing. A region arrives as an | |
| .npz (array "volume", uint8, layers first, about 9.6 um), is read into memory, and goes back as maps and a status. | |
| On a ZeroGPU Space the GPU is attached only to gpu_lens, for a duration computed from the region's patch count | |
| (engine.gpu_seconds). When ZeroGPU refuses (quota, no GPU free) a region of at most CPU_SIDE px with one window runs on | |
| the Space's CPU instead; anything larger is pointed to local mode. | |
| 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 json | |
| import os | |
| import sys | |
| import tempfile | |
| import threading | |
| import time | |
| import zipfile | |
| 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 compare # noqa: E402 | |
| import engine # noqa: E402 | |
| import gate # noqa: E402 | |
| import inklens # noqa: E402 | |
| import volio # noqa: E402 | |
| import zoo # noqa: E402 | |
| ON_ZERO_GPU = bool(spaces) and os.environ.get("SPACES_ZERO_GPU", "").lower() in ("1", "true") | |
| OUT = Path(tempfile.gettempdir()) / "ink-lens-out" | |
| CPU_SIDE = 512 | |
| MAX_UPLOAD_BYTES = inklens.MAX_UPLOAD_BYTES | |
| MAX_VOXELS = engine.MAX_SIDE * engine.MAX_SIDE * engine.MAX_LAYERS | |
| ENGINE, ENGINE_ERROR = None, None | |
| READY = threading.Event() | |
| CPU_SLOT = threading.Semaphore(1) | |
| GALLERY = HERE / "gallery" | |
| HAS_GALLERY = (GALLERY / "index.json").is_file() # the gallery tab and endpoint appear once one is published | |
| COMPARE_MAX_SIDE = 512 # 9.6 um grid pixels: a 2048 px square of the 2.4 um scan, 109 layers (about 460 MB) | |
| COMPARE_LOADED = {} | |
| COMPARE_LOCK = threading.Lock() | |
| GPU_REFUSALS = ("ZeroGPU quota exceeded", "ZeroGPU illegal duration", "ZeroGPU pending credits exceeded", | |
| "ZeroGPU queue timeout", "ZeroGPU client error") | |
| # One scroll bar for the whole page (user decision, 27 October 2026): the fragment python -m kit spaceconnect render writes fits | |
| # every same-origin frame to its content, so nothing inside the page scrolls on its own | |
| HEAD = (HERE / "onescroll.html").read_text() if (HERE / "onescroll.html").exists() else "" | |
| class LensError(ValueError): | |
| """Shown to the caller as it is.""" | |
| def log(message): | |
| print(f"[ink-lens {time.strftime('%H:%M:%S')}] {message}", flush=True) | |
| # ---------------------------------------------------------------- start-up | |
| def _load(): | |
| global ENGINE, ENGINE_ERROR | |
| try: | |
| # On ZeroGPU the readers stay on the CPU here: this thread is outside every spaces.GPU call, so moving them to | |
| # "cuda" reaches torch's low-level CUDA init, which ZeroGPU refuses. run() moves them inside the GPU call. | |
| ENGINE = engine.Engine(readers=tuple(engine.READERS), place_on_cuda=not ON_ZERO_GPU) | |
| log(f"readers ready in {ENGINE.startup_s} 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-readers", daemon=True).start() | |
| def eng(wait_s=5.0): | |
| if not READY.wait(wait_s): | |
| raise LensError("the readers are still loading (about a minute after the Space wakes up); try again shortly") | |
| if ENGINE is None: | |
| raise LensError(f"the readers failed to load: {ENGINE_ERROR}") | |
| return ENGINE | |
| # ---------------------------------------------------------------- the one GPU call | |
| def _duration(volume, reader, windows): | |
| return engine.gpu_seconds(engine.patches(volume.shape[1], volume.shape[2], windows, reader)) | |
| def _on_gpu(fn): | |
| return spaces.GPU(duration=_duration)(fn) if ON_ZERO_GPU else fn | |
| def gpu_lens(volume, reader, windows): | |
| """Both depth orders, every window, every chosen reader: one GPU call, so the map never runs without its | |
| control.""" | |
| return ENGINE.run(volume, reader=reader, windows=windows, device="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, reader, windows): | |
| e = eng() | |
| import torch | |
| if ON_ZERO_GPU: | |
| try: | |
| return gpu_lens(volume, reader, windows), "" | |
| except Exception as exc: # noqa: BLE001 | |
| if not _is_refusal(exc): | |
| raise | |
| note = f"ZeroGPU refused the call ({str(getattr(exc, 'message', exc))[:160]})" | |
| elif torch.cuda.is_available(): | |
| return e.run(volume, reader=reader, windows=windows, device="cuda"), "" | |
| else: | |
| note = "No GPU here" | |
| if max(volume.shape[1:]) > CPU_SIDE or windows != 1: | |
| raise LensError(f"{note}. The CPU fallback takes regions up to {CPU_SIDE} px with one window; run larger " | |
| "regions in local mode (python3 inklens.py run --local ...), or try again later") | |
| if not CPU_SLOT.acquire(blocking=False): | |
| raise LensError(f"{note}, and the Space's CPU is busy with another fallback run; try again in a minute") | |
| try: | |
| torch.set_num_threads(max(1, (os.cpu_count() or 2) - 1)) | |
| return e.run(volume, reader=reader, windows=windows, device="cpu"), f"{note}, so it ran on the Space's CPU." | |
| finally: | |
| CPU_SLOT.release() | |
| # ---------------------------------------------------------------- comparison: many models, one region | |
| def _compare_duration(volume, scan, models, centers, steps, labels=None, sample=None): | |
| k = 4 if scan == "2.4" else 1 | |
| return compare.estimate(models, volume.shape[1] // k, volume.shape[2] // k, len(centers) * len(steps)) | |
| def _compare_on_gpu(fn): | |
| return spaces.GPU(duration=_compare_duration)(fn) if ON_ZERO_GPU else fn | |
| def gpu_compare(volume, scan, models, centers, steps, labels=None, sample=None): | |
| """Every chosen model, every window, both depth orders: one GPU call, so no map runs without its control.""" | |
| import torch | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| return compare.run(volume, scan, models, labels=labels, centers=centers, steps=steps, loaded=COMPARE_LOADED, | |
| device=device, say=log, sample=sample) | |
| def _models_arg(models): | |
| names = [m.strip() for m in (models if isinstance(models, (list, tuple)) else str(models).split(",")) if m.strip()] | |
| if not names: | |
| raise LensError("choose at least one model (list_models names them)") | |
| bad = [n for n in names if n not in zoo.models()] | |
| if bad: | |
| raise LensError(f"unknown models {bad}; list_models names them") | |
| live = [n for n in names if zoo.get(n)["family"] != "ct3d"] | |
| if not live: | |
| raise LensError("the scan-space 3D models need minutes of GPU per sample and do not run live; " | |
| "tools/gallery_job.py runs them on a GPU") | |
| return live | |
| def _ints(text, default): | |
| try: | |
| vals = [int(v) for v in str(text).replace(",", " ").split()] if str(text).strip() else list(default) | |
| except ValueError: | |
| raise LensError(f"{text!r}: give whole numbers separated by spaces") from None | |
| if not vals or len(vals) > 5: | |
| raise LensError("give one to five values") | |
| return vals | |
| def compare_volume(volume, scan, models, centers, steps, sample=None, labels=None): | |
| scan = str(scan) | |
| if scan not in compare.SCANS: | |
| raise LensError("scan is 2.4 (the about 2.4 um scan) or native (about 9.4 um)") | |
| if volume.ndim != 3 or volume.dtype.name != "uint8": | |
| raise LensError("a region is uint8, layers x rows x columns") | |
| k = 4 if scan == "2.4" else 1 | |
| if max(volume.shape[1:]) // k > COMPARE_MAX_SIDE or min(volume.shape[1:]) // k < 128: | |
| raise LensError(f"a region is 128 to {COMPARE_MAX_SIDE} px a side on the 9.6 um grid " | |
| f"({128 * k} to {COMPARE_MAX_SIDE * k} px of this scan)") | |
| models = _models_arg(models) | |
| with COMPARE_LOCK: # weights load once, on the CPU, outside the GPU call | |
| for n in models: | |
| if n not in COMPARE_LOADED: | |
| try: | |
| import runners | |
| COMPARE_LOADED[n] = runners.load(n, device="cpu") | |
| except Exception as exc: # noqa: BLE001 (run() reports the model's error in its row) | |
| log(f"{n} failed to load: {exc}") | |
| requested = _compare_duration(volume, scan, models, centers, steps) | |
| result, maps, _ = gpu_compare(volume, scan, models, centers, steps, labels=labels, sample=sample) | |
| result["gpu_seconds_requested"] = requested | |
| import numpy as np | |
| order = [n for n in models if (n, "c+0_s1", "forward") in maps] + (["ensemble"] if result.get("ensemble") else []) | |
| cols = sorted({(kk, o) for (n, kk, o) in maps if n in order and kk.startswith("c")}, | |
| key=lambda t: (t[1] != "forward", int(t[0].split("_s")[1]), int(t[0][1:].split("_")[0]))) | |
| mos = compare.mosaic(maps, order, cols, labels=labels) | |
| OUT.mkdir(parents=True, exist_ok=True) | |
| path = OUT / f"inklens-compare-{int(time.time() * 1000)}.npz" | |
| np.savez_compressed(path, **{"|".join(kk): np.asarray(v, np.float16) for kk, v in maps.items()}) | |
| result["mosaic"] = {"rows": order, "columns": [f"{kk} {o}" for kk, o in cols]} | |
| return api_json(result), str(path), mos | |
| def compare_sample(sample_id="0841-w00", models="ink_9um-s42,reader-v2,reader-v3,hecate-9.6", size=384, scan="2.4", | |
| centers="0", steps="1"): | |
| """Several ink models on one public sample, side by side: same pixels, same labels, each with its own control. | |
| sample_id: a public sample (list_samples). models: comma-separated names from list_models (the scan-space 3D | |
| models are in the gallery endpoint). size: 128 to 512 px of the 9.6 um grid around the sample's crop center. | |
| scan: 2.4 (default; every model reads the about 2.4 um scan at its own scale: 9 um models pooled 4x) or native | |
| (the 9.4 um scan, 9 um models only). centers, steps: depth windows (9.6 um layers from the middle; spacing). | |
| Returns the record (per model: AUC, control AUC, gap, 95 % interval, liveness, training exposure; the ensemble; | |
| stability), an .npz of every map, and a mosaic (rows: models; columns: windows, as stored then reversed; labeled | |
| ink outlined). Model output, not a reading.""" | |
| s = inklens.sample_by(str(sample_id)) | |
| centers, steps = _ints(centers, (0,)), _ints(steps, (1,)) | |
| size = max(128, min(COMPARE_MAX_SIDE, int(size or 384))) | |
| vol, lab, info = compare.sample_inputs(s, str(scan), size=size) | |
| meta = {"id": s["id"], "scroll": s["scroll"], "segment": s["segment"], "box": info["box"], "grid": info["grid"]} | |
| return compare_volume(vol, scan, models, centers, steps, sample=meta, labels=lab) | |
| def compare_region(volume_npz, models="ink_9um-s42,reader-v2,hecate-9.6", scan="2.4", centers="0", steps="1"): | |
| """Several ink models on your own region, side by side, each with its own control (no labels, so no AUC). | |
| volume_npz: an .npz with one array 'volume' (uint8, layers x rows x columns): scan 2.4 for an about 2.4 um | |
| surface volume (at least 64 layers for Hecate 2.4 and canonical; 512 to 2048 px a side), native for about 9.4 | |
| um (28 or more layers; 128 to 512 px). Returns the record, an .npz of every map, and a mosaic.""" | |
| if not volume_npz: | |
| raise LensError("upload an .npz first") | |
| path = volume_npz if isinstance(volume_npz, str) else volume_npz.get("path") | |
| return compare_volume(read_npz(path), scan, models, _ints(centers, (0,)), _ints(steps, (1,))) | |
| def api_json(obj): | |
| """A record as the API returns it: every dict key "path" (a weight file inside its repo) becomes "file", because | |
| gradio_client takes any JSON dict holding "path" for a file to download and fails on it.""" | |
| if isinstance(obj, dict): | |
| return {("file" if k == "path" else k): api_json(v) for k, v in obj.items()} | |
| if isinstance(obj, (list, tuple)): | |
| return [api_json(v) for v in obj] | |
| return obj | |
| def list_models(): | |
| """Every ink model Ink Lens compares: family, scale, depth, license, pinned weights, the control each is judged | |
| against, and on which public scroll its training saw labels (with the source).""" | |
| return api_json({"models": zoo.listing(), "ink_sdk": f"{zoo.INK_SDK_REPO} @ {zoo.INK_SDK_COMMIT}", | |
| "gallery": sorted(json.loads((GALLERY / "index.json").read_text())["samples"]) | |
| if (GALLERY / "index.json").is_file() else []}) | |
| def gallery(sample_id="0841-w00"): | |
| """The precomputed comparison of every model, the scan-space 3D models included, on a sample's whole 640 px crop: | |
| the record (scores, intervals, liveness, exposure, depth profiles, orientation check) and the mosaic.""" | |
| idx = GALLERY / "index.json" | |
| if not idx.is_file(): | |
| raise LensError("the gallery has not been published yet") | |
| entry = json.loads(idx.read_text())["samples"].get(str(sample_id)) | |
| if entry is None: | |
| raise LensError(f"no gallery for {sample_id}") | |
| import numpy as np | |
| from PIL import Image | |
| rec = json.loads((GALLERY / entry["record"]).read_text()) | |
| return api_json(rec), np.asarray(Image.open(GALLERY / entry["mosaic"]).convert("RGB")) | |
| def compare_md(result): | |
| lines = [compare.table(result), ""] | |
| e = result.get("ensemble") | |
| if e: | |
| lines.append(f"Ensemble: {', '.join(e['members'])} (ink-sdk's z-score recipe).") | |
| st = result.get("stability") or {} | |
| if st.get("0.5"): | |
| lines.append(f"Stability: on average {st['0.5']['mean_share']:.0%} of the {st['0.5']['cells']} alive cells " | |
| "put a pixel at or above 0.5.") | |
| lines += ["", f"Mosaic rows: {', '.join(result.get('mosaic', {}).get('rows', []))}; columns: " | |
| f"{', '.join(result.get('mosaic', {}).get('columns', []))}. Labeled ink is outlined in amber.", | |
| "", f"*{compare.NOTE}*"] | |
| return "\n".join(lines) | |
| # ---------------------------------------------------------------- inputs | |
| def read_npz(path): | |
| """The 'volume' array of an uploaded .npz, refused before decompression when it is too big or not plain.""" | |
| import numpy as np | |
| p = Path(path) | |
| try: | |
| if p.stat().st_size > MAX_UPLOAD_BYTES: | |
| raise LensError(f"the upload is {p.stat().st_size / 2 ** 20:.1f} MB; the limit is " | |
| f"{MAX_UPLOAD_BYTES >> 20} MB") | |
| if p.suffix.lower() != ".npz": | |
| raise LensError("upload an .npz with one array named 'volume' (uint8, layers x rows x columns)") | |
| with zipfile.ZipFile(p) as zf: | |
| infos = {i.filename: i for i in zf.infolist()} | |
| if "volume.npy" not in infos: | |
| raise LensError(f"the .npz holds {sorted(infos)}; it needs volume") | |
| if infos["volume.npy"].file_size > MAX_VOXELS + 4096: | |
| raise LensError("the volume is larger than a region may be (2048 x 2048 px, 65 layers)") | |
| with np.load(p, allow_pickle=False) as z: | |
| vol = z["volume"] | |
| except (zipfile.BadZipFile, ValueError, OSError) as exc: | |
| if isinstance(exc, LensError): | |
| raise | |
| raise LensError(f"not a readable .npz: {exc}") from None | |
| finally: | |
| try: | |
| p.unlink() | |
| except OSError: | |
| pass | |
| return volio.to_uint8(vol) | |
| def preview(volume, kept_u8): | |
| """The region's middle CT layer in gray with kept ink in amber, as an RGB array for the page.""" | |
| import numpy as np | |
| ct = inklens.ct_middle(volume).astype(np.float32) | |
| lo, hi = np.percentile(ct, [1, 99]) if ct.size else (0, 1) | |
| g = np.clip((ct - lo) / max(hi - lo, 1e-6), 0, 1) | |
| rgb = np.stack([g, g, g], -1) | |
| a = (kept_u8.astype(np.float32) / 255.0)[..., None] * 0.85 | |
| rgb = rgb * (1 - a) + np.array([1.0, 0.62, 0.1]) * a | |
| return (rgb * 255).astype(np.uint8) | |
| def lens_volume(volume, reader, windows, sample=None, box=None): | |
| engine.check_volume(volume, int(windows)) | |
| reader, windows = str(reader), int(windows) | |
| requested = _duration(volume, reader, windows) | |
| run, note = run_engine(volume, reader, windows) | |
| decision, kept, _ = inklens.analyze(run["forward"], run["reverse"], volume) | |
| result = {"tool": f"Ink Lens {engine.VERSION}", "gate": decision, "readers": run["readers"], | |
| "windows": windows, "patches": run["patches"], "device": run["device"], | |
| "device_name": run["device_name"], "seconds": run["seconds"], "gpu_seconds_requested": requested, | |
| "window_layers": run["window_layers"], "stack_start": run.get("stack_start"), | |
| "shape": [int(v) for v in volume.shape], "engine": ENGINE.describe(), "note": inklens.NOTE} | |
| if note: | |
| result["fallback"] = note | |
| if len(run["readers"]) > 1: | |
| result["per_reader"] = {n: gate.decide(m["forward"], m["reverse"], inklens.ct_middle(volume)) | |
| for n, m in run["per_reader"].items()} | |
| if sample is not None: | |
| result["sample"] = {"id": sample["id"], "role": sample["role"], "box": list(box)} | |
| try: | |
| ink, sup = inklens.labels_for(sample, box, run["forward"].shape) | |
| from kit import auc | |
| result["labels"] = {"shares": gate.label_shares(kept, ink, sup), | |
| "auc_forward": auc.score_array(run["forward"], ink, sup, inner=64)["auc"], | |
| "auc_reverse": auc.score_array(run["reverse"], ink, sup, inner=64)["auc"]} | |
| except Exception as exc: # noqa: BLE001 (scoring is a bonus; the map stands without it) | |
| result["labels"] = {"error": f"{type(exc).__name__}: {str(exc)[:200]}"} | |
| import numpy as np | |
| OUT.mkdir(parents=True, exist_ok=True) | |
| maps = OUT / f"inklens-{int(time.time() * 1000)}.npz" | |
| np.savez_compressed(maps, forward=run["forward"], reverse=run["reverse"], kept=gate.to_u8(kept)) | |
| return result, str(maps), preview(volume, gate.to_u8(kept)) | |
| def summary_md(result): | |
| g = result["gate"] | |
| lines = [f"**Status: {g['status']}.** {g['status_text']}.", ""] | |
| if g.get("excess") is not None: | |
| lines.append(f"Excess {g['excess']:.2f} (3 or more is ink, 1.5 to 3 weak); threshold T = {g['threshold']:.4f}, " | |
| f"the 99th percentile of the reversed map on {g['papyrus_px']:,} papyrus pixels.") | |
| if result.get("labels", {}).get("shares"): | |
| s = result["labels"]["shares"] | |
| lines += ["", f"Labels: kept ink covers {s['ink_kept_share']:.1%} of labeled ink pixels and " | |
| f"{s['background_kept_share']:.1%} of labeled background. Raw AUC {result['labels']['auc_forward']}" | |
| f", reversed control {result['labels']['auc_reverse']}."] | |
| lines += ["", f"{result['patches']} patches on {result['device_name']} in {result['seconds']} s " | |
| f"(asked ZeroGPU for {result['gpu_seconds_requested']} s).", "", "*Model output, not a reading.*"] | |
| if result.get("fallback"): | |
| lines.insert(0, f"> {result['fallback']}\n") | |
| return "\n".join(lines) | |
| # ---------------------------------------------------------------- API and MCP tools | |
| def lens_region(volume_npz, reader="d9v2", windows=3): | |
| """Ink map of your own region with its reverse-depth control and a status that can fail. | |
| volume_npz: an .npz with one array 'volume' (uint8, layers x rows x columns, about 9.6 um per pixel, 21 to 65 | |
| layers centered on the papyrus surface, 128 to 2048 px a side). reader: d9v2 (default), ink_9um or both. | |
| windows: 3 (default; three depth windows averaged) or 1. Returns the result (status ink, weak, none, too_small | |
| or unreadable; excess; threshold), an .npz of the float maps (forward, reverse) and the kept map (uint8), and a | |
| preview. Model output, not a reading.""" | |
| if not volume_npz: | |
| raise LensError("upload an .npz first") | |
| path = volume_npz if isinstance(volume_npz, str) else volume_npz.get("path") | |
| return lens_volume(read_npz(path), reader, windows) | |
| def lens_sample(sample_id="0841-w00", size=640, reader="d9v2", windows=3): | |
| """Ink Lens on a public sample crop (PHerc0841, not prize-eligible), read from the open-data | |
| bucket, with the kept ink scored against the published labels. size: 128 up to the sample's 640 px crop.""" | |
| s = inklens.sample_by(str(sample_id)) | |
| box = inklens.sample_box(s, int(size) if size else None) | |
| volume, _ = inklens.read_sample(s, box) | |
| return lens_volume(volume, reader, windows, sample=s, box=box) | |
| def list_samples(): | |
| """The public samples Ink Lens can run on, with their crops and what each one is to the readers.""" | |
| return {"samples": [{k: s[k] for k in ("id", "title", "role", "crop", "surface_shape")} | |
| for s in inklens.load_samples()["samples"]], | |
| "readers": {n: {k: r[k] for k in ("title", "license", "source", "held_out")} | |
| for n, r in engine.READERS.items()}} | |
| def estimate(height=1024, width=1024, windows=3, reader="d9v2"): | |
| """Patches and the ZeroGPU seconds one call on a height x width region (9.6 um pixels) asks for.""" | |
| return engine.estimate(int(height), int(width), int(windows), str(reader)) | |
| INTRO = """# Ink Lens | |
| Pick a region of a segment and get an ink map **with its own control**. One ZeroGPU call runs an openly licensed | |
| 9 um ink model ([d9v2](https://github.com/TAUIL-Abd-Elilah/pherc0826-first-letters-search), MIT, or the Vesuvius | |
| Challenge's [`ink_9um`](https://huggingface.co/scrollprize/ink_9um), MIT) over three depth windows, with the layers as | |
| stored and reversed. The reversed map sets the threshold: only ink stronger than 99 % of what the control produces is | |
| kept, and the region gets a status that can fail (`none`: no ink above the control). | |
| The client (`inklens.py`, from [ChaseHendrick/Scrolls](https://github.com/ChaseHendrick/Scrolls/tree/main/spaces/ink-lens)) | |
| renders the region from your VC3D segment, calls this Space with your own Hugging Face token (your ZeroGPU quota | |
| pays, about 10 s for a 1 cm region) and writes the result back as VC3D's own files: an overlay in the Ink Detection | |
| panel, and letter-candidate pins as a point collection. | |
| **Everything shown is model output, not a reading.** | |
| """ | |
| AGENTS = """```python | |
| from gradio_client import Client, handle_file | |
| c = Client("SharpMeow/ink-lens", hf_token=YOUR_TOKEN) # your token: your own ZeroGPU quota | |
| result, maps, preview = c.predict(handle_file("region.npz"), "d9v2", 3, api_name="/lens_region") | |
| result["gate"]["status"] # ink | weak | none | too_small | unreadable | |
| ``` | |
| Compare models: `c.predict("0841-w00", "ink_9um-s42,reader-v3,hecate-9.6", 384, "2.4", "0", "1", | |
| api_name="/compare_sample")` returns the record, every map and a mosaic; `/compare_region` takes your own .npz, | |
| `/list_models` names every model with its training exposure. | |
| Also `/lens_sample`, `/list_samples`, `/estimate`; every endpoint is an MCP tool at `/gradio_api/mcp/sse`.""" | |
| COMPARE_INTRO = """Every released ink model on **the same pixels, scored against the same labels**. Each sample is read | |
| from its about 2.4 um scan, and each model reads it at its own scale (9 um models pooled 4x, as villa prepares their | |
| training data; Hecate 2.4 and canonical 2 um natively, their maps pooled onto the 9.6 um grid the labels use). Every | |
| map comes with its own control (the same layers reversed), passes ink-sdk's liveness check, and its row says whether | |
| the model's training saw labels from this scroll: a score there shows the pipeline runs, not that it generalizes. | |
| Choose several depth windows to get a sweep, a stability share and a z-score ensemble. Model output, not a reading.""" | |
| GALLERY_INTRO = """Precomputed on a GPU Job: every model on each sample's whole 640 px crop, with depth sweeps, Hecate's | |
| depth profiles, ink-sdk's label-free orientation check, and the two scan-space 3D models (the team's Paris 4 3D | |
| model and the DINO-guided 3D model), which read the CT around the sheet and are projected onto the same mesh pixels, | |
| judged against the sheet moved two pitches out and in.""" | |
| def build_demo(): | |
| import gradio as gr | |
| def guard(fn): | |
| def wrapped(*args, **kwargs): | |
| try: | |
| return fn(*args, **kwargs) | |
| except (LensError, inklens.Refused, engine.EngineError, gate.GateError, volio.VolumeError) 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)[:300]}") from None | |
| return wrapped | |
| def page_sample(sid, size, reader, windows): | |
| result, maps, img = guard(lens_sample)(sid, size, reader, windows) | |
| return summary_md(result), result, maps, img | |
| def page_upload(f, reader, windows): | |
| result, maps, img = guard(lens_region)(f, reader, windows) | |
| return summary_md(result), result, maps, img | |
| def page_compare(sid, models, size, scan, centers, steps): | |
| result, maps, img = guard(compare_sample)(sid, ",".join(models or []), size, scan, centers, steps) | |
| return compare_md(result), result, maps, img | |
| def page_gallery(sid): | |
| result, img = guard(gallery)(sid) | |
| return result.get("table", ""), result, img | |
| samples = [(s["title"], s["id"]) for s in inklens.load_samples()["samples"]] | |
| live_models = [(f"{zoo.get(n)['title']} ({n})", n) for n in zoo.models() if zoo.get(n)["family"] != "ct3d"] | |
| with gr.Blocks(title="Ink Lens") as demo: | |
| gr.Markdown(INTRO) | |
| with gr.Tab("Compare models"): | |
| gr.Markdown(COMPARE_INTRO) | |
| with gr.Row(): | |
| with gr.Column(scale=2, min_width=300): | |
| c_sid = gr.Dropdown(samples, value=samples[0][1], label="Sample") | |
| c_models = gr.CheckboxGroup(live_models, value=["ink_9um-s42", "reader-v2", "reader-v3", | |
| "hecate-9.6"], label="Models") | |
| c_size = gr.Slider(128, COMPARE_MAX_SIDE, value=384, step=64, | |
| label="Region side (px on the 9.6 um grid)") | |
| c_scan = gr.Radio([("2.4 um scan (every model)", "2.4"), ("9.4 um scan (9 um models)", "native")], | |
| value="2.4", label="Scan") | |
| with gr.Row(): | |
| c_centers = gr.Textbox("0", label="Window centers (layers from the middle)") | |
| c_steps = gr.Textbox("1", label="Layer steps") | |
| run_c = gr.Button("Compare", variant="primary") | |
| with gr.Column(scale=3, min_width=320): | |
| c_img = gr.Image(label="Mosaic: rows are models, columns are depth windows", type="numpy", | |
| format="png") | |
| c_md = gr.Markdown("Choose models, then compare.") | |
| # Every record box grows with its content (max_height=None): gradio's default 500 px box would | |
| # scroll inside the page (one scroll bar, user decision, 27 October 2026) | |
| c_res = gr.JSON(label="Run record", max_height=None) | |
| c_maps = gr.File(label="Every map (.npz, 9.6 um grid)") | |
| run_c.click(page_compare, [c_sid, c_models, c_size, c_scan, c_centers, c_steps], | |
| [c_md, c_res, c_maps, c_img], api_visibility="private", concurrency_id="gpu", | |
| concurrency_limit=2) | |
| with gr.Tab("Gallery: every model, 3D included", visible=HAS_GALLERY): | |
| gr.Markdown(GALLERY_INTRO) | |
| g_sid = gr.Dropdown(samples, value=samples[0][1], label="Sample") | |
| g_btn = gr.Button("Show") | |
| g_img = gr.Image(label="Every model on the whole crop", type="numpy", format="png") | |
| g_md = gr.Markdown() | |
| g_res = gr.JSON(label="Record", max_height=None) | |
| g_btn.click(page_gallery, [g_sid], [g_md, g_res, g_img], api_visibility="private") | |
| with gr.Tab("One model, gated"), gr.Row(): | |
| with gr.Column(scale=2, min_width=300): | |
| with gr.Tab("Public sample"): | |
| sid = gr.Dropdown(samples, value=samples[0][1], label="Sample") | |
| size = gr.Slider(128, 640, value=640, step=64, label="Region side (px at 9.4 um)") | |
| run_s = gr.Button("Run Ink Lens", variant="primary") | |
| with gr.Tab("Your region (.npz)"): | |
| up = gr.File(label="region.npz (array 'volume', uint8, layers first)", file_types=[".npz"], | |
| type="filepath") | |
| run_u = gr.Button("Run Ink Lens", variant="primary") | |
| reader = gr.Radio(list(engine.READER_CHOICES), value="d9v2", label="Reader") | |
| windows = gr.Radio([3, 1], value=3, label="Depth windows") | |
| with gr.Accordion("For agents and scripts", open=False): | |
| gr.Markdown(AGENTS) | |
| with gr.Accordion("Connect your tool", open=False): | |
| # The data's URLs and ready snippets, made by python -m kit spaceconnect render from connect.json | |
| gr.HTML((HERE / "connect.html").read_text() if (HERE / "connect.html").exists() else "") | |
| with gr.Column(scale=3, min_width=320): | |
| img = gr.Image(label="Middle CT layer, kept ink in amber", type="numpy", format="png") | |
| md = gr.Markdown("Choose a sample or upload a region, then run.") | |
| res = gr.JSON(label="Result", max_height=None) | |
| maps = gr.File(label="Maps (.npz: forward, reverse, kept)") | |
| outs = [md, res, maps, img] | |
| run_s.click(page_sample, [sid, size, reader, windows], outs, api_visibility="private", | |
| concurrency_id="gpu", concurrency_limit=2) | |
| run_u.click(page_upload, [up, reader, windows], outs, api_visibility="private", concurrency_id="gpu", | |
| concurrency_limit=2) | |
| with gr.Row(visible=False): | |
| a_file = gr.File(type="filepath", file_types=[".npz"], label="volume_npz") | |
| a_reader, a_windows = gr.Textbox("d9v2", label="reader"), gr.Number(3, precision=0, label="windows") | |
| a_sid, a_size = gr.Textbox("0841-w00", label="sample_id"), gr.Number(640, precision=0, label="size") | |
| a_h, a_w = gr.Number(1024, precision=0, label="height"), gr.Number(1024, precision=0, label="width") | |
| a_json, a_maps, a_img, a_info = gr.JSON(), gr.File(), gr.Image(type="numpy", format="png"), gr.JSON() | |
| a_models = gr.Textbox("ink_9um-s42,reader-v2,hecate-9.6", label="models") | |
| a_scan = gr.Textbox("2.4", label="scan") | |
| a_centers, a_steps = gr.Textbox("0", label="centers"), gr.Textbox("1", label="steps") | |
| a_csize = gr.Number(384, precision=0, label="size") | |
| btns = [gr.Button() for _ in range(8)] | |
| for btn, fn, ins, o, conc in ( | |
| (btns[0], lens_region, [a_file, a_reader, a_windows], [a_json, a_maps, a_img], "gpu"), | |
| (btns[1], lens_sample, [a_sid, a_size, a_reader, a_windows], [a_json, a_maps, a_img], "gpu"), | |
| (btns[2], list_samples, [], a_info, None), | |
| (btns[3], estimate, [a_h, a_w, a_windows, a_reader], a_info, None), | |
| (btns[4], compare_sample, [a_sid, a_models, a_csize, a_scan, a_centers, a_steps], | |
| [a_json, a_maps, a_img], "gpu"), | |
| (btns[5], compare_region, [a_file, a_models, a_scan, a_centers, a_steps], [a_json, a_maps, a_img], | |
| "gpu"), | |
| (btns[6], list_models, [], a_info, None)) + ( | |
| ((btns[7], gallery, [a_sid], [a_json, a_img], None),) if HAS_GALLERY else ()): | |
| btn.click(guard(fn), ins, o, api_name=fn.__name__, api_description=fn.__doc__.split("\n\n")[0], | |
| concurrency_id=conc, concurrency_limit=2 if conc else 8) | |
| gr.HTML('<footer style="opacity:.7;font-size:.85em">Built by Chase Hendrick 路 models by the Vesuvius Challenge, ' | |
| 'TAUIL-Abd-Elilah, Domenico Russo and others (each credited in its row) 路 comparison features after ' | |
| '<a href="https://github.com/LimeGS/ink-sdk">ink-sdk</a> (MIT) 路 data by the Vesuvius Challenge ' | |
| f'(CC BY-NC 4.0) 路 Ink Lens {engine.VERSION}</footer>') | |
| return demo | |
| if __name__ == "__main__": | |
| startup() | |
| build_demo().queue(max_size=128, default_concurrency_limit=4).launch( | |
| mcp_server=True, max_file_size=f"{MAX_UPLOAD_BYTES >> 20}mb", allowed_paths=[str(OUT)], head=HEAD) | |