File size: 6,272 Bytes
f6dcaa7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 | #!/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()
|