Skip to main content

Luxar

CI License Python 3.12+ PyPI npm Docs Live demos

Luxar: n-dimensional scientific data, compiled and explored in the browser. Points, lines, Gaussian splats, meshes.

Compile n-dimensional scientific data once in Python. Explore gigabytes of it in any browser.

Luxar turns a scene you describe in Python (points, lines, triangle meshes, and image volumes fitted as Gaussian splats) into a chunked, spatially indexed Zarr archive that any static file host can serve and any WebGL2 browser can stream. Time, channel, or any other axis is a real dimension of the scene, not a folder of frames. It is built for microscopists with gigabyte volumes and time-lapses, and for anyone with more points, tracks, or dimensions than a desktop viewer will hold.

Drosophila gastrulation: 500 light-sheet timepoints fitted as Gaussian splats, streamed and played in the Luxar viewer
A Drosophila embryo through gastrulation: 500 light-sheet timepoints, 256K Gaussian splats each, streamed from static hosting and played in the browser. Open it live. Recording by the Keller lab, HHMI Janelia.

Three ideas carry most of the design:

  • Compile, don't load. Ordering, chunking, compression, and levels of detail happen once, at compile time. The viewer fetches only the chunks the current view touches and starts drawing while the rest arrives.
  • n dimensions are first class. Time, channel, camera, or an abstract axis is a dimension with units and extent. Hidden axes become sliders and toggles, and geometry is sliced by nD proximity.
  • Represent, don't rasterize. An image volume is fitted with sparse oriented Gaussians and shipped as geometry rather than voxels: a 3.6 GB light-sheet stack becomes 2 MB, a 400-timepoint time-lapse 81 MB.

demos.luxarviewer.dev hosts 88 live demos with nothing to install. Already have a scene? Open it in the hosted viewer at https://luxarviewer.dev/?src=<url>. To build your own, start at Quick start; if your data is an image volume, start at Volume rendering.

How it works · Quick start · Your first scene · Gallery · Volume rendering · Geometry · Scene graph · nD data · Sharing · Viewer · Format and architecture · Develop · Docs · Cite


How it works

Everything expensive happens before anyone opens a browser. The compiler orders each node along a space-filling curve (a mesh has none: it loads whole), cuts it into chunks of about 64 KB, compresses them, writes an nD spatial index and levels of detail, and, for image volumes, runs the Gaussian fit. The viewer's job is reduced to fetching the chunks a view needs and drawing them, so what remains at exploration time is bounded by your graphics card, your screen, and your network link rather than by the size or format of the file.

How Luxar works: describe a scene in Python, compile it once into a chunked, indexed .luxar.zarr archive, host the archive on any static file server, explore it in any browser

The four stages, left to right: describe the scene in Python, compile it once, host the archive as static files, explore it in any browser.

Capability What you get
Four geometries Points, lines, Gaussian splats, and triangle meshes share one attribute model: nD positions, colors, and opacity, plus a per-element size for all but meshes
n dimensions Named axes with physical units, radius-based slicing, keyboard navigation, per-axis transforms
Scale Interactive frame rates up to about 10 million primitives, with levels of detail and progressive streaming from local files or remote hosts
Volume rendering Image volumes fitted to oriented Gaussians: gigabytes of voxels become megabytes of streamable, GPU-native geometry, time-lapses included
Appearance HDR 16-bit float color, bloom and tone mapping, matplotlib and colorcet colormaps, six blending modes from additive to physically based emission and absorption
Fitting pipeline cal → fit → lod with cross-validated splat budgets, optional CUDA or Apple MPS, whole-time-lapse fitting across many GPUs or a Slurm cluster
Interoperable Reads classical 3D-Gaussian-splatting captures (INRIA, .splat, .spz, SuperSplat, PlayCanvas SOG); writes INRIA PLY
Shareable Any static host serves a scene; luxar export writes an offline folder or a native macOS or Linux app that opens with no install
Requirement Notes
Python 3.12 or newer
Operating system macOS and Linux are developed and tested on; Windows is untested
Browser WebGL2 (Chromium, Firefox, and WebKit engines are smoke-tested; see Using the viewer)
GPU Not needed to view. Optional for fitting splats: NVIDIA CUDA or Apple MPS make it much faster than the CPU path
Node.js 22 Only to develop the viewer; users never need it

Scope. Luxar does not ray-march voxels: volumes are fitted, and the fit is lossy in a measured way. It is a visualization system, not an annotation or segmentation tool, and it needs a browser with WebGL2. Fitting a large time-lapse wants a GPU; viewing one does not.

Status. Luxar is versioned by calendar date and released from main; what stays stable across releases and how breaking changes are announced is in the compatibility policy, and every change is in the changelog.


Quick start

pip install luxar       # viewer, compiler and CLI; add "luxar[gsplats]" to fit volumes
luxar demo              # Browse the 90 bundled demos
luxar demo run cloud    # Run one: generates the data and opens the viewer

Some demos need extra packages; luxar demo deps --install fetches what a demo reports missing. To work on Luxar itself, or to run from a source checkout:

git clone https://github.com/royerlab/luxar.git
cd luxar
make setup-dev          # Auto-installs Node.js, pnpm, Hatch (no sudo)
hatch shell             # Activate the environment setup-dev created; then the same commands

luxar demo run cloud builds a convective cloud, a 4D point cloud that evolves over a time axis, and opens it in the viewer, where the time slider plays it back:

Quick start recording: pip install luxar and luxar demo run cloud typed in a terminal, then the evolving cloud playing in the Luxar viewer
The whole thing in one take: pip install luxar, then luxar demo run cloud.

Where to go next depends on your data. An image volume or a time-lapse of volumes: Volume rendering. Points, tracks, or embeddings: Your first scene. A mesh file: luxar mesh import under Geometry types. A 3D-Gaussian-splatting capture: luxar gsplat import under Volume rendering.

The demo catalog and the luxar demo commands

luxar demo prints every bundled demo, grouped by category: index, key, geometry, what it needs, and whether you have already built it.

