quiltwright 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.
@@ -0,0 +1,95 @@
1
+ """
2
+ Quiltwright — holographic output for Looking Glass displays.
3
+
4
+ Turns a rendered scene into a *quilt*: the tiled multi-view image that
5
+ lenticular light-field displays fuse into real depth. Two rendering
6
+ backends feed the same assembler:
7
+
8
+ quiltwright.lfd PyVista / VTK scenes, plus quilt geometry,
9
+ video encoding, and Looking Glass Bridge control
10
+ quiltwright.povray POV-Ray scenes, ray-traced off-axis views
11
+ quiltwright.hld Hololuminescent Displays, which play ordinary
12
+ 2-D video rather than quilts
13
+
14
+ Every view uses an off-axis (asymmetric-frustum) projection rather than a
15
+ "toe-in" rotation, which is the geometric requirement for a display to fuse
16
+ the views instead of ghosting them.
17
+
18
+ Typical usage::
19
+
20
+ import pyvista as pv
21
+ from quiltwright import QUILT_PRESETS, render_quilt, save_quilt
22
+
23
+ p = pv.Plotter(off_screen=True)
24
+ p.add_mesh(pv.ParametricTorus())
25
+ spec = QUILT_PRESETS["portrait"]
26
+ save_quilt(render_quilt(p, spec), "torus", spec)
27
+
28
+ Author: Eric G. Suchanek, PhD
29
+ """
30
+
31
+ from .hld import (
32
+ HLD_RESOLUTION,
33
+ HLD_SAFE_MARGINS,
34
+ add_floor_shadow,
35
+ apply_safe_area,
36
+ hld_orbit_speed,
37
+ render_hld_still,
38
+ render_hld_video,
39
+ style_plotter_for_hld,
40
+ )
41
+ from .lfd import (
42
+ BRIDGE_URL,
43
+ QUILT_PRESETS,
44
+ QuiltSpec,
45
+ assemble_quilt,
46
+ cast_quilt,
47
+ find_ffmpeg,
48
+ focal_distance_for_range,
49
+ pause_quilt,
50
+ render_quilt,
51
+ render_quilt_video,
52
+ resume_quilt,
53
+ save_quilt,
54
+ stop_quilt,
55
+ view_disparity,
56
+ view_offsets,
57
+ )
58
+ from .povray import PovCamera, camera_block, render_pov_quilt
59
+
60
+ __version__ = "0.1.0"
61
+ __all__ = [
62
+ # Quilt geometry
63
+ "QuiltSpec",
64
+ "QUILT_PRESETS",
65
+ "assemble_quilt",
66
+ "view_offsets",
67
+ # Depth budget
68
+ "view_disparity",
69
+ "focal_distance_for_range",
70
+ # PyVista backend
71
+ "render_quilt",
72
+ "render_quilt_video",
73
+ # POV-Ray backend
74
+ "PovCamera",
75
+ "render_pov_quilt",
76
+ "camera_block",
77
+ # Output
78
+ "save_quilt",
79
+ "find_ffmpeg",
80
+ # Looking Glass Bridge
81
+ "BRIDGE_URL",
82
+ "cast_quilt",
83
+ "pause_quilt",
84
+ "resume_quilt",
85
+ "stop_quilt",
86
+ # Hololuminescent Display
87
+ "HLD_RESOLUTION",
88
+ "HLD_SAFE_MARGINS",
89
+ "render_hld_video",
90
+ "render_hld_still",
91
+ "style_plotter_for_hld",
92
+ "apply_safe_area",
93
+ "add_floor_shadow",
94
+ "hld_orbit_speed",
95
+ ]
quiltwright/hld.py ADDED
@@ -0,0 +1,364 @@
1
+ """
2
+ Hololuminescent Display (HLD) Renderer
3
+ ======================================
4
+
5
+ Renders PyVista scenes as videos for Looking Glass *Hololuminescent
6
+ Displays* — the HLD product line (16" / 27" / 86" Portrait).
7
+
8
+ HLDs are a different technology from the classic light-field Looking Glass
9
+ devices (which consume multi-view quilts; see
10
+ :mod:`quiltwright.lfd`). An HLD is an LCD with a fixed holographic
11
+ "alcove" volume embedded in its optical stack; ordinary **flat 2-D video**
12
+ is multiply-blended into that volume. The consequences for rendering:
13
+
14
+ * **Pure white pixels are invisible** — they show only the holographic
15
+ alcove. Subjects must sit on a white background.
16
+ * The subject should be **centred inside safe-area margins** (~9% top,
17
+ 3% bottom/left/right) so it stays within the alcove.
18
+ * A **slow turntable orbit** with an otherwise static camera reads best.
19
+ * The master format is **3840x2160 (16:9 landscape) HEVC MP4**, 30 or 60
20
+ fps, bt709. HLD Author requires landscape input and handles device
21
+ orientation internally.
22
+
23
+ Spec: https://hlddocs.lookingglassfactory.com/resources/media-specs-and-encoding
24
+
25
+ Delivery: run the rendered ``*_hld.mp4`` through Looking Glass's free **HLD
26
+ Author** app, then copy the exported file to the player's USB drive. For
27
+ signage players (BrightSign/Yodeck) or direct HDMI, use the master as-is.
28
+
29
+ Typical usage::
30
+
31
+ import pyvista as pv
32
+ from quiltwright.hld import render_hld_video, style_plotter_for_hld
33
+
34
+ p = pv.Plotter(off_screen=True)
35
+ p.add_mesh(pv.ParametricTorus(), color="teal")
36
+ style_plotter_for_hld(p) # white bg, safe-area framing
37
+ render_hld_video(p, "torus") # -> torus_hld.mp4 (10s orbit)
38
+ p.close()
39
+
40
+ Part of Quiltwright — https://github.com/suchanek/quiltwright
41
+ Author: Eric G. Suchanek, PhD
42
+ """
43
+
44
+ from __future__ import annotations
45
+
46
+ import subprocess
47
+ import tempfile
48
+ from pathlib import Path
49
+
50
+ import numpy as np
51
+
52
+ from quiltwright.lfd import find_ffmpeg
53
+
54
+ try:
55
+ import pyvista as pv # noqa: F401
56
+
57
+ _PYVISTA_AVAILABLE = True
58
+ except ImportError:
59
+ _PYVISTA_AVAILABLE = False
60
+
61
+
62
+ def _require_pyvista(fn_name: str) -> None:
63
+ """Raise a clear ImportError if pyvista is not installed."""
64
+ if not _PYVISTA_AVAILABLE:
65
+ raise ImportError(
66
+ f"{fn_name}() requires pyvista.\nInstall with: poetry install --with viz"
67
+ )
68
+
69
+
70
+ #: Master render resolution — 4K landscape (16:9) as required by HLD Author.
71
+ #: One master serves all HLD sizes; players downscale for the 16".
72
+ HLD_RESOLUTION: tuple[int, int] = (3840, 2160)
73
+
74
+ #: Safe-area margins from the official content guidelines, as fractions of
75
+ #: frame size: (top, bottom, left, right). The larger top margin keeps the
76
+ #: subject clear of the alcove roof.
77
+ HLD_SAFE_MARGINS: tuple[float, float, float, float] = (0.09, 0.03, 0.03, 0.03)
78
+
79
+
80
+ def apply_safe_area(camera, margins: tuple[float, float, float, float] = HLD_SAFE_MARGINS) -> None:
81
+ """Frame the current camera view inside the HLD safe area.
82
+
83
+ Assumes the camera currently frames the subject to the full viewport
84
+ (e.g. after ``reset_camera()``). Zooms out so the subject fits the
85
+ safe-area box and shifts the projection window so the box's centre
86
+ (slightly below frame centre, because the top margin is larger) holds
87
+ the subject.
88
+
89
+ :param camera: ``pv.Camera`` / vtkCamera to mutate.
90
+ :param margins: ``(top, bottom, left, right)`` fractions of frame size.
91
+ """
92
+ top, bottom, left, right = margins
93
+ fit = min(1.0 - left - right, 1.0 - top - bottom)
94
+ camera.Zoom(fit)
95
+ # WindowCenter shifts the frustum in NDC (half-extent = 1): moving the
96
+ # frustum up by (top - bottom) moves the rendered subject down to the
97
+ # safe-area centre.
98
+ wcx = camera.GetWindowCenter()[0] + (left - right)
99
+ wcy = camera.GetWindowCenter()[1] + (top - bottom)
100
+ camera.SetWindowCenter(wcx, wcy)
101
+
102
+
103
+ def style_plotter_for_hld(
104
+ plotter,
105
+ *,
106
+ safe_area: bool = True,
107
+ zoom: float = 1.0,
108
+ resolution: tuple[int, int] = HLD_RESOLUTION,
109
+ ) -> None:
110
+ """Apply HLD content rules to a composed plotter.
111
+
112
+ Sets the pure-white background (white = transparent on the device),
113
+ switches the window to the 16:9 master resolution (3840×2160), refits
114
+ the camera to the scene at that aspect, applies safe-area margins, then
115
+ applies an optional *zoom* factor so subjects fill the frame.
116
+
117
+ :param plotter: ``pv.Plotter`` with the scene composed.
118
+ :param safe_area: Apply :func:`apply_safe_area` framing.
119
+ :param zoom: Extra zoom applied after safe-area framing. Values > 1
120
+ scale the subject up to fill more of the frame; 1.0 = no extra zoom.
121
+ Useful for portrait-shaped subjects (brains, bodies) that appear
122
+ small inside a 16:9 landscape frame after ``reset_camera()``.
123
+ :param resolution: Render ``(width, height)``; default 3840×2160.
124
+ """
125
+ _require_pyvista("style_plotter_for_hld")
126
+ plotter.set_background("white")
127
+ plotter.window_size = resolution
128
+ plotter.render() # apply window size so reset_camera sees 16:9
129
+ if not plotter.camera.is_set:
130
+ plotter.camera_position = plotter.renderer.get_default_cam_pos()
131
+ plotter.reset_camera()
132
+ if safe_area:
133
+ apply_safe_area(plotter.camera)
134
+ if zoom != 1.0:
135
+ plotter.camera.Zoom(zoom)
136
+
137
+
138
+ def render_hld_still(
139
+ plotter,
140
+ out_stem: str | Path,
141
+ *,
142
+ resolution: tuple[int, int] = HLD_RESOLUTION,
143
+ ) -> Path:
144
+ """Render a single HLD-ready PNG at the current camera position.
145
+
146
+ Same white-background, safe-area framing as :func:`render_hld_video` but
147
+ outputs one ``*_hld.png`` instead of a video. Useful for previews or
148
+ signage systems that accept still images.
149
+
150
+ :param plotter: An *off-screen* ``pv.Plotter`` with the scene composed and
151
+ already styled via :func:`style_plotter_for_hld`.
152
+ :param out_stem: Output path; ``_hld.png`` is appended.
153
+ :param resolution: Render ``(width, height)``; default 3840×2160.
154
+ :return: Path of the PNG written.
155
+ """
156
+ _require_pyvista("render_hld_still")
157
+ try:
158
+ from PIL import Image
159
+ except ImportError as exc:
160
+ raise ImportError(
161
+ "render_hld_still() requires pillow.\nInstall with: poetry install --with viz"
162
+ ) from exc
163
+
164
+ out_stem = Path(out_stem)
165
+ if out_stem.suffix.lower() in (".png", ".jpg", ".jpeg"):
166
+ out_stem = out_stem.with_suffix("")
167
+ out_path = out_stem.parent / f"{out_stem.name}_hld.png"
168
+ out_path.parent.mkdir(parents=True, exist_ok=True)
169
+
170
+ plotter.window_size = resolution
171
+ plotter.renderer.reset_camera_clipping_range()
172
+ plotter.render()
173
+ img = plotter.screenshot(None, return_img=True)[..., :3]
174
+ Image.fromarray(img).save(out_path)
175
+ return out_path
176
+
177
+
178
+ def _hld_encode_args(fps: int, crf: int) -> list[str]:
179
+ """ffmpeg output arguments per the official HLD media spec.
180
+
181
+ HEVC in MP4, yuv420p, bt709 tagging. *fps* is constrained to the
182
+ spec's 30/60.
183
+ """
184
+ if fps not in (30, 60):
185
+ raise ValueError(f"HLD spec requires 30 or 60 fps, got {fps}")
186
+ return [
187
+ "-vcodec",
188
+ "libx265",
189
+ "-crf",
190
+ str(crf),
191
+ "-pix_fmt",
192
+ "yuv420p",
193
+ "-color_primaries",
194
+ "bt709",
195
+ "-color_trc",
196
+ "bt709",
197
+ "-colorspace",
198
+ "bt709",
199
+ "-tag:v",
200
+ "hvc1",
201
+ ]
202
+
203
+
204
+ def render_hld_video(
205
+ plotter,
206
+ out_stem: str | Path,
207
+ *,
208
+ n_frames: int = 300,
209
+ fps: int = 30,
210
+ orbit_degrees: float = 360.0,
211
+ resolution: tuple[int, int] = HLD_RESOLUTION,
212
+ crf: int = 18,
213
+ rotate_for_player: bool = False,
214
+ on_frame=None,
215
+ progress: bool = True,
216
+ ) -> Path:
217
+ """Render a turntable HLD master video of the plotter's scene.
218
+
219
+ One ordinary 2-D render per frame (no multi-view sweep), camera
220
+ orbiting the focal point, encoded to the official HLD master spec
221
+ (3840×2160 landscape HEVC bt709). Style the scene first with
222
+ :func:`style_plotter_for_hld` (white background is what makes the
223
+ hologram read on the device).
224
+
225
+ :param plotter: An *off-screen* ``pv.Plotter`` with the scene composed.
226
+ :param out_stem: Output path; ``_hld.mp4`` is appended.
227
+ :param n_frames: Frame count (default 300 @ 30 fps = 10 s loop).
228
+ :param fps: 30 or 60 per the HLD spec.
229
+ :param orbit_degrees: Total orbit over the clip; 360 loops seamlessly.
230
+ Pass 0 to disable the turntable (use *on_frame*).
231
+ :param resolution: Render ``(width, height)``; default 3840×2160.
232
+ :param crf: x265 quality (lower = better; 15–20 sensible).
233
+ :param rotate_for_player: Rotate 90° CCW before output. Leave ``False``
234
+ (default) for HLD Author and signage/HDMI delivery.
235
+ :param on_frame: Optional ``callback(frame_index)`` before each frame.
236
+ :param progress: Print a progress line while rendering.
237
+ :return: Path of the MP4 written.
238
+ """
239
+ _require_pyvista("render_hld_video")
240
+ ffmpeg = find_ffmpeg()
241
+
242
+ try:
243
+ from PIL import Image
244
+ except ImportError as exc:
245
+ raise ImportError(
246
+ "render_hld_video() requires pillow.\nInstall with: poetry install --with viz"
247
+ ) from exc
248
+
249
+ out_stem = Path(out_stem)
250
+ if out_stem.suffix.lower() == ".mp4":
251
+ out_stem = out_stem.with_suffix("")
252
+ out_path = out_stem.parent / f"{out_stem.name}_hld.mp4"
253
+ out_path.parent.mkdir(parents=True, exist_ok=True)
254
+
255
+ plotter.window_size = resolution
256
+ if not plotter.camera.is_set:
257
+ plotter.camera_position = plotter.renderer.get_default_cam_pos()
258
+ plotter.reset_camera()
259
+
260
+ step = orbit_degrees / n_frames if n_frames else 0.0
261
+ with tempfile.TemporaryDirectory(prefix="hld_frames_") as tmp:
262
+ for i in range(n_frames):
263
+ if on_frame is not None:
264
+ on_frame(i)
265
+ plotter.renderer.reset_camera_clipping_range()
266
+ plotter.render()
267
+ img = plotter.screenshot(None, return_img=True)[..., :3]
268
+ Image.fromarray(img).save(f"{tmp}/frame{i:05d}.png")
269
+ plotter.camera.Azimuth(step)
270
+ if progress:
271
+ print(f"\r HLD frame {i + 1}/{n_frames}", end="", flush=True)
272
+ if progress:
273
+ print()
274
+
275
+ args = _hld_encode_args(fps, crf)
276
+ if rotate_for_player:
277
+ args += ["-vf", "transpose=2"] # 90° counter-clockwise
278
+ cmd = [
279
+ ffmpeg,
280
+ "-y",
281
+ "-framerate",
282
+ str(fps),
283
+ "-i",
284
+ f"{tmp}/frame%05d.png",
285
+ *args,
286
+ str(out_path),
287
+ ]
288
+ result = subprocess.run(cmd, capture_output=True, text=True)
289
+ if result.returncode != 0:
290
+ raise RuntimeError(f"ffmpeg failed ({result.returncode}):\n{result.stderr[-2000:]}")
291
+ return out_path
292
+
293
+
294
+ def add_floor_shadow(
295
+ plotter,
296
+ subject_bounds: tuple[float, float, float, float, float, float],
297
+ *,
298
+ opacity: float = 0.25,
299
+ scale: float = 1.4,
300
+ dark_bg: bool = False,
301
+ ) -> None:
302
+ """Add a soft fake contact shadow under the subject.
303
+
304
+ The HLD guidelines call contact shadows on the alcove floor "crucial
305
+ for the 3D effect". PyVista's ray-traced shadows are unreliable with
306
+ translucent voxel clouds, so this paints a flattened grey disc just
307
+ below the subject's bounding box.
308
+
309
+ On a **white** background (HLD): the disc fades centre→light-grey,
310
+ rim→white, so the rim is invisible against the background.
311
+
312
+ On a **dark** background (interactive viewer): pass ``dark_bg=True``
313
+ to flip the ramp — centre→dark-grey, rim→black — so the shadow reads
314
+ correctly instead of appearing as a glowing white disc.
315
+
316
+ :param plotter: Active ``pv.Plotter``.
317
+ :param subject_bounds: ``(xmin, xmax, ymin, ymax, zmin, zmax)`` of the
318
+ subject (e.g. ``grid.bounds``).
319
+ :param opacity: Shadow darkness (0 = none, 1 = full grey at centre).
320
+ :param scale: Shadow radius as a fraction of the subject's half-extent;
321
+ keep it > 1 so the shadow spreads past the footprint.
322
+ :param dark_bg: If ``True``, use a dark-background–compatible ramp
323
+ (centre = dark grey, rim = black). Default ``False`` is optimised
324
+ for HLD's white background (centre = light grey, rim = white).
325
+ """
326
+ _require_pyvista("add_floor_shadow")
327
+ xmin, xmax, ymin, ymax, zmin, zmax = subject_bounds
328
+ cx, cy = (xmin + xmax) / 2.0, (ymin + ymax) / 2.0
329
+ radius = scale * max(xmax - xmin, ymax - ymin) / 2.0
330
+ drop = 0.02 * (zmax - zmin)
331
+ disc = pv.Disc(
332
+ center=(cx, cy, zmin - drop),
333
+ inner=0.0,
334
+ outer=radius,
335
+ normal=(0, 0, 1),
336
+ c_res=64,
337
+ )
338
+ pts = disc.points
339
+ r = np.linalg.norm(pts[:, :2] - np.array([cx, cy]), axis=1) / max(radius, 1e-12)
340
+ disc.point_data["shadow"] = (1.0 - np.clip(r, 0.0, 1.0)) ** 2
341
+ if dark_bg:
342
+ # "Greys_r": 0=black → 1=white.
343
+ # clim=(0, 3) maps the peak scalar (1.0) to position 0.33 → ~#555555.
344
+ # Rim scalar (0.0) stays black and blends into the dark background.
345
+ cmap, clim = "Greys_r", (0.0, 3.0)
346
+ else:
347
+ # "Greys": 0=white → 1=black. Rim (scalar=0) fades to white and
348
+ # disappears into the HLD white background.
349
+ # clim max = 1.5 maps the peak scalar to 0.67 → ~#555555 dark grey,
350
+ # clearly visible against the white HLD background.
351
+ cmap, clim = "Greys", (0.0, 1.5)
352
+ plotter.add_mesh(
353
+ disc,
354
+ scalars="shadow",
355
+ cmap=cmap,
356
+ clim=clim,
357
+ show_scalar_bar=False,
358
+ lighting=False,
359
+ )
360
+
361
+
362
+ def hld_orbit_speed(n_frames: int, fps: int) -> float:
363
+ """Degrees of rotation per second for a given clip configuration."""
364
+ return 360.0 * fps / n_frames if n_frames else 0.0