Note
Go to the end to download the full example code.
Template-Space Projection¶
While data is shared in a subject’s own T1w space, it is possible to move the maps onto a shared template, making them ready for group-level comparison.
Two routes share the same to_template entry point:
The volume route lands on MNI305 via the linear affine
FreeSurfer fits during recon-all (stored under the historical
name talairach.lta).
The surface route walks the volume through vol_to_surf onto
the subject’s fsnative mesh, then resamples onto
fsaverage.
Both routes accept single maps, batched
(n_trials, n_voxels) arrays, and fsnative-surface arrays.
Note
Run plot_01 first so the shared
data directory has the FreeSurfer recon and anatomical mask
this example reads. Install the template extras with
uv sync --extra template or
pip install "laion-fmri[template]".
Pick a per-voxel map to project¶
Anything that has one value per brain-mask voxel can travel through the projection. A session-level noise-ceiling map is a convenient starting point but mean betas, decoding accuracies, or any other per-voxel summary slot in the same way.
NC shape: (272080,)
NC range: [0.00, 95.08]
NC > 0.2: 132612 voxels above threshold
Volume route: T1w → MNI305¶
Starting with the volume route: to_template reads the
recon’s talairach.lta affine and resamples the
noise-ceiling map onto the MNI305 reference grid that
templateflow ships. The return is a 3-D NIfTI, ready for
further handling. Here, the focus is on visualization.
Volumetric alignment from a single linear affine is good
enough for whole-brain visualizations, but cortical analyses
that need sub-millimetre accuracy will do better with the
surface route further down.
mni305_img = sub.to_template(nc, "MNI305")
print(f"MNI305 image: shape={mni305_img.shape}, "
f"dtype={mni305_img.get_data_dtype()}")
MNI305 image: shape=(172, 220, 156), dtype=float32
Visualize the MNI305 output¶
A good way to convince yourself the projection worked is to
overlay the resampled map on the matching MNI305 anatomy and
see whether high noise-ceiling voxels land where they should.
Building that figure also makes a nice excuse to walk through
how laion_fmri plays with the wider neuroimaging
ecosystem, so the code below is intentionally a little more
verbose than strictly necessary. The plan is to (i) pull the
MNI305 T1w and brain mask from templateflow, (ii) use
nilearn to multiply the T1w by the brain mask and crop
to the brain’s bounding box so the backdrop shows cortex
rather than skull and air, (iii) threshold the noise-ceiling
map at 10% variance explained, and (iv) overlay the result on
the prepared anatomy. This is the typical pattern for any
group-level figure on this dataset.
import matplotlib.pyplot as plt
import seaborn as sns
from matplotlib.colors import Normalize
from nilearn import plotting
from nilearn.image import crop_img, math_img
from templateflow.api import get as tflow_get
# use templateflow to get the mni305 template
mni305_t1w = tflow_get(
"MNI305", suffix="T1w", extension=".nii.gz",
)
mni305_brain_mask = tflow_get(
"MNI305", suffix="mask", desc="brain", extension=".nii.gz",
)
# Templateflow ships a head T1w and a brain mask for MNI305 but
# no brain-extracted T1w directly. Thus, the T1w is multiplied
# by the brain mask to drop the skull / dura / air and then
# cropped to the brain's bounding box so plot_stat_map renders
# only the cortex.
mni305_bg = crop_img(
math_img(
"img * mask",
img=str(mni305_t1w), mask=str(mni305_brain_mask),
)
)
# define the colormap and data range
mako_cmap = sns.color_palette("mako", as_cmap=True)
nc_vmax = float(nc.max())
# define the NC threshold and cuts to plot. The threshold is
# shared with the surface figure further down so colors read
# the same way on both.
threshold = 10.0
cuts = [-17, -5, 8]
# set up the figure
fig = plt.figure(figsize=(10, 4), facecolor="white")
gs = fig.add_gridspec(2, 1, height_ratios=[1, 0.05], hspace=0.1)
fig.subplots_adjust(top=0.98, bottom=0.15)
strip_gs = gs[0].subgridspec(1, 3, wspace=0.05)
axes = [fig.add_subplot(strip_gs[0, i]) for i in range(3)]
# set up the colorbar strip, narrowed to match the surface
# figure's colorbar width-vs-figure-width proportion
cbar_strip_gs = gs[1].subgridspec(
1, 3, width_ratios=[0.15, 0.7, 0.15],
)
cbar_ax = fig.add_subplot(cbar_strip_gs[0, 1])
# plot the different cuts
for ax, z in zip(axes, cuts):
ax.set_facecolor("white")
plotting.plot_stat_map(
mni305_img, bg_img=mni305_bg, axes=ax,
display_mode="z", cut_coords=[z],
cmap=mako_cmap, vmax=nc_vmax, colorbar=False,
black_bg=False, threshold=threshold,
)
# define and render the colorbar
sm = plt.cm.ScalarMappable(
cmap=mako_cmap, norm=Normalize(vmin=0, vmax=nc_vmax),
)
fig.colorbar(
sm, cax=cbar_ax, orientation="horizontal",
label=f"{session} noise ceiling on MNI305 (% var. expl.)",
)
plt.show()

