unroll / tools /precompute_template.py
SharpMeow's picture
Data-driven scroll framework and Open your own
f6dcaa7
Raw History Blame Contribute Delete
6.27 kB
#!/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/<id>.obj the surface, decimated, with UVs (u along the winding, v down the roll)
xray/<id>.jpg the X-ray (CT) layer laid out on those UVs, if you give one
ink/<id>.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()