ink-lens / engine.py
SharpMeow's picture
One page scroll bar and American English
978066c verified
Raw History Blame Contribute Delete
24.9 kB
"""Ink Lens engine: an openly licensed 9 um ink model on one region, three depth windows, both depth orders.
The model is built by villa's own flat ink inference (vesuvius.ink_detection.inference.infer.configure_model, at the
commit pinned in VILLA_COMMIT) from a checkpoint downloaded by URL and refused unless its SHA-256 matches. The patch
schedule (128 px patches, stride 64, the last flush with the end), the layer choice (center crop, reversed for the
control), villa's per-patch robust normalization and the Hann blend are villa's; two things differ from villa's
command line, both to fit one ZeroGPU call:
1. the robust normalization runs batched on the device (robust_torch), checked against villa's CPU function
(robust_np is a line-for-line copy of villa's normalize_robust for a patch with no mask; tests/test_inklens.py
compares them, and compares robust_np with villa's own function when villa is importable);
2. the maps stay float (0 to 1) instead of villa's uint8 encoding, so the control-set threshold is not quantized.
run_reference() is the same algorithm in numpy with any predict function: the tests run it with a stand-in model,
and the torch path is checked against it.
python3 engine.py estimate 1024 1024 --windows 3 patches and the GPU seconds one call asks for
python3 engine.py fetch d9v2 download and check a checkpoint into the cache
"""
from __future__ import annotations
import hashlib
import math
import os
import platform
import shutil
import subprocess
import sys
import tarfile
import tempfile
import time
import urllib.request
from pathlib import Path
VERSION = "0.1.0"
VILLA_REPO = "https://github.com/ScrollPrize/villa.git"
VILLA_TARBALL = "https://codeload.github.com/ScrollPrize/villa/tar.gz/{commit}"
VILLA_COMMIT = "8aa507c1215f1fa8758f13d0538f7af9710e02d7" # ScrollPrize/villa main, 18 Oct 2026
CACHE = Path(os.environ.get("INKLENS_CACHE") or Path(tempfile.gettempdir()) / "ink-lens")
USER_AGENT = f"ink-lens/{VERSION}"
# Openly licensed 9 um readers that drop into villa's flat inference. Public held-out scores (PHerc0841, crops with a
# 64 px inner edge, raw / reversed AUC) are in docs/results.json; d9v2 is the default.
READERS = {
"d9v2": {
"title": "d9v2 (TAUIL-Abd-Elilah, MIT)",
"url": "https://github.com/TAUIL-Abd-Elilah/pherc0826-first-letters-search/releases/download/v1.0/"
"d9v2_ft-012000.pth",
"file": "d9v2_ft-012000.pth",
"sha256": "50d2ad0ef7690a6a422640804d38f01409da8bc96cfc1ab72f45324a4a18f966",
"license": "MIT",
"source": "https://github.com/TAUIL-Abd-Elilah/pherc0826-first-letters-search",
"held_out": "PHerc0841 w00, ag896, ag405 (its reader/test.txt)",
},
"ink_9um": {
"title": "ink_9um seed 42 step 75,000 (Vesuvius Challenge, MIT)",
"url": "https://huggingface.co/scrollprize/ink_9um/resolve/main/hybrid_3d2d-seed42/step-075000.pth",
"file": "ink_9um-seed42-step-075000.pth",
"sha256": "e635558ae6a1a807a7e5ec1e83adfd45bc3c0ac53883ea43f1d4e085d62a9cab",
"license": "MIT",
"source": "https://huggingface.co/scrollprize/ink_9um",
"held_out": "PHerc0841 (its label dataset has no PHerc0841 segment)",
},
}
READER_CHOICES = ("d9v2", "ink_9um", "both")
PATCH, STRIDE, DEPTH = 128, 64, 17
STACK = 21 # layers kept around the middle: the three windows below fit in it
WINDOW_OFFSETS = {1: (2,), 3: (0, 2, 4)} # window starts inside the 21-layer stack (2 is villa's own center)
BATCH = 64
MIN_SIDE, MAX_SIDE = PATCH, 2048
MIN_LAYERS, MAX_LAYERS = DEPTH, 65
# One call's duration: a fixed start plus a per-patch allowance, capped. The per-patch figure is an estimate (3 to
# 6 ms per patch with batched fp16 and normalization on the GPU) with a margin; every receipt records the measured
# seconds so the first calls calibrate it.
GPU_START_S, GPU_PER_PATCH_S, GPU_MAX_S = 4.0, 0.012, 40
class EngineError(ValueError):
"""A request the engine refuses; the message says why."""
# ---------------------------------------------------------------- sizing
def positions(length, patch=PATCH, stride=STRIDE):
"""Patch starts along one axis, the last flush with the end (villa's _sliding_positions_1d)."""
if length <= patch:
return [0]
out = list(range(0, length - patch + 1, max(1, stride)))
if out[-1] != length - patch:
out.append(length - patch)
return out
def readers_of(choice):
if choice not in READER_CHOICES:
raise EngineError(f"reader {choice!r}: choose one of {', '.join(READER_CHOICES)}")
return ("d9v2", "ink_9um") if choice == "both" else (choice,)
def patches(height, width, windows=3, reader="d9v2"):
"""Patches one call runs: per pass the patch grid, times windows, times two depth orders, times readers."""
if windows not in WINDOW_OFFSETS:
raise EngineError(f"windows {windows}: use 1 or 3")
return len(positions(height)) * len(positions(width)) * windows * 2 * len(readers_of(reader))
def gpu_seconds(n_patches):
"""The duration a call asks ZeroGPU for; refuses a call that would need more than GPU_MAX_S."""
need = math.ceil(GPU_START_S + n_patches * GPU_PER_PATCH_S)
if need > GPU_MAX_S:
raise EngineError(f"{n_patches} patches need about {need} GPU seconds, more than the {GPU_MAX_S} s one call "
"may ask for; use a smaller region, one depth window, or local mode")
return need
def estimate(height, width, windows=3, reader="d9v2"):
check_side(height, width)
n = patches(height, width, windows, reader)
out = {"height": int(height), "width": int(width), "windows": int(windows), "reader": reader, "patches": n}
try:
out["gpu_seconds_requested"] = gpu_seconds(n)
except EngineError as exc:
out["gpu_seconds_requested"] = None
out["refused"] = str(exc)
return out
def check_side(height, width):
for name, side in (("height", height), ("width", width)):
if not MIN_SIDE <= int(side) <= MAX_SIDE:
raise EngineError(f"{name} {side} px: a region is {MIN_SIDE} to {MAX_SIDE} px a side at 9.6 um")
def check_volume(volume, windows=3):
"""Refuse anything but a uint8 (layers, rows, columns) region of the right size and depth."""
import numpy as np
a = np.asarray(volume)
if a.ndim != 3:
raise EngineError(f"a surface volume has 3 axes (layers, rows, columns), this one has {a.ndim}")
if a.dtype != np.uint8:
raise EngineError(f"type {a.dtype}: the engine takes uint8 (volio.to_uint8 converts uint16)")
L, H, W = a.shape
need = STACK if windows == 3 else DEPTH
if not need <= L <= MAX_LAYERS:
raise EngineError(f"{L} layers: {windows} depth window{'s' if windows > 1 else ''} need{'' if windows > 1 else 's'} "
f"{need} to {MAX_LAYERS} layers, layers first")
check_side(H, W)
if not a.any():
raise EngineError("the region is all zero (off the segment, or a hole in it)")
return L, H, W
def stack(volume, windows=3):
"""The middle STACK layers (or all, if fewer), as villa's center crop chooses them (upper center when uneven)."""
L = volume.shape[0]
n = min(L, STACK)
start = L // 2 - n // 2
return volume[start:start + n]
def window_indices(n_layers, offset, direction):
"""Layer indices of one 17-layer window inside the stack; reversed for the control."""
if n_layers < STACK: # one window, centered (villa's own choice)
start = n_layers // 2 - DEPTH // 2
else:
start = offset
idx = list(range(start, start + DEPTH))
return idx[::-1] if direction == "reverse" else idx
def passes(windows):
"""(offset, direction) for every pass of one reader: each window in both orders."""
return [(o, d) for o in WINDOW_OFFSETS[windows] for d in ("forward", "reverse")]
# ---------------------------------------------------------------- villa's normalization, numpy and torch
def robust_np(patch):
"""villa's normalize_robust (vesuvius/image_proc/intensity/normalization.py at VILLA_COMMIT) with no mask:
clip to the 1st and 99th percentiles, subtract the median, divide by 1.4826 x MAD, with villa's fallbacks."""
import numpy as np
arr = np.asarray(patch).astype(np.float32, copy=True)
valid = arr.reshape(-1)
if valid.size == 0:
return arr
std_preclip = float(np.std(valid))
lower = float(np.percentile(valid, 1.0))
upper = float(np.percentile(valid, 99.0))
np.clip(arr, lower, upper, out=arr)
valid = arr.reshape(-1)
median = float(np.median(valid))
mad = float(np.median(np.abs(valid - median)))
scaled = 1.4826 * mad
if not np.isfinite(scaled) or scaled < 1e-6:
span = upper - lower
cands = []
if np.isfinite(std_preclip):
cands.append(abs(std_preclip))
if np.isfinite(span):
cands.append(abs(span) / 2.0)
scaled = next((c for c in cands if c >= 1e-6), None)
if scaled is None or not np.isfinite(scaled):
scaled = 1.0
arr -= median
arr /= scaled
np.nan_to_num(arr, copy=False, nan=0.0, posinf=0.0, neginf=0.0)
return arr
def _lerp_sorted(torch, s, q):
"""numpy's linear percentile of each row of an already sorted (B, N) tensor."""
n = s.shape[1]
pos = q * (n - 1)
lo = int(math.floor(pos))
hi = min(lo + 1, n - 1)
frac = pos - lo
return s[:, lo] + (s[:, hi] - s[:, lo]) * frac
def robust_torch(x):
"""robust_np on every row of a (B, N) float32 tensor at once, on its device."""
import torch
x = x.float()
s, _ = torch.sort(x, dim=1)
std_pre = x.std(dim=1, unbiased=False)
lower, upper = _lerp_sorted(torch, s, 0.01), _lerp_sorted(torch, s, 0.99)
x = torch.maximum(torch.minimum(x, upper[:, None]), lower[:, None])
s = torch.maximum(torch.minimum(s, upper[:, None]), lower[:, None])
median = _lerp_sorted(torch, s, 0.5)
dev, _ = torch.sort((x - median[:, None]).abs(), dim=1)
scaled = 1.4826 * _lerp_sorted(torch, dev, 0.5)
half_span = (upper - lower).abs() / 2.0
ok = torch.isfinite(scaled) & (scaled >= 1e-6)
fb = torch.where(torch.isfinite(std_pre) & (std_pre.abs() >= 1e-6), std_pre.abs(),
torch.where(torch.isfinite(half_span) & (half_span >= 1e-6), half_span,
torch.ones_like(half_span)))
scaled = torch.where(ok, scaled, fb)
out = (x - median[:, None]) / scaled[:, None]
return torch.nan_to_num(out, nan=0.0, posinf=0.0, neginf=0.0)
def hann2d():
"""villa's floored Hann blend map (compute_importance_map_2d, mode hann), in numpy."""
import numpy as np
w1 = np.hanning(PATCH).astype(np.float32) # numpy's hanning is the symmetric (periodic=False) window
w = np.outer(w1, w1)
w /= max(float(w.max()), float(np.finfo(np.float32).eps))
return np.maximum(w, 0.001).astype(np.float32)
# ---------------------------------------------------------------- the numpy reference
def run_reference(volume, predict, windows=3):
"""The whole algorithm in numpy: predict(batch (B, 1, 17, 128, 128) float32) -> (B, 128, 128) probabilities.
Returns {"forward": F, "reverse": R} (float32, 0 to 1), each the mean over the windows."""
import numpy as np
vol = stack(np.asarray(volume), windows)
L, H, W = vol.shape
ys, xs = positions(H), positions(W)
wmap = hann2d()
maps = {"forward": [], "reverse": []}
for off, direction in passes(windows):
idx = window_indices(L, off, direction)
psum = np.zeros((H, W), np.float32)
wsum = np.zeros((H, W), np.float32)
for y0 in ys:
for x0 in xs:
raw = vol[idx, y0:y0 + PATCH, x0:x0 + PATCH]
if not raw.any():
continue
p = np.asarray(predict(robust_np(raw)[None, None]), np.float32)[0]
psum[y0:y0 + PATCH, x0:x0 + PATCH] += p * wmap
wsum[y0:y0 + PATCH, x0:x0 + PATCH] += wmap
prob = np.divide(psum, wsum, out=np.zeros_like(psum), where=wsum > 1e-6)
maps[direction].append(np.clip(prob, 0, 1))
return {d: np.mean(m, axis=0).astype(np.float32) for d, m in maps.items()}
# ---------------------------------------------------------------- downloads
def sha256_file(path, block=1 << 22):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(block), b""):
h.update(chunk)
return h.hexdigest()
def _download(url, dest):
dest.parent.mkdir(parents=True, exist_ok=True)
part = dest.with_name(dest.name + ".part")
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=600) as resp, open(part, "wb") as out:
shutil.copyfileobj(resp, out, 1 << 20)
os.replace(part, dest)
def ensure_checkpoint(name, cache=CACHE):
"""A reader's checkpoint, downloaded once and refused unless its SHA-256 matches READERS. INKLENS_<NAME> (for
example INKLENS_D9V2) may name a local copy; its SHA-256 is still checked."""
spec = READERS[name]
env = os.environ.get("INKLENS_" + name.upper())
path = Path(env).expanduser() if env else Path(cache) / "checkpoints" / spec["file"]
if not path.is_file():
if env:
raise EngineError(f"INKLENS_{name.upper()}={env} does not exist")
_download(spec["url"], path)
digest = sha256_file(path)
if digest != spec["sha256"]:
raise EngineError(f"{path} has SHA-256 {digest}, expected {spec['sha256']} for {name}; refusing it")
return path
def _git_commit(path):
try:
return subprocess.run(["git", "-C", str(path), "rev-parse", "HEAD"], capture_output=True, text=True,
check=True).stdout.strip()
except (OSError, subprocess.CalledProcessError):
return "unknown"
def _villa_tarball(tmp, commit):
root = tmp.resolve()
kept = 0
req = urllib.request.Request(VILLA_TARBALL.format(commit=commit), headers={"User-Agent": USER_AGENT})
with urllib.request.urlopen(req, timeout=600) as resp, tarfile.open(fileobj=resp, mode="r|gz") as tf:
for m in tf:
_, _, rel = m.name.partition("/")
if not rel.startswith("vesuvius/src/"):
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 EngineError(f"the villa tarball for {commit} held no vesuvius/src files")
def ensure_villa(cache=CACHE):
"""villa's vesuvius package on sys.path: VILLA_SRC (a local vesuvius/src) when set, else the pinned commit,
downloaded once (sparse git fetch, else GitHub's tarball)."""
if os.environ.get("VILLA_SRC"):
src = Path(os.environ["VILLA_SRC"]).expanduser().resolve()
info = {"source": "VILLA_SRC", "commit": _git_commit(src)}
else:
dest = Path(cache) / f"villa-{VILLA_COMMIT[:12]}"
if not (dest / "COMMIT").is_file():
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", VILLA_REPO],
["sparse-checkout", "set", "vesuvius/src"],
["fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", VILLA_COMMIT],
["checkout", "-q", "FETCH_HEAD"]):
subprocess.run(git + cmd, capture_output=True, text=True, check=True, timeout=600)
except (OSError, subprocess.SubprocessError):
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir(parents=True)
_villa_tarball(tmp, VILLA_COMMIT)
(tmp / "COMMIT").write_text(VILLA_COMMIT + "\n")
shutil.rmtree(dest, ignore_errors=True)
os.replace(tmp, dest)
src = dest / "vesuvius" / "src"
info = {"source": "pinned download", "commit": (dest / "COMMIT").read_text().strip()}
if not (src / "vesuvius" / "ink_detection" / "inference" / "infer.py").is_file():
raise EngineError(f"{src} holds no villa vesuvius package")
if str(src) not in sys.path:
sys.path.insert(0, str(src))
info["pinned"] = VILLA_COMMIT
return info
def load_villa_model(name, cache=CACHE):
"""(model, preprocessing, amp dtype, info) for one reader, built by villa's configure_model. The checkpoint is
opened with torch.load(weights_only=True): villa's loader allows any pickle, so it is replaced for this call."""
import argparse
import torch
villa = ensure_villa(cache)
path = ensure_checkpoint(name, cache)
from vesuvius.ink_detection.inference import infer
original = infer.load_checkpoint
infer.load_checkpoint = lambda p: torch.load(str(p), map_location="cpu", weights_only=True)
try:
cm = infer.configure_model(argparse.Namespace(checkpoint=path, amp_dtype="auto"))
finally:
infer.load_checkpoint = original
got = (int(cm.patch_size), int(cm.input_depth))
if got != (PATCH, DEPTH):
raise EngineError(f"{name} has patch and depth {got}; Ink Lens is built for {(PATCH, DEPTH)}")
if cm.preprocessing not in ("tifxyz_robust", "divide_255"):
raise EngineError(f"{name} asks for preprocessing {cm.preprocessing!r}, which Ink Lens does not run")
info = {"name": name, "sha256": READERS[name]["sha256"], "url": READERS[name]["url"],
"license": READERS[name]["license"], "preprocessing": cm.preprocessing,
"amp_on_cuda": str(cm.amp_dtype).replace("torch.", ""), "villa": villa, "loader": "weights_only=True"}
return cm.model.eval(), cm.preprocessing, cm.amp_dtype, info
# ---------------------------------------------------------------- the torch engine
def pick_device(prefer=None):
import torch
if prefer:
return torch.device(prefer)
if torch.cuda.is_available():
return torch.device("cuda")
if getattr(torch.backends, "mps", None) is not None and torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
class Engine:
"""Readers loaded once; run() does one region on one device.
models: {name: (module, preprocessing, amp_dtype, info)} to use instead of downloading (the tests pass a
stand-in); otherwise every name in `readers` is loaded through villa."""
def __init__(self, readers=("d9v2",), cache=CACHE, models=None, place_on_cuda=True):
t0 = time.time()
import torch
self.torch = torch
self.models = dict(models) if models else {n: load_villa_model(n, cache) for n in readers}
if place_on_cuda and torch.cuda.is_available():
for m in self.models.values():
m[0].to("cuda")
self.startup_s = round(time.time() - t0, 1)
def describe(self):
return {"engine": f"ink-lens {VERSION}", "readers": {n: m[3] for n, m in self.models.items()},
"patch": PATCH, "stride": STRIDE, "depth": DEPTH, "stack": STACK, "blend": "hann (villa, floored)",
"batch": BATCH, "torch": self.torch.__version__}
def run(self, volume, reader="d9v2", windows=3, device=None, batch=BATCH):
"""{"forward": F, "reverse": R, "per_reader": {...}, ...} for one uint8 region, float32 maps in 0 to 1."""
import numpy as np
torch = self.torch
names = readers_of(reader)
missing = [n for n in names if n not in self.models]
if missing:
raise EngineError(f"reader {', '.join(missing)} is not loaded here")
check_volume(volume, windows)
dev = pick_device(device)
t0 = time.perf_counter()
vol = stack(np.asarray(volume), windows)
L, H, W = vol.shape
stack_start = int(np.asarray(volume).shape[0] // 2 - L // 2)
vt = torch.from_numpy(np.ascontiguousarray(vol)).to(dev)
coords = [(y, x) for y in positions(H) for x in positions(W)]
wmap = torch.from_numpy(hann2d()).to(dev)
per_reader, n_patches = {}, 0
for name in names:
model, prep, amp, _ = self.models[name]
model = model.to(dev)
maps = {"forward": [], "reverse": []}
for off, direction in passes(windows):
idx = torch.tensor(window_indices(L, off, direction), device=dev)
sub = vt.index_select(0, idx)
psum = torch.zeros((H, W), dtype=torch.float32, device=dev)
wsum = torch.zeros((H, W), dtype=torch.float32, device=dev)
for b0 in range(0, len(coords), batch):
chunk = coords[b0:b0 + batch]
raw = torch.stack([sub[:, y:y + PATCH, x:x + PATCH] for y, x in chunk]) # (B, 17, 128, 128)
keep = raw.reshape(len(chunk), -1).any(dim=1) # villa skips all-zero patches, before normalizing
if not bool(keep.any()):
continue
if not bool(keep.all()):
chunk = [c for c, k in zip(chunk, keep.tolist()) if k]
raw = raw[keep]
flat = raw.reshape(len(chunk), -1).float()
norm = robust_torch(flat) if prep == "tifxyz_robust" else flat / 255.0
images = norm.reshape(len(chunk), 1, DEPTH, PATCH, PATCH)
ctx = (torch.autocast("cuda", dtype=amp) if dev.type == "cuda" and amp is not None
else _null())
with torch.inference_mode(), ctx:
logits = model(images)
prob = logits.float().sigmoid()
if prob.shape[-2:] != (PATCH, PATCH):
prob = torch.nn.functional.interpolate(prob, size=(PATCH, PATCH), mode="bilinear",
align_corners=False)
prob = prob[:, 0]
for (y, x), p in zip(chunk, prob):
psum[y:y + PATCH, x:x + PATCH] += p * wmap
wsum[y:y + PATCH, x:x + PATCH] += wmap
n_patches += len(chunk)
out = torch.where(wsum > 1e-6, psum / wsum.clamp_min(1e-6), torch.zeros_like(psum)).clamp(0, 1)
maps[direction].append(out)
per_reader[name] = {d: torch.stack(m).mean(0).cpu().numpy().astype(np.float32) for d, m in maps.items()}
if dev.type == "cuda":
torch.cuda.synchronize(dev)
F = np.mean([per_reader[n]["forward"] for n in names], axis=0).astype(np.float32)
R = np.mean([per_reader[n]["reverse"] for n in names], axis=0).astype(np.float32)
name = torch.cuda.get_device_name(dev) if dev.type == "cuda" else (platform.processor() or platform.machine())
return {"forward": F, "reverse": R, "per_reader": per_reader, "readers": list(names), "windows": windows,
"window_layers": {f"{o}:{d}": window_indices(L, o, d) for o, d in passes(windows)},
"stack_layers": int(L), "stack_start": stack_start, "patches": n_patches, "device": str(dev), "device_name": name,
"seconds": round(time.perf_counter() - t0, 3)}
class _null:
def __enter__(self):
return None
def __exit__(self, *exc):
return False
def main(argv=None):
import argparse
import json
ap = argparse.ArgumentParser(description="Ink Lens engine helpers")
sub = ap.add_subparsers(dest="cmd", required=True)
e = sub.add_parser("estimate")
e.add_argument("height", type=int)
e.add_argument("width", type=int)
e.add_argument("--windows", type=int, default=3, choices=(1, 3))
e.add_argument("--reader", default="d9v2", choices=READER_CHOICES)
f = sub.add_parser("fetch")
f.add_argument("reader", choices=sorted(READERS))
a = ap.parse_args(argv)
try:
if a.cmd == "estimate":
print(json.dumps(estimate(a.height, a.width, a.windows, a.reader), indent=1))
else:
print(json.dumps({"reader": a.reader, "path": str(ensure_checkpoint(a.reader))}))
except EngineError as exc:
print(f"refused: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
sys.exit(main())