Multi-Pose Data¶
What is Multi-Pose Imaging?¶
In multi-pose fUSI, a probe is physically stepped to a series of positions along one spatial axis. At each position (a pose), one or more volumes are acquired. Stacking the poses together extends the field of view beyond what a single probe position can cover.
The probe at each pose can image a 2D plane or a 3D volume, depending on the probe type:
- Linear probes (e.g., standard linear probes): each pose yields a single 2D image (one elevation slice). Stepping across N poses and stacking gives a 3D volume of N elevation slices.
- 2D probes (e.g., matrix, RCA, or stacked linear probes): each pose already yields a 3D volume. Stepping across N poses concatenates these volumes into a larger 3D volume.
Multiple fUSI systems support this approach, including Iconeus, EchoFrame, and AUTC.
ConfUSIus represents multi-pose data with a pose dimension and per-pose affine
transformations that record the world position of each pose.
Rotational sweeps are not yet supported
consolidate_poses requires a
purely translational sweep, where the probe is shifted along one axis without
rotating. Rotational sweeps (so-called tomographic acquisitions) are not yet
supported and will raise a ValueError.
The Multi-Pose Data Model¶
A multi-pose VoxelData array follows the same dimension order as any other VoxelData
array, (..., time, pose, k, j, i), with pose sitting between time and the native
voxel dimensions k/j/i. This reflects the fact that probe poses are typically
acquired sequentially during a single recording.
pose is not just an extra axis: it changes what the z/y/x world coordinates and
the time coordinate mean, because both are per-pose rather than per-array.
- Geometry is per-pose. Each pose has its own voxel-to-world affine, so the world
coordinates
z/y/xare pose-dependent with shape(pose, k, j, i). Resolving a voxel to a single world position requires selecting a scalarposefirst, e.g.data.isel(pose=0).sel(z=1.2). See The VoxelData Model for the full explanation of pose-dependent voxel-to-world geometry. -
Timing can be per-pose. When poses are acquired sequentially rather than simultaneously, each pose is captured at a slightly different time. The
timecoordinate then holds each pose's own acquisition timestamp directly, shaped(time, pose)instead of the usual 1D(time,)—mirroring howz/y/xrequire a scalarposeselection before they resolve to a single value:This matters for slice timing correction (see Slice Timing Correction below), which accounts for the fact that different poses were not acquired simultaneously.
Per-volume quantities such as confounds and sample masks are timed by the whole volume, not by one pose: their 1D
timecoordinate is the whole-volume time, as a consolidated array would have.
These two rules—pose-dependent world coordinates, pose-dependent timing—drive everything else in this guide: how multi-pose data is loaded and built, what you can and cannot do with it directly, and what consolidation resolves.
Loading and Constructing Multi-Pose Data¶
Iconeus SCAN Files¶
When loading Iconeus SCAN files containing multi-pose data (3Dscan or 4Dscan
acquisition modes), the resulting VoxelData array has a pose dimension. The example
below illustrates a recording from a mouse acquired with an IcoPrime-4D MultiArray
probe—four linear probes stacked along the elevation axis, giving 4 elevation slices
per pose—translated across multiple regularly spaced positions:
>>> from confusius.io import load_scan
>>> pwd = load_scan("sub-01_task-awake_pwd.scan")
>>> pwd
<xarray.DataArray 'scan_data' (time: 409, pose: 4, k: 4, j: 92, i: 118)> Size: 568MB
dask.array<transpose, shape=(409, 4, 4, 92, 118), dtype=float64, chunksize=(106, 4, 4, 92, 106), chunktype=numpy.ndarray>
Coordinates:
time (time, pose) float64 13kB 0.4 2.05 0.95 1.5 ... 899.7 898.6 899.1
* pose (pose) int64 32B 0 1 2 3
* k (k) int64 32B 0 1 2 3
* j (j) int64 736B 0 1 2 3 ... 89 90 91
* i (i) int64 944B 0 1 2 3 ... 116 117
z (pose, k, j, i) float64 1MB ...
y (pose, k, j, i) float64 1MB ...
x (pose, k, j, i) float64 1MB ...
Attributes:
affines: {}
device_serial_number: ASAO0000
software_version: IcoScan v.1.9.0
iconeus_scan_mode: 4Dscan
iconeus_subject: sub-01
iconeus_session: task-awake
iconeus_scan: sub-01_task-awake_pwd
...
The probe was stepped across 4 positions, each contributing 4 elevation slices—a total of 16 slices once consolidated.
Reloading from Zarr¶
Saving unconsolidated multi-pose data to Zarr preserves the pose dimension and the
per-pose affine stack (see Saving below). cf.load
round-trips this transparently: reloading a Zarr store written by
cf.save rebuilds the pose-dependent VoxelToWorldIndex from
attrs["voxel_to_world"], so a reloaded array is indistinguishable from the one that
was saved.
import confusius as cf
pwd = cf.load("sub-01_task-awake_pwd_multipose.zarr") # dims: (time, pose, k, j, i)
Other Systems¶
For fUSI systems without a dedicated loader, multi-pose data must be assembled manually, in one of two ways:
-
From a raw array, using
create_voxeldatawith aposedimension, an(npose, 4, 4)voxel_to_worldaffine stack, and, if poses were acquired sequentially, a 1Dt0array giving each pose's own time origin: -
From independently loaded single-pose arrays, using
stack_posesto combine them into one pose-dependent array.from confusius.multipose import stack_poses poses = [cf.load(f"sub-01_pose-{i:02d}_pwd.nii.gz") for i in range(4)] multipose = stack_poses(poses) # dims: (time, pose, k, j, i)If the individual poses were acquired sequentially rather than simultaneously and carry different
timevalues,stack_posesautomatically produces a pose-dependent(time, pose)coordinate instead of a shared 1Dtime.
Working with Multi-Pose Arrays¶
Most Xarray operations that don't touch world coordinates or timing work on multi-pose
arrays exactly as on any other VoxelData array—reductions, arithmetic, .isel, mask
extraction, and so on all treat pose as an ordinary dimension.
Operations that resolve positions or timestamps, however, need a scalar pose
first, per The Multi-Pose Data Model above:
pose0 = pwd.sel(pose=0)
shallow = pose0.sel(z=slice(0, 2.5)) # world-coordinate selection now works.
subset = pose0.sel(time=slice(10, 60)) # time is now 1D, .sel(time=...) works too.
A number of ConfUSIus functions go further and require multi-pose data to be reduced to a single spatial grid entirely, since they operate on one voxel-to-world affine:
| Operation | Requires |
|---|---|
| Registration (registration guide) | Single pose, or consolidated data |
| Saving to NIfTI (single affine per file) | Single pose, or consolidated data—see Saving |
Slice Timing Correction¶
Because poses in a sequential acquisition are not captured simultaneously, comparing
voxels across poses at the same nominal time index compares samples taken at slightly
different real times. correct_slice_timings
resamples each pose's time series onto a shared, per-volume reference time, removing
this offset. It accepts either unconsolidated data with a pose-dependent (time, pose)
time coordinate, or already-consolidated data with a
slice_time coordinate:
from confusius.multipose import correct_slice_timings
corrected = correct_slice_timings(pwd) # time coordinate becomes a plain 1D index.
Pose Consolidation¶
consolidate_poses merges the pose dimension
and the sweep spatial dimension into a single axis with physically meaningful
coordinates, producing a VoxelData array with a single, non-pose-dependent
voxel-to-world affine. consolidate_poses
performs the following steps:
- Detect the swept voxel dimension (
sweep_dim) from the per-pose affines: match the pose-translation direction against each voxel dimension's world-space direction, and take the best-aligned one. - Read the per-pose affines to compute the world position of every
(pose, sweep_dim)voxel. - Find the precise sweep direction via SVD of all these voxel positions, and project each voxel onto that axis.
- Check that the resulting positions form a regular grid.
- Reindex the data in ascending position order, replacing
poseandsweep_dimwith a single consolidated coordinate in world space.
>>> import confusius as cf
>>> pwd = cf.load("sub-01_task-awake_pwd.scan")
>>> volume = cf.multipose.consolidate_poses(pwd)
>>> volume
<xarray.DataArray 'scan_data' (time: 409, k: 16, j: 92, i: 118)> Size: 568MB
array([...])
Coordinates:
* time (time) float64 3kB 2.05 4.25 6.45 8.65 ... 895.2 897.5 899.7
* k (k) int64 128B 0 1 2 3 ... 13 14 15
slice_time (time, k) float64 52kB 0.4 2.05 0.95 1.5 ... 899.7 898.6 899.1
* j (j) int64 736B 0 1 2 3 ... 89 90 91
* i (i) int64 944B 0 1 2 3 ... 116 117
z (k, j, i) float64 1MB ...
y (k, j, i) float64 1MB ...
x (k, j, i) float64 1MB ...
Attributes:
affines: {}
device_serial_number: ASAO0000
software_version: IcoScan v.1.9.0
iconeus_scan_mode: 4Dscan
iconeus_subject: sub-01
iconeus_session: task-awake
iconeus_scan: sub-01_task-awake_pwd
...
4 poses × 4 slices = 16 consolidated z positions, spanning −4.95 to 2.93 mm in world
space. The pose-dependent time coordinate is reduced to a single whole-volume time
and a slice_time coordinate with dims (time, k): each slice retains the timestamp
of the pose it came from.
After consolidation, the per-pose affine stack is reduced to a single (4, 4) matrix
representing the consolidated volume's orientation in world space.
Regularity requirement
consolidate_poses will raise a
ValueError if the consolidated positions are not regularly spaced within a
relative tolerance of 1% (default rtol=0.01). This check ensures uniform voxel
spacing, which is required for registration and NIfTI export. Non-uniform spacing
typically indicates motor positioning errors.
Saving¶
After Consolidation¶
Once consolidated, a multi-pose array is still a VoxelData array and can be saved to any format:
import confusius as cf
anat = cf.load("sub-01_acq-anat_pwd.scan")
volume = cf.multipose.consolidate_poses(anat)
# Save to NIfTI (creates .nii.gz and a matching fUSI-BIDS JSON sidecar).
volume.fusi.save("sub-01_acq-anat_pwd.nii.gz")
# Or to Zarr.
volume.to_zarr("sub-01_acq-anat_pwd.zarr")
Without Consolidation¶
Non-consolidated data can be saved to Zarr directly, preserving the pose dimension
and all per-pose affines:
Saving non-consolidated data to NIfTI is not straightforward because NIfTI stores a single affine per file. If you need NIfTI output before consolidating (e.g., for per-pose slice timing correction), save each pose as a separate file: