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()