🎬 90 Luxar demos  ·  75 built  ·  8 cached  ·  7 not generated yet

 ASTRONOMY ──────────────────────────────────────────────────────────── 6 demos
 ✓  2  asteroids_solar_system                  points+lines  300 MB
 ✓ 12  cosmicflows_laniakea                    points+lines  25 MB
 • 15  desi_galaxies                           points        73 MB
 ...

 MEDICAL ────────────────────────────────────────────────────────────── 4 demos
 ✓ 17  dmri_tractography                       lines         588 MB
 • 29  gsplats_2d_cmu1_pathology               gsplats       150 MB GPU?
 ...

 SYNTHETIC ─────────────────────────────────────────────────────────── 18 demos
 ✓ 10  cloud                                   points
   11  collision                               points
 ...
 ✓ 22  exotic_surfaces                         points

 ✓ built   • inputs cached   (blank) not generated yet
 GPU/GPU? = required/optional     git-lfs kaggle manual = data you supply

 Run      luxar demo run <key|#>                e.g. luxar demo run cloud
 Details  luxar demo info <key|#>
 Filter   luxar demo list -c astronomy -g gsplats
 Deps     luxar demo deps                       --install fixes what it reports
 Caches   luxar demo cache list                 cache clear <key> to reclaim
 Stop     luxar demo stop                       frees ports of forgotten runs

The left rail is what you already have: ✓ the scene is built, • the inputs are downloaded, blank means neither, so a second pass over the catalog shows exactly what is on disk. The last column is what a demo costs you before you commit to it: the download size, GPU (required) or GPU? (optional), plus git-lfs, kaggle, or manual when it needs Git-LFS data, Kaggle credentials, or a file you supply.

The luxar demo commands

Command What it does
luxar demo Browse the catalog (same as luxar demo list)
luxar demo list -c microscopy Filter by category: synthetic, microscopy, embeddings, photogrammetry, astronomy, structural, networks, medical, geoscience, genomics, connectome
luxar demo list -g gsplats Filter by geometry: points, gsplats, lines, mesh, points+lines, mixed
luxar demo info <key|#> Requirements, caches, outputs, and how to run one demo
luxar demo run <key|#> Run by key (e.g. luxar demo run lorenz) or by the index shown in the table — keys are stable, indices shift as demos are added
luxar demo run <key> -- ARGS Forward arguments to the demo script, e.g. luxar demo run gsplats_3d_tribolium_embryo -- --recompute --no-serve
luxar demo run-all Build every eligible demo's dataset unattended. Manual/Kaggle-data demos are always skipped; GPU-required demos, downloads over 200 MB, and already-built outputs are skipped by default (--include-gpu, --max-download-mb 0, --force lift these)
luxar demo cache list Inventory the demo caches under ~/.cache/luxar/, with sizes and orphans
luxar demo cache clear <keys> Reclaim space — --all for everything, --outputs to drop generated scenes too, --dry-run to preview

Demos run as subprocesses, so Ctrl-C tears down the demo and the viewer it spawned. Every tile in the Gallery is one of these demos; pick a key from luxar demo and luxar demo run <key> reproduces it locally.


Your first scene

Define a coordinate system, add geometry, and the compiler writes a .luxar.zarr archive:

import numpy as np
from luxar import LuxarZarrCompiler, Dimensions, Dimension

# Define coordinate system
dims = Dimensions([
    Dimension("x", unit="um", display=True),
    Dimension("y", unit="um", display=True),
    Dimension("z", unit="um", display=True),
])

# Create and save visualization
with LuxarZarrCompiler("my_data.luxar.zarr") as compiler:
    scene = compiler.create_scene(dimensions=dims)

    # Your data as NumPy arrays
    positions = np.random.randn(100_000, 3).astype(np.float32) * 50
    colors = np.random.rand(100_000, 3).astype(np.float32)

    scene.add_points("MyPoints", positions, colors=colors)
luxar serve my_data.luxar.zarr --viewer --open   # data server + viewer, opens the browser
luxar info my_data.luxar.zarr --stats            # what you compiled, including the chunk layout
luxar serve my_data.luxar.zarr --profile 3g --viewer   # how it behaves on a slow link
luxar export my_data.luxar.zarr -o my_export/    # a folder a colleague opens without installing Luxar

(From a source checkout, luxar export needs make build-viewer once.) To publish the scene as a link, see Sharing and hosting a scene.


A cross-section of the bundled demos, all four geometry types across real scientific datasets. Each tile is an orbit preview: click the image for the video, or the title to open that scene live at demos.luxarviewer.dev. Each data tile carries a short credit; full citations and licenses are in ACKNOWLEDGMENTS.md. Tiles with no credit are synthetic.

Gaussian splats: microscopy, medical, and astronomy

Cells3D — multichannel fluorescence Mouse Blastocyst — multichannel nuclei Zebrafish Neuromast — 4D timelapse
Cells3D
multichannel fluorescence
Allen Institute for Cell Science
Mouse Blastocyst
Lamin B1 + DAPI
Blin et al. 2019
Zebrafish Neuromast
4D timelapse
Jacobo lab, CZ Biohub
Tribolium Embryo — light-sheet CT Anatomy Atlas — TotalSegmentator Milky Way Dust — galactic dust clouds
Tribolium Embryo
light-sheet
Barry et al. 2022
CT Anatomy Atlas
TotalSegmentator
Wasserthal et al. 2023
Milky Way Dust
galactic dust clouds
Leike et al. 2020

The living cell & genome

