Brain Tumor Segmentation on MRI with MONAI and UNet
Introduction
Medical imaging data is fundamentally different from standard RGB photos. MRI and CT scans are 3-dimensional volumes stored in specialized formats like NIfTI (.nii.gz) or DICOM. To perform deep learning on these volumes, we use MONAI (Medical Open Network for AI), a PyTorch-based framework specifically designed for healthcare imaging. In this tutorial, we will build a 3D U-Net to automatically segment brain tumors from multi-modal MRI scans using the BraTS (Brain Tumor Segmentation) dataset.
Architecture Overview
graph TD
Input[4-Channel 3D MRI Volume] --> Enc1[Encoder Block 1]
Enc1 --> Down1((Downsample)) --> Enc2[Encoder Block 2]
Enc2 --> Down2((Downsample)) --> Enc3[Encoder Block 3]
Enc3 --> Down3((Downsample)) --> Bottleneck[Bottleneck]
Bottleneck --> Up1((Upsample)) --> Dec1[Decoder Block 1]
Enc3 -. Skip Connection .-> Dec1
Dec1 --> Up2((Upsample)) --> Dec2[Decoder Block 2]
Enc2 -. Skip Connection .-> Dec2
Dec2 --> Up3((Upsample)) --> Dec3[Decoder Block 3]
Enc1 -. Skip Connection .-> Dec3
Dec3 --> Output[4-Channel 3D Segmentation Mask]
Prerequisites
- Python 3.9+
- PyTorch, MONAI, nibabel
- A GPU with at least 12GB VRAM (3D convolutions are memory-intensive)
- The Medical Decathlon Task01_BrainTumour dataset
Step 1: Install Dependencies
pip install torch torchvision monai nibabel matplotlib
Step 2: Define the MONAI Transform Pipeline
MONAI provides a suite of dictionary-based transforms (ending in d) that handle loading NIfTI files, aligning voxel orientations, normalizing intensities, and applying data augmentation in 3D.
from monai.transforms import (
Compose, LoadImaged, EnsureChannelFirstd, Spacingd,
Orientationd, ScaleIntensityRanged, CropForegroundd,
RandCropByPosNegLabeld, RandAffined
)
# Define the training transforms
train_transforms = Compose([
# Load image and label from NIfTI files
LoadImaged(keys=["image", "label"]),
# Ensure shape is [Channels, Depth, Height, Width]
EnsureChannelFirstd(keys=["image", "label"]),
# Resample all images to 1x1x1 mm isotropic voxel spacing
Spacingd(keys=["image", "label"], pixdim=(1.0, 1.0, 1.0), mode=("bilinear", "nearest")),
# Reorient to Right-Anterior-Superior (RAS) axis
Orientationd(keys=["image", "label"], axcodes="RAS"),
# Crop away zero-intensity background to save compute
CropForegroundd(keys=["image", "label"], source_key="image"),
# Extract 96x96x96 random patches ensuring we hit tumor regions
RandCropByPosNegLabeld(
keys=["image", "label"],
label_key="label",
spatial_size=(96, 96, 96),
pos=1, neg=1, num_samples=4
),
# Random 3D rotations for augmentation
RandAffined(keys=['image', 'label'], prob=0.5, rotate_range=(0.1, 0.1, 0.1))
])
Step 3: Setup Datasets and Dataloaders
We use MONAI's CacheDataset to load everything into memory (if RAM allows) to drastically speed up training, or standard Dataset for lazy loading.
from monai.data import CacheDataset, DataLoader
import os
# Assume we have a list of dictionaries with paths
# data_dicts = [{"image": "img1.nii.gz", "label": "seg1.nii.gz"}, ...]
# train_files, val_files = data_dicts[:80], data_dicts[80:]
train_ds = CacheDataset(data=train_files, transform=train_transforms, cache_rate=1.0)
train_loader = DataLoader(train_ds, batch_size=2, shuffle=True, num_workers=4)
# (Validation transforms and dataloader setup omitted for brevity)
Step 4: The 3D U-Net Model
MONAI provides a heavily optimized 3D U-Net. Because MRI scans often have multiple channels (e.g., T1, T1Gd, T2, FLAIR), the in_channels might be 4. The out_channels represents the number of segmentation classes (e.g., Background, Edema, Enhancing Tumor, Non-Enhancing Core).
from monai.networks.nets import UNet
import torch
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = UNet(
spatial_dims=3, # 3D Network
in_channels=4, # 4 MRI Modalities
out_channels=4, # 4 Tumor Classes
channels=(16, 32, 64, 128, 256), # Number of filters per layer
strides=(2, 2, 2, 2), # Downsampling strides
num_res_units=2 # Use residual blocks
).to(device)
Step 5: Training Loop with Dice Loss
For medical segmentation, standard Cross-Entropy is often inadequate due to massive class imbalance (the tumor is tiny compared to the background). We use DiceLoss instead, which optimizes the intersection over union.
from monai.losses import DiceLoss
loss_function = DiceLoss(to_onehot_y=True, softmax=True)
optimizer = torch.optim.Adam(model.parameters(), 1e-4)
max_epochs = 50
for epoch in range(max_epochs):
model.train()
epoch_loss = 0
for batch_data in train_loader:
inputs, labels = batch_data["image"].to(device), batch_data["label"].to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = loss_function(outputs, labels)
loss.backward()
optimizer.step()
epoch_loss += loss.item()
print(f"Epoch {epoch+1}/{max_epochs}, Loss: {epoch_loss/len(train_loader):.4f}")
Troubleshooting: CUDA Out of Memory (OOM)
Training 3D Convolutional Neural Networks requires massive amounts of VRAM. If you encounter a CUDA out of memory error, try the following steps in order:
- Reduce Batch Size: Change
batch_size=2tobatch_size=1in your DataLoader. - Enable Mixed Precision: Use PyTorch's
torch.cuda.amp.autocast()to compute gradients in 16-bit precision, cutting memory usage in half. - Reduce Spatial Size: Change the
spatial_sizeinRandCropByPosNegLabeldfrom(96, 96, 96)to(64, 64, 64). - Gradient Accumulation: If you must use a batch size of 1, accumulate gradients over multiple steps before calling
optimizer.step()to simulate a larger batch size.
Conclusion
MONAI abstracts away the immense complexity of handling 3D medical images. By using robust DictionaryTransforms to handle spacing and orientation issues natively within the dataloader, and DiceLoss to handle class imbalance, developers can focus on architecture and deployment rather than writing tedious, error-prone voxel-manipulation code. Once trained, this model can be easily exported via TorchScript and deployed using the Triton Inference Server.