Load multi-pose recordings¶
This example shows how to assemble a multi-pose fUSI recording that was not acquired through an Iconeus SCAN file, by loading each pose as a separate NIfTI file and stacking them into a single VoxelData array with pose-dependent voxel-to-world geometry.
The chunk- entity (chunk-0, chunk-1, ...); this is exactly the "Other Systems"
case described in the Multi-Pose Imaging guide
— data must be assembled manually rather than loaded as one file, unlike Iconeus
SCAN's 3Dscan/4Dscan modes.
Fetch one recording's chunks¶
We select one subject, session, and acquisition so only the four chunks belonging to a single recording are downloaded, rather than the full multi-gigabyte dataset.
import re
from pathlib import Path
import matplotlib as mpl
import xarray as xr
import confusius as cf
# Adapt background color to the current Matplotlib style.
bg_color = mpl.colors.to_hex(mpl.rcParams["figure.facecolor"])
xr.set_options(display_expand_data=False)
subject = "cp230420a"
session = "1MEDISOses5"
acq = "3dfusi"
bids_root = cf.datasets.fetch_pepe_mariani_2026(
datasets="rawdata",
subjects=subject,
sessions=session,
acqs=acq,
datatypes="fusi",
)
If you use this dataset in your work, please cite the following source: Pepe, C., Mariani, J.-C., Urosevic, M., Gini, S., Stuefer, A., Ricci, F., Galbusera, A., Iurilli, G., & Gozzi, A. (2026). Structural and dynamic embedding of the mouse functional connectome revealed by functional ultrasound imaging (Fusi). https://doi.org/10.64898/2026.02.05.704055
Locate and load every chunk¶
Each chunk is an ordinary single-pose fUSI recording — (time, k, j, i) with a
singleton k, since a linear probe images one elevation slice per pose — with its own
voxel_to_world affine derived from that file's own NIfTI header. We sort by the
chunk- index so poses end up in acquisition order, then load and compute each one
(they're small enough to fit comfortably in memory).
fusi_dir = Path(bids_root) / f"sub-{subject}" / f"ses-{session}" / "fusi"
chunk_glob = (
f"sub-{subject}_ses-{session}_task-rest_acq-{acq}_probe-linear_chunk-*_pwd.nii.gz"
)
chunk_paths = sorted(
fusi_dir.glob(chunk_glob),
key=lambda p: int(re.search(r"chunk-(\d+)", p.name).group(1)),
)
chunks = [cf.load(p).compute() for p in chunk_paths]
npose = len(chunks)
print(f"{npose} poses, each shaped {chunks[0].dims} = {chunks[0].shape}")
Each chunk's own origin confirms the poses sit at different physical positions along
elevation (z), stepped by about 1 mm, and were acquired with slightly offset start
times — this is the per-pose geometry we want to preserve, not collapse into one
shared affine. y/x origins don't vary across chunks, so we only show z/time.
origins = [chunk.fusi.origin for chunk in chunks]
print("z origins:", tuple(origin["z"] for origin in origins))
print("time origins:", tuple(origin["time"] for origin in origins))
Stack the poses into one pose-dependent DataArray¶
stack_poses concatenates independently loaded
single-pose grids into one DataArray with a pose dimension, one voxel-to-world
affine per pose. Poses were also acquired sequentially rather than simultaneously, so
each chunk's time coordinate is offset from the others by a fraction of the
repetition time; when per-pose timestamps differ, stack_poses keeps them as a
pose-dependent (time, pose)-shaped time coordinate rather than collapsing them
into one shared time axis.
Pose-transparent vs. pose-specific geometry¶
Some geometry queries are well-defined without picking a pose: voxel spacing must be
identical across poses (a stacked affine with differing scale is rejected at
construction), so .fusi.spacing works
directly on the multi-pose array.
Origin and direction, however, are inherently single-grid concepts—there is no one answer for "the origin" of a stack of differently-positioned grids—so they require selecting a scalar pose first.
World-coordinate .sel() requires a scalar pose
.sel(z=..., y=..., x=...) raises ValueError on pose-dependent data unless
pose was already reduced to a scalar, e.g. .isel(pose=0).sel(z=..., y=...,
x=...). Resolving a world coordinate to a voxel means picking one pose's
affine first; ConfUSIus never resamples poses onto a shared grid implicitly.
pose0_origin = multipose.isel(pose=0).fusi.origin
pose3_origin = multipose.isel(pose=3).fusi.origin
print("spacing (pose-transparent):", multipose.fusi.spacing)
print("z origins (pose 0, 3):", (pose0_origin["z"], pose3_origin["z"]))
print("time origins (pose 0, 3):", (pose0_origin["time"], pose3_origin["time"]))
Visualize each pose¶
We plot the temporal mean of each pose side by side with
plot_volume's slice_mode="pose", which labels
each panel by its pose value.
World-coordinate slice modes require a scalar pose
For the same reason as .sel, plot_volume(..., slice_mode="z") (or "y"/"x")
also raises ValueError on pose-dependent data, since display is always in
world space. Slicing along pose itself works fine, since it doesn't require
picking one affine.
mean_db = multipose.mean("time").fusi.scale.db().isel(k=0)
plotter = cf.plotting.plot_volume(
mean_db,
slice_mode="pose",
cbar_label="Power Doppler (dB)",
bg_color=bg_color,
yincrease=True,
)
Consolidate into a single volume¶
Since every pose here shares the same rotation and is offset by a pure translation
along elevation, consolidate_poses can
merge pose and the swept voxel dimension (k, the default) into one physically
ordered axis — the same operation load_scan-produced 3Dscan/4Dscan data goes
through, now working directly off the DataArray's own per-pose geometry rather than a
separately stored affine.
- time: 1500
- k: 4
- j: 82
- i: 128
- 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
array([[[[0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], ..., [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.]], [[0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], ..., [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.]], [[0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], ..., ... ..., [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.]], [[0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], ..., [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.]], [[0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], ..., [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.], [0., 0., 0., ..., 0., 0., 0.]]]], shape=(1500, 4, 82, 128)) - time(time)float640.0 2.4 4.8 ... 3.595e+03 3.598e+03
- units :
- s
- volume_acquisition_reference :
- start
- volume_acquisition_duration :
- 2.2
array([0.0000e+00, 2.4000e+00, 4.8000e+00, ..., 3.5928e+03, 3.5952e+03, 3.5976e+03], shape=(1500,)) - k(k)int640 1 2 3
array([0, 1, 2, 3])
- slice_time(time, k)float641.8 1.2 0.6 ... 3.598e+03 3.598e+03
- units :
- s
- volume_acquisition_reference :
- start
- volume_acquisition_duration :
- 0.3999999999999999
array([[1.8000e+00, 1.2000e+00, 6.0000e-01, 0.0000e+00], [4.2000e+00, 3.6000e+00, 3.0000e+00, 2.4000e+00], [6.6000e+00, 6.0000e+00, 5.4000e+00, 4.8000e+00], ..., [3.5946e+03, 3.5940e+03, 3.5934e+03, 3.5928e+03], [3.5970e+03, 3.5964e+03, 3.5958e+03, 3.5952e+03], [3.5994e+03, 3.5988e+03, 3.5982e+03, 3.5976e+03]], shape=(1500, 4)) - j(j)int640 1 2 3 4 5 6 ... 76 77 78 79 80 81
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81]) - i(i)int640 1 2 3 4 5 ... 123 124 125 126 127
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127]) - z(k, j, i)float64-2.995 -2.995 ... 0.0056 0.0056
- units :
- mm
[41984 values with dtype=float64]
- y(k, j, i)float64-0.0369 -0.0369 ... -8.137 -8.137
- units :
- mm
[41984 values with dtype=float64]
- x(k, j, i)float640.0112 0.1212 ... 13.87 13.98
- units :
- mm
[41984 values with dtype=float64]
- timePandasIndex
PandasIndex(Index([ 0.0, 2.4, 4.8, 7.199999999999999, 9.6, 12.0, 14.399999999999999, 16.8, 19.2, 21.599999999999998, ... 3576.0, 3578.4, 3580.7999999999997, 3583.2, 3585.6, 3588.0, 3590.4, 3592.7999999999997, 3595.2, 3597.6], dtype='float64', name='time', length=1500)) - kPandasIndex
PandasIndex(Index([0, 1, 2, 3], dtype='int64', name='k'))
- jPandasIndex
PandasIndex(Index([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81], dtype='int64', name='j')) - iPandasIndex
PandasIndex(Index([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ... 118, 119, 120, 121, 122, 123, 124, 125, 126, 127], dtype='int64', name='i', length=128)) - z
y
xVoxelToWorldIndex<confusius._utils.geometry.VoxelToWorldIndex object at 0x7f0852302090>
- sform_code :
- 2
- manufacturer :
- Iconeus
- manufacturers_model_name :
- Iconeus One
- probe_type :
- linear
- probe_model :
- IcoPrime
- probe_central_frequency :
- 15.625
- probe_number_of_elements :
- 128
- probe_pitch :
- 0.11
- probe_radius_of_curvature :
- 0
- depth :
- [1, 10]
- transmit_frequency :
- 15.625
- pulse_repetition_frequency :
- 5500
- probe_voltage :
- 25
- sequence_name :
- default
- clutter_filter_window_duration :
- 400
- clutter_filters :
- ['Fixed-threshold SVD [60-200]']
- power_doppler_integration_duration :
- 400
- probe_focal_width :
- 0.4
- probe_aperture :
- 1.5
- probe_focal_depth :
- 8
- plane_wave_angles :
- [-10, -8, -6, -4, -2, 0, 2, 4, 6, 8, 10]
- compound_sampling_frequency :
- 500
- task_name :
- Sedation
- task_description :
- Sedated mouse in stereotaxic frame.
- affines :
- {'world_to_sform': array([[1., 0., 0., 0.], [0., 1., 0., 0.], [0., 0., 1., 0.], [0., 0., 0., 1.]])}
The consolidated volume has a genuine k extent of npose elevation slices (each
pose here contributes exactly one, since the probe is linear) and no more pose
dimension, positioned by real physical location rather than acquisition order.
plotter = cf.plotting.plot_volume(
consolidated.mean("time").fusi.scale.db(),
slice_mode="z",
cmap="gray",
cbar_label="Power Doppler (dB)",
bg_color=bg_color,
yincrease=True,
)
Total running time: 12.9 s



