BundleParc: Tractography-Free White Matter Bundle Parcellation with MedNeXt + Cross-Attention | AI Trend Blend

BundleParc: The Brain Mapping Method That Skips Tractography Entirely — and Does It Better

Researchers at Université de Sherbrooke built a prompt-conditioned MedNeXt encoder-decoder that reads fiber orientation maps and outputs anatomically consistent white matter parcellations in seconds — without generating a single streamline — outperforming all competing methods on reproducibility across healthy subjects, multiple sclerosis patients, and brain tumor cases.

BundleParc Tractometry Diffusion MRI MedNeXt Cross-Attention Prompting fODF Segmentation White Matter Tractography-Free

Tractometry — measuring microstructural properties along white matter bundles — is one of the most powerful tools available for understanding neurological diseases. But the pipeline required to do it is genuinely painful: acquire diffusion MRI, model it, run tractography, segment the resulting tractogram into bundles, estimate centerlines, parcellate those bundles into subsections, and finally extract microstructural measurements. Any one of these steps can fail in ways that propagate downstream. BundleParc collapses all of them into one.


Why the Existing Pipeline Is Broken by Design

Tractography reconstructs white matter connections by following the local fiber orientation through the brain. It is inherently ill-posed: there is no unique solution, and the method produces both false positives (streamlines that connect regions that should not be connected) and false negatives (missing connections). When 42 different research groups were asked to segment 14 white matter bundles from the same dataset, the variability between their results was substantial. This is not a failure of individual researchers — it reflects genuine ambiguity in the underlying data and algorithm.

Once bundles are obtained, they need to be parcellated into subsections for tractometry. Three main approaches exist. Static parcellation resamples every streamline to the same number of points and averages their 3D positions — simple but anatomically incoherent, because neighboring streamline points can end up in different parcels. Centerline parcellation extracts a representative centerline, resamples it to C points, and assigns every streamline point to its nearest centerline point — more consistent, but centerline estimation is tricky. Hyperplane parcellation uses an SVM to find maximally separating hyperplanes between the C resampled points — accurate within a bundle, but expensive and not guaranteed to be reproducible across subjects or timepoints.

The deeper problem with both centerline and hyperplane approaches is that they both depend on estimating the centerline from the bundle itself. This creates three failure modes that compound across subjects and longitudinal timepoints.

Three Ways Centerline Estimation Quietly Breaks Your Analysis

Complex bundle shapes like the cingulum yield multiple candidate centerlines depending on how streamlines are distributed, so the resulting parcellation labels can be anatomically wrong at the bundle extremities. Uneven streamline density biases the centerline toward certain regions, making the parcellation fail to capture fanning geometry. And inconsistent streamline orientation between hemispheres, subjects, or acquisitions produces flipped label orderings — making cross-sectional and longitudinal comparisons meaningless. BundleParc avoids all three by never computing a centerline from subject data at all.

The Architecture: One Prompt-Conditioned Network for All 71 Bundles

The Backbone: MedNeXt with Two-Way Cross-Attention

BundleParc is built on MedNeXt, a UNet-based architecture that extends the classical encoder-decoder design with large-kernel depthwise convolutions and channel expansion strategies borrowed from modern convolutional networks. These modifications improve representational capacity and parameter efficiency over a standard UNet while preserving the stability and strong inductive biases of the encoder-decoder structure.

The model is a four-layer encoder-decoder with skip connections. Each encoder layer contains two MedNeXt convolutional blocks followed by a downsampling block that halves the spatial resolution. Each MedNeXt block consists of a grouped 3D convolution (kernel 3, stride 1, padding 1), GroupNorm, a second 3D convolution with expanded channels, GeLU activation, and a final 3D convolution, with a residual connection over the entire block. The decoder mirrors this structure with transposed convolutions for upsampling.

The novel component introduced in this work is a two-way cross-attention block in each decoder layer. This is not part of the original MedNeXt architecture. It performs cross-attention in both directions: from the volumetric feature maps to the bundle-specific prompt, and from the prompt back to the feature maps, with a multi-layer perceptron in between. LayerNorm follows each sub-block, and 3D sinusoidal positional encoding is added to the input volume before attention is applied.

The purpose of this attention mechanism is to condition the decoder on which bundle is being predicted. Rather than training 71 separate models, a single model processes any of the 71 bundles by receiving a one-hot vector identifying the target bundle. The cross-attention blocks inject this bundle-specific information into the decoding stages at every resolution level, allowing the model to adapt its internal representations to produce the correct bundle’s mask and parcellation. This is architecturally similar to how visual prompt conditioning works in segmentation foundation models, applied here to 3D neuroanatomy.

Inputs and Outputs

The input is a spherical harmonics-encoded fODF volume of order 8 (45 SH coefficients) at 128×128×128 voxel resolution, paired with a one-hot encoded bundle prompt. SH-represented fODFs were chosen because they preserve the full angular information of the local fiber orientation distribution, including secondary lobes and complex fiber configurations. In contrast to peak-based representations, which retain only a limited number of principal directions, SH coefficients capture the complete diffusion signal, providing richer information for distinguishing neighboring pathways.

The output is a two-channel 3D volume: one channel for the binary bundle mask and one for the continuous parcellation map in the range (0, 1]. The parcellation map is Gaussian-blurred during training to produce smooth label targets. At inference, the continuous output is discretized to any desired number of parcels C by simply multiplying by C and applying a ceiling function — no retraining required to change C.

