Spaces:
Running on Zero
Running on Zero
Download lens.py from SharpMeow/scroll-lens: direct link, hf CLI and curl.
- Browser
- Download file 55.7 kB
-
https://huggingface.co/spaces/SharpMeow/scroll-lens/resolve/main/lens.py
- Command line
-
hf download hf://spaces/SharpMeow/scroll-lens/lens.py
-
curl -L -o lens.py https://huggingface.co/spaces/SharpMeow/scroll-lens/resolve/main/lens.py
55.7 kB
| """Scroll Lens core: everything except the web page. | |
| A visitor picks a point inside one of the released 2.4 um scans listed in VOLUMES. Scroll Lens reads the cube of | |
| the scan around that point from the Vesuvius Challenge open-data bucket and runs three models the Vesuvius | |
| Challenge released with the PHerc. Paris 4 paper (Angelotti et al. 2026, arXiv:2606.29085) on it: | |
| - dinovol, a 3D DINO model that turns every 8 x 8 x 8 voxel patch into an 864-number embedding; | |
| - ink_3d_dino_guided, a 3D U-Net that marks ink voxels directly in the scan (made for PHerc. Paris 4); | |
| - surface_recto_3dunet, a 3D U-Net that marks the written (recto) surface of each papyrus sheet. | |
| Clicks then light up every patch whose embedding is close to the clicked ones: the expert-click method the | |
| organizers used to build the reference ink embedding behind ink_3d_dino_guided. Everything here is model output, | |
| not a reading; a cube is 0.6 mm across, smaller than one letter. | |
| No gradio here, and torch is imported only inside the model code, so the tests run with numpy alone. | |
| python3 lens.py volumes the scans this Space may read | |
| python3 lens.py look PHercParis4 Z Y X [--size 128] [--layers dino,ink,surface] [--out DIR] | |
| python3 lens.py files the files of the Space, with their SHA-256 | |
| python3 lens.py stage DEST copy them into DEST (absent or empty), laid out as the Space runs | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import concurrent.futures | |
| import hashlib | |
| import io | |
| import json | |
| import math | |
| import os | |
| import shutil | |
| import subprocess | |
| import sys | |
| import tarfile | |
| import tempfile | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| from pathlib import Path | |
| HERE = Path(__file__).resolve().parent | |
| VERSION = "1.1.1" | |
| BUCKET = "https://vesuvius-challenge-open-data.s3.us-east-1.amazonaws.com" | |
| CHUNK = 128 # every listed scan is stored as uncompressed uint8 chunks of 128^3 voxels | |
| SIZES = (128, 256) # cube sides in voxels; 256 is the models' training patch, 128 fits a CPU | |
| PATCH = 8 # dinovol embeds each 8^3 voxel patch | |
| EMBED_DIM = 864 | |
| MIN_FOREGROUND = 0.05 # a cube with less papyrus than this (the scans are masked to 0 outside it) is refused | |
| REDUCED_DIM = 128 # gallery cubes keep this many components of each embedding (see reduce_grid) | |
| TAU = 0.5 # the threshold the organizers used on cosine similarity to their ink reference | |
| MAX_CONCEPT_BYTES = 4 << 20 | |
| USER_AGENT = f"scroll-lens/{VERSION}" | |
| class LensError(Exception): | |
| """A refusal with a message meant for the visitor.""" | |
| # ---------------------------------------------------------------- the scans this Space may read | |
| # | |
| # Only these volumes, all about 2.4 um scans at full resolution (level 0), the scale dinovol was trained on. None of | |
| # them is on the First Letters or Grand Prize eligible lists (kit/data/prizes-2026-10-11.json). Anything else is | |
| # refused before a byte is fetched. "role" says what each model saw of the scroll, from the model cards and the | |
| # checkpoints' own training configs (read 16 October 2026). | |
| INK_TEACHER = ("Not trained on this scroll, but its teacher model (stage 1) was trained on this scroll's " | |
| "ink labels.") | |
| VOLUMES = { | |
| "PHercParis4": dict( | |
| scroll="PHerc. Paris 4", scan="20260411134726-2.400um-0.2m-78keV-masked.zarr", voxel_um=2.400, | |
| shape=(75784, 32693, 32693), | |
| role=dict(dino="Trained on this scan.", ink="Trained on this scroll: the model was made for it.", | |
| surface="Not in its training list."), | |
| note="Scroll 1 of the Vesuvius Challenge. Unwrapped and read in Angelotti et al. 2026, where ink is " | |
| "visible in the scan itself. Its title is still an open prize."), | |
| "PHerc0139": dict( | |
| scroll="PHerc. 139", scan="20260102150214-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(76953, 26511, 26511), | |
| role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Trained on this scroll."), | |
| note="The scroll Unroll shows in 3D, with 37 traced windings and published ink detection."), | |
| "PHerc1667": dict( | |
| scroll="PHerc. 1667 (Scroll 4)", scan="20251217075048-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(37076, 15229, 15229), | |
| role=dict(dino="Not in its training list.", ink=INK_TEACHER, surface="Trained on this scroll."), | |
| note="The one scan here that dinovol never saw in training."), | |
| "PHerc0500P2": dict( | |
| scroll="PHerc. 500P2", scan="20250526151718-2.215um-0.4m-111keV-masked.zarr", voxel_um=2.215, | |
| shape=(28096, 18209, 18209), | |
| role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Trained on this scroll."), | |
| note="Seen in training by all three models."), | |
| "PHerc0343P": dict( | |
| scroll="PHerc. 343P", scan="20260304131111-2.215um-0.4m-111keV-masked.zarr", voxel_um=2.215, | |
| shape=(20714, 13155, 13155), | |
| role=dict(dino="Trained on this scan.", ink="Not in its training list.", | |
| surface="Trained on this scroll."), | |
| note="The First Letters prize for PHerc. 343 was awarded on 8 October 2026."), | |
| "PHerc0814": dict( | |
| scroll="PHerc. 814", scan="20260309142202-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(74568, 23891, 23891), | |
| role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Not in its training list."), | |
| note=""), | |
| "PHerc0009B": dict( | |
| scroll="PHerc. 9B", scan="20250820154339-2.401um-0.3m-77keV-masked.zarr", voxel_um=2.401, | |
| shape=(29112, 28259, 28259), | |
| role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Not in its training list."), | |
| note=""), | |
| "PHercMAN5": dict( | |
| scroll="PHerc. MAN 5", scan="20260311104824-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(31640, 17495, 17495), | |
| role=dict(dino="Trained on this scan.", ink=INK_TEACHER, surface="Not in its training list."), | |
| note=""), | |
| "PHerc1299": dict( | |
| scroll="PHerc. 1299", scan="20260309130042-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(49233, 20579, 20579), | |
| role=dict(dino="Trained on this scan.", ink="Not in its training list.", | |
| surface="Not in its training list."), | |
| note=""), | |
| "PHerc0332": dict( | |
| scroll="PHerc. 332", scan="20251211183505-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(33592, 15761, 15761), | |
| role=dict(dino="Trained on this scan.", ink="Not in its training list.", | |
| surface="Not in its training list."), | |
| note=""), | |
| "PHerc1451": dict( | |
| scroll="PHerc. 1451", scan="20260319101107-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(59944, 20811, 20811), | |
| role=dict(dino="Trained on this scan.", ink="Not in its training list.", | |
| surface="Not in its training list."), | |
| note=""), | |
| "PHercMANB": dict( | |
| scroll="PHerc. MAN B", scan="20260323091048-2.399um-0.2m-78keV-masked.zarr", voxel_um=2.399, | |
| shape=(72681, 26723, 26723), | |
| role=dict(dino="Trained on this scan.", ink="Not in its training list.", | |
| surface="Listed as MAN Bp on the model card: trained on this scroll."), | |
| note=""), | |
| } | |
| # PHerc. Paris 4's title is still an open prize, so Scroll Lens never picks Paris 4 points itself: "Surprise me" and the | |
| # gallery use only these, the eight Paris 4 points the organizers listed for training in the ink checkpoint's own | |
| # config (extra_patches.coords_xyz of ckpt_78k_fullsup.pth, sampled with 1024 voxels of jitter), here as z y x. | |
| PARIS4_TRAINING_POINTS = ((37628, 8793, 22167), (35344, 19651, 19735), (70688, 19605, 10649), (41802, 16221, 12261), | |
| (41652, 10039, 8270), (41652, 8812, 8006), (41652, 10476, 19310), (41778, 17662, 27405)) | |
| LAYERS = ("dino", "ink", "surface") | |
| LAYER_TITLES = {"dino": "3D DINO embeddings", "ink": "3D ink model", "surface": "Recto surface model"} | |
| # ---------------------------------------------------------------- the models and the code they need | |
| # | |
| # Weights come from the Hugging Face Hub at a pinned revision and are refused unless their SHA-256 matches. | |
| # The model code is the organizers' own: dinovol (MIT, with DINOv2 and DINOv3 parts under their licenses) and | |
| # villa's vesuvius package (MIT), each downloaded at a pinned commit and put on sys.path. Nothing of theirs is | |
| # copied into this Space. | |
| MODELS = { | |
| "dino": dict(repo="scrollprize/dinovol_v2_ps8_with_paris4_352500", | |
| revision="6a8cccbafef191a966da815e22ff5c6eae075aae", | |
| file="dinovol_v2_ps8_paris4_step352500_teacher_backbone.pt", bytes=863606099, | |
| sha256="e041ca870dd2570f8a44d1dd26db1197b3f74121f62023bc774fbc9d40e51a59"), | |
| "ink": dict(repo="scrollprize/ink_3d_dino_guided", revision="73a79525466037432191284dfa237baf830c49ec", | |
| file="ckpt_78k_fullsup.pth", bytes=1703138075, | |
| sha256="5a148c2c1bb730bfa683f2b3e3cdfc2000424003605e42300b527ff90118b303"), | |
| "ink_reference": dict(repo="scrollprize/ink_3d_dino_guided", | |
| revision="73a79525466037432191284dfa237baf830c49ec", | |
| file="avg_ref_embedding.npy", bytes=3584, | |
| sha256="61bdf93bc5e3fd956eebdbed52618985d27264b8a9e5cb043087d0f234507a81"), | |
| "surface": dict(repo="scrollprize/surface_recto_3dunet", revision="8ce8a4627e5104dda0af2b05aaa49820c0888774", | |
| file="checkpoint_inference_ready.pth", bytes=1138388181, | |
| sha256="dd36b3a89b2f4e21a6cafa14fea459f775f846a6ed036f75fb8e6b65aee3137b"), | |
| } | |
| CODE = { | |
| "dinovol": dict(repo="https://github.com/ScrollPrize/dinovol.git", | |
| tarball="https://codeload.github.com/ScrollPrize/dinovol/tar.gz/{commit}", | |
| commit="a72fda2b1e02389a8711b3dc6a26215237f35eca", keep="dinovol_2/", | |
| check="dinovol_2/model/model.py"), | |
| "villa": dict(repo="https://github.com/ScrollPrize/villa.git", | |
| tarball="https://codeload.github.com/ScrollPrize/villa/tar.gz/{commit}", | |
| commit="b29f310957c7b29e7d332f9876e0e487819cfaa3", keep="vesuvius/src/", | |
| check="vesuvius/src/vesuvius/models/build/build_network_from_config.py"), | |
| } | |
| CACHE = Path(os.environ.get("LENS_CACHE") or Path(tempfile.gettempdir()) / "scroll-lens") | |
| def log(message): | |
| print(f"[lens {time.strftime('%H:%M:%S')}] {message}", flush=True) | |
| # ---------------------------------------------------------------- cubes: where and how to read them | |
| def volume_id(vid): | |
| """The listed scan id for `vid` (case does not matter), or a refusal that lists them.""" | |
| if isinstance(vid, str): | |
| for k in VOLUMES: | |
| if k.lower() == vid.strip().lower(): | |
| return k | |
| raise LensError(f"{vid!r} is not one of the scans this Space may read; choose from {', '.join(VOLUMES)}") | |
| def volume(vid): | |
| """The listed volume `vid`, or a refusal. Nothing else is ever read.""" | |
| return VOLUMES[volume_id(vid)] | |
| def volume_url(vid, level=0): | |
| vid = volume_id(vid) | |
| return f"{BUCKET}/{vid}/volumes/{VOLUMES[vid]['scan']}/{int(level)}" | |
| def cube_origin(vid, center_zyx, size): | |
| """The corner of the cube of side `size` around `center_zyx` (level-0 voxels, z y x), kept inside the scan. Where | |
| it can, the corner sits on the scan's 128-voxel chunk grid (so a 256 cube is exactly 8 stored chunks) and the | |
| point lies at most 64 voxels from the cube's center; near the scan's far edge the cube ends at the edge instead, | |
| off the grid. The point always lies inside the cube.""" | |
| size = check_size(size) | |
| shape = volume(vid)["shape"] | |
| try: | |
| center = [as_int(c, "each of z, y and x") for c in center_zyx] | |
| except TypeError: | |
| raise LensError("give the point as three whole numbers, z y x, in level-0 voxels") from None | |
| if len(center) != 3: | |
| raise LensError("give the point as three whole numbers, z y x, in level-0 voxels") | |
| out = [] | |
| for c, n in zip(center, shape): | |
| if not 0 <= c < n: | |
| raise LensError(f"point {center} is outside the scan, whose shape is {list(shape)}") | |
| o = max(0, (c - size // 2 + CHUNK // 2) // CHUNK * CHUNK) # nearest grid line, halves rounded up | |
| if o + size > n: | |
| o = max(0, n - size) | |
| out.append(o) | |
| return tuple(out) | |
| def as_int(value, name): | |
| """A whole number from an int, an integral float or a string of digits; anything else is a refusal naming it.""" | |
| if isinstance(value, bool): | |
| raise LensError(f"{name} must be a whole number, not {value!r}") | |
| try: | |
| f = float(value) | |
| except (TypeError, ValueError): | |
| raise LensError(f"{name} must be a whole number, not {value!r}") from None | |
| if not math.isfinite(f) or f != int(f): | |
| raise LensError(f"{name} must be a whole number, not {value!r}") | |
| return int(f) | |
| def check_size(size): | |
| size = as_int(size, "the cube side") | |
| if size not in SIZES: | |
| raise LensError(f"cube side must be one of {SIZES} voxels, not {size}") | |
| return size | |
| def chunk_keys(origin, size): | |
| """The stored chunks (z, y, x indices) that a cube at `origin` of side `size` covers.""" | |
| ranges = [range(o // CHUNK, (o + size - 1) // CHUNK + 1) for o in origin] | |
| return [(a, b, c) for a in ranges[0] for b in ranges[1] for c in ranges[2]] | |
| def _get(url, timeout=60, tries=3): | |
| """One object from the open-data bucket, anonymously. None for a missing key (HTTP 404): the bucket stores no | |
| chunk where the scan holds nothing but the masked-out outside. Throttling and server errors are retried; any | |
| other refusal (403 included) is an error, never read as an empty chunk.""" | |
| last = None | |
| for attempt in range(tries): | |
| try: | |
| req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) | |
| with urllib.request.urlopen(req, timeout=timeout) as resp: | |
| return resp.read() | |
| except urllib.error.HTTPError as exc: | |
| if exc.code == 404: | |
| return None | |
| if exc.code not in (429, 500, 502, 503, 504): | |
| raise LensError(f"the open-data bucket refused {url.rsplit('/volumes/', 1)[-1]} (HTTP {exc.code})") \ | |
| from None | |
| last = exc | |
| except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc: | |
| last = exc | |
| time.sleep(1.5 * (attempt + 1)) | |
| raise LensError(f"could not read the open-data bucket ({last}); try again in a minute") | |
| def _list_keys(prefix, fetch=None): | |
| """The object keys under `prefix` in the open-data bucket (anonymous S3 ListObjectsV2, which this bucket | |
| allows), following continuation tokens.""" | |
| import re | |
| import urllib.parse | |
| keys, token = [], None | |
| while True: | |
| q = {"list-type": "2", "prefix": prefix, "max-keys": "1000"} | |
| if token: | |
| q["continuation-token"] = token | |
| raw = (fetch or _get)(f"{BUCKET}/?{urllib.parse.urlencode(q)}") | |
| text = (raw or b"").decode("utf-8", "replace") | |
| keys += re.findall(r"<Key>([^<]+)</Key>", text) | |
| m = re.search(r"<NextContinuationToken>([^<]+)</NextContinuationToken>", text) | |
| if "<IsTruncated>true</IsTruncated>" not in text or not m: | |
| return keys | |
| token = m.group(1) | |
| def read_cube(vid, origin, size, fetch=_get, workers=8): | |
| """The uint8 cube of side `size` at `origin` (z y x, level 0), anywhere inside the scan: an origin off the chunk | |
| grid (a CPU-fallback receipt's) reads the chunks it overlaps and crops. Chunks the bucket does not hold read as 0.""" | |
| import numpy as np | |
| size = check_size(size) | |
| shape = volume(vid)["shape"] | |
| origin = tuple(int(o) for o in origin) | |
| if any(o < 0 or o + size > n for o, n in zip(origin, shape)): | |
| raise LensError(f"cube at {list(origin)} is not inside the scan, whose shape is {list(shape)}") | |
| base = volume_url(vid) | |
| keys = chunk_keys(origin, size) | |
| out = np.zeros((size, size, size), np.uint8) | |
| def one(key): | |
| raw = fetch(f"{base}/{key[0]}/{key[1]}/{key[2]}") | |
| if raw is None: | |
| return key, None | |
| if len(raw) != CHUNK ** 3: | |
| raise LensError(f"chunk {key} has {len(raw)} bytes, not {CHUNK ** 3}: not an uncompressed uint8 chunk") | |
| return key, np.frombuffer(raw, np.uint8).reshape(CHUNK, CHUNK, CHUNK) | |
| with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool: | |
| for key, chunk in pool.map(one, keys): | |
| if chunk is None: | |
| continue | |
| lo = [max(k * CHUNK, o) for k, o in zip(key, origin)] # overlap of chunk and cube, scan voxels | |
| hi = [min((k + 1) * CHUNK, o + size) for k, o in zip(key, origin)] | |
| dst = tuple(slice(a - o, b - o) for a, b, o in zip(lo, hi, origin)) | |
| src = tuple(slice(a - k * CHUNK, b - k * CHUNK) for a, b, k in zip(lo, hi, key)) | |
| out[dst] = chunk[src] | |
| return out | |
| def foreground(cube): | |
| """Fraction of voxels that are papyrus (the listed scans are masked to 0 outside the scroll).""" | |
| return float((cube > 0).mean()) | |
| def check_cube(cube): | |
| frac = foreground(cube) | |
| if frac < MIN_FOREGROUND: | |
| raise LensError(f"only {frac:.1%} of this cube is papyrus; the scan is masked to 0 outside the scroll. " | |
| "Pick a point on the papyrus: press Surprise me on the page, or call the random_point " | |
| "endpoint.") | |
| return frac | |
| def random_point(vid, rng, fetch=_get, tries=24, list_fetch=None): | |
| """A random point on papyrus: read random level-3 chunks (1/8 resolution) and pick a bright voxel in one. | |
| `list_fetch` reads bucket listings for the fallback (default: `fetch`).""" | |
| import numpy as np | |
| shape = volume(vid)["shape"] | |
| lv = 3 | |
| f = 2 ** lv | |
| small = [math.ceil(n / f) for n in shape] | |
| nchunks = [max(1, math.ceil(n / CHUNK)) for n in small] | |
| base = volume_url(vid, lv) | |
| for _ in range(tries): | |
| # stay off the top and bottom tenth of the scroll, where the scan often holds no papyrus | |
| cz = int(rng.integers(int(nchunks[0] * 0.1), max(int(nchunks[0] * 0.9), int(nchunks[0] * 0.1) + 1))) | |
| cy, cx = (int(rng.integers(0, n)) for n in nchunks[1:]) | |
| raw = fetch(f"{base}/{cz}/{cy}/{cx}") | |
| if raw is None or len(raw) != CHUNK ** 3: | |
| continue | |
| a = np.frombuffer(raw, np.uint8).reshape(CHUNK, CHUNK, CHUNK) | |
| # papyrus is the brighter part of a masked scan; a 16-voxel margin keeps the cube away from the mask edge | |
| inner = a[16:-16, 16:-16, 16:-16] | |
| idx = np.argwhere(inner > max(1, np.percentile(a[a > 0], 60) if (a > 0).any() else 255)) | |
| if len(idx) < 50: | |
| continue | |
| for _ in range(16): | |
| p = idx[int(rng.integers(0, len(idx)))] + 16 | |
| # the 256 cube around the point (16 voxels each way at this level) must lie inside the scroll mask | |
| win = a[p[0] - 16:p[0] + 16, p[1] - 16:p[1] + 16, p[2] - 16:p[2] + 16] | |
| if (win > 0).mean() < 0.9: | |
| continue | |
| zyx = [int((c * CHUNK + p[i]) * f + f // 2) for i, c in enumerate((cz, cy, cx))] | |
| if all(0 <= v < n for v, n in zip(zyx, shape)): | |
| return tuple(zyx) | |
| # Some scans store few level-3 chunks (most of their chunk grid lies outside the scroll), so blind draws can all | |
| # land on missing keys. Then draw among the chunks the bucket holds, from a listing of one z slab at a time. The | |
| # blind draws come first so that points drawn before this fallback existed are drawn again unchanged. | |
| v = volume(vid) | |
| prefix = f"{volume_id(vid)}/volumes/{v['scan']}/{lv}/" | |
| for _ in range(tries): | |
| cz = int(rng.integers(int(nchunks[0] * 0.1), max(int(nchunks[0] * 0.9), int(nchunks[0] * 0.1) + 1))) | |
| stored = set() | |
| for key in _list_keys(f"{prefix}{cz}/", fetch=list_fetch or fetch): | |
| parts = key[len(prefix):].split("/") | |
| if len(parts) == 3 and all(x.isdigit() for x in parts): | |
| stored.add((int(parts[1]), int(parts[2]))) | |
| # chunks whose four in-plane neighbors are stored too lie inside the scroll, away from the mask edge | |
| inner = sorted(k for k in stored if all((k[0] + dy, k[1] + dx) in stored | |
| for dy, dx in ((1, 0), (-1, 0), (0, 1), (0, -1)))) | |
| pool = inner or sorted(stored) | |
| if not pool: | |
| continue | |
| cy, cx = pool[int(rng.integers(0, len(pool)))] | |
| raw = fetch(f"{base}/{cz}/{cy}/{cx}") | |
| if raw is None or len(raw) != CHUNK ** 3: | |
| continue | |
| a = np.frombuffer(raw, np.uint8).reshape(CHUNK, CHUNK, CHUNK) | |
| inner_a = a[16:-16, 16:-16, 16:-16] | |
| idx = np.argwhere(inner_a > max(1, np.percentile(a[a > 0], 60) if (a > 0).any() else 255)) | |
| if len(idx) < 50: | |
| continue | |
| for _ in range(16): | |
| p = idx[int(rng.integers(0, len(idx)))] + 16 | |
| win = a[p[0] - 16:p[0] + 16, p[1] - 16:p[1] + 16, p[2] - 16:p[2] + 16] | |
| if (win > 0).mean() < 0.9: | |
| continue | |
| zyx = [int((c * CHUNK + p[i]) * f + f // 2) for i, c in enumerate((cz, cy, cx))] | |
| if all(0 <= v < n for v, n in zip(zyx, shape)): | |
| return tuple(zyx) | |
| raise LensError("could not find papyrus in this scan after several tries; try again") | |
| # ---------------------------------------------------------------- downloads: code and weights | |
| def sha256_file(path, block=1 << 22): | |
| h = hashlib.sha256() | |
| with open(path, "rb") as fh: | |
| for part in iter(lambda: fh.read(block), b""): | |
| h.update(part) | |
| return h.hexdigest() | |
| def _fetch_code(name, dest): | |
| """One pinned repository's needed subtree: a sparse, blobless git fetch of the commit, else GitHub's tarball.""" | |
| spec = CODE[name] | |
| commit = spec["commit"] | |
| tmp = dest.with_name(dest.name + ".part") | |
| shutil.rmtree(tmp, ignore_errors=True) | |
| tmp.mkdir(parents=True) | |
| try: | |
| git = ["git", "-C", str(tmp)] | |
| for cmd in (["init", "-q"], ["remote", "add", "origin", spec["repo"]], | |
| ["sparse-checkout", "set", spec["keep"].rstrip("/")], | |
| ["fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", commit], | |
| ["checkout", "-q", "FETCH_HEAD"]): | |
| subprocess.run(git + cmd, capture_output=True, text=True, check=True, timeout=900) | |
| head = subprocess.run(git + ["rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip() | |
| if head != commit: | |
| raise RuntimeError(f"git fetched {head}, not {commit}") | |
| shutil.rmtree(tmp / ".git", ignore_errors=True) | |
| except (OSError, RuntimeError, subprocess.SubprocessError) as exc: | |
| log(f"git fetch of {name} failed ({getattr(exc, 'stderr', None) or exc}); trying the tarball") | |
| shutil.rmtree(tmp, ignore_errors=True) | |
| tmp.mkdir(parents=True) | |
| _tarball(spec, tmp) | |
| if not (tmp / spec["check"]).is_file(): | |
| raise RuntimeError(f"{name} at {commit} has no {spec['check']}") | |
| (tmp / "COMMIT").write_text(commit + "\n") | |
| shutil.rmtree(dest, ignore_errors=True) | |
| os.replace(tmp, dest) | |
| def _tarball(spec, tmp): | |
| """Stream GitHub's tarball of the pinned commit and keep only the needed subtree.""" | |
| root = tmp.resolve() | |
| commit = spec["commit"] | |
| kept = 0 | |
| req = urllib.request.Request(spec["tarball"].format(commit=commit), headers={"User-Agent": USER_AGENT}) | |
| with urllib.request.urlopen(req, timeout=900) as resp, tarfile.open(fileobj=resp, mode="r|gz") as tf: | |
| for m in tf: | |
| top, _, rel = m.name.partition("/") | |
| if commit[:7] not in top: | |
| raise RuntimeError(f"the tarball for {commit} holds {top!r}") | |
| if not rel.startswith(spec["keep"]): | |
| continue | |
| target = (root / rel).resolve() | |
| if root not in target.parents or m.issym() or m.islnk(): | |
| continue | |
| if m.isdir(): | |
| target.mkdir(parents=True, exist_ok=True) | |
| elif m.isfile(): | |
| target.parent.mkdir(parents=True, exist_ok=True) | |
| with tf.extractfile(m) as src, open(target, "wb") as out: | |
| shutil.copyfileobj(src, out) | |
| kept += 1 | |
| if not kept: | |
| raise RuntimeError(f"the tarball for {commit} held no {spec['keep']} files") | |
| def ensure_code(cache=CACHE): | |
| """Put dinovol and villa's vesuvius on sys.path at their pinned commits. LENS_DINOVOL and LENS_VILLA_SRC name | |
| local checkouts instead (the dinovol repository root, and villa's vesuvius/src).""" | |
| info = {} | |
| for name, env, sub in (("dinovol", "LENS_DINOVOL", ""), ("villa", "LENS_VILLA_SRC", "vesuvius/src")): | |
| if os.environ.get(env): | |
| path = Path(os.environ[env]).expanduser().resolve() | |
| info[name] = {"source": f"local checkout ({env})", "pinned": CODE[name]["commit"]} # no local path | |
| else: | |
| dest = Path(cache) / f"{name}-{CODE[name]['commit'][:12]}" | |
| if not (dest / "COMMIT").is_file(): | |
| log(f"fetching {name} {CODE[name]['commit'][:12]}") | |
| _fetch_code(name, dest) | |
| path = dest / sub if sub else dest | |
| info[name] = {"source": "pinned download", "commit": (dest / "COMMIT").read_text().strip()} | |
| if str(path) not in sys.path: | |
| sys.path.insert(0, str(path)) | |
| return info | |
| def ensure_weights(cache=CACHE, names=tuple(MODELS)): | |
| """Each named file from the Hub at its pinned revision, refused unless its SHA-256 matches. LENS_WEIGHTS names | |
| a folder holding local copies laid out as REPO_NAME/FILE (their SHA-256 is still checked).""" | |
| paths = {} | |
| local = os.environ.get("LENS_WEIGHTS") | |
| for name in names: | |
| spec = MODELS[name] | |
| if local: | |
| path = Path(local).expanduser() / spec["repo"].split("/")[1] / spec["file"] | |
| else: | |
| from huggingface_hub import hf_hub_download | |
| path = Path(hf_hub_download(spec["repo"], spec["file"], revision=spec["revision"], | |
| cache_dir=str(Path(cache) / "hub"))) | |
| digest = sha256_file(path) | |
| if digest != spec["sha256"]: | |
| raise RuntimeError(f"{spec['repo']}/{spec['file']} has SHA-256 {digest}, not {spec['sha256']}") | |
| paths[name] = path | |
| return paths | |
| # ---------------------------------------------------------------- the models | |
| class Engine: | |
| """The three models, on the CPU until a call moves them. Built once per process.""" | |
| def __init__(self, cache=CACHE): | |
| import numpy as np | |
| t0 = time.time() | |
| self.code = ensure_code(cache) | |
| self.paths = ensure_weights(cache) | |
| self.reference = np.load(self.paths["ink_reference"], allow_pickle=False).astype(np.float32) | |
| self.reference /= np.linalg.norm(self.reference) | |
| self.dino = self._load_dino(self.paths["dino"]) | |
| self.ink, self.ink_norm = self._load_ink(self.paths["ink"]) | |
| self.surface = self._load_surface(self.paths["surface"]) | |
| self.startup_s = round(time.time() - t0, 1) | |
| # -- loading | |
| def _load_dino(path): | |
| import torch | |
| from dinovol_2.model.model import DinoVitStudentTeacher, _upgrade_weight_norm_state_dict_keys | |
| ck = torch.load(path, map_location="cpu", weights_only=False) | |
| state = {k.replace("backbone.", "", 1): v for k, v in ck["teacher"].items() if k.startswith("backbone.")} | |
| net = DinoVitStudentTeacher._build_backbone(ck["config"]["model"]) | |
| net.load_state_dict(_upgrade_weight_norm_state_dict_keys(state), strict=True) | |
| return net.eval() | |
| def _load_ink(path): | |
| import torch | |
| from vesuvius.ink_detection.inference.inference_runtime import TargetModel | |
| from vesuvius.ink_detection.models.checkpoint import config_from_checkpoint, load_model_state | |
| from vesuvius.ink_detection.models.model import make_model | |
| payload = torch.load(path, map_location="cpu", weights_only=False) | |
| cfg = config_from_checkpoint(payload) | |
| net = make_model(cfg) | |
| load_model_state(net, payload["ema_model"]) # the card recommends the EMA weights for inference | |
| return TargetModel(net.eval()).eval(), cfg.data.normalization | |
| def _load_surface(path): | |
| import torch | |
| from types import SimpleNamespace | |
| from vesuvius.models.build.build_network_from_config import NetworkFromConfig | |
| ck = torch.load(path, map_location="cpu", weights_only=False) | |
| mc = ck["model_config"] | |
| mgr = SimpleNamespace(targets=mc["targets"], train_patch_size=tuple(mc["train_patch_size"]), | |
| train_batch_size=mc["train_batch_size"], in_channels=1, autoconfigure=False, | |
| model_config=mc, op_dims=3, spacing=(1.0, 1.0, 1.0), model_name=mc["model_name"]) | |
| net = NetworkFromConfig(mgr) | |
| net.load_state_dict(ck["model"], strict=True) | |
| return net.eval() | |
| def describe(self): | |
| return {"models": {k: {kk: v[kk] for kk in ("repo", "revision", "file", "sha256")} | |
| for k, v in MODELS.items()}, | |
| "code": self.code, "startup_s": self.startup_s} | |
| # -- running | |
| def run(self, cube, layers=LAYERS, device="cpu", tta=False): | |
| """The chosen layers for one cube. Returns numpy arrays: `grid` (Z/8, Y/8, X/8, 864) float16 unit vectors, | |
| `ink` and `surface` (Z, Y, X) uint8 probabilities times 255, and timings.""" | |
| import numpy as np | |
| import torch | |
| layers = check_layers(layers) | |
| dev = torch.device(device) | |
| out = {"device": str(dev), "device_name": _device_name(dev), "seconds": {}, "tta": bool(tta)} | |
| for name in layers: | |
| t0 = time.time() | |
| if name == "dino": | |
| out["grid"] = self._dino_grid(cube, dev) | |
| elif name == "ink": | |
| out["ink"] = self._ink(cube, dev, tta) | |
| else: | |
| out["surface"] = self._surface(cube, dev, tta) | |
| if dev.type == "cuda": | |
| torch.cuda.synchronize() | |
| out["seconds"][name] = round(time.time() - t0, 3) | |
| return out | |
| def _autocast(self, dev): | |
| import contextlib | |
| import torch | |
| return torch.autocast("cuda", dtype=torch.bfloat16) if dev.type == "cuda" else contextlib.nullcontext() | |
| def _dino_grid(self, cube, dev): | |
| """dinovol's dense patch embeddings, as its own napari inspector computes them: robust normalization of | |
| the whole cube, 128^3 windows overlapping by one patch, edge-tapered averaging, unit length.""" | |
| import numpy as np | |
| import torch | |
| import torch.nn.functional as F | |
| from dinovol_2.dataset.normalization import get_normalization | |
| net = self.dino.to(dev) | |
| vol = get_normalization("robust", intensityproperties=None).run(cube.astype(np.float32)) | |
| n = cube.shape[0] // PATCH | |
| win = min(n, 128 // PATCH) | |
| starts = _window_starts(n, win, 1) | |
| w1 = _taper(win, 1 if win < n else 0) | |
| weight = w1[:, None, None] * w1[None, :, None] * w1[None, None, :] | |
| acc = np.zeros((n, n, n, EMBED_DIM), np.float32) | |
| wsum = np.zeros((n, n, n), np.float32) | |
| with torch.inference_mode(), self._autocast(dev): | |
| for z in starts: | |
| for y in starts: | |
| for x in starts: | |
| tile = vol[z * PATCH:(z + win) * PATCH, y * PATCH:(y + win) * PATCH, | |
| x * PATCH:(x + win) * PATCH] | |
| t = torch.from_numpy(np.ascontiguousarray(tile))[None, None].to(dev) | |
| tok = net.forward_features(t, masks=None, view_kind="global")["x_norm_patchtokens"][0] | |
| tok = F.normalize(tok.float().reshape(win, win, win, -1), dim=-1).cpu().numpy() | |
| acc[z:z + win, y:y + win, x:x + win] += tok * weight[..., None] | |
| wsum[z:z + win, y:y + win, x:x + win] += weight | |
| grid = acc / np.maximum(wsum[..., None], np.finfo(np.float32).eps) | |
| grid /= np.maximum(np.linalg.norm(grid, axis=-1, keepdims=True), 1e-12) | |
| return grid.astype(np.float16) | |
| def _ink(self, cube, dev, tta): | |
| import numpy as np | |
| import torch | |
| from vesuvius.ink_detection.data.normalization import normalize_image | |
| net = self.ink.to(dev) | |
| x = torch.from_numpy(np.ascontiguousarray(normalize_image(cube.astype(np.float32), self.ink_norm))) | |
| x = x[None, None].to(dev) | |
| with torch.inference_mode(), self._autocast(dev): | |
| prob = _mirrored(lambda t: net(t).float().sigmoid(), x, tta) | |
| return _to_u8(prob[0, 0]) | |
| def _surface(self, cube, dev, tta): | |
| import numpy as np | |
| import torch | |
| net = self.surface.to(dev) | |
| c = cube.astype(np.float32) | |
| x = torch.from_numpy((c - c.mean()) / max(float(c.std()), 1e-8))[None, None].to(dev) # z-score, per cube | |
| with torch.inference_mode(), self._autocast(dev): | |
| prob = _mirrored(lambda t: torch.softmax(net(t)["surface"].float(), 1)[:, 1:2], x, tta) | |
| return _to_u8(prob[0, 0]) | |
| def _mirrored(fn, x, tta): | |
| """fn(x), or with tta the mean of fn over the eight mirrorings of x, each flipped back (villa's mirror TTA).""" | |
| if not tta: | |
| return fn(x) | |
| total = None | |
| for axes in ((), (2,), (3,), (4,), (2, 3), (2, 4), (3, 4), (2, 3, 4)): | |
| y = fn(x.flip(axes) if axes else x) | |
| y = y.flip(axes) if axes else y | |
| total = y if total is None else total + y | |
| return total / 8.0 | |
| def _to_u8(prob): | |
| import numpy as np | |
| return np.clip(np.rint(prob.float().cpu().numpy() * 255.0), 0, 255).astype(np.uint8) | |
| def _device_name(dev): | |
| if dev.type == "cuda": | |
| import torch | |
| return torch.cuda.get_device_name(dev) | |
| import platform | |
| return platform.processor() or platform.machine() | |
| def _window_starts(n, win, overlap): | |
| if win >= n: | |
| return [0] | |
| step = win - overlap | |
| starts = list(range(0, n - win + 1, step)) | |
| if starts[-1] != n - win: | |
| starts.append(n - win) | |
| return starts | |
| def _taper(length, overlap): | |
| import numpy as np | |
| w = np.ones(length, np.float32) | |
| if overlap <= 0 or length <= 1: | |
| return w | |
| r = np.arange(length, dtype=np.float32) | |
| return np.minimum(np.minimum(r + 1.0, length - r) / float(overlap + 1), 1.0).astype(np.float32) | |
| def check_layers(layers): | |
| layers = tuple(dict.fromkeys(layers or ())) | |
| bad = [x for x in layers if x not in LAYERS] | |
| if bad: | |
| raise LensError(f"unknown layer {bad[0]!r}; choose from {', '.join(LAYERS)}") | |
| if not layers: | |
| raise LensError("choose at least one model to run") | |
| return layers | |
| def gpu_seconds(size, layers, tta=False): | |
| """The duration one cube asks ZeroGPU for. ZeroGPU reserves 1.5 times this from the visitor's quota before it | |
| starts the call (it refused a 256 cube asking 26 s with "39s requested vs. 38s left") and then charges the time | |
| used. Measured on the Space (99 gallery cubes, 2026-10-17): 256 cube, all three models, 3.2 to 8.0 s of model time | |
| and about 4.5 s of quota; 128 cube about 0.3 s. Six seconds cover moving the models to the GPU.""" | |
| size, layers = check_size(size), check_layers(layers) | |
| scale = (size / 256) ** 3 | |
| per = {"dino": 4.0, "ink": 2.0 * (8 if tta else 1), "surface": 2.0 * (8 if tta else 1)} | |
| return int(math.ceil(6 + scale * sum(per[x] for x in layers))) | |
| # ---------------------------------------------------------------- embeddings: clicks, similarity, colors | |
| def unit(v, axis=-1): | |
| import numpy as np | |
| v = np.asarray(v, np.float32) | |
| return v / np.maximum(np.linalg.norm(v, axis=axis, keepdims=True), 1e-12) | |
| def grid_of(result): | |
| """The (Z/8, Y/8, X/8, 864) float32 embeddings of a result. A full result's are unit vectors. A compact (gallery) | |
| result's are rebuilt as the projection of each unit embedding onto the kept REDUCED_DIM directions, left as they | |
| are: their length is the share of the embedding kept (median 0.98 over the gallery, at least 0.68), and a dot | |
| product with a unit concept estimates the original cosine without the bias that rescaling to unit length adds.""" | |
| import numpy as np | |
| if "grid" in result: | |
| return np.asarray(result["grid"], np.float32) | |
| if "grid_reduced" in result: | |
| return np.asarray(result["grid_reduced"], np.float32) @ np.asarray(result["grid_basis"], np.float32).T | |
| raise LensError("this cube has no DINO embeddings; run it with the 3D DINO layer") | |
| def reduce_grid(grid, k=REDUCED_DIM): | |
| """Keep the top `k` directions of a cube's embeddings (uncentered SVD, so the common direction stays): 1/7 of the | |
| size. Returns (coefficients float16, basis (864, k) float32). grid_of() rebuilds unit vectors from them.""" | |
| import numpy as np | |
| g = np.asarray(grid, np.float32).reshape(-1, EMBED_DIM) | |
| _, _, vt = np.linalg.svd(g[:: max(1, len(g) // 20000)], full_matrices=False) | |
| basis = vt[:k].T.astype(np.float32) | |
| coef = (g @ basis).reshape(*np.shape(grid)[:3], k) | |
| return coef.astype(np.float16), basis | |
| def token_of(voxel_zyx, size): | |
| """The DINO patch (token) index holding a voxel of the cube.""" | |
| n = size // PATCH | |
| return tuple(int(min(max(int(v) // PATCH, 0), n - 1)) for v in voxel_zyx) | |
| def parse_points(text, limit=64): | |
| """Points written as "z,y,x; z,y,x" (spaces, semicolons or new lines between them) as a list of (z, y, x).""" | |
| text = str(text or "").replace("\n", ";").strip() | |
| out = [] | |
| for part in [p for p in text.split(";") if p.strip()]: | |
| nums = [s for s in part.replace(" ", ",").split(",") if s] | |
| try: | |
| pt = tuple(int(float(s)) for s in nums) | |
| except ValueError: | |
| raise LensError(f"not a point: {part.strip()!r}; write z,y,x in voxels") from None | |
| if len(pt) != 3 or min(pt) < 0: | |
| raise LensError(f"not a point: {part.strip()!r}; write z,y,x in voxels, each 0 or more") | |
| out.append(pt) | |
| if len(out) > limit: | |
| raise LensError(f"at most {limit} points") | |
| return out | |
| def voxel_in_cube(point_zyx, origin, size): | |
| """A scan point as a voxel of the cube at `origin`; refuses a point outside the cube.""" | |
| v = [int(p) - int(o) for p, o in zip(point_zyx, origin)] | |
| if min(v) < 0 or max(v) >= size: | |
| raise LensError(f"point {tuple(int(p) for p in point_zyx)} is outside the cube at {tuple(origin)} of side " | |
| f"{size}; points must lie inside the cube being searched") | |
| return v | |
| def top_matches(sim_tokens, origin, k=20): | |
| """The `k` best patches of a similarity map, best first: each patch's center in scan voxels and its score.""" | |
| import numpy as np | |
| k = int(min(max(int(k), 1), 200)) | |
| flat = np.asarray(sim_tokens, np.float32).ravel() | |
| idx = np.argsort(-flat, kind="stable")[:k] | |
| out = [] | |
| for i in idx: | |
| t = np.unravel_index(int(i), sim_tokens.shape) | |
| out.append({"center_zyx": [int(o + PATCH * a + PATCH // 2) for o, a in zip(origin, t)], | |
| "score": round(float(flat[i]), 4)}) | |
| return out | |
| def concept(grid, tokens): | |
| """The organizers' recipe for a reference embedding: the unit-length mean of the clicked patches' embeddings.""" | |
| import numpy as np | |
| if not tokens: | |
| return None | |
| vecs = np.stack([grid[t] for t in tokens]).astype(np.float32) | |
| return unit(vecs.mean(0)) | |
| def similarity(grid, positive, negative=None): | |
| """Per patch: cosine similarity to the positive concept, minus that to the negative one when there is one.""" | |
| import numpy as np | |
| s = np.tensordot(grid, positive, axes=([-1], [0])).astype(np.float32) | |
| if negative is not None: | |
| s = s - np.tensordot(grid, negative, axes=([-1], [0])).astype(np.float32) | |
| return s | |
| def pca_colors(grid): | |
| """The three main directions of a cube's embeddings as red, green and blue, stretched to the 1st and 99th | |
| percentiles: patches the model sees as alike get alike colors. (Z, Y, X, 3) uint8 at patch resolution.""" | |
| import numpy as np | |
| g = np.asarray(grid, np.float32).reshape(-1, grid.shape[-1]) | |
| g = g - g.mean(0) | |
| # the three main directions: the top eigenvectors of the covariance, the same as the SVD's and far cheaper | |
| _, vecs = np.linalg.eigh((g.T @ g).astype(np.float64)) | |
| p = g @ vecs[:, ::-1][:, :3].astype(np.float32) | |
| # fix each component's sign so the colors do not flip between runs of the same cube | |
| p *= np.sign(np.sum(p ** 3, axis=0, keepdims=True) + 1e-12) | |
| lo, hi = np.percentile(p, 1, axis=0), np.percentile(p, 99, axis=0) | |
| p = np.clip((p - lo) / np.maximum(hi - lo, 1e-6), 0, 1) | |
| return (p * 255).astype(np.uint8).reshape(*grid.shape[:3], 3) | |
| def upsample(tokens, size): | |
| """Patch-resolution values (Z/8, Y/8, X/8[, C]) to voxels by repetition.""" | |
| import numpy as np | |
| f = size // tokens.shape[0] | |
| a = np.repeat(tokens, f, axis=0) | |
| a = np.repeat(a, f, axis=1) | |
| return np.repeat(a, f, axis=2) | |
| def auc(scores, labels): | |
| """Probability that a positive scores above a negative (ties count half). None when a class is empty or a score | |
| is not a number.""" | |
| import numpy as np | |
| s = np.asarray(scores, np.float64).ravel() | |
| y = np.asarray(labels, bool).ravel() | |
| n1, n0 = int(y.sum()), int((~y).sum()) | |
| if not n1 or not n0 or not np.isfinite(s).all(): | |
| return None | |
| order = np.argsort(s, kind="mergesort") | |
| ss = s[order] | |
| first = np.concatenate(([True], ss[1:] != ss[:-1])) # start of each run of equal scores | |
| starts = np.flatnonzero(first) | |
| ends = np.append(starts[1:], len(ss)) - 1 | |
| ranks = np.empty(len(s), np.float64) | |
| ranks[order] = ((starts + ends) / 2.0 + 1.0)[np.cumsum(first) - 1] # average rank within each run | |
| return float((ranks[y].sum() - n1 * (n1 + 1) / 2.0) / (n1 * n0)) | |
| CONTROL_SHIFTS = 16 # the agreement control: the ink mask rolled by this many fixed pseudo-random shifts | |
| def agreement(sim_tokens, ink_u8, threshold=0.5): | |
| """How well a patch similarity map picks out the patches the 3D ink model marks as ink (mean probability over | |
| the patch above `threshold`), as an AUC, beside controls that must fail: the same ink mask rolled away from its | |
| place, which breaks the link between place and score. `auc_control_shifted` rolls it by half the cube on every | |
| axis; because papyrus is layered, one roll can line the layers up again, so `auc_control_mean`, `_p05` and `_p95` | |
| summarize CONTROL_SHIFTS rolls between a quarter and three quarters of the cube (fixed seed). The AUC means | |
| something only above `auc_control_p95`. Agreement with a model, not ground truth.""" | |
| import numpy as np | |
| n = sim_tokens.shape[0] | |
| f = ink_u8.shape[0] // n | |
| ink_tok = ink_u8.reshape(n, f, n, f, n, f).mean(axis=(1, 3, 5)) / 255.0 > threshold | |
| shifted = np.roll(ink_tok, (n // 2, n // 2, n // 2), axis=(0, 1, 2)) | |
| rng = np.random.default_rng(20261017) | |
| controls = [auc(sim_tokens, np.roll(ink_tok, tuple(int(v) for v in rng.integers(n // 4, 3 * n // 4 + 1, 3)), | |
| axis=(0, 1, 2))) for _ in range(CONTROL_SHIFTS)] | |
| controls = [c for c in controls if c is not None] | |
| out = {"auc": auc(sim_tokens, ink_tok), "auc_control_shifted": auc(sim_tokens, shifted), | |
| "ink_patch_fraction": float(ink_tok.mean())} | |
| if controls: | |
| out.update(auc_control_mean=float(np.mean(controls)), auc_control_p05=float(np.percentile(controls, 5)), | |
| auc_control_p95=float(np.percentile(controls, 95))) | |
| return out | |
| def load_concept(path): | |
| """A concept file a visitor uploads: .npy, float, shape (864,) or (K, 864) with K at least 1, finite, at most | |
| 4 MB, no pickles. The header is read before the data, so a file that claims a huge shape is refused without | |
| allocating it. Returns the unit-length mean, refused when the rows cancel out.""" | |
| import numpy as np | |
| p = Path(path) | |
| try: | |
| nbytes = p.stat().st_size | |
| except OSError as exc: | |
| raise LensError(f"cannot read the concept file: {exc}") from None | |
| if nbytes > MAX_CONCEPT_BYTES: | |
| raise LensError(f"a concept file is at most {MAX_CONCEPT_BYTES >> 20} MB") | |
| try: | |
| with open(p, "rb") as fh: | |
| version = np.lib.format.read_magic(fh) | |
| if version == (1, 0): | |
| shape, fortran, dtype = np.lib.format.read_array_header_1_0(fh) | |
| else: | |
| shape, fortran, dtype = np.lib.format.read_array_header_2_0(fh) | |
| data_at = fh.tell() | |
| except (ValueError, OSError, EOFError) as exc: | |
| raise LensError(f"not a plain .npy array: {exc}") from None | |
| if dtype.kind != "f" or len(shape) not in (1, 2) or shape[-1] != EMBED_DIM or 0 in shape: | |
| raise LensError(f"a concept is a float array of shape ({EMBED_DIM},) or (K, {EMBED_DIM}), not {dtype} " | |
| f"{shape}") | |
| if data_at + math.prod(shape) * dtype.itemsize != nbytes: | |
| raise LensError("the concept file's size does not match its header") | |
| try: | |
| a = np.load(p, allow_pickle=False) | |
| except (ValueError, OSError, EOFError) as exc: | |
| raise LensError(f"not a plain .npy array: {exc}") from None | |
| a = np.asarray(a, np.float64).reshape(-1, EMBED_DIM) | |
| norms = np.linalg.norm(a, axis=1) | |
| if not np.isfinite(a).all() or not np.isfinite(norms).all() or not norms.all(): | |
| raise LensError("the concept holds non-finite or zero vectors") | |
| mean = (a / norms[:, None]).mean(0) | |
| if np.linalg.norm(mean) < 1e-3: | |
| raise LensError("the concept's rows cancel out (their mean is close to zero)") | |
| return unit(mean.astype(np.float32)) | |
| # ---------------------------------------------------------------- saving and the gallery | |
| def layers_in(result): | |
| """The models whose output a result holds: DINO is stored as `grid` (or `grid_reduced` when compact).""" | |
| return [k for k in LAYERS if (k == "dino" and ("grid" in result or "grid_reduced" in result)) | |
| or (k != "dino" and k in result)] | |
| def receipt(vid, origin, size, center, result, engine_info=None, extra=None): | |
| vid = volume_id(vid) | |
| v = VOLUMES[vid] | |
| r = {"tool": "Scroll Lens", "version": VERSION, "volume": vid, "scroll": v["scroll"], | |
| "zarr": f"{BUCKET}/{vid}/volumes/{v['scan']}", "level": 0, "voxel_um": v["voxel_um"], | |
| "point_zyx": [int(c) for c in center], "cube_origin_zyx": [int(o) for o in origin], "cube_size": int(size), | |
| "layers": layers_in(result), | |
| "device": result.get("device"), "device_name": result.get("device_name"), "tta": result.get("tta", False), | |
| "seconds": result.get("seconds", {}), "models_trained_on_this_scroll": v["role"], | |
| "note": "Model output, not a reading. A cube is smaller than one letter."} | |
| if engine_info: | |
| r["engine"] = engine_info | |
| if extra: | |
| r.update(extra) | |
| return r | |
| def quantize(prob_u8, levels=16): | |
| """uint8 probabilities rounded to `levels` evenly spaced values (0, 17, ..., 255 for 16): a compact gallery cube | |
| is about a quarter of the size and no probability moves by more than 8/255.""" | |
| import numpy as np | |
| step = 255 // (levels - 1) | |
| return (np.rint(np.asarray(prob_u8, np.float32) / step) * step).clip(0, 255).astype(np.uint8) | |
| def save_npz(path, cube_meta, result, compact=False): | |
| """A cube's model outputs (not the scan, which anyone can read again from the bucket): embeddings as float16 | |
| unit vectors, ink and surface as uint8, and the receipt as JSON. `compact` (the gallery) keeps REDUCED_DIM | |
| components of the embeddings (see reduce_grid) and 16 levels of each probability (see quantize).""" | |
| import numpy as np | |
| meta = dict(cube_meta, compact=bool(compact)) | |
| arrays = {"receipt": np.frombuffer(json.dumps(meta).encode(), np.uint8)} | |
| if "grid" in result: | |
| if compact: | |
| arrays["grid_reduced"], arrays["grid_basis"] = reduce_grid(result["grid"]) | |
| else: | |
| arrays["grid"] = np.asarray(result["grid"], np.float16) | |
| elif "grid_reduced" in result: | |
| arrays["grid_reduced"], arrays["grid_basis"] = result["grid_reduced"], result["grid_basis"] | |
| for k in ("ink", "surface"): | |
| if k in result: | |
| arrays[k] = quantize(result[k]) if compact else np.asarray(result[k], np.uint8) | |
| np.savez_compressed(path, **arrays) | |
| return Path(path) | |
| def load_npz(path): | |
| """A cube saved by save_npz, checked: the shapes agree with the receipt and nothing holds Python objects.""" | |
| import numpy as np | |
| with np.load(path, allow_pickle=False) as z: | |
| out = {k: z[k] for k in z.files} | |
| meta = json.loads(bytes(out.pop("receipt")).decode()) | |
| meta["layers"] = layers_in(out) # receipts before 1.1.0 left dino out of this list | |
| size = check_size(meta["cube_size"]) | |
| volume(meta["volume"]) | |
| n = size // PATCH | |
| if "grid" in out and out["grid"].shape != (n, n, n, EMBED_DIM): | |
| raise LensError(f"{path}: embeddings of shape {out['grid'].shape}, not {(n, n, n, EMBED_DIM)}") | |
| if "grid_reduced" in out and (out["grid_reduced"].shape[:3] != (n, n, n) | |
| or out["grid_basis"].shape != (EMBED_DIM, out["grid_reduced"].shape[3])): | |
| raise LensError(f"{path}: reduced embeddings do not match their basis") | |
| for k in ("ink", "surface"): | |
| if k in out and (out[k].shape != (size,) * 3 or out[k].dtype != np.uint8): | |
| raise LensError(f"{path}: {k} is {out[k].dtype} {out[k].shape}, not uint8 {(size,) * 3}") | |
| return meta, out | |
| def gallery(root=HERE / "gallery"): | |
| """The precomputed cubes: gallery/index.json lists them, each with its .npz beside it. Refuses an index that is | |
| not a list of complete, unique entries naming files that exist.""" | |
| idx = Path(root) / "index.json" | |
| if not idx.is_file(): | |
| return [] | |
| data = json.loads(idx.read_text()) | |
| items = data.get("cubes", []) if isinstance(data, dict) else None | |
| if not isinstance(items, list): | |
| raise LensError("gallery/index.json has no list of cubes") | |
| seen = set() | |
| for it in items: | |
| if not isinstance(it, dict): | |
| raise LensError("gallery/index.json holds an entry that is not an object") | |
| for key in ("title", "note"): # cubes built before 1.1.0 spell it the British way; the page is American | |
| if isinstance(it.get(key), str): | |
| it[key] = it[key].replace("rganisers", "rganizers") | |
| for key in ("id", "volume", "title", "note", "file", "point_zyx", "size"): | |
| if key not in it: | |
| raise LensError(f"gallery entry {it.get('id')!r} has no {key}") | |
| if it["id"] in seen: | |
| raise LensError(f"gallery entry {it['id']!r} is listed twice") | |
| seen.add(it["id"]) | |
| vid = volume_id(it["volume"]) | |
| if "/" in it["file"] or ".." in it["file"] or not it["file"].endswith(".npz"): | |
| raise LensError(f"gallery entry {it['id']!r} names a file outside gallery/") | |
| if not (Path(root) / it["file"]).is_file(): | |
| raise LensError(f"gallery entry {it['id']!r} names {it['file']}, which is missing") | |
| if it["size"] not in SIZES: | |
| raise LensError(f"gallery entry {it['id']!r} has size {it['size']!r}, not one of {SIZES}") | |
| p = it["point_zyx"] | |
| if not (isinstance(p, list) and len(p) == 3 and all(isinstance(v, int) for v in p) | |
| and all(0 <= v < n for v, n in zip(p, VOLUMES[vid]["shape"]))): | |
| raise LensError(f"gallery entry {it['id']!r} has a point_zyx outside the scan") | |
| return items | |
| # ---------------------------------------------------------------- the Space's own files | |
| SPACE_FILES = ("README.md", "app.py", "lens.py", "viewer.js", "requirements.txt", "LICENSE", "NOTICE", "connect.json", | |
| "connect.html", "CONNECT.md", "onescroll.html") | |
| def space_files(root=HERE): | |
| """The code and text of the Space. The gallery is not among them: tools/build_gallery.py uploads it.""" | |
| files = [root / f for f in SPACE_FILES] | |
| missing = [str(p) for p in files if not p.is_file()] | |
| if missing: | |
| raise RuntimeError(f"missing Space files: {missing}") | |
| return files | |
| def stage(dest, root=HERE): | |
| dest = Path(dest) | |
| if dest.exists() and any(dest.iterdir()): | |
| raise RuntimeError(f"{dest} is not empty") | |
| for p in space_files(root): | |
| rel = p.relative_to(root) | |
| (dest / rel).parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy2(p, dest / rel) | |
| return dest | |
| # ---------------------------------------------------------------- command line | |
| def main(argv=None): | |
| ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) | |
| sub = ap.add_subparsers(dest="cmd", required=True) | |
| sub.add_parser("volumes", help="the scans this Space may read") | |
| lk = sub.add_parser("look", help="run the models on one cube on this machine and save it") | |
| lk.add_argument("volume") | |
| lk.add_argument("zyx", nargs=3, type=int, help="a point in level-0 voxels, z y x") | |
| lk.add_argument("--size", type=int, default=128, choices=SIZES) | |
| lk.add_argument("--layers", default="dino,ink,surface") | |
| lk.add_argument("--tta", action="store_true", help="average the eight mirrorings (8x the ink and surface time)") | |
| lk.add_argument("--device", default=None, help="cpu or cuda (default: cuda when present)") | |
| lk.add_argument("--out", default=".", help="folder for CUBE.npz and CUBE.json") | |
| sub.add_parser("files", help="the Space's files with their SHA-256") | |
| st = sub.add_parser("stage", help="copy the Space's files into an empty folder") | |
| st.add_argument("dest") | |
| a = ap.parse_args(argv) | |
| try: | |
| if a.cmd == "volumes": | |
| for vid, v in VOLUMES.items(): | |
| print(f"{vid:12s} {v['scroll']:24s} {v['voxel_um']:.3f} um {v['scan']}") | |
| return 0 | |
| if a.cmd == "files": | |
| for p in space_files(): | |
| print(f"{sha256_file(p)} {p.relative_to(HERE)}") | |
| return 0 | |
| if a.cmd == "stage": | |
| print(stage(a.dest)) | |
| return 0 | |
| import torch | |
| device = a.device or ("cuda" if torch.cuda.is_available() else "cpu") | |
| layers = check_layers(a.layers.split(",")) | |
| origin = cube_origin(a.volume, a.zyx, a.size) | |
| cube = read_cube(a.volume, origin, a.size) | |
| check_cube(cube) | |
| eng = Engine() | |
| res = eng.run(cube, layers, device=device, tta=a.tta) | |
| meta = receipt(a.volume, origin, a.size, a.zyx, res, eng.describe()) | |
| if "grid" in res and "ink" in res: | |
| meta["agreement_with_ink_model"] = agreement(similarity(res["grid"].astype("float32"), eng.reference), | |
| res["ink"]) | |
| out = Path(a.out) | |
| out.mkdir(parents=True, exist_ok=True) | |
| name = f"{a.volume}-{'-'.join(map(str, origin))}-{a.size}" | |
| save_npz(out / f"{name}.npz", meta, res) | |
| (out / f"{name}.json").write_text(json.dumps(meta, indent=1) + "\n") | |
| print(json.dumps(meta, indent=1)) | |
| return 0 | |
| except LensError as exc: | |
| print(f"lens: {exc}", file=sys.stderr) | |
| return 2 | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |