Skip to content

Working with BrainData

Open in molab

Run this tutorial

This page is rendered from the marimo notebook docs/tutorials/data-operations/01_brain_data.py. Click the badge to run it in the cloud (free, no install), or locally: download 01_brain_data.py and run uvx marimo edit --sandbox 01_brain_data.py. The outputs below were produced when this page was built.

BrainData is the object almost every nltools analysis starts from: imaging data as an images-by-voxels matrix, one row per image and one column per voxel inside the mask. That shape is what makes it behave like a dataframe — index it, slice it, do arithmetic on it, iterate over it, and the metadata follows.

Load

fetch_pain() retrieves the pain dataset from Chang et al., 2015: 28 subjects with one contrast image each at low, medium and high pain intensity, cached locally on first use. The joblib cache below keeps the docs build from reloading 84 images on every run; call fetch_pain() directly in your own code.

Printing an object reports its shape, grid and mask, and len() is the number of images. .data is the numpy array underneath, .X a polars DataFrame of per-image metadata, and .Y outcomes or labels the same way:

from joblib import Memory

from nltools.data import BrainData
from nltools.datasets import fetch_pain

memory = Memory(".tutorial-cache", verbose=0)
data = memory.cache(fetch_pain)()

print(data)
print(f".data is a {type(data.data).__name__} of shape {data.data.shape}")
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)
.data is a ndarray of shape (84, 238955)

.X, one row per image:

data.X.head()
shape: (5, 8)
filenameSubjectIDPainLevelPainIntensityAgeSexneurovault_idname
stri64i64stri64stri64str
"sub-01_pain-low.nii.gz"11"low"29"Female"7540"Pain Subject 1 Low"
"sub-01_pain-medium.nii.gz"12"medium"29"Female"7541"Pain Subject 1 Medium"
"sub-01_pain-high.nii.gz"13"high"29"Female"7539"Pain Subject 1 High"
"sub-02_pain-low.nii.gz"21"low"25"Male"7570"Pain Subject 2 Low"
"sub-02_pain-medium.nii.gz"22"medium"25"Male"7571"Pain Subject 2 Medium"

Your own data loads by path, and many files load together as a list. BrainData also takes a URL, a nibabel image, or a list of any of those.

one = BrainData("sub-01_pain-high.nii.gz")
many = BrainData(["sub-01_pain-high.nii.gz", "sub-02_pain-high.nii.gz"])

Pass mask to put an object on a specific grid — see Brain space and resolution for what decides the grid when you do not.

Index and slice

Indexing works the Python way, and every form carries .X and .Y along with the images it keeps: an integer gives one image, a slice a range of them on the same voxels and grid, a list of integers any images in any order, and a boolean array — usually from a column of .X — filters images. append concatenates along the image axis, and objects are iterable:

One image, by integer:

print(data[0])
nltools.data.braindata.BrainData(data=(238955,), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

A range of images, by slice:

print(data[:5])
nltools.data.braindata.BrainData(data=(5, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

Any images in any order, by list:

print(data[[0, 10, 20, 30]])
nltools.data.braindata.BrainData(data=(4, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

The high-pain images, by boolean filter:

high_pain = data[data.X["PainLevel"] == 3]

print(f"{len(high_pain)} high-pain images")
28 high-pain images

Two objects concatenated:

print(data[:2].append(data[4]))
nltools.data.braindata.BrainData(data=(3, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

Iterating over a stack, one image at a time:

[round(float(image.data.mean()), 2) for image in data[:5]]
[-0.32, -0.21, -0.11, -0.1, -0.07]

Arithmetic and statistics

Scalars broadcast over every voxel, and two objects add and subtract voxelwise, so subtracting two images gives one contrast map. Multiplying by a vector as long as the stack is a weighted sum across images instead, which is how you write a contrast over more than two conditions. mean and std reduce across images by default (axis=0), giving one value per voxel; axis=1 reduces across voxels. Methods chain, so temporal signal-to-noise ratio is one expression.

standardize z-scores or centers each voxel across images. threshold zeros everything below upper, everything above lower, or everything between the two when you pass both, resolving a percentile string over the nonzero voxels. binarize=True turns the survivors into ones, which is how you get a mask out of a map:

print((data + 10) * 2)
print(data[1] - data[0])
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)
nltools.data.braindata.BrainData(data=(238955,), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)
mean_map = data.mean()
tsnr = mean_map / data.std()

tsnr.plot(title="Temporal signal-to-noise ratio")
2026-10-08T18:20:35.688879 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -1.6 -0.8 0 0.8 1.6 Temporal signal-to-noise ratio
z_scored = data.standardize(method="zscore")

print(f"z-scored mean across voxels: {z_scored.data.mean():.6f}")
z-scored mean across voxels: 0.000000
top_voxels = mean_map.threshold(upper="95%", binarize=True)

print(f"top 5% of the mean map: {top_voxels.data.sum():.0f} voxels")
top 5% of the mean map: 11693 voxels
mean_map.threshold(upper="95%").plot(cmap="Blues", title="Top 5% of voxels")
2026-10-08T18:20:36.446589 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R 0 0.2 0.39 0.59 0.78 Top 5% of voxels

Save

write saves to NIfTI, or to HDF5 when the file name ends in .h5 or .hdf5. Both load back with BrainData, but only HDF5 preserves .X, .Y and the mask: a NIfTI file holds the images and nothing else, so a round trip through it loses the metadata.

import tempfile
from pathlib import Path

tmpdir = Path(tempfile.mkdtemp())
nifti_path = tmpdir / "pain_subset.nii.gz"
h5_path = tmpdir / "pain_subset.h5"

data[:3].write(str(nifti_path))
data[:3].write(str(h5_path))

from_nifti = BrainData(str(nifti_path))
from_h5 = BrainData(str(h5_path))

print(f"NIfTI {nifti_path.stat().st_size / 1e6:.1f} MB")
print(f"  -> {from_nifti.shape}, X has {from_nifti.X.shape[1]} columns")
print(f"HDF5  {h5_path.stat().st_size / 1e6:.1f} MB")
print(f"  -> {from_h5.shape}, X has {from_h5.X.shape[1]} columns")
NIfTI 2.7 MB
  -> (3, 238955), X has 0 columns
HDF5  2.7 MB
  -> (3, 238955), X has 8 columns

Plot

plot draws a glass brain by default, and method='slices' draws cross-sections, one row per axis in view; the remaining keywords go to the matching nilearn plot. On a stack it draws one figure per image up to limit, which defaults to 3 so an 84-image object does not silently produce 84 figures. plot_surf projects the volume onto the fsaverage pial surface as a lateral and medial montage, plot_flatmap onto a flattened surface, and to_nifti hands back a nibabel image — 3D for one image, 4D for a stack — for anything these do not cover:

mean_map.plot(title="Mean activation")
2026-10-08T18:20:37.199158 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -0.78 -0.39 0 0.39 0.78 Mean activation
data[:2].plot(limit=2)
[<Figure size 950x350 with 6 Axes>, <Figure size 950x350 with 6 Axes>]
2026-10-08T18:20:37.843357 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -2.9 -1.4 0 1.4 2.9 image 0
2026-10-08T18:20:37.952658 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -2.8 -1.4 0 1.4 2.8 image 1
mean_map.plot(method="slices", view="xyz")
2026-10-08T18:20:38.835984 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R z=-40 L R z=-31 L R z=-22 L R z=-13 L R z=-4 L R z=5 L R z=14 L R z=23 L R z=32 L R z=41 L R z=50 L R z=59 L R z=68 -0.78 -0.39 0 0.39 0.78
2026-10-08T18:20:39.591821 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ x=-50 x=-42 x=-34 x=-26 x=-18 x=-10 x=-2 x=6 x=14 x=22 x=30 x=38 x=46 -0.78 -0.39 0 0.39 0.78
2026-10-08T18:20:40.306117 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R y=-80 L R y=-70 L R y=-60 L R y=-50 L R y=-40 L R y=-30 L R y=-20 L R y=-10 L R y=0 L R y=10 L R y=20 L R y=30 L R y=40 -0.78 -0.39 0 0.39 0.78
mean_map.plot_surf()
2026-10-08T18:20:41.519681 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ −0.6 −0.4 −0.2 0.0 0.2 0.4 0.6
mean_map.plot_flatmap()
2026-10-08T18:20:42.494237 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ −0.6 −0.4 −0.2 0.0 0.2 0.4 0.6
from nilearn.plotting import plot_stat_map

_display = plot_stat_map(mean_map.to_nifti(), display_mode="z", cut_coords=5)
2026-10-08T18:20:43.514720 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R z=-16 L R z=4 L R z=22 L R z=46 L R z=74 -0.78 -0.39 0 0.39 0.78

iplot returns an interactive niivue viewer: a WebGL anywidget with a threshold slider above the volume. Drag the slider (or right-drag the image) to window the map, scroll through slices, scrub 4D frames, render in 3D, and overlay an atlas with hover-to-label. controls=False hides the slider, colorbar=False the colorbar. It needs a live kernel, so this static page shows a placeholder instead:

mean_map.iplot()
Interactive viewer (needs a live kernel)

Masks and ROIs

A mask restricts an object to the voxels you care about. Masks come from three places: a sphere around a coordinate, a parcellation split into its regions, and a thresholded statistic map.

create_sphere draws binary spheres on the current brain space's grid. Centers are MNI millimeter coordinates and radius is in millimeters, so the same request covers the same physical volume whatever grid you are on. apply_mask rewrites the voxel axis to the mask's own support: a sphere inside the brain leaves a much narrower object, and the voxels it drops are gone rather than zeroed. It needs the mask on the object's grid already; extract_roi is the one that resamples, collapsing each region of a mask to one number per image — here the mean signal in the sphere for each of the 84 images:

from nltools.mask import create_sphere

sphere = create_sphere([0, 0, 0], radius=30)
sphere_data = data.apply_mask(sphere)

print(f"whole brain:  {data.shape}")
print(f"30 mm sphere: {sphere_data.shape}")
whole brain:  (84, 238955)
30 mm sphere: (84, 14147)
sphere_data.mean().plot()
2026-10-08T18:20:44.651832 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -0.35 -0.17 0 0.17 0.35
import matplotlib.pyplot as plt

roi_mean = data.extract_roi(sphere)
_fig, _ax = plt.subplots(figsize=(8, 3))
_ax.plot(roi_mean)
_ax.set(xlabel="image", ylabel="mean signal", title="30 mm sphere at [0, 0, 0]")
[Text(0.5, 0, 'image'), Text(0, 0.5, 'mean signal'), Text(0.5, 1.0, '30 mm sphere at [0, 0, 0]')]
2026-10-08T18:20:45.293200 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ 0 20 40 60 80 image −0.4 −0.2 0.0 0.2 0.4 0.6 0.8 1.0 mean signal 30 mm sphere at [0, 0, 0]

A parcellation is one image whose voxel values are integer region IDs. nltools ships several; this is a 50-region whole-brain parcellation, fetched from the nltools data repository on Hugging Face. expand_mask turns those IDs into a stack of 50 binary masks, one per region, and collapse_mask folds such a stack back into one labeled image, numbering the regions in stack order and dropping any overlap.

A statistic map becomes a mask the same way: averaging the high-pain images and keeping the tails outside the middle 95% gives a thresholded map, and regions splits that into one image per blob — smoothing the map first, cutting each connected component at its local peaks, and dropping anything smaller than min_region_size (1350 mm³). The count is printed below; index the stack, or raise limit, to draw more than the first three:

from nltools.datasets import fetch_resource
from nltools.mask import collapse_mask, expand_mask

_k50 = "masks/default/2mm-MNI152-2009fsl-k50.nii.gz"
parcellation = BrainData(fetch_resource(_k50))
regions = expand_mask(parcellation)

print(f"{parcellation.shape} labeled image -> {regions.shape} binary masks")
(238955,) labeled image -> (50, 238955) binary masks
regions[:3].plot()
[<Figure size 950x350 with 6 Axes>, <Figure size 950x350 with 6 Axes>, <Figure size 950x350 with 6 Axes>]
2026-10-08T18:20:46.050730 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R 0 0.25 0.5 0.75 1 image 0
2026-10-08T18:20:46.158183 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R 0 0.25 0.5 0.75 1 image 1
2026-10-08T18:20:46.265615 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R