Label Generation: Atlas-Guided Consistent Hyperplane Parcellation

Ground truth labels were generated using a novel variant of the hyperplane parcellation method. Instead of computing centerlines per bundle per subject (the naïve approach that leads to flipping), centerlines were derived from a population-averaged bundle atlas in MNI152 space.

The procedure: 105 annotated HCP subjects were registered to MNI space and their bundles concatenated to form population-averaged white matter bundles. Centerlines were obtained by resampling these atlas bundles and computing point-wise coordinate averages across subjects. Manual quality control corrected any biases from streamline density or registration errors. These atlas centerlines were then registered back to each individual subject’s space. Subject streamlines were reoriented to minimize the MDF (minimum average direct-flip distance) with the subject-specific atlas centerline, ensuring consistent orientation across all subjects and acquisitions. SVM hyperplanes were then fitted in subject space.

This atlas-in-the-loop approach is the key to reproducibility: because centerline orientation is dictated by the atlas rather than computed from subject data, label ordering is globally consistent across hemispheres, subjects, scanners, and timepoints.

Training

BundleParc was pretrained for 1000 epochs on 868 HCP subjects using atlas-registered parcellation labels, then fine-tuned for 300 epochs on the 105 annotated HCP subjects with silver-standard bundles using a cross-entropy loss for the continuous labels and a Dice loss for the bundle mask. Batch size 3, AdamW optimizer (β₁=0.9, β₂=0.999, weight decay 0.1), initial learning rate 1×10⁻⁴ with cosine decay and 50-epoch warmup. Training took approximately 100 hours on four NVIDIA A100 40GB GPUs. Each bundle of each subject is treated as a single datum (21×71 elements per fold in 5-fold cross-validation).

ViRefSAM framework diagram showing Visual Contextual Prompt Encoder and Dynamic Target Alignment Adapter integrated with SAM for remote sensing few-shot segmentation
Figure 1: BundleParc architecture. The encoder extracts hierarchical spatial features from the fODF volume. Skip connections preserve fine-grained anatomical details. Cross-attention blocks in the decoder inject bundle-specific prompt information at every resolution level. The stem block adapts from 45 SH channels to the model’s feature width; the head block maps back to 2 output channels (bundle mask + parcellation map).

What the Experiments Show

Bundle Segmentation Accuracy (HCP105)

MethodMean Dice (subject-wise)WM Overreach (cm³)Requires Tractography
BundleParc0.8258.5 ± 6.5No
TractSeg0.81912.3 ± 16.9No (peaks only)
BundleSeg0.68914.0 ± 9.9Yes
Classifyber0.6787.5 ± 5.1Yes
Atlas Registration0.61412.6 ± 15.3No

Table 1: Bundle segmentation on HCP105 (105 annotated subjects). BundleParc achieves the highest Dice and produces fewer voxels outside white matter than TractSeg, while requiring no tractography at inference time.

Parcellation Reproducibility (Penthera 3T, C=10)

MethodMean Dice (C=10)Mean Dice (C=25)Label Consistency
BundleParc0.820.68Atlas-enforced — no flipping
TractSeg0.630.48May flip across acquisitions
BundleSeg + Consistent0.560.34Atlas-enforced
BundleSeg + Naïve0.310.18Frequently flipped

Table 2: Test-retest and scan-rescan label-wise Dice on Penthera 3T. BundleParc’s 0.82 vs TractSeg’s 0.63 is a 30% relative improvement in parcellation reproducibility. The gap widens further at C=25, demonstrating that BundleParc’s advantage is not just about having fewer parcels to get right.

Microstructure Reproducibility (ICC, Penthera 1.5T, FA)

MethodMean ICC (FA)ICC CategoryMean ICC (ihMTsat, MS Controls)
BundleParc0.90Excellent0.81 (Good)
Atlas Registration0.85Good0.83 (Good)
TractSeg0.78Good0.74 (Moderate)
BundleSeg + Consistent0.73Good0.68 (Moderate)
BundleSeg + Naïve0.20Poor0.67 (Moderate)

Table 3: Intraclass correlation coefficient (ICC) for microstructural reproducibility. BundleParc achieves “excellent” ICC for FA — the only method to do so. Note that BundleSeg+Naïve has surprisingly moderate ihMTsat ICC despite very poor label-wise Dice, illustrating why volume-based and microstructure-based reproducibility measures cannot be used interchangeably.

“BundleParc greatly simplifies the bundle parcellation process by reducing to one step the label computation, going directly from fODF volumes to bundle parcellations.” — Théberge et al. — Medical Image Analysis 112 (2026) 104087

Limitations

Current Limitations of BundleParc

Dipy-only fODF basis compatibility. BundleParc was trained on fODF volumes in the descoteaux07 spherical harmonics basis, which is the default output of Dipy-based pipelines. Users processing data through MRtrix3-based workflows with tournier07 SH basis would need to convert their fODF volumes before inference — an extra step that may introduce approximation errors, particularly in single-shell acquisitions.

GPU requirement. The MedNeXt architecture requires a GPU to run in a reasonable time. CPU inference is possible but extremely slow for 128³×45 volumes. In clinical settings without GPU infrastructure, this limits practical deployment.

Bundle coverage restricted to TractSeg definitions. BundleParc covers 71 bundles following TractSeg’s definitions, which emphasise long-range association, commissural, and projection pathways. Superficial white matter, short association fibres, and cerebellar pathways are not included. This is not a fundamental architectural limitation — extending to new bundles requires new atlas labels and fine-tuning, not architectural changes — but it does mean the method is not yet applicable to all white matter research questions.

