我一直觉得,医学图像处理这个方向最容易劝退人的地方,不是网络结构看不懂,而是从论文到能跑通之间隔着一整套“默认你会”的工程细节。U-Net的论文翻来覆去就那么多,但真到自己处理MRI数据时,重采样怎么做、归一化用什么范围、mask怎么对齐、Dice Loss怎么实现、训练中期为什么会突然全预测成背景,这些问题论文里一句都不会提。这篇文章是系列的第一篇,我会用U-Net对腹部MRI的肝脏做分割,把完整流程走一遍,包括数据准备、网络搭建、训练调参、问题排查,每一步都给出可以直接抄走的方案。如果你是刚入医学图像方向的学生,或者想转算法岗但手上只有公开数据集的朋友,这篇文章应该能帮你省下一到两周的摸索时间。
1. 任务拆解:从问题到U-Net这条路
1.1 肝脏分割到底在解决什么问题
肝脏分割是腹部医学影像分析里非常基础又很关键的任务。临床医生在做肝脏体积测量、肝肿瘤评估、手术切除方案规划、放疗靶区勾画时,第一步基本都需要把肝脏区域从影像中分离出来。CT做得比较多,但MRI在软组织对比度上有明显优势,尤其在判断肝脏局灶性病变和弥漫性病变时,MRI往往更可靠。问题在于,一个腹部MRI序列可能有几十到上百张切片,医生一张张手工勾画轮廓,熟练的也要花上半小时到一小时,如果要做三维体积重建,工作量还要翻倍。所以我们需要一个自动化的模型,输入MRI序列,输出逐像素的肝脏区域标注,把医生从重复劳动里解放出来。
这个需求落到深度学习上,就是一个标准的语义分割问题。每个像素要么属于背景,要么属于肝脏,模型做的事情本质上是对整张图像做像素级分类。听起来很简单,但医疗影像有它自己的一套脾气,直接拿自然图像分割的经验往上套,后面大概率会翻车。
1.2 MRI肝脏分割的难点在哪里
第一个难点是类别极不平衡。肝脏在整张腹部切片里占的面积通常不到10%,如果模型把所有像素都预测成背景,准确率已经能到90%以上。这就是为什么很多新手代码跑完,Dice看起来还行,但把mask可视化出来发现模型基本没学到东西,因为常规的像素级损失函数被背景主导了。
第二个难点是边界模糊。MRI里肝脏和周围组织之间的对比度受序列参数影响很大,T1加权、T2加权、压脂序列出来的图像灰度分布完全不同。再加上患者呼吸运动会产生运动伪影,肝脏边缘经常出现重影或模糊带,医生手工也很难画出一条绝对精确的边界,模型学习的时候自然更困难。
第三个难点是体素分辨率不是各向同性的。MRI扫描时,平面内的像素间距可能只有1mm左右,但层厚常常是5mm甚至8mm。这意味着直接按原始切片训练2D模型时,不同层之间包含的信息密度差异很大,如果直接归一化到固定尺寸,空间位置信息会被扭曲。
第四个难点是个体差异。不同患者的体型、肝脏大小、脂肪肝程度、腹部是否有腹水,都会影响肝脏在MRI上的形态和信号强度。模型如果没有见过足够多样的样本,很容易在某个特定体型或信号分布上过拟合。
1.3 为什么是U-Net,而不是FCN或DeepLab
U-Net诞生于2015年,本来是做细胞分割的,后来几乎成了医学图像分割的事实标准。它的核心结构是编码器-解码器加跳跃连接。编码器通过卷积和池化不断缩小特征图,提取越来越抽象的语义信息;解码器通过上采样逐步恢复空间分辨率;跳跃连接把编码器各层的细节特征直接拼到解码器对应层,让模型能同时兼顾语义和细节。
很多人会问,FCN不也是编码解码结构吗,为什么效果不如U-Net。区别就在跳跃连接。FCN的解码路径主要靠深层特征图上采样,浅层细节信息大量丢失,分割出来的边缘经常很粗糙。DeepLab系列引入了空洞卷积和ASPP模块,在多尺度语义提取上很强,但结构复杂、显存占用大,在医学小数据集上反而容易过拟合。U-Net的跳跃连接相当于给解码器配了一个“细节备忘录”,每一层都知道原始图像的边缘、纹理长什么样,这对边界通常比较平滑、结构相对固定的肝脏来说非常合适。
另外,U-Net一个很大的优势是数据效率高。医学数据标注昂贵,一个数据集往往只有几十例,U-Net这种轻量级对称结构在小样本下依然能训练出可用的模型。这也是为什么很多人提到医学分割第一反应就是U-Net,而不是去套一个更大的Backbone。等后面你真做多了,会发现大多数医学影像比赛的冠军方案都是基于U-Net的变体,比如nnU-Net,本质上也是把U-Net的训练细节做到极致。
2. 数据准备:从原始MRI到可训练的样本
2.1 数据集选择与标注格式
MRI肝脏分割的公开数据集不算多,我个人最推荐的是CHAOS数据集。CHAOS是一个腹部器官分割挑战赛,里面有CT和MRI两部分,MRI部分提供了T1-DUAL和T2-SPIR两种序列,标注包含肝脏、右肾、脾脏等器官。T1-DUAL是双回波T1加权,T2-SPIR是带有脂肪抑制的T2加权,两种序列对肝脏的显示效果不一样,正好可以拿来测试模型在不同对比度下的表现。
数据格式方面,大部分医学影像数据集都会用NIfTI格式(.nii.gz)存储。NIfTI格式会把图像数组和体素间距、方向、原点这些空间信息存在同一个文件里。拿到数据第一步,一定不要急着训练,先打开看一眼。ITK-SNAP这个工具非常适合做医学影像的标注和可视化,可以同时展示三维切面和标注mask,确认图像和标注有没有错位、方向是否一致、肝脏区域大概在哪个切片范围。
划分数据集的时候有个容易踩的坑:要按患者划分训练集和验证集,而不是按切片随机划分。同一个患者的相邻切片高度相似,如果随机切分,模型相当于提前在验证集上“做过题”,验证指标会虚高,真实泛化能力却被高估。按患者划分后,每个患者的全部切片都只会出现在训练集或验证集中。
2.2 预处理流程:归一化不是随便减均值
预处理是整个流程里最影响最终效果的一步,而且不同模态的数据处理方式差别很大。CT图像有标准的亨氏单位(HU),窗宽窗位是固定的,但MRI没有这个统一标度,每个序列的灰度范围都不一样,同一台机器不同患者扫出来的信号强度也差很多。所以MRI做归一化要格外小心。
我常用的预处理流程是这样的。首先重采样,因为MR的体素通常不是各向同性,比如平面内是1.5mm×1.5mm,层厚是5mm。我会先用SimpleITK把整个三维体积重采样成1.5mm×1.5mm×1.5mm的各向同性体素,然后再沿着轴位方向切片。这样切出来的每张2D图,对应的是固定的空间厚度,模型学到的特征更有物理意义。
然后是偏置场校正。MRI扫描时由于磁场不均匀,同一组织在图像不同位置的灰度会有缓慢变化,这叫偏置场或者强度不均匀性。SimpleITK里的N4ITK偏置场校正是比较常用的处理方法,能显著提高同一组织在不同位置灰度的一致性。这一步不强制,但对MRI来说效果提升明显,建议做。
归一化上,我推荐先对每个患者的三维图像做百分位截断,比如把1%和99.5%分位之外的灰度值截断掉,然后做z-score归一化,减去截断后的均值再除以标准差。这里有个细节:统计均值和标准差的时候,最好只在非零区域统计,或者用mask圈出来的身体区域统计,否则大量黑色背景会把均值拉低,导致前景灰度被压缩得很厉害,对比度反而变差。
最后是裁剪。肝脏通常位于腹部中上部,如果直接把整张512×512的切片输入网络,大部分计算都浪费在无关背景上。可以根据训练集标注的质心位置,固定裁剪出一个以质心为中心的ROI区域,比如256×256,这样能显著降低显存占用,也让模型更容易聚焦。推理时对没标注的测试数据,可以先用一个简单的阈值法或者训练好一个粗分割器找到ROI,或者直接沿用训练集统计的平均质心位置。
2.3 数据增强:用廉价方式扩展训练集
医学图像数据集普遍很小,几十例到几百例,直接训练很容易过拟合。数据增强是缓解这个问题最有效的手段之一。对肝脏分割来说,我用得最多的是随机旋转、水平翻转、随机缩放、弹性形变、灰度扰动和添加高斯噪声。
旋转角度我一般控制在±15度内,肝脏在腹腔内的位置相对固定,大幅旋转不符合解剖结构。水平翻转要注意一点,翻转后肝脏会跑到画面左侧,脾脏跑到右侧,对单器官分割任务来说影响不大,但如果后面你要做多器官分割,翻转要慎用,因为左右脏器的相对位置关系发生了改变,会给模型引入错误的结构先验。
弹性形变是医学图像里非常好用的增强手段,可以模拟组织因为呼吸、手术导致的轻微形变。albumentations库里的ElasticTransform和GridDistortion都支持image和mask同步变换,实现起来很方便。但有一个非常关键的坑:一定要确保image和mask使用完全相同的变换参数和随机种子。很多人在做数据增强时会分别对image和mask调用随机变换,结果两张图根本对不上,训练出来的模型基本是废的。
强度扰动方面,可以做高斯噪声、高斯模糊、伽马校正,这些都能帮助模型抵抗MRI信号强度的不确定性。增强后的样本建议直接可视化出来检查一遍,看看是不是符合解剖学常识,我用过一些自动化增强管道,偶尔会把肝脏形状扭曲得离谱,这种样本喂进去只会让模型学坏。
3. 网络实现:用PyTorch从零搭建U-Net
3.1 网络结构的设计思路
U-Net的结构看起来对称工整,但我自己写的时候有几个关键决策点。第一是初始通道数怎么选。原版U-Net第一层64个通道,但医学小数据集上通常不需要这么大,从32个通道起步就足够了。通道数翻倍可以减少不少参数量和显存占用,训练速度也会明显提升。如果数据量很少,初始通道降到16也不是不行,但太低的通道会限制模型表达力,需要自己在验证集上权衡。
第二是上采样方式。解码器里可以用转置卷积,也可以用双线性插值加卷积。转置卷积虽然可学习参数更多,但在医学图像上容易产生棋盘格伪影,双线性插值加3×3卷积相对更稳定。我从经验上讲,这种分割任务里双线性插值完全够用,而且训练更稳。
第三是最后的输出方式。因为是二分类语义分割,我倾向于让网络最后一层输出单通道logits,然后配合BCEWithLogitsLoss和DiceLoss组合使用。这样网络末尾不用手动加sigmoid,数值稳定性更好。如果想用多类交叉熵,就要输出2通道并用Softmax,但二分类场景下前者更简洁。
跳跃连接是整个U-Net的核心,它化解了一个看起来矛盾的问题:下采样能扩大感受野、提取语义,但会丢失边缘细节;上采样能恢复分辨率,但如果没有跳跃连接,模型只能靠深层特征里残存的模糊信息去猜边缘。跳跃连接把浅层的细节信息直接传给了解码器,让细节和语义各司其职。
这里也要提醒一下,PyTorch里的U-Net实现要做一步feature map裁剪。因为编码器和解码器拼接时,如果输入尺寸不是2的整数次幂,池化和上采样会导致特征图尺寸不匹配。原版U-Net的做法是裁剪编码器特征图到解码器特征图的尺寸,然后再拼接。为了省事,最简单的方案是把输入图统一resize到256×256,这样经过5次池化后尺寸刚好对齐,不会有裁剪问题。
3.2 核心代码实现
下面的代码是我平时写分割任务的一个精简版U-Net,初始通道32,深度4层,输入是256×256的单通道图,输出也是单通道的logits。我尽量保持结构清晰,方便你在此基础上改。
import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels=1, out_channels=1, features=32): super().__init__() self.enc1 = DoubleConv(in_channels, features) self.enc2 = DoubleConv(features, features * 2) self.enc3 = DoubleConv(features * 2, features * 4) self.enc4 = DoubleConv(features * 4, features * 8) self.pool = nn.MaxPool2d(2) self.bottleneck = DoubleConv(features * 8, features * 16) self.up4 = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=True) self.dec4 = DoubleConv(features * 16 + features * 8, features * 8) self.up3 = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=True) self.dec3 = DoubleConv(features * 8 + features * 4, features * 4) self.up2 = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=True) self.dec2 = DoubleConv(features * 4 + features * 2, features * 2) self.up1 = nn.Upsample(scale_factor=2, mode="bilinear", align_corners=True) self.dec1 = DoubleConv(features * 2 + features, features) self.final = nn.Conv2d(features, out_channels, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) b = self.bottleneck(self.pool(e4)) d4 = torch.cat([self.up4(b), e4], dim=1) d4 = self.dec4(d4) d3 = torch.cat([self.up3(d4), e3], dim=1) d3 = self.dec3(d3) d2 = torch.cat([self.up2(d3), e2], dim=1) d2 = self.dec2(d2) d1 = torch.cat([self.up1(d2), e1], dim=1) d1 = self.dec1(d1) return self.final(d1)用的时候注意输入x要是float型、范围最好是0附近,灰度图不要直接丢原始0-255进去。输出是logits,训练用BCEWithLogitsLoss时不用加sigmoid,推理时再对logits取sigmoid,然后阈值0.5转成mask。
3.3 损失函数与评估指标的选择
训练分割网络,损失函数的选择直接影响收敛效果。用单独的BCE Loss时,如果肝脏面积占比很小,损失会被背景像素主导,模型倾向于把一切都预测成背景。用单独的Dice Loss时,虽然直接优化了区域重合度,但梯度在某些情况下不够稳定,训练容易波动。
我的默认配置是Dice Loss加BCE Loss,权重各一半。这样BCE负责像素级的稳定梯度,Dice负责解决类别不平衡问题。Dice Loss的代码实现里,有个平滑系数smooth,通常取1,这样避免分母为0,也起到一定的平滑作用。
class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = torch.sigmoid(logits) # 把batch和通道维度合并 probs = probs.contiguous().view(probs.size(0), -1) targets = targets.contiguous().view(targets.size(0), -1) intersection = (probs * targets).sum(dim=1) dice = (2.0 * intersection + self.smooth) / (probs.sum(dim=1) + targets.sum(dim=1) + self.smooth) return 1.0 - dice.mean()评估指标方面,最常用的是Dice系数和IoU。Dice系数是预测区域和真实标注区域交叠程度的度量,公式是2倍交集除以两个区域的像素总数之和;IoU是交集除以并集。两者很像,但Dice的数值一般会比IoU高几个点,所以和文献对比时一定要问清楚对方用的是哪种指标。肝脏分割中,Dice达到0.90以上就算是一个能用的基线,优秀的结果在0.94到0.96。
边界误差方面,可以加Hausdorff距离来评估预测mask表面和真实mask表面的最大偏差。某些情况下Dice很高但边界抖动严重,Hausdorff距离能把这个差距暴露出来。不过它不适合直接做损失函数,数值不稳定,一般只作为评估指标。
4. 训练全流程实录:超参数、显存管理与调参
4.1 环境配置与超参数设置
我跑这个项目的环境是Python 3.9、PyTorch 2.0、CUDA 11.8,单张RTX 3090显卡,其实24G显存对这个任务非常宽裕。如果你的显卡只有8G显存,也能跑,只要把batch size降到4,或者用混合精度,甚至初始通道改成16。下面是我常用的超参数表格,照抄基本不会出大问题。
| 超参数 | 推荐值 | 备注 |
|---|---|---|
| 输入尺寸 | 256×256 | 需要兼顾显存和分辨率 |
| Batch Size | 8 | 8G显存可降到4 |
| 优化器 | AdamW | 权重衰减设1e-4 |
| 初始学习率 | 1e-4 | 太高容易Dice崩坏 |
| 学习率调度 | CosineAnnealingLR | T_max设为epoch数 |
| Epochs | 150 | 配合早停策略 |
| 早停Patience | 25 | 看验证Dice |
| 损失函数 | 0.5BCE + 0.5Dice | 类别不平衡时的默认解 |
| 混合精度 | torch.cuda.amp | 显存减半,速度提升 |
优化器上我习惯用AdamW而不是SGD。SGD配合好的学习率调度在分割任务上也能有不错效果,但AdamW对学习率的敏感度低,参数更容易调到稳定状态,对新手更友好。初始学习率1e-4是一个很安全的起点,我用3e-4发生过loss在早期震荡的情况,降回1e-4之后两三个epoch就稳了。
混合精度训练在PyTorch里实现非常简单,用torch.cuda.amp的autocast和GradScaler包一下训练循环就行。不仅能省显存,在30系和40系显卡上训练速度还能提升20%到30%。不过要注意,如果用了BatchNorm,混合精度下偶尔会有精度损失,但分割任务里实测影响很小。
训练时建议用余弦退火调度器让学习率平滑降到接近0,比固定学习率跑到底收敛得更充分。每次epoch结束都计算验证集上的Dice,如果连续25个epoch没有提升,就提前终止训练并恢复之前保存的最佳模型权重。这套流程我复现过很多次,既稳定又省时间。
4.2 训练监控与可视化
训练不是只看loss曲线就完事,医学分割尤其要经常把预测结果可视化出来看。我一般每个epoch结束都会在验证集上挑几个代表性切片,把原图、标注mask、预测mask叠在一起,存成一张对比图。肉眼看一次比看一百行日志都管用,能很快发现模型是不是把脾脏当成了肝脏、边界是不是偏移了、有没有漏掉小病灶。
TensorBoard和wandb都用过,小项目用TensorBoard就够。记录的内容包括训练loss、验证loss、验证Dice、当前学习率,以及每轮的预测可视化。保存模型时,我会把checkpoint做成字典保存model_state_dict、optimizer_state_dict、epoch、best_dice四个字段,这样后续想从某个epoch恢复训练很方便。
另一个容易被忽略的细节是model.train()和model.eval()的切换。因为网络里有BatchNorm和Dropout,两种模式下行为不一样。训练时忘了切到eval,验证指标会偏低而且有随机性;推理时忘了切到eval,模型输出的结果可能不稳定。很多人发现同样权重推理结果时好时坏,基本都是这个原因。
推理的时候用torch.no_grad()包裹,可以省掉梯度计算的显存和耗时。如果显存吃紧,可以一次性只做一个batch,或者对单张图做预测。对切片逐个预测完,再按原来的切片位置拼回三维体积,就得到了整个肝脏的三维分割结果。
4.3 调参实录与翻车记录
这个项目里我踩过不少坑,挑两个最有代表性的说。
第一个翻车是第一次训练结束时,训练Dice到了0.95,验证Dice也有0.92,看起来一切正常,但我把验证集预测结果在ITK-SNAP里打开一看,发现模型把脾脏的一部分也圈了出来。原因是CHAOS数据集的T2-SPIR序列里,脾脏和肝脏有时信号强度比较接近,而我当时只用了最简单的BCE Loss,模型在像素级上没有学到足够强的边界约束。后来把损失改成BCE加Dice的组合,又给Dice部分做了一点边界加权,用更大的权重惩罚边界区域的预测错误,误分割明显减少。
第二个翻车是换了一个测试序列做推理时,模型输出几乎全黑,所有像素都预测成背景。排查了很久才发现不是模型问题,而是预处理流程中归一化用的是训练集全体数据的均值和标准差,但测试序列因为扫描参数不同,灰度分布和训练集差了很多,截断位置也不一样,导致输入分布完全偏离训练时的分布。这个问题在MRI上特别常见,因为MRI没有CT那种标准化的灰度标度。我的解法是在推理阶段测试时也做一次中位数对齐,先把测试图像的中位数灰度调整到与训练集一致,再做归一化,效果立刻恢复。这也提醒我,在做数据划分时一定要把不同序列来源的数据从根上分离,不能稀里糊涂混在一起训练。
5. 高频问题排查与效率提升技巧
5.1 常见失败模式速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 模型把所有像素预测成背景 | 类别不平衡;学习率过高;mask与image错位 | 可视化一个batch的GT和预测;检查Dice曲线 | 换成BCE+Dice组合损失;调低学习率;检查标签对齐 |
| 模型把所有像素预测成前景 | 输出没有正确加sigmoid;损失函数设置错误 | 查看模型输出数值范围 | 确认使用BCEWithLogitsLoss;推理时加sigmoid |
| 训练Dice高,验证Dice明显低 | 过拟合 | 对比训练/验证Dice差距 | 增加数据增强;加入weight decay;早停 |
| 验证集出现大量小块假阳性 | 增强时image/mask不同步;阈值偏低 | 可视化增强结果;检查预测mask连通域 | 修复增强管道;后处理取最大连通域 |
| 小肝脏或边界模糊区域漏检 | 下采样次数过多丢失细节;loss对边界不敏感 | 看预测可视化,确认漏检位置 | 减少下采样次数;边界加权Dice;增加浅层通道数 |
| 同一个模型换序列后效果崩坏 | 不同序列灰度分布差异大;归一化方式不统一 | 对比训练/测试序列灰度直方图 | 推理时按中位数对齐;做domain adaptation或按序列单独训练 |
| 显存不足训练中断 | 模型大;batch size过大 | 观察显存使用情况 | 降batch size;用混合精度;减小输入尺寸 |
5.2 三个让我少走弯路的实践细节
第一个细节是拿到数据千万不要急着写网络。先用ITK-SNAP把每例影像和标注都翻一遍,确认方向、位置、覆盖范围没有问题。我见过有人因为标注文件命名错位,训练了好几天才发现模型学的根本是别的器官的mask。数据可视化永远是第一优先级,这一遍花的时间后面都会省回来。
第二个细节是训练前先做一个过拟合小实验。拿一个batch的样本,只迭代几十步,看模型能不能把训练Dice拉到接近1。如果连一个batch都拟合不了,说明代码实现有bug,需要先把代码调通再跑完整训练。这个习惯帮我节省了大量排错时间。大多数时候,问题出在编码器和解码器的拼接维度对不上,或者损失函数的target类型不是float。
第三个细节是后处理里最常用也最有效的一招是保留最大连通域。肝脏是连续器官,正常的预测mask应该是一个完整的大区域,而那些零散的小块基本都是假阳性。我通常在推理阶段按三维体积处理,先对每个预测mask取二值,然后做三维连通域分析,只保留体积最大的连通域,再用形态学闭运算把内部的小孔填掉。这个简单操作通常能让Dice再涨1到2个点,而且几乎不需要额外调参。
5.3 最小可复现检查清单
如果你今天就要开始复现U-Net做MRI肝脏分割,我建议按这个顺序检查:
- image和mask的shape、方向是否一致,用一个切片可视化确认对得上;
- 预处理时做重采样、归一化、裁剪,保存成npy或nii缓存,避免每次训练都重复处理;
- 数据增强时,image和mask用同一个albumentations管道,传不同的seed;
- 网络输出为单通道logits,输入输出尺寸匹配;
- 训练用BCE+Dice组合损失,评估用Dice和IoU;
- 优化器用AdamW,初始学习率1e-4,配合余弦退火和早停;
- 每个epoch结束后在验证集上保存最佳模型,并保存预测可视化;
- 推理时用torch.no_grad(),对logits做sigmoid,阈值0.5,后处理取最大连通域。
这套流程跑通了,你的U-Net肝脏分割任务基本就到可用的水平了。后续如果想进一步优化,可以考虑把2D切片改成3D体数据输入,或者在U-Net里引入残差连接和注意力机制,再往后就是nnU-Net那一套自动配置的框架了。
我个人在实际操作中的体会是,U-Net这个结构本身没什么秘密,真正决定结果上下限的,是数据准备和训练策略的细节。Dice从0.87到0.94,很多时候不是网络变强了,而是预处理做扎实了、数据增强对齐了、loss选对了。医学图像处理和自然图像处理最大的不同就在这里,数据里的坑比模型里的坑多得多。系列下一篇我打算把3D分割和nnU-Net的复现写一写,如果你已经在2D版本上跑通了,那个迁移过程会顺畅很多。