简介:面向计算机相关专业学生与研究者的脑肿瘤分割毕设项目资料包,聚焦 3D-UNet、3D-VNet 与 2D-UNet 三种经典分割网络的算法实现与对比,并附带生存预测模型,覆盖从数据生成、模型训练到结果分析的完整流程,可直接用于毕业设计、课程设计或入门深度学习分割任务。压缩包共五十个文件,主要包含 Python 源码、Jupyter Notebook 实验记录、网络结构示意图、预训练权重与预测模型文件,另有项目报告 PDF 和说明文档,整体仅三点零五兆字节,轻量易部署。已有九十三人学习下载。资料中不仅有可复现的训练脚本和测试代码,还包含二十余个分析过程,以及生存预测的回归分类模型与 SVM、Cox 等拟合结果,方便对照实验和撰写报告。对于希望快速搭建脑肿瘤分割基线、理解多模型融合思路或进行二次开发的学习者,这份资源能提供清晰参考。
1. 一个能跑的脑肿瘤分割项目:三种UNet实现与生存预测模型的价值
很多做医学图像分割的同学卡在同一个地方:论文里把 2D-UNet、3D-UNet、3D-VNet 讲得头头是道,真到自己动手,数据预处理、模型搭建、训练收敛、指标评估每一步都可能翻车。如果你正在做脑肿瘤分割相关的毕设或课程设计,这份资源值得仔细拆一遍——它把三条技术路线完整落地了:2D-UNet 的多轴向切片分割、3D-UNet 的全三维体素分割、3D-VNet 的残差式编码解码,并且额外带了一个多数分割项目都没有的模块:基于分割结果的生存预测模型。也就是说,你不只能拿到脑肿瘤区域的分割掩膜,还能进一步拟合患者的生存风险分数,这正好是 BraTS 挑战赛里从分割走向临床决策的经典路径。适合两类人:一是需要快速跑通基线做毕设开题的本科生,二是想在分割基础上加预后分析的研究生。
2. 数据准备与预处理:把BraTS四模态nii文件切成可训练的张量
2.1 多模态输入的读取与归一化
脑肿瘤分割用的标准数据是 BraTS 格式的 NIfTI 文件,四个模态分别是 T1、T1ce、T2、FLAIR,每个患者一个文件夹,里面有四个模态的 nii.gz 和一个分割标签 nii.gz。这个项目里的data_gen.py承担的就是从原始 nii 文件到训练张量的转换工作。常见做法是先把四个模态沿着通道维拼接,形成一个[4, D, H, W]或[D, H, W, 4]的多通道三维体数据,再做体素值归一化。
import nibabel as nib import numpy as np def load_braTS_volume(patient_path): modalities = ['t1', 't1ce', 't2', 'flair'] volume_list = [] for mod in modalities: nii_path = f"{patient_path}/{patient_path.split('/')[-1]}_{mod}.nii.gz" img = nib.load(nii_path).get_fdata() volume_list.append(img) # 四模态堆叠:形状 (H, W, D, 4) volume = np.stack(volume_list, axis=-1) return volume def normalize_volume(volume): # 逐模态独立做 z-score 归一化,避免高信号区压制低信号区 for c in range(volume.shape[-1]): channel = volume[..., c] mean = channel.mean() std = channel.std() volume[..., c] = (channel - mean) / (std + 1e-8) return volume这里的 normalize 逻辑有一个容易忽视的细节:为什么不能把四个模态拉平后一起算均值和方差?因为 T2 和 FLAIR 的信号强度范围通常比 T1 高不少,统一归一化会让 T1 通道的特征信息被压缩,训练时分割网络会更依赖 T2 通道。逐模态独立归一化能保证每个模态的对比度都被保留。尾部的1e-8是为了防止某些纯背景切片标准差为零导致除零错误,这个在医学影像中很常见。
2.2 2D 模型的多轴向切片策略
2D-UNet 要吃的是二维切片,但脑肿瘤是三维结构,从哪个方向切、切多少片直接决定模型能不能学到肿瘤的空间形态。项目里2D_axis1、2D_axis2、2D_axis3三个目录对应的就是分别沿三个坐标轴方向切片训练独立的 2D-UNet,最后对三个模型的结果做集成投票。Axis Integration目录就是干这个的。
def extract_2d_slices(volume, label, axis=2): # axis=2 表示沿 z 轴切,即 axial 横断面 slices_X, slices_y = [], [] num_slices = volume.shape[axis] for idx in range(num_slices): if axis == 0: x, y = volume[idx, :, :, :], label[idx, :, :] elif axis == 1: x, y = volume[:, idx, :, :], label[:, idx, :] else: x, y = volume[:, :, idx, :], label[:, :, idx] slices_X.append(x) slices_y.append(y) return np.array(slices_X), np.array(slices_y)实际训练时通常会跳过肿瘤像素占比低于某个阈值的切片,比如只保留标签中肿瘤区域面积大于 10 个像素的 slice,否则背景切片占比过高会让模型倾向预测全背景。这个过滤逻辑在data_gen.py里是作为一个配置项存在的,你改成自己的数据时,要注意这个阈值调大调小的影响:阈值调大,正样本更集中但切片数量减少;阈值调小,切片数量多但正负样本失衡加剧。
2.3 3D 输入的随机裁剪与数据增强
3D 模型训练最大的问题不是精度而是显存。BraTS 原始数据是 240×240×155×4,这个体量直接塞进 3D-UNet,单张 24G 的显卡也扛不住。项目里对 3D 输入做了随机裁剪,常见做法是裁128×128×128或96×96×96的 patch 作为训练输入。
def random_crop_3d(volume, label, crop_size=(128, 128, 128)): D, H, W, C = volume.shape d_start = np.random.randint(0, D - crop_size[0]) if D > crop_size[0] else 0 h_start = np.random.randint(0, H - crop_size[1]) if H > crop_size[1] else 0 w_start = np.random.randint(0, W - crop_size[2]) if W > crop_size[2] else 0 vol_crop = volume[d_start:d_start+crop_size[0], h_start:h_start+crop_size[1], w_start:w_start+crop_size[2], :] label_crop = label[d_start:d_start+crop_size[0], h_start:h_start+crop_size[1], w_start:w_start+crop_size[2]] return vol_crop, label_crop随机裁剪要配合一个策略:肿瘤区域要保证被裁进去。简单随机裁会导致大部分 patch 是纯背景,3D 模型收敛极慢甚至不收敛。常见做法是先求标签的包围盒,以包围盒中心为均值、设定一个偏移范围做高斯采样,确保每次裁剪大概率覆盖肿瘤区域。项目里没有把这种做法硬编码,但训练脚本里提供了crop_center参数,手动指定固定裁剪中心也是一种折中方案——损失一点空间多样性,换来确定的正样本覆盖率。
3. 2D-UNet与3D-UNet对比:结构差异、训练参数与第一次翻车
3.1 两种网络的核心结构差异
项目里2d_2class_models和3d_Unet_v1两个目录分别放了 2D-UNet 和 3D-UNet 的实现。最大的结构差异只有一个字:卷积核的维度。2D-UNet 用Conv2d,3D-UNet 用Conv3d,但这一换带来的连锁反应是非常深远的:参数量、感受野、池化方式、输入输出形状甚至训练稳定性都不一样。
import torch.nn as nn class ConvBlock2D(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.block(x) class ConvBlock3D(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv3d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm3d(out_ch), nn.ReLU(inplace=True), nn.Conv3d(out_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm3d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.block(x)同样的通道数配置,3D 卷积的参数量大约是 2D 的三倍(因为多了一个深度维度的 kernel 权重),显存占用更高。但 3D 网络能直接捕捉肿瘤在 z 轴上的连续结构——脑肿瘤在横断面上可能看起来是孤立的两个区域,但矢状面看其实是连通的,这种信息 2D 网络无法感知。这也是 3D-UNet 在 BraTS 上上限更高的根本原因。
3.2 训练参数怎么设才合理
项目里提供了分类用的main.py和预训练脚本pretrain3d.py,二者的训练参数差别很大。2D 模型 batch size 可以开到 16 甚至 32,但 3D 模型 batch size 通常只能开到 2 或 4。学习率方面,2D 用 Adam 时常见初始学习率是1e-3,3D 建议降到1e-4,原因很简单:3D 模型参数量更大,梯度更新方差更大,高学习率很容易震荡。
# 2D 训练常用配置 optimizer_2d = torch.optim.Adam(model_2d.parameters(), lr=1e-3, weight_decay=1e-5) scheduler_2d = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer_2d, T_max=100) # 3D 训练常用配置 optimizer_3d = torch.optim.Adam(model_3d.parameters(), lr=1e-4, weight_decay=1e-5) scheduler_3d = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer_3d, mode='max', patience=10)两个值得注意的细节。第一,3D 模型配ReduceLROnPlateau比固定步长衰减更实用,因为 3D 训练的 loss 曲线波动大,按 epoch 固定衰减容易在模型还没收敛时就把学习率降没了。第二,weight_decay 两个模型都设1e-5就够了,医学图像分割的标签本身噪音大,正则化过强会压制模型对细节边缘的拟合能力。
3.3 第一次翻车:2D 模型训完预测结果全是黑的
我最早跑这个项目的 2D 分支时,先用的2D_axis2(冠状面)做训练,Dice 看起来还行,loss 也降到了合理区间,但推理出来的分割掩膜基本是全黑的。查了很久发现是坐标映射的问题:我用 z 轴切片训练的模型,推理时却沿 x 轴切了输入数据。2D-UNet 不认识方向,你给它什么方向的切片它就学什么方向的特征,推理时切片方向必须和训练时严格一致。项目里2D_axis1/2/3三个独立目录的命名就是在提醒这一点——每个轴方向的模型只能吃对应方向的数据。
4. 3D-VNet:残差连接、损失权衡与显存管理
4.1 VNet 与 UNet 的本质区别
VNet 在很多实现里被误认为只是“把 UNet 的卷积块换成残差块”,其实关键区别在两点:一是 VNet 在下采样路径使用了带残差连接的卷积块,梯度可以更直接地回传到浅层;二是 VNet 的编码器输出和解码器输入之间用了 concat 之外的另一种融合方式——加法融合,类似 FPN 的思想。项目3d_Vnet目录下的3dvnet.py是标准的 VNet 实现,它的模型文件比 3D-UNet 长不少,主要长在残差块的定义上。
class VNetResBlock(nn.Module): def __init__(self, in_ch, out_ch, use_residual=True): super().__init__() self.use_residual = use_residual self.conv1 = nn.Conv3d(in_ch, out_ch, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm3d(out_ch) self.conv2 = nn.Conv3d(out_ch, out_ch, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm3d(out_ch) # 捷径连接:如果输入输出通道不一致,用 1x1x1 卷积对齐 if in_ch != out_ch: self.shortcut = nn.Conv3d(in_ch, out_ch, kernel_size=1) else: self.shortcut = nn.Identity() def forward(self, x): identity = self.shortcut(x) out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) if self.use_residual: out = out + identity return F.relu(out)残差连接的价值在训练初期就体现出来了。没有残差连接的深 U 型网络,前几个 epoch 的 loss 往往掉得很慢,因为梯度从最后一层传回第一层时已经衰减得差不多。加了残差连接后,浅层也能拿到较大的梯度信号,模型在 30 个 epoch 左右的收敛速度明显快于同等深度的 UNet。
4.2 损失函数的选择:Dice Loss 和它的坑
项目用的是 Dice Loss 作为主损失,源码里loss_function的可选参数有DiceLoss、BCEDiceLoss、CrossEntropyLoss三种。肿瘤分割是典型的前背景像素比极度不平衡的场景,肿瘤区域占整个脑部体素的比例通常不到 2%,CE Loss 在这种数据上会完全被背景主导。Dice Loss 天然的尺度不变性让它不需要显式处理类别权重。
def dice_loss(pred, target, smooth=1.0): # pred: (B, C, D, H, W) 概率输出, target: (B, C, D, H, W) one-hot 标签 intersection = (pred * target).sum(dim=(2, 3, 4)) union = pred.sum(dim=(2, 3, 4)) + target.sum(dim=(2, 3, 4)) dice = (2.0 * intersection + smooth) / (union + smooth) return 1.0 - dice.mean()smooth 参数设为 1.0 是经验值,过小(比如1e-6)在标签稀疏时数值不稳定,过大会让 Dice 分数虚高、梯度失真。另外注意dice_loss的输入是概率值不是 logits,使用前要确认模型输出已经过了 sigmoid。我见过直接把 logits 丢进 Dice Loss 的实现,训练时 loss 波动特别大,就是这个原因。
4.3 显存管理:3D 模型跑不动时的三条退路
3D-VNet 的显存占用比 3D-UNet 更高,因为残差连接意味着前向传播时要保留更多中间特征图用于反向传播。显存不够时有三个常见处理方式,按性价比从高到低:梯度累积、混合精度、patch 尺寸递减。
# 梯度累积:等效 batch size = 4, 显存占用只有 batch=1 的水平 accumulation_steps = 4 optimizer.zero_grad() for step, (volume, label) in enumerate(train_loader): output = model(volume) loss = dice_loss(output, label) loss = loss / accumulation_steps # 归一化,避免累积梯度过大 loss.backward() if (step + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()梯度累积的一个细节是 loss 要除以accumulation_steps,否则等效 batch size 变大后梯度绝对值同步放大,学习率不变的话很容易在训练中期发散。混合精度在 PyTorch 里用torch.cuda.amp包一下 forward 和 backward 就行,3D 卷积对 fp16 的容忍度比 2D 低,如果出现 loss 变为 NaN,优先怀疑是 fp16 动态 loss scaling 失效,关掉 amp 回溯一版对比。最后才是把128×128×128的 patch 缩到96×96×96,这个能省接近一半显存,但感受野变小,分割边缘会粗糙一些。
5. 避坑:五个影响复现的典型问题与排查路径
5.1 标签类别错位
现象:训练时 loss 降得很快,但推理出来的分割结果和原图完全对不上,比如把水肿区域标成了增强肿瘤。
原因:BraTS 原始标签是整数编码(1=坏死,2=水肿,4=增强肿瘤),如果你的代码里把标签当作 one-hot 处理,
np.eye(4)[label]会把标签值 4 映射到第 0 类,整个语义就错位了。项目里2d_4class_models和2d_2class_models的类别数不同,二分类模型把标签重映射为(1,2,4)合并成前景类,多分类则要显式做标签重编码。解决:训练前先打印
np.unique(label)检查标签分布。多分类任务做重编码时,用显式映射表:label[label==4] = 3,而不是靠np.eye自动索引。
5.2 验证集 Dice 高但预测掩膜很碎
现象:验证集 Dice 到 0.85 以上,但实际预测的肿瘤区域有大量孤立的小块,噪音明显。
原因:2D 模型逐切片推理时,切片之间缺乏空间一致性约束。简单的规则是模型对某个切片上看到的小块区域置信度高,但三维上它是孤岛。
解决:推理时用滑窗加重叠策略,对重叠区域取平均而不是取最大概率。另外可以加一个后处理步骤,用
scipy.ndimage.label做连通域分析,删除体素小于 50 的连通域。
5.3 3D 训练中期显存溢出
现象:训练前几十个 iteration 正常,突然报
CUDA out of memory,且不是显存不足而是out of memory发生在某个特定形状的输入上。原因:输入数据的 nii 文件尺寸不一致,某些患者的体数据是 240×240×155,有些是 240×240×154,random crop 时如果边界处理不严谨,裁出来的 patch 维度不统一,模型前向传播时瞬间申请大量显存。
解决:在 data_gen 里加一个 resample 或 pad 统一尺寸的步骤,保证所有输入经过预处理后
shape严格一致。同时检查 dataloader 的drop_last=True,因为最后一个 batch 样本数不足时,部分实现会触发额外显存分配。
5.4 生存预测模型效果差:回归模型全线跑偏
- 现象:Survival Prediction 里的回归模型损失函数降不下去,预测值和真实值的散点图呈现明显的非线性关系。
- 原因:生存时间的分布高度右偏,大部分患者是短期生存,少数患者长期生存,直接做均方误差回归会让模型把所有预测都押在均值附近,对长尾幸存者完全失去区分度。
- 解决:先对生存时间做 log1p 转换再回归,或者在分类模型里把生存期分成几个区间做 Ordinal Regression。项目里同时提供了 Regression Model 和 Classification Model 两套实现,实际使用以分类为主、回归为辅的集成方式效果更稳。
5.5 训练结果无法复现:随机种子没固定
现象:同一个模型和数据,两次训练出来的 Dice 分数差 3 个点以上。
原因:PyTorch 在 GPU 上的卷积实现是非确定性的,不同卡、不同 cuDNN 版本之间的计算顺序有微小差异,加上随机裁剪、随机翻转等数据增强的随机性,结果自然无法复现。
解决:训练脚本入口固定
torch.manual_seed(42)、np.random.seed(42),并把torch.backends.cudnn.deterministic = True打开。注意打开 deterministic 后部分操作会变慢,验证阶段可以关掉。
6. 生存预测与模型集成:从分割掩膜到风险分数的最后一步
6.1 生存预测模型做了什么
生存预测模块输入的不是原始影像而是特征向量。项目里survival_data.csv保存的是从分割结果中提取的影像组学特征,比如肿瘤体积、坏死区域比例、增强区域表面积、肿瘤在三轴上的最大径等。回归模型用 Cox 比例风险回归拟合生存风险,分类模型用支持向量机对风险等级做判别。cox.joblib和SVMfit.joblib是训练好直接可调的模型权重路径。
import joblib import numpy as np # 从分割掩膜提取特征 feature_vector = np.array([ tumor_volume, # 肿瘤总体积 necrosis_ratio, # 坏死区域占比 enhancing_ratio, # 增强区域占比 edema_ratio, # 水肿区域占比 max_diameter_sagittal, # 矢状位最大径 max_diameter_axial, # 横断面最大径 ]) cox_model = joblib.load('cox.joblib') svm_model = joblib.load('SVMfit.joblib') risk_score = cox_model.predict(feature_vector.reshape(1, -1)) risk_level = svm_model.predict(feature_vector.reshape(1, -1)) print(f"Cox 风险分数: {risk_score[0]:.3f}, SVM 等级: {risk_level[0]}")Cox 模型输出的是风险分数而不是生存天数,这个分数是相对值——分数越高预后越差。如果项目要求输出具体生存期,需要在 Cox 基础上再配一个基线生存函数做累积风险转换,这一步在源码里没有封装成一个独立函数,动手实践时最容易被忽略。
6.2 三个方向模型的集成策略
项目里的ensembling.png展示的就是集成流程图:把2D_axis1、2D_axis2、2D_axis3三个单轴向模型和 3D 模型的分割结果做投票。集成不是简单的平均,因为不同模型擅长捕获的信息不同。2D 模型擅长保留纹理细节,3D 模型擅长保持空间连通性,像素级的最终预测采用加权投票:3D 模型的权重为 0.4,三个 2D 模型各 0.2。
def ensemble_predict(models, input_volume): # models: {'axial2d': model_a, 'coronal2d': model_b, 'sagittal2d': model_c, 'unet3d': model_d} prob_sum = np.zeros_like(input_volume[..., 0], dtype=np.float32) for name, model in models.items(): if name == 'unet3d': pred = model.predict_3d(input_volume) weight = 0.4 else: pred = model.predict_2d(input_volume) # 内部按各自轴向切片推理 weight = 0.2 prob_sum += weight * pred final_mask = (prob_sum >= 0.5).astype(np.uint8) return final_mask一个集成技巧:投票阈值可以从默认的 0.5 上调到 0.6 或 0.65,让最终掩膜更保守,减少假阳性。BraTS 的评估指标中 Dice 和 HD95 的平衡点通常出现在阈值略高于 0.5 的位置,具体多少要根据验证集调,不要默认 0.5 就交。
6.3 验证生存预测模型时最容易忽略的点
用 Cox 模型时有几个事情必须小心。第一,Cox 模型是比例风险假设的,如果样本量小、事件数少,建议用交叉验证评估判别指数而不是直接看训练集的 p 值。第二,生存预测要防止数据泄漏:从分割结果提取特征时,训练集和验证集的特征统计量(比如体积的均值和标准差)不能混用,否则验证集性能虚高。第三,SVM 分类模型的输入要先做标准化,Cox 不需要但 SVM 很敏感。
我自己跑这个项目时最大的教训是:一上来就急着调网络结构,忽略了对数据预处理和标签映射的检查。后来每次换新数据集、换模型分支,我都强制走一遍检查清单:打印标签取值分布、验证推理时的切片方向一致、固定随机种子、确认生存模型的特征文件没有混入验证集样本。这四步走完,项目复现基本就不会出大问题。希望这篇拆解能帮你少走我这些弯路。
本文还有配套的精品资源,点击获取