Surface route: T1w → fsaverage (single hemisphere)¶
Surface projections take a two-step trip: vol_to_surf lifts
the volume onto the subject’s fsnative mesh, then a surface
resampler carries it across to fsaverage. The
hemisphere can be picked with hemi="L" or "R" and
the mesh density with fsaverage_density. The default
fsaverage5 (10k vertices per hemi) is fast; fsaverage6
(41k) and fsaverage (164k) trade compute time for finer
detail.
nc_fsavg_lh = sub.to_template(nc, "fsaverage", hemi="L")
nc_fsavg_rh = sub.to_template(nc, "fsaverage", hemi="R")
print(f"fsaverage5 L: {nc_fsavg_lh.shape}")
print(f"fsaverage5 R: {nc_fsavg_rh.shape}")
# Higher density (fsaverage6 / 41k vertices per hemi)
nc_fsavg6_lh = sub.to_template(
nc, "fsaverage", hemi="L", fsaverage_density="fsaverage6",
)
print(f"fsaverage6 L: {nc_fsavg6_lh.shape}")
fsaverage5 L: (10242,)
fsaverage5 R: (10242,)
fsaverage6 L: (40962,)
Re-project the noise ceiling at the full fsaverage density¶
To showcase the high resolution in the visualization, the
projection is re-run to fsaverage (164k vertices per hemi).
However, the choice of the surface depends on the intended use
case and analyses.
nc_fsavg_lh_hi = sub.to_template(
nc, "fsaverage", hemi="L", fsaverage_density="fsaverage",
)
nc_fsavg_rh_hi = sub.to_template(
nc, "fsaverage", hemi="R", fsaverage_density="fsaverage",
)
print(f"fsaverage L (164k): {nc_fsavg_lh_hi.shape}")
print(f"fsaverage R (164k): {nc_fsavg_rh_hi.shape}")
fsaverage L (164k): (163842,)
fsaverage R (164k): (163842,)
Configure the gyri / sulci backdrop and the view layout¶
Two things make a surface figure read well: a clear anatomical
backdrop (so the reader can tell where on the cortex they are
looking) and a layout that shows enough viewpoints to cover
the regions of interest. The cell below sets up both at once.
The backdrop encodes gyri / sulci in two shades of gray so
the noise-ceiling overlay stays the most colorful element on
the plot, and the view layout is a per-row tuple that pairs
each anatomical view (lateral, medial, posterior,
flat) with the matching fsaverage mesh. This is the
same recipe to reach for whenever a surface figure has to be
compared cleanly against a volume one further down.
from matplotlib.colors import ListedColormap, to_rgba
from nilearn import datasets, surface
# define colors for the surface plots
GYRI_HEX = "#9B978D"
SULCI_HEX = "#595959"
N_NC_BINS = 64
fsavg = datasets.fetch_surf_fsaverage("fsaverage") # 164k
# stack the gyri / sulci greys ahead of 64 mako bins so a single
# integer label per vertex covers both anatomy and data
mako_bins = [
mako_cmap(i / (N_NC_BINS - 1)) for i in range(N_NC_BINS)
]
composite_cmap = ListedColormap(
[(1, 1, 1, 1), to_rgba(GYRI_HEX), to_rgba(SULCI_HEX)]
+ mako_bins
)
# define a per-row layout with multiple views:
# (label, (lh_view, rh_view), lh_mesh, rh_mesh).
view_rows = [
("lateral",
("lateral", "lateral"),
fsavg.infl_left, fsavg.infl_right),
("medial",
("medial", "medial"),
fsavg.infl_left, fsavg.infl_right),
("posterior",
("posterior", "posterior"),
fsavg.infl_left, fsavg.infl_right),
("flat",
((90, -90), (90, -90)),
fsavg.flat_left, fsavg.flat_right),
]
# define a shared spatial extent for both flat panels.
# Without this, matplotlib 3D auto-fits each panel to its own
# mesh and the two end up at slightly different vertical
# positions
lh_flat_verts = surface.load_surf_mesh(fsavg.flat_left)[0]
rh_flat_verts = surface.load_surf_mesh(fsavg.flat_right)[0]
flat_all = np.concatenate([lh_flat_verts, rh_flat_verts])
flat_xlim = (flat_all[:, 0].min(), flat_all[:, 0].max())
flat_ylim = (flat_all[:, 1].min(), flat_all[:, 1].max())
flat_zlim = (flat_all[:, 2].min(), flat_all[:, 2].max())
[fetch_surf_fsaverage] Dataset found in $HOME/nilearn_data/fsaverage
Render the multi-view surface grid¶
With the layout and the composite colormap in place, the goal of this cell is to render four anatomical views per hemisphere (lateral, medial, posterior, flat) using the same noise- ceiling threshold and color range as the volume figure above. Keeping both figures on the same scale matters because it lets the reader make a direct visual comparison: a noise-ceiling value of, say, 0.4 produces the exact same color whether it is shown on the MNI305 slices or on the inflated fsaverage surface, and the thresholded boundary marks the same cut on both sides. Without that alignment, two figures of the same underlying data can look misleadingly different.
# setup the figure
fig = plt.figure(figsize=(9.0, 11.5), facecolor="white")
gs = fig.add_gridspec(
5, 1, height_ratios=[1, 1, 1, 1, 0.06], hspace=0.02,
)
fig.subplots_adjust(top=0.99, bottom=0.07, left=0.04, right=0.99)
# loop over views
for row_idx, (label, views, mesh_l, mesh_r) in enumerate(view_rows):
row_gs = gs[row_idx].subgridspec(1, 2, wspace=0.02)
for col_idx, (mesh, arr, curv_path, hemi_full, view) in (
enumerate(zip(
(mesh_l, mesh_r),
(nc_fsavg_lh_hi, nc_fsavg_rh_hi),
(fsavg.curv_left, fsavg.curv_right),
("left", "right"),
views,
))
):
ax = fig.add_subplot(row_gs[0, col_idx], projection="3d")
curv = surface.load_surf_data(curv_path)
roi_map = np.where(curv < 0, 1.0, 2.0)
above = arr >= threshold
if above.any():
norm = np.clip(arr / nc_vmax, 0.0, 1.0)
bin_idx = (norm * (N_NC_BINS - 1)).astype(np.int32)
roi_map[above] = 3.0 + bin_idx[above]
plotting.plot_surf_roi(
mesh, roi_map=roi_map,
hemi=hemi_full, view=view,
cmap=composite_cmap, vmin=0, vmax=2 + N_NC_BINS,
colorbar=False, figure=fig, axes=ax,
)
ax.set_anchor("C")
if label == "flat":
ax.set_xlim(flat_xlim)
ax.set_ylim(flat_ylim)
ax.set_zlim(flat_zlim)
if col_idx == 0:
ax.text2D(
-0.05, 0.5, label,
transform=ax.transAxes,
rotation=90, ha="center", va="center",
fontsize=11,
)
# define and render the colorbar
cbar_strip_gs = gs[4].subgridspec(
1, 3, width_ratios=[0.15, 0.7, 0.15],
)
cbar_ax = fig.add_subplot(cbar_strip_gs[0, 1])
sm_surf = plt.cm.ScalarMappable(
cmap=mako_cmap,
norm=Normalize(vmin=0, vmax=nc_vmax),
)
fig.colorbar(
sm_surf, cax=cbar_ax, orientation="horizontal",
label=f"{session} noise ceiling on fsaverage (% var. expl.)",
)
plt.show()

