Skip to content

Basic Data Operations

Open in molab

Run this tutorial

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

Most of nltools is built around BrainData. It holds imaging data as an images-by-voxels matrix: one row per image, one column per in-mask voxel. That shape is what makes the class feel like a dataframe — you index it, slice it, do arithmetic on it, and iterate over it with plain Python.

Download a dataset

fetch_pain() retrieves the pain dataset from Chang et al., 2015: 28 subjects with three beta images each, at low, medium and high thermal pain. The files are cached locally on first use and loaded straight into a BrainData.

from nltools.datasets import fetch_pain

data = fetch_pain()
data
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

The image metadata comes with it, on .X, as a polars DataFrame with 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"

Load your own files

A NIfTI file loads by path, and many files load together as a list. The grid comes from the file's own affine: nltools matches it to the closest bundled MNI template, and resamples only when the match is not exact. The active brain space is the default for data that has no grid of its own, not an override for data that does. Pass mask= to pin a specific grid — see Brain Space and Resolution.

from nltools.data import BrainData

one = BrainData("sub-01_pain-high.nii.gz")
many = BrainData(["sub-01_pain-high.nii.gz", "sub-02_pain-high.nii.gz"])
remote = BrainData("https://neurovault.org/media/images/2099/some_map.nii.gz")

Basic operations

len() is the number of images:

len(data)
84

.shape is images by voxels:

data.shape
(84, 238955)

Index with integers, lists of integers, slices, or boolean arrays:

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

Reduce across images to get one value per voxel:

data.mean()
nltools.data.braindata.BrainData(data=(238955,), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)
data.std()
nltools.data.braindata.BrainData(data=(238955,), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

Methods chain, so the mean of 84 images is a single image:

data.mean().shape
(238955,)

Two BrainData objects add and subtract voxelwise:

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

Scalars broadcast over every voxel — here, add 10 and scale by 2:

(data + 10) * 2
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

.copy() gives you an independent object:

data.copy()
nltools.data.braindata.BrainData(data=(84, 238955), resolution=2.0mm, space=mni, mask=2mm-MNI152-2009fsl-mask.nii.gz)

.to_nifti() converts back to a nibabel image — a 3D volume for one image, 4D for a stack — which is how you hand data to nilearn or any other toolbox:

data.to_nifti().shape
(91, 109, 91, 84)

.append() concatenates along the image axis:

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

.write() saves to NIfTI or, with an .h5 extension, to HDF5 — which is smaller and keeps .X, .Y and the mask:

import os
import tempfile

with tempfile.TemporaryDirectory() as _tmpdir:
    _path = os.path.join(_tmpdir, "pain_subset.nii.gz")
    data[:3].write(_path)
    print(
        f"{os.path.getsize(_path) / 1e6:.1f} MB written to {os.path.basename(_path)}"
    )
2.7 MB written to pain_subset.nii.gz

Images are iterable, so a comprehension gives you one value per image:

[image.mean() for image in data[:5]]
[np.float32(-0.32340634), np.float32(-0.21444643), np.float32(-0.111590356), np.float32(-0.099880464), np.float32(-0.06570267)]

Plotting

Convert to nibabel and any nilearn plot works:

from nilearn.plotting import plot_glass_brain

_glass = plot_glass_brain(data.mean().to_nifti())
2026-09-13T00:38:34.686395 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R 0 0.032 0.2 0.39 0.59 0.78

.plot() is the built-in shortcut, and draws a glass brain by default:

data.mean().plot()
2026-09-13T00:38:35.170713 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -0.78 -0.39 -0.032 0.032 0.39 0.78

Calling it on a stack draws one figure per image. limit caps how many, and defaults to 3 so that an 84-image object does not silently produce 84 figures; raise it, or index first, when you want more:

data[:4].plot(limit=4)
[<Figure size 950x350 with 6 Axes>, <Figure size 950x350 with 6 Axes>, <Figure size 950x350 with 6 Axes>, <Figure size 950x350 with 6 Axes>]
2026-09-13T00:38:37.084984 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -2.9 -1.4 -0.15 0.15 1.4 2.9 image 0
2026-09-13T00:38:37.245791 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -2.8 -1.4 -0.082 0.082 1.4 2.8 image 1
2026-09-13T00:38:37.405281 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -2.3 -1.2 -0.045 0.045 1.2 2.3 image 2
2026-09-13T00:38:37.565664 image/svg+xml Matplotlib v3.11.1, https://matplotlib.org/ L R L R -2.2 -1.1 -0.045 0.045 1.1 2.2 image 3