#!/usr/bin/env python3 """Template: turn traced windings into an Unroll "mesh" scroll package. Copy this file, fill in the WINDINGS table (or the find_windings() function) for your scroll, and run: python tools/precompute_template.py --out scrolls/MyScroll --id MyScroll --name "My scroll" --voxel-mm 0.007910 For every winding it writes meshes/.obj the surface, decimated, with UVs (u along the winding, v down the roll) xray/.jpg the X-ray (CT) layer laid out on those UVs, if you give one ink/.jpg the ink layer, if you give one and then manifest.json, which tools/validate_scroll.mjs checks. Inputs it understands out of the box: * a tifxyz folder (x.tif, y.tif, z.tif: one 3D point per grid cell, -1 where there is no surface), which is how the Vesuvius Challenge publishes segment meshes * an OBJ that already has UVs (copied through, optionally rescaled) Layers are images in the same UV frame (the flattened segment): PNG, JPG or TIFF, 8 or 16 bit. Needs Python 3.9+, numpy and Pillow; tifffile for tifxyz and 16-bit TIFF layers. Everything here is a starting point; check the TODO lines for your data. """ import argparse import json import shutil from pathlib import Path import numpy as np from PIL import Image # TODO: list your windings, outermost first. Paths are relative to --src. WINDINGS = [ # {"id": "w001", "mesh": "segments/w001.tifxyz", "ct": "layers/w001_ct.tif", "ink": "layers/w001_ink.png"}, ] MAX_SIDE = 2400 # longest side of a layer image, px (the page allows up to 8192) GRID_STEP = 4 # keep every Nth tifxyz cell (decimation); the page allows 2,000,000 vertices in all def find_windings(src: Path): """TODO: discover windings automatically instead of filling WINDINGS by hand.""" return WINDINGS def tifxyz_to_obj(folder: Path, out: Path, step: int): import tifffile x, y, z = (tifffile.imread(folder / f"{a}.tif").astype(np.float32) for a in "xyz") x, y, z = x[::step, ::step], y[::step, ::step], z[::step, ::step] R, C = x.shape valid = (x > 0) & (y > 0) & (z > 0) vid = -np.ones((R, C), np.int64) vid[valid] = np.arange(valid.sum()) rr, cc = np.nonzero(valid) lines = [f"# from {folder.name}, voxel units, roll axis along z"] lines += [f"v {x[r, c]:.2f} {y[r, c]:.2f} {z[r, c]:.2f}" for r, c in zip(rr, cc)] # u runs along the winding (columns), v runs down the roll (rows); OBJ's v points up, so flip it lines += [f"vt {c / max(1, C - 1):.6f} {1 - r / max(1, R - 1):.6f}" for r, c in zip(rr, cc)] for r in range(R - 1): for c in range(C - 1): a, b, d, e = vid[r, c], vid[r, c + 1], vid[r + 1, c], vid[r + 1, c + 1] if min(a, d, b) >= 0: lines.append(f"f {a+1}/{a+1} {d+1}/{d+1} {b+1}/{b+1}") if min(b, d, e) >= 0: lines.append(f"f {b+1}/{b+1} {d+1}/{d+1} {e+1}/{e+1}") out.write_text("\n".join(lines) + "\n") return int(valid.sum()) def layer_image(src: Path, out: Path): if src.suffix.lower() in (".tif", ".tiff"): import tifffile a = tifffile.imread(src).astype(np.float32) if a.ndim == 3: a = a[..., 0] lo, hi = np.percentile(a, [0.5, 99.5]) # TODO: fixed limits if your layers must match a = np.clip((a - lo) / max(hi - lo, 1e-6), 0, 1) * 255 im = Image.fromarray(a.astype(np.uint8)) else: im = Image.open(src).convert("L") im.thumbnail((MAX_SIDE, MAX_SIDE), Image.LANCZOS) im.save(out, quality=90) def main(): ap = argparse.ArgumentParser() ap.add_argument("--src", default=".", help="folder the WINDINGS paths are relative to") ap.add_argument("--out", required=True) ap.add_argument("--id", required=True) ap.add_argument("--name", required=True) ap.add_argument("--voxel-mm", type=float, required=True) args = ap.parse_args() src, out = Path(args.src), Path(args.out) for d in ("meshes", "xray", "ink"): (out / d).mkdir(parents=True, exist_ok=True) wins = find_windings(src) if not wins: raise SystemExit("No windings: fill in WINDINGS or find_windings() first.") entries, have_ct, have_ink, total = [], [], [], 0 for w in wins: m = src / w["mesh"] dst = out / "meshes" / f"{w['id']}.obj" if m.is_dir(): total += tifxyz_to_obj(m, dst, GRID_STEP) else: shutil.copy(m, dst) # TODO: decimate large meshes first entries.append({"id": w["id"], "mesh": f"meshes/{w['id']}.obj"}) if w.get("ct"): layer_image(src / w["ct"], out / "xray" / f"{w['id']}.jpg"); have_ct.append(w["id"]) if w.get("ink"): layer_image(src / w["ink"], out / "ink" / f"{w['id']}.jpg"); have_ink.append(w["id"]) print(f"{w['id']}: done") layers = [] if have_ct: layers.append({"id": "xray", "role": "ct", "label": "X-ray", "texture": "xray/{id}.jpg", "windings": have_ct, "source": "TODO: which scan and surface volume", "resolutionUM": round(args.voxel_mm * 1000, 3)}) if have_ink: layers.append({"id": "ink", "role": "ink", "label": "Ink", "texture": "ink/{id}.jpg", "windings": have_ink, "source": "TODO: which model or detection, on which scan", "resolutionUM": None}) layers[-1].pop("resolutionUM") manifest = { "schema": "unroll-scroll/1", "id": args.id, "name": args.name, "description": "TODO: one or two sentences about this scroll.", "voxelMM": args.voxel_mm, "geometry": {"format": "mesh", "units": "voxels", "axis": "z"}, "windings": entries, "outermost": "first", "layers": layers, "credits": {"text": "TODO: who made the data", "url": "https://scrollprize.org", "citation": "TODO"}, "dataTerms": {"license": "TODO: for example CC BY-NC 4.0", "url": "https://creativecommons.org/licenses/by-nc/4.0/"}, } (out / "manifest.json").write_text(json.dumps(manifest, indent=1, ensure_ascii=False) + "\n") print(f"wrote {out / 'manifest.json'}; now run: node tools/validate_scroll.mjs {out / 'manifest.json'}") if __name__ == "__main__": main()