Reduced fanning and brain stem cut-off. Visual comparison with tractography-based methods shows that BundleParc tends to under-represent the fanning at bundle extremities and can truncate bundles in the brain stem region. This is likely inherited from the TractSeg training data, which exhibits the same issue. It may cause BundleParc to miss microstructural pathology localised in these under-represented regions.

Population-averaged atlas dependence. The ground truth labels used for training were derived from a population atlas registered to individual subjects. In populations with significant anatomical deviation from the atlas (e.g., paediatric brains, severe atrophy, large space-occupying lesions), registration quality degrades, which could introduce systematic label errors at inference even if BundleParc itself is structurally sound.

Agreement with competing methods in tumour data is moderate. In the brain tumour experiment, BundleParc, tractography, and atlas registration all agree with each other at Dice scores below 0.5. The authors argue this reflects variability in tractography and atlas registration rather than a failure of BundleParc, and no ground truth is available to arbitrate. But this remains an open validation gap for clinical use with pathological anatomy.

Hyperparameters were empirically selected, not optimised. Due to the high computational cost of training (~100 GPU-hours per run), architecture and training hyperparameters were chosen based on validation performance rather than systematic search. The chosen configuration may not be optimal, and downstream users cannot easily re-tune it for new acquisition protocols.


Conclusion

BundleParc answers a straightforward but historically intractable question: can you skip tractography entirely and go directly from diffusion MRI to anatomically consistent, reproducible white matter parcellations? The answer, it turns out, is yes — and doing so produces better reproducibility than any existing tractography-based or atlas-based approach on the metrics that actually matter for tractometry.

The two design decisions that make this possible are the atlas-guided training labels (which enforce globally consistent label ordering without requiring subject-level centerline estimation) and the cross-attention prompt conditioning (which allows a single model to handle all 71 bundles by injecting bundle-specific information at every decoder resolution). Together these make BundleParc fast enough to parcel all 71 bundles on a consumer GPU in seconds, consistent enough to use in longitudinal studies, and accurate enough to match TractSeg’s whole-bundle segmentation Dice while surpassing it decisively in parcellation quality.

Complete Proposed Model Code (PyTorch)

The implementation below is a complete, self-contained PyTorch reproduction of the full BundleParc framework: MedNeXt convolutional blocks with GroupNorm and residual connections, downsampling and upsampling blocks, two-way cross-attention decoder blocks with 3D sinusoidal positional encoding, stem and head projection layers, the full four-layer encoder-decoder with skip connections, bundle-prompt conditioning, continuous label map prediction with arbitrary-C discretization, combined cross-entropy and Dice training loss, and a full training loop with 5-fold cross-validation support. A smoke test verifies shapes end-to-end.

# ==============================================================================
# BundleParc: Consistent White Matter Bundle Parcellation Without Tractography
# Paper: Medical Image Analysis 112 (2026) 104087
# Authors: Antoine Théberge et al.
# Affiliation: Université de Sherbrooke (VITAL + SCIL labs)
# DOI: https://doi.org/10.1016/j.media.2026.104087
# Complete PyTorch implementation — maps to Section 2.1 (Architecture + Training)
# GitHub: https://github.com/scil-vital/BundleParc-flow
# ==============================================================================

from __future__ import annotations
import math, warnings
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from typing import List, Optional, Tuple

warnings.filterwarnings('ignore')
torch.manual_seed(42)


# ─── SECTION 1: MedNeXt Convolutional Block (Section 2.1.1) ───────────────────

class MedNeXtBlock(nn.Module):
    """
    MedNeXt convolutional block (Section 2.1.1, Fig. 3).

    Structure (maps to paper description):
      1. Grouped 3D conv (kernel=3, stride=1, pad=1, groups=channels) — depthwise style
      2. GroupNorm
      3. 3D conv (kernel=1) with expanded channels (expansion_ratio × C) — pointwise
      4. GeLU activation
      5. 3D conv (kernel=1) back to original channels — project down
      + Residual connection adding input to output

    Parameters
    ----------
    channels       : number of input/output channels F
    expansion_ratio: channel expansion factor FR/F (paper uses 4)
    n_groups       : number of groups for GroupNorm
    """
    def __init__(self, channels: int, expansion_ratio: int = 4, n_groups: int = 8):
        super().__init__()
        exp_ch = channels * expansion_ratio
        self.block = nn.Sequential(
            nn.Conv3d(channels, channels, kernel_size=3, stride=1,
                      padding=1, groups=channels),          # depthwise grouped conv
            nn.GroupNorm(min(n_groups, channels), channels),
            nn.Conv3d(channels, exp_ch, kernel_size=1),    # expand channels
            nn.GELU(),
            nn.Conv3d(exp_ch, channels, kernel_size=1),    # project back
        )

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return x + self.block(x)   # residual connection


# ─── SECTION 2: Downsample and Upsample Blocks (Section 2.1.1) ────────────────

class DownsampleBlock(nn.Module):
    """
    Downsampling block identical to MedNeXtBlock but with stride=2 on the
    first conv layer, halving spatial resolution (Section 2.1.1).
    The residual connection is matched via 1×1×1 conv with stride=2.
    """
    def __init__(self, in_ch: int, out_ch: int, expansion_ratio: int = 4, n_groups: int = 8):
        super().__init__()
        exp_ch = out_ch * expansion_ratio
        self.block = nn.Sequential(
            nn.Conv3d(in_ch, in_ch, kernel_size=3, stride=2,
                      padding=1, groups=in_ch),             # stride=2 → halve resolution
            nn.GroupNorm(min(n_groups, in_ch), in_ch),
            nn.Conv3d(in_ch, exp_ch, kernel_size=1),
            nn.GELU(),
            nn.Conv3d(exp_ch, out_ch, kernel_size=1),
        )
        self.skip = nn.Conv3d(in_ch, out_ch, kernel_size=1, stride=2)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.block(x) + self.skip(x)


class UpsampleBlock(nn.Module):
    """
    Upsampling block using 3D transposed convolutions to double spatial
    resolution (Section 2.1.1). Structure mirrors DownsampleBlock.
    """
    def __init__(self, in_ch: int, out_ch: int, expansion_ratio: int = 4, n_groups: int = 8):
        super().__init__()
        exp_ch = out_ch * expansion_ratio
        self.block = nn.Sequential(
            nn.ConvTranspose3d(in_ch, in_ch, kernel_size=2, stride=2,
                               groups=in_ch),                 # transposed → double resolution
            nn.GroupNorm(min(n_groups, in_ch), in_ch),
            nn.Conv3d(in_ch, exp_ch, kernel_size=1),
            nn.GELU(),
            nn.Conv3d(exp_ch, out_ch, kernel_size=1),
        )
        self.skip = nn.ConvTranspose3d(in_ch, out_ch, kernel_size=2, stride=2)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        return self.block(x) + self.skip(x)


# ─── SECTION 3: 3D Sinusoidal Positional Encoding (Section 2.1.1) ─────────────

class SinusoidalPosEnc3D(nn.Module):
    """
    3D sinusoidal positional encoding added to the input volume before
    cross-attention is performed (Section 2.1.1, Wang and Liu, 2019).

    Produces a (1, channels, D, H, W) encoding by summing independent
    sinusoidal encodings for D, H, and W axes.
    """
    def __init__(self, channels: int, max_len: int = 128):
        super().__init__()
        self.channels = channels
        pe = torch.zeros(max_len, channels)
        pos = torch.arange(max_len).unsqueeze(1).float()
        div = torch.exp(torch.arange(0, channels, 2).float() * (-math.log(10000.0) / channels))
        pe[:, 0::2] = torch.sin(pos * div)
        pe[:, 1::2] = torch.cos(pos * div[:pe[:, 1::2].shape[1]])
        self.register_buffer('pe', pe)   # (max_len, channels)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        """x: (B, C, D, H, W) — add PE along each spatial dimension."""
        B, C, D, H, W = x.shape
        pe_d = self.pe[:D].T.reshape(1, C, D, 1, 1)
        pe_h = self.pe[:H].T.reshape(1, C, 1, H, 1)
        pe_w = self.pe[:W].T.reshape(1, C, 1, 1, W)
        return x + pe_d + pe_h + pe_w


# ─── SECTION 4: Two-Way Cross-Attention Block (Novel component, Section 2.1.1) ─

class TwoWayCrossAttentionBlock(nn.Module):
    """
    Two-way cross-attention block — novel component introduced in BundleParc
    (Section 2.1.1). NOT part of original MedNeXt.

    Performs cross-attention in both directions:
      Sub-block 1: feature_maps → prompt  (maps attend to bundle prompt)
      Sub-block 2: prompt → feature_maps  (prompt attends back to maps)
    A shared MLP with GeLU activation sits between the two sub-blocks.
    All sub-blocks are followed by LayerNorm (Section 2.1.1).

    3D sinusoidal positional encoding is added to feature maps before
    attention is applied.

    Parameters
    ----------
    feat_ch     : number of feature map channels F
    prompt_dim  : dimension of the one-hot prompt (n_bundles = 71)
    n_heads     : number of attention heads
    mlp_ratio   : expansion ratio in the intermediate MLP
    """
    def __init__(
        self,
        feat_ch:    int,
        prompt_dim: int = 71,
        n_heads:    int = 4,
        mlp_ratio:  int = 4,
    ):
        super().__init__()
        self.pos_enc = SinusoidalPosEnc3D(feat_ch)

        # Linearly project prompt to match feature map channel width (Section 2.1.1)
        self.prompt_proj = nn.Linear(prompt_dim, feat_ch)

        # Sub-block 1: feature maps → prompt (cross-attention)
        self.xattn_feat2prompt = nn.MultiheadAttention(feat_ch, n_heads, batch_first=True)
        self.ln1 = nn.LayerNorm(feat_ch)

        # Intermediate MLP with GeLU (Section 2.1.1)
        self.mlp = nn.Sequential(
            nn.Linear(feat_ch, feat_ch * mlp_ratio),
            nn.GELU(),
            nn.Linear(feat_ch * mlp_ratio, feat_ch),
        )
        self.ln2 = nn.LayerNorm(feat_ch)

        # Sub-block 2: prompt → feature maps (cross-attention)
        self.xattn_prompt2feat = nn.MultiheadAttention(feat_ch, n_heads, batch_first=True)
        self.ln3 = nn.LayerNorm(feat_ch)

    def forward(
        self,
        x:      torch.Tensor,   # (B, C, D, H, W) volumetric feature maps
        prompt: torch.Tensor,   # (B, n_bundles) one-hot bundle prompt
    ) -> torch.Tensor:
        B, C, D, H, W = x.shape

        # Add 3D sinusoidal positional encoding to feature maps (Section 2.1.1)
        x = self.pos_enc(x)

        # Flatten spatial dims: (B, D*H*W, C) — attend over spatial positions
        x_flat = x.reshape(B, C, -1).permute(0, 2, 1)   # (B, N, C)

        # Project prompt to feature channel width: (B, 1, C)
        p = self.prompt_proj(prompt.float()).unsqueeze(1)  # (B, 1, C)

        # Sub-block 1: feature maps attend to prompt (Eq. — Section 2.1.1)
        attn1_out, _ = self.xattn_feat2prompt(
            query=x_flat, key=p, value=p
        )
        x_flat = self.ln1(x_flat + attn1_out)

        # MLP in between (Section 2.1.1)
        x_flat = self.ln2(x_flat + self.mlp(x_flat))

        # Sub-block 2: prompt attends back to feature maps
        attn2_out, _ = self.xattn_prompt2feat(
            query=p, key=x_flat, value=x_flat
        )
        # Broadcast prompt context back to all spatial positions
        x_flat = self.ln3(x_flat + attn2_out)

        # Reshape back to volumetric: (B, C, D, H, W)
        return x_flat.permute(0, 2, 1).reshape(B, C, D, H, W)


# ─── SECTION 5: Encoder Layer (Section 2.1.1) ─────────────────────────────────

class EncoderLayer(nn.Module):
    """
    One encoder layer: two MedNeXt blocks + a DownsampleBlock (Section 2.1.1).

    Outputs:
      skip : output of the second MedNeXt block (sent to corresponding decoder)
      down : output of the DownsampleBlock (sent to next encoder layer)
    """
    def __init__(self, in_ch: int, out_ch: int):
        super().__init__()
        self.mnx1 = MedNeXtBlock(in_ch)
        self.mnx2 = MedNeXtBlock(in_ch)
        self.down = DownsampleBlock(in_ch, out_ch)

    def forward(self, x: torch.Tensor) -> Tuple[torch.Tensor, torch.Tensor]:
        x = self.mnx1(x)
        skip = self.mnx2(x)   # saved for skip connection
        down = self.down(skip)
        return skip, down


# ─── SECTION 6: Decoder Layer (Section 2.1.1) ─────────────────────────────────

class DecoderLayer(nn.Module):
    """
    One decoder layer: Upsample → MedNeXt block → TwoWayCrossAttention → MedNeXt block.
    Skip connection from the corresponding encoder layer is added after upsampling
    (Section 2.1.1).

    The cross-attention blocks are the novel component of BundleParc — they inject
    bundle-specific prompt information into the decoding process at each resolution.
    """
    def __init__(self, in_ch: int, out_ch: int, prompt_dim: int = 71):
        super().__init__()
        self.up    = UpsampleBlock(in_ch, out_ch)
        self.mnx1  = MedNeXtBlock(out_ch)
        self.xattn = TwoWayCrossAttentionBlock(out_ch, prompt_dim)
        self.mnx2  = MedNeXtBlock(out_ch)

    def forward(
        self,
        x:      torch.Tensor,   # (B, in_ch, D, H, W) from previous decoder or bottleneck
        skip:   torch.Tensor,   # (B, out_ch, D*2, H*2, W*2) skip from encoder
        prompt: torch.Tensor,   # (B, n_bundles) one-hot prompt
    ) -> torch.Tensor:
        x = self.up(x)           # double spatial resolution
        x = x + skip             # add skip connection
        x = self.mnx1(x)
        x = self.xattn(x, prompt)  # inject bundle-specific context
        x = self.mnx2(x)
        return x


# ─── SECTION 7: Full BundleParc Model (Section 2.1.1, Fig. 3) ─────────────────

class BundleParc(nn.Module):
    """
    BundleParc — prompt-conditioned MedNeXt U-Net for tractography-free
    white matter bundle parcellation (Section 2.1, Fig. 3).

    Architecture:
      Stem: 1×1×1 conv: SH_channels → F (channel adaptation)
      Encoder: 4 layers with MedNeXtBlocks + DownsampleBlocks
      Bottleneck: 2 MedNeXt blocks (no spatial change)
      Decoder: 4 layers with UpsampleBlocks + TwoWayCrossAttentionBlocks
      Head: 1×1×1 conv: F → 2 output channels

    Input:
      fodf   : (B, sh_channels, D, H, W) = (B, 45, 128, 128, 128) SH fODF volume
      prompt : (B, n_bundles) one-hot bundle identifier vector

    Output: (B, 2, D, H, W)
      Channel 0 : binary bundle mask (sigmoid → binary)
      Channel 1 : continuous parcellation map in (0, 1] (sigmoid → multiply by C → ceil)

    Parameters (from Section 2.1.1)
    ----------
    sh_channels  : number of SH coefficients (order 8 = 45)
    n_bundles    : number of bundle classes (71, TractSeg definition)
    base_ch      : base feature width F at first encoder layer
    depth        : number of encoder/decoder levels (paper: 4)
    """
    def __init__(
        self,
        sh_channels: int   = 45,
        n_bundles:   int   = 71,
        base_ch:     int   = 32,   # paper uses larger F; reduced here for memory
        depth:       int   = 4,
    ):
        super().__init__()
        self.depth = depth

        # Channel sizes at each encoder/decoder level: [F, 2F, 4F, 8F]
        chs = [base_ch * (2 ** i) for i in range(depth)]

        # Stem: adapt SH input channels to first encoder width (Section 2.1.1)
        self.stem = nn.Conv3d(sh_channels, chs[0], kernel_size=1)

        # Encoder layers
        self.encoders = nn.ModuleList([
            EncoderLayer(chs[i], chs[min(i+1, depth-1)])
            for i in range(depth)
        ])

        # Bottleneck: two MedNeXt blocks at deepest resolution (no skip sent)
        self.bottleneck = nn.Sequential(
            MedNeXtBlock(chs[-1]),
            MedNeXtBlock(chs[-1]),
        )

        # Decoder layers (process in reverse)
        decoder_in_chs  = [chs[depth-1]] + [chs[i] for i in range(depth-1, 0, -1)]
        decoder_out_chs = [chs[i] for i in range(depth-1, -1, -1)]
        self.decoders = nn.ModuleList([
            DecoderLayer(decoder_in_chs[i], decoder_out_chs[i], n_bundles)
            for i in range(depth)
        ])

        # Head: adapt final decoder channels to 2 output channels (Section 2.1.1)
        self.head = nn.Conv3d(chs[0], 2, kernel_size=1)

    def forward(
        self,
        fodf:   torch.Tensor,   # (B, 45, D, H, W)
        prompt: torch.Tensor,   # (B, 71) one-hot
    ) -> torch.Tensor:
        """
        Returns
        -------
        out : (B, 2, D, H, W)
          out[:, 0] = bundle mask logit (apply sigmoid for probability)
          out[:, 1] = continuous parcellation logit (apply sigmoid → (0,1])
        """
        x = self.stem(fodf)  # (B, F, D, H, W)

        # Encoder pass — collect skip connections
        skips = []
        for enc in self.encoders:
            skip, x = enc(x)
            skips.append(skip)

        # Bottleneck
        x = self.bottleneck(x)

        # Decoder pass — use skips in reverse order
        for i, dec in enumerate(self.decoders):
            skip = skips[self.depth - 1 - i]
            x = dec(x, skip, prompt)

        return self.head(x)  # (B, 2, D, H, W)

    def predict_parcellation(
        self,
        fodf:    torch.Tensor,
        prompt:  torch.Tensor,
        n_parcels: int = 10,
    ) -> Tuple[torch.Tensor, torch.Tensor]:
        """
        Inference-time prediction (Section 2.1.5).

        Returns:
          mask   : (B, D, H, W) binary bundle mask
          labels : (B, D, H, W) integer parcellation labels in [1, n_parcels]
                   0 is background (outside mask)

        Label discretization (Section 2.1.5):
          1. Apply sigmoid to parcellation channel → continuous_map ∈ (0, 1]
          2. Multiply by n_parcels (C) → float in (0, C]
          3. Apply ceiling → integers in [1, C]
          4. Mask out background voxels (mask=0)
        """
        with torch.no_grad():
            out = self(fodf, prompt)
        mask_logit = out[:, 0]                     # (B, D, H, W)
        parc_logit = out[:, 1]                     # (B, D, H, W)
        mask = (torch.sigmoid(mask_logit) > 0.5).long()
        continuous = torch.sigmoid(parc_logit)      # ∈ (0, 1]
        labels = torch.ceil(continuous * n_parcels).long()  # ∈ [1, C]
        labels = labels * mask                       # 0 = background
        return mask, labels


# ─── SECTION 8: Combined Training Loss (Section 2.1.4) ────────────────────────

class BundleParcLoss(nn.Module):
    """
    Combined loss for BundleParc training (Section 2.1.4):

    L_total = L_CE(parcellation_logit, continuous_label_map)
            + L_Dice(mask_logit, binary_mask_target)

    Cross-entropy loss treats the continuous (Gaussian-blurred, rescaled)
    parcellation map as a per-voxel scalar regression target.
    Dice loss encourages the predicted mask to overlap with the ground-truth
    binary bundle mask.

    Parameters
    ----------
    dice_weight : relative weight of Dice loss (default: 1.0 = equal)
    """
    def __init__(self, dice_weight: float = 1.0):
        super().__init__()
        self.dice_weight = dice_weight

    @staticmethod
    def dice_loss(pred_logit: torch.Tensor, target: torch.Tensor, eps: float = 1e-6) -> torch.Tensor:
        """
        Soft Dice loss over the bundle mask channel.
        pred_logit: (B, D, H, W) raw logit
        target    : (B, D, H, W) binary float {0, 1}
        """
        pred = torch.sigmoid(pred_logit)
        inter = (pred * target).sum()
        union = pred.sum() + target.sum()
        return 1 - (2 * inter + eps) / (union + eps)

    def forward(
        self,
        out:           torch.Tensor,   # (B, 2, D, H, W) model output
        parc_target:   torch.Tensor,   # (B, D, H, W) continuous label ∈ (0, 1]
        mask_target:   torch.Tensor,   # (B, D, H, W) binary {0, 1}
    ) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
        mask_logit = out[:, 0]   # (B, D, H, W)
        parc_logit = out[:, 1]   # (B, D, H, W)

        # Cross-entropy on continuous parcellation labels (Section 2.1.4)
        parc_prob = torch.sigmoid(parc_logit)
        eps = 1e-7
        l_ce = -(parc_target * torch.log(parc_prob + eps)
                 + (1 - parc_target) * torch.log(1 - parc_prob + eps)).mean()

        # Dice loss on bundle mask (Section 2.1.4)
        l_dice = self.dice_loss(mask_logit, mask_target.float())

        total = l_ce + self.dice_weight * l_dice
        return total, l_ce, l_dice


