ZVIOLIN
技术实践

BraTS 脑肿瘤分割实践:从数据预处理到滑动窗口推理

以 BraTS 数据集为例,梳理 3D 医学图像分割的完整流程:NIfTI 数据组织、归一化策略、patch 训练、Dice 损失与滑动窗口推理的工程细节

最近几个月一直在 BraTS 数据集上折腾脑肿瘤分割,做 SegResMamba-Lite 的时候把整条管线从零到一过了好几遍。这篇想把流程串起来讲一遍——重点不是介绍模型,而是那些论文里不会写、实验记录里一闪而过、但实际动手时一定会撞上的坑。

先搞清楚数据长什么样

BraTS 每个病例是四个模态的 MRI 加一个分割标注:

文件后缀 含义 一句话记忆
t1.nii.gz T1 加权 解剖结构清楚,肿瘤看着偏暗
t1ce.nii.gz T1 增强 打了造影剂,增强肿瘤就看它
t2.nii.gz T2 加权 水肿敏感
flair.nii.gz FLAIR 水肿和浸润区域最敏感
seg.nii.gz 标注 标签 1/2/4,4 是增强肿瘤

尺寸统一是 240 x 240 x 155,spacing 也不用重采样——这点 BraTS 对新手很友好。等换了 AMOS、KiTS 那种数据集,各向异性 spacing 处理就躲不掉了,先在 BraTS 上把流程跑顺再换是个不错的路径。

标签有个容易忽略的点:评估用的不是原始三标签,而是三个嵌套子区域——WT(1+2+4,全肿瘤)、TC(1+4,瘤核心)、ET(只算 4,增强肿瘤)。预处理时习惯把标签 4 重映射成 3,得到 {0,1,2,3} 的连续整数,one-hot 的时候省心。

预处理:模型上限其实卡在这里

刚开始我也以为预处理就是"归一化一下",直到第一版模型 dice 死活上不去,排查了两天才发现是归一化把背景算进统计了。

MRI 的背景是真·无信号,拿全图做 min-max 或者全图 z-score,均值方差全被 0 拉偏,前景信号被压得不成样子。正确做法是只统计非零体素:

import numpy as np

def zscore_normalize(volume: np.ndarray) -> np.ndarray:
    """
    按非零体素做 z-score(0 是背景,不参与统计)
    """
    mask = volume > 0
    mean = volume[mask].mean()
    std = volume[mask].std()
    return (volume - mean) / (std + 1e-8)

就这一行 volume > 0 的区别,当时让 WT dice 直接抬了快两个点。

另外两件值得做的小事:

一是裁掉纯黑边。原始体积四周大片是零,按非零 bounding box 裁一圈再训练,省显存也省时间:

def crop_to_nonzero(volume: np.ndarray, margin: int = 8):
    """
    裁到非零区域 bounding box,留 margin 个体素余量
    """
    coords = np.argwhere(volume > 0)
    lo, hi = coords.min(axis=0), coords.max(axis=0)
    lo = np.maximum(lo - margin, 0)
    hi = np.minimum(hi + margin + 1, volume.shape)
    return volume[lo[0]:hi[0], lo[1]:hi[1], lo[2]:hi[2]]

二是数据增强要克制。自然图像那套大刀阔斧的增强搬到医学图像上经常翻车——比如三轴随便翻转,大脑左右大致对称所以只有左右轴翻转是合理的,前后轴一翻,解剖上就不成立了,模型学到的是假样本。我的常用组合就是 MONAI 的仿射(旋转 ±15°、缩放 0.9~1.1)+ 弹性形变 + gamma/高斯噪声,没有更多花活。

模型怎么选:先跑通,再谈花活

选型这件事我现在的看法很简单:先用 3D U-Net 或 SegResNet 拿到基线,再看结构有没有必要换。结构带来的收益,往往没有预处理和增强调对带来的收益大。

模型 一句话点评 显存
3D U-Net 基线首选,不解释 中
SegResNet 残差编码 + 参数量小,精度不差 低
nnU-Net 自动配置一切,打不过它先别谈改进 高
SwinUNETR Transformer 编码器,大数据集再考虑 很高

nnU-Net 我建议把它当「对手盘」用:如果你的改进版打不过它默认配置,那问题大概率不在结构,在自己的管线。

SegResNet 在 MONAI 里开箱即用,BraTS 四模态进、四通道出:

from monai.networks.nets import SegResNet

model = SegResNet(
    in_channels=4,        # 四个模态
    out_channels=4,       # 背景 + 三个肿瘤子区域
    init_filters=16,      # 显存紧张从 8 起步
    blocks_down=[1, 2, 2, 4],
    blocks_up=[1, 1, 1],
)

损失函数:别让交叉熵一个人扛

分割绕不开类别不均衡——增强肿瘤有时只占体积的 0.5%,纯交叉熵会被背景主导,训出来就是「什么都预测成背景」。Dice Loss 天然对不均衡鲁棒:

LDice=1−2∑ipigi∑ipi+∑igi\mathcal{L}_{Dice} = 1 - \frac{2 \sum_i p_i g_i}{\sum_i p_i + \sum_i g_i}

但只拿 Dice 训也有问题:单样本梯度太软,前期收敛慢。实践里最稳的是 Dice + CE 联合,也就是 nnU-Net 那套思路——Dice 管不均衡,CE 提供稳定的逐像素梯度:

import torch
import torch.nn as nn
import torch.nn.functional as F

class DiceCELoss(nn.Module):
    """
    Dice + 交叉熵联合损失
    """
    def __init__(self):
        super().__init__()
        self.ce = nn.CrossEntropyLoss()

    def forward(self, logits, target):
        # logits: (B, C, D, H, W),target: (B, D, H, W) 长整型
        ce_loss = self.ce(logits, target)
        prob = F.softmax(logits, dim=1)
        dice = 0.0
        for c in range(1, prob.shape[1]):
            p = prob[:, c]
            g = (target == c).float()
            dice += 1 - (2 * (p * g).sum() + 1e-5) / (p.sum() + g.sum() + 1e-5)
        return ce_loss + dice / (prob.shape[1] - 1)

训练配置上,128 x 128 x 128 的 patch 是 24G 显存下比较舒服的平衡点(batch size 2 起步)。有个细节很关键:patch 中心要过采样前景——不做的话,小肿瘤病例的 patch 里可能压根没有目标,等于白训。我一般是 2/3 概率把 patch 中心落在前景体素上。优化器 SGD momentum 0.9 在 3D 分割上通常比 AdamW 稳,学习率 poly 衰减就够用。

推理:滑动窗口的接缝问题

训练用 patch,推理整卷进来,中间靠滑窗拼接。这里最容易出的事故是接缝伪影:相邻窗口预测不一致,拼出来的概率图上有一道道"格子"。解法是重叠采样 + 高斯重要性图加权——窗口中心的预测更可信,边缘的权重压低:

import torch

def sliding_window_infer(model, volume, patch_size=(128, 128, 128), overlap=0.5):
    """
    滑动窗口推理:高斯权重融合,避免拼接边界伪影
    """
    device = next(model.parameters()).device
    output = torch.zeros((1, 4) + volume.shape[-3:], device=device)
    weight_map = torch.zeros(output.shape, device=device)

    # 高斯重要性图:中心权重高、边缘权重低
    importance = torch.ones(patch_size, device=device)
    for axis, s in enumerate([p / 6 for p in patch_size]):
        center = patch_size[axis] / 2
        coord = torch.arange(patch_size[axis], device=device)
        gauss = torch.exp(-((coord - center) ** 2) / (2 * s ** 2))
        shape = [1, 1, 1]
        shape[axis] = -1
        importance = importance * gauss.view(shape)

    model.eval()
    with torch.no_grad():
        for start in generate_starts(volume.shape[-3:], patch_size, overlap):
            patch = volume[..., start[0]:start[0]+patch_size[0],
                           start[1]:start[1]+patch_size[1],
                           start[2]:start[2]+patch_size[2]]
            logits = model(patch)
            output[..., *start] += logits * importance
            weight_map[..., *start] += importance

    return output / weight_map.clamp(min=1e-5)

实际项目直接用 MONAI 的 sliding_window_inference,它还做了 patch 级 batch 并行。贴这段是想说清楚权重融合的原理——理解了它,预测图上出现奇怪的格子时才知道去查哪里。

评估:全局 Dice 会骗人

最后说说评估,这也是我交过学费的地方。早期我把所有病例的预测堆在一起算了个全局 Dice,数字挺好看,直到可视化逐病例结果才发现:大肿瘤病例贡献了绝大部分体素,把小肿瘤病例上的失败全平均掉了。

正确做法是分病例算 Dice 再取平均。另外 Dice 在漏检整块肿瘤时会是刺眼的 0,配合 HD95(95% Hausdorff 距离)看边界质量才完整。BraTS 验证集上个人复现过的大致量级:

区域 Dice HD95 (mm)
WT 0.91 ~ 0.93 4 ~ 7
TC 0.79 ~ 0.83 6 ~ 10
ET 0.75 ~ 0.80 5 ~ 9

写在最后

回头看,3D 医学分割真正的门槛不在模型结构,而在数据管线的严谨性——归一化统计错了、patch 没过采样前景、滑窗没加权,每一处都会悄悄吃掉好几个点,而且训练曲线完全看不出来,只有逐病例看预测可视化才会暴露。

所以我的建议是:先用 MONAI 官方 tutorial 跑通基线,然后花一个下午用 3D Slicer 把验证集的预测逐个翻一遍——建立对错误模式的直觉,比多试十个模型结构有用得多。

SegResMamba 那边的训练流水线细节(MoA 专家怎么设计、V1 到 V12 怎么迭代),之后单独写一篇。