1. 从零上手 nnUNet:为什么它值得你花时间
如果你做过医学图像分割,大概率听过 nnUNet 这个名字。它在多个国际挑战赛上拿过冠军,但真正让它出圈的,是它“开箱即用”的设计哲学——你不需要手动调参、不需要设计复杂的网络结构,只要把数据整理成它要求的格式,它就能自动给你一个相当能打的基线模型。我第一次接触 nnUNet 是在一个肝脏肿瘤分割的项目里,当时试过自己搭 U-Net、调学习率、做数据增强,折腾了两周 Dice 只有 0.78,换成 nnUNet 之后,同样的数据,默认配置跑完直接到 0.86。这个差距让我意识到,对于大多数实际项目来说,与其自己造轮子,不如先把 nnUNet 用透。
但问题也来了:nnUNet 的官方文档虽然详细,却有一个不小的门槛——它有一套非常严格的目录结构和命名规范,而且不同版本之间的命令差异很大。很多人在第一步“数据准备”就卡住了,网上的教程又大多是针对旧版本的,照着做经常报错。这篇文章就是来解决这个问题的。我会从数据格式整理开始,一步步带你走完训练、验证、推理的完整流程,重点讲清楚那些官方文档里一笔带过、但实际操作中一定会踩的坑。无论你是刚入门医学图像分割的研究生,还是需要快速出结果的工程师,这篇内容都能让你少走至少两天的弯路。
需要提前说明的是,nnUNet 目前主流版本是 v2,和早期的 v1 在命令上有不少区别。我下面所有的操作都基于 v2 版本,如果你还在用 v1,建议先升级,因为 v2 在训练速度和多 GPU 支持上优化明显。另外,虽然 nnUNet 支持 2D、3D 全分辨率、3D 低分辨率等多种配置,但本文主要围绕最常用的 3D 全分辨率模式展开,其他模式的操作逻辑是相通的,理解了核心流程之后切换配置并不难。
2. 数据整理:nnUNet 的目录规范与命名逻辑
2.1 为什么 nnUNet 对数据格式如此“苛刻”
nnUNet 的核心优势在于自动化,而自动化的前提是标准化。它需要从文件命名中自动推断出模态、标签、训练测试划分等信息,所以对目录结构和文件名有严格要求。很多人觉得这是“多此一举”,但换个角度想:如果你有 200 个病例,每个病例有 4 个模态的图像和 1 个标注,手动写 DataLoader 去匹配这些文件,光是处理命名不一致的问题就要花半天。nnUNet 用一套固定规则把这些事情全部自动化了,你只需要按规则放文件,剩下的交给它。
具体来说,nnUNet 要求的数据格式是:每个病例对应一个文件夹,文件夹内包含多个.nii.gz文件,命名规则是病例ID_模态编号.nii.gz。比如case_001_0000.nii.gz表示第一个病例的第一个模态,case_001_0001.nii.gz表示第二个模态,以此类推。标签文件的命名是病例ID.nii.gz,不带模态编号。这个设计的好处是,nnUNet 可以通过模态编号自动识别输入通道数,你不需要在配置文件里额外指定。
2.2 从原始数据到 nnUNet 格式的转换实操
假设你手头的数据是常见的 DICOM 格式,或者虽然已经是 NIfTI 但命名混乱,第一步就是做格式转换和重命名。我通常会用 Python 脚本批量处理,核心逻辑是:遍历每个病例,读取图像和标注,统一重采样到相同 spacing,然后按 nnUNet 规则重命名保存。
这里有一个关键细节:nnUNet 对图像的 orientation 有要求,推荐统一成 RAS 或 LPS。如果原始数据的 orientation 不一致,后续训练时会出现图像和标签对不上的情况,表现为损失函数不下降或者 Dice 极低。我建议在转换阶段就用nibabel的as_closest_canonical函数统一 orientation,这个操作成本很低,但能避免后面很多莫名其妙的错误。
转换完成后,你需要把数据放到 nnUNet 指定的目录下。v2 版本的默认路径是nnUNet_raw,里面每个任务对应一个文件夹,命名格式是DatasetXXX_任务名,其中 XXX 是三位数字,比如Dataset001_Liver。这个文件夹里面需要包含三个子目录:imagesTr放训练图像,imagesTs放测试图像(如果没有测试集可以留空),labelsTr放训练标签。注意,测试集如果没有标签,只放图像即可,nnUNet 推理时会自动处理。
2.3 数据集配置文件 dataset.json 的填写要点
在DatasetXXX_任务名文件夹下,还需要一个dataset.json文件,这是 nnUNet 理解你数据集的唯一入口。这个文件里最重要的字段是channel_names、labels和numTraining。channel_names是一个字典,键是模态编号(字符串形式),值是模态名称,比如{"0": "CT"}。labels也是字典,键是标签值,值是标签名称,背景通常是 0,前景从 1 开始。numTraining是训练病例的数量。
这里有一个容易出错的地方:labels里的标签值必须和你的标注文件中的像素值完全一致。我见过有人标注用的是 1 和 2,但labels里写的是 0 和 1,结果训练出来的模型把所有前景都预测成背景。另外,如果你的任务是多类别分割,labels里要列出所有类别,nnUNet 会自动处理 one-hot 编码,你不需要手动转换。
还有一个隐藏的坑:dataset.json的编码必须是 UTF-8,而且不能有 BOM 头。如果你在 Windows 上用记事本编辑,保存时可能会自动加 BOM,导致 nnUNet 读取时报 JSON 解析错误。建议用 VS Code 或 Sublime 编辑,保存时选择 UTF-8 无 BOM。
3. 环境配置与预处理:那些官方文档没细说的事
3.1 安装 nnUNet 的正确姿势与版本选择
nnUNet 的安装本身不复杂,但依赖比较多,建议用 conda 创建一个独立环境。官方推荐的是 Python 3.9 以上,PyTorch 1.12 以上。我实测下来,Python 3.10 + PyTorch 2.0 + CUDA 11.8 的组合比较稳定。安装命令很简单,pip install nnunetv2即可,但要注意,如果你之前装过 v1,一定要先卸载干净,否则两个版本的命令会冲突。
安装完成后,需要设置三个环境变量:nnUNet_raw、nnUNet_preprocessed和nnUNet_results。这三个路径分别对应原始数据、预处理后数据和训练结果。我建议把它们设在同一个大盘下,因为预处理后的数据和训练权重可能会占用几十 GB 的空间。设置方法是在.bashrc或.zshrc里加一行export nnUNet_raw="/path/to/nnUNet_raw",然后source一下。Windows 用户可以在系统环境变量里设置,或者用set命令临时设置。
3.2 预处理命令的执行与常见报错
数据放好之后,第一步是运行nnUNetv2_plan_and_preprocess命令。这个命令会做两件事:一是分析数据集的属性(比如图像尺寸、spacing、强度分布),生成规划文件;二是执行预处理,包括重采样、归一化、裁剪等。命令格式是nnUNetv2_plan_and_preprocess -d 数据集ID -planner nnUNetPlanner -c 3d_fullres。其中-d后面跟数据集编号,比如 001;-c指定配置,常用的有2d、3d_fullres、3d_lowres。
这一步最常见的报错是“找不到数据集”或“dataset.json 格式错误”。如果报找不到数据集,先检查nnUNet_raw环境变量是否设置正确,以及数据集文件夹命名是否符合DatasetXXX_名称的格式。如果报 JSON 格式错误,用python -m json.tool dataset.json检查一下文件是否合法。另一个常见问题是内存不足,预处理阶段会把所有数据加载到内存里做分析,如果数据集很大(比如超过 500 个病例),可能会爆内存。这时候可以用-np参数指定并行进程数,减少内存占用。
预处理完成后,你会在nnUNet_preprocessed目录下看到对应的数据集文件夹,里面包含nnUNetPlans.json和预处理后的数据文件。这个nnUNetPlans.json非常重要,它记录了 nnUNet 自动分析出的网络结构、patch size、batch size 等参数。如果你后续想手动调整某些参数,就是在这个文件里改。
3.3 预处理阶段的一个隐藏技巧:手动指定 spacing
nnUNet 默认会计算数据集中所有病例的 spacing 中位数,然后统一重采样到这个 spacing。这个策略在大多数情况下是合理的,但如果你的数据集里 spacing 差异很大(比如有些病例是 0.5mm,有些是 2mm),中位数可能不是最优选择。我遇到过一个案例:数据集里大部分病例是薄层 CT(0.7mm),但有几个是厚层(3mm),nnUNet 自动算出的中位数是 0.8mm,结果那几个厚层病例重采样后信息损失严重,训练时表现很差。
解决办法是在nnUNetPlans.json里手动指定spacing。具体操作是:先跑一遍预处理,然后打开生成的nnUNetPlans.json,找到configurations下的3d_fullres,里面有一个spacing字段,把它改成你想要的数值,然后重新跑预处理。注意,改完之后要删掉之前预处理生成的数据,否则 nnUNet 会跳过已存在的文件。这个技巧在数据异质性较大的场景下特别有用,能让模型对所有病例都保持较好的性能。
4. 训练与调优:从默认配置到针对性改进
4.1 启动训练:命令参数与显存优化
训练命令是nnUNetv2_train 数据集ID 配置 折数,比如nnUNetv2_train 001 3d_fullres 0表示用 3d_fullres 配置训练第 0 折。nnUNet 默认做 5 折交叉验证,所以折数从 0 到 4。如果你只想快速验证一下流程,可以只跑一折,但最终报告结果时建议跑完 5 折取平均,这样更可靠。
显存是训练阶段最大的瓶颈。3d_fullres 配置下,nnUNet 默认的 patch size 是 128x128x128,batch size 是 2,这个配置在 12GB 显存的卡上勉强能跑,但如果你用的是 8GB 的卡,大概率会 OOM。解决办法有两个:一是改用3d_lowres配置,patch size 更小,显存占用更低;二是手动修改nnUNetPlans.json里的batch_size和patch_size。我通常会把 batch size 降到 1,patch size 降到 96x96x96,这样在 8GB 卡上也能跑,虽然训练速度慢一些,但最终性能下降不明显。
另一个实用技巧是开启混合精度训练。nnUNet 默认支持 AMP,只需要在训练命令后面加--amp即可。实测下来,AMP 能减少约 30% 的显存占用,训练速度提升 20% 左右,而且对最终 Dice 几乎没有影响。这个选项在显存紧张时几乎是必开的。
4.2 训练过程中的监控与日志解读
nnUNet 的训练日志会实时打印损失值和验证集 Dice。这里要注意,训练初期损失波动是正常的,不要一看到损失上升就以为出了问题。我通常关注两个指标:一是训练损失是否整体呈下降趋势,二是验证集 Dice 是否在 50 个 epoch 后开始稳定上升。如果训练损失下降但验证 Dice 不涨,可能是过拟合了,这时候可以考虑增加数据增强或提前停止。
nnUNet 默认每 50 个 epoch 保存一次 checkpoint,保存在nnUNet_results/DatasetXXX/配置/fold_X/下。如果你训练中断了,可以用--c参数从最近的 checkpoint 继续训练,命令是nnUNetv2_train 001 3d_fullres 0 --c。这个功能在长时间训练时非常有用,避免因为意外中断而从头再来。
还有一个细节:nnUNet 默认的训练 epoch 数是 1000,但很多任务在 500 个 epoch 左右就已经收敛了。如果你时间紧张,可以在nnUNetPlans.json里把num_epochs改成 500,能节省一半时间,性能损失通常在 1% 以内。当然,如果你的数据集很小(比如少于 50 个病例),建议还是跑满 1000 个 epoch,因为小数据集需要更多轮次来充分学习。
4.3 针对特定任务的微调策略
虽然 nnUNet 的默认配置已经很强大,但在某些特定任务上,稍微调整一下能带来明显提升。比如在分割小目标(如肺结节)时,默认的 patch size 可能太大,导致小目标在 patch 中占比过低,模型学不到足够的细节。这时候可以把 patch size 改小,比如 64x64x64,同时增加 batch size 到 4 或 8,让每个 batch 里包含更多小目标样本。
另一个常见的调整是损失函数。nnUNet 默认用 Dice + CrossEntropy 的组合损失,这个组合在大多数情况下表现很好。但如果你的数据类别极度不平衡(比如背景占 99%,前景只占 1%),可以尝试把 CrossEntropy 的权重调低,或者改用 Focal Loss。具体操作是在nnUNetTrainer里重写_build_loss方法,这部分需要一些代码基础,但 nnUNet 的代码结构很清晰,照着改并不难。
还有一个容易被忽略的点:数据增强的强度。nnUNet 默认的增强包括随机旋转、缩放、弹性形变等,强度是中等偏上的。如果你的数据本身已经做了标准化处理,或者图像质量很高,可以适当降低增强强度,避免引入过多噪声。反之,如果数据质量参差不齐,可以加大增强强度,提升模型的鲁棒性。这些参数都在nnUNetPlans.json的augmentation字段里,改起来很方便。
5. 推理与后处理:让模型输出真正可用的结果
5.1 用训练好的模型做推理
训练完成后,推理命令是nnUNetv2_predict -i 输入文件夹 -o 输出文件夹 -d 数据集ID -c 配置 -f 折数。输入文件夹里放的是待推理的.nii.gz文件,命名要符合 nnUNet 的规范(即病例ID_模态编号.nii.gz)。输出文件夹里会生成对应的分割结果,命名是病例ID.nii.gz。
这里有一个非常关键的细节:推理时的模态编号必须和训练时一致。比如训练时channel_names是{"0": "CT"},那么推理时输入文件必须是病例ID_0000.nii.gz。如果你不小心把模态编号写错了,nnUNet 不会报错,但输出的分割结果会完全错误。我见过有人因为这个问题排查了一整天,最后发现只是文件名少写了一个 0。
另一个实用技巧是使用--save_probabilities参数,这样除了分割结果,还会保存每个体素的概率图。概率图在后续做后处理时非常有用,比如你可以设置一个概率阈值,只保留高置信度的区域,或者用概率图做模型集成。
5.2 后处理:nnUNet 自带的与非自带的
nnUNet 自带一个简单的后处理功能,主要是去除小连通域和填充空洞。这个后处理在推理时可以通过--disable_postprocessing关闭,默认是开启的。对于大多数任务,这个后处理能去掉一些明显的噪声,但如果你分割的是细长结构(如血管),去除小连通域可能会把正确的细长分支也去掉,这时候建议关闭后处理。
除了自带的后处理,还有一些通用的后处理技巧可以显著提升 Dice。比如最大连通域保留:对于每个类别,只保留最大的连通域,去掉其他小区域。这个操作在分割单一器官时特别有效,能去掉很多假阳性。另一个技巧是形态学闭运算,用来填充分割结果中的小孔洞。这些操作可以用scipy.ndimage或SimpleITK实现,代码量不大,但效果立竿见影。
还有一个进阶技巧:多折模型集成。nnUNet 默认训练 5 折,你可以用 5 个模型分别推理,然后把概率图平均,再取 argmax 得到最终分割。这个操作通常能提升 1-2 个百分点的 Dice,而且实现起来很简单,只需要在推理时指定-f 0 1 2 3 4,nnUNet 会自动做集成。如果你的算力允许,强烈建议用这个方式。
5.3 评估指标计算与结果解读
推理完成后,你需要计算评估指标来验证模型性能。常用的指标有 Dice、IoU、HD95 等。nnUNet 自带一个评估脚本nnUNetv2_evaluate_folder,可以计算这些指标。命令是nnUNetv2_evaluate_folder 参考文件夹 预测文件夹 -djfile dataset.json -o 输出文件。这个脚本会输出每个病例的指标和整体平均值。
解读结果时要注意,Dice 高不一定代表分割质量好。比如在类别极度不平衡时,模型把所有体素都预测为背景,Dice 也可能有 0.9 以上。所以除了 Dice,还要看 HD95 和可视化结果。我通常会把预测结果和原始图像叠加起来看,重点检查边界区域和细小结构,这些地方最容易出问题。
还有一个容易被忽略的点:评估时的标签值必须和训练时一致。如果你训练时标签是 1 和 2,评估时参考文件的标签也必须是 1 和 2,不能是 0 和 1。这个细节在跨数据集评估时特别容易出错,建议每次评估前都检查一下标签值。
6. 实战中积累的几个关键经验
6.1 数据量不足时的应对策略
医学图像分割最常见的问题就是数据量少,几十个病例是常态。nnUNet 虽然强大,但在数据量极少时也会过拟合。我的经验是,如果病例数少于 30 个,建议用 2D 配置而不是 3D,因为 2D 的参数更少,过拟合风险更低。另外,可以开启更强的数据增强,比如把旋转角度范围从默认的 30 度扩大到 180 度,弹性形变的强度也调大一些。
还有一个技巧是使用预训练权重。nnUNet 官方提供了一些预训练模型,虽然任务不同,但底层特征提取的能力是通用的。你可以用预训练模型初始化,然后在小数据集上微调,这样通常比从头训练效果好。不过要注意,预训练模型的模态和你的数据要匹配,比如都是 CT 或都是 MRI,否则效果可能适得其反。
6.2 多模态数据融合的注意事项
如果你的数据有多个模态(比如 MRI 的 T1、T2、FLAIR),nnUNet 会自动把它们作为不同通道输入,并在网络早期做融合。这个策略在大多数情况下是有效的,但有一个前提:不同模态的图像必须已经配准好了。如果模态之间没有配准,nnUNet 不会报错,但模型学到的特征会是错乱的,性能会大打折扣。
我遇到过一个案例:T1 和 T2 图像没有配准,直接丢给 nnUNet 训练,结果 Dice 只有 0.6,后来花时间做了刚性配准,Dice 直接涨到 0.82。所以如果你的多模态数据没有配准,一定要先做配准,这个时间投入是值得的。配准工具可以用ANTs或SimpleElastix,都是免费开源的。
6.3 训练时间与硬件选择的权衡
nnUNet 的 3D 全分辨率训练非常吃硬件,一个 100 病例的数据集,在单张 3090 上跑完 5 折大约需要 3-4 天。如果你只有一张消费级显卡,建议先用 2D 配置快速验证流程,确认数据格式和预处理没问题之后,再跑 3D。另外,nnUNet 支持多 GPU 训练,如果你有多个卡,可以用CUDA_VISIBLE_DEVICES指定多张卡,训练速度能提升接近线性。
但多 GPU 训练有一个坑:nnUNet 默认用DataParallel而不是DistributedDataParallel,前者在卡数多时效率不高。如果你有 4 张以上的卡,建议手动改成 DDP,具体做法是在训练脚本里设置--num_gpus参数,nnUNet v2 已经内置了对 DDP 的支持,只需要在命令里加--num_gpus 4即可。这个改动能让 4 卡训练的加速比从 2.5 倍提升到 3.8 倍左右。
6.4 版本升级带来的兼容性问题
nnUNet 从 v1 到 v2 变化很大,最明显的是命令前缀从nnUNet_变成了nnUNetv2_,而且环境变量的名称也变了。如果你之前用 v1 训练过模型,想迁移到 v2,不能直接复用,需要重新预处理和训练。虽然麻烦,但 v2 在训练速度和显存优化上确实有优势,长期来看升级是值得的。
另外,nnUNet 的 GitHub 仓库更新很频繁,有时候新版本会引入一些不兼容的改动。我的建议是,如果你用某个版本跑通了流程,不要轻易升级,除非新版本解决了你遇到的问题。如果非要升级,先在测试环境里跑一遍完整流程,确认没问题再迁移到生产环境。这个习惯能帮你避免很多不必要的麻烦。
7. 一些零散但重要的补充
关于随机种子,nnUNet 默认不固定随机种子,这意味着每次训练的结果会有轻微波动。如果你需要可复现的结果,可以在训练命令里加--seed 42,这样每次训练的结果都是一致的。但要注意,固定种子后模型性能可能会略低于随机种子的平均值,因为随机种子本身也是一种正则化。
关于模型保存,nnUNet 默认只保存checkpoint_final.pth和checkpoint_best.pth,前者是最后一个 epoch 的权重,后者是验证集 Dice 最高的权重。实际使用时,我建议用checkpoint_best.pth做推理,因为它在验证集上表现最好。但如果你发现checkpoint_best.pth在测试集上表现不如checkpoint_final.pth,那可能是验证集和测试集分布不一致,这时候需要检查数据划分是否合理。
关于日志管理,nnUNet 的训练日志默认输出到终端,如果你用nohup或screen跑后台任务,日志会保存在nohup.out或screenlog里。我建议把日志重定向到一个单独的文件,方便后续排查问题。命令是nnUNetv2_train 001 3d_fullres 0 > train_fold0.log 2>&1 &,这样日志会保存到train_fold0.log,而且不会因为终端关闭而丢失。
最后分享一个我踩过的坑:nnUNet 在预处理阶段会生成一个plans.json文件,这个文件里记录了数据集的指纹信息。如果你后来修改了原始数据(比如增加了病例或修改了标注),必须重新跑预处理,否则 nnUNet 会用旧的指纹信息,导致训练时数据对不上。这个错误很隐蔽,因为 nnUNet 不会报错,只是训练结果莫名其妙地差。所以记住:只要原始数据有变动,就重新跑一遍nnUNetv2_plan_and_preprocess。