crackle-check / koine_engine.py
SharpMeow's picture
One page scroll bar and American English
faa2ae9 verified
Raw History Blame Contribute Delete
10.2 kB
"""Readers that run through the team's koine_machines inference (villa branch merge-ink-pipelines), such as Reader V3.
Ink Lens's engine (engine.py, a copied file this folder never edits) runs 128 px villa-flat readers. Koine readers use
larger patches (Reader V3: 17 x 384 x 384), so they get this small engine with the same contract: run(volume) returns
{"forward", "reverse", ...} float32 maps in 0 to 1, the reverse map from the same layers in reversed depth order. It
follows koine's own inference (koine_machines/inference/infer.py at the pinned commit): the center 17 layers, robust
or /255 preprocessing per patch, sigmoid of the model's logits, patches at 50 % overlap blended with koine's floored
Hann map, all-zero patches skipped. The checkpoint is SHA-256 pinned and loaded with torch.load(weights_only=True).
To add a reader, add an entry to KOINE_READERS (a fixed URL, its SHA-256 and license); app.py lists it.
"""
from __future__ import annotations
import math
import os
import shutil
import subprocess
import sys
import tarfile
import time
import urllib.request
from pathlib import Path
import engine
KOINE_REPO = engine.VILLA_REPO
KOINE_COMMIT = "3ea17f54a9b3d5fd1aaf73e1d2c8386dbaa9f30e" # villa merge-ink-pipelines, as the Transfer Matrix ran it
OVERLAP = 0.5
KOINE_READERS = {
"readerv3": {
"title": "Reader V3 (Domenico Russo, MIT)",
"url": "https://huggingface.co/domenicor046/reader-v3/resolve/main/reader-v3.pth",
"file": "reader-v3.pth",
"sha256": "ea405417450abd8ace1b8ff79ebce3adb646c5e48ab95da46844d77efe984d8a",
"license": "MIT",
"source": "https://huggingface.co/domenicor046/reader-v3",
"held_out": "author-reported: every published result on data it never trained on, PHerc0841 w00 among them",
},
}
def ensure_checkpoint(name, cache=engine.CACHE):
spec = KOINE_READERS[name]
path = Path(cache) / "checkpoints" / spec["file"]
if not path.is_file():
engine._download(spec["url"], path)
digest = engine.sha256_file(path)
if digest != spec["sha256"]:
raise engine.EngineError(f"{path} has SHA-256 {digest}, expected {spec['sha256']} for {name}; refusing it")
return path
def _tarball(tmp):
root = tmp.resolve()
kept = 0
url = engine.VILLA_TARBALL.format(commit=KOINE_COMMIT)
req = urllib.request.Request(url, headers={"User-Agent": engine.USER_AGENT})
with urllib.request.urlopen(req, timeout=900) as resp, tarfile.open(fileobj=resp, mode="r|gz") as tf:
for m in tf:
_, _, rel = m.name.partition("/")
if not rel.startswith("ink-detection/koine_machines/"):
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 engine.EngineError(f"the villa tarball for {KOINE_COMMIT} held no koine_machines files")
def ensure_koine(cache=engine.CACHE):
"""koine_machines on sys.path (KOINE_SRC, a local ink-detection folder, when set), plus villa's vesuvius package
from engine.ensure_villa for the robust normalization koine imports."""
engine.ensure_villa(cache)
if os.environ.get("KOINE_SRC"):
src = Path(os.environ["KOINE_SRC"]).expanduser().resolve()
else:
dest = Path(cache) / f"koine-{KOINE_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", KOINE_REPO],
["sparse-checkout", "set", "ink-detection/koine_machines"],
["fetch", "-q", "--depth", "1", "--filter=blob:none", "origin", KOINE_COMMIT],
["checkout", "-q", "FETCH_HEAD"]):
subprocess.run(git + cmd, capture_output=True, text=True, check=True, timeout=900)
except (OSError, subprocess.SubprocessError):
shutil.rmtree(tmp, ignore_errors=True)
tmp.mkdir(parents=True)
_tarball(tmp)
(tmp / "COMMIT").write_text(KOINE_COMMIT + "\n")
shutil.rmtree(dest, ignore_errors=True)
os.replace(tmp, dest)
src = dest / "ink-detection"
if not (src / "koine_machines" / "inference" / "infer.py").is_file():
raise engine.EngineError(f"{src} holds no koine_machines package")
if str(src) not in sys.path:
sys.path.insert(0, str(src))
return {"koine": KOINE_COMMIT, "villa": engine.VILLA_COMMIT}
def load(name, cache=engine.CACHE):
"""(model, info) for one koine reader, built by koine's own builder from the checkpoint's config."""
import torch
versions = ensure_koine(cache)
path = ensure_checkpoint(name, cache)
from koine_machines.inference import infer
payload = torch.load(str(path), map_location="cpu", weights_only=True)
cm = infer.build_repo_training_model_bundle(payload, path)
amp = infer.resolve_amp_dtype(amp_dtype_arg="auto", checkpoint_payload=payload, checkpoint_path=path)
del payload
info = {"name": name, "engine": "koine", "patch": int(cm.roi_size), "depth": int(cm.in_chans),
"preprocessing": cm.preprocessing, "amp_on_cuda": str(amp).replace("torch.", ""),
"sha256": KOINE_READERS[name]["sha256"], "url": KOINE_READERS[name]["url"],
"license": KOINE_READERS[name]["license"], "loader": "weights_only=True", **versions}
return cm.model.eval(), info, amp
def positions(length, patch, stride):
"""Patch starts covering 0..length, the last flush with the end (one start when the region is smaller)."""
if length <= patch:
return [0]
n = math.ceil((length - patch) / stride) + 1
starts = [i * stride for i in range(n - 1)] + [length - patch]
return sorted(set(starts))
def hann(patch):
import torch
w = torch.outer(torch.hann_window(patch, periodic=False), torch.hann_window(patch, periodic=False))
return torch.clamp(w / w.max(), min=1e-3)
def patches(height, width, patch=384):
stride = max(1, round(patch * (1 - OVERLAP)))
return len(positions(height, patch, stride)) * len(positions(width, patch, stride)) * 2
class KoineReader:
"""One koine reader, loaded once (on the CPU; run() moves it to the call's device)."""
def __init__(self, name, cache=engine.CACHE, model=None, info=None):
if model is None:
model, info, self.amp = load(name, cache)
else:
self.amp = None
self.name, self.model, self.info = name, model, info
def run(self, volume, device="cpu", batch=4):
import numpy as np
import torch
t0 = time.perf_counter()
dev = torch.device(device)
vol = np.asarray(volume)
L, H, W = vol.shape
depth, patch = self.info["depth"], self.info["patch"]
if L < depth:
raise engine.EngineError(f"{L} layers: {self.name} reads {depth}")
start = L // 2 - depth // 2 # koine's center crop (upper center when uneven)
sub = vol[start:start + depth]
ph, pw = max(H, patch), max(W, patch) # a region smaller than one patch is padded with zeros
padded = np.zeros((depth, ph, pw), np.uint8)
padded[:, :H, :W] = sub
stride = max(1, round(patch * (1 - OVERLAP)))
coords = [(y, x) for y in positions(ph, patch, stride) for x in positions(pw, patch, stride)]
wmap = hann(patch).to(dev)
model = self.model.to(dev)
robust = self.info["preprocessing"] == "tifxyz_robust"
if robust:
from vesuvius.image_proc.intensity.normalization import normalize_robust
maps, n = {}, 0
for direction in ("forward", "reverse"):
stack = padded if direction == "forward" else padded[::-1]
psum = torch.zeros((ph, pw), dtype=torch.float32, device=dev)
wsum = torch.zeros((ph, pw), dtype=torch.float32, device=dev)
for b0 in range(0, len(coords), batch):
chunk = [(y, x) for y, x in coords[b0:b0 + batch] if stack[:, y:y + patch, x:x + patch].any()]
if not chunk:
continue
arrs = []
for y, x in chunk:
p = np.ascontiguousarray(stack[:, y:y + patch, x:x + patch])
arrs.append(normalize_robust(p) if robust else p.astype(np.float32) / 255.0)
images = torch.from_numpy(np.stack(arrs).astype(np.float32))[:, None].to(dev)
ctx = (torch.autocast("cuda", dtype=self.amp) if dev.type == "cuda" and self.amp is not None
else engine._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)
for (y, x), pr in zip(chunk, prob[:, 0]):
psum[y:y + patch, x:x + patch] += pr * wmap
wsum[y:y + patch, x:x + patch] += wmap
n += len(chunk)
out = torch.where(wsum > 1e-6, psum / wsum.clamp_min(1e-6), torch.zeros_like(psum)).clamp(0, 1)
maps[direction] = out[:H, :W].cpu().numpy().astype(np.float32)
return {"forward": maps["forward"], "reverse": maps["reverse"], "patches": n,
"layers": [start, start + depth], "seconds": round(time.perf_counter() - t0, 3)}