
简介本资源面向医学影像处理方向的深度学习学习者与研究人员提供一套基于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 常见层厚 15 mm轴位像素间距 0.61.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(keylambda s: float(s.ImagePositionPatient[2])) # 体数据堆叠成 (Z, H, W) volume np.stack([s.pixel_array.astype(np.float32) for s in slices], axis0) # 用 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要按你数据集的标注协议填比如椎体是 17、椎管是 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_size8, fg_ratio0.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, replacelen(has_fg) n_fg).tolist() idx np.random.choice(no_fg, batch_size - n_fg, replacelen(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-NetFCN 的核心思想是用卷积替代全连接让网络输出空间热图。经典 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_channels512, num_classes2): super().__init__() # 1x1 卷积把骨干特征压到类别数 self.score nn.Conv2d(in_channels, num_classes, kernel_size1) # 8 倍上采样回到输入分辨率 self.upsample nn.Upsample(scale_factor8, modebilinear, align_cornersFalse) def forward(self, x): x self.score(x) x self.upsample(x) return x class FCNResNet34(nn.Module): def __init__(self, num_classes2, pretrainedTrue): super().__init__() backbone models.resnet34(pretrainedpretrained) # 去掉最后两层保留到 layer4输出 stride32 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 特征。参数说明pretrainedTrue用 ImageNet 预训练权重医学数据少的时候能明显加速收敛。num_classes2是前景背景二分类多类分割改这个值即可。注意上采样倍数和骨干 stride 必须匹配。stride 32 配 8 倍上采样输出会比输入小 4 倍训练时 loss 形状对不上会直接报错。稳妥做法是用nn.functional.interpolate指定sizeinput.shape[-2:]。3.3 损失函数Dice 与 BCE 怎么配脊椎分割正负不平衡单用 BCE 会被背景主导。常见组合是 BCE 加 DiceBCE 管像素级稳定梯度Dice 直接优化重叠度。class DiceBCELoss(nn.Module): def __init__(self, bce_weight0.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, dim1)[:, 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 单卡 816。训练轮数看数据量几百例数据一般 50100 epoch早停看验证集 Dice。4. 推理、后处理与评估让分割结果能用4.1 滑窗推理处理大体积全腹 CT 重采样后体数据很大整卷塞进显存不现实。常见做法是沿 Z 轴滑窗每次推理若干层窗口间留重叠最后拼接。def sliding_window_inference(model, volume, window16, overlap4, devicecuda): model.eval() depth volume.shape[0] pred np.zeros((depth, volume.shape[1], volume.shape[2]), dtypenp.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)), modeedge) tensor torch.from_numpy(chunk).unsqueeze(1).to(device).float() out torch.softmax(model(tensor), dim1)[:, 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_size500): 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 对内部填充敏感对边界不敏感。脊椎分割关心边界贴合度要加 HD9595% 豪斯多夫距离。指标含义脊椎分割参考值Dice重叠度010.90 以上可用IoU交并比0.85 以上HD9595% 边界最大距离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 网络窗口深度控制在 1632 层。混合精度训练AMP能省 30% 显存PyTorch 里用torch.cuda.amp几行就能开。6. 把 FCN 脊椎分割推到可用的几个进阶技巧模型跑通只是起点要让分割结果真的能进流程还得在几个地方下功夫。第一个是测试时增强TTA对输入做水平翻转、小角度旋转推理后把多份概率平均Dice 通常能涨 12 个点。代价是推理时间翻几倍适合离线批处理不适合实时场景。def tta_inference(model, image, devicecuda): model.eval() probs [] with torch.no_grad(): # 原始 probs.append(torch.softmax(model(image), dim1)[:, 1]) # 水平翻转 flipped torch.flip(image, dims[-1]) out torch.softmax(model(flipped), dim1)[:, 1] probs.append(torch.flip(out, dims[-1])) return torch.stack(probs).mean(dim0)逻辑说明翻转后的输出要翻回来再平均否则空间对不上。可以继续加垂直翻转和 90 度旋转但收益递减。参数说明TTA 只建议在最终评估或离线生成结果时用训练时不用。第二个是难例挖掘。训练几轮后把验证集里 Dice 低的样本挑出来看看是哪个椎体、哪个层面分不好。常见难例是胸腰交界处和骶椎这些地方形态变化大。针对难例可以过采样或者单独微调几轮。第三个是后处理里的椎体编号。如果下游任务需要区分 L1、L2 这些具体椎体二分类分割不够要在分割基础上做实例分离或直接上多类分割。多类分割把num_classes改成椎体数加背景损失函数换成多类 Dice 或 CE评估时按类算 Dice。技巧收益成本TTADice 12推理时间 ×3难例过采样难例 Dice 35训练不稳定风险多类分割支持椎体编号标注要求高混合精度显存 -30%几乎无我自己做这类项目习惯是先把二分类跑通、Dice 稳定在 0.90 以上再考虑加多类和编号。一上来就搞多类标注里椎体边界不清的地方会直接把模型带偏回头查错比重做还费时间。数据预处理那一步千万别省我见过太多人模型换了好几个最后发现是 HU 没转、标签没对齐。希望帮到你。本文还有配套的精品资源点击获取