FlyWire Connectome — fly-brain neurons Single-Cell 3D Genome — Dip-C chromosomes C. elegans — 4D nuclei-tracking timelapse
FlyWire Connectome
fly-brain neurons
Dorkenwald et al. 2024
Single-Cell 3D Genome
Dip-C chromosomes
Tan et al. 2018
C. elegans
4D nuclei-tracking timelapse
Santella et al. 2022
ATP Synthase — molecular machine Tabula Sapiens — human cell atlas Human Multiome — ATAC-peak UMAP
ATP Synthase
molecular machine
Zhou et al. 2015
Tabula Sapiens
human cell atlas
Tabula Sapiens Consortium 2022
Human Multiome
ATAC-peak UMAP
Domcke et al. 2020
Human White-Matter Tractography — 87 dMRI tracts Cells3D Isosurfaces — shaded triangle meshes
White-Matter Tractography
87 human dMRI tracts
Yeh 2022
Cells3D Isosurfaces
shaded membrane + nuclei surfaces
Allen Institute for Cell Science

Earth & geoscience

Global Earthquakes — USGS on the globe Rivers of Earth — topography + river networks
Global Earthquakes
USGS on the globe
USGS catalog; NASA Blue Marble
Rivers of Earth
topography + river networks
HydroSHEDS + NOAA NCEI

Networks & embeddings

CAIDA — Internet AS topology HuRI — human interactome Protein Landscape — CAFA5 embeddings
CAIDA
Internet AS topology
CAIDA, UC San Diego
HuRI
human interactome
Luck et al. 2020
Protein Landscape
CAFA5 embeddings
CAFA5; Elnaggar et al. 2022
Spotify — audio-feature embedding Zebrahub — multiome embedding
Spotify
audio-feature embedding
Spotify Web API
Zebrahub
multiome embedding
Kim et al. 2024

Synthetic & mathematical

Lorenz Attractor — chaotic dynamics Rainbow Sphere — HDR Fibonacci sphere Quantum Orbitals — hydrogen 2p_z
Lorenz Attractor
chaotic dynamics
Rainbow Sphere
HDR Fibonacci sphere
Quantum Orbitals
hydrogen 2p_z
Spiral Galaxy — barred multi-armed disk Galaxy Simulation — density-wave spiral Hilbert Curve — 3D space-filling
Spiral Galaxy
barred multi-armed disk
Galaxy Simulation
density-wave spiral
Hilbert Curve
3D space-filling
Particle Collision — physics event Ocean — bioluminescent jellyfish
Particle Collision
physics event
Ocean
bioluminescent jellyfish

The curated set lives in scripts/gallery/manifest.json and make generate-gallery regenerates the media. A few heavy scenes (the DESI cosmic web, the Gaia catalog) have no tile here because they need a GPU to capture or data you must fetch yourself; every tile is a bundled demo, so luxar demo run <key> reproduces it and luxar demo run-all builds every eligible one (it always skips manual/Kaggle data, and by default skips GPU-required demos, downloads over 200 MB, and already-built outputs).


Volume rendering with Gaussian splats

An image volume is a grid of voxels; a modern light-sheet timepoint is several gigabytes of them, and a time-lapse is hundreds of timepoints. Luxar does not ship that grid. It fits the volume with a sparse mixture of oriented 3D Gaussians and renders those instead, so the size of the representation follows how much structure the sample contains rather than the grid it was sampled on. Empty space costs nothing, and what is left is small enough to stream:

Dataset Source volume Fitted representation
Tribolium embryo — light-sheet, 1 timepoint 965 × 1871 × 991 = 1.8 G voxels (3.6 GB as TIFF) 296,559 splats · 2.0 MB
C. elegans embryo — confocal, 400 timepoints 400 × 41 × 512 × 512 = 4.3 G voxels 5.64M splats · 81 MB (about 200 KB per timepoint)

The C. elegans fit is fetched from the permissively licensed Zenodo demo record (see Where the demo data lives); the Tribolium fit is produced locally because its source is not redistributable. Both use full source resolution, and the single-file Tribolium fit works out to about 7 bytes per splat on disk. Both render in any WebGL2 browser: no 3D textures, no ray-marching, and no CUDA on the viewing machine.

From a light-sheet stack to splats: the raw volume in napari, luxar gsplat fit converging in a terminal with its PSNR curve, a short Python script, and the fitted embryo opening in the browser
A raw Drosophila light-sheet stack in napari, luxar gsplat fit converging (training PSNR plotted as it runs), a dozen lines of Python to compile the scene, and the result opening in the browser. Excerpts of Supplementary Video 1.

The pipeline: cal, fit, lod

# 1. Choose the splat budget K* by blind-spot cross-validation
luxar gsplat cal volume.tiff cal.json

# 2. Fit at K*
luxar gsplat fit volume.tiff fit.gsplats.zarr --seeds <K*>

# 3. Build the streaming topology
luxar gsplat lod fit.gsplats.zarr scene.gsplats.zarr --recipe stream

cal sweeps the splat count, finds where held-out PSNR peaks, and reports the dataset's noise floor, so the budget is chosen by cross-validation against the data rather than by guesswork. Inputs can be .zarr, .zarr.zip, OME-Zarr, .tiff, .npy, or .npz, with --timepoint, --channel, and --array-key to pick a slice of a larger store. luxar gsplat compare reports PSNR, SSIM, and MSE of a fit against its source.

Fitting hardware. Fitting needs the gsplats extra (pip install "luxar[gsplats]", or pip install -e ".[gsplats]" from a checkout). It runs on the CPU, and much faster on an NVIDIA GPU (make build-cuda compiles the CUDA kernels for your card; they cannot ship in the wheel) or on Apple silicon through MPS. Seeding and the optional non-local-means denoising follow the same device choice. On a cluster, make build-cuda SLURM=1 builds the extension on a GPU node; if a fit logs "GPU fitting will use slower PyTorch fallback", it still runs, just without the compiled kernels. Viewing never needs any of this.

Every option, with examples, is in the CLI package README; the fitting model and the LOD algorithms are in the Gaussian splatting README.

Scaling: pick a topology, stream the rest

luxar gsplat lod --recipe turns a fitted dataset into a level-of-detail topology. The recipes are named by intent and ordered by dataset scale:

