简介:本资源面向医学影像处理方向的深度学习学习者与研究人员,提供一套基于FCN全卷积神经网络的腹部脊椎自动分割完整方案,可用于医学图像分割入门实践与算法复现。压缩包共981个文件,约453.83MB,以jpg与png图像数据为主,另含py训练脚本、pth模型权重、xml标注文件及readme说明,覆盖数据、代码与结果全流程。在epoch 10时,全局准确率达0.9953,平均IoU为0.8486,脊椎类召回率0.9703,指标表现稳定可靠。已有86人学习下载。读者可直接获得可运行的数据集、网络实现与训练权重,对照指标复现分割效果,并在此基础上调整网络结构或迁移至其他医学图像分割任务,适合作为课程设计、科研入门与算法对比的参考素材。
1. 腹部脊椎分割为什么值得用 FCN 从头做一遍
腹部 CT 的脊椎分割,是影像科和手术导航里绕不开的一环。椎体、椎管、棘突这些结构在轴位上挨得极近,软组织对比度又低,手工勾画一层要几十秒,一套全腹序列几百层,医生根本耗不起。FCN(全卷积神经网络)把分类网络的全连接层换成卷积层,输入任意尺寸图像、输出同分辨率的分割掩膜,正好对上「逐像素判定是不是脊椎」这个需求。相比 U-Net 那类编码解码结构,FCN 更朴素,参数量小、训练快,适合拿来把语义分割的完整链路——数据准备、标签处理、损失选择、指标评估——从头跑通一遍。这篇面向想自己复现腹部脊椎分割的工程师和医工方向学生,从数据集组织讲到推理后处理,把能抄的代码和会翻车的地方都摆出来。
2. 数据集准备与标签处理:从 DICOM 到可训练掩膜
2.1 腹部脊椎分割数据集长什么样
公开的腹部脊椎分割数据,常见形态是腹部 CT 体数据配一份逐层标注。标注有两种粒度:一种是只标椎体(vertebrae),另一种把椎体、椎管、椎间盘分开标。做 FCN 二分类分割时,通常把所有脊椎相关结构合并成一个前景类,背景是其余所有组织。这样标签就是 0/1 掩膜,训练目标简单,评估也直观。
数据到手后先别急着写模型,花十分钟把数据摸清楚:层厚是多少、像素间距是否各向同性、标注是每层都有还是隔层标、有没有缺失层。腹部 CT 常见层厚 1~5 mm,轴位像素间距 0.6~1.0 mm,各向异性明显。如果直接按原始分辨率训练,脊椎在 Z 方向会被拉长或压扁,影响分割边界。常见做法是重采样到各向同性,比如统一到 1×1×1 mm,代价是显存占用上升,需要配合裁剪。
提示:先确认标注坐标系和图像坐标系一致。有些数据集标注存的是世界坐标,直接当像素索引用会整体偏移,这是最隐蔽的坑之一。
2.2 用 Python 读取 DICOM 并生成掩膜
下面这段代码做三件事:读一个序列的 DICOM、按层厚排序、把标注转成和图像同形状的 0/1 掩膜。依赖 pydicom、numpy、SimpleITK。
import pydicom import numpy as np import SimpleITK as sitk from pathlib import Path def load_series(series_dir): # 读取目录下所有 DICOM,按 InstanceNumber 或 SliceLocation 排序 slices = [pydicom.dcmread(str(p)) for p in Path(series_dir).glob("*.dcm")] slices.sort(key=lambda s: float(s.ImagePositionPatient[2])) # 体数据堆叠成 (Z, H, W) volume = np.stack([s.pixel_array.astype(np.float32) for s in slices], axis=0) # 用 RescaleSlope/Intercept 转成 HU slope = float(slices[0].RescaleSlope) intercept = float(slices[0].RescaleIntercept) volume = volume * slope + intercept spacing = (float(slices[0].SliceThickness), float(slices[0].PixelSpacing[0]), float(slices[0].PixelSpacing[1])) return volume, spacing def build_mask(label_volume, vertebra_ids): # label_volume 是整型标注体,vertebra_ids 是脊椎类别的 id 列表 mask = np.isin(label_volume, vertebra_ids).astype(np.uint8) return mask逻辑说明:load_series里排序键用ImagePositionPatient[2]而不是文件名,因为文件名顺序不可靠。HU 转换必须做,否则不同机器的像素值范围不一致,归一化会失效。build_mask用np.isin一次性把多个椎体类别合并,比循环快很多。
参数说明:vertebra_ids要按你数据集的标注协议填,比如椎体是 1~7、椎管是 8,那就传list(range(1,9))。spacing三个值分别是 Z、Y、X 方向,后面重采样要用。
2.3 重采样与窗宽窗位归一化
腹部 CT 的 HU 范围很宽,骨头能到 1000 以上,空气是 -1000。脊椎分割关心骨性结构,常见做法是截断到 [-200, 1000] 再归一化到 [0,1],这样既保留骨皮质又压掉大部分软组织噪声。
def preprocess(volume, spacing, target_spacing=(1.0, 1.0, 1.0)): # 用 SimpleITK 做各向同性重采样 img = sitk.GetImageFromArray(volume) img.SetSpacing(spacing[::-1]) # SITK 顺序是 X,Y,Z resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing[::-1]) new_size = [int(round(sz * sp / tsp)) for sz, sp, tsp in zip(img.GetSize(), img.GetSpacing(), target_spacing[::-1])] resampler.SetSize(new_size) resampler.SetInterpolator(sitk.sitkLinear) out = sitk.GetArrayFromImage(resampler.Execute(img)) # 窗宽窗位截断 + 归一化 out = np.clip(out, -200, 1000) out = (out + 200) / 1200.0 return out.astype(np.float32)逻辑说明:重采样用线性插值,标签要用最近邻,两者必须分开处理,否则标签会被插出 0.5 这种非法值。窗宽窗位截断放在重采样之后,避免插值把边界值拉出范围。
参数说明:target_spacing设 (1,1,1) 是通用起点。如果显存吃紧,Z 方向可以放宽到 2 mm,但脊椎上下端边界会变糊,评估时注意区分。
2.4 切片采样与类别不平衡处理
腹部 CT 里脊椎像素占比通常不到 5%,正负样本极度不平衡。直接训练模型会倾向于全预测背景,Dice 看着还行但实际什么都没分出来。常见做法是切片采样时保证每批里含脊椎的切片占一定比例,比如 70% 含前景、30% 随机背景切片。
def sample_slices(mask_volume, batch_size=8, fg_ratio=0.7): # 统计每层是否含前景 has_fg = [i for i in range(mask_volume.shape[0]) if mask_volume[i].sum() > 0] no_fg = [i for i in range(mask_volume.shape[0]) if mask_volume[i].sum() == 0] n_fg = int(batch_size * fg_ratio) idx = np.random.choice(has_fg, n_fg, replace=len(has_fg) < n_fg).tolist() idx += np.random.choice(no_fg, batch_size - n_fg, replace=len(no_fg) < batch_size - n_fg).tolist() np.random.shuffle(idx) return idx逻辑说明:replace参数处理前景切片不足的情况,避免np.random.choice报错。采样只决定取哪些层,真正的裁剪和增强在 Dataset 里做。
参数说明:fg_ratio从 0.5 到 0.8 都有人用,0.7 是折中。如果验证集 Dice 上不去,先调这个再看损失函数。
3. FCN 网络结构与训练配置:把分割头搭对
3.1 为什么选 FCN 而不是直接上 U-Net
FCN 的核心思想是用卷积替代全连接,让网络输出空间热图。经典 FCN-8s 用 VGG16 做骨干,把 pool4 和 pool5 的特征上采样后融合,兼顾语义和位置。放到腹部脊椎分割上,脊椎是大块连续结构,不需要 U-Net 那种密集跳连来抠细小边界,FCN 的粗融合够用,而且结构简单、调试成本低。
选骨干时,VGG16 参数量大、显存吃紧,实际项目里更常用 ResNet34 或 MobileNetV2 做编码器。MobileNetV2 的深度可分离卷积把参数量压到几百万,适合单卡 8G 显存跑 3D 或 2.5D。这里给一个基于 ResNet34 的 FCN 分割头,输入 2D 切片,输出同尺寸 logits。
3.2 FCN 分割头的 PyTorch 实现
import torch import torch.nn as nn import torchvision.models as models class FCNHead(nn.Module): def __init__(self, in_channels=512, num_classes=2): super().__init__() # 1x1 卷积把骨干特征压到类别数 self.score = nn.Conv2d(in_channels, num_classes, kernel_size=1) # 8 倍上采样回到输入分辨率 self.upsample = nn.Upsample(scale_factor=8, mode='bilinear', align_corners=False) def forward(self, x): x = self.score(x) x = self.upsample(x) return x class FCNResNet34(nn.Module): def __init__(self, num_classes=2, pretrained=True): super().__init__() backbone = models.resnet34(pretrained=pretrained) # 去掉最后两层,保留到 layer4,输出 stride=32 self.stem = nn.Sequential(*list(backbone.children())[:-2]) self.head = FCNHead(512, num_classes) def forward(self, x): feat = self.stem(x) # (B, 512, H/32, W/32) out = self.head(feat) # (B, 2, H, W) return out逻辑说明:list(backbone.children())[:-2]去掉 avgpool 和 fc,保留卷积部分。ResNet34 的 layer4 输出 stride 是 32,所以上采样倍数设 8 只能回到 H/4,这里为了代码简洁用 8 倍示意,实际要对齐输入尺寸需要按input_size // feat_size动态算,或者改成 FCN-16s 融合 layer3 特征。
参数说明:pretrained=True用 ImageNet 预训练权重,医学数据少的时候能明显加速收敛。num_classes=2是前景背景二分类,多类分割改这个值即可。
注意:上采样倍数和骨干 stride 必须匹配。stride 32 配 8 倍上采样,输出会比输入小 4 倍,训练时 loss 形状对不上会直接报错。稳妥做法是用
nn.functional.interpolate指定size=input.shape[-2:]。
3.3 损失函数:Dice 与 BCE 怎么配
脊椎分割正负不平衡,单用 BCE 会被背景主导。常见组合是 BCE 加 Dice,BCE 管像素级稳定梯度,Dice 直接优化重叠度。
class DiceBCELoss(nn.Module): def __init__(self, bce_weight=0.5): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.bce_weight = bce_weight def forward(self, logits, targets): # logits: (B, 2, H, W) targets: (B, H, W) 取值 0/1 bce_loss = self.bce(logits[:, 1], targets.float()) probs = torch.softmax(logits, dim=1)[:, 1] intersection = (probs * targets).sum() dice_loss = 1 - (2 * intersection + 1e-6) / (probs.sum() + targets.sum() + 1e-6) return self.bce_weight * bce_loss + (1 - self.bce_weight) * dice_loss逻辑说明:BCE 只取前景通道的 logits,避免背景通道干扰。Dice 用 softmax 后的概率算,加 1e-6 防止除零。两项加权求和,权重可调。
参数说明:bce_weight从 0.3 到 0.7 都试过,0.5 是起点。如果验证集 Dice 波动大,把 Dice 权重调高到 0.7 试试。
3.4 训练循环与关键超参
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for imgs, masks in loader: imgs = imgs.to(device).float() masks = masks.to(device).long() optimizer.zero_grad() logits = model(imgs) loss = criterion(logits, masks) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader)逻辑说明:标准训练循环,注意masks转 long 给 BCE 用,如果损失里直接算 Dice 要转 float。logits不经过 sigmoid,因为 BCEWithLogitsLoss 内部做了。
参数说明:优化器用 Adam,学习率 1e-4 起步,weight decay 1e-5。batch size 单卡 8~16。训练轮数看数据量,几百例数据一般 50~100 epoch,早停看验证集 Dice。
4. 推理、后处理与评估:让分割结果能用
4.1 滑窗推理处理大体积
全腹 CT 重采样后体数据很大,整卷塞进显存不现实。常见做法是沿 Z 轴滑窗,每次推理若干层,窗口间留重叠,最后拼接。
def sliding_window_inference(model, volume, window=16, overlap=4, device='cuda'): model.eval() depth = volume.shape[0] pred = np.zeros((depth, volume.shape[1], volume.shape[2]), dtype=np.float32) count = np.zeros_like(pred) step = window - overlap with torch.no_grad(): for start in range(0, depth, step): end = min(start + window, depth) chunk = volume[start:end] if chunk.shape[0] < window: pad = window - chunk.shape[0] chunk = np.pad(chunk, ((0, pad), (0, 0), (0, 0)), mode='edge') tensor = torch.from_numpy(chunk).unsqueeze(1).to(device).float() out = torch.softmax(model(tensor), dim=1)[:, 1].cpu().numpy() out = out[:end - start] pred[start:end] += out count[start:end] += 1 return pred / np.maximum(count, 1)逻辑说明:重叠区域累加后除以计数,得到平均概率,减少窗口边界伪影。末尾不足一个窗口时用 edge 填充,推理后裁掉。
参数说明:window和overlap要按显存调,16 层窗口配 4 层重叠是常见配置。重叠太小边界会有接缝,太大推理变慢。
4.2 连通域后处理去小碎块
FCN 输出常有孤立小碎块,尤其在肠道气体附近。用连通域分析去掉小于阈值的块,能明显提升视觉效果。
from scipy import ndimage def remove_small_components(mask, min_size=500): labeled, num = ndimage.label(mask) for i in range(1, num + 1): if (labeled == i).sum() < min_size: mask[labeled == i] = 0 return mask逻辑说明:ndimage.label做 3D 连通域标记,遍历每个块判断大小。min_size按体素算,重采样到 1mm 后 500 体素约等于 0.5 立方厘米。
参数说明:min_size设太小去不掉碎块,设太大会把椎体边缘削掉。建议在验证集上试 200、500、1000 三档。
4.3 评估指标:Dice、IoU 和 HD95
分割评估不能只看 Dice。Dice 对内部填充敏感,对边界不敏感。脊椎分割关心边界贴合度,要加 HD95(95% 豪斯多夫距离)。
| 指标 | 含义 | 脊椎分割参考值 |
|---|---|---|
| Dice | 重叠度,0~1 | 0.90 以上可用 |
| IoU | 交并比 | 0.85 以上 |
| HD95 | 95% 边界最大距离 | 3 mm 以内 |
| Precision | 查准率 | 0.90 以上 |
| Recall | 查全率 | 0.90 以上 |
def dice_score(pred, target): intersection = (pred * target).sum() return (2 * intersection + 1e-6) / (pred.sum() + target.sum() + 1e-6)逻辑说明:Dice 计算简单,注意加平滑项。HD95 用 medpy 或 SimpleITK 的 HausdorffDistanceImageFilter 算,需要先转成二值。
参数说明:评估时 pred 要二值化,阈值一般取 0.5。阈值调到 0.4 或 0.6 会改变 Precision/Recall 平衡,按临床需求定。
5. 避坑与排查:腹部脊椎分割的 5 个血泪教训
5.1 现象:训练 loss 下降但 Dice 一直是 0
原因:标签和图像没对齐,或者标签值不是 0/1 而是 1/2。模型学到的前景和评估时的前景定义不一致。
解决:训练前打印np.unique(mask)确认标签值。如果是 1/2,先减 1 或重新映射。再检查图像和标签的 shape 是否完全一致,重采样时两者必须用同一套 spacing 和 size。
5.2 现象:验证集 Dice 高但推理结果全是背景
原因:验证集采样时前景切片比例过高,模型在验证集上表现好,但实际推理时背景切片占多数,模型倾向预测背景。
解决:验证集要按真实分布采样,不能只挑含前景的层。评估时分别统计含前景层和背景层的指标,背景层全预测背景是正常的,含前景层才是关键。
5.3 现象:不同机器上推理结果差异大
原因:预处理时 HU 截断范围不一致,或者归一化用了各自的均值和方差。
解决:把预处理参数固定下来,截断范围、归一化系数写进配置文件,推理时严格按训练时的参数来。不要用推理数据的统计量做归一化。
5.4 现象:脊椎上下端边界分割模糊
原因:Z 方向层厚大,重采样到各向同性后插值模糊,或者滑窗推理时窗口边界正好落在椎体上。
解决:Z 方向重采样尽量到 1mm,如果原始层厚 5mm,插值后边界会糊,评估时对上下端单独看。滑窗重叠加大到窗口的 1/3,减少边界伪影。
5.5 现象:显存溢出,batch size 降到 1 还报错
原因:3D 体数据直接输入,或者 2D 切片分辨率太高没裁剪。
解决:训练时随机裁剪到 256×256 或 512×512,不要整层输入。如果用 3D 网络,窗口深度控制在 16~32 层。混合精度训练(AMP)能省 30% 显存,PyTorch 里用torch.cuda.amp几行就能开。
6. 把 FCN 脊椎分割推到可用的几个进阶技巧
模型跑通只是起点,要让分割结果真的能进流程,还得在几个地方下功夫。第一个是测试时增强(TTA):对输入做水平翻转、小角度旋转,推理后把多份概率平均,Dice 通常能涨 1~2 个点。代价是推理时间翻几倍,适合离线批处理,不适合实时场景。
def tta_inference(model, image, device='cuda'): model.eval() probs = [] with torch.no_grad(): # 原始 probs.append(torch.softmax(model(image), dim=1)[:, 1]) # 水平翻转 flipped = torch.flip(image, dims=[-1]) out = torch.softmax(model(flipped), dim=1)[:, 1] probs.append(torch.flip(out, dims=[-1])) return torch.stack(probs).mean(dim=0)逻辑说明:翻转后的输出要翻回来再平均,否则空间对不上。可以继续加垂直翻转和 90 度旋转,但收益递减。
参数说明:TTA 只建议在最终评估或离线生成结果时用,训练时不用。
第二个是难例挖掘。训练几轮后,把验证集里 Dice 低的样本挑出来,看看是哪个椎体、哪个层面分不好。常见难例是胸腰交界处和骶椎,这些地方形态变化大。针对难例可以过采样,或者单独微调几轮。
第三个是后处理里的椎体编号。如果下游任务需要区分 L1、L2 这些具体椎体,二分类分割不够,要在分割基础上做实例分离或直接上多类分割。多类分割把num_classes改成椎体数加背景,损失函数换成多类 Dice 或 CE,评估时按类算 Dice。
| 技巧 | 收益 | 成本 |
|---|---|---|
| TTA | Dice +1~2 | 推理时间 ×3 |
| 难例过采样 | 难例 Dice +3~5 | 训练不稳定风险 |
| 多类分割 | 支持椎体编号 | 标注要求高 |
| 混合精度 | 显存 -30% | 几乎无 |
我自己做这类项目,习惯是先把二分类跑通、Dice 稳定在 0.90 以上,再考虑加多类和编号。一上来就搞多类,标注里椎体边界不清的地方会直接把模型带偏,回头查错比重做还费时间。数据预处理那一步千万别省,我见过太多人模型换了好几个,最后发现是 HU 没转、标签没对齐。希望帮到你。
本文还有配套的精品资源,点击获取