Both hemispheres at once (hemi=None)¶
If both hemispheres should be returned in a single call,
hemi can be left at its default of None. The return
value becomes a dict keyed by "L" and "R", with each
value the same 1-D array shape as the single-hemi call above.
# project both hemispheres at once
both_hemis = sub.to_template(nc, "fsaverage")
print(f"Returned: {type(both_hemis).__name__} with keys "
f"{sorted(both_hemis)}")
print(f"L shape: {both_hemis['L'].shape}")
print(f"R shape: {both_hemis['R'].shape}")
Returned: dict with keys ['L', 'R']
L shape: (10242,)
R shape: (10242,)
Batched input: (n_trials, n_voxels) → (n_trials, n_vertices)¶
Single-trial analyses usually start from a (n_trials,
n_voxels) array rather than a single summary map. The same
to_template call accepts the 2-D input and keeps the trial
axis on the output: a 4-D NIfTI for volume targets, a 2-D
(n_trials, n_vertices) array for surface targets. Inputs
are sized to the brain mask. For ROI-masked betas, scatter
them back into the brain-mask shape first with
Subject.to_nifti().
# load the first eight single-trial betas at the brain-mask
# resolution. ``streaming=True`` keeps peak memory low while
# the session NIfTI is decompressed and read volume by volume.
betas = sub.get_betas(session=session, streaming=True)
batch_full = betas[:2]
print(f"Input batch: {batch_full.shape}")
# project the batched input onto MNI305 (4-D NIfTI, trial axis
# trailing)
mni305_batch = sub.to_template(batch_full, "MNI305")
print(f"MNI305 4-D: shape={mni305_batch.shape}")
# project the batched input onto fsaverage5 (2-D, trial axis
# leading)
fsavg_batch = sub.to_template(batch_full, "fsaverage", hemi="L")
print(f"fsavg5 batch: {fsavg_batch.shape}")
Input batch: (2, 272080)
MNI305 4-D: shape=(172, 220, 156, 2)
fsavg5 batch: (2, 10242)
Surface → surface (fsnative → fsaverage)¶
If the data already lives on the subject’s fsnative mesh
(e.g., a surface-based ROI mask, surface-fit betas, or
anything else in surface space), surface_to_template
skips the volume step and resamples straight onto
fsaverage. Pass a per-hemi array with hemi="L" or
"R", or a {"L": ..., "R": ...} dict for both
hemispheres in one call. The input vertex count has to match
the subject’s fsnative mesh.
# pull an fsnative-space ROI mask from disk
roi_data = sub.get_roi_data(roi, format="func.gii", hemi="L")
fsnative_lh = roi_data[roi]["gii"]["hemi-L"]["func.gii"]
print(f"fsnative L mesh size: {fsnative_lh.shape}")
# resample the fsnative ROI mask onto fsaverage5
ffa1_on_fsavg = sub.surface_to_template(
fsnative_lh.astype(np.float32), hemi="L",
)
print(f"FFA1 on fsavg5 L: {ffa1_on_fsavg.shape}")
print(f"Vertices marked ROI: {int((ffa1_on_fsavg > 0.5).sum())}")
fsnative L mesh size: (148371,)
FFA1 on fsavg5 L: (10242,)
Vertices marked ROI: 12
Direction-specific entry points¶
to_template infers the route from the target name. If the
input / output direction should be visible at the call site,
three direction-specific methods do the same work under more
descriptive names:
volume_to_template(values, target): volume in, volume out (e.g. T1w → MNI305).volume_to_surface(values, target="fsaverage"): volume in, surface out (e.g. T1w → fsaverage).surface_to_template(values, target="fsaverage", hemi=...): surface in, surface out (e.g. fsnative → fsaverage).
A target that doesn’t match the chosen direction raises
ValueError at the call site, so a wrong combination is
caught before any data moves.
# direct volume-to-volume projection
vol_mni = sub.volume_to_template(nc, "MNI305")
# direct volume-to-surface projection
vol_surf_l = sub.volume_to_surface(nc, hemi="L")
print(f"volume_to_template(MNI305): {vol_mni.shape}")
print(f"volume_to_surface(L): {vol_surf_l.shape}")
# For example, when the target doesn't match the chosen
# direction (uncomment locally to see the raised ValueError;
# kept commented so the gallery build does not crash):
# sub.volume_to_template(nc, "fsaverage") # ValueError
volume_to_template(MNI305): (172, 220, 156)
volume_to_surface(L): (10242,)
Total running time of the script: (15 minutes 19.507 seconds)