Recipe Structure Use when
flat one bare leaf tiny data, debugging
stream one leaf + progressive ladder small data, fast first paint
levels coarse→fine replacement levels zooming across scales
tiles spatial tiles, each with its own ladder large scene at one scale
overview instant coarse overview, fine tiles on zoom huge scene, "see everything first"
adaptive tiles where every tile picks its own level largest scenes, locally adaptive

The six LOD recipes built from one Tribolium fit, shown side by side as six embryos, then up close: the levels column swapping from its coarse level to its finest as the camera approaches, the tiles column, and the adaptive column
One Tribolium fit, six topologies side by side (flat, stream, levels, tiles, overview, adaptive), every column but flat colored by the part or level it is drawn from; the data-loading monitor counts what is resident as the camera dollies in. Open the demo or watch Supplementary Video 7.

Apart from flat, every recipe carries a progressive streaming ladder by default: splats are reordered so that early prefixes carry as much of the signal as possible, so the first chunk to arrive is already a meaningful picture and later chunks refine it. Where levels replace each other, the viewer picks between them by the screen area the object occupies, a viewport fraction that needs no per-resolution tuning: the finest level shows while the object fills at least half the screen, each halving of occupied area steps one level coarser, and the partition-bound recipes (adaptive, overview) anchor one step higher.

Time-lapses are one dataset, not a folder of frames

Each timepoint is fitted in 3D and the results are stacked onto a time axis: every splat gains a time coordinate and a matching covariance entry (zero width for a discrete axis, a real extent if you want temporal spread), so a complete 4D acquisition (or 5D, adding channel or camera) lives in a single .gsplats.zarr. The viewer's time slider is ordinary nD slice navigation, and because splats are stored timepoint-major, each frame's splats are contiguous on disk: scrubbing fetches the chunks of the current frame and nothing else (at most the one chunk on a frame boundary carries a few splats of its neighbor). Coarsening treats the time and channel axes as hard barriers: coarse splats are never merged across them and mass is conserved per barrier group, so a timepoint keeps its exact brightness at every level of detail and scrubbing never smears one frame into the next.

Fitting a whole time-lapse is one command, on whatever hardware you have:

# Every GPU in the box, planned over T×C, resumable
luxar gsplat batch-fit run movie.zarr out/ --gpus auto

# Or a Slurm array job on a cluster
luxar gsplat batch-fit submit movie.zarr out/ -p gpu --tiling content --cal cal.json

Both plan tiles once across all timepoints and channels, fit each tile as an independent task, then stream-merge the results, so peak memory is one tile region, never the whole movie. status, validate, merge, and cancel are shared by both backends; --merge-recipe gives each spatial part its own LOD ladder as it streams.

Emission and absorption, not just glow

The volumetric blending mode implements the emission-absorption model of direct volume rendering (Max 1995) in closed form for Gaussians. One per-layer knob, absorption κ, morphs the render continuously:

κ Look Good for
0 pure emission, bit-identical to additive sparse fluorescence, X-ray-like projection
small attenuated projection: near structure pops, occluded structure dims depth cueing in dense time-lapses
large dense smoke- or ink-like medium opaque tissue, anatomy

Because fitted amplitudes are background-relative image intensities rather than learned opacities, κ reads as an effective turbidity of the sample instead of an arbitrary rendering constant, and absorption is orientation-consistent: an elongated splat seen end-on absorbs more than the same splat seen side-on. Points, lines, and Gaussian splats all render the same physics, on both the WebGL and the WebGPU backend.

luxar gsplat convert fit.gsplats.zarr scene.luxar.zarr \
    --blending-mode volumetric --absorption 4 --colormap plasma --tone-mapping ACES

The other modes cover the classical spectrum: additive and luminous (pure emission; additive is the default), max (maximum-intensity projection), and normal and opaque (surfaces). The derivation is in the Volumetric Blending Spec.

Photogrammetric splats, too

The same renderer reads classical 3D-Gaussian-splatting captures. luxar gsplat import auto-detects INRIA .ply, antimatter15 .splat, Niantic and Scaniverse .spz, SuperSplat compressed .ply, and PlayCanvas SOG, then feeds them through the same LOD and streaming path; the largest interop demo is a 13.6M-Gaussian aerial city. luxar gsplat export writes INRIA PLY back out.

How faithful is it

The fit is lossy, so fidelity is measured rather than asserted. Across a 17-volume microscopy benchmark (4 to 107 million voxels; spinning-disk, confocal, light-sheet, and iSIM), fits at each volume's cross-validated splat budget land between 26 and 67 dB PSNR at 6 to 340 times compression (median 99), where compression is the source volume at its stored bit depth over the stored splat archive. The benchmark, the cross-validation protocol, and the residual analysis are in the preprint (see Citation); luxar gsplat compare reports the same metrics for your own data. The Tribolium fit above is a gigavoxel source outside the benchmark's per-volume range, so its ratio exceeds that ceiling; the C. elegans figure aggregates 400 timepoints of 10.7 M voxels each.


Geometry types

Points

Collections of nD points rendered as soft-edged spheres.

scene.add_points(
    "ParticleCloud",
    positions,           # (N, D) float32 - nD coordinates
    colors=colors,       # (N, 3) float32 - RGB (0-1, HDR supported)
    radii=radii,         # (N,) float32 - per-point size
    sharpness=sharpness, # (N,) float32 - edge falloff (0-1, normalized)
    opacity=0.8,         # Global opacity
    # "additive" (default), "volumetric", "normal", "max", "opaque", "luminous"
    blending_mode="additive",
)

Lines

Connected segments with per-vertex attributes: tracks, trajectories, skeletons.

scene.add_lines(
    "Branches",
    vertices,      # (N, D) float32 - nD vertex positions
    widths=widths, # (N,) float32 - per-vertex width
    colors=colors, # (N, 3) float32 - per-vertex colors
)

Gaussian splats

Oriented Gaussian functions, Luxar's volume-rendering primitive. Fit them with the pipeline above and add the result with scene.add_gsplats_from_data(name, result) or scene.add_gsplats_from_file(name, "fit.gsplats.zarr"); embedding an existing fit needs no extra dependency.

