胰腺病变分割数据集实战:从预处理到模型训练与评估

胰腺病变分割数据集实战:从预处理到模型训练与评估 简介面向医学图像分割任务这份胰腺病变图像分割数据集提供了完整的训练集与测试集适合计算机视觉初学者及医学影像研究者用于病灶区域分割模型的训练与验证。数据集包含2个类别即背景与病变区域共531张PNG格式图像及对应掩膜外加1个Python可视化脚本与1个说明文本整体压缩包约22.58MB共533个文件结构清晰便于直接读取。目前已有604人学习使用。用户拿到数据后可基于脚本随机抽取样本一次性对比原始图、GT掩膜及叠加蒙版效果快速检查标注质量训练集约210对图像、测试集约50对图像规模适中可用于验证U-Net等常用分割网络。配套的医学图像分割网络专栏还可进一步了解模型搭建与调参思路适合在课题入门或论文复现阶段参考使用。1. 胰腺病变分割数据集先搞清数据长什么样再谈训练医学图像分割里光有模型架构远远不够。做过几个项目后你会发现模型效果的天花板往往在数据准备阶段就已经定死了。这篇要聊的胰腺病变图像分割数据集包含训练集、测试集和对应的标签文件属于典型的“拿来即用型”数据集。但即拿即用不意味着不做检查DICOM 转 NIfTI 的坐标系差异、标签类别定义含糊、训练测试分布漂移这些都是实际动手时一定会遇到的坎。这篇文章会从数据组织方式讲起接着给出预处理、增强、模型训练和评估的完整闭环最后针对胰腺这种“小目标背景占比极高”的分割场景给出几个能直接落地的调参与后处理技巧。无论是用 nnU-Net、3D UNet 还是 SAM 类模型这套流程都能复用。2. 拆解胰腺病变分割数据集目录结构、标签定义与读取方式拿到数据第一件事不是开训练而是把磁盘上的文件结构、标签语义、图像方向搞清楚。胰腺分割数据集的常见组织方式有两种一种是纯 2D 切片集每个病例一个文件夹内部按切片序号排另一种是 3D 体数据每个病例一个 NIfTI 或 MHA 文件。两种结构对应的读取代码完全不同踩坑点也不一样。2.1 从目录结构反推数据格式一个典型的胰腺分割数据集目录长这样pancreas_dataset/ ├── imagesTr/ # 训练集原图 │ ├── case_001_0000.nii.gz │ ├── case_002_0000.nii.gz │ └── ... ├── labelsTr/ # 训练集标签 │ ├── case_001.nii.gz │ └── ... ├── imagesTs/ # 测试集原图 │ └── ... └── dataset.json # 元数据说明这种命名风格参考了 nnU-Net 的 Task 目录规范imagesTr存放训练图像labelsTr存放对应标签imagesTs是测试图像。文件名的_0000后缀表示模态编号多模态数据会依次递增为_0000、_0001。dataset.json 里通常记录模态类型、标签类别数和训练测试病例 ID。读取时用 SimpleITK 是最稳的做法import SimpleITK as sitk def load_medical_image(path): image sitk.ReadImage(path) data sitk.GetArrayFromImage(image) spacing image.GetSpacing() direction image.GetDirection() origin image.GetOrigin() return data, spacing, direction, origin image_data, spacing, direction, origin load_medical_image(imagesTr/case_001_0000.nii.gz) label_data, _, _, _ load_medical_image(labelsTr/case_001.nii.gz) print(Image shape:, image_data.shape, Spacing:, spacing) print(Label shape:, label_data.shape, Unique values:, np.unique(label_data))注意 SimpleITK 读出来的数组顺序是(z, y, x)对应 NIfTI 的(i, j, k)但与 numpy 常规的(height, width, channels)习惯不一致。spacing 的返回顺序也是(x, y, z)和数组维度正好相反。如果你直接把 spacing 套到 numpy 数组的对应轴上去做重采样方向就反了。我一般会写成spacing[2], spacing[1], spacing[0]来对应数组的三个轴避免后续重采样或裁剪时出错。2.2 标签值分布决定了损失函数怎么设计打印np.unique(label_data)你会看到类似[0, 1, 2]的结果。胰腺病变分割数据集的常见标签定义是0 表示背景1 表示胰腺实质2 表示病变区域如囊腺瘤、导管腺癌或神经内分泌肿瘤。也有的数据集只区分背景与肿瘤标签就变成[0, 1]或者细分成更多类别。理解标签定义直接决定你的分割头设计和损失函数选择标签值含义对训练的影响0背景占比极高需要处理类别不平衡1胰腺组织中等占比形态复杂边缘模糊2病变区域常为极小目标甚至只有几百个像素多类分割时损失函数常见的做法是对每个类别的 Dice 分别计算后取平均或者加权求和。对于胰腺数据集我一般用weighted Dice CrossEntropy组合。权重设置来自标签统计values, counts np.unique(label_data, return_countsTrue) total counts.sum() weights {v: total / (len(values) * c) for v, c in zip(values, counts)} print(weights)这个公式是逆频率加权逻辑是类别像素占比越小权重越大。如果你的数据里有类别 2且它的体素数只有几万而背景有上千万那权重会拉开很大差距。手动设置损失权重不如直接采用generalized Dice loss它自动基于体素总数归一化对极不平衡场景更稳定。2.3 检查训练集与测试集的分布一致性有一类隐蔽问题是训练集和测试集来自不同扫描设备或重建参数导致 HU 值分布差异。哪怕同一个数据集打包发布也要做一次分布对齐检查def compute_hu_percentiles(data_list): percentiles [] for data in data_list: p5, p50, p95 np.percentile(data, [5, 50, 95]) percentiles.append((p5, p50, p95)) return np.array(percentiles) # 假设 train_imgs 和 test_imgs 是读取后的图像列表 train_pct compute_hu_percentiles(train_imgs) test_pct compute_hu_percentiles(test_imgs) print(Train P5/P50/P95:, train_pct.mean(axis0)) print(Test P5/P50/P95:, test_pct.mean(axis0))如果两边的 P5 或 P95 相差超过 100 HU说明存在明显的分布漂移推荐做量化的归一化策略而不是简单 z-score。具体做法是分别统计训练和测试的均值方差在推理时用测试集自身的统计量归一化或者在预处理阶段把两边的窗宽窗位统一到相同区间。这里有个核心细节z-score 归一化所用的均值和方差必须从训练集计算后保存下来推理时沿用同一组参数而不能在每张图上重新计算。否则模型输入分布和训练时不一致性能会打个折扣。3. 胰腺病变分割的数据预处理与数据增强从原始体素到能喂进网络数据读取确认无误之后预处理阶段的质量直接决定训练是否收敛。胰腺区域在腹部 CT 中只占很小比例直接整图输入网络会浪费大量计算在背景上且模型学不到精细边界因此需要先经过裁剪、重采样、归一化再叠加以空间变换为核心的增强策略。3.1 体素重采样统一 spacing 是关键一步CT 采集时不同扫描协议的层厚可能为 1mm、2mm、5mm平面内分辨率也可能有差异。如果不做重采样网络看到的同一解剖结构在不同病例中呈现的形态不一致影响非常大。常见做法是把所有数据统一重采样到目标 spacing例如[1.0, 1.0, 1.0]或[1.5, 1.5, 1.5]。1mm 各向同性分辨率信息量最大但显存占用也高如果处理 3D 体数据模型输出尺寸受显存限制使用[1.5, 1.5, 1.5]更实际。def resample_to_spacing(image, label, target_spacing): original_spacing image.GetSpacing() original_size image.GetSize() target_size [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(target_size) resampler.SetOutputDirection(image.GetDirection()) resampler.SetOutputOrigin(image.GetOrigin()) resampler.SetInterpolator(sitk.sitkLinear) image_resampled resampler.Execute(image) # 标签使用最近邻插值避免引入新标签值 label_resampler sitk.ResampleImageFilter() label_resampler.SetOutputSpacing(target_spacing) label_resampler.SetSize(target_size) label_resampler.SetOutputDirection(image.GetDirection()) label_resampler.SetOutputOrigin(image.GetOrigin()) label_resampler.SetInterpolator(sitk.sitkNearestNeighbor) label_resampled label_resampler.Execute(label) return image_resampled, label_resampled图像用线性插值标签必须用最近邻插值这个要点要记住。最近邻插值确保标签值仍属于{0, 1, 2}不会生成 0.7 这种无意义类别。如果用了线性插值新产生的中间值在计算损失时会出现“虚假类别”导致指标虚高或训练不稳定。3.2 HU 值裁剪与归一化窗宽窗位的实际作用CT 图像的 HU 值范围可达 -1024 到 3000 以上但胰腺组织与病变的实际对比度集中在 -100 到 240 之间。常见做法是先做窗宽窗位裁剪再进行均值为 0、方差为 1 的标准化。def normalize_ct(image, window_min-100, window_max240): image np.clip(image, window_min, window_max) image (image - window_min) / (window_max - window_min) return image def z_score_normalize(image, mean, std): return (image - mean) / std窗宽窗位的选择会影响模型关注的对比度范围。只做简单 z-score 的话背景空气的 -1024 会拉低整体均值导致胰腺区域信号被压缩。先裁剪到软组织窗再归一化模型更容易捕获胰腺实质和病灶之间的微弱差异。另外两个窗口参数可参考[-150, 50]偏向评估脂肪萎缩和低密度病变[30, 300]偏向观察增强后的血管和富血供病灶。不明确数据集覆盖范围时我通常用[-100, 240]作为起点在验证集上微调看哪个窗口对 Dice 增益最大。这个区间能覆盖胰腺 CT 的常规软组织窗。3.3 数据增强空间变换与强度变换的组合策略胰腺分割训练数据通常只有几十到几百例纯靠随机裁剪容易过拟合。数据增强的目标是让模型对扫描参数差异更鲁棒。空间变换和强度变换需要同时做只做旋转或只做亮度调整效果都会打折扣。import random from scipy.ndimage import rotate, zoom def spatial_augment(image, label, rotation_range(-15, 15), scale_range(0.85, 1.15)): angle random.uniform(*rotation_range) image rotate(image, angleangle, axes(1, 2), order1, reshapeFalse, modenearest) label rotate(label, angleangle, axes(1, 2), order0, reshapeFalse, modenearest) scale random.uniform(*scale_range) zoom_factor (1.0, scale, scale) image zoom(image, zoom_factor, order1, modenearest) label zoom(label, zoom_factor, order0, modenearest) return image, label def intensity_augment(image): if random.random() 0.5: noise np.random.normal(0, 5.0, image.shape) image image noise if random.random() 0.5: gamma random.uniform(0.9, 1.1) image np.power(np.clip(image, 0, 1), gamma) return image旋转角度控制在 15 度以内原因在于腹部CT扫描时患者体位基本固定大幅旋转会引入不符合解剖学的样本增加训练噪声。缩放范围 0.85 到 1.15 模拟不同体型患者的器官大小差异。强度增强这里加了高斯噪声与 gamma 变换用于模拟扫描剂量差异和重建算法差异。增强执行的顺序也有讲究先空间后强度。先做旋转缩放再在变换后的图像上叠加噪声才符合真实采集过程中“先扫描后加噪”的物理顺序。3D 体数据增强时我会在 z 轴保持不动只对 xy 平面做仿射变换因为层间间距经过重采样后已是固定的沿 z 轴旋转没有解剖意义。3.4 从 3D 体数据到训练样本的 Patch 提取显存限制下直接输入完整 3D 体数据通常不可行。常见做法是随机裁剪出固定大小的 patch 参与训练。以输入尺寸(128, 128, 64)为例代表在 xy 平面上取 128×128 的区域z 方向取 64 层。patch 太大显存不够太小则上下文信息不足。胰腺与周围器官的边界本身就模糊裁剪过小后模型分辨不清哪些像素属于胰腺。训练时裁剪策略会混杂一部分 patch 从胰腺区域附近随机采样另一部分从全图随机采样。单纯全图随机采样一个 batch 里大多数 patch 都是纯背景模型训练效率会明显下降。我一般将 70% 的 patch 中心点落在标签区域附近def get_patch_center(label, patch_size, foreground_ratio0.7): foreground_voxels np.argwhere(label 0) if len(foreground_voxels) 0 and random.random() foreground_ratio: center foreground_voxels[random.randint(0, len(foreground_voxels) - 1)] else: center [ random.randint(0, label.shape[i] - 1) for i in range(len(patch_size)) ] # 限制 center 在合理范围内 center [ min(max(center[i], patch_size[i] // 2), label.shape[i] - patch_size[i] // 2) for i in range(len(patch_size)) ] return center推理时则采用滑窗法或整体输入。如果显存允许直接整图过一遍、再用 softmax 输出概率图是信息损失最小的方式。patch 提取与滑窗推理的边界重叠部分用高斯权重融合这个细节对最终结果的影响在胰腺分割场景下能带来 1% 到 2% 的 Dice 提升。4. 用 nnU-Net / 3D UNet 训练胰腺分割模型参数配置与损失函数数据准备好了下一步进入训练环节。胰腺分割的常用模型是 3D UNet 及自适应框架 nnU-Net。nnU-Net 能自动推断数据预处理参数与网络结构但对胰腺这类小目标仍有必要检查它推断出的配置。4.1 nnU-Net 的数据集格式转换nnU-Net 要求数据放在特定结构里nnUNet_raw/ └── Dataset110_Pancreas/ ├── imagesTr/ │ ├── case_001_0000.nii.gz │ └── ... ├── labelsTr/ │ ├── case_001.nii.gz │ └── ... ├── imagesTs/ │ └── ... └── dataset.jsondataset.json 的内容大致是{ channel_names: { 0: CT }, labels: { background: 0, pancreas: 1, lesion: 2 }, numTraining: 80, file_ending: .nii.gz }然后运行nnUNetv2_plan_and_preprocess -d 110 --verify_dataset_integrity nnUNetv2_train 110 3d_fullres 03d_fullres是 nnU-Net 的 full resolution 3D 配置。在胰腺数据集上如果整体显存不足或训练时间太长可选用3d_lowres先跑通 pipeline。训练完成后预测nnUNetv2_predict -i nnUNet_raw/Dataset110_Pancreas/imagesTs -o output_folder -d 110 -c 3d_fullresverify_dataset_integrity这一步值得说一说。它会检查每个 case 的 spacing、direction、shape 是否匹配。许多来源不同的公开数据集的标签与图像对的 direction 矩阵不一致nnU-Net 会直接报错。如果报错信息指出方向不一致最简单的修正方式是用 SimpleITK 的SetDirection将标签方向改为与图像完全一致再重新保存。4.2 手写一个 3D UNet 的关键参数不想引入 nnU-Net 那套复杂 pipeline也可以自己实现一个 3D UNet。核心和尚浅这里给出结构定义层面的参数骨架import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch, dropout0.1): super().__init__() self.conv1 nn.Conv3d(in_ch, out_ch, kernel_size3, padding1) self.norm1 nn.InstanceNorm3d(out_ch) self.conv2 nn.Conv3d(out_ch, out_ch, kernel_size3, padding1) self.norm2 nn.InstanceNorm3d(out_ch) self.dropout nn.Dropout3d(dropout) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.norm1(self.conv1(x))) x self.dropout(x) x self.relu(self.norm2(self.conv2(x))) return x class UNet3D(nn.Module): def __init__(self, in_channels1, num_classes3, base_channels32): super().__init__() self.enc1 ConvBlock(in_channels, base_channels) self.enc2 ConvBlock(base_channels, base_channels * 2) self.enc3 ConvBlock(base_channels * 2, base_channels * 4) self.enc4 ConvBlock(base_channels * 4, base_channels * 8) self.pool nn.MaxPool3d(kernel_size2, stride2) self.up4 nn.ConvTranspose3d(base_channels * 8, base_channels * 4, kernel_size2, stride2) self.dec4 ConvBlock(base_channels * 8, base_channels * 4) self.up3 nn.ConvTranspose3d(base_channels * 4, base_channels * 2, kernel_size2, stride2) self.dec3 ConvBlock(base_channels * 4, base_channels * 2) self.up2 nn.ConvTranspose3d(base_channels * 2, base_channels, kernel_size2, stride2) self.dec2 ConvBlock(base_channels * 2, base_channels) self.out nn.Conv3d(base_channels, num_classes, kernel_size1) 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)) d4 self.dec4(torch.cat([self.up4(e4), e3], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e2], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e1], dim1)) return self.out(d2)关键参数的具体含义base_channels32是首层卷积输出通道数之后每层翻倍显存不足时调成 16。InstanceNorm3d比BatchNorm3d在小 batch size 下稳定这是医学图像分割通用经验。3D 医学图像通常单样本 batch 训练BatchNorm 统计量不稳定。Dropout3d这里的值 0.1 是低配参考若过拟合严重可增至 0.3。4.3 损失函数Dice 与交叉熵的组合胰腺分割的损失函数设计重点在于胰腺与病变区域的类别不平衡。推荐使用 Dice Loss 和 CrossEntropy 的混合class DiceLoss(nn.Module): def __init__(self, n_classes, smooth1e-5): super().__init__() self.n_classes n_classes self.smooth smooth def forward(self, pred, target): pred torch.softmax(pred, dim1) target_onehot torch.nn.functional.one_hot(target, num_classesself.n_classes) target_onehot target_onehot.permute(0, 4, 1, 2, 3).float() intersection (pred * target_onehot).sum(dim(0, 2, 3, 4)) union pred.sum(dim(0, 2, 3, 4)) target_onehot.sum(dim(0, 2, 3, 4)) dice (2.0 * intersection self.smooth) / (union self.smooth) return 1.0 - dice.mean() class CombinedLoss(nn.Module): def __init__(self, n_classes, weight_dice0.8, weight_ce0.2): super().__init__() self.dice DiceLoss(n_classes) self.ce nn.CrossEntropyLoss() self.weight_dice weight_dice self.weight_ce weight_ce def forward(self, pred, target): return self.weight_dice * self.dice(pred, target) self.weight_ce * self.ce(pred, target)组合权重weight_dice0.8, weight_ce0.2是一个经验起点。CrossEntropy 提供像素级的梯度信号避免 Dice Loss 在初期收敛缓慢Dice Loss 则直接优化目标指标。如果发现训练初期损失波动大可以把weight_ce调到 0.3 到 0.4 帮助稳定。有个容易踩的坑Dice Loss 的smooth参数不宜设得太大。smooth1.0可能导致梯度被过度平滑模型边界输出模糊医学图像场景下smooth1e-5更合适只做数值稳定性兜底不干扰真实梯度。另外对于 3D 数据Dice 要按整个 3D 体积累计计算先逐 slice 计算再平均的话在 z 轴层数不一的病例间会引入偏差。4.4 优化器与学习率策略Adam 或 SGD 都能用但参数有讲究。nnU-Net 的默认方案是 SGD momentum 0.99配合 Poly 学习率衰减import math def poly_lr(epoch, max_epochs, base_lr0.01, power0.9): return base_lr * (1 - epoch / max_epochs) ** powerPoly 学习率衰减的含义是训练后期用很小的学习率精细收敛避免在最优解附近震荡。SGD 的 momentum 设为 0.99 而不是常规的 0.9是参考 nnU-Net 的配置。原因在于医学分割训练通常 epoch 数量有限100 到 300 个高动量能平滑梯度噪声让模型更稳定地收敛到平坦区域。如果用 Adam学习率起点从3e-4开始比较稳妥。无论哪种优化器weight decay 建议设置在1e-5到3e-5之间过大会压迫网络表达力。5. 测试集推理与评估Dice、表面距离与可视化模型训练完成后在测试集上的评估绝不是跑一遍代码看个数字这么简单。评估指标的选择、统计方式、以及结果导出形式都会影响对模型性能的判断。5.1 用滑窗推理完成全图预测对于 3D 体数据如果网络输入 patch 小于原图尺寸推理时需要滑窗拼接。常见的做法是使用 50% 重叠的窗口并给每个窗口一个高斯权重重叠区域取加权平均。这样做是为了避免窗口边缘的拼接痕迹尤其是胰腺这种边缘对比度不高的器官拼缝处容易产生撕裂状伪影。def sliding_window_inference(image, model, patch_size, overlap0.5): stride [int(p * (1 - overlap)) for p in patch_size] output np.zeros((num_classes, *image.shape), dtypenp.float32) weight_map np.zeros(image.shape, dtypenp.float32) for z in range(0, image.shape[0] - patch_size[0] 1, stride[0]): for y in range(0, image.shape[1] - patch_size[1] 1, stride[1]): for x in range(0, image.shape[2] - patch_size[2] 1, stride[2]): patch image[z:z patch_size[0], y:y patch_size[1], x:x patch_size[2]] with torch.no_grad(): pred model(torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).float().cuda()) pred torch.softmax(pred, dim1).squeeze(0).cpu().numpy() output[:, z:z patch_size[0], y:y patch_size[1], x:x patch_size[2]] pred weight_map[z:z patch_size[0], y:y patch_size[1], x:x patch_size[2]] 1 output / (weight_map[None, ...] 1e-8) return output代码里的weight_map做的是简单平均未用高斯权重。如果想进一步优化边缘效果可预先创建一个与 patch 同尺寸的高斯核叠加时乘上它。显存允许的话批处理多个 patch 可以大幅提升推理速度。5.2 评估指标的计算与统计陷阱Dice 是胰腺分割最常用的指标但计算方式有明显差异。每个病例单独计算 Dice 再取平均与把所有病例的体素堆在一起算一个 Dice结果会不同。医学影像评估惯例是前者也就是每个病例权重相同避免大体积病例主导指标。def compute_dice_per_case(pred, gt, num_classes3): dices [] for c in range(1, num_classes): pred_c (pred c) gt_c (gt c) intersection (pred_c gt_c).sum() union pred_c.sum() gt_c.sum() dice (2.0 * intersection) / (union 1e-8) dices.append(dice) return dices除了 Dice还有一个在胰腺分割场景中常被忽略的指标——95% Hausdorff 距离HD95。Dice 对整体重叠敏感但对边界突出的小区域不敏感。一个模型如果漏掉了一个很小的病灶Dice 下降可能只有 0.01但 HD95 会显著增大。评估胰腺病变分割时Dice 和 HD95 应当同时看。用medpy.metric可以直接计算from medpy import metric hd95 metric.binary.hd95(pred_lesion, gt_lesion, voxelspacingspacing)voxelspacing参数必须传入否则默认假设体素是 1mm 各向同性。如果数据是 2mm 层厚HD95 的值直接偏大。注意105% 的评估差异可能完全由这个参数错误引起而不是模型真实表现。5.3 把预测结果可视化成可检查的图数值指标不能代替视觉检查。可以生成每个病例的中间切片叠加图直接把 GT 轮廓和预测轮廓画在原图上import matplotlib.pyplot as plt from skimage import measure def save_overlay(image_slice, gt_slice, pred_slice, save_path): plt.figure(figsize(12, 4)) plt.subplot(1, 3, 1) plt.imshow(image_slice, cmapgray) plt.title(CT) plt.subplot(1, 3, 2) plt.imshow(image_slice, cmapgray) gt_contours measure.find_contours(gt_slice, 0.5) for contour in gt_contours: plt.plot(contour[:, 1], contour[:, 0], g-, linewidth1) plt.title(GT) plt.subplot(1, 3, 3) plt.imshow(image_slice, cmapgray) pred_contours measure.find_contours(pred_slice, 0.5) for contour in pred_contours: plt.plot(contour[:, 1], contour[:, 0], r-, linewidth1) plt.title(Pred) plt.savefig(save_path, dpi150, bbox_inchestight) plt.close()逐切片生成图片后用ffmpeg合成视频能快速浏览整个病例的预测质量。边缘模糊区域、误判区域在切片图上一目了然。值得关注的情况是模型在胰腺尾部出现断裂、错误把脾脏或左肾的一部分识别为胰腺以及病灶区域预测边界明显超出真实边界。这些都是 Dice 分数难以反映的实际问题。5.4 测试集结果的标准化输出评估结果建议统一保存为 CSV 文件方便多次实验对比。列结构可以是case_idpancreas_dicelesion_dicepancreas_hd95lesion_hd95case_0010.9120.7433.125.87case_0020.8870.8014.014.23保存代码很简单import pandas as pd results [] for case_id in test_cases: pred load_pred(case_id) gt load_gt(case_id) dices compute_dice_per_case(pred, gt) hd95_pancreas metric.binary.hd95(pred 1, gt 1, voxelspacingspacing) hd95_lesion metric.binary.hd95(pred 2, gt 2, voxelspacingspacing) results.append({ case_id: case_id, pancreas_dice: dices[0], lesion_dice: dices[1], pancreas_hd95: hd95_pancreas, lesion_hd95: hd95_lesion, }) df pd.DataFrame(results) df.to_csv(evaluation_results.csv, indexFalse) print(df.groupby(case_id).mean())注意binary.hd95在预测或 GT 完全为空时会返回nan。胰腺病变数据集里部分病例可能没有病变区域此时病变类别的 HD95 无意义应直接跳过而不是填充 0否则统计结果会出现虚高或虚低。6. 处理胰腺分割的特殊难点小目标病变、标签噪声与模型集成胰腺分割里躲不开的几个特殊问题病变区域往往体积很小类别不平衡极端专家标注也存在不一致性单模型预测稳定性有限。针对这些问题有几种直接可用的策略。6.1 小目标病变的损失加权与焦点损失当病变区域体素数只占整个 CT 体积的 0.1% 时即使使用 Dice Loss模型也容易忽略这一区域。一个有效的做法是引入Focal Loss与 Dice 联合计算class FocalDiceLoss(nn.Module): def __init__(self, n_classes, gamma2.0, alpha0.25): super().__init__() self.dice DiceLoss(n_classes) self.focal nn.ModuleList([ FocalLoss(alphaalpha, gammagamma) for _ in range(n_classes) ]) def forward(self, pred, target): dice_loss self.dice(pred, target) focal_loss sum(criterion(pred[:, i], (target i).float()) for i, criterion in enumerate(self.focal)) return dice_loss 0.5 * focal_lossFocal Loss 的核心参数是gamma2.0。它让模型更关注那些难分类的像素也就是靠近边界的模糊区域和小病变内部纹理变化大的像素。alpha0.25平衡正负样本比例。经验上加入 Focal 分支后病变类别的 Dice 可能有 4 到 8 个百分点的提升代价是训练时间增加约 10% 到 15%因为需要额外计算逐类别的 focal 损失。6.2 测试时增强TTA不做白不做的推理优化单模型推理方差较大时测试时增强Test Time AugmentationTTA可以稳定预测结果。做法是推理时对输入做几次变换得到多个概率输出后取平均。胰腺分割场景下沿轴翻转和 90 度旋转的组合是比较实际的选择def tta_inference(image, model, patch_size): preds [] # 原始方向 preds.append(sliding_window_inference(image, model, patch_size)) # xy平面翻转 flipped image[:, ::-1, :] pred sliding_window_inference(flipped, model, patch_size) preds.append(pred[:, :, ::-1, :]) # 90度旋转 rotated np.rot90(image, k1, axes(1, 2)) pred sliding_window_inference(rotated, model, patch_size) preds.append(np.rot90(pred, k-1, axes(2, 3))) return np.mean(preds, axis0)TTA 会增加推理时间数倍但它能提升 Dice 约 1% 到 3%更大的价值在于让概率图更平滑减少单次预测中的孤点噪声。用 TTA 后的概率图再做argmax得到的结果往往在视觉上更干净。6.3 标签噪声的识别与清洗数据集里偶尔会混入标注错误——比如把胰腺周围脂肪标注成胰腺或漏标了整个病变区域。训练完成后检查训练集自身预测错误的样本是一种实用的筛选方式。用训练好的模型对训练集做推断挑出 Dice 低于某个阈值比如 0.7的病例逐一切片看预测与 GT 的差异。如果发现是 GT 漏标或错标修正后再训练一轮效果通常有显著提升。另外提一个方向。如果训练集规模较小、想要更强的语义理解能力可以考虑medical-sam-adapter这类基于 SAM 的微调方案。它的做法是冻结 SAM 图像编码器在分割解码器端添加轻量适配器模块然后用医学图像数据做微调。对比从零训练一个 UNet这类方案的收敛速度和边界质量有优势尤其适合标注数据不足百例的情况。但需要注意这类模型通常以 2D 切片为单位训练直接用于 3D 体数据时要逐层推理并做好层间一致性处理。6.4 胰腺与病变分开建模如果发现单个模型很难同时把胰腺边界和病变区域都分割好可以考虑拆成两个模型一个负责分割胰腺整体另一个在胰腺区域内分割病变。这种 pipelines 的级联方式在 KiTS 和胰腺数据集上被反复验证过。具体实现是模型 A 输入完整 CT输出胰腺整体掩膜。模型 B 的输入是裁剪到胰腺区域的 CT标签只包含病变类别。推理时先跑模型 A 得到胰腺区域再在该区域跑模型 B。这样做的好处病变模型不需要在大背景中搜索“哪里是胰腺”只需在小区域内判断病变与非病变学习难度显著下降。训练时模型 B 的输入 patch 中心点全部落在胰腺区域不需要做前景采样效率也更高。6.5 模型集成简单平均与投票的取舍最后落地部署时如果推理资源允许推荐做模型集成。训练三个不同随机种子的模型推理概率取平均通常可以压掉随机种子带来的性能波动。对于胰腺分割三个模型平均后能提升 Dice 约 1.5%并显著减少零散的错误像素。集成后需要进行连通域后处理保留最大连通分量因为胰腺在解剖学上是单一器官如果预测结果出现多个分离区域多半是误检。使用cc3d库可以快速执行这个操作pip install connected-components-3dimport cc3d import numpy as np def keep_largest_component(mask): labels cc3d.connected_components(mask, connectivity6) if labels.max() 0: return mask largest 1 np.argmax(np.bincount(labels.ravel())[1:]) return (labels largest).astype(np.uint8)connectivity6是 3D 六邻域连通更适合体数据。在 z 轴方向用 6 邻域而不是 26 邻域能过滤掉那些仅在单一轴向相连的细长伪影。这个后处理通常只对胰腺整体做不对病变区域做。病变可能本身就是多发的保留最大连通域反而会漏掉真正的病灶需要单独判断。本文还有配套的精品资源点击获取