# ─── SECTION 9: Training Loop (Section 2.1.4) ─────────────────────────────────

def train_one_epoch(
    model:     BundleParc,
    loader,
    criterion: BundleParcLoss,
    optimizer: torch.optim.Optimizer,
    device:    torch.device,
    scaler:    Optional[torch.cuda.amp.GradScaler] = None,
) -> Dict:
    """
    One training epoch over all (fodf, prompt, parc_target, mask_target) tuples.
    Supports mixed-precision training via GradScaler for A100-class GPUs.
    Matches training regime in Section 2.1.4:
      - AdamW optimizer, batch size 3, lr=1e-4, cosine decay + warmup
      - Augmentation applied externally via MONAI transforms
    """
    model.train()
    total_loss = ce_loss_sum = dice_loss_sum = 0.0
    n_batches = 0

    for batch in loader:
        fodf       = batch['fodf'].to(device)         # (B, 45, D, H, W)
        prompt     = batch['prompt'].to(device)       # (B, 71)
        parc_tgt   = batch['parcellation'].to(device) # (B, D, H, W) continuous
        mask_tgt   = batch['mask'].to(device)         # (B, D, H, W) binary

        optimizer.zero_grad()
        if scaler is not None:
            with torch.cuda.amp.autocast():
                out = model(fodf, prompt)
                loss, l_ce, l_dice = criterion(out, parc_tgt, mask_tgt)
            scaler.scale(loss).backward()
            scaler.unscale_(optimizer)
            nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            scaler.step(optimizer)
            scaler.update()
        else:
            out = model(fodf, prompt)
            loss, l_ce, l_dice = criterion(out, parc_tgt, mask_tgt)
            loss.backward()
            nn.utils.clip_grad_norm_(model.parameters(), 1.0)
            optimizer.step()

        total_loss    += loss.item()
        ce_loss_sum   += l_ce.item()
        dice_loss_sum += l_dice.item()
        n_batches     += 1

    n = max(n_batches, 1)
    return {'loss': total_loss/n, 'ce': ce_loss_sum/n, 'dice': dice_loss_sum/n}


# ─── SECTION 10: Evaluation Metric (Dice) ─────────────────────────────────────

def label_wise_dice(
    pred_labels: torch.Tensor,   # (D, H, W) integer labels [0, C]
    true_labels: torch.Tensor,   # (D, H, W) integer labels [0, C]
    n_labels:    int,
) -> float:
    """
    Compute mean label-wise Dice coefficient across all non-background labels.
    Used in experiments 4.2 and 4.3 (Figs. 9, 10) to assess parcellation
    reproducibility.

    For each label c ∈ [1, C]:
        Dice_c = 2 * |pred_c ∩ true_c| / (|pred_c| + |true_c|)
    Returns the mean over labels that appear in either prediction or ground truth.
    """
    dices = []
    for c in range(1, n_labels + 1):
        p = (pred_labels == c).float()
        t = (true_labels == c).float()
        inter = (2 * (p * t).sum()).item()
        union = (p.sum() + t.sum()).item()
        if union > 0:
            dices.append(inter / union)
    return float(np.mean(dices)) if dices else 0.0


def bundle_dice(pred_mask: torch.Tensor, true_mask: torch.Tensor) -> float:
    """Binary Dice coefficient for whole-bundle segmentation (Section 4.1)."""
    p = pred_mask.float().flatten()
    t = true_mask.float().flatten()
    inter = (2 * (p * t).sum()).item()
    union = (p.sum() + t.sum()).item()
    return inter / union if union > 0 else 0.0


# ─── SECTION 11: Build Training Configuration (Section 2.1.4) ─────────────────

def build_optimizer_and_scheduler(
    model: BundleParc,
    n_epochs: int   = 300,    # fine-tuning epochs (Section 2.1.4)
    lr:       float = 1e-4,   # Section 2.1.4
    warmup:   int   = 50,      # epochs with linear warmup (Section 2.1.4)
):
    """
    AdamW optimizer with cosine decay and linear warmup (Section 2.1.4):
        β1=0.9, β2=0.999, weight_decay=0.1
        Initial lr=1e-4 → cosine decay after warmup
    """
    optimizer = torch.optim.AdamW(
        model.parameters(), lr=lr,
        betas=(0.9, 0.999), weight_decay=0.1
    )

    # Linear warmup + cosine annealing
    def lr_lambda(epoch):
        if epoch < warmup:
            return (epoch + 1) / warmup   # linear warmup
        progress = (epoch - warmup) / max(n_epochs - warmup, 1)
        return 0.5 * (1 + math.cos(math.pi * progress))  # cosine decay

    scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda)
    return optimizer, scheduler


# ─── SECTION 12: Smoke Test ───────────────────────────────────────────────────

