Skip to content

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:

  1. The plotter's camera defines the center view; its focal point becomes the plane of the physical glass.
  2. 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).
  3. 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.
  4. Views are captured at ~14° FOV (camera dollied back to compensate), matching real-world parallax at typical viewing distance.
  5. 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:

  1. 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).
  2. 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.
  3. Verify Bridge sees the device -- see the next section.
  4. Optionally install Looking Glass Studio (same downloads page), a quilt player/library app: drag any *_qs*.png / *_qs*.mp4 in 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 answers POST with 200 OK and an empty body, so a POST client 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_playlist or pause_playlist endpoint. Guessing plausible names here is a trap: Bridge answers an unrecognized endpoint the same way it answers a wrong HTTP verb -- 200 OK with an empty body -- so a wrong guess looks identical to a slow success until you check that the response has no status field. 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_playlist is documented but was unsafe in testing. bridge.js's own reference implementation of "stop" (BridgeClient.stopStudioPlaylist) calls delete_playlist then show_window(false). On this Bridge install (2.6.3, macOS), delete_playlist reliably 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_pause then show_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 where delete_playlist behaves, 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.