简介本资源面向医学影像处理方向的开发者与研究者提供一套基于FCN全卷积神经网络的腹部脊椎自动分割完整方案适合具备一定深度学习基础、希望上手医学图像分割实战的读者。包内共981个文件以jpg与png图像数据为主辅以py训练脚本、pth模型权重、xml标注文件及txt说明文档压缩包约453.83MB数据集、代码与结果一并打包开箱即可复现。作者在10个epoch下取得global correct 0.9953、mean IoU 0.8486、脊椎类召回率0.9703等指标分割区域与实际标注重叠度较高可作为性能基线参考。读者可直接获得可运行源码、已训练权重与配套数据集用于复现实验、调参改进或迁移至其他医学图像分割任务省去数据采集与预处理成本。目前已有86人学习下载。1. 腹部脊椎分割为什么值得用 FCN 做一遍腹部 CT 里把脊椎从软组织、脏器、肠气中抠出来是很多临床流程绕不开的一步术前规划要看椎体位置骨密度分析要逐节定位放疗靶区勾画要避开脊髓。手工逐层勾画一套腹部 CT 的脊椎熟手也要一两个小时层数多、椎体边界在骨窗和软组织窗下表现还不一样。用 FCN 卷积神经网络做语义分割把「每个像素是不是脊椎」变成一个端到端的预测问题是这条链路里性价比最高的方案之一。它不像目标检测那样只给框而是直接给像素级掩膜椎体形态、椎管边界都能保留下来。这篇笔记就围绕「基于 FCN 卷积神经网络实现的腹部脊椎分割」把数据集怎么准备、网络怎么搭、训练参数怎么设、结果怎么验证、坑在哪按我自己复现过的路径讲清楚。适合已经会写 PyTorch、想找一个结构清晰的分割任务练手或落地的人也适合手上有一批腹部 CT、想先跑通 baseline 再谈优化的工程师。2. 数据集准备与 FCN 输入管线的搭建2.1 腹部脊椎分割数据集长什么样、怎么划分腹部脊椎分割的数据通常来自腹部或胸腹联合 CT格式以 DICOM 序列或已转好的 NIfTI 为主。原始数据是三维体数据但 FCN 是二维语义分割网络常见做法是沿轴位axial切片把每一层当成一张灰度图标签是同一层的二值掩膜脊椎为 1背景为 0。如果原始标注是三维的需要先按层拆开保证图像和掩膜文件名一一对应。划分上我一般按「病人」而不是按「切片」来分训练/验证/测试。按切片随机分是最容易翻车的地方同一个病人的相邻层高度相似随机分会让验证集里混进训练集病人的近邻层指标虚高上线就露馅。按病人划分训练集、验证集、测试集大致 7:1.5:1.5病人之间不重叠。项目常见取值说明输入尺寸512×512 或 256×256与原始层分辨率对齐太大显存吃紧灰度窗骨窗窗宽 2000 / 窗位 400脊椎与软组织对比更明显标签二值掩膜 0/1多类任务可扩展为逐椎体标签划分单位病人避免相邻层泄漏数据增强随机旋转 ±10°、缩放 0.9~1.1、水平翻转翻转需确认解剖左右语义是否可接受窗宽窗位这一步很多人直接跳过用原始 HU 值归一化。原始 HU 范围跨度大脊椎骨在 300~1500 HU软组织在 -100~80 HU直接归一化后骨和软组织对比被压缩网络要花更多容量去区分。先做骨窗截断再归一化收敛更快。2.2 用 Dataset 和 DataLoader 把切片喂进网络下面是我常用的数据集封装核心是把图像和掩膜同步做几何变换避免图像转了掩膜没转这种低级错误。import os import numpy as np import torch from torch.utils.data import Dataset, DataLoader import cv2 class SpineSliceDataset(Dataset): def __init__(self, img_dir, mask_dir, file_list, img_size512, augmentFalse): self.img_dir img_dir self.mask_dir mask_dir self.files [l.strip() for l in open(file_list) if l.strip()] self.img_size img_size self.augment augment def __len__(self): return len(self.files) def _window(self, img, ww2000, wl400): # 骨窗截断把 HU 压到 [wl-ww/2, wlww/2] 再归一化 lo, hi wl - ww / 2, wl ww / 2 img np.clip(img, lo, hi) return (img - lo) / (hi - lo) def __getitem__(self, idx): name self.files[idx] img np.load(os.path.join(self.img_dir, name)).astype(np.float32) mask np.load(os.path.join(self.mask_dir, name)).astype(np.float32) img self._window(img) img cv2.resize(img, (self.img_size, self.img_size), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (self.img_size, self.img_size), interpolationcv2.INTER_NEAREST) if self.augment: if np.random.rand() 0.5: img, mask img[:, ::-1], mask[:, ::-1] # 水平翻转图像掩膜同步 angle np.random.uniform(-10, 10) M cv2.getRotationMatrix2D((self.img_size / 2, self.img_size / 2), angle, 1.0) img cv2.warpAffine(img, M, (self.img_size, self.img_size)) mask cv2.warpAffine(mask, M, (self.img_size, self.img_size), flagscv2.INTER_NEAREST) img torch.from_numpy(img).unsqueeze(0).float() # [1, H, W] mask torch.from_numpy(mask).unsqueeze(0).float() # [1, H, W] return img, mask train_set SpineSliceDataset(data/train/img, data/train/mask, data/train.txt, augmentTrue) train_loader DataLoader(train_set, batch_size8, shuffleTrue, num_workers4, pin_memoryTrue)逻辑说明_window做骨窗截断把 HU 映射到 0~1这是脊椎分割里比直接 min-max 更稳的预处理。几何增强里翻转和旋转都对图像和掩膜用同一套参数掩膜重采样必须用最近邻用双线性会在边界插出 0.5 这种非 0 非 1 的值训练时 BCE 会算错。参数上img_size建议先 256 跑通再上 512batch_size受显存限制8 是 8GB 显存跑 512 输入比较稳的值num_workers设成 CPU 核数的一半左右设太大反而因为 IO 争抢变慢。提示掩膜重采样一律用最近邻这是分割任务里最容易被忽略又最致命的一步。3. FCN 网络结构选型与训练参数怎么定3.1 从 FCN-8s 到轻量 backbone编码器怎么选FCN 的核心思想是把全连接层换成卷积层让网络输出和输入同尺寸的特征图从而实现逐像素分类。经典 FCN 用 VGG16 做编码器把最后三个池化层的特征分别上采样后融合得到 FCN-32s、FCN-16s、FCN-8s。跳级融合skip是 FCN 的精髓深层特征语义强但位置粗浅层特征位置准但语义弱融合后边界更贴合。实际做腹部脊椎分割VGG16 编码器参数量大、显存吃紧我一般换成 ResNet34 或 MobileNetV2 做编码器解码端保留 FCN 的跳级融合思路。脊椎在轴位切片上是大块连通区域不像血管那么细所以对边界精度要求没有血管分割那么苛刻轻量 backbone 完全够用还能把 batch_size 提上去。选型时看两点一是编码器有没有预训练权重有的话收敛快很多二是下采样总倍数FCN-8s 是 8 倍下采样后融合如果输入 512最深层特征图是 64×64再上采样回 512边界会糊所以跳级融合不能省。import torch.nn as nn import torch.nn.functional as F from torchvision.models import resnet34, ResNet34_Weights class FCNSpine(nn.Module): def __init__(self, num_classes1): super().__init__() backbone resnet34(weightsResNet34_Weights.DEFAULT) self.layer0 nn.Sequential(backbone.conv1, backbone.bn1, backbone.relu) # 1/2 self.pool backbone.maxpool self.layer1 backbone.layer1 # 1/4 self.layer2 backbone.layer2 # 1/8 self.layer3 backbone.layer3 # 1/16 self.layer4 backbone.layer4 # 1/32 self.score4 nn.Conv2d(512, num_classes, 1) self.score3 nn.Conv2d(256, num_classes, 1) self.score2 nn.Conv2d(128, num_classes, 1) def forward(self, x): x0 self.layer0(x) x1 self.layer1(self.pool(x0)) x2 self.layer2(x1) x3 self.layer3(x2) x4 self.layer4(x3) s4 self.score4(x4) s4 F.interpolate(s4, sizex3.shape[2:], modebilinear, align_cornersFalse) s3 self.score3(x3) s4 # 跳级融合 s3 F.interpolate(s3, sizex2.shape[2:], modebilinear, align_cornersFalse) s2 self.score2(x2) s3 out F.interpolate(s2, sizex.shape[2:], modebilinear, align_cornersFalse) return out逻辑说明score4/3/2是 1×1 卷积把不同深度的特征统一到类别数通道再逐级上采样相加这就是 FCN 跳级融合的工程写法。align_cornersFalse在分割里比 True 更常用避免边缘像素错位。参数上num_classes1表示二分类配合 BCE 损失如果要做多类逐椎体改成对应类别数并用交叉熵。上采样用双线性而不是转置卷积是因为转置卷积容易产生棋盘格伪影脊椎这种大块区域棋盘格会直接污染边界。3.2 损失函数、学习率和训练循环的关键参数脊椎分割有个绕不开的问题正样本脊椎像素占比远小于背景。一张 512×512 的轴位切片脊椎可能只占 5%~15%纯 BCE 会让网络倾向于全预测背景也能拿到低 loss。常见做法是 BCE 加 Dice 的混合损失Dice 直接优化重叠度对类别不平衡不敏感。import torch def dice_loss(pred, target, eps1e-6): # pred 已经过 sigmoidtarget 是 0/1 inter (pred * target).sum(dim(2, 3)) union pred.sum(dim(2, 3)) target.sum(dim(2, 3)) return 1 - ((2 * inter eps) / (union eps)).mean() def bce_dice(pred_logits, target): bce torch.nn.functional.binary_cross_entropy_with_logits(pred_logits, target) dice dice_loss(torch.sigmoid(pred_logits), target) return bce dice # 权重可调默认 1:1 model FCNSpine().cuda() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) for epoch in range(50): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() pred model(img) loss bce_dice(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()逻辑说明bce_dice把逐像素分类损失和区域重叠损失相加前者保证每个像素分类正确后者保证整体形状贴合。学习率 1e-4 配 AdamW 是分割任务的稳妥起点太大前期震荡太小收敛慢。余弦退火让学习率平滑下降比阶梯下降更少调参。参数上weight_decay1e-4抑制过拟合数据量小的时候可以提到 1e-3。训练轮数看验证集 Dice 是否还在涨一般 30~50 轮就够涨不动就停。注意Dice 计算前一定要先 sigmoid把 logits 转成概率直接拿 logits 算 Dice 数值会乱。4. 训练过程排查与结果验证的避坑清单4.1 脊椎分割训练里最常见的 5 个坑现象一训练 loss 一直降验证 Dice 卡在 0.6 上不去。原因多半是数据泄漏或验证集分布和训练集差太多。先检查划分是不是按病人做的再确认验证集的窗宽窗位和训练集一致。解决重新按病人划分预处理参数统一写进配置文件训练和验证走同一套_window。现象二预测掩膜整体偏移或翻转。原因是增强时图像和掩膜用了不同参数或者水平翻转后没同步。解决所有几何变换必须图像掩膜成对处理翻转、旋转、缩放都用一个随机数控制掩膜重采样用最近邻。现象三边界出现一圈灰色过渡带Dice 上不去。原因是掩膜重采样用了双线性插出 0~1 之间的值。解决掩膜 resize 和 warpAffine 全部改INTER_NEAREST标签只允许 0 和 1。现象四显存溢出batch_size 只能设 1。原因是输入尺寸 512 加 VGG 编码器太重。解决换 ResNet34 或 MobileNetV2 编码器输入先降到 256 跑通或者用混合精度训练。from torch.cuda.amp import autocast, GradScaler scaler GradScaler() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() optimizer.zero_grad() with autocast(): pred model(img) loss bce_dice(pred, mask) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()混合精度能把显存占用降三成左右速度也快代价是要用GradScaler管理梯度缩放否则小梯度会下溢成 0。现象五单层预测很好连续层之间椎体跳变、不连续。原因是二维 FCN 逐层独立预测没有层间一致性约束。解决后处理做连通域分析去掉面积过小的孤立区域或者把相邻三层堆成多通道输入让网络看到层间上下文。4.2 用 Dice、IoU 和逐层可视化验证结果指标上 Dice 和 IoU 是分割任务的标准搭配Dice 对边界更敏感IoU 更严格。计算时按测试集整体累计再算不要每张图算完取平均小目标图的指标会拉偏整体。def evaluate(model, loader): model.eval() inter_sum, union_sum, pred_sum, gt_sum 0, 0, 0, 0 with torch.no_grad(): for img, mask in loader: img, mask img.cuda(), mask.cuda() pred (torch.sigmoid(model(img)) 0.5).float() inter_sum (pred * mask).sum().item() union_sum ((pred mask) 0).sum().item() pred_sum pred.sum().item() gt_sum mask.sum().item() dice 2 * inter_sum / (pred_sum gt_sum 1e-6) iou inter_sum / (union_sum 1e-6) return dice, iou逻辑说明阈值 0.5 是默认起点脊椎分割里可以扫 0.3~0.7 找 Dice 最高的点因为正样本少阈值偏低往往召回更好。inter_sum等按整个测试集累计避免逐图平均的偏差。除了数字一定要抽几层做可视化叠加把预测轮廓画在原图上看边界是不是贴合椎体、有没有把椎管误判成背景。数字好看但可视化一塌糊涂的情况多半是验证集和测试集分布不一致。提示指标只信测试集验证集用来选模型和调参别拿验证集数字对外说。5. 把二维 FCN 结果拼回三维脊椎的技巧二维逐层预测跑通后真正落地还要面对「怎么把一堆切片拼成一个连续的三维脊椎」。我一般先在测试集上把所有层的预测掩膜按原始层序存成 NIfTI再用连通域做层间一致性清理对每一层取最大连通域去掉面积小于阈值比如 50 像素的碎片然后沿 Z 轴检查相邻层掩膜的重叠率重叠率低于 0.3 的层标记为可疑回看原图确认是漏标还是真没脊椎。一个具体技巧是「层间投票」对第 z 层把 z-1、z、z1 三层的预测概率图平均后再二值化。脊椎在相邻层位置变化平缓平均能压掉单层的随机噪声边界也更稳。代价是层数边界第一层和最后一层没有邻居单独处理。这个操作在 numpy 里几行就能实现import numpy as np def temporal_vote(prob_volume, kernel3): # prob_volume: [Z, H, W]sigmoid 后的概率 pad kernel // 2 padded np.pad(prob_volume, ((pad, pad), (0, 0), (0, 0)), modeedge) out np.zeros_like(prob_volume) for z in range(prob_volume.shape[0]): out[z] padded[z:z kernel].mean(axis0) return (out 0.5).astype(np.uint8)逻辑说明modeedge让首尾层用自身补齐避免边界层被拉低。kernel3是经验值层厚大比如 5mm时可以不加投票层厚小1mm时相邻层高度相关投票收益明显。参数上阈值仍取 0.5如果发现投票后小椎体被抹掉把阈值降到 0.4 再试。验证三维结果时我习惯把预测和真值分别做体渲染从矢状面和冠状面看脊椎是不是连续、有没有断节。轴位 Dice 高不代表三维连续这两个视角必须都看。最后说个我自己的习惯每次跑新数据先拿 5 个病人做小规模过拟合测试让网络在这 5 个病人上把 Dice 刷到 0.95 以上确认网络、损失、预处理这条链路没问题再上全量数据。这一步能挡掉八成「训练不收敛」的玄学问题比盲目调参省时间。希望帮到你。本文还有配套的精品资源点击获取