02 · Reading real brains: the I/O layer#
SpectralBrain tutorial series — notebook 2 of 10. (Previous: the Laplace–Beltrami operator.)
Notebook 1 decomposed a hippocampus that was already a BrainMesh. Real studies
start from a zoo of file formats. This notebook walks the five subjects in
tutorials/data/ through SpectralBrain’s I/O layer and turns each into something
decompose accepts.
Learning objectives#
Load FreeSurfer surfaces, morphometry, segmentations, HippUnfold, and TractSeg.
Turn a volumetric label into a surface with marching cubes.
Understand the difference between a
BrainMeshand aBrainPointCloud, and when the robust Laplacian is needed.
The teaching dataset#
Five subjects, each chosen to exercise one ingestion path. Nothing here is simulated; these are real FreeSurfer / HippUnfold / TractSeg outputs, trimmed to exactly what the notebooks need.
import sys, json
from pathlib import Path
sys.path.insert(0, str(Path.cwd()))
import numpy as np, matplotlib.pyplot as plt
import nibabel as nib, spectralbrain as sb
from _tutorial_utils import data_path, DATA
manifest = json.load(open(DATA / "manifest.json"))
for sid, info in manifest["subjects"].items():
print(f" {sid} [{info['modality']:11s}] {info['note']}")
sub01 [freesurfer ] cortical pial + aseg + hippocampal subfields + morphometry
sub02 [freesurfer ] cortical pial + aseg + hippocampal subfields + morphometry
sub03 [hippunfold ] HippUnfold v2 den-8k hippocampus (L/R)
sub04 [hippunfold ] HippUnfold v2 den-8k hippocampus (L/R)
sub05 [tractseg ] 15 white-matter bundles (binary masks from TOM) + 1 example TOM
1. FreeSurfer cortical surfaces#
A FreeSurfer ?h.pial / ?h.white file stores a triangular surface (vertices +
faces). sb.load_freesurfer_surface returns them as plain arrays; wrap in a
BrainMesh. quality_report() flags the mesh problems that matter for spectral
work (non-manifold edges, degenerate triangles).
v, f = sb.load_freesurfer_surface(data_path("fs", "sub01", "lh.pial.T1"))
cortex = sb.BrainMesh(v, f)
print(f"left pial: {cortex.n_vertices:,} vertices, {cortex.n_faces:,} faces, "
f"area {cortex.surface_area():.0f} mm^2")
rep = cortex.quality_report()
print("quality_report keys:", list(rep)[:8])
left pial: 123,494 vertices, 246,984 faces, area 101445 mm^2
quality_report keys: ['n_vertices', 'n_faces', 'n_edges', 'euler_characteristic', 'genus', 'is_closed', 'surface_area', 'edge_length_min']
2. Morphometry: scalars that live on a surface#
FreeSurfer also writes per-vertex scalar maps (?h.area, ?h.thickness,
?h.curv). These are fields on the surface — one number per vertex — exactly
the shape that descriptors and statistics consume. load_freesurfer_morph
returns the array; its length must match the surface’s vertex count.
area = sb.load_freesurfer_morph(data_path("fs", "sub01", "lh.area.pial"))
print(f"area map: {area.shape[0]:,} values (surface has {cortex.n_vertices:,} vertices)")
print(f"matches surface? {area.shape[0] == cortex.n_vertices}")
print(f"area per vertex: mean={area.mean():.3f}, range [{area.min():.2f}, {area.max():.2f}] mm^2")
area map: 123,494 values (surface has 123,494 vertices)
matches surface? True
area per vertex: mean=0.821, range [0.01, 6.60] mm^2
3. Subcortical structures: from a label volume to a surface#
Subcortical structures live inside a segmentation volume, not as a surface. The
FreeSurfer aseg uses label 17 for the left hippocampus and 53 for the
right. We isolate the label and run marching cubes to extract its boundary
surface, then wrap it in a BrainMesh.
from spectralbrain.core.base import marching_cubes
seg = nib.load(data_path("fs", "sub01", "aparc.a2009s+aseg.mgz"))
vol = np.asarray(seg.dataobj)
for lab, name in [(17, "left hippocampus"), (53, "right hippocampus")]:
print(f" label {lab:3d} ({name}): {(vol == lab).sum():,} voxels")
V, F = marching_cubes((vol == 17).astype(np.float32), seg.affine, level=0.5)
aseg_hipp = sb.BrainMesh(V, F)
print(f"\nmarching-cubes mesh: {aseg_hipp.n_vertices:,} vertices, "
f"closed surface? {aseg_hipp.is_closed()}")
label 17 (left hippocampus): 4,573 voxels
label 53 (right hippocampus): 3,379 voxels
marching-cubes mesh: 3,402 vertices, closed surface? True
4. Hippocampal subfields#
FreeSurfer’s hippoAmygLabels segments the hippocampus into subfields and the
amygdala into nuclei. The label codes follow the standard FreeSurfer scheme; a few
hippocampal codes we can name with confidence are below. We list the labels
present, then extract the hippocampal tail (226) as a surface.
HIPP_LABELS = {203: "parasubiculum", 211: "HATA", 212: "fimbria",
215: "hippocampal fissure", 226: "hippocampal tail"}
hl = nib.load(data_path("fs", "sub01", "lh.hippoAmygLabels-T1.v22.mgz"))
hd = np.asarray(hl.dataobj)
labs, counts = np.unique(hd[hd > 0], return_counts=True)
print(f"{len(labs)} labels present. Largest few:")
for L, c in sorted(zip(labs.astype(int), counts), key=lambda x: -x[1])[:6]:
print(f" {L:5d}: {HIPP_LABELS.get(L, '(amygdala nucleus / other)'):24s} {c:,} voxels")
Vt, Ft = marching_cubes((hd == 226).astype(np.float32), hl.affine, level=0.5)
tail = sb.BrainMesh(Vt, Ft)
print(f"\nhippocampal tail surface: {tail.n_vertices:,} vertices")
28 labels present. Largest few:
226: hippocampal tail 19,654 voxels
7001: (amygdala nucleus / other) 17,232 voxels
237: (amygdala nucleus / other) 16,150 voxels
7003: (amygdala nucleus / other) 10,825 voxels
245: (amygdala nucleus / other) 8,758 voxels
236: (amygdala nucleus / other) 8,541 voxels
hippocampal tail surface: 8,802 vertices
5. HippUnfold surfaces#
HippUnfold v2 emits dense midthickness surfaces named by vertex density
(den-8k ≈ 8,000 vertices). These are GIfTI files; load_gifti_surface reads
coordinates and faces by data type (not by intent code, which is a common trap).
Subjects 03 and 04 each contribute a left and right hippocampus.
hipp_meshes = {}
for sid in ["sub03", "sub04"]:
for hemi in ["L", "R"]:
p = data_path("hippunfold", sid, f"hemi-{hemi}_space-T1w_den-8k_label-hipp_midthickness.surf.gii")
vv, ff = sb.load_gifti_surface(p)
hipp_meshes[f"{sid}_{hemi}"] = sb.BrainMesh(vv, ff)
for name, m in hipp_meshes.items():
print(f" {name}: {m.n_vertices:,} vertices, area {m.surface_area():.0f} mm^2")
sub03_L: 8,192 vertices, area 865 mm^2
sub03_R: 8,192 vertices, area 1055 mm^2
sub04_L: 8,192 vertices, area 1128 mm^2
sub04_R: 8,192 vertices, area 1116 mm^2
6. White-matter tracts: orientation maps to point clouds#
TractSeg can output Tract Orientation Maps (TOMs): a 4-D volume
\((X, Y, Z, 3)\) where each voxel holds the bundle’s local orientation vector.
That is not a binary mask. To get geometry we collapse the vector field to a mask
(a voxel belongs to the bundle if its orientation vector is non-zero), which is
exactly the preprocessing baked into the staged bundle_segmentations/ here.
Let us see the conversion on the one TOM we kept.
tom = np.asarray(nib.load(data_path("tractseg", "sub05", "TOM_CST_left.nii.gz")).dataobj)
print(f"TOM shape: {tom.shape} (the trailing 3 = orientation vector per voxel)")
mask = np.linalg.norm(tom, axis=-1) > 0
print(f"non-zero voxels after collapsing to a mask: {mask.sum():,}")
staged = np.asarray(nib.load(
data_path("tractseg", "sub05", "bundle_segmentations", "CST_left.nii.gz")).dataobj)
print(f"staged binary mask voxels: {int(staged.sum()):,} (matches: {int(staged.sum()) == mask.sum()})")
TOM shape: (256, 256, 80, 3) (the trailing 3 = orientation vector per voxel)
non-zero voxels after collapsing to a mask: 20,324
staged binary mask voxels: 20,324 (matches: True)
load_tractseg reads a whole TractSeg directory of binary masks at once. With
output='pointcloud' each bundle becomes a BrainPointCloud (voxel centres);
with output='mesh' each becomes a marching-cubes BrainMesh.
bundles = sb.load_tractseg(data_path("tractseg", "sub05"), output="pointcloud")
print(f"loaded {len(bundles)} bundles as point clouds:")
for name in sorted(bundles)[:6]:
print(f" {name:10s}: {bundles[name].points.shape[0]:,} points")
print(" ...")
[06/09/26 01:57:26] INFO TractSeg: 15 bundle masks in /home/claude/work/spectralbrain-main/tutorials/data/tractseg/sub05/bundle_segmentation s
[06/09/26 01:57:26] INFO Extracted 47086 points for label 1
INFO Extracted 45527 points for label 1
INFO Extracted 3048 points for label 1
INFO Extracted 302867 points for label 1
[06/09/26 01:57:27] INFO Extracted 20324 points for label 1
INFO Extracted 19332 points for label 1
INFO Extracted 46771 points for label 1
INFO Extracted 48325 points for label 1
INFO Extracted 17846 points for label 1
INFO Extracted 16753 points for label 1
INFO Extracted 32051 points for label 1
INFO Extracted 52144 points for label 1
INFO Extracted 50127 points for label 1
INFO Extracted 13036 points for label 1
INFO Extracted 13591 points for label 1
[06/09/26 01:57:27] INFO Loaded 15/15 TractSeg bundles as pointcloud.
loaded 15 bundles as point clouds:
AF_left : 47,086 points
AF_right : 45,527 points
CA : 3,048 points
CC : 302,867 points
CST_left : 20,324 points
CST_right : 19,332 points
...
7. Meshes vs point clouds, and the robust Laplacian#
A BrainMesh carries connectivity (faces), so it uses the cotangent Laplacian
of notebook 1. A BrainPointCloud has no faces — just points. For it,
SpectralBrain builds the robust / intrinsic Laplacian (Sharp & Crane 2020),
which defines a Laplace operator directly on points (or on poor-quality meshes)
by locally triangulating neighbourhoods. The descriptor pipeline downstream is
identical; only the operator construction differs.
The robust path needs the
robust_laplacianpackage (pip install robust_laplacian).
cst = bundles["CST_left"]
dec_pc = cst.decompose(k=40) # robust Laplacian (point cloud)
print(f"point-cloud CST_left: {cst.points.shape[0]:,} points -> "
f"{dec_pc.n_eigenvalues} eigenvalues, lambda_1={dec_pc.eigenvalues[1]:.3e}")
tract_meshes = sb.load_tractseg(data_path("tractseg", "sub05"), output="mesh")
dec_m = tract_meshes["CST_left"].decompose(k=40) # cotangent Laplacian (mesh)
print(f"mesh CST_left: {tract_meshes['CST_left'].n_vertices:,} vertices -> "
f"lambda_1={dec_m.eigenvalues[1]:.3e}")
[06/09/26 01:57:28] INFO Point cloud Laplacian (robust): N=20324, nnz=229438
point-cloud CST_left: 20,324 points -> 40 eigenvalues, lambda_1=2.295e-04
mesh CST_left: 11,379 vertices -> lambda_1=0.000e+00
[06/09/26 01:57:31] INFO TractSeg: 15 bundle masks in /home/claude/work/spectralbrain-main/tutorials/data/tractseg/sub05/bundle_segmentation s
[06/09/26 01:57:34] INFO Loaded 15/15 TractSeg bundles as mesh.
[06/09/26 01:57:34] INFO Laplacian (cotangent): N=11379, nnz=79677
Everything reduces to one of two objects#
Input |
Loader |
Becomes |
Laplacian |
|---|---|---|---|
FreeSurfer |
|
|
cotangent |
GIfTI |
|
|
cotangent |
|
|
|
cotangent |
TractSeg masks |
|
|
robust / cotangent |
Once you hold a BrainMesh or BrainPointCloud, every later notebook applies
unchanged.
Exercises#
Right hippocampus from
aseg. Repeat section 3 with label53and compare the vertex count to the left. Are subcortical marching-cubes meshes closed?Morphometry sanity. Load
rh.area.pialforsub02and confirm its length matches thesub02right pial surface vertex count.A second subfield. Extract the fimbria (label 212) for
sub01and render it withplot_surface_sixview. How many vertices does such a thin structure yield?All bundles. Build a table of every TractSeg bundle with its point count, sorted from largest (CC) to smallest (CA). Which bundles are too small to decompose reliably with
k=40?Mesh vs cloud spectra. Decompose
CST_leftboth as a point cloud and as a mesh (k=40) and plot the two spectra together. Do the low eigenvalues agree?
What’s next#
With every structure now a BrainMesh or BrainPointCloud, notebook 03
computes the first real descriptor — ShapeDNA — and uses it to compare the
four HippUnfold hippocampi, measuring left–right asymmetry and between-subject
distance from the spectrum alone.