Mesh

Triangle surfaces: isosurfaces, segmentation boundaries, cortical and organ meshes. The other three primitives are soft and emissive; a mesh is the one connected, shaded type, lit by a view-anchored key so shape reads from shading rather than density.

scene.add_mesh(
    "Nuclei",
    vertices,               # (V, D) float32 - nD vertex positions
    faces,                  # (F, 3) uint32  - triangle vertex indices
    normals=normals,        # (V, 3) float32 - optional, needs normal_dims
    normal_dims=[0, 1, 2],  # which three dims the normals describe
    colors=colors,          # (V, 3|4) - optional per-vertex RGB(A)
)

A mesh has no per-element size (its extent comes from its vertices) and renders with opaque blending by default, which makes it depth-correct without sorting. Normals are optional: omit them and the shader derives flat per-face normals; pass them and normal_dims says which three dimensions they describe. Already have a mesh file? luxar mesh import reads PLY, OBJ, STL, VTP, and glTF/GLB with no extra dependencies, and a directory of T<number>-indexed files stacks into a mesh time-lapse:

luxar mesh import bunny.ply bunny.luxar.zarr
luxar mesh import frames/ frames.luxar.zarr --pattern '*.ply'
luxar mesh lod bunny.luxar.zarr bunny_lod.luxar.zarr -L 4   # coarse levels
luxar serve bunny_lod.luxar.zarr --viewer

Meshes support spatial partitioning (partition=), decimated coarse levels (substitutive_lod=, built by luxar.mesh.decimate), and a spatially coherent reveal ladder (additive_lod={"method": "radial"}), though only one of the three per mesh; an additive ladder over an arbitrary order and volumetric blending are refused with an explanation rather than degraded silently (a volumetric inherited from a parent falls back to opaque with a warning). The reasons are in the mesh spec.


Scene graph and transforms

Nodes live in groups, groups nest, and transforms compose down the tree. A 4×4 transform moves geometry through the three displayed dimensions:

from luxar import transforms

# Basic transforms
t = transforms.translate(10, 0, 0)
r = transforms.rotate_z(45)
s = transforms.scale(2, 2, 2)

# Compose (applied left-to-right)
combined = transforms.compose(t, r, s)

# Apply to groups
group = scene.add_group("Cluster", transform=combined)
scene.add_points("Points", positions, parent=group)

# Hierarchical transforms
parent = scene.add_group("Robot")
parent.transform = transforms.translate(100, 0, 0)

arm = parent.add_group("Arm")
arm.transform = transforms.rotate_y(45)  # Relative to parent

The separate nd_transform does the same for the non-displayed dimensions, so two datasets recorded on different clocks, sampling rates, or channel orders can be aligned in one scene instead of being resampled first. Continuous and discrete axes take an affine scale and offset; categorical axes take a permutation:

group = scene.add_group(
    "DatasetB",
    transform=transforms.translate(10, 0, 0),      # displayed dims (4x4)
    nd_transform={
        "time": {"scale": 0.001, "offset": 50.0},  # ms → s, shifted
        "channel": {"permutation": [2, 1, 0]},     # reorder channels
    },
)
group.add_points("cells", positions_5d)            # children inherit it

The viewer applies these by inverse-transforming the query (slice position and tolerance) from world to local space once per view change rather than transforming millions of coordinates; see the nD Transforms Spec.


n-dimensional data

Every axis of a scene is declared once, with a unit, an extent, and whether it is displayed or navigated:

dims = Dimensions([
    # Displayed dimensions (shown in 3D viewer)
    Dimension("x", unit="um", range=(-100, 100), display=True),
    Dimension("y", unit="um", range=(-100, 100), display=True),
    Dimension("z", unit="um", range=(-50, 50), display=True),

    # Non-displayed dimensions (navigated via sliders)
    Dimension("time", unit="s", range=(0, 60), step=1.0, display=False),
    Dimension("channel", unit="", categories=["DAPI", "GFP", "mCherry"], display=False),
])

Points in nD are treated as hyperspheres: an element is visible in the current 3D slice when its hypersphere intersects it, and for axes declared spatial=True (hidden axes default to non-spatial) its effective radius shrinks with distance d from the slice as sqrt(r² - d²). Every non-displayed axis becomes a control in the viewer: a slider for continuous and discrete axes, a toggle or dropdown for categorical ones. Press 1 to 9 to select a hidden axis, [ and ] to step it, and N to open the dimension panel.

The Dimension Navigation panel playing the time axis of the C. elegans nuclei-tracking scene: a handful of nuclei become a full embryo with its lineage tracks
The Dimension Navigation panel driving the time axis of the C. elegans nuclei-tracking scene (splats for nuclei, lines for tracks): time is a hidden dimension, played like any other. Excerpt of Supplementary Video 12.


Sharing and hosting a scene

A compiled scene is a folder of static files, or a single .luxar.zarr.zip. Any host that serves files over HTTPS can publish it, with no server code to run: upload the store and share https://luxarviewer.dev/?src=<URL of the store>. The host needs two things:

  • CORS. The viewer page comes from luxarviewer.dev while the data comes from your host, so the host must send Access-Control-Allow-Origin (* is fine for public data). Without it the scene stays empty and the browser console shows requests blocked by CORS policy, not a 404.
  • Byte ranges, for zipped stores only. A .luxar.zarr.zip is read in place with Range requests, so the host must honor them, allow the Range request header, and expose Content-Range, Content-Length, Accept-Ranges, and ETag. A directory store uses plain GETs and needs only CORS.

