Formats and URLs¶
Every dataset is served through every frontend simultaneously. Given a dataset named
em on http://localhost:8000:
| frontend | Neuroglancer source URL | notes |
|---|---|---|
n5 |
n5://http://localhost:8000/em/n5 |
multiscale group, s0..sN; gzip or raw chunks |
zarr |
zarr2://http://localhost:8000/em/zarr |
Zarr v2 + OME-NGFF 0.4 multiscales; consolidated .zmetadata too |
zarr3 |
zarr3://http://localhost:8000/em/zarr3 |
Zarr v3 + OME-NGFF 0.5 in group attributes |
precomputed |
precomputed://http://localhost:8000/em/precomputed |
raw encoding, 3-D or 4-D (c,z,y,x) only; HTTP gzip when accepted |
mesh |
precomputed://http://localhost:8000/em/mesh |
a surface of the dataset as Neuroglancer meshes, segment 1, each fragment meshed when fetched, at one resolution or several (finer where you zoom); see Meshes |
A cache-busting token may be inserted after the name: /em/@{digest}/zarr3. The API always
hands out this form; the plain form always serves the current pipeline.
Chunk paths¶
| frontend | metadata | chunk key |
|---|---|---|
n5 |
attributes.json, s0/attributes.json |
s0/x/y/z (x first) |
zarr |
.zgroup, .zattrs, s0/.zarray |
s0/z/y/x or s0/z.y.x (both accepted) |
zarr3 |
zarr.json, s0/zarr.json |
s0/c/z/y/x |
precomputed |
info |
s0/x0-x1_y0-y1_z0-z1, must align to chunk grid |
Internally arrays are numpy C order (z, y, x). N5 and precomputed list axes x-first, so
their metadata and keys are reversed relative to zarr.
The group and each level also answer as directories, for clients that browse a store
rather than name its keys: a path ending in /, or a group or level path asked for with
HTML first in Accept (as browsers and Java's HTTP client send), gets a listing in the
form Python's http.server writes, with the metadata keys and one sN/ per level, never
the chunks. That is how Fiji's N5 viewer finds the levels (n5's HTTP access lists them;
checked with n5 4.0.1 and n5-zarr 2.0.1 for N5, zarr v2 and v3). Clients that ask for
*/*, as Neuroglancer, tensorstore and zarr-python do, get the metadata at those paths as
before. Precomputed has no directories.
| client | reads | checked |
|---|---|---|
| Neuroglancer | all four formats | the docs' demos |
| Fiji / BigDataViewer (n5-universe) | N5, zarr v2, zarr v3, and browses the levels | n5's HTTP access reading every format and listing the levels; the Fiji application itself not run |
| zarr-python, dask, napari's zarr reader | zarr v2, v3 | zarr-python 3.4 and dask reading a pipeline over HTTP |
| tensorstore | all four formats | tests/test_clients.py, against a running server |
| webKnossos | zarr v2, v3, N5, precomputed | not run; it asks for byte ranges only of sharded data, which the server does not produce |
Compression¶
Zarr2Frontend, Zarr3Frontend accept compressor="blosc" | "zstd" | "gzip" | "none",
blosc (zstd with byte shuffle) by default: on a 64×256×256 chunk it encodes float32 30
times faster than gzip and uint8 about 30 times faster, at the same ratio, and every
zarr reader listed above reads it except Fiji and BigDataViewer without the native Blosc
library (--compressor gzip for them). N5Frontend accepts gzip or raw. Precomputed raw is uncompressed by definition, so
the server applies Content-Encoding: gzip when the client accepts it.
Edge chunks¶
Zarr requires full-size chunks, so edge chunks are zero-padded (by the browser engine too). N5 and precomputed encode the clipped size.
GeoZarr, for map clients (browser engine)¶
The browser engine serves each view twice: as OME-Zarr 0.5 for Neuroglancer
(virtual/<page>/<view>/) and, for a view read from a georeferenced source (a GeoTIFF), as
GeoZarr for map clients such as OpenLayers (virtual/<page>/geo/<view>/). The GeoZarr
group carries the zarr-conventions multiscales (one layout entry per level, its
spatial:shape), proj: (proj:code, the projection the page names: a map client must
know it, as the Moon page registers the lunar south polar stereographic) and spatial:
(spatial:dimensions [y, x], spatial:bbox the pixels' corners) attributes; level <i>
is a group holding the view as its one band, <i>/<view>, a 2-D y, x array (the view's
one z plane; a map reads 2-D bands). Chunks are the same chunks, computed once whichever
layout asks. The Python server has no GeoZarr frontend yet. The engine also serves each
view as zarr v2 with OME-Zarr 0.4 (virtual/<page>/zarr2/<view>/), the same chunks, for
readers that predate zarr v3's final spec: GDAL 3.8, as the fires demo runs it in the page
(gdal3.js), opens a level as a zarr array and translates it to a GeoTIFF.
A dataset written to a store (Python and the command line)¶
chunkmirage materialize SOURCE OUT [--op ...] [--format zarr3|zarr|n5] [--level N ...] [--box lo:hi]
computes SOURCE through its ops and writes it to OUT (a directory, or a file://, gs://
or s3:// URL, through tensorstore) as a frontend serves it: zarr v3 with OME-Zarr 0.5
(the default), zarr v2 with OME-Zarr 0.4 and consolidated metadata, or N5. The metadata
and every chunk are byte for byte what a client fetches, so the store opens wherever the
served dataset did and reads back as a stored source of its own: a stage computed once (a
registration's pass, a model's output over a region) is then read from disk instead of
computed again. Every level's metadata is written first, so a store whose writing stopped
part way is still the dataset, zero where no chunk was written yet; then the chunks of the
levels asked for (all by default), several at once, each of nothing but zeros left out
(--keep-empty writes them). --box takes level 0's voxels along the last axes, lo:hi
each comma separated in C order (0,0,0:64,512,512), and every level writes the same
region on its own voxels, rounded out to whole voxels. As the browser engine does,
--extent box (the default) writes exactly the box: each level an array of the box's
shape, its translation moved by the box's corner so it lands where the box is, in chunks
of the dataset's shape counted from that corner, those at the far edge stored whole with
zeros past the box. Each of its chunks is read from the dataset's chunks it overlaps; when
the last stage keeps no chunks of its own (no cache: true), those pass through a cache
of up to 1 GiB, walked so that the fewest wait (the axes the box's corner is on chunk
edges along first, then the one with the most chunks), so each is computed once while a
slab of them fits. --extent level writes the box's chunks, rounded out to whole chunks,
into arrays of the levels' whole shapes, for several boxes in one store; a box of the
whole level is the same store either way. From Python,
chunkmirage.materialize(pipeline, where, format=, levels=, box=, extent=, compressor=,
skip_empty=, progress=) does the same for any pipeline, another dataset's chain read
through dataset:// among them (registry.get(name)), and returns the counts
({chunks, written, skipped, bytes}). Inside a running server it competes with clients'
requests as any work asked for from Python does (never dropped, in arrival order), and
its chunks pass through the server's cache.
A region written to a store (browser engine)¶
The browser engine also writes a view, or a box of it, into a store as files
(Engine.materialize; see the design notes): a
folder the visitor picks, the origin's private file system or memory. What it writes is
the layout it serves for the view, one level of it, so the result opens wherever the view
did and lands in the same place: the group lists the level written as its one dataset,
0, with that level's scale; the array has the chunk shape and dtype served, and chunks
compressed with gzip unless asked not to be. What the array covers is the extent:
"box"(the default): exactly the box,hi − lovoxels (clipped to the level, rounded out to whole voxels, not to chunks), with the level's translation moved bylovoxels (the translation is where voxel 0's centre is, so it lands where the view's voxellodoes). Its chunks have the served chunk shape, counted fromlo. The chunks at the box's far edge overhang it and are stored whole, zero (the fill value) past the box, as zarr stores a chunk at an array's edge (zarr v3's regular grid: border chunks have the full chunk size, the fill value recommended outside the array; zarr v2: every chunk has the same shape)."level": the level's whole shape, with the level's translation, so a box keeps its place in it, as several boxes written into one store need; the chunks of the box are written, rounded out to whole chunks, each the chunk the engine serves.
A box of the whole level is the same store either way, byte for byte. Where a box's lo is
off the served chunks' edges along an axis, the box splits the served chunks there: each of
its chunks is stitched from the served chunks it overlaps, up to two along each such axis,
and each served chunk is computed once. The served chunks are walked axis by axis, the axes
the box does not split outermost, then the split ones, the one with the most of the box's
chunks first, and each is copied into the box's chunks it overlaps, a box chunk written
once the last of them is in. Only the box's chunks begun and not yet complete are held: a
slab of them across the split axes but the outermost, a row and one more. With the box's
chunks n₁ ≥ n₂ ≥ n₃ along three split axes that is n₂ × n₃ + n₃ + 1; along two, n₂ + 1;
along one, 1; none where lo is on the chunk edges on every axis, the box's chunks then
the served chunks as they are. A box chunk none of whose served chunks holds a value but
zero takes no memory. So a 512 × 2048 × 2048 box of 64³ chunks off the chunk edges
everywhere (8 × 32 × 32 chunks) holds 32 × 8 + 8 + 1 = 265 chunks, which for 3-channel
float32 chunks (3 MiB) is about 800 MiB; with lo on a chunk edge along z, 32 + 1. No
order of the axes holds fewer; putting lo on the chunk edges along an axis is what
shrinks it further. These counts are exact where the box has three chunks or more along each
split axis, and the most it can hold otherwise. Engine.materializePlan(view, options)
gives the exact count for a box before anything is computed (plan(region(...)) in
web/src/materialize.ts): peakChunks, and peakBytes, those chunks' bytes (chunkBytes
each), besides the concurrency served chunks on their way, with the array's shape and
chunk, its chunks (total), the served chunks it computes (needed), the axes it splits
and the walk's order. A page can warn, or offer to put lo on the chunk grid along an axis,
before it starts. Should a smaller peak ever matter, the way to get one is to compute the
plane of served chunks that two slabs of the box share twice, along the outermost split
axis, so each slab is finished before the next is begun, rather than compressing chunks
while they wait or refusing the box.
A chunk of nothing but zeros is left out (zarr reads a missing chunk as the fill value, 0), unless asked to be written. A channel axis the view's ops add is the first axis and one chunk, as served, whole whatever the extent. Progress counts the array's chunks done (written or skipped) of its total, and the served chunks computed of those the box needs; each only grows. As zarr v2 with OME-Zarr 0.4 (the default, which more tools read):
.zgroup {"zarr_format": 2}
.zattrs {...extra attributes, "multiscales": [{"name": <view>, "axes": [...],
"datasets": [{"path": "0", "coordinateTransformations": [scale, translation]}],
"version": "0.4"}]}
0/.zarray {"zarr_format": 2, "shape": <the level's, or the box's>, "chunks": <as served>, "dtype": "<u2",
"compressor": {"id": "gzip", "level": 6}, "fill_value": 0, "order": "C",
"filters": null, "dimension_separator": "/"}
0/.zattrs {"_ARRAY_DIMENSIONS": ["z", "y", "x"]}
0/<i>/<j>/<k> each chunk, gzipped
As zarr v3 with OME-Zarr 0.5:
zarr.json {"zarr_format": 3, "node_type": "group", "attributes": {...extra attributes,
"ome": {"version": "0.5", "multiscales": [{... "datasets": [{"path": "0", ...}]}]}}}
0/zarr.json the level's array as served (the box's shape, with the box's extent),
its codecs [{"name": "bytes", ...}, {"name": "gzip", "configuration": {"level": 6}}]
0/c/<i>/<j>/<k> each chunk, gzipped
The metadata is written first, so a store whose writing was stopped part way is still a
valid OME-Zarr, zero where no chunk was written. Extra attributes (a consumer's
provenance) sit beside the OME ones in the group's attributes, which they cannot replace.
gzip is what a browser compresses with on its own (CompressionStream; level 6 is the
level it writes at, which no reader needs); the Python server's default, blosc, would need
an encoder of its own in the page.
Meshes, computed when fetched¶
The mesh frontend serves a dataset's surface in Neuroglancer's precomputed mesh formats,
for a segmentation layer whose source is precomputed://.../<name>/mesh, segment 1.
One resolution (the default): the legacy format. info
({"@type": "neuroglancer_legacy_mesh"}), the manifest 1:0 listing one fragment per chunk
of one level, and each fragment 1:0:i_j_k, meshed when it is fetched (uint32 vertex
count, float32 x, y, z per vertex in nanometres, uint32 triangle corners). A fragment is
its chunk plus one voxel on its high sides, so neighbouring fragments meet exactly (the
surface is watertight across them), and closed at the array's edges. Neuroglancer fetches
every fragment of the manifest, so the level bounds the work, and zooming in shows nothing
finer.
Several resolutions (lods above 1): the multi-resolution format, whose meshes get finer
where the viewer zooms in. Level of detail i is the pyramid level level - lods + 1 + i,
its octree nodes that level's chunks, so each finer level splits a node in eight. info
(neuroglancer_multilod_draco, 16-bit positions), the index 1.index (per level of detail
its nodes and their sizes; each level's voxel size is its scale, the offset of its origin
its vertex offset) and the fragments' file 1, which Neuroglancer reads by HTTP Range
requests, one node at a time, for the nodes in view at the detail it wants (a segmentation
layer's meshRenderScale: larger is coarser). The format wants every fragment's size before
any is fetched, which on-demand meshing cannot know, so every fragment is padded to one size
(128 KB; Draco decoders read what they need and ignore the rest, and the server gzips them,
so a padded fragment costs a few hundred bytes to a few tens of kilobytes on the wire). A
node's mesh is marching cubes on each of its octants apart (no triangle may cross one, so
Neuroglancer can show part of a coarse node while its children load), its vertices integers
across the node, coarsened past 100,000 triangles, and encoded with Draco (DracoPy, in the
mesh extra; the browser engine uses Draco's own WebAssembly build). Only nodes within two
voxels of the coarsest level's surface are listed, which the index is made from when it is
first asked for (the whole coarsest level is read once): a finer level's surface lies near
the coarser one's, and a listed node that turns out empty costs only its fetch. Four levels
of detail of the Mandelbulb from its 256³ level list about 47,000 nodes, a 750 KB index;
more than two million are refused.
The spec's mesh (CLI --mesh) says what is meshed: kind: surface (default) is the
boundary of the voxels at or above threshold (128), by marching cubes (scikit-image, the
mesh extra); kind: terrain is an elevation model (y, x, or z, y, x with one z) as
two triangles per cell, its elevation times exaggeration as height, cells with a NaN
corner left out (one resolution only). level picks the level, the coarsest with lods
(default: the finest whose longest side is at most 512 voxels), and lods the levels of
detail. The browser engine serves the same at virtual/<page>/<view>/mesh, its workers
running the same module (chunkmirage.meshes); for the multi-resolution index it masks the
coarsest level's eighths in its workers at once (each with a border, for its surface band),
and cuts the coarsest nodes' fragments from that mask (the Python server does the same), so
the coarsest level is computed once. The index has to be made from the whole coarsest level
before Neuroglancer can ask for any fragment: the Mandelbulb's appears after about 16 s in a
browser, against 8 s for its single-resolution mesh.
chunkmirage serve 'synthetic://mandelbulb?shape=268435456,268435456,268435456&voxel_size=1&unit=nm' \
--mesh threshold=255,level=20,lods=4 --chunk 32,32,32 --python-viewer
Data types¶
Precomputed supports uint8, uint16, uint32, uint64, float32 only; add a cast op
for anything else. Precomputed volumes are typed by what their values are (ArrayInfo.kind,
which ops declare): labels and masks segmentation, images image; where nothing says,
uint32/uint64 are segmentation and others image. Override with
PrecomputedFrontend(volume_type=...).
Stored arrays (zarr, N5, precomputed, HDF5) say nothing of what their values are, so their
kind is guessed from the dtype: integers of 32 bits or more are labels, booleans masks, the
rest unknown. Labels and masks are resampled by nearest voxel and downsampled by their most
common value, never averaged, so no ids are invented between two segments. A spec's kind
(open_source(..., kind=...) in Python) overrides the guess: "kind": "image" for a uint32
image, "label" for ids stored as uint16.
Sources¶
Synthetic (procedural) sources¶
synthetic://<kind>?shape=z,y,x&chunk=64,64,64&levels=N&seed=0&voxel_size=8&unit=nm
generates data on the fly from voxel coordinates. Nothing is stored, so the volume can be
as large as you like, and each scale level is the same function sampled at a coarser
spacing, so the pyramid is exact (s1[z,y,x] == s0[2z,2y,2x]). Kinds: blobs (Gaussian
blobs), shells (hollow spheres, membrane-like), noise (fractal value noise), julia
(a 3-D slice of a quaternion Julia set), mandelbulb (the power-8 Mandelbulb, to zoom
into); combine with +, e.g. blobs+noise. Useful for demos and for stress-testing
pipelines without I/O. Generation is vectorised numpy, so the server's threadpool runs it
on all cores, and the browser engine's Pyodide workers run the same module for a view
whose source is synthetic:// (nothing is read).
mandelbulb spans [-1.25, 1.25] on every axis in float64 from integer indices, so an
array 2^28 voxels across (21 levels, 10^25 voxels) still resolves its finest voxels. Its
value is 4 × the smooth escape iteration (255 inside), the same at every level, so a
colour stays a colour as the viewer changes level; and it is the one kind whose levels are
not exactly the same function: a level iterates 10 + 3 log2(256 / voxels across) times,
more the finer it is, as fractal zoomers do, so zooming in resolves new detail.
Scene sources (OME-Zarr 0.6 transformations)¶
scene://<group>?image=<image path>&target=<image path or coordinate system>
serves one image of an OME-Zarr 0.6 scene resampled onto another grid through the scene's coordinate transformations, including displacement fields. No viewer applies those yet: Neuroglancer refuses to load an image whose transformation is a displacement field, and napari and vizarr skip them. Served this way, every viewer and every zarr reader sees the registered volume, and nothing is written. Each output chunk is computed on its own: its voxel centres go through the transformation chain into the source image, the exact bounding box of the results is read at the matching resolution level, and the values are interpolated. Ops can follow as with any source.
chunkmirage serve "scene:///data/fly_brains.zarr?image=FCWB&target=JRC2018F"
examples/fly_brain_registration.py builds that scene from the public RFC-5 example (two
Drosophila brain templates, a displacement field plus affine between them) and serves the
registered template next to the fixed one.
| parameter | default | meaning |
|---|---|---|
image |
the group itself, if it is a multiscale image | image to resample, relative to the group |
target |
the scene's first coordinate system, else the image's own | an image path (its grid and levels are used) or a coordinate system name |
interpolation |
linear; nearest for label images |
nearest keeps label ids exact |
inverse |
exact |
approx also walks displacement fields backwards, estimating their inverse by fixed-point iteration |
shape, voxel_size, translation, levels, chunk |
the target image's grid, else the source image's | explicit output grid, e.g. for a scene coordinate system with no image: spatial axes, C order, target units; one value applies to every axis |
field_cache_gb |
0.0625 |
decoded-chunk cache per displacement field; raise it for fields stored in large chunks so each is decoded once (see below) |
large_field_chunks |
0 |
1 accepts fields stored in chunks over 256 MiB, decoding them one at a time (see below) |
Finding the transformation. Coordinate systems (the scene's own and each image's) are
the nodes of a graph and transformations are its edges. The shortest path from the target
to the image's intrinsic system is used. An edge is walked backwards only if it has a
closed-form inverse (scale, translation, affine, rotation, mapAxis, and sequences or
byDimension of those) or is a bijection. Registration tools usually store fixed →
moving, which is the direction resampling needs, so this is rarely a limit; otherwise
inverse=approx estimates the inverse of displacement fields. Where a field folds there
is no unique inverse, and those voxels are left empty rather than filled wrongly.
Details.
- Transformation types: identity, scale, translation, affine, rotation, mapAxis,
projectAxis, sequence, displacements, coordinates, bijection, byDimension; parameters
inline or stored under
path. - Fields: 0.6 multiscale field groups (vector axis first), and the draft layout (a bare
array with the vector axis last, as BigWarp exports). Outside its grid a field takes the
nearest edge value.
cubicfield interpolation is read as linear. - Time and channel axes of the image pass through unchanged; transformations act on the spatial axes. The output is the image's leading axes plus the target's spatial grid.
- Each output level reads the coarsest source level that is still at least as fine as one output voxel, measured through the transformation at the grid centre, so zoomed-out views read small levels.
- Voxels that map outside the image are 0.
- Field chunking decides speed and memory. Each output chunk reads the small window of the
field it needs, but a store decodes whole chunks: a field saved as a few huge chunks (for
example one full-z, full-x band per writer job) costs a full-chunk decode per output
chunk, and a viewer's parallel requests start several at once. A 113 GB field in 6.6 GiB
chunks exhausted a 93 GB workstation this way. So fields whose chunks exceed 256 MiB are
refused, with the numbers, unless
large_field_chunks=1; then they are decoded one at a time, andfield_cache_gbabove one chunk decodes each only once. Better: store fields in chunks of about 64³, or sharded with small inner chunks. - One output region may read at most 2 GiB of the source image; a transformation that
spreads a chunk over more than that is refused rather than allocated. Use smaller output
chunks (
chunk=) if you hit it. - Also read: the 0.6 drafts' string
input/outputreferences,multiscalesobjects and snake_caseinput_axes. - The RFC-5 transformation conformance
suite runs in the tests
(
tests/data/rfc5_conformance).
The cache key of each level folds in the whole transformation chain, so editing the
scene's metadata and re-PUTting the dataset gives new URLs. A field rewritten in place
under the same path keeps its key; restart or clear the cache after doing that.
Warp sources (an image through a displacement field)¶
warp://<image>?field=<source>&affine=<12 numbers>&target=<source>
warp://<image>?field=<newest source>|...|<oldest source>&affine=<12 numbers>
warp://<image>?affine=<12 numbers>&target=<source>
warp://<image>?field=swirl&angle=90&radius=200¢re=z,y,x&plane=y,x
warp://<image>?field=swirls&count=8&seed=0&angle=90&radius=200
serves <image> (anything above or below, including synthetic:// URLs) resampled
through a displacement field and an affine. The field is any source: a (c, z, y, x)
array whose three components are the z, y and x displacement, in the image's units, at
each voxel of the field's own grid, in the output's space. Another dataset of this server
(dataset://<name>: a block
matcher's lattice, a model's predicted deformation), a stored zarr, or a warp's own
show=field. A point x of the output is read from the image at A(x + d(x)), A the
affine (identity by default), the convention register:// uses. The output is on the
image's own grid, or on target's (any source: its levels are the output's, each reading
the image level that suits it through the mapping), so a pass fitted on a fixed image's
grid is applied there: warp://moving?field=dataset://pass1&target=dataset://fixed.
Several fields, | apart, are applied in turn, the first listed first, each sampled
where the ones before it have moved the point, and the image is read once. With
T_i(x) = x + d_i(x), field=d_n|...|d_1 reads the image at A(T_1(...T_n(x)...)); for
two fields that is A(x + d_2(x) + d_1(x + d_2(x))), not a sum of the two vectors at x.
That is how the passes of a registration compose: the latest pass's field goes first,
since it was fitted on the image the earlier passes had already moved, so after two passes
warp://moving?field=dataset://pass2|dataset://pass1&target=dataset://fixed is the image
both passes correct. Warping the warped image instead
(warp://warp://moving?field=...pass1...?field=...pass2...) is the same mapping but
interpolates the image once per pass, which blurs it and the images later passes match on;
the one-resample form is sharper, measurably. No field with an affine or a target
places the image through the affine alone, on the target's grid: the first step of a
chain, before any pass has made a field, and the way to put two images on one grid for a
two-input op. Every field's keys are part of the output's, so an edit upstream of any of
them reaches it. A field or target URL with a query of its own is percent-encoded
(urllib.parse.quote(url, safe="")); dataset:// names need none.
Or the field is swirl or swirls, deformations computed from coordinates: the image
twisted on its own grid, nothing stored, not even the field, so any parameter can change
at any time: PUT a new URL and the viewer refetches what is on screen. With
&show=field either kind serves the displacement itself, a (c, z, y, x) float32 volume;
of several fields, the net displacement T_1(...T_n(x)...) - x, the affine kept apart.
The resampling is the same as for scene sources, and each chunk reads only the compact
region its displaced voxels come from, however large the displacement.
swirl is one twist about an axis along a spatial axis (z for the default plane=y,x),
fading with distance from that axis, so it is a column through the volume. swirls
(3-D images) are count balls of twist at random places, each about its own axis in a
random direction and fading with distance from its centre, so they move points along z
too. Their displacements add; where they overlap the sum is smooth but no longer a pure
rotation. The same seed gives the same swirls.
With &frames=N the swirl grows along a new leading t axis, from none at frame 0 to
angle at the last, so a viewer animates it by playing t (Neuroglancer's playback)
instead of loading new URLs: each frame is its own chunks, computed when first shown and
cached from then on. examples/swirl_demo.py shows the original, the swirled image and
the field side by side and plays the frames.
| parameter | default | meaning |
|---|---|---|
field |
required unless affine or target is given |
a source URL, or several \| apart applied in that order (displacements sampled from each, at its level nearest each output level's voxel size; a lattice's levels by the voxels they were fitted on, ArrayInfo.computed_from, so a zoomed-out view reads the lattice fitted at its level), swirl or swirls |
affine |
identity | applied after the displacement: 12 numbers, 3 rows of 4 in C order (z, y, x), or 16 with the last row 0 0 0 1; 6 or 9 for a 2-D image |
target |
the image's grid | the source whose grid (levels, shape, voxel size, position) the output takes |
angle |
90 |
twist on the axis, degrees; it fades as exp(-(r/radius)²) with distance r from the axis (swirl) or the centre (swirls). Each of the swirls turns by ½ to 1 × angle, either way |
radius |
swirl: a quarter of the smaller in-plane extent; swirls: a fifth of the smallest extent |
fall-off distance, image units; each of the swirls gets ½ to 1 × radius |
centre |
the volume centre | image units, C order: a point on the axis (swirl), the middle of the box the centres fall in (swirls) |
plane |
the last two axes, e.g. y,x |
swirl: the two axes that turn |
count |
8 |
swirls: how many |
seed |
0 |
swirls: random seed for their centres, axes, radii and angles |
spread |
half the volume | swirls: half-size of the box around centre their centres fall in; one value or z,y,x |
show |
image |
field serves the displacement instead |
frames |
none | the swirls only: frames on a leading t axis; frame i twists by angle·i/(frames−1). An image's own t axis must hold one time point, which the frames replace |
chunk |
the grid's | output chunk shape of the spatial axes, C order; thin chunks such as 8,128,128 compute less for a view of one plane |
interpolation |
linear; nearest for uint32/uint64 |
as for scene sources |
The parameters follow the last ?, so the image URL may carry its own query. With
serve --device cuda (or auto; chunkmirage.gpu.set_device in Python) the resampler runs
on a GPU, here and for scene://: every output voxel through the affines and sampled fields
and the image interpolated, the resampler's own code run with CuPy for numpy and
cupyx.scipy.ndimage for scipy.ndimage (the cupy extra), in single precision, so values
agree with the CPU's to within a grey level and the cache keys are the same. Measured on a 184 M-voxel volume
through three lattice fields, CuPy took 0.5 s on an A100 where one CPU thread took 154 s
and 24 took 13.5 s, most of it sampling the fields. A chain with any other step (a swirl,
an inverse estimated numerically) is resampled on the CPU. To show the field as RGB, its components must be a Neuroglancer shader channel dimension (c^, read
with getDataValue(0..2)). The precomputed frontend makes them one, but holds only
(c, z, y, x); with frames, serve zarr and rename the channel dimension c' to c^
(Viewer.rename_dimensions, as the demo does).
Dataset sources (another dataset of the server, in-process)¶
dataset://<name>
reads another dataset this server serves, by name, as a source: its levels are that
dataset's own output stages, so a chunk comes through its stage cache, never over HTTP and
never computed twice, and every stage key downstream holds its keys. A chain of such
datasets is how passes of a computation are stacked without writing anything to disk
between them (each stage still cached): a field fitted by one pass, the moving image
warped by it, the next pass fitted on that
(stack://dataset://fixed|dataset://corrected), its field, and the original image warped
through both fields at once (warp://dataset://moving?field=dataset://pass2|dataset://pass1,
one resample; see warp sources). Each
stage of the chain is cached once, and a change to one pass's parameters recomputes only
that pass and what follows it on screen.
Editing a dataset (PUT) rebuilds the datasets that read it, transitively, so their
digests, links and change events follow the edit; GET /api/datasets/{name} lists what a
dataset reads under upstream. A dataset may not read itself by any chain: a cycle is
refused when it is added (400). Removing a dataset leaves those reading it serving what
they last read of it, until they are edited. A dataset:// URL resolves against the
registry building the dataset that holds it, so it is opened through the server (the REST
API, create_app's datasets, a DatasetRegistry), not by open_source on its own.
Coverage sources (what the cache holds of a dataset)¶
coverage://<name>
one voxel per chunk of another dataset of this server, saying what has been computed
there, to tell a real result from a preview while browsing: zoomed out, a dataset shows its
ops run on the coarse level, not a downsample of the full-resolution result, and nothing on
screen says whether that result exists. The levels are the dataset's. A voxel covers one
chunk of that level's last cached stage (the last op with cache; the blocks of an op with
overlap, where it ran; stage 0 when nothing after it is cached) and holds the sum of two
bits: 1 when that chunk is in the cache, 2 when every chunk of the finest level under it
is. So at full resolution a chunk is 3 or 0; zoomed out, 1 marks a preview (computed on the
coarse level, the fine chunks under it not all computed), 3 a region whose full-resolution
result exists. Served as labels (kind label), so a viewer colours the values apart. It is
read afresh at every request and never cached; a viewer that keeps what it fetched, as
Neuroglancer does, shows the state at the time, so reload the layer to see more.
GET /api/datasets/{name}/coverage gives the counts per level. A dataset none of whose
stages is cached (a dataset:// source with uncached ops, or cache_source: false) has
no coverage, since its chunks are computed at each request: look at the datasets it reads.
As dataset:// does, it resolves against the registry building the dataset that holds it,
and counts the dataset it describes as one it reads, so editing that rebuilds it.
Register sources (deformable registration on a GPU)¶
register://<moving>?fixed=<fixed>&affine=<matrix.npy>
register://<moving>?fixed=<fixed>&affine=<matrix.npy>&show=pair
serves <moving> registered onto <fixed>'s grid, at every level of <fixed> and with
every channel of <moving>. Opening the source solves for a smooth displacement field u
in the fixed image's space such that the moving image sampled at affine(p + u(p)) looks
like the fixed image at p. The solve reads a few coarse levels of both images, fits
their local normalized cross-correlation with a penalty on the field's gradient, and runs
Adam from coarse levels to fine ones, on a GPU when there is one (PyTorch:
pip install chunkmirage[gpu]; otherwise the CPU, slowly). Left out of the fit are
windows with no contrast in either image, and fixed voxels whose window the moving image
does not cover under the affine (a moving image with a smaller field of view): there is
nothing to match, and fitting them would drag the moving image's edge over whatever lies
beyond it, so the field there follows from its smoothness. The field lives on a control grid a few voxels
apart, so it is small (megabytes for an organ) and stays in memory. Every output chunk is
then the moving image resampled through it by the scene sources' resampler, so full
resolution is served without anything being written.
u has the convention registration pipelines write to disk (a displacement in fixed
space, applied before the fixed-to-moving affine, e.g. bigstream's), so it can be
compared with theirs directly (show=field), and the moving image's other channels come
out registered too. On a 1231×14124×5452 EASI-FISH gut round (an RTX 2080 Ti, the affine
from a keypoint fit, scored at level 3, finer than the solve): levels 6 and 5 solve in
2.4 s and beat a bigstream registration on local correlation and on the overlap of bright
voxels; levels 6 to 4, the default, take 15 s and match a cluster block-matching
registration's local correlation, 0.02 below it on overlap, its field within a median
13 µm of theirs.
Solved fields are remembered per process (the last 8), so opening the same URL again (a
downstream op edit, or another show) does not solve again, while changing a solver
parameter does. The solve runs in whatever opens the source (server start-up, or the
PUT that sets it), plus about 10 s the first time to import PyTorch from a network
file system.
With refine=n, the n levels below the solved ones are fitted where they are looked
at, not up front. Each gets its own control lattice, grid voxels apart, in blocks of
block voxels (64×128×128 by default, whatever the output's chunks), and a block is
fitted when a chunk it covers is first requested: from the
solved field over the block plus halo voxels of context, against the fixed and moving
voxels those reach, coarse to fine over halved copies of them (down to about the solved
level's resolution) for that level's iterations per copy; then it is kept in the
server's chunk cache with the computed chunks, so --cache-gb bounds it and
DELETE /api/cache clears it (1 GiB of its own when opened from Python without a cache). Every block starts from the solved field, so no level waits on
another's blocks. The finest fitted level's field serves every level below it. So the fit's
detail grows with the zoom and its cost with what is viewed rather than with the volume,
and whatever asks for chunks drives it: Neuroglancer, Fiji, webKnossos, a dask array.
Blocks are fitted separately, each over its context too, so neighbouring blocks overlap,
and they are blended there, as bigstream blends its blocks: a lattice point's value is the
average of the blocks whose fits cover it, each weighted 1 over its own block and less the
further into its context, so the field turns from one block's fit to the next's across the
overlap. Without it, neighbouring fits that disagree made the field step at their shared
edge: on the EASI-FISH pair below, by up to 0.9 µm per voxel, three times the steepest 1%
inside a block, enough to shift structures by a few voxels at a seam. Blended, the field
changes no faster across block edges than inside a block (0.17 to 0.21 µm per voxel at the
99th percentile, against 0.18 to 0.24 inside). A block's fit still depends only on the
solved field and the images, so the result does not depend on which blocks were fitted
first. The price is a ring of blocks: a chunk at a block's edge needs the block on the
other side too (zooming the browser page into the EASI-FISH pair, 66 blocks instead of 45,
and the view complete in 115 s instead of 98 s). On the tests' swirled volume the block
fit and a whole-image solve of the same levels agree to 2×10⁻³ in correlation with the
fixed image.
The window matters most at the fine levels. Two rounds of one EASI-FISH fly brain sit in a public bucket that allows any origin (3.4 G voxels per channel, 0.23 µm), so this runs from anywhere:
E=https://janelia-data-examples.s3.amazonaws.com/fly-efish/NP31_R2_20240119
chunkmirage serve "register://$E/NP31_R2_2_1_SS00090_FMRFa_546_Proc_647_1x_Central.zarr/0?fixed=$E/NP31_R2_1_1_SS00090_Spab_546_Nplp1_647_1x_Central.zarr/0&affine=auto&refine=3&iterations=100,40,40,40&window=15,31,31,31&show=pair" --https
affine=auto finds in 2 s how round 2 was remounted: turned over (z and x reversed, a half
turn about y) and tilted about 22° in the z-y plane. Level 3 (the default, 54 M voxels)
solves in 20 s on an RTX 2080 Ti, and levels 2 to 0 are fitted as you zoom. Whether round 2
is instead a mirror image (z alone reversed, as a stack taken the other way round would be)
the images cannot say: the brain is nearly symmetric, and a tilted mirror, given as the
affine, correlates as well (0.826 against 0.822), full-resolution chunks splitting between
the two. The half turn needs the smaller field afterwards (a median 0.2 µm against 0.5 µm),
so the search's rotation is kept; whoever knows the round was a mirror gives its affine.
Three full-resolution chunks in the tissue correlate with the fixed image at 0.75, 0.64 and
0.86 through the level-3 field, and at 0.85, 0.77 and 0.87 through blocks fitted with the
command above. Measured with blocks of 128³ voxels, the window matters most: 7 gives 0.79,
0.69 and 0.86, 15 gives 0.83, 0.75 and 0.87, 31 gives 0.86, 0.79 and 0.87 (a wider window
helps the level-3 solve too: 15 there alone gives 0.82, 0.73 and 0.86), while a larger halo
or more iterations changed nothing. Deeper blocks fit better, having room to go coarse to
fine: with the command's windows, blocks of 16×128×128 voxels reach 0.83, 0.75 and 0.87,
64×128×128 (the default) 0.85, 0.77 and 0.87, and 128³ 0.86, 0.79 and 0.87. The first
chunk of a region takes about 20 s, mostly its blocks (with the neighbours its
interpolation touches), then about 10 s a chunk, and blocks are kept, so a region fills in
faster the longer it is looked at.
affine=auto finds the starting affine from the images (the coarsest solved level of
each, halved to a few hundred thousand voxels): their intensity moments are matched, centre
to centre and principal axis to principal axis, the best-correlated of the orientations
that do not mirror the image is kept, and the 12
numbers are then fitted by gradient ascent
on the normalized cross-correlation at two resolutions, as the browser page does. The
moments assume both images show the same whole object; a crop of one needs an affine given.
A mirror image is not searched for: on a nearly symmetric specimen a mirror correlates as
well as the right rotation while swapping left and right (on the fly templates exactly as
well, 0.872, and 226 µm from the published affine), and a fit cannot reach one from a
rotation (it would pass through a matrix that flattens the volume). An affine with a
negative determinant, such as z reversed, is used as given. A found affine is
remembered per image pair, like the solves.
show=pair serves a (c, z, y, x) volume whose two channels are the fixed image's
matched channel and the registered moving image's, so one Neuroglancer shader can compare
them on the viewer's GPU (overlay, checkerboard, fade, difference) with no refetch. The
source carries that shader, so the link chunkmirage serve prints, the REST API's
neuroglancer routes and the python viewer show the pair in colour without being asked:
fixed magenta, registered green, white where they agree, with contrast from the coarsest
solved level and a mode control for the other three comparisons. show=field likewise
comes as a heat map of how far each point moved (a direction box colours by direction).
examples/register_demo.py shows that next to the affine alone (iterations=0), and
re-solves when you type new settings, the viewer keeping its camera.
| parameter | default | meaning |
|---|---|---|
fixed |
(required) | the fixed image: anything open_source reads, with the same spatial units as <moving>; percent-encode it if it has a query |
affine |
identity | fixed-to-moving affine in physical units, C order: a .npy or text file with a 4×4 or 3×4 matrix, or its 12 or 16 values inline, row by row; auto finds one from the images |
fixed_channel, moving_channel |
0 |
the channel each image is matched on, for images with a c axis |
levels |
from the coarsest with ≥ 16 voxels on every axis to the finest with ≤ 2²⁵ | fixed-image levels to solve on, coarse to fine, e.g. 6,5,4; each is matched with the moving level nearest its voxel size |
iterations |
100 |
Adam steps per level: one value, one per level, or one per level with the refined ones; 0 leaves the affine alone (no GPU needed) |
refine |
0 |
levels below the solved ones fitted block by block where they are viewed: refine=3 with levels=6,5,4 fits levels 3, 2 and 1 |
halo |
8 |
voxels of context on every side of a block being fitted: how far beyond it the fit looks |
block |
64,128,128 |
voxels per refined block, C order, independent of the output's chunks; deeper blocks fit better (coarse to fine within them), thinner ones answer a single slice sooner |
smooth |
1 |
weight of the penalty on the field's gradient |
grid |
4 |
control-point spacing, voxels of each level |
window |
7 |
correlation window, voxels (odd): one value, one per level, or one per level with the refined ones; fine levels gain from a wider one (see below) |
show |
image |
pair (fixed and registered as two channels) or field (u, components first, physical units) |
frames |
none | a leading t axis of that many snapshots of the solve, from the affine alone to the final field: play t to watch it converge. The moving image's own t axis must hold one time point, which the frames replace |
chunk |
the fixed image's | output chunk shape of the spatial axes, C order |
interpolation |
linear; nearest for uint32/uint64 |
as for scene sources |
device |
auto |
auto is the GPU with the most free memory, else the CPU; or cpu, cuda:1, ... |
The parameters follow the last ?, so the moving image's URL may carry its own query (a
warp:// URL, say, which is how the tests check that a known swirl is undone). They are
one Pydantic model, RegisterParams, whose JSON Schema (chunkmirage schema) the browser
engine's types and form defaults are generated from: the
browser page solves the
same spec on the viewer's GPU and shows the chunkmirage serve 'register://…' command for
its settings, and fed the same affine and levels the two fields agree to 0.01 µm (median)
on the fly templates, whose field moves tissue by 7 µm (median). It fits blocks on demand
(refine) the same way, on the viewer's GPU, so the EASI-FISH pair above runs from a link
with nothing installed:
register.html with the EASI-FISH rounds
(it shows the two rounds; Register solves).
Stack sources (several images as one array's channels)¶
stack://<image>|<image>[|<image>...]
serves images that share a grid as the channels of one (c, z, y, x) array ((c, y, x) for
images in y and x, such as GeoTIFF bands): channel 0 is
the first image's first channel, channel 1 the second's, and so on. The images must agree
level by level on shape, voxel size, translation and units (the stack has as many levels as
the image with the fewest), and each is anything open_source reads, so a stack can hold a
stored volume next to a synthetic:// or register:// one. Nothing is copied: a stack chunk
reads the same box of each image, through that image's own cache.
A stack exists for ops that need two images at once. Such an op takes the channel axis and
returns an array without it; the pipeline reads every channel and pads only the spatial axes
by the op's halo, and --chunk may then give just the three spatial values. The first such
op is contacts: the voxels within a distance of both structures.
On OpenOrganelle's published organelle predictions, mirrored back into the EM's frame (see
below), followed by label to colour and size-filter the sites:
P=https://janelia-cosem-datasets.s3.amazonaws.com/jrc_hela-2/jrc_hela-2.n5/labels
chunkmirage serve "stack://flip://$P/mito_pred?axes=y|flip://$P/er_pred?axes=y" \
--op contacts:distance=12 --op label:min_size=50 --chunk 16,128,128 --python-viewer
examples/contact_sites.py serves the same with the EM underneath and the two predictions
tinted, opens where the two organelles touch most, and takes new settings at a prompt.
Only the chunks on screen are computed, out of 122 gigavoxels of cell, and a change of
distance recomputes just those, from predictions the first pass left in the cache. The
distance is in nanometres and each level counts it in its own voxels, so a contact means
the same at every zoom (radius, in voxels, would reach proportionally further on a
zoomed-out level, as op parameters in voxels do).
Flip sources (images stored the other way round)¶
flip://<image>?axes=y[,x,...]
serves <image> mirrored along the named axes, every level in place: voxel i of a
flipped axis of length n is voxel n − 1 − i of the image, and shape, voxel size,
translation and chunks stay the image's. It repairs data whose orientation its metadata
gets wrong: OpenOrganelle's N5 organelle predictions of jrc_hela-2 (labels/*_pred) are
upside down in y relative to its EM, zarr and N5 alike (with the flip, every predicted
mitochondrion voxel of a coarse slice lies on cell; without it a fifth do), though nothing
in their attributes says so. The levels must share their centre, as OME-Zarr and COSEM
pyramids whose extents halve evenly do, since a level mirrored about its own centre would
otherwise drift from the others; flip:// refuses a pyramid whose levels do not (as
synthetic:// levels along an odd extent, where a coarse level covers one fine voxel more).
Stitch sources (tiles stitched by interest points and RANSAC)¶
stitch://<BigStitcher project.xml>?channel=0&model=translation&epsilon=5&threshold=0.005
stitches the tiles of one channel of a BigStitcher project when opened, as BigStitcher's
interest-point registration does, then serves them fused, region by region: no fused copy
is written. Each overlap of two tiles' stage positions, grown by margin, is read at the
tiles' level level, and its bright blobs found in both tiles: local maxima of a
difference of Gaussians (sigma, and 2^(1/4) times it, scaled to the same physical size on
every axis) at least threshold of the region's intensity range, located to a fraction of
a voxel. A point's descriptor is the offsets to neighbors of its neighbors + redundancy
nearest neighbours (every such subset), which a shift leaves unchanged; two points match
when their descriptors are the closest pair both ways and significance times closer than
the next candidate. RANSAC then draws iterations minimal samples of the matches, keeps the
model (translation, rigid or affine) that most matches agree with to within
epsilon, refits it to them until they settle, and keeps the pair if it has min_inliers
inliers and min_inlier_ratio of its matches. Finally every tile's correction is fitted to
all the kept matches at once (each round refits each tile to where its neighbours put their
ends of its matches), one tile of each linked group held still.
The fused volume has a level for each level every tile has, the first tile's voxel size
there, covering every placed tile. A region of it is each tile that reaches it, sampled
trilinearly where its placement puts it, averaged with weights that fall off as a half
cosine over blend at the tiles' edges in y and x, so the seams do not show. The project's
images must be OME-Zarr (BigStitcher-Spark's bdv.multimg.zarr loader); a tile's stage
position is its view transforms but the outermost ones named "Stitching Transform" (a
stitching done before), and the log says how far the result lies from that one. The steps
are functions of arrays in chunkmirage_contrib.stitching (numpy and scipy, so the ops extra), the
same code the browser's stitch page runs in Pyodide.
chunkmirage serve 'stitch://https://janelia-bigstitcher-spark.s3.amazonaws.com/Stitching/dataset.xml' --python-viewer
On BigStitcher-Spark's example (a larval fly CNS in 2 × 3 tiles), the defaults keep 8 of its 11 overlaps and put the tiles 0.4 µm (RMS) from BigStitcher's own phase-correlation stitching; opening takes about 9 s, most of it reading the overlaps.
GeoTIFF sources (cloud-optimized GeoTIFFs)¶
https://.../site.tif (s3://, gs:// or a local path too; .tif or .tiff)
The tiled GeoTIFFs most geospatial and planetary rasters are published as, cloud-optimized
ones (COGs) above all, read where they are looked at: the header once (a few range
requests), then each tile a read covers, fetched by range, decoded by tifffile and kept in a
256 MB cache. The pages of decreasing size inside a COG (its overviews) are the levels, so
a zoomed-out view reads the small ones; masks are skipped. One sample per pixel; the axes
are y, x in the projection's units (metres, unitless for a geographic raster in degrees),
y counting down the image as rows do (minus the northing), voxel (0, 0) at the tiepoint
(ModelTiepoint, ModelPixelScale). A float raster's GDAL_NODATA reads as NaN. Strip
TIFFs (untiled) are refused: every read would fetch whole rows. Needs the tiff extra
(tensorstore's own tiff driver reads only uint8 images). The browser engine reads them
with geotiff.js, the same way, as z, y, x with one z.
chunkmirage serve 'https://astrogeo-ard.s3.us-west-2.amazonaws.com/moon/lro/lola/barker_south_pole_dems/Site01/Site01.tif' \
--op hillshade:azimuth=135,altitude=10 --chunk 256,256 --python-viewer
Stored sources¶
Stored arrays are opened and placed by worldarray,
whose specification states the rules below in full; chunkmirage adds xarray coordinates, CF
packing, the spec's overrides and kind. Sources are detected by content, not extension: zarr.json → zarr v3, .zarray → zarr v2,
attributes.json → N5, info → precomputed. A group's levels are the paths in its
multiscales[0].datasets (OME-NGFF, COSEM N5), in that order; without that, s0, s1, ...
are probed. N5 and precomputed data are transposed to C order (z, y, x) on read.
Remote stores are read the way a public dataset most likely lets one in, with credentials
only when a public read is refused. s3:// is read anonymously first, then with AWS's
default credentials (environment, ~/.aws, instance metadata), since S3 refuses even a
public read signed with a stale or foreign key. gs:// is read with Google's default
credentials (anonymously without any), then through the bucket's public
https://storage.googleapis.com URL, for credentials that are there but broken. Which way
worked is remembered per bucket. Metadata reads give up after about five seconds of
retries rather than tensorstore's default of many minutes, so a mistyped host fails fast.
A zarr v2 compressor with members tensorstore rejects, such as the checksum newer
numcodecs writes for zstd, is read without them.
Voxel size, translation, units and axes are read per level, first match wins:
| Format | Metadata, in order of precedence |
|---|---|
| zarr v2/v3 | parent OME-NGFF multiscales entry whose path is this array, composed with the multiscale-level coordinateTransformations if present (0.4/0.5; in 0.6 those lead to other coordinate systems and are applied only through a scene:// source, and axes come from the intrinsic coordinate system); else, for an array xarray wrote (geo, climate and solar data), its dimension names (_ARRAY_DIMENSIONS, or zarr v3 dimension_names) as the axes and the 1-D coordinate array named after each dimension as its spacing and origin, where evenly spaced and increasing (days since … and other CF time units become seconds, degrees unitless); else the array's own resolution/voxel_size, offset, units, axis_names (funlib) or transform (COSEM), C order; else funlib's keys on the parent group |
| N5 | the array's transform (COSEM, C order); the parent's multiscales[].datasets[].transform for this path; funlib's voxel_size and offset (array, else group, C order); pixelResolution/resolution (array, else group) × downsamplingFactors, plus offset, x-first |
| precomputed | resolution (nm) and voxel_offset (the first voxel's corner, in voxels) |
| HDF5 | resolution/voxel_size and offset attributes, C order |
A zarr array with CF packing attributes (scale_factor, add_offset) is read as float32
in its units, stored × scale_factor + add_offset, and its _FillValue (else the array's
fill value) as NaN: NASA's MUR sea temperature, stored as int16 hundredths of a degree
offset from 298.15 K, reads as kelvin with land NaN. Arrays without those attributes are
read as stored. Data whose axes are not named z, y, x (time, lat, lon) keeps its own
names: the OME frontends type a time axis as time (by name or a time unit), and the
viewer helpers show its last three axes as x, y and z.
Unitless z, y, x axes are served as nanometres by every frontend (left without a unit,
Neuroglancer would read OME axes as metres). Units are converted by worldarray's table
(wa.units): to metres and seconds in Neuroglancer links, to nanometres in precomputed's
resolution, a spatial unit it does not know taken as nanometres. Neuroglancer counts zoom
(crossSectionScale) in the smallest scale among the viewer's dimensions, whatever their
units, so beside a time axis in seconds a zoom of 1 is not one pixel per voxel:
chunkmirage.neuroglancer.cross_section_scale(dims, axis, voxels_per_pixel) converts.
offset/translate are in world units. A translation is where voxel 0's centre is, as in
OME-Zarr, COSEM's transform and CF coordinates. Formats that give voxel 0's corner are
moved half a voxel along their spatial axes on reading: precomputed's voxel_offset, and
funlib's offset (0 when only a resolution is given), which is how Neuroglancer and
funlib place them. An N5 level with no offset has its corner at the origin at every
level, as Neuroglancer draws N5: voxel 0's centre is half a voxel of the level on
(downsamplingFactors / 2 × resolution), where BigDataViewer would centre voxel 0 on the
origin. An array nothing places, a stored one
without placement metadata, an xarray dimension without an even coordinate, and every
array made from nothing (synthetic://, an ArrayInfo given no translation), has its
corner at the origin, as Neuroglancer draws a plain array: voxel 0's centre is half a
voxel on along its spatial axes (those named z, y or x, else the last three),
worldarray's rule. Served as precomputed, voxel_offset is the translation less half a voxel,
rounded to whole voxels, so a precomputed source served again keeps its offset, and chunks
are keyed from it (x0-x1_y0-y1_z0-z1 in the scale's voxels, offset included), as
Neuroglancer and tensorstore ask for them. Served as OME-Zarr, the translation is written as
is, always (worldarray's ome_multiscales writes the multiscales), and Neuroglancer moves it
back half a voxel to the corner. So a volume shows in the same place whichever format serves it, except that
precomputed cannot hold half-voxel offsets. Anything in the spec (voxel_size, units,
axes, translation) overrides what was read. The spec's select pins non-spatial axes
to one index each, {"c": 1, "t": 0} (--select c=1,t=0), so the ops see one channel of
one time point as a z, y, x volume; only that channel is read. HDF5 uses file.h5::/dataset and needs
the hdf5 extra.
The browser engine reads the same stored formats in TypeScript (web/src/sources.ts), all
but HDF5, and places them as above (web/src/placement.ts, worldarray's rules in
TypeScript, which npm test runs on worldarray's fixture of metadata layouts): OME-Zarr
v2/v3, zarr arrays outside an OME group (funlib, COSEM or xarray), N5 groups and arrays of
three axes (gzip or raw), and Neuroglancer precomputed volumes, each scale a level, unsharded or sharded
(neuroglancer_uint64_sharded_v1 with the identity hash), encoded raw, jpeg (a
chunk's z planes one tall image, decoded by the browser) or compressed_segmentation. A
precomputed URL may keep Neuroglancer's precomputed://. gs:// and s3:// URLs are
fetched as Neuroglancer fetches them: s3:// at the bucket's https URL, which S3 serves
with CORS for a public bucket; gs:// through the Cloud Storage JSON API
(storage.googleapis.com/storage/v1/b/<bucket>/o/<object>?alt=media, the object's name
encoded as one piece), because the bucket's own URL answers a page with no CORS headers
unless its owner set them, and most have not. The service worker's store cache sees the
API's URLs, and tells metadata from chunks by the decoded name.
Your own schemes (sources from other packages)¶
A package adds a URL scheme of its own the way it adds an op: an opener that takes the URL
and returns a MultiscaleSource (a list of levels, each a Source with an info and a
read(box)), listed under the chunkmirage.sources entry point with the scheme as its
name. open_source and so chunkmirage serve then open myscheme://... like the built-in
schemes, and pipelines, caching and every frontend work on it unchanged. An opener is passed
cache_bytes (tensorstore's pool) and cache (the pipeline's chunk cache) if it takes
them. chunkmirage.sources.register_source(scheme, opener) does the same in a running
process, and schemes() lists what is known. The built-in schemes cannot be replaced. A
format told apart by its path rather than a scheme (a GeoTIFF by its .tif) lists a
matcher instead, path -> opener or None, under chunkmirage.source_matchers (or
register_matcher); open_source asks each in turn for a path with no known scheme before
trying tensorstore. The sources shipped with chunkmirage that compute (synthetic://,
register://, stitch://, GeoTIFFs) are registered this way too, from chunkmirage_contrib
(what stays in the core).
[project.entry-points."chunkmirage.sources"]
myscheme = "mypackage.sources:open_myscheme"
[project.entry-points."chunkmirage.source_matchers"]
myformat = "mypackage.sources:match" # def match(path) -> opener | None
A source suits work that reads the data itself rather than a padded block: a model wrapper that opens its own input, a format with its own reader, data computed from coordinates.