The VoxelData Model¶
VoxelData Arrays¶
VoxelData is ConfUSIus's canonical DataArray model for any spatially referenced voxel array—beamformed IQ and fUSI recordings, atlas volumes, decomposition component maps, displacement fields, and anything else gridded in space.
A VoxelData array is a DataArray that satisfies the following requirements:
- Dimensions
(..., time, pose, k, j, i)in that order, where...maybe be any number of extra dimensions (e.g. decomposition components, channels, etc.) andtimeandposemay be absent. - A
VoxelToWorldIndexattached to the world coordinates(z, y, x), which it derives either from(pose, k, j, i)through one voxel-to-world affine transformation per pose. - Attributes
units,volume_acquisition_reference, andvolume_acquisition_durationontime.
Dimension Ordering: (..., time, pose, k, j, i)¶
VoxelData arrays have the following dimensions, in the order shown:
| Dimension | Coordinate dtype | Optional | Typical axis meaning | Typical size |
|---|---|---|---|---|
... |
any | Yes | Components, regions, ... | Any |
time |
float | Yes | Acquisition time | Thousands |
pose |
int | Yes | Probe poses | Tens |
k |
int | No | Elevation (stacking direction) | One to tens |
j |
int | No | Axial / depth | Tens to hundreds |
i |
int | No | Lateral | Tens to hundreds |
Why (..., time, k, j, i) instead of (i, j, k, time, ...)?
Users familiar with neuroimaging are typically accustomed to spatiotemporal
conventions like (i, j, k, time, ...). These conventions come from languages like
MATLAB and formats like NIfTI where the first axis i varies fastest in storage.
This is the opposite of NumPy's (and most of Python's) default memory layout, where
the last axis varies fastest. ConfUSIus therefore uses (..., time, pose, k, j, i)
to map NIfTI data naturally onto the memory layout used throughout the Python
scientific ecosystem, often without copying or rearranging the data.
Thankfully, Xarray makes dimension ordering transparent in practice: you can always
refer to dimensions by name and in any order (e.g. data.mean("time"),
data.sel(x=4.54, y=-2.48, z=0.0)) rather than by axis index, so you won't have to
remember the order of the dimensions. Moreover,
ensure_voxeldata will automatically
reorder dimensions to the canonical order if they are not already, so you can always
pass a DataArray to ConfUSIus functions without worrying about its dimension order.
This ordering pays off beyond the NumPy memory layout above:
- Contiguous volumes: the last axes are contiguous in memory, so
data[t]is one contiguous block—the natural unit for IQ processing, motion correction, and other volume-wise operations. (samples, features)for free:data.stack(space=["k", "j", "i"])reshapes to(time, space)without copy, matching the scikit-learn/ statsmodels convention for statistical analysis.- Atlas-aligned:
(k, j, i)follows the same orientation as BrainGlobe atlases (e.g. Allen CCFv3). - Visualization-ready: plotting
(time, k, j, i)directly yields a correctly oriented(j, i)slice withtime/ksliders, since many Python tools (e.g., napari) expect the last two axes as the display axes.
Metadata¶
The units, volume_acquisition_reference, and volume_acquisition_duration
attributes from requirement 3 above have the following meanings:
| Attribute | Meaning |
|---|---|
units |
Physical unit of the time coordinate values ("s" is typical). |
volume_acquisition_reference |
Which point of the acquisition window each time value marks: "start", "center", or "end". |
volume_acquisition_duration |
Duration to acquire the whole (k, j, i) grid, in the same units as time. |
Additionally, the VoxelToWorldIndex attached to (z, y, x) carries its own units
attribute, which is the physical unit of the world coordinates (millimeters is typical).
It is accessed through the
.fusi.affine.units property and set with
.fusi.affine.set_units(...).
Don't set attributes on world coordinates directly
World coordinates (z, y, x) are always derived fresh from the
VoxelToWorldIndex; any operation that touches the index (.isel,
.assign_coords, etc.) regenerates them, including their .attrs, from the index's
own state. Mutating data.coords["z"].attrs[...] directly is unsupported: the
mutation is silently discarded the next time the coordinate is rebuilt. The index
does write units onto each world coordinate's .attrs too, purely so external
packages that read it from there still see it — but some xarray operations that
combine several arrays (e.g. xr.where, which only keeps attrs from its x
argument) can drop that copy from the result without touching the index's own
units. Always trust .fusi.affine.units, never .coords["z"].attrs["units"],
when correctness matters.
Creating and Validating VoxelData Arrays¶
To build a VoxelData array from raw arrays (NumPy, CuPy, etc.), use
create_voxeldata. If you then modified a
VoxelData array and want to check that it still satisfies the VoxelData model, use
validate_voxeldata.
All ConfUSIus functions that expect a VoxelData array call
ensure_voxeldata on their input, which
canonicalizes the DataArray first—reordering dimensions to (...extra, time, pose, k,
j, i), restoring a voxel dimension collapsed to a scalar coordinate by a prior
.isel, and filling in missing time metadata with sensible
defaults—before validating.
Temporal Conventions¶
The time coordinate marks when each volume was acquired, in the units named by its
units attribute (seconds, typically). Because a volume is acquired over a nonzero
window rather than instantaneously, time alone doesn't say which point of that window
the value refers to—volume_acquisition_reference disambiguates this: "start",
"center", or "end" of the acquisition window. volume_acquisition_duration gives
the window's length, in the same units as time. Together, these three attributes let
downstream code (e.g. GLM HRF convolution) reconstruct the exact acquisition window of
every volume rather than treating time as an instantaneous sample.
The rest of this section covers how these definitions adapt for multi-pose data, where poses making up one volume are swept through sequentially rather than acquired simultaneously.
Unconsolidated Multi-Pose Data¶
While a VoxelData array still carries a pose dimension, time must be pose-dependent
with shape (time, pose) instead of the plain 1D coordinate above. time then
describes each pose's acquisition timestamp.
In this shape, volume_acquisition_duration and volume_acquisition_reference describe
one pose's own (k, j, i) acquisition window, not the time it takes to sweep
through every pose. Resolving a single real timestamp requires selecting a scalar pose
first, which reduces time back to the plain 1D case.
Consolidated Multi-Pose Data¶
consolidate_poses merges pose into the
sweep voxel dimension the poses were stepped along (i, j, or k, detected
automatically), so the result has no pose dimension left. Two things happen to
timing:
timebecomes an ordinary whole-array 1D coordinate again, butvolume_acquisition_duration/volume_acquisition_referenceare recomputed to describe the full sweep across every pose—from the earliest pose's onset to the latest pose's offset—rather than one pose's window.- Each slice's own real timestamp survives separately as a new
slice_timecoordinate, with dims(time, <sweep_dim>)(or a single dim whentimeis scalar or absent), inheriting the pre-consolidationtime'svolume_acquisition_duration/volume_acquisition_reference.slice_timerequires the same three attributes astimeandensure_voxeldatafills in missing ones the same way, defaultingvolume_acquisition_durationfrom the median consecutive gap between slices along<sweep_dim>.
validate_voxeldata checks that every
slice's slice_time falls within its own volume's acquisition window, as defined by
time and its volume_acquisition_duration/volume_acquisition_reference.
This doesn't forbid overlap between consecutive volumes:
volume_acquisition_duration may exceed the spacing between time values, e.g
sliding-window beamformed IQ processing acquisition). It only constrains a slice against
its own volume's window.
Slice Timing Correction¶
correct_slice_timings uses slice_time
(or an unconsolidated pose-dependent time) to resample each slice's or pose's time
series onto the shared, whole-array time reference—working on both unconsolidated and
consolidated data. See the Multi-Pose Imaging guide
for a full example.
Spatial Conventions¶
To localize a VoxelData array in physical space, ConfUSIus works with four kinds of coordinate systems:
- Array space: the dense, zero-based array position along each spatial dimension.
- Voxel space: the
(i, j, k)coordinate labels attached to the underlying array storage, coinciding with array space only when voxel labels themselves start at0and increase by1. - World space: derived from voxel space through the DataArray's voxel-to-world
affine transformation(s) and exposed as the coordinates
(x, y, z). - Reference spaces: any coordinate system (atlas, scanner, etc.) linked to the world
space through affine transforms stored in
.attrs["affines"].
For most recordings, one voxel-to-world affine defines one world grid for the whole
DataArray. Multi-pose acquisitions are the main exception: they carry one affine per
pose, so (x, y, z) become pose-dependent coordinates and a scalar pose selection
is required before selecting by world coordinate.
Each space feeds the next: array position gets a voxel label, a voxel label gets a world coordinate, and a world coordinate can reach any number of reference spaces:
---
config:
layout: elk
---
flowchart LR
A["<b>Array space</b>"]
V["<b>Voxel space</b>"]
P["<b>World space</b>"]
W1["<b>Scanner space</b>"]
ellipsis{{"..."}}
W2["<b>Atlas space</b>"]
A -->|"integer labels"| V
V -->|"VoxelToWorldIndex"| P
P -->|".attrs[affines]"| W1
P -->|".attrs[affines]"| W2
P --> ellipsis
ellipsis@{ shape: text }
Array Space¶
The array space defines the dense, zero-based position along each spatial dimension:
position 0 is always the array's first stored element, position dim_size - 1 its
last, and every position in between is contiguous. In Xarray,
.isel indexes by array position. If you're used to NumPy,
think of array space as the "axis index" of each dimension, for example data[0, 0, 0]
is the first voxel in array space, data[-1, -1, -1] the last.
Voxel Space¶
The Voxel space is defined by the DataArray's (i, j, k) coordinate labels, indexed by
label with .sel. Labels coincide with array space for a
freshly built DataArray, but the two diverge once a DataArray is cropped or strided from
a larger one:
>>> cropped = data.isel(i=slice(3, 6))
>>> cropped.coords["i"].values
array([3, 4, 5])
>>> cropped.isel(i=0).coords["i"].item() # Array position 0's label.
3
>>> cropped.sel(i=3).coords["i"].item() # The voxel labeled 3.
3
Use reindex_voxels to rebase voxel labels back to
dense array-space positions—see Rebasing voxel coordinates to dense
positions in Working with
Xarray.
World Space¶
The world space is defined by the DataArray's voxel-to-world affine transformation (or
transformations, for multi-pose data) contained in the VoxelToWorldIndex and exposed
as coordinates (x, y, z). For ordinary single-pose data these coordinates are arrays
with shape (k, j, i). For multi-pose data they are pose-dependent with shape (pose,
k, j, i), so selecting in world space requires a scalar pose first. The unit shared
by x/y/z is exposed as data.fusi.affine.units and set with
data.fusi.affine.set_units(...); millimeters are the usual default for fUSI
recordings.
Units are not enforced
ConfUSIus does not check or convert between units across its APIs—units is
metadata only. We plan to make the data model more unit-aware in the future.
World space is not tied to any one physical space—it's whatever space the DataArray's voxel-to-world affine currently encodes, and that changes over the course of a pipeline. A freshly loaded recording is typically expressed in scanner space: the space of the first acquired probe pose, with origin at the probe surface and axes along lateral, depth, and elevation. Once the data is resampled or registered, world space becomes whatever space that operation targeted instead: an atlas template (e.g. Allen CCFv3), another recording's grid, or any other space you choose.
World coordinates are set when attaching a VoxelToWorldIndex to the DataArray.
Different loaders derive them in different ways:
- EchoFrame: Lateral and axial coordinates are read from the acquisition metadata file.
- AUTC: Spacing must be supplied explicitly to
convert_autc_dats_to_zarr—AUTC files carry no spacing metadata of their own. Origin is optional and defaults to a probe-centered, surface-referenced position when omitted. - Iconeus SCAN: Coordinates are derived from the
voxelsToProbeaffine embedded in the SCAN file. The axial axis is flipped so that it is always positive and increases with depth. - NIfTI: Coordinates are derived from the "best" affine transformation found in the
file header, or from whichever one
load_nifti'scoordinate_affineargument selects explicitly.
The "best" NIfTI affine
NIfTI files can store two affine transforms in their header: qform and a
sform, each with an associated integer code indicating whether the affine is
valid (code > 0) and which space it points to.
qformcannot contain shears and is typically used to encode transforms from voxel space to scanner space.sformcan be any arbitrary affine transformation and is typically used to encode transforms from voxel space to "standard" reference spaces, such as a recording's world space or an atlas space.
By default, ConfUSIus follows the same logic as
NiBabel: if
sform_code > 0 the sform is used to define the world coordinates; otherwise, if
qform_code > 0 the qform is used. If both codes are zero a warning is emitted
and coordinates fall back to a diagonal affine built from the NIfTI pixdim field.
Pass coordinate_affine="sform"/"qform" to force one explicitly.
Hand-constructed DataArrays get whatever voxel-to-world affine the user provides via
create_voxeldata.
Voxel-to-World Affine¶
The voxel-to-world affine is a (4, 4) homogeneous matrix (or a (pose, 4, 4) stack
for multi-pose data) mapping (k, j, i) voxel-space coordinates to (z, y, x)
world-space coordinates:
The voxel-to-world affine can be read with
.fusi.affine.voxel_to_world, or
replaced outright with
.fusi.affine.set_voxel_to_world.
That raw mapping is rarely what you want directly, though: most tools that consume
world-space geometry think in array space, not in whatever labels the
(k, j, i) coordinates happen to carry after upstream cropping or striding. If you
specifically need the affine mapping array space to world space (that packages like
NiBabel or the NIfTI format expect) call
reindex_voxels first: it rebases the voxel space to
the array space and updates voxel_to_world to match, so the affine you then read off
directly maps dense array positions instead of labels.
Origin, Spacing, Direction¶
.fusi.origin,
.fusi.spacing, and
.fusi.direction (see Global
Helpers in Working with Xarray) describe the same
voxel-to-world affine, but anchored to array space rather than voxel labels. They are
the typical parameters used to describe a world-space grid in neuroimaging tools like
ITK:
- origin: world position of the array space's origin. Keyed by world axis
(
z/y/x). - spacing: world distance covered by one array-space step. Keyed by voxel axis
(
k/j/i). - direction:
(3, 3)matrix of unit world-space direction vectors, one per array-space axis: columns are voxel axes (k/j/i), rows are world axes (z/y/x). Direction flips sign on an axis whose labels run descending (e.g. after.isel(dim=slice(None, None, -1))).
For example, if k's coordinates are [0, 2, 4] (every other voxel was kept, e.g. by
.isel(k=slice(None, None, 2))) and the voxel-to-world affine's scale along k is
0.1, then data.fusi.spacing["k"] is 0.2, not 0.1: consecutive array positions
are two sampled voxels apart. Together, origin/spacing/direction reconstruct
world coordinates from array position rather than from labels:
Reference Spaces¶
ConfUSIus stores affine transformations relating the DataArray's current world space to
any number of other named spaces in .attrs["affines"], a dictionary keyed by affine
name. Reference spaces can be an atlas space, a scanner or lab space, another
recording's world space, or a space the data has already moved away from (e.g. the raw
probe-relative space a recording started in).
A space counts as a "reference space" rather than the world space only because
reaching it currently takes an affine.
.fusi.affine.apply can turn any one of
them into the world space itself (see Switching World
Spaces). Each reference space is stored in .attrs["affines"]
as a homogeneous affine matrix in (z, y, x) convention that maps a world-space point
to the corresponding point in the reference space. Most are plain (4, 4) matrices;
for multi-pose data they may also be stacked (pose, 4, 4) affines with one entry per
pose.
Several loaders populate .attrs["affines"] automatically:
- NIfTI: The world space is whichever affine (
sformorqform) was selected; its own entry ("world_to_sform"or"world_to_qform") is the identity. When the other one is also valid, it is stored under the other key, so the world space can be switched between the two.save_niftican write any named affine in.attrs["affines"]back to the header via itsqform=/sform=arguments, defaulting to"world_to_qform"/"world_to_sform"when not specified. - Iconeus SCAN:
load_scanstores a"world_to_lab"affine mapping ConfUSIus world coordinates to the Iconeus lab coordinate system. For multi-pose acquisitions (3Dscan,4Dscan), one affine per pose is stored, with shape(pose, 4, 4).
Registration transforms are handled separately from .attrs["affines"].
register_volume returns the estimated
transform explicitly but does not store automatically in .attrs["affines"].
Why store affines as world → reference instead of voxel → reference?
A voxel → reference affine breaks the moment the voxel space is reindexed—via
reindex_voxels or when saving to NIfTI. A world
→ reference affine doesn't have this problem: it operates on world coordinate
values, which stay physically correct through reindexing.
save_nifti reconstructs the voxel → reference NIfTI
affine it needs by composing the stored world → reference affine with the
DataArray's current voxel → world affine at save time.
Switching World Spaces¶
To switch a DataArray's world space to one of its reference spaces, apply the affine
that relates them: .fusi.affine.apply
takes a (4, 4) affine (or a key into .attrs["affines"]) and re-expresses the
DataArray's world coordinates in that space, which becomes the new world space. For
multi-pose data, applying a single affine broadcasts it over every pose, while a
stacked affine is applied pose-by-pose.
Take the sform/qform example from Reference Spaces. A NIfTI
file with sform_code > 0 anchors its world space to the sform space, and the
relationship to the qform space (a 90° rotation in the (z, y) plane, here) is
carried on the world_to_qform affine attribute:
>>> da.coords["z"].values.flatten()
array([-1., -1., -1., -1., 0., 0., 0., 0., 1., 1., 1., 1.])
>>> da.coords["y"].values.flatten()
array([0.5, 1.5, 2.5, 3.5, 0.5, 1.5, 2.5, 3.5, 0.5, 1.5, 2.5, 3.5])
>>> da.attrs["affines"]["world_to_qform"]
array([[ 0., -1., 0., 0.],
[ 1., 0., 0., 0.],
[ 0., 0., 1., 0.],
[ 0., 0., 0., 1.]])
Applying world_to_qform absorbs the rotation into the DataArray's voxel-to-world
affine, and the derived z/y coordinates change accordingly. The qform space
becomes the new world space, and "world_to_qform" is dropped from the result:
applying a stored affine by its own key re-anchors the world space to exactly that
space, so the entry would carry no information any more. The sform entry, identity
before, now holds the inverse rotation, so the sform space stays recoverable.
>>> da_q = da.fusi.affine.apply("world_to_qform")
>>> da_q.coords["z"].values.flatten()
array([-0.5, -1.5, -2.5, -3.5, -0.5, -1.5, -2.5, -3.5, -0.5, -1.5, -2.5,
-3.5])
>>> da_q.coords["y"].values.flatten()
array([-1., -1., -1., -1., 0., 0., 0., 0., 1., 1., 1., 1.])
>>> da_q.attrs["affines"]
{'world_to_sform': array([[ 0., 1., 0., 0.],
[-1., 0., 0., 0.],
[ 0., 0., 1., 0.],
[ 0., 0., 0., 1.]])}
See Affine Transforms in Working with Xarray for
the rest of the .fusi.affine API (voxel_to_world, set_voxel_to_world, to).