"""Desikan-Killiany + aseg (the MRtrix ``fs_default`` convention, 84 nodes: 34 cortical + 8 subcortical per hemisphere), majority-voted from a SynthSeg segmentation in T1 space onto a diffusion grid, plus a 14-node lobar grouping (7 lobes x 2 hemispheres) and a white-matter stop mask for tracking. The segmentation itself is made *outside* this repo by ``SynthSeg --parc --robust`` (BBillot/SynthSeg; https://github.com/BBillot/SynthSeg) on the T1, run in its own environment (this script has no TensorFlow dependency). This module only consumes that NIfTI: it registers the T1 onto the diffusion grid (rigid, dipy, mutual information) and resamples the labels with true majority voting -- for every diffusion voxel, every label found among its ``factor**3`` sub-points (sampled at the T1's native resolution through the fitted transform) is counted and the plurality wins, rather than a single nearest-neighbour point sample. python tools/build_parcellation.py --t1 T1w.nii.gz --synthseg aseg.nii.gz --ref mean_b0.nii.gz --out-dir OUT Writes, on the reference (diffusion) grid, under ``--out-dir``: labels.npy int16 (X, Y, Z): compact node id 1..84 in the 84 regions, 0 elsewhere stop_mask.npy bool (X, Y, Z): True where the segmentation's majority label is white matter (a streamline may continue there; leaving white matter into cortex, a ventricle or CSF ends it) regions.json the 84 nodes (id, name, hemisphere, lobe) and the 14 lobar groups """ from __future__ import annotations import argparse import json import numpy as np # ---- the FreeSurfer label tables (FreeSurferColorLUT.txt), restricted to what SynthSeg --parc --robust emits ----- # The 34 Desikan-Killiany cortical regions, FreeSurfer's own (alphabetical) ctx-lh-* numbering, 1001..1035 skipping # 1004 (corpuscallosum, not part of the DK pial surface labelling and not emitted by SynthSeg); ctx-rh-* is +1000. CORTICAL_NAMES = { 1001: "bankssts", 1002: "caudalanteriorcingulate", 1003: "caudalmiddlefrontal", 1005: "cuneus", 1006: "entorhinal", 1007: "fusiform", 1008: "inferiorparietal", 1009: "inferiortemporal", 1010: "isthmuscingulate", 1011: "lateraloccipital", 1012: "lateralorbitofrontal", 1013: "lingual", 1014: "medialorbitofrontal", 1015: "middletemporal", 1016: "parahippocampal", 1017: "paracentral", 1018: "parsopercularis", 1019: "parsorbitalis", 1020: "parstriangularis", 1021: "pericalcarine", 1022: "postcentral", 1023: "posteriorcingulate", 1024: "precentral", 1025: "precuneus", 1026: "rostralanteriorcingulate", 1027: "rostralmiddlefrontal", 1028: "superiorfrontal", 1029: "superiorparietal", 1030: "superiortemporal", 1031: "supramarginal", 1032: "frontalpole", 1033: "temporalpole", 1034: "transversetemporal", 1035: "insula", } # The 8 subcortical structures per hemisphere SynthSeg --parc --robust emits (aseg ids); left and right are not a # uniform offset of one another, so both are given explicitly. SUBCORTICAL_NAMES_LEFT = { 10: "Thalamus", 11: "Caudate", 12: "Putamen", 13: "Pallidum", 17: "Hippocampus", 18: "Amygdala", 26: "Accumbens-area", 28: "VentralDC", } SUBCORTICAL_NAMES_RIGHT = { 49: "Thalamus", 50: "Caudate", 51: "Putamen", 52: "Pallidum", 53: "Hippocampus", 54: "Amygdala", 58: "Accumbens-area", 60: "VentralDC", } # A standard six-lobe grouping of the 34 DK cortical regions (Klein & Tourville 2012-style), plus a seventh # "subcortical" group per hemisphere for the 8 subcortical structures: 7 lobes x 2 hemispheres = 14 groups. LOBE_OF_CORTICAL = { "superiorfrontal": "frontal", "rostralmiddlefrontal": "frontal", "caudalmiddlefrontal": "frontal", "parsopercularis": "frontal", "parstriangularis": "frontal", "parsorbitalis": "frontal", "lateralorbitofrontal": "frontal", "medialorbitofrontal": "frontal", "precentral": "frontal", "paracentral": "frontal", "frontalpole": "frontal", "superiorparietal": "parietal", "inferiorparietal": "parietal", "supramarginal": "parietal", "postcentral": "parietal", "precuneus": "parietal", "superiortemporal": "temporal", "middletemporal": "temporal", "inferiortemporal": "temporal", "bankssts": "temporal", "fusiform": "temporal", "transversetemporal": "temporal", "entorhinal": "temporal", "temporalpole": "temporal", "parahippocampal": "temporal", "lateraloccipital": "occipital", "lingual": "occipital", "cuneus": "occipital", "pericalcarine": "occipital", "caudalanteriorcingulate": "cingulate", "rostralanteriorcingulate": "cingulate", "posteriorcingulate": "cingulate", "isthmuscingulate": "cingulate", "insula": "insula", } assert set(LOBE_OF_CORTICAL) == set(CORTICAL_NAMES.values()) LOBES = ("frontal", "parietal", "temporal", "occipital", "cingulate", "insula", "subcortical") # White-matter labels: a streamline may continue here (cerebral + cerebellar white matter, brain-stem); leaving # this set (into cortex, a ventricle or CSF) ends it. Judgement call, not part of the 84-node parcellation itself. WM_STOP_LABELS = frozenset({2, 41, 7, 46, 16}) # Left/Right-Cerebral-WM, Left/Right-Cerebellum-WM, Brain-Stem def node_table(): """The 84 nodes in a fixed, documented order: node id 1..42 is the left hemisphere (34 cortical by ascending FreeSurfer id, then the 8 subcortical structures), 43..84 the same for the right. Each entry is ``{"id", "fs_label", "name", "hemisphere", "lobe"}``.""" nodes = [] for hemi, cortical, subcortical in ( ("left", CORTICAL_NAMES, SUBCORTICAL_NAMES_LEFT), ("right", {k + 1000: v for k, v in CORTICAL_NAMES.items()}, SUBCORTICAL_NAMES_RIGHT), ): for fs_id in sorted(cortical): name = cortical[fs_id] nodes.append({"fs_label": fs_id, "name": name, "hemisphere": hemi, "lobe": LOBE_OF_CORTICAL[name]}) for fs_id in sorted(subcortical): nodes.append({"fs_label": fs_id, "name": subcortical[fs_id], "hemisphere": hemi, "lobe": "subcortical"}) for i, node in enumerate(nodes, start=1): node["id"] = i assert len(nodes) == 84 return nodes def regions_payload(): """``regions.json``'s content: the 84 nodes plus the 14 lobar groups (lobe x hemisphere).""" nodes = node_table() groups = [f"{lobe}-{hemi}" for lobe in LOBES for hemi in ("left", "right")] return { "convention": "Desikan-Killiany + aseg (MRtrix fs_default, 84 nodes: 34 cortical + 8 subcortical per hemisphere)", "regions": nodes, "lobes": groups, } def register_t1_to_ref(t1_data, t1_affine, ref_data, ref_affine): """The rigid (6 DOF) transform of ``t1``'s world space onto ``ref``'s (dipy, mutual information: a standard cross-modal registration). Returns the ``AffineMap``-style 4x4 matrix mapping a point in ``ref``'s world space to the corresponding point in ``t1``'s world space (dipy's convention: domain -> codomain).""" from dipy.align.imaffine import transform_centers_of_mass, AffineRegistration, MutualInformationMetric from dipy.align.transforms import RigidTransform3D com = transform_centers_of_mass(ref_data, ref_affine, t1_data, t1_affine) metric = MutualInformationMetric(nbins=32, sampling_proportion=0.3) affreg = AffineRegistration(metric=metric, level_iters=[1000, 200, 50], sigmas=[3.0, 1.0, 0.0], factors=[4, 2, 1]) rigid = affreg.optimize(ref_data, t1_data, RigidTransform3D(), params0=None, static_grid2world=ref_affine, moving_grid2world=t1_affine, starting_affine=com.affine) return rigid.affine def _supersampled_affine(ref_affine, factor): """The grid-to-world affine of ``ref``'s grid split ``factor`` ways per axis, each sub-voxel centred within its parent voxel (sub-index ``k``'s continuous parent coordinate is ``(k + 0.5) / factor - 0.5``).""" c = 0.5 / factor - 0.5 m = np.eye(4) m[0, 0] = m[1, 1] = m[2, 2] = 1.0 / factor m[:3, 3] = c return ref_affine @ m def majority_vote(seg_data, seg_affine, ref_shape, ref_affine, t1_to_ref_affine, factor=4): """The segmentation's majority label per voxel of ``ref``'s grid: every voxel is split into ``factor**3`` sub-points (sampled through the fitted rigid transform at nearest-neighbour), and the plurality label among them is kept. ``0`` where nothing in ``seg_data`` maps there. Returns an ``int32`` array of shape ``ref_shape``.""" from dipy.align.imaffine import AffineMap if factor < 1: raise ValueError(f"factor must be a positive integer, got {factor}") sup_shape = tuple(int(s) * factor for s in ref_shape) sup_affine = _supersampled_affine(ref_affine, factor) amap = AffineMap(t1_to_ref_affine, domain_grid_shape=sup_shape, domain_grid2world=sup_affine, codomain_grid_shape=seg_data.shape, codomain_grid2world=seg_affine) sup_labels = amap.transform(seg_data.astype(np.int32), interpolation="nearest") X, Y, Z = ref_shape blocks = sup_labels.reshape(X, factor, Y, factor, Z, factor) blocks = np.transpose(blocks, (0, 2, 4, 1, 3, 5)).reshape(X, Y, Z, factor ** 3) uniq, inverse = np.unique(blocks, return_inverse=True) inverse = inverse.reshape(X * Y * Z, factor ** 3) counts = np.zeros((X * Y * Z, len(uniq)), dtype=np.int32) for k in range(factor ** 3): np.add.at(counts, (np.arange(X * Y * Z), inverse[:, k]), 1) majority = uniq[counts.argmax(axis=1)].reshape(X, Y, Z) return majority.astype(np.int32) def labels_and_stop_mask(majority_fs_labels): """``(labels, stop_mask)`` on ``majority_fs_labels``'s grid: ``labels`` is the compact node id (1..84, 0 elsewhere) of :func:`node_table`; ``stop_mask`` is True on :data:`WM_STOP_LABELS`.""" nodes = node_table() fs_to_node = {n["fs_label"]: n["id"] for n in nodes} labels = np.zeros(majority_fs_labels.shape, dtype=np.int16) for fs_id, node_id in fs_to_node.items(): labels[majority_fs_labels == fs_id] = node_id stop_mask = np.isin(majority_fs_labels, list(WM_STOP_LABELS)) return labels, stop_mask def label_volumes_mm3(labels, voxel_volume_mm3): """``{node_id: volume_mm3}`` for every node present in ``labels`` (0 excluded).""" vals, counts = np.unique(labels, return_counts=True) return {int(v): float(c) * voxel_volume_mm3 for v, c in zip(vals, counts) if v != 0} def build_parcellation(t1_path, synthseg_path, ref_path, out_dir, factor=4): """The CLI's body: register, majority-vote, write ``labels.npy``, ``stop_mask.npy``, ``regions.json`` under ``out_dir``. Returns ``(labels, stop_mask, regions)`` for a caller (``build_brain_asset.py``) that wants them in memory instead of round-tripping through disk.""" import os import nibabel as nib t1_im = nib.load(t1_path) seg_im = nib.load(synthseg_path) ref_im = nib.load(ref_path) if seg_im.shape != t1_im.shape or not np.allclose(seg_im.affine, t1_im.affine, atol=1e-3): raise ValueError("the SynthSeg output must be on the T1's own grid (it is by construction unless T1 was " "not already at the resolution SynthSeg resamples to internally)") t1_data = np.asarray(t1_im.dataobj, dtype=np.float64) ref_data = np.asarray(ref_im.dataobj, dtype=np.float64) seg_data = np.asarray(seg_im.dataobj, dtype=np.int32) t1_to_ref = register_t1_to_ref(t1_data, t1_im.affine, ref_data, ref_im.affine) majority_fs = majority_vote(seg_data, seg_im.affine, ref_im.shape, ref_im.affine, t1_to_ref, factor=factor) labels, stop_mask = labels_and_stop_mask(majority_fs) regions = regions_payload() voxel_mm3 = float(np.abs(np.linalg.det(ref_im.affine[:3, :3]))) volumes = label_volumes_mm3(labels, voxel_mm3) n_present = sum(1 for n in regions["regions"] if n["id"] in volumes) print(f"{n_present}/84 nodes present on the diffusion grid") if volumes: v = np.array(list(volumes.values())) print(f"per-label volume mm3: min {v.min():.1f}, median {np.median(v):.1f}, max {v.max():.1f}") os.makedirs(out_dir, exist_ok=True) np.save(os.path.join(out_dir, "labels.npy"), labels) np.save(os.path.join(out_dir, "stop_mask.npy"), stop_mask) with open(os.path.join(out_dir, "regions.json"), "w") as f: json.dump(regions, f, indent=2) return labels, stop_mask, regions def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--t1", required=True, help="the subject's T1 (the grid SynthSeg's output is on)") ap.add_argument("--synthseg", required=True, help="SynthSeg --parc --robust's output NIfTI, on the T1 grid") ap.add_argument("--ref", required=True, help="a NIfTI defining the diffusion grid (e.g. the mean b=0)") ap.add_argument("--out-dir", required=True) ap.add_argument("--factor", type=int, default=4, help="sub-points per axis per diffusion voxel for the " "majority vote (default 4, i.e. 64 sub-points)") a = ap.parse_args() build_parcellation(a.t1, a.synthseg, a.ref, a.out_dir, factor=a.factor) if __name__ == "__main__": main()