Download tools/precompute_template.py from SharpMeow/unroll: direct link, hf CLI and curl.
- Browser
- Download file 6.27 kB
-
https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/tools/precompute_template.py
- Command line
-
hf download hf://spaces/SharpMeow/unroll/tools/precompute_template.py
-
curl -L -o precompute_template.py https://huggingface.co/spaces/SharpMeow/unroll/resolve/main/tools/precompute_template.py
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() | |