
简介这是一套面向医学图像分割学习者的FCN腹部多脏器五分割完整实战资源。项目基于ResNet50/ResNet101作为特征提取网络配合余弦退火学习率、交叉熵损失与收敛更快的Adam优化器在仅测试20个epoch时全局像素准确度即达0.99、mIoU达0.80若加大训练轮次性能还有进一步提升空间。压缩包共1025个文件、约456.22MB其中991张PNG图像覆盖数据集与可视化结果7个Python脚本承担训练与推理流程3个pth权重文件可直接加载另有README、日志与工程配置文件等目录结构清晰小白也能快速上手。目前已有221人学习下载。资源内包含完整训练与预测代码、预处理后的数据集及训练日志日志中可查看各类别的iou、recall、precision和全局准确率等指标将待推理图像放入inference目录后直接运行predict.py即可输出结果无需额外调参适合具备一定深度学习基础、希望快速复现FCN分割任务的读者。1. 用 FCN 网络做腹部多脏器 5 分割一个模型同时分清四种器官腹部多脏器分割和单器官分割完全是两码事FCN 要在同一张 CT 上同时把肝脏、脾脏、左右肾或胃、胰腺取决于标签定义逐像素分开。器官之间密度接近、边界粘连模型稍有不慎就把两个器官糊成一片。全卷积网络FCN是最早把分类网络端到端变成像素级分类的语义分割模型用全卷积化加转置卷积恢复分辨率天然适合这种“整图上下文决定边界”的任务。这篇实战文章面向准备用 FCN 跑腹部多脏器 5 分割、却被 NIfTI 预处理和训练细节卡住的工程师从数据整理、模型搭建到常见坑完整走一遍。2. 从 NIfTI 到模型输入腹部 CT 数据整理与预处理细节2.1 FCN 的分割原理以及 5 分类任务的定义FCN 语义分割的完整流程可以拆成两件事把 VGG 这类分类网络里最后几个全连接层替换成卷积层让网络接受任意尺寸的图像并输出二维空间上的类别得分再用转置卷积也叫反卷积把 stride 不断减小的特征图一步步放大回输入分辨率。经典版本按上采样融合深度分成 FCN-32s、FCN-16s、FCN-8s其中 FCN-8s 同时融合了第三、四、五个池化层的输出浅层细节和深层语义都照顾到了在器官边界这种灰度渐变区域表现比 32s 稳得多。这里说的“5 分割”常规定义是背景加上四种目标器官。比如 Synapse / BTCV 这类公开腹部 CT 多器官数据集原始标签覆盖脾脏、右肾、左肾、胆囊、肝脏、胃、主动脉、胰腺共 8 类你需要把其中 4 个器官映射成类别 14其余器官以及所有非目标组织一律归为背景 0。选哪四个器官取决于临床目的常见做法是选体积最大、最容易出现边界粘连的肝、脾、双肾想提高难度就再加一个胃或胰腺。类别编号顺序直接影响后续混淆矩阵的解读建议在代码里用显式映射表写清楚不要靠记忆。2.2 读取 NIfTI 与标签合并保持空间对齐是底线腹部 CT 公开数据集大多是 NIfTI 格式一个 volume 是一个三维数组加一个 affine 矩阵。第一步是把 CT 和分割标签成对读进来做强度归一化、尺寸缩放和标签合并。最容易翻车的地方是CT 和 seg 的 voxel spacing 不一致时直接 resize 会让两个空间错位训练出的模型边界会整体偏移一块。常见做法是先读两个文件的 header算出目标 spacing再用同一套 zoom 因子同时作用于 CT 和标签标签部分必须用最近邻插值不能引入新的灰度值。import nibabel as nib import numpy as np from scipy.ndimage import zoom # 标签合并规则Synapse/BTCV 的 8 器官映射到 4 类 背景 LABEL_MAP {1: 1, 2: 2, 3: 3, 4: 4} # 这里把原始标签 1/2/3/4 分别映射成类别 1/2/3/4 # 用不到的 5/6/7/8 以及 0 都保留在背景类里 def load_and_resample(ct_path, seg_path, target_spacing(1.0, 1.0, 1.0)): ct_img nib.load(ct_path) seg_img nib.load(seg_path) ct ct_img.get_fdata().astype(np.float32) seg seg_img.get_fdata().astype(np.int16) # 1) 强度归一化窗宽窗位把非目标灰度压掉 hu_min, hu_max -200, 200 # 腹部实质器官常用窗位 40、窗宽 400 ct np.clip(ct, hu_min, hu_max) ct (ct - hu_min) / (hu_max - hu_min) # 2) 重采样按 spacing 比例计算 zoom 因子 ct_spacing ct_img.header.get_zooms()[:3] seg_spacing seg_img.header.get_zooms()[:3] ct_zoom [orig / target for orig, target in zip(ct_spacing, target_spacing)] seg_zoom [orig / target for orig, target in zip(seg_spacing, target_spacing)] ct zoom(ct, zoomct_zoom, order3) # 图像用三阶样条 seg zoom(seg, zoomseg_zoom, order0) # 标签必须最近邻 # 3) 标签合并 merged np.zeros_like(seg) for src, dst in LABEL_MAP.items(): merged[seg src] dst return ct, merged逻辑说明代码先读双模态文件用 clip 把 Hounsfield 值限制在 -200200 HU这是腹部实质器官对比度较明显的常用范围然后按两个文件各自的实际 spacing 计算 zoom 因子统一到 1mm 各向同性采样。图像用三阶样条会带来亚像素平滑标签用零阶最近邻避免插值产生类别 2.7 这种非法值。最后把 8 类标签合并成 4 类目标其余值归零。参数说明窗宽窗位不是死的分割胃和肠道时空气多视野要放宽到 -300300只做肝脾时 -150250 更聚焦。重采样目标建议 1.0mm 或 1.5mm太细显存撑不住太粗小器官比如胰腺尾部直接丢像素。zoom 之后 CT 和 seg 可能出现 1 像素尺寸差稳妥做法是用切片对齐两数组形状。注意zoom的order0是最近邻order3是三阶样条。标签绝不能跟着图像一起用三阶否则类别值会被插成小数交叉熵计算直接报错。2.3 按病人划分训练集而不是按切片随机分另一个反复踩的点是数据划分。CT 是三维 volume切片之间高度相关如果直接用随机 shuffle 把二维切片分开同一病人的相邻切片会同时进训练集和验证集模型等于提前“见过”验证图像Dice 虚高 5 到 10 个点。正确做法是先按病人编号分组把一个病人的所有层整体分进训练/验证/测试。腹部数据集中一个病人动辄上百层按病人划分后验证集通常还有几百张图足够评估。增强这里我一般只做轻度随机水平翻转、旋转 ±10 度、亮度扰动。腹部器官解剖位置固定上下翻转会把肝脏翻到左边违背生理结构模型反而学偏。弹性形变对医学图像有用但早期不建议用FCN 上它会破坏连续边界。增强操作必须在同一个随机状态下同时作用于 CT 和标签否则增强完标签就对不上。import numpy as np from scipy.ndimage import rotate def augment(img_slice, seg_slice, angle_range10): # img_slice: (1, H, W) 单通道图像seg_slice: (H, W) 标签 seed np.random.randint(0, 2**32) rng np.random.default_rng(seed) if rng.random() 0.5: img_slice np.flip(img_slice, axis2) # 水平翻转 seg_slice np.flip(seg_slice, axis1) angle rng.uniform(-angle_range, angle_range) img_slice rotate(img_slice, angle, axes(2, 1), reshapeFalse, order1) seg_slice rotate(seg_slice, angle, axes(1, 0), reshapeFalse, order0) return img_slice.copy(), seg_slice.copy()逻辑说明同一个seed生成的随机数同时决定翻转和旋转图像和标签永远不会错位。图像旋转用一阶线性插值标签用零阶最近邻原因和重采样相同。reshapeFalse保证旋转后尺寸不变不会产生裁剪对齐问题。参数说明angle_range10对腹部 CT 够用超过 15 度会把器官形态拉得太畸形。如果你觉得手写增强麻烦直接用 albumentations 的RandomHorizontalFlip和Rotate同时传入image和mask两个参数效果一样。2.4 三维体积切成二维切片后的 Dataset 封装FCN 的输入是单张二维图像CT 是几十上百层的三维体积所以训练管线里必须有一个“切层”的动作。最常见做法是沿轴状位axial脚往头方向看逐层切把每一层 CT 和对应标签当成一个样本。注意要保留病人 ID 和层号否则后期做三维后处理时无法把概率图拼回完整体积。from torch.utils.data import Dataset class AbdominalDataset(Dataset): def __init__(self, case_list, transformNone): self.items [] for case_id in case_list: ct, seg load_and_resample( fdata/ct/{case_id}.nii.gz, fdata/seg/{case_id}.nii.gz ) for z in range(ct.shape[2]): self.items.append((case_id, z, ct[:, :, z], seg[:, :, z])) self.transform transform def __len__(self): return len(self.items) def __getitem__(self, idx): _, z, ct_slice, seg_slice self.items[idx] ct_slice ct_slice[None, ...].astype(np.float32) # (1, H, W) seg_slice seg_slice.astype(np.int64) # (H, W) if self.transform is not None: ct_slice, seg_slice self.transform(ct_slice, seg_slice) return ct_slice, seg_slice逻辑说明构造函数里提前把每个病人的每层切好放入内存适合单机小数据量数据量大时应该改成__init__只存路径__getitem__再去nibabel读取。seg_slice的 dtype 必须是int64PyTorch 的交叉熵不接受 float 类型的标签索引这个细节让不少人训练时报错。3. 搭一个 FCN-8sVGG16 主干、转置卷积与损失函数3.1 为什么选 VGG16 做主干而不是白手起家或直接上 ResNetFCN 的原始实现基于 VGG16后续很多工作换成 ResNet 主干能拿到更高精度。但对腹部多脏器 5 分割这个任务我的建议是先把 VGG16 版本跑通。原因有三一是 VGG16 的 ImageNet 预训练权重迁移到医学灰度图上前几层提取的低级边缘纹理特征依然可靠二是 VGG16 结构规整各 stage 边界清晰做跳跃连接时不容易数错层数三是显存占用相对可控256 输入、batch 8 在单张 11G 显卡上能跑。ResNet 的残差结构理论上更好但 FCN 的框架本身就是“先学会怎么上采样”主干换得越晚调试成本越低。FCN-32s 直接对第五个池化层的输出做 32 倍上采样边界非常糊FCN-16s 融合了 pool4 的 2 倍上采样结果FCN-8s 再融合 pool3额外补上 4 像素级别的细节。腹部器官边界处的灰度差本来就小我直接用 FCN-8s代价只是多两条跳跃连接训练收益却很明显。3.2 模型定义PyTorch 手写 FCN-8s下面是基于 torchvision VGG16 features 构造 FCN-8s 的完整代码。注意features是一个Sequential内部每个模块的下标是固定的拆成五个 stage 时不要数错。import torch import torch.nn as nn from torchvision.models import vgg16 class FCN8s(nn.Module): def __init__(self, n_class5, pretrainedTrue): super().__init__() features list(vgg16(pretrainedpretrained).features.children()) # VGG16 features 共 31 层按 pool 位置拆成 5 段 self.stage1 nn.Sequential(*features[:5]) # conv1 到 pool1 self.stage2 nn.Sequential(*features[5:10]) # conv2 到 pool2 self.stage3 nn.Sequential(*features[10:17]) # conv3 到 pool3 self.stage4 nn.Sequential(*features[17:24]) # conv4 到 pool4 self.stage5 nn.Sequential(*features[24:31]) # conv5 到 pool5 # 用卷积替换原 FC6 / FC7保持 4096 通道 self.conv6 nn.Conv2d(512, 4096, kernel_size7, padding3) self.relu6 nn.ReLU(inplaceTrue) self.drop6 nn.Dropout2d(0.5) self.conv7 nn.Conv2d(4096, 4096, kernel_size1) self.relu7 nn.ReLU(inplaceTrue) self.drop7 nn.Dropout2d(0.5) # 三个下采样层各出一个 1x1 分类头 self.score_pool3 nn.Conv2d(256, n_class, 1) self.score_pool4 nn.Conv2d(512, n_class, 1) self.score_pool5 nn.Conv2d(4096, n_class, 1) # 两个 2 倍转置卷积 一个 8 倍转置卷积 self.upsample2 nn.ConvTranspose2d(n_class, n_class, kernel_size4, stride2, padding1) self.upsample_pool4 nn.ConvTranspose2d(n_class, n_class, kernel_size4, stride2, padding1) self.upsample8 nn.ConvTranspose2d(n_class, n_class, kernel_size16, stride8, padding4) def forward(self, x): pool1 self.stage1(x) # H/2 pool2 self.stage2(pool1) # H/4 pool3 self.stage3(pool2) # H/8 pool4 self.stage4(pool3) # H/16 pool5 self.stage5(pool4) # H/32 v self.relu6(self.conv6(pool5)) v self.drop6(v) v self.relu7(self.conv7(v)) v self.drop7(v) s5 self.score_pool5(v) # H/32, n_class s5 self.upsample2(s5) # H/16, n_class s4 self.score_pool4(pool4) # H/16, n_class # 对齐最后一像素 s5 s5 s4[:, :, :s5.size(2), :s5.size(3)] s4 self.upsample_pool4(s5) # H/8, n_class s3 self.score_pool3(pool3) # H/8, n_class s4 s4 s3[:, :, :s4.size(2), :s4.size(3)] out self.upsample8(s4) # H, n_class return out逻辑说明前向里所有尺寸按输入 256 假定如果输入不是 256 的整数倍最后一两个像素会出现 1 像素差切片对齐是通用保险手段。stage 拆分严格对应 VGG16 features 的 31 层想换 ResNet 时把 features 换成 resnet50 的 layer1layer4再在 score head 上做对应通道数替换。参数说明conv6的kernel_size7保留了原 FC6 的大感受野padding3保证特征图尺寸不变三个 score head 都是 1x1 卷积本质是在每个空间位置对特征做类别打分。upsample8的kernel_size16与stride8配合输出尺寸满足out (in - 1) * stride kernel - 2 * padding。输入改成 512 时 kernel 和 padding 不用改显存会翻 4 倍。3.3 损失函数交叉熵极易偏向大器官腹部多脏器分割最典型的现象是“肝脏分割很好胃和胰腺几乎为零”。原因是背景、肝脏、脾脏占了训练样本里绝大多数像素普通交叉熵会把梯度集中在大类别上。我通常用 Dice Loss 和交叉熵的组合并对小器官类别额外加权重这个思路在 fcn 语义分割模型里同样适用。import torch.nn.functional as F class DiceCEWithWeights(nn.Module): def __init__(self, n_class5, class_weightsNone, smooth1.0): super().__init__() if class_weights is None: class_weights torch.tensor([0.1, 1.0, 1.0, 1.5, 1.5]) self.class_weights class_weights self.smooth smooth self.n_class n_class def forward(self, pred, target): # pred: (B, C, H, W) logitstarget: (B, H, W) long prob F.softmax(pred, dim1) target_onehot F.one_hot(target, self.n_class).permute(0, 3, 1, 2).float() inter (prob * target_onehot).sum(dim(2, 3)) union prob.sum(dim(2, 3)) target_onehot.sum(dim(2, 3)) dice (2.0 * inter self.smooth) / (union self.smooth) # 按类别权重累加背景 0 给最小权重 dice_loss (self.class_weights.to(pred.device) * (1 - dice)).mean() ce F.cross_entropy(pred, target, weightself.class_weights.to(pred.device)) return ce dice_loss逻辑说明one-hot 在通道维展开Dice 逐类计算背景权重给 0.1避免背景主导肝脏、脾脏给 1.0胃和胰腺给 1.5。交叉熵同时用 weight 加权。这个设计在训练初期能明显改善小器官的召回率。参数说明class_weights要根据实际标签里各类像素占比重新统计通用做法是统计占比后取倒数再归一化。smooth1.0是平滑项防止分母为零。target不能是 one-hot 后的 float交叉熵只接受类索引的 long 张量。训练循环部分和优化器选择核心就三行optimizer torch.optim.SGD(model.parameters(), lr0.001, momentum0.9, weight_decay1e-4) criterion DiceCEWithWeights(n_class5) model FCN8s(n_class5).cuda()SGD比 Adam 更容易稳定收敛到平坦最优点配合 poly 学习率衰减lr * (1 - iter/total)^0.9用 60 到 100 个 epoch。如果发现训练震荡先把lr降到 3e-4别的先别动。4. 训练、调参与排查从显存爆炸到标签错位的四个翻车现场4.1 训练参数速查表下面这组默认参数在单张 11G 显存上能稳定跑通参数取值说明输入尺寸256x256保留腹部实质器官轮廓的最小尺寸batch size8小于 4 时 BN 不稳定优化器SGD, momentum0.9比 Adam 更容易收敛初始学习率1e-3配 poly 衰减训练轮数60~100验证集 Dice 不再上升即停数据增强水平翻转 / 旋转±10° / 亮度±20%禁止上下翻转损失权重0.1 / 1.0 / 1.0 / 1.5 / 1.5按各类像素比例调整训练验证划分按病人严禁按切片随机 shuffle监控指标建议每 epoch 打印每个类别的 Dice而不是只看平均 Dice。平均 Dice 会被肝脾拉高小器官翻车时根本看不出来。训练节奏上前 10 个 epoch 总 loss 应明显下降Dice 从 0.2 附近爬到 0.7 左右如果 loss 降而 Dice 不动先检查标签是不是大部分都是背景。4.2 显存不足batch 调到 2 仍然爆显存现象输入 512、batch 2加载模型后直接报 CUDA out of memory。原因VGG16 的conv6是 4096 通道的 7x7 卷积特征图通道数大显存峰值出现在上采样前的分类头附近。不是模型写错是容量设计超过了显存。解决先把输入降到 256batch 设为 8还不够就用梯度累积每 4 个 batch 更新一次参数模拟 batch 32。最后才是换 ResNet 主干降低通道数。torch.cuda.empty_cache()释放不了显存中被 PyTorch 缓存分配器占用的块换参数后重启训练脚本通常更有效。4.3 验证集 Dice 虚高换新病人就掉点现象按切片随机划分训练 10 个 epoch 验证集肝脏 Dice 0.97换一个新病人测试掉到 0.85。原因同一病人的相邻切片被同时分进训练集和验证集相邻层几乎就是复制粘贴模型提前看到了验证分布。这是腹部数据集最容易出现的“虚假高分”比过拟合更难发现。解决在 2.3 节已经强调过按case_id整体划分。把划分代码放在预处理最前面打印每个集合的病人 ID 确认没有重叠。我第一版训练时验证集“完美”后来按病人重划分直接掉了 5 个点那以后我再也不敢随机切了。4.4 小器官 Dice 接近 0胃和胰腺全是噪点现象背景、肝脏、脾脏 Dice 正常胃和胰腺的预测结果全是散落噪点。原因小器官像素占比可能只有 1%普通交叉熵的梯度被大器官和背景淹没模型根本不敢预测小类别。解决换成 3.3 节的 DiceCE 加权损失batch size 调大让每个 batch 尽量包含多个含小器官的切片训练前统计每类像素占比如果胃和胰腺确实太低对小器官切片做 3 倍过采样比单纯调权重更直接。4.5 标签错位所有预测边界整体外扩现象预测边界比真实标签向外扩一圈每个器官都一样。原因CT 和 seg 的zoom因子不一致或者重采样后没有重新对齐尺寸。训练初期如果不做可视化这类系统性误差很难发现。解决检查load_and_resample里两个文件是否用了同一组 zoom 因子在每个 epoch 开始前随机抽 3 张图把 CT 和标签叠加可视化。看不到这一步后面的调参全是玄学。可视化可以简单用plt.imshow(ct_slice, cmapgray)再叠一条标签轮廓。4.6 上采样出现棋盘格纹理现象输出概率图有规则的棋盘格状纹理叠加在器官边界上。原因转置卷积的 kernel 重叠不均匀stride 和 kernel 大小搭配不当尤其是最后 8 倍上采样时放大倍数大棋盘格更明显。解决把最后的upsample8替换成nn.Upsample(scale_factor8, modebilinear, align_cornersTrue)前两个 2 倍上采样保留转置卷积。棋盘格对 Dice 影响不大但会干扰后期三维重建和临床展示。5. 评估指标、CRF 后处理与迁移到自己的数据5.1 用每类 Dice 和 mIoU 代替“整体准确率”像素准确率在这种严重类别不平衡的任务里没有任何参考价值背景占 90% 的时候一直预测背景也有 90% 的准确率。我习惯每类分别算 Dice再算排除背景后的 mIoUdef compute_dice(pred, target, n_class5): dice_per_class [] for c in range(1, n_class): p (pred c) t (target c) inter (p t).sum() union p.sum() t.sum() dice_per_class.append((2.0 * inter 1e-6) / (union 1e-6)) return dice_per_class def compute_miou(pred, target, n_class5): ious [] for c in range(1, n_class): p (pred c) t (target c) inter (p t).sum() union (p | t).sum() ious.append(inter / (union 1e-6)) return np.mean(ious)逻辑说明背景类不参与计算因为它占比太高会稀释指标。评估时用测试集所有切片的预测结果在像素级累计再计算每类 Dice而不是对每张图算完再取平均后者会被切片数量平衡掉权重。5.2 CRF 后处理在腹部 CT 上收益有限早期语义分割教程普遍推荐 DenseCRF 做后处理但我在腹部 CT 上的经验是收益很小甚至翻车。腹部器官边界不是颜色突变而是灰度渐变CRF 的依据是“相邻像素强度接近则标签一致”它会把本来就是渐变的器官边界“糊”得更平。如果预测图有破碎噪声先试 3x3 或 5x5 中值滤波无效再考虑 CRF做 CRF 时 pairwise 项权重从 3 起步别一上来就调到 10。5.3 迁移到新数据集的最小改动路径换一个腹部数据集时改动的顺序应该是标签映射、窗宽窗位、重采样 spacing、类别权重统计backbone 预训练权重可以保留。数据量少时先冻结 stage1stage3 只训练 score head 和上采样层验证集 Dice 开始上升后再解冻全部层学习率同步降到 1e-4。一个有用的习惯是把每个病人的预处理结果CT 和标签叠加导出一批 PNG 存到debug/目录换数据集时先看这几十张图再动手训练。评估时把每个 case 的每层 Dice 画成曲线能直观看出模型是在哪一层、哪个器官开始坏的。这个方向值不值得投入取决于你手头数据的标注质量。FCN 8s 不是精度天花板但它是把数据管线、损失设计和评估流程扎稳的底座底座稳了后面换 U-Net、换 Transformer 都是一两天的事。我第一版训练吃了按切片随机分的亏得到一个虚假的 0.93 Dice后来按病人重划分掉到 0.87从那以后预处理每一步都必须可视化看不见的边界偏移和标签错位一定会变成训练后的事后后悔药。希望帮到你。本文还有配套的精品资源点击获取