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 +1 -0
- glazed/__main__.py +4 -0
- glazed/cli.py +155 -0
- glazed/io.py +288 -0
- glazed/preview.html +613 -0
- glazed/preview.py +176 -0
- glazed/render/__init__.py +5 -0
- glazed/render/assets.py +148 -0
- glazed/render/blender.py +42 -0
- glazed/render/camera.py +61 -0
- glazed/render/color.py +49 -0
- glazed/render/geometry.py +55 -0
- glazed/render/output.py +166 -0
- glazed/render/pipeline.py +79 -0
- glazed/render/scene.py +58 -0
- glazed/render/studio.py +44 -0
- glazed/settings.py +55 -0
- glazed-0.1.0.dist-info/METADATA +311 -0
- glazed-0.1.0.dist-info/RECORD +23 -0
- glazed-0.1.0.dist-info/WHEEL +5 -0
- glazed-0.1.0.dist-info/entry_points.txt +2 -0
- glazed-0.1.0.dist-info/licenses/LICENSE +21 -0
- glazed-0.1.0.dist-info/top_level.txt +1 -0
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
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")
|