Typical options, roughly from most to least convenient for scenes of a few GB:

  • Cloudflare R2 (what serves the demo corpus at data.luxarviewer.dev): object storage with no egress fees, a free tier of about 10 GB, public buckets, custom domains, and a CORS policy pasted in the dashboard. Reads are billed per request, so chunk size matters (see below). Watch out: a custom domain needs a Cloudflare-managed DNS zone. Check the free tier before publishing terabytes.
  • Amazon S3 / Google Cloud Storage: same static-object model; the S3 CORS JSON in the Viewer Guide applies to S3 and R2 as is, and GCS takes the equivalent through gsutil cors set. Watch out: egress is billed per GB, which is the cost that grows with popularity.
  • GitHub Pages: free, versioned, zero setup for small scenes (commit the store to a gh-pages branch). CORS and byte ranges work out of the box. Watch out: files above 100 MB are rejected and Git LFS objects are not served, so it suits scenes under a few hundred MB in total. Drop a .nojekyll file at the site root, or Jekyll strips the dotfiles a zarr-v2 store and .luxar-index.json depend on.
  • Your lab's web server (nginx, Apache): data stays on infrastructure you control; an nginx snippet is in the Viewer Guide. Watch out: you add the CORS and range headers yourself; institutional proxies sometimes strip Range.
  • No host at all: luxar export scene.luxar.zarr -o out/ writes the viewer plus a stdlib-only serve.py; from a source checkout, after make build-launchers, --native macos gives a double-clickable app (Distributing scenes). Watch out: the recipient runs it locally; nothing is shareable as a link.

An archive of record such as Zenodo is the right place to deposit a scene for citation; whether it can also serve it to the viewer depends on its CORS and range headers, so test before linking to it.

Object stores bill per request, and most generated stores land far below the 64 KB chunk target (the demo corpus averages 5 KB per file), so run luxar optimize before publishing and pick the profile by access pattern: local when the viewer will slice into a large node, hosting or archive when it loads the node whole (one demo went from 9,390 requests to 2,348 on a cold load). The re-chunked store carries a new content hash, so publish it under a new URL prefix rather than over the old one. To verify a host: curl -sI -H "Origin: https://luxarviewer.dev" <URL> should return an access-control-allow-origin header, and for a zipped store a ranged GET (curl -s -o /dev/null -D - -H "Range: bytes=0-0" <URL>) should answer 206 (a HEAD may legitimately answer 200). The full host-by-host setup, the .luxar-index.json that makes a folder of scenes browsable, and the export and native-bundle paths are in Distributing scenes, the Viewer Guide, and the Demo Site Runbook, which documents how the demo corpus itself is served.


Using the viewer

Key or input Action
Left drag, right drag Rotate, pan (the macOS default; the reverse elsewhere; swap them in the Navigation panel)
Scroll, Shift + scroll, Ctrl/Cmd + scroll Zoom, roll, field of view
V, F, Space Cycle orbit / fly / ortho, recenter, fullscreen
W A S D, Alt+W/S, arrows, Q/E, I Fly mode: move, up and down, look, roll, inertia
1 to 9, [ ], N Select a hidden dimension, step it, dimension panel
L, R, M, T, P, O, H Layers, rendering, monitor, recording, performance, dataset browser, help (Ctrl+L: debug console)
URL parameter Effect
?src=<url> Scene to open
?theme=dark|light|frosted-glass|liquid-glass Interface theme
?renderer=webgl|webgpu WebGL2 (default) or the WebGPU backend
?debug Expose window.__luxarDebug
?noCache, ?clearCache, ?noPrefetch Disable the cache tiers, clear the persistent cache, disable prefetch

If a scene stays empty, check the browser console: a CORS error means the host is refusing the data (see Sharing and hosting); a 404 means the URL is wrong. The panels, the camera modes, and every shortcut are described in the Viewer Guide.

Browser Compatibility

Luxar needs WebGL2; WebGPU is opt-in with ?renderer=webgpu and falls back to WebGL2 when no adapter is available. The build targets esnext with no browserslist, so there is no version floor to quote, only what has been run. The end-to-end smoke subset passes on Playwright's three bundled engines:

Engine Smoke subset Notes
Chromium pass Persistent (OPFS) cache tier active
Firefox pass Persistent (OPFS) cache tier active
WebKit pass Runs without the persistent cache tier, so Safari and the native launcher keep chunks in memory only (the cache panel shows the opfs-unavailable badge)

Playwright's WebKit is not Safari, so Safari and Edge themselves are untested, and real phones and tablets are supported but unmeasured; touch input (one- and two-finger orbit, pinch, twist, tap-to-pick, long-press menus) is exercised by an emulated mobile suite. The dated run and how to reproduce it are in the viewer README.


Data format, performance, and architecture

A scene is a Zarr store (format 3 by default; format 2 is read and can be written with LUXAR_ZARR_FORMAT=2) whose nodes carry their arrays, a spatial index, and optional LOD ladders:

scene.luxar.zarr/
├── zarr.json               # Scene attributes and consolidated metadata
└── node_name/
    ├── zarr.json           # Node attributes (type, transform, rendering)
    ├── positions/          # (N, D) coordinates (quantized on disk, decoded as float32)
    ├── colors/             # (N, 3) RGB values
    ├── radii/              # (N,) point sizes
    └── chunk_bounds/       # Spatial index for efficient queries

The store is specified in LUXAR_ZARR_FORMAT.md and the fitted-splat container in GSPLATS_ZARR_FORMAT.md.

Rendering cost. On an NVIDIA RTX 3070 at 1280×720, with adaptive DPR pinned to 1.0 and the primitives drawn at the reference size (4 px for points and splats, 1.5 px for lines) through the default HDR pipeline, one synchronized render-and-readback call takes (milliseconds; the 60 FPS budget is 16.7):

Elements Lines Points Gaussian splats
100K 1.21 1.23 1.72
1M 3.31 4.98 8.64
10M — 18.8 19.4

At ten million elements the call runs 12 to 16% over budget, and at that element density lowering the render resolution does not help (the cost tracks elements per pixel, not pixels), which is what the projected-density guard below is for. Typical scenes are lighter than these synthetic sweeps: the fifteen demo scenes of the same sweep all hold 60 FPS, the heaviest, a 2.2M-splat time-lapse frame, in 5.6 ms. Very large overdraw-bound scenes such as a 29.6M-splat whole-slide image are thinned by the projected-density guard (on by default). Load time is dominated by transfer and decode, so it follows your link and cache state rather than the element count. The whole-slide and Apple M4 Max measurements, and the method, are in the viewer performance audit.

