Light-Field Display (LFD) Output¶
Module: quiltwright.lfd
Downstream CLI: waverider-voxel-viz --quilt <device> (see note below)
Source: src/quiltwright/lfd.py
Quilt geometry (QuiltSpec, presets, assemble_quilt, save_quilt) lives
in quiltwright.quilt and is re-exported here. Bridge HTTP (cast_quilt)
lives in quiltwright.bridge and is re-exported here. See
architecture.md.
"A manifold you can slice with a mouse is good. A manifold floating behind glass is better."
Which Looking Glass do you have? This page covers the light-field line (Portrait, Go, 16"/27"/32"/65" LFD), which consumes multi-view quilts via Bridge/Studio. The Hololuminescent Displays (16"/27"/86" HLD) are a different technology that plays ordinary 2-D video -- see hld.md.
Concept¶
Looking Glass light-field displays are lenticular screens: they emit dozens of slightly different views of a scene across a horizontal cone, and your two eyes (plus head movement) pick up different views -- true glasses-free 3-D.
The content format is a quilt: a single PNG that tiles N renders of the
scene in a grid, view 0 at the bottom-left (leftmost camera) through the
top-right (rightmost camera). The filename suffix
_qs<cols>x<rows>a<aspect> carries the layout metadata, so Looking Glass
Studio and Bridge configure playback automatically.
quiltwright.lfd turns any PyVista scene into a quilt:
- The plotter's camera defines the center view; its focal point becomes the plane of the physical glass.
- The camera sweeps the device's view cone (35° default; the Gen3 16" landscape reports 50°) in N steps -- translating, never rotating ("toe-in" breaks the optics).
- Each view uses an off-axis (asymmetric-frustum) projection via VTK's window-center shear, so the focal plane is pixel-identical across all views -- the geometric requirement for the display to fuse them.
- Views are captured at ~14° FOV (camera dollied back to compensate), matching real-world parallax at typical viewing distance.
- Tiles are assembled and saved with the quilt filename convention.
Step 2 is the one people get wrong. The intuitive approach -- orbit the camera slightly per view, aiming each at the subject -- is "toe-in," and it shears the focal plane differently in every view. The display cannot fuse the result, and you get ghosting instead of depth.
1. Set up Bridge and the display¶
Looking Glass Bridge is the driver/daemon that talks to the hardware. One-time setup:
- Download Bridge from https://lookingglassfactory.com/software/looking-glass-bridge (macOS / Windows / Linux) and install it on the computer the display is plugged into. It runs in the background (menu-bar/tray icon).
- Connect the display. LFD panels need both cables: USB-C (data and calibration) and HDMI/DisplayPort (video). The panel also appears to the OS as an ordinary external monitor.
- Verify Bridge sees the device -- see the next section.
- Optionally install Looking Glass Studio (same downloads page), a
quilt player/library app: drag any
*_qs*.png/*_qs*.mp4in and the settings are auto-detected from the filename.
cast_quilt() / --cast then displays renders directly from Python via
Bridge's HTTP orchestration flow (enter_orchestration -> show_window ->
instance_playlist -> insert_playlist_entry -> play_playlist). Run it on the
machine the display is plugged into.
2. Ask the panel what it wants¶
Do this before your first render. Quilt specs differ between generations of the same nominal size, and the published tables lag the hardware -- the Gen3 16" landscape wants 8×6 @ 7680×4320, where older docs list 7×7 @ 5999×5999. Bridge reports the truth:
TOK=$(curl -s -X PUT -H 'Content-Type: application/json' -d '{"name":"probe"}' \
http://localhost:33334/enter_orchestration | python3 -c \
'import sys,json; print(json.load(sys.stdin)["payload"]["value"])')
curl -s -X PUT -H 'Content-Type: application/json' -d "{\"orchestration\":\"$TOK\"}" \
http://localhost:33334/available_output_devices | python3 -m json.tool
On the entry whose hwid matches your serial, read:
| Field | Use |
|---|---|
hardwareVersion |
Device + generation, e.g. 16_gen3_l |
defaultQuilt |
quiltX/quiltY (pixels) and tileX/tileY (grid) |
calibration -> viewCone |
View cone in degrees -- pass to --view-cone |
calibration -> screenW/screenH |
Native panel resolution |
Match those against the preset table below. If they disagree, trust the
device and override with --quilt-grid / --view-cone, or build a
QuiltSpec directly.
Bridge's HTTP API requires
PUT. It answersPOSTwith200 OKand an empty body, so aPOSTclient silently reads back no orchestration token and every subsequent call quietly does nothing. If a cast "succeeds" but the glass never changes, check the verb first.
Note that non-Looking-Glass monitors may also be listed (with
hardwareVersion thirdparty and an empty calibration). head_index: -1
lets Bridge pick the real device.
3. Render a quilt¶
# Gen3 16" landscape -- render and show it on the glass
waverider-voxel-viz --dataset iris --quilt 16-landscape --out iris --cast
# -> iris_qs8x6a1.77778.png (48 views, 8x6, 7680x4320)
# Looking Glass Portrait
waverider-voxel-viz --dataset iris --quilt portrait --out iris --cast
# -> iris_qs8x6a0.75.png (48 views, 8x6, 3360x3360)
# Rotating manifold: looping turntable quilt video
waverider-voxel-viz --dataset iris --quilt 16-landscape --quilt-video \
--frames 180 --fps 24 --out iris_spin
# -> iris_spin_qs8x6a1.77778.mp4 (7.5 s seamless 360-degree loop)
# More views: smoother look-around, less per-view resolution
waverider-voxel-viz --dataset iris --quilt 16-landscape --quilt-grid 11x6 --out iris
# Override the view cone (use what the device reported)
waverider-voxel-viz --dataset digits --quilt 16-landscape --view-cone 50 --out digits
# CT/MRI demo mode: brain MRI instead of a manifold fit
waverider-voxel-viz --ct-demo --ct-dataset brain --quilt portrait --out brain --cast
Drop --cast to just write the file, then drag it into Looking Glass
Studio -- the _qs...a... suffix configures playback automatically.
From Python -- any PyVista scene¶
import pyvista as pv
from quiltwright import QUILT_PRESETS, render_quilt, save_quilt, cast_quilt
p = pv.Plotter(off_screen=True)
p.add_mesh(pv.ParametricTorus(), color="teal")
spec = QUILT_PRESETS["16-landscape"]
quilt = render_quilt(p, spec, zoom=1.6) # (4320, 7680, 3) uint8
path = save_quilt(quilt, "torus", spec) # torus_qs8x6a1.77778.png
cast_quilt(path, spec) # show it on the device
p.close()
From the manifold pipeline¶
from waverider import build_grid, fit_and_observe, render_quilt_single, voxelize
_, _, pf, pca_info = fit_and_observe(X, y)
grid = build_grid(voxelize(pf))
render_quilt_single(grid, pf, scalar="curvature", out_path="curvature",
device="16-landscape", pca_info=pca_info, cast=True)
# Rotating version (MP4 turntable)
render_quilt_single(grid, pf, scalar="curvature", out_path="curvature_spin",
device="16-landscape", pca_info=pca_info,
video=True, n_frames=180, fps=24)
From the CT/MRI demo mode¶
from waverider.voxel_viz import render_ct_quilt
render_ct_quilt(ct_dataset="brain", out_path="brain",
device="16-landscape", cast=True)
# Rotating version (MP4 turntable)
render_ct_quilt(ct_dataset="full_head", out_path="head_spin",
device="16-landscape", video=True, n_frames=180, fps=24)
Animated holograms beyond turntables¶
render_quilt_video() accepts an on_frame(i) callback that runs before
each frame -- mutate the scene (advance a time step, move a slice plane,
update scalars) for arbitrary animation:
from quiltwright import QUILT_PRESETS, render_quilt_video
render_quilt_video(plotter, QUILT_PRESETS["16-landscape"], "evolving",
n_frames=240, fps=24, orbit_degrees=0.0,
on_frame=lambda i: advance_simulation(plotter, i))
Video encoding needs ffmpeg on the PATH, or pip install imageio-ffmpeg
for a bundled binary. Encoding follows the official quilt-video spec:
MP4 + yuv420p, H.264 for quilts up to 6000 px on the longest side and
HEVC above that (so the 16" landscape's 7680 px quilt encodes as HEVC), and
the same _qs...a... filename convention.
Note that a quilt video renders n_frames × n_views images -- a 180-frame
turntable on a 48-view device is 8 640 renders. Preview with --frames 30
before committing to a long clip.
4. Control playback¶
cast_quilt() starts playback; three more calls cover the rest of a normal
session -- pause, resume, and stop (which pauses and hides the window,
leaving the playlist in place so the next cast_quilt() replaces it
cleanly):
from quiltwright import cast_quilt, pause_quilt, resume_quilt, stop_quilt
cast_quilt(path, spec) # play (creates/replaces the "quiltwright" playlist)
pause_quilt() # freeze the current frame
resume_quilt() # continue from where it paused
stop_quilt() # pause + hide the display window
Or via curl, once you have an orchestration token (see the probe in
step 2):
curl -s -X PUT -H 'Content-Type: application/json' \
-d "{\"orchestration\":\"$TOK\"}" \
http://localhost:33334/transport_control_pause
curl -s -X PUT -H 'Content-Type: application/json' \
-d "{\"orchestration\":\"$TOK\"}" \
http://localhost:33334/transport_control_play
There is no
stop_playlistorpause_playlistendpoint. Guessing plausible names here is a trap: Bridge answers an unrecognized endpoint the same way it answers a wrong HTTP verb --200 OKwith an empty body -- so a wrong guess looks identical to a slow success until you check that the response has nostatusfield. The real control group is transport control (transport_control_play/_pause/_next/_previous/_seek_to_index). None of this is in Bridge's public docs; it's confirmed against the endpoint list in the official bridge.js SDK source (src/library/components/endpoints.ts), the JS client for this same HTTP API.
delete_playlistis documented but was unsafe in testing. bridge.js's own reference implementation of "stop" (BridgeClient.stopStudioPlaylist) callsdelete_playlistthenshow_window(false). On this Bridge install (2.6.3, macOS),delete_playlistreliably left the daemon unresponsive to every further HTTP call -- reproduced twice, once mid-video and once on a single still image, so it wasn't a large-file decode race.stop_quilt()avoids it entirely:transport_control_pausethenshow_window(false), both individually proven safe, reaching the same visible end state (nothing showing, playback halted) without deleting anything. If you want the playlist actually removed and are on a Bridge version wheredelete_playlistbehaves, call it directly via Bridge's HTTP API -- just be ready to restart Bridge if it doesn't return.
| Action | Endpoint | Bridge request body |
|---|---|---|
| Play / cast | play_playlist |
{orchestration, name, head_index} |
| Pause | transport_control_pause |
{orchestration} |
| Resume | transport_control_play |
{orchestration} |
| Next / previous entry | transport_control_next / _previous |
{orchestration} |
| Seek to entry | transport_control_seek_to_index |
{orchestration, index} |
| Hide/show window | show_window |
{orchestration, show_window, head_index} |
| Delete playlist | delete_playlist |
{orchestration, name, loop} -- hung Bridge in testing, see above |
Device presets¶
QUILT_PRESETS follows the official ideal-quilt table
(https://lfdocs.lookingglassfactory.com/keyconcepts/quilts), with
16-landscape corrected against a physical Gen3 panel:
| Key | Device | Quilt | Grid | Views | View aspect | Cone |
|---|---|---|---|---|---|---|
portrait |
Portrait | 3360×3360 | 8×6 | 48 | 0.75 | 35° |
go |
Go | 4092×4092 | 11×6 | 66 | 0.5625 | 35° |
16-landscape |
16" landscape (Gen3) | 7680×4320 | 8×6 | 48 | 1.77778 | 50° |
16-portrait |
16" portrait | 5995×6000 | 11×6 | 66 | 0.5625 | 35° |
27-landscape |
27" landscape | 7680×4320 | 8×6 | 48 | 1.777 | 35° |
27-portrait |
27" portrait | 7680×4320 | 12×4 | 48 | 0.5625 | 35° |
32-landscape |
32" landscape | 8190×8190 | 7×7 | 49 | 1.777 | 35° |
32-portrait |
32" portrait | 8184×8184 | 11×6 | 66 | 0.5625 | 35° |
65 |
65" | 8192×8192 | 8×9 | 72 | 1.777 | 35° |
Only 16-landscape has been verified against physical hardware; the rest
come from the published table. Confirm yours with the probe above before
trusting a row.
Some presets store views anamorphically (tile pixel aspect ≠ view aspect). The 16" landscape is one: 7680×4320 in an 8×6 grid gives 960×720 tiles (4:3) holding 16:9 views. The renderer captures each view at the declared aspect and resamples into the tile, so geometry is never distorted.
Custom layouts are just a dataclass away:
from quiltwright import QuiltSpec
spec = QuiltSpec(columns=8, rows=6, quilt_width=7680, quilt_height=4320,
aspect=1.77778, view_cone=50.0)
View sweeps -- when the consumer is not a panel¶
A quilt's view count is columns × rows, so a rectangular grid cannot
express a prime count. That is a real constraint rather than a curiosity: the
LitiHolo desktop 3D hologram printer's published input specification asks for
23 viewzone images per hogel, and 23 is prime. There is no grid.
A single row expresses any count at all, and sweep_spec() builds one:
from quiltwright import sweep_spec
spec = sweep_spec(n_views=23, view_cone=45.0, tile_width=1600, tile_height=2000)
spec.n_views # 23
The camera sweep is identical to a quilt's -- the same off-axis frusta, the
same cone, the same focal plane on the camera's aim point. Only the packing
differs, and consumers other than a light-field panel (hologram printers,
lenticular interlacers) want the views as separate frames anyway. Pair it with
render_pov_views() from povray.md, which writes them out
individually instead of tiling them.
LITIHOLO_SWEEP is that spec, preset:
| Field | Value |
|---|---|
| Views | 23 |
| View cone | 45°, lateral, horizontal parallax only |
| Per-view pixels | 1600×2000 (aspect 0.8) |
The view count and the cone come from the printer's published specification. The per-view pixel size does not -- it is not published. 1600×2000 is a deliberate over-estimate: it comfortably exceeds the ~102×127 hogel grid of a 4×5-inch plate at 1 mm hogels, and downsampling is cheap where re-rendering is not. Aspect 0.8 is that plate in portrait; transpose for landscape. If you use this preset, you are inheriting a guess on that one field and should size it yourself once you know the real figure.
What this does and does not establish¶
Nothing produced this way has been through the printer's software. The claim quiltwright supports is that it emits a sweep matching the published specification. That is not the same as "compatible with the LitiHolo printer", and two open questions sit between them:
- Off-axis or toe-in? Quiltwright sweeps with off-axis sheared frusta, which is unambiguously correct for a lenticular panel -- it is the whole point of the Concept section above. But the 2003 hologram submission of one of these same scenes used a circular arc with the aim point pinned to the subject, which is toe-in. These are not interchangeable, and which one a hogel slicer expects is unknown.
- Is 23 views over 45° too coarse? It works out to 2.05° between adjacent views (45° over 22 intervals), against 0.74° for a Looking Glass Portrait quilt (35° over 47) -- about 2.75× coarser. On a lenticular panel, that much would step visibly as you moved rather than glide. Whether a hogel-based recording is more forgiving is genuinely unknown.
Because of the second point, run format_depth_budget() before rendering a
sweep rather than after. Coarse angular sampling and a generous depth budget
compound, and a sweep has less margin than a quilt, not more.
Framing is a depth budget¶
Before committing to a 48-view render, ask what the disparity will be:
import math
from quiltwright import QUILT_PRESETS, depth_report
spec = QUILT_PRESETS["16-landscape"]
print(depth_report(plotter, spec, fov=14.0, zoom=1.6,
extra_depths={"sky": math.inf}))
focal plane 18.4 units
view cone 50.0 deg over 48 views
adjacent-view disparity:
nearest geometry 16.4 7.19 px <- soft
focal plane (display surface) 18.4 0.00 px
farthest geometry 20.4 5.76 px <- soft
Roughly 4-5 px is the practical ceiling; past ~8 px expect visible ghosting. Content at the focal plane has zero disparity by definition, which is why the focal point belongs in the middle of the subject.
Pass the same fov and zoom you will pass to render_quilt().
render_quilt() narrows the FOV and dollies back before it sweeps, so a
budget measured from the plotter as-composed is computed at the wrong FOV
and the wrong focal distance -- it describes a picture you are not about to
make. depth_report() models that reframing; reading the camera yourself
does not. Use scene_depths() if you want the numbers rather than the
report. Neither touches the plotter.
For POV-Ray (or Cycles) scenes the equivalent is format_depth_budget(),
which takes any camera with fov and focal_distance -- a PovCamera, a
CyclesCamera, or the same two-field lens depth_report builds for
PyVista.
Framing a tilted view¶
reset_camera() fits the un-tilted bounds, so as soon as the view is
tilted -- an orbit, or an explicit camera_position -- that framing is too
loose and the subject reads as small with a lot of empty margin. Ask for a
mountain hologram and get a speck. frame_and_focus() re-fits at the final
view direction and focuses in one call:
from quiltwright import frame_and_focus, render_quilt
near, far, focal = frame_and_focus(plotter, fov=14.0) # camera now locked
quilt = render_quilt(plotter, spec, fov=None) # do not reframe
It projects the eight bounding-box corners onto the camera's own
right/up/forward axes, which accounts for foreshortening -- a flat,
elongated terrain viewed obliquely needs far less distance than its bounding
sphere would suggest -- and puts the focal plane at the harmonic mean of
the resulting depths. Unlike scene_depths() it modifies the camera:
position, view angle and focal point are all overwritten, so pass
fov=None to render_quilt() afterwards or it will frame the scene a
second time from scratch. The Cycles counterpart, for a mesh rather than a
plotter, is frame_camera().
Perceived depth scales with how much of each view the subject fills. A volume occupying a third of the frame delivers roughly a third of the parallax the panel can show, and wastes most of the per-view resolution -- each view is only a fraction of the quilt.
PyVista's default framing leaves a lot of empty space, so --quilt-zoom
defaults to 1.6. On the iris manifold, going from PyVista's framing to
the default zoom moved subject coverage from 35% to 85% of frame width and
raised the mean difference between the extreme views from 15.1 to 51.0.
This dollies the camera rather than narrowing the view angle. That matters: pulling in preserves the ~14° FOV the parallax geometry assumes and keeps the focal plane on the glass, whereas a zoom that shrinks the view angle changes the frustum the whole cone was built around.
# Default framing (zoom 1.6)
waverider-voxel-viz --dataset iris --quilt 16-landscape --out iris --cast
# PyVista's own framing, with the scale bar restored
waverider-voxel-viz --dataset iris --quilt 16-landscape --out iris \
--quilt-zoom 1.0 --quilt-scalar-bar
The color scale bar is off by default for quilts. Any 2-D overlay
renders identically in every view, which pins it to the focal plane -- it
reads as a flat pane cutting through the hologram rather than a label. It
stays legible, so --quilt-scalar-bar is there when you need the values
more than the depth.
How many views?¶
The presets follow each device's factory-calibrated ideal. The lenticular driver interpolates between quilt views, so:
- More views (
--quilt-grid 11x6= 66) -> smoother look-around and less ghosting at the cone edges, but each view gets fewer pixels (the quilt's total pixel budget is fixed per device). - Fewer views -> sharper individual views, more visible "jumping" as you move your head.
The factory default is the sweet spot for the panel's optics; go denser only if you notice stepping artifacts in deep scenes.
Composition tips¶
- Center the most important structure at the camera's focal point -- it sits at the glass surface and stays sharpest.
- Depth budget is asymmetric: content can recede far behind the glass, but pop-out in front degrades quickly.
- Keep 2-D overlays (scalar bars, titles) to a minimum -- see above.
- For turntable videos, 360° over 6-10 s (
--frames 180 --fps 24) reads well; faster spins fight the depth effect.
Troubleshooting¶
| Symptom | Likely cause |
|---|---|
--cast reports success, glass unchanged |
Bridge not running, or a client using POST instead of PUT. cast_quilt() now raises RuntimeError on an empty orchestration token rather than failing silently. |
RuntimeError: ... returned no orchestration token |
Bridge is reachable but rejected the handshake. Check it's ≥ 2.2 and that the display is connected. |
| Connection refused on :33334 | Bridge isn't running, or you're not on the machine the display is plugged into. cast_quilt() takes a bridge_url if it lives elsewhere. |
| Ghosting instead of depth | Quilt tiling doesn't match what the device expects. Re-check defaultQuilt against your preset -- a quilt read with the wrong grid mixes views. |
| Shallow or absent parallax | Subject too small in frame (raise --quilt-zoom), or view cone set below the panel's (--view-cone). |
| Depth reads but the scene looks flat and papery | 2-D overlays sitting on the focal plane; drop --quilt-scalar-bar. |
To inspect a quilt without the hardware, open the PNG and check the tile count, that no tile is blank, and that the first and last tiles differ -- if extreme views are identical, the camera sweep didn't happen.