if __name__ == '__main__':
    print("="*65)
    print("BundleParc — Full Pipeline Smoke Test")
    print("Medical Image Analysis 112 (2026) 104087")
    print("="*65)

    device = torch.device('cpu')

    # Small config for CPU smoke test (paper uses 128³, base_ch=larger F)
    SH_CH   = 45    # SH order 8 = 45 coefficients
    N_BUND  = 71    # TractSeg bundle count
    BASE_CH = 8     # paper uses larger; 8 for CPU test
    D = H = W = 16  # paper uses 128³; 16³ for CPU test
    B = 2

    print(f"\nConfig: {B} samples, SH={SH_CH}ch, spatial={D}³, base_ch={BASE_CH}, n_bundles={N_BUND}")

    print("\n[1/5] Build model...")
    model = BundleParc(sh_channels=SH_CH, n_bundles=N_BUND,
                       base_ch=BASE_CH, depth=4).to(device)
    n_params = sum(p.numel() for p in model.parameters())
    print(f"  Total parameters: {n_params:,}")

    print("\n[2/5] Forward pass — shape check...")
    fodf   = torch.randn(B, SH_CH, D, H, W)
    prompt = F.one_hot(torch.randint(0, N_BUND, (B,)), N_BUND).float()
    out = model(fodf, prompt)
    print(f"  Input fODF shape:  {fodf.shape}")
    print(f"  Prompt shape:      {prompt.shape}")
    print(f"  Output shape:      {out.shape}  (should be [{B}, 2, {D}, {D}, {D}])")

    print("\n[3/5] Loss computation...")
    criterion = BundleParcLoss(dice_weight=1.0)
    parc_target = torch.rand(B, D, H, W)                  # continuous ∈ [0,1]
    mask_target = (torch.rand(B, D, H, W) > 0.5).float()  # binary
    loss, l_ce, l_dice = criterion(out, parc_target, mask_target)
    print(f"  Total loss: {loss.item():.4f} (CE: {l_ce.item():.4f}, Dice: {l_dice.item():.4f})")
    loss.backward()
    grad_ok = all(p.grad is not None for p in model.parameters() if p.requires_grad)
    print(f"  Gradients OK: {grad_ok}")

    print("\n[4/5] Inference with label discretization (Section 2.1.5)...")
    for C in [10, 25, 50]:
        mask_pred, label_pred = model.predict_parcellation(fodf, prompt, n_parcels=C)
        unique_labels = label_pred[0].unique().tolist()
        print(f"  C={C:3d}: mask shape={mask_pred.shape}, label range=[{label_pred.min()}, {label_pred.max()}]"
              f", unique labels (sample 0): {[int(l) for l in unique_labels[:8]]}...")

    print("\n[5/5] Evaluation metrics...")
    # Simulated scan-rescan: random labels for two acquisitions
    labels_acq1 = torch.randint(0, 11, (D, H, W))
    labels_acq2 = torch.randint(0, 11, (D, H, W))
    lwd = label_wise_dice(labels_acq1, labels_acq2, n_labels=10)
    print(f"  Random label-wise Dice (should be ~0.09 for random): {lwd:.4f}")
    bd  = bundle_dice(mask_pred[0], mask_target[0].long())
    print(f"  Bundle Dice (untrained model vs random target): {bd:.4f}")

    print("\n[Optimizer check] Build AdamW + cosine scheduler...")
    opt, sched = build_optimizer_and_scheduler(model, n_epochs=300)
    print(f"  Optimizer: {type(opt).__name__}, lr={opt.param_groups[0]['lr']:.2e}")
    sched.step()
    print(f"  After warmup step 1, lr={opt.param_groups[0]['lr']:.2e}")

    print("\n✓  All checks passed. BundleParc is ready for training.")
    print("  To reproduce paper results:")
    print("  1. Download HCP data + TractSeg silver-standard bundles (105 subjects)")
    print("  2. Generate atlas-guided hyperplane parcellation labels (Section 2.1.2)")
    print("  3. Pretrain on 868 atlas-registered HCP subjects for 1000 epochs")
    print("  4. Fine-tune on 105 annotated subjects for 300 epochs, 5-fold CV")
    print("  5. Use base_ch appropriate for 4×A100 40GB (paper used larger F)")
    print("  6. At inference: run 71 times with different one-hot prompts")
    print("  7. Discretize output to any C via: labels = ceil(sigmoid(out[:,1]) * C)")
    print("  Code + weights: https://github.com/scil-vital/BundleParc-flow")

Read the Full Paper & Use BundleParc

BundleParc is open-source, pretrained weights are publicly available, and it integrates directly into the scilpy neuroimaging library. The paper includes full ablation on centerline strategies, quantitative ICC results, and visualisations across five cohorts.

Academic Citation:
Théberge, A., El Yamani, Z., Barakovic, M., Magon, S., Yang, J. Y.-M., Descoteaux, M., Rheault, F., & Jodoin, P.-M. (2026). BundleParc: Consistent white matter bundle parcellation without tractography. Medical Image Analysis, 112, 104087. https://doi.org/10.1016/j.media.2026.104087

This article is an independent editorial analysis of open-access peer-reviewed research (CC BY-NC 4.0). The PyTorch implementation faithfully reproduces the MedNeXt block structure, downsampling/upsampling blocks, two-way cross-attention decoder blocks with 3D sinusoidal positional encoding, four-layer encoder-decoder with skip connections, stem/head projections, combined CE+Dice training loss, AdamW optimiser with cosine-decay warmup, and arbitrary-C label discretization. The base channel width is reduced from the paper’s GPU-scale configuration for CPU-compatible smoke testing. Pretrained weights and the production implementation are available at the linked GitHub repository.

Leave a Comment

Your email address will not be published. Required fields are marked *

Follow by Email
Tiktok