glazed 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
glazed/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """glazed: studio-lit Cycles renders of point clouds, meshes, and scenes mixing both."""
glazed/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
glazed/cli.py ADDED
@@ -0,0 +1,155 @@
1
+ """The `glazed` command: render asset and scene files from the shell."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from .io import MESH_SUFFIXES, POINT_CLOUD_SUFFIXES, SCENE_SUFFIXES, AssetSpec, read_inputs
10
+ from .settings import MODES, Animation, Colormap, Lighting, RenderSettings, View
11
+
12
+
13
+ class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter):
14
+ """Shows defaults, except None ones, whose help already says what happens instead."""
15
+
16
+ def _get_help_string(self, action):
17
+ return action.help if action.default is None else super()._get_help_string(action)
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ p = argparse.ArgumentParser(
22
+ description="Studio-lit Cycles renders of point clouds, meshes, and scenes mixing both.",
23
+ formatter_class=HelpFormatter)
24
+ p.add_argument("inputs", nargs="+", metavar="INPUT",
25
+ help=f"asset files ({' '.join(POINT_CLOUD_SUFFIXES + MESH_SUFFIXES)}) "
26
+ f"or scene files ({' '.join(SCENE_SUFFIXES)})")
27
+ p.add_argument("--out", help="output path; the mode adds .png, .mp4 or .gif "
28
+ "(default: outs/<first input's name>)")
29
+ p.add_argument("--mode", nargs="+", default=["still"], choices=MODES)
30
+
31
+ g = p.add_argument_group("objects (scene file entries override these)")
32
+ g.add_argument("--color", default=AssetSpec.color,
33
+ help="colour of anything without per-point values")
34
+ g.add_argument("--radius", type=float, help="point radius in data units (default: automatic)")
35
+ g.add_argument("--radius-scale", type=float, default=AssetSpec.radius_scale,
36
+ help="multiplier on the automatic point radius")
37
+
38
+ g = p.add_argument_group("colormap")
39
+ g.add_argument("--cmap", nargs="+", default=list(Colormap.names),
40
+ help="one render pass per colormap")
41
+ g.add_argument("--vmin", type=float, help="colormap's low end (default: data min)")
42
+ g.add_argument("--vmax", type=float, help="colormap's high end (default: data max)")
43
+ g.add_argument("--colorbar", action="store_true", help="also write colorbar.png")
44
+ g.add_argument("--colorbar-label", default=Colormap.label)
45
+
46
+ g = p.add_argument_group("camera")
47
+ g.add_argument("--azim", type=float, default=View.azim, help="degrees")
48
+ g.add_argument("--elev", type=float, default=View.elev, help="degrees")
49
+ g.add_argument("--distance", type=float,
50
+ help="multiple of the scene's half-size (default: fit the scene in frame)")
51
+ g.add_argument("--lens", type=float, default=View.lens,
52
+ help="mm; shorter = stronger perspective")
53
+ g.add_argument("--waypoints",
54
+ help='camera path JSON {"eye": [[x, y, z], ...], "target": [...], "loop": true}')
55
+
56
+ g = p.add_argument_group("animation")
57
+ g.add_argument("--frames", type=int, default=Animation.frames)
58
+ g.add_argument("--fps", type=int, default=Animation.fps)
59
+ g.add_argument("--turns", type=float, default=Animation.turns)
60
+ g.add_argument("--elev-amp", type=float, default=Animation.elev_amp,
61
+ help="degrees of up-down sway")
62
+ g.add_argument("--dolly", type=float, default=Animation.dolly,
63
+ help="fractional push-in over the shot, e.g. 0.15")
64
+ g.add_argument("--ease", action="store_true", help="slow in/out; breaks a seamless loop")
65
+ g.add_argument("--gif-width", type=int, default=Animation.gif_width)
66
+ g.add_argument("--keep-frames", action=argparse.BooleanOptionalAction,
67
+ default=Animation.keep_frames)
68
+
69
+ g = p.add_argument_group("lighting")
70
+ g.add_argument("--key", type=float, default=Lighting.key, help="key light multiplier")
71
+ g.add_argument("--ambient", type=float, default=Lighting.ambient,
72
+ help="environment multiplier; lower = darker shadows, more contrast")
73
+ g.add_argument("--key-azim", type=float, default=Lighting.key_azim,
74
+ help="key angle from the camera, degrees; nearer 0 = flatter, +-90 = more depth")
75
+ g.add_argument("--key-elev", type=float, default=Lighting.key_elev,
76
+ help="key height, degrees; lower = stronger shading")
77
+ g.add_argument("--exposure", type=float, default=RenderSettings.exposure,
78
+ help="stops; -1 halves the brightness")
79
+ g.add_argument("--bg", default=RenderSettings.background, help="backdrop colour")
80
+ g.add_argument("--transparent", action="store_true", help="keep the background transparent")
81
+
82
+ g = p.add_argument_group("quality")
83
+ g.add_argument("--res", type=int, nargs=2, default=list(RenderSettings.resolution),
84
+ metavar=("W", "H"))
85
+ g.add_argument("--supersample", type=int, default=RenderSettings.supersample,
86
+ help="render at Nx, then box-filter down to --res; 1 to disable")
87
+ g.add_argument("--samples", type=int, default=RenderSettings.samples)
88
+ g.add_argument("--gpu", action="store_true")
89
+ g.add_argument("--save-blend", action="store_true",
90
+ help="also save a .blend for tweaking in Blender")
91
+
92
+ g = p.add_argument_group("preview")
93
+ g.add_argument("--preview", action="store_true",
94
+ help="instead of rendering, open the scene in the browser to pick the view "
95
+ "and lighting, and get the matching command")
96
+ g.add_argument("--port", type=int, help="preview server port (default: any free one)")
97
+ return p
98
+
99
+
100
+ def parse_args(argv=None) -> argparse.Namespace:
101
+ if argv is None: # under `blender -b -P render.py -- <args>`, ours follow the "--"
102
+ argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else sys.argv[1:]
103
+ return build_parser().parse_args(argv)
104
+
105
+
106
+ def to_argv(values: dict) -> list[str]:
107
+ """Arguments that parse back to `values`, leaving out options at their defaults."""
108
+ argv = [str(path) for path in values["inputs"]]
109
+ for action in build_parser()._actions:
110
+ value = values.get(action.dest, action.default)
111
+ if not action.option_strings or value == action.default:
112
+ continue
113
+ if isinstance(action, argparse.BooleanOptionalAction):
114
+ argv.append(action.option_strings[0 if value else 1])
115
+ elif action.nargs == 0:
116
+ argv.append(action.option_strings[0])
117
+ else:
118
+ items = value if isinstance(value, (list, tuple)) else [value]
119
+ argv += [action.option_strings[0], *map(str, items)]
120
+ return argv
121
+
122
+
123
+ def main(argv=None) -> None:
124
+ args = parse_args(argv)
125
+ defaults = {"color": args.color, "radius": args.radius, "radius_scale": args.radius_scale}
126
+ try:
127
+ specs = read_inputs(args.inputs, defaults)
128
+ except (OSError, ValueError, TypeError) as err:
129
+ sys.exit(f"glazed: {err}")
130
+ if args.preview:
131
+ from .preview import serve
132
+ serve(specs, args)
133
+ return
134
+ try:
135
+ from .render import render
136
+ except ImportError as err:
137
+ sys.exit(f"glazed: rendering needs Blender's bpy ({err}).\n"
138
+ "Install it in a Python 3.13 environment: pip install 'glazed[render]'")
139
+
140
+ render(
141
+ specs, args.out or str(Path("outs") / Path(args.inputs[0]).stem), args.mode,
142
+ settings=RenderSettings(resolution=tuple(args.res), supersample=args.supersample,
143
+ samples=args.samples, exposure=args.exposure, gpu=args.gpu,
144
+ background=None if args.transparent else args.bg),
145
+ lighting=Lighting(key=args.key, ambient=args.ambient, key_azim=args.key_azim,
146
+ key_elev=args.key_elev),
147
+ view=View(azim=args.azim, elev=args.elev, distance=args.distance, lens=args.lens,
148
+ waypoints=args.waypoints),
149
+ animation=Animation(frames=args.frames, fps=args.fps, turns=args.turns,
150
+ elev_amp=args.elev_amp, dolly=args.dolly, ease=args.ease,
151
+ gif_width=args.gif_width, keep_frames=args.keep_frames),
152
+ colormap=Colormap(names=tuple(args.cmap), vmin=args.vmin, vmax=args.vmax,
153
+ colorbar=args.colorbar, label=args.colorbar_label),
154
+ save_blend=args.save_blend,
155
+ )
glazed/io.py ADDED
@@ -0,0 +1,288 @@
1
+ """The glazed scene format: write point clouds, meshes, and scene files, and read them back.
2
+
3
+ Needs only numpy (plus tomli before Python 3.11), so it installs into any research
4
+ environment with `pip install glazed`. Rendering the files is glazed.render's job,
5
+ which needs Blender's bpy. Every function here accepts numpy arrays, lists, or
6
+ torch tensors on any device.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+ import json
13
+ import math
14
+ import os
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ import numpy as np
19
+
20
+ try:
21
+ import tomllib
22
+ except ImportError: # Python < 3.11
23
+ import tomli as tomllib
24
+
25
+ POINT_CLOUD_SUFFIXES = (".npy",)
26
+ MESH_SUFFIXES = (".obj", ".ply", ".stl", ".glb", ".gltf", ".fbx")
27
+ SCENE_SUFFIXES = (".toml", ".json")
28
+ RADIUS_PER_SPACING = 1.2 # closes most gaps in randomly sampled clouds without merging points
29
+
30
+
31
+ @dataclass
32
+ class AssetSpec:
33
+ """One file in a scene. The transform is in the file's own units, applied before
34
+ the whole scene is normalised for rendering."""
35
+ path: str
36
+ color: str = "#cccccc" # for anything without per-point values; "original" keeps mesh materials
37
+ location: tuple[float, float, float] = (0.0, 0.0, 0.0)
38
+ rotation: tuple[float, float, float] = (0.0, 0.0, 0.0) # XYZ degrees, about the file's origin
39
+ scale: float = 1.0
40
+ radius: float | None = None # point clouds; None picks one from the point spacing
41
+ radius_scale: float = 1.0
42
+ smooth: bool = True # meshes: smooth shading
43
+
44
+
45
+ SCENE_KEYS = tuple(field.name for field in dataclasses.fields(AssetSpec))
46
+
47
+ # Rotations taking the named axis to +z, glazed's up axis.
48
+ _TO_Z_UP = {
49
+ "z": lambda p: p,
50
+ "-z": lambda p: np.stack([p[:, 0], -p[:, 1], -p[:, 2]], axis=1),
51
+ "y": lambda p: np.stack([p[:, 0], -p[:, 2], p[:, 1]], axis=1),
52
+ "-y": lambda p: np.stack([p[:, 0], p[:, 2], -p[:, 1]], axis=1),
53
+ "x": lambda p: np.stack([-p[:, 2], p[:, 1], p[:, 0]], axis=1),
54
+ "-x": lambda p: np.stack([p[:, 2], p[:, 1], -p[:, 0]], axis=1),
55
+ }
56
+
57
+
58
+ def as_numpy(x) -> np.ndarray:
59
+ if hasattr(x, "detach"):
60
+ x = x.detach().cpu().numpy()
61
+ return np.asarray(x)
62
+
63
+
64
+ def save_points(path, xyz, values=None, rgb=None) -> str:
65
+ """Point cloud as .npy: (N, 3) xyz; with `values`, (N, 4) coloured through the
66
+ colormap; with `rgb` in [0, 1], (N, 6) coloured as given."""
67
+ if values is not None and rgb is not None:
68
+ raise ValueError("pass values or rgb, not both")
69
+ columns = [as_numpy(xyz).reshape(-1, 3)]
70
+ if values is not None:
71
+ columns.append(as_numpy(values).reshape(-1, 1))
72
+ if rgb is not None:
73
+ columns.append(as_numpy(rgb).reshape(-1, 3))
74
+ data = np.concatenate(columns, axis=1).astype(np.float32)
75
+ _require_finite(path, data)
76
+ np.save(path, data)
77
+ return str(path)
78
+
79
+
80
+ def save_mesh(path, vertices, faces) -> str:
81
+ """Triangle or quad mesh as binary PLY, which Blender reads with coordinates as
82
+ stored. Prefer it to OBJ, which Blender's importer rotates from Y-up to Z-up."""
83
+ v = as_numpy(vertices).reshape(-1, 3).astype("<f4")
84
+ f = as_numpy(faces).astype(np.int64)
85
+ if f.ndim != 2 or f.shape[1] not in (3, 4):
86
+ raise ValueError(f"{path}: faces must be (F, 3) or (F, 4), got {f.shape}")
87
+ if f.min() < 0 or f.max() >= len(v):
88
+ raise ValueError(f"{path}: face indices out of range for {len(v)} vertices")
89
+ _require_finite(path, v)
90
+
91
+ records = np.empty(len(f), dtype=[("n", "u1"), ("idx", "<i4", (f.shape[1],))])
92
+ records["n"] = f.shape[1]
93
+ records["idx"] = f
94
+ header = ("ply\nformat binary_little_endian 1.0\n"
95
+ f"element vertex {len(v)}\nproperty float x\nproperty float y\nproperty float z\n"
96
+ f"element face {len(f)}\nproperty list uchar int vertex_indices\nend_header\n")
97
+ with open(path, "wb") as fh:
98
+ fh.write(header.encode("ascii"))
99
+ fh.write(v.tobytes())
100
+ fh.write(records.tobytes())
101
+ return str(path)
102
+
103
+
104
+ def sample_surface(vertices, faces, n: int, seed: int = 0) -> np.ndarray:
105
+ """(n, 3) points spread uniformly over a mesh's surface."""
106
+ v = as_numpy(vertices).reshape(-1, 3).astype(np.float64)
107
+ f = as_numpy(faces).astype(np.int64)
108
+ if f.shape[1] == 4:
109
+ f = np.concatenate([f[:, [0, 1, 2]], f[:, [0, 2, 3]]])
110
+ tri = v[f]
111
+ area = 0.5 * np.linalg.norm(np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]), axis=1)
112
+ rng = np.random.default_rng(seed)
113
+ picked = tri[rng.choice(len(tri), n, p=area / area.sum())]
114
+ r1, r2 = rng.random((2, n, 1))
115
+ s = np.sqrt(r1)
116
+ return (1 - s) * picked[:, 0] + s * (1 - r2) * picked[:, 1] + s * r2 * picked[:, 2]
117
+
118
+
119
+ def to_z_up(xyz, up: str = "y") -> np.ndarray:
120
+ """Rotate points so the `up` axis ("y", "-y", "x", "-x", "z", "-z") becomes +z.
121
+ Apply the same call to every array in a scene so its parts stay aligned."""
122
+ return _TO_Z_UP[up](as_numpy(xyz).reshape(-1, 3))
123
+
124
+
125
+ def point_radius(xyz, radius: float | None = None, radius_scale: float = 1.0) -> float:
126
+ """The sphere radius glazed renders a cloud's points with, in file units: `radius`
127
+ if given, otherwise one picked from the point spacing, times `radius_scale`."""
128
+ if radius:
129
+ return radius
130
+ return RADIUS_PER_SPACING * median_spacing(as_numpy(xyz).reshape(-1, 3)) * radius_scale
131
+
132
+
133
+ def median_spacing(points: np.ndarray, n_query: int = 2000, n_ref: int = 20000,
134
+ seed: int = 0) -> float:
135
+ """Median nearest-neighbour distance, estimated on a subsample."""
136
+ rng = np.random.default_rng(seed)
137
+ n = len(points)
138
+ query = points[rng.choice(n, min(n_query, n), replace=False)]
139
+ ref = points[rng.choice(n, min(n_ref, n), replace=False)]
140
+ nearest = np.full(len(query), np.inf)
141
+ for i in range(0, len(ref), 4096):
142
+ d = np.linalg.norm(query[:, None, :] - ref[None, i:i + 4096, :], axis=2)
143
+ d[d < 1e-12] = np.inf
144
+ nearest = np.minimum(nearest, d.min(axis=1))
145
+ finite = nearest[np.isfinite(nearest)]
146
+ return float(np.median(finite)) if finite.size else 1e-3
147
+
148
+
149
+ def check_alignment(a, b, names: tuple[str, str] = ("a", "b")) -> dict:
150
+ """Print the sizes of two point sets and the closest distance between them.
151
+
152
+ Catches unit mix-ups (sizes ~1000x apart: mm vs m) and parts exported in
153
+ different frames (a gap far larger than expected), which glazed can't detect:
154
+ it never moves one file relative to another. For meshes pass sample_surface()
155
+ points; raw vertices of large faces overstate the gap.
156
+ """
157
+ a = as_numpy(a).reshape(-1, 3).astype(np.float64)
158
+ b = as_numpy(b).reshape(-1, 3).astype(np.float64)
159
+ rng = np.random.default_rng(0)
160
+ query = a[rng.choice(len(a), min(len(a), 2000), replace=False)]
161
+ ref = b[rng.choice(len(b), min(len(b), 20000), replace=False)]
162
+ gap = min(np.linalg.norm(query[:, None] - ref[None, i:i + 2048], axis=2).min()
163
+ for i in range(0, len(ref), 2048))
164
+ report = {
165
+ f"{names[0]}_size": float(np.linalg.norm(np.ptp(a, axis=0))),
166
+ f"{names[1]}_size": float(np.linalg.norm(np.ptp(b, axis=0))),
167
+ "gap": float(gap),
168
+ "center_distance": float(np.linalg.norm(a.mean(0) - b.mean(0))),
169
+ }
170
+ print("[check_alignment] " + ", ".join(f"{k} {v:.4g}" for k, v in report.items()))
171
+ return report
172
+
173
+
174
+ def grid_locations(count: int, spacing: float, columns: int | None = None) -> list:
175
+ """[x, y, 0] offsets laying `count` groups out in rows `spacing` apart. Give every
176
+ file of one group (e.g. a hand and its object) the same location."""
177
+ columns = columns or math.ceil(math.sqrt(count))
178
+ return [[(i % columns) * spacing, -(i // columns) * spacing, 0.0] for i in range(count)]
179
+
180
+
181
+ def write_scene(path, objects: list, comment: str | None = None) -> str:
182
+ """Write a scene .toml from AssetSpecs or dicts of AssetSpec fields. Paths may be
183
+ absolute; they are stored relative to the scene file."""
184
+ path = Path(path)
185
+ lines = [f"# {comment}"] if comment else []
186
+ for obj in objects:
187
+ entry = _entry(obj)
188
+ lines += ["", "[[objects]]"]
189
+ for key in SCENE_KEYS:
190
+ if key in entry:
191
+ value = entry[key]
192
+ if key == "path":
193
+ relative = os.path.relpath(Path(value).resolve(), path.parent.resolve())
194
+ value = Path(relative).as_posix()
195
+ lines.append(f"{key} = {_toml_value(value)}")
196
+ text = "\n".join(lines).lstrip("\n") + "\n"
197
+ tomllib.loads(text)
198
+ path.write_text(text)
199
+ return str(path)
200
+
201
+
202
+ def read_scene(path, defaults: dict | None = None) -> list[AssetSpec]:
203
+ """AssetSpecs from a .toml or .json scene file. `defaults` fill keys an entry
204
+ leaves out; entry paths resolve relative to the scene file."""
205
+ path = Path(path)
206
+ parse = tomllib.loads if path.suffix.lower() == ".toml" else json.loads
207
+ data = parse(path.read_text())
208
+ if not isinstance(data, dict) or "objects" not in data:
209
+ raise ValueError(f"{path}: not a scene file (no 'objects' list)")
210
+ specs = []
211
+ for entry in data["objects"]:
212
+ entry = {**(defaults or {}), **entry}
213
+ entry["path"] = str(path.parent / entry["path"])
214
+ specs.append(AssetSpec(**entry))
215
+ return specs
216
+
217
+
218
+ def read_inputs(paths, defaults: dict | None = None) -> list[AssetSpec]:
219
+ """AssetSpecs from any mix of asset files and scene files."""
220
+ specs = []
221
+ for path in map(Path, paths):
222
+ if path.suffix.lower() in SCENE_SUFFIXES:
223
+ specs += read_scene(path, defaults)
224
+ else:
225
+ specs.append(AssetSpec(str(path), **(defaults or {})))
226
+ for spec in specs:
227
+ if Path(spec.path).suffix.lower() not in POINT_CLOUD_SUFFIXES + MESH_SUFFIXES:
228
+ supported = " ".join(POINT_CLOUD_SUFFIXES + MESH_SUFFIXES + SCENE_SUFFIXES)
229
+ raise ValueError(f"{spec.path}: unsupported file type, expected one of {supported}")
230
+ return specs
231
+
232
+
233
+ def check_scene(path) -> list[str]:
234
+ """Problems glazed would hit rendering a scene file, found without rendering:
235
+ missing or unsupported files, malformed point arrays, and a caution when OBJ
236
+ is mixed with other formats. Returns an empty list when the scene is fine."""
237
+ specs = read_scene(path)
238
+ problems = []
239
+ for spec in specs:
240
+ file = Path(spec.path)
241
+ if not file.exists():
242
+ problems.append(f"{file}: missing")
243
+ elif file.suffix.lower() not in POINT_CLOUD_SUFFIXES + MESH_SUFFIXES:
244
+ problems.append(f"{file}: unsupported file type")
245
+ elif file.suffix.lower() in POINT_CLOUD_SUFFIXES:
246
+ data = np.load(file, mmap_mode="r")
247
+ if data.ndim != 2 or data.shape[1] not in (3, 4, 6):
248
+ problems.append(f"{file}: expected an (N, 3|4|6) array, got {data.shape}")
249
+ elif not np.isfinite(data).all():
250
+ problems.append(f"{file}: contains NaN or inf")
251
+ suffixes = {Path(spec.path).suffix.lower() for spec in specs}
252
+ if ".obj" in suffixes and len(suffixes) > 1:
253
+ problems.append("caution: OBJ files are read as Y-up and rotated to Z-up, while the "
254
+ "other formats are read as stored. Fine for standard Y-up OBJ files; an "
255
+ "OBJ written in the other files' Z-up frame will be tilted 90 degrees. "
256
+ "save_mesh writes PLY, which avoids the question")
257
+ return problems
258
+
259
+
260
+ def _entry(obj) -> dict:
261
+ if isinstance(obj, AssetSpec):
262
+ defaults = AssetSpec(obj.path)
263
+ return {key: value for key, value in dataclasses.asdict(obj).items()
264
+ if key == "path" or value != getattr(defaults, key)}
265
+ unknown = set(obj) - set(SCENE_KEYS)
266
+ if unknown or "path" not in obj:
267
+ raise ValueError(f"scene entries need 'path' and only {SCENE_KEYS}; got {sorted(obj)}")
268
+ return obj
269
+
270
+
271
+ def _toml_value(value) -> str:
272
+ if isinstance(value, (bool, np.bool_)):
273
+ return "true" if value else "false"
274
+ if isinstance(value, str):
275
+ return json.dumps(value)
276
+ if isinstance(value, (list, tuple, np.ndarray)) or hasattr(value, "detach"):
277
+ return "[" + ", ".join(_toml_value(v) for v in as_numpy(value).tolist()) + "]"
278
+ if isinstance(value, (int, np.integer)):
279
+ return str(int(value))
280
+ number = float(value)
281
+ if not math.isfinite(number):
282
+ raise ValueError(f"non-finite number in scene: {value}")
283
+ return repr(number)
284
+
285
+
286
+ def _require_finite(path, data: np.ndarray) -> None:
287
+ if not np.isfinite(data).all():
288
+ raise ValueError(f"{path}: contains NaN or inf")