Architecture. Luxar is two code bases that never import each other. The Python package authors, fits, and compiles; the TypeScript viewer streams and renders; the archive is the only contract between them, so anything that writes it feeds anything that reads it: the hosted viewer, an offline export, an embedded @luxar/viewer, a native launcher.

Luxar architecture: the Python package (scene graph, annotations, Gaussian-splat and mesh fitting, compiler and encoding, CLI, remote control, demos), the .luxar.zarr archive in the middle served by any static host, and the TypeScript viewer (LuxarApp, interface, navigation, rendering, compute, streaming, configuration, sound) plus the export folder and native launcher

The Python package on the left, the viewer on the right, and the archive between them; each row names the module that owns it.

Python side. core/ is the scene graph a user builds: nodes for the four geometry types, groups with transforms, Dimensions that declare every axis with its unit and extent, per-node appearance, and annotations (text, image, video and HTML overlays, sound, story waypoints). gsplats/ turns image volumes into that geometry: cross-validated calibration, the fitter with its CUDA and Apple-MPS kernels, tiled and multi-GPU batch fitting, and the LOD recipes. mesh/ imports and decimates surfaces. io/ is the compiler: it orders every node along a space-filling curve (a mesh has none and loads whole), chunks it to about 64 KB, writes the nD spatial index and the LOD ladders, and emits Zarr format 3 (reading both 2 and 3); encoding/ decides how each attribute is quantized and compressed. cli/ exposes all of it, control/ drives a running viewer from Python, and demos/ holds the 90 bundled demos, whose hosted data is pinned by digest and fetched on demand.

The archive. A directory of small chunk files, or a single zip read by byte range. It carries its own metadata (dimensions, node tree, chunk bounds, ladder energies), so no server code is needed: luxar serve for a laptop, a lab web server, object storage, GitHub Pages or an exported folder all serve it the same way, and the viewer opens it from a URL. The format is specified in LUXAR_ZARR_FORMAT.md; the fitted-splat container in GSPLATS_ZARR_FORMAT.md.

Viewer side. From the bottom up: data/ and cache/ are the Zarr client, the spatial queries that turn a view into chunk requests, prefetch, and four cache tiers (in-memory S-cache, L0, L1 and an OPFS-backed L2 that survives a reload). workers/ decode off the main thread and call Rust kernels in wasm/ for the nD projection, effective radii and Mahalanobis tests, with a TypeScript reference implementation for scenes above 16 dimensions. rendering/ and scene/ draw with Three.js on WebGL2 or WebGPU through an HDR pipeline with six blending modes, choosing levels of detail by projected screen area. controls/ and input/ implement orbit, fly and ortho cameras for mouse, keyboard and touch; ui/ and themes/ are the rail and its panels; config/ holds the URL parameters and settings, including the density guard and adaptive resolution; audio/ plays sound cued by the hidden dimensions. core/ ties these into LuxarApp, the object a page embeds and scripts. Two more consumers sit beside the browser: luxar export writes the viewer and the data as an offline folder, and packages/luxar-launcher (Go) wraps that folder as a double-clickable macOS or Linux app.


Development and contributing

make setup-dev     # Node.js, pnpm, Hatch, pre-commit hooks (no sudo)
make check-deps    # Verify the toolchain
make test-fast     # Inner loop: Python (not slow) + TypeScript units
make test-all      # Everything: Python incl. CUDA, Rust/WASM, TypeScript, Go
make check-all     # Lint, format, type-check (reformats the tree)
make viewer        # Viewer dev server on port 5173
make build-viewer  # Production build (needed by luxar export from a checkout)

Python runs through Hatch (hatch run test, hatch run python script.py); the viewer through pnpm in packages/luxar-viewer (pnpm dev, pnpm test --run, pnpm test:e2e); the optional Rust kernels build with make install-rust and make build-wasm. The full setup, including HPC clusters without sudo, is in the build system guide.

Contributions are welcome: read CONTRIBUTING.md first, ask questions in Discussions, report bugs in Issues, and report vulnerabilities privately as described in SECURITY.md. The project follows the Contributor Covenant.

Contribute with a coding agent. Working through a coding agent such as Claude Code is highly recommended: the repository is built for it. CLAUDE.md (mirrored as AGENTS.md for other agents) carries the working knowledge of the project, from the build and test commands to the format, the gotchas, and the conventions every check enforces, and the agent skills below teach an agent the Luxar workflows themselves. An agent that has read those files gets a change through make check-all and make test-all far faster than a newcomer reading the same 1,600 lines by hand, and a PR opened that way is held to exactly the same review as any other.


AI agent skills

Luxar ships Agent Skills: reusable instructions in the cross-tool SKILL.md format that teach a coding agent (Claude Code, OpenAI Codex, Gemini CLI, Cursor, and others) how to drive Luxar. They are committed under .agents/skills/ and symlinked into .claude/skills/, so cloning is the only install step.

Skill What it teaches
luxar-install Install Luxar on a machine — user-install vs full dev setup, per profile: modest laptop (CPU/MPS), NVIDIA-GPU desktop (CUDA extension), and Slurm/HPC cluster (no-sudo bootstrap, build-cuda SLURM=1).
luxar-visualization Build a .luxar.zarr scene from a dataset — Points/Lines/GSplats, Dimensions, transforms, hierarchy, serve/export — grounded in the demos and examples.
luxar-gsplat-pipeline Fit Gaussian splats to an nD image: the cal → fit → lod pipeline, the full CLI option surface, tiling, the Python fitting API, and adding a gsplat node to a scene.
luxar-hpc-batch-fit Fit a whole nD timelapse at scale — local multi-GPU (batch-fit run) or Slurm/Bruno (batch-fit submit), plus status/validate/merge/cancel and the GPU benchmark profile.
luxar-gsplat-edit Post-fit toolbox on a .gsplats.zarr: slice, transform, cull, filter, partition, merge, convert, migrate-format, and inspect (info/render/compare/view/napari).
luxar-data-loading Load an nD image/volume (.zarr/OME-Zarr/.tiff/.npy/.npz) — channel/timepoint/array-key selection and --axes overrides, with the RAM/axes pitfalls.
luxar-export Package a scene for sharing — a standalone offline folder (viewer + data + serve.py) or a native macOS/Linux app bundle.

Documentation

Document Description
Documentation site Tutorials, guides, format specs, and the generated Python + TypeScript API reference
Live demo gallery 88 demos as interactive scenes in the browser
Hosted viewer Open any reachable scene: luxarviewer.dev/?src=<url>
Demo Site Runbook How the two sites above are hosted and published
Python Package README Full Python API documentation
Viewer Guide Navigating a scene: camera, nD slicing, panels, keyboard
CLI Reference Every luxar command, what it is for and where its guide lives; --help lists the flags
CLI package README Per-command options and runnable examples for the fitting pipeline (cal, fit, lod, batch-fit)
Viewer README Viewer features and configuration
Zarr Format Spec Complete data format specification
GSplats Format Spec The .gsplats.zarr fitted-splat container and its LOD node tree
Format and Migration Zarr format 2 vs 3, LUXAR_ZARR_FORMAT, migrating older stores
Compatibility Policy What stays stable across releases and how breaking changes are announced
Distributing scenes Static hosting, folder exports, native bundles, sharing across OSes
Mesh Spec The shaded triangle-mesh node: attributes, LOD, what it refuses and why
HDR Guide HDR color workflow
Gaussian Splatting n-Dimensional Gaussian fitting
Volumetric Blending Spec Emission–absorption compositing: the optical model, the κ maths, and the testable invariants
nD Transforms Spec Per-axis transforms on non-displayed dimensions — domains, composition, inverse-query design
Agent Skills Cross-tool AI agent skills (Claude Code, Codex, …) shipped with Luxar
Build System Development environment setup
Project Statistics Codebase size, language mix, test coverage, git activity (see project_stats.html for the styled report)

Acknowledgments

Built with:

Every demo renders openly shared scientific data, and each demo's docstring carries its full citation. The credits, licenses, and links for all of them are in ACKNOWLEDGMENTS.md.

Where the demo data lives

The Python package does not ship these artifacts. The demos consume derived products (Gaussian-splat fits and point catalogs computed from the datasets credited in ACKNOWLEDGMENTS.md) archived on Zenodo in four demo-data records. The ShareAlike files need a separate record because a Zenodo record carries a single license field; the two large timelapses each have their own record so the data collector is credited on the recording itself:

Record Contents Cite
Permissively licensed (CC-BY, CC0, public domain) 46 files, 1.2 GiB 10.5281/zenodo.21912279
ShareAlike (CC BY-SA 4.0) 3 files, 20 MiB 10.5281/zenodo.21912281
Zebrafish histone timelapse (253 + 51 timepoints) 2 files, 6.5 GiB 10.5281/zenodo.21912283
Drosophila embryogenesis (500 timepoints) 1 file, 1.1 GiB 10.5281/zenodo.22118694

These records cover 25 of the 31 demo datasets. Those 31 are the datasets tracked by the data manifest; the other six (Gaia, IllustrisTNG, Acto3D, Tribolium, FlyLight MCFO, Dip-C) are built locally because redistribution is not permitted or not yet arranged, or because regeneration is cheap. The remaining demos fetch their catalogs straight from the upstream providers credited in ACKNOWLEDGMENTS.md.

luxar demo run <name> resolves only what that demo needs, caches it under ~/.cache/luxar/, and verifies every file against a SHA-256 recorded in data_manifest.json. Files fetched from Zenodo must match the hosted digest or the download is deleted. Existing cache and packaged copies are checksum-checked the same way.

The DOIs above are concept DOIs: they always resolve to the newest version of a record. Each individual version also has its own DOI, which is what to use when a result needs to be reproducible against exact bytes.


Citation

GitHub's Cite this repository button reads CITATION.cff. For the software (this repository, whichever version you used):

@software{luxar2026,
  title = {Luxar: Gaussian splatting and interactive web visualization for
           multidimensional scientific data},
  author = {Royer, Lo{\"i}c A.},
  year = {2026},
  url = {https://github.com/royerlab/luxar}
}

For the method and the benchmarks (Gaussian-splat fitting of microscopy volumes, blind-spot cross-validation of the splat budget, the streaming viewer), cite the preprint:

@article{royer2026luxar,
  title = {Luxar: Gaussian splatting for microscopy and scalable interactive web
           visualisation of multidimensional scientific data},
  author = {Royer, Lo{\"i}c A.},
  journal = {bioRxiv},
  year = {2026},
  url = {https://github.com/royerlab/luxar}
}

If you use the demo datasets, cite the Zenodo record they came from as well as the upstream data (see Where the demo data lives); each record's description names the upstream dataset per file. The splat fits are lossy representations built for visualization, not substitutes for source imagery in quantitative work, and the coordinate, label, and catalog files are derived analysis outputs.


License

BSD 3-Clause. See LICENSE.

Issues · Discussions · Security

Metadata

Release files for luxar 2026.9.22

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for luxar 2026.9.22
File Interpreter ABI Platform
luxar-2026.9.22-py3-none-any.whl Python 3 none any Details

Release files / luxar-2026.9.22-py3-none-any.whl

Download URL luxar-2026.9.22-py3-none-any.whl
Size 10.6 MB
Tags Python 3
SHA-256 checksum
How to use checksums
ff9ec226a5967cc5c46ab0e8e9e6bccb9fb5db449c3044f07779a01f40b4dcfb
BLAKE2b-256 checksum
How to use checksums
d64ef900078313ab25d94b4925a58c19218d20ba7db09d98a4f53967a2a4ebfe
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 22, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2026.9.22 This release

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page