胰腺CT图像分割:数据集解析与U-Net实战指南 简介医学图像分割是医疗影像分析的关键环节这份数据集专注胰腺病变区域提取提供背景与病变区域两类分割标签适合医学图像处理、深度学习方向的师生及算法工程师作为训练与测评数据。资源包为7z压缩格式解压后共533个文件其中531个png图像含原始影像与对应mask掩膜另有1个python可视化脚本和1个txt说明文件整体大小仅22.58MB。训练集包含约210张图像及同名掩码测试集约50张图像与掩膜一一对应便于直接送入模型训练附带的脚本能随机抽取一张图像并生成原图、真值标签、叠加蒙版三个视图保存在当前目录方便直观检查分割质量。目前已有605人学习下载这份数据集其结构简洁、体量轻量配合作者博客中的分割网络介绍可帮助初学者理解病灶分割流程也适合用于课程设计或算法验证。1. 胰腺病变图像分割数据集一份能直接开始训练的数据长什么样「胰腺病变图像分割数据集」这个标题其实把三件事说清楚了这是医学图像分割任务目标是胰腺及病灶区域并且配套了训练集、测试集和标签。做过腹部CT相关项目的人都知道胰腺分割是典型的小器官难题器官体积小、边界模糊、周围组织复杂标注成本很高。一份整理好的数据集能直接把入门时间从几周压缩到几天。这篇笔记要解决的是一线问题拿到数据集后先看什么、目录怎么组织、标签怎么解读、CT数据怎么预处理、U-Net怎么跑通基线、以及最容易翻车的几个坑。适合正在做医学图像分割的研究生、刚转到医疗AI方向的算法工程师以及想用现成数据验证自己分割流程的开发者。2. 训练集、测试集与标签的组织形态目录、命名和掩码语义2.1 数据集的常见目录结构与命名规则下载解压之后第一件事不是急着写加载代码而是先看目录结构。这类医学分割数据集最常见的组织方式是一对平行目录images 放原始图像labels 放标注掩码train 与 test 分开。我一般会先跑一个 tree 命令把全貌拉出来pancreas_dataset/ ├── README.md ├── train/ │ ├── images/ │ │ ├── patient_001.nii.gz │ │ ├── patient_002.nii.gz │ │ └── ... │ └── labels/ │ ├── patient_001.nii.gz │ ├── patient_002.nii.gz │ └── ... ├── test/ │ ├── images/ │ │ ├── patient_101.nii.gz │ │ └── ... │ └── labels/ │ ├── patient_101.nii.gz │ └── ... └── meta/ ├── train_val_split.csv └── class_mapping.json这个结构里最关键的是命名一致性image 和 label 的文件名必须一一对应否则 DataLoader 里一错位后面所有训练结果都是废的。很多数据集会把同一个患者的多次扫描放在同一个目录里靠文件名后辍或子目录区分这一点 README 里通常会写明。格式方面CT 体数据最常见的是 NIfTI.nii 或 .nii.gz少数数据集会提供 2D 切片 PNG 或 DICOM 序列。NIfTI 的好处是一个文件包含整个三维体数据加载方便坏处是轴序和方向容易搞混后面讲避坑时细说。还有一点值得注意有的数据集在 meta 目录里带 class_mapping.json这里定义的就是标签里每个整数值代表什么类别建议先打开看一眼不要想当然把 1 当成胰腺。2.2 标签掩码的语义类别编码、硬标签与软标签标签掩码mask和目标检测的框是两套完全不同的东西。分割数据集的标签通常是一张和原图同样尺寸的整数矩阵每个像素的值对应一个类别。胰腺病变分割里最常见的编码方式有三种背景为 0、胰腺实质为 1、病灶为 2或者只分背景和非背景0 和 1再或者把正常胰腺和多种病变类型拆成多个类别。class_mapping.json 里一般长这样{ 0: background, 1: pancreas, 2: lesion, 3: cyst }我用一段脚本快速统计一下标签里的实际类别和分布这一步能提前发现标注有没有问题import nibabel as nib import numpy as np label nib.load(train/labels/patient_001.nii.gz) lbl np.asanyarray(label.dataobj).astype(np.uint8) classes, counts np.unique(lbl, return_countsTrue) for c, n in zip(classes, counts): print(fclass {c}: {n} pixels ({n / lbl.size:.4%}))如果打印出来的类别和 class_mapping 对不上或者出现了 mapping 里没有的整数值说明这份数据的标签格式和文档不一致要先和数据集说明核对而不是硬着头皮训练。这段代码里的 np.unique 统计是全图级的对三维体数据也适用不需要额外按层切片。另外要留意「硬标签」和「软标签」的区别。大多数数据集给的是硬标签即每个像素一个确定的类别整数少数数据集会额外提供软标签概率图或者标注者间存在争议的区域。如果只有一个整数 mask那就按硬标签处理即可不用自己折腾软标签的事。标注边界不干净是常态后面评估时我会专门说怎么处理。2.3 训练集与测试集的划分为什么要按患者级切分很多初学者拿到数据集后第一件事是把所有切片混在一起按 8:2 随机切训练和验证这是最典型的错误。CT 体数据里相邻切片高度相似来自同一个患者的几十张切片会被拆进训练集和验证集模型实际上见过验证集的「邻居切片」评估出来的 Dice 虚高很多换个外部数据集就现原形。正确的做法是按患者维度切分一个患者的所有切片只能出现在一边。官方已经分好 train/test 的数据集基本是按患者切的直接用即可。测试集标签是否公开也决定了玩法如果测试集带标签可以在本地做完整评估如果测试集标签不公开一般只能提交到官方评测平台。标题明确写了「包含训练集和测试集、标签」那大概率是两边的标签都有评估流程可以完整走通。我给自己的团队定了一条规矩测试集只在最终评估时碰一次平时的参数调优和模型选择一律用从训练集里单独切出来的验证集。3. 把CT体数据变成训练样本读取、窗口化和增强的落地参数3.1 用NIfTI读取并可视化先确认方向与切片轴CT 体数据读出来是一个三维数组但不同设备、不同预处理流程下数组的三个维度到底对应横断面axial、冠状面coronal还是矢状面sagittal并不固定。很多次我拿到数据后第一版可视化图是旋转了 90 度的原因是默认把 shape 的第一个维度当成了「切片数」但有些数据第一个维度是图像的宽或高。我建议拿到任何一份新数据先写一个 20 行的脚本把方向看清楚import nibabel as nib import numpy as np import matplotlib.pyplot as plt img nib.load(train/images/patient_001.nii.gz) data np.asanyarray(img.dataobj) print(shape:, data.shape, dtype:, data.dtype) print(affine:\n, img.affine) # 去掉可能存在的单维度 if data.ndim 4 and data.shape[-1] 1: data data[..., 0] # 取中间切片按 shape 的第三个维度当作轴向切片数 slice_idx data.shape[2] // 2 plt.figure(figsize(12, 5)) plt.subplot(1, 2, 1) plt.imshow(data[..., slice_idx], cmapgray) plt.title(CT slice) plt.subplot(1, 2, 2) lbl np.asanyarray(nib.load(train/labels/patient_001.nii.gz).dataobj) plt.imshow(lbl[..., slice_idx], cmapjet, alpha0.5) plt.title(label overlay) plt.show()这段脚本的关键是打印 shape 和 affine。affine 矩阵记录着体素坐标和真实解剖坐标的对应关系如果数据集是用 SimpleITK 或 nibabel 不同工具重新存过方向可能会不一致。后面的所有训练代码全程只用 nibabel 一个库读取不要在同一个流程里混用 nibabel 和 SimpleITK这是我踩过坑之后确认过的混用两个库一个按 array 顺序读、一个按方向矩阵重排mask 和图像就错位了。3.2 窗宽窗位与归一化CT数值处理的顺序不能反CT 图像的原始数值是亨氏单位HU范围通常在 -1024 到 3000 以上这个数值不是 0~255 的像素值不能直接归一化送进网络。不同组织的 HU 值差异很大空气约 -1000水约 0软组织在 20~80 之间骨骼在 400 以上。如果不做窗口化直接算 mean/std胰腺这种软组织会被骨骼和空气把分布拉散模型很难学到有效特征。腹部 CT 观察软组织常用窗宽 350~400、窗位 40~50。我一般按这个流程处理先 clip 到窗口范围再做标准化。顺序不能反先标准化再做窗口化等于白做窗口化def ct_windowing(img, window_width400, window_level40): lower window_level - window_width / 2 upper window_level window_width / 2 return np.clip(img, lower, upper) img np.asanyarray(nib.load(train/images/patient_001.nii.gz).dataobj) img_windowed ct_windowing(img) # STEP 1: 窗口化 img_normalized (img_windowed - img_windowed.mean()) / (img_windowed.std() 1e-8) # STEP 2: 标准化window_width400、window_level40 只是给腹部软组织的一个常用起点。如果数据集里的病灶特别大或者特别小可以扫一遍所有训练样本的 HU 直方图再定参数。还有一点标准化用的均值和标准差只能从训练集统计不能把测试集一起算进去否则就是信息泄露这一点到避坑章还会展开。如果数据集不是 CT 而是 MRI窗口化这套就不适用了不同序列的 MRI 数值没有统一的物理含义直接做 z-score 标准化即可。判断数据集是什么模态README 里一般会写明或者看 affine 和图像本身也能认出来。3.3 数据增强的参数选择翻转、旋转与弹性形变的边界医学图像分割的数据增强和自然图像不完全一样。水平翻转可以用但垂直翻转要非常谨慎——人体解剖结构有固定的上下方向把肝脏翻到上面去训练模型会学到错误的解剖先验。我通常只开水平翻转垂直翻转默认不开。随机旋转的角度也别太大CT 体数据在采集时患者姿势基本固定超过 15 度的旋转会产生大量不真实的空间形变。albuemntations 在医学分割任务里用得很多它天然支持 image 和 mask 同步变换。我常用的一套增强配置如下import albumentations as A train_transform A.Compose([ A.RandomRotate90(p0.5), A.HorizontalFlip(p0.5), A.ElasticTransform(p0.2, alpha1.0, sigma50.0), A.RandomBrightnessContrast(p0.2, brightness_limit0.1, contrast_limit0.1), A.Resize(256, 256), ]) # 应用时把 image 和 mask 同时传入保证变换参数一致 augmented train_transform( imageslice_2d, # shape: (H, W) 单通道 maskmask_2d, # shape: (H, W) )ElasticTransform 的 alpha 和 sigma 决定形变强度alpha 越大位移越夸张。对于胰腺这种本身形态就多变的小器官适度的弹性形变能提升泛化性但 alpha 超过 2.0 后会让解剖结构变得不像话标注也跟着变形反而引入噪声。RandomBrightnessContrast 只作用于图像不作用于 mask这是安全的但要注意它模拟的是成像亮度的变化不要调得太狠。还有一个经常被忽略的问题增强发生在切片层面还是体数据层面。如果先按轴位切出 2D 切片再逐片增强每一片的变换是独立的相邻切片之间的空间连续性就断了这会损失一部分 3D 上下文。更严谨的做法是对整个 3D 体数据做统一变换再切成 2D 切片代价是内存开销大、实现复杂。跑基线阶段用逐片增强就够了等后面做 3D 模型再上体数据级增强。4. 用U-Net跑通第一条基线数据加载、混合损失与评估顺序4.1 自定义Dataset让图像和掩码在DataLoader里对齐预处理做完之后下一步是把数据装进 PyTorch 的 DataLoader。这里最关键的是把文件名对齐写死在 Dataset 里而不是在训练循环里现找文件。我一般会在 Dataset 初始化时读取所有 image 路径和对应 label 路径并断言每一对都存在import os import torch from torch.utils.data import Dataset class PancreasSliceDataset(Dataset): def __init__(self, image_dir, label_dir, file_list, transformNone): self.image_paths [] self.label_paths [] self.transform transform for name in file_list: img_path os.path.join(image_dir, name) lbl_path os.path.join(label_dir, name) if not os.path.exists(lbl_path): # 有的数据集用同名不同后缀存标签先做显式对齐 raise FileNotFoundError(flabel missing for {name}) self.image_paths.append(img_path) self.label_paths.append(lbl_path) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img self._load_volume(self.image_paths[idx]) lbl self._load_volume(self.label_paths[idx]) # 这里默认按轴向切片展开每一条记录返回一张2D切片 slice_idx img.shape[2] // 2 img_2d img[..., slice_idx] lbl_2d lbl[..., slice_idx].astype(np.int64) if self.transform: aug self.transform(imageimg_2d, masklbl_2d) img_2d, lbl_2d aug[image], aug[mask] return torch.from_numpy(img_2d).float().unsqueeze(0), torch.from_numpy(lbl_2d).long()这段代码里有几个值得注意的参数取舍每个体数据只取中间那一张切片是为了快速跑通流程等到正式训练时我会改成按切片索引遍历整卷或者用随机切片采样。如果 class_mapping 里包含背景、胰腺、病变三个类别这里 lbl_2d 的 dtype 用 int64配合 PyTorch 的 one_hot 或交叉熵损失都正合适。unsqueeze(0) 是因为二维 U-Net 的输入是 (B, C, H, W)单通道 CT 切片要补一个通道维。4.2 混合损失Dice与交叉熵按什么比例配胰腺病变分割里类别不平衡非常严重。胰腺本身只占腹部切片的百分之几病变区域更是只占其中一小部分。如果只用交叉熵模型会倾向把所有像素预测为背景因为这样也能拿到很低的 loss。Dice loss 直接优化区域重叠率天然对小目标更友好Dice 加交叉熵混合是我跑这类任务的标准组合。def dice_coef(logits, mask, num_classes3, eps1e-6): probs torch.softmax(logits, dim1) # (B, C, H, W) mask_onehot torch.nn.functional.one_hot(mask, num_classesnum_classes) mask_onehot mask_onehot.permute(0, 3, 1, 2).float() inter (probs * mask_onehot).sum(dim(2, 3)) union probs.sum(dim(2, 3)) mask_onehot.sum(dim(2, 3)) dice (2 * inter eps) / (union eps) return dice[1:].mean() # 忽略背景类只关注胰腺和病变 def mixed_loss(logits, mask, num_classes3): ce torch.nn.functional.cross_entropy(logits, mask) dice 1.0 - dice_coef(logits, mask, num_classes) return 0.4 * ce 0.6 * dice两个细节要说明。一是 dice 的计算要排除背景类背景占比最大把它算进 dice 会让指标虚高模型其实只学会了输出全背景。我这里的dice[1:]就是忽略标签 0。二是权重比例 0.4/0.6 是经验值当病灶特别小的时候可以加大 dice 权重到 0.7 甚至 0.8但也别直接去掉交叉熵不然训练前期梯度容易不稳定。4.3 训练循环与评估顺序测试集只能碰一次模型结构用 MONAI 的 2D U-Net 就能起步没必要自己实现一个。channel 数从小开始数据量不大时一上来就 64、128、256 很容易过拟合from monai.networks.nets import UNet model UNet( spatial_dims2, in_channels1, out_channels3, # 背景、胰腺、病变 channels(16, 32, 64, 128), strides(2, 2, 2), num_res_units2, )训练循环我习惯把验证放在每个 epoch 之后并保存验证集上 dice 最高的权重而不是保存最后一轮的权重best_dice 0.0 for epoch in range(60): model.train() train_loss 0.0 for images, masks in train_loader: images, masks images.to(device), masks.to(device) logits model(images) loss mixed_loss(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() train_loss loss.item() model.eval() val_dice 0.0 with torch.no_grad(): for images, masks in val_loader: logits model(images.to(device)) val_dice dice_coef(logits, masks.to(device)).item() val_dice / len(val_loader) if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), pancreas_unet_best.pt) print(fepoch {epoch1:02d} loss {train_loss / len(train_loader):.4f} val_dice {val_dice:.4f})训练参数方面我推荐一组比较稳的起点不是最优但很少翻车参数推荐值说明patch size256 x 256覆盖胰腺区域内存友好batch size16显存不够就 8配合梯度累积optimizerAdamW权重衰减用 1e-4学习率1e-4不配 lr scheduler 也不容易炸epochs60医学图像中小数据60 轮足够看出趋势early stop10 轮验证集 dice 连续不涨就停混合精度bf16A100 及更新的卡支持显存减半整个流程跑完之后测试集才第一次拿出来跑一次推理计算最终的 dice 和 IoU然后把数字记在实验表里。测试集每碰一次就相当于用它调了一次参反复在上面试会高估模型的真实泛化能力这个「后悔药」是没有的。5. 胰腺分割数据集使用避坑五个翻车点与逐条排查方法5.1 训练集Dice上涨但验证集Dice不动先查是不是数据泄露训练 loss 一路下降训练集 dice 能到 0.9验证集 dice 却卡在 0.3 附近不动这种现象我在医学分割任务里见过很多次。除开模型过拟合最常见的原因是数据泄露。典型作案方式有两个一是做标准化时用整个数据集的均值和标准差而不是只用训练集的统计量验证集和测试集的信息提前进入了训练过程模型在训练时「隐约看见」了验证数据的分布但训练集上表现更好验证集表现反而不行二是划分方式错了同一个患者的切片同时出现在训练集和验证集模型其实是靠记忆相邻切片撑起的高分一旦换到严格按患者划分的验证集就崩。排查方法很直接查你的划分代码是按患者为单位还是按切片为单位查预处理脚本里 mean/std 是从哪里算的。解决就是把统计量改成只从训练集计算划分严格按患者 ID 去重保证 train 和 val 的患者集合交集为空。5.2 叠加图对不上mask和图像错位多半出在读取方向把图像和 mask 叠加显示时发现 mask 的边缘和器官轮廓是错开的胰腺的位置整体偏移或者旋转了 90 度。这不是模型的问题而是数据读取阶段的方向处理不一致。nibabel 读出来的数组顺序是按存储格式排列的有的数据存成 (width, height, depth)有的存成 (depth, height, width)如果图像和 mask 的存储方向不同直接按同一个索引切片就会错位。解决的办法读取图像和 mask 时统一使用同一个库并打印两者的 affine 和 shape 核对。如果 shape 不一致先看看是不是需要转置或翻转如果再处理不了可以尝试用 SimpleITK 读取它会根据方向矩阵把数据重排到标准方向代价是速度比 nibabel 慢一些。关键是整个过程只用一种库不要混用。5.3 预测结果满屏噪声类别不平衡别用纯交叉熵第一次跑通后翻开预测结果发现输出几乎全是背景偶尔有一小撮噪声群胰腺区域基本没预测出来。这是小器官分割的经典翻车现场原因在于类别占比悬殊背景占了 95% 以上纯交叉熵损失下模型的最优策略就是全部预测成背景。如果用的是目标检测的思路——像 YOLO 训练自己的数据集那样直接框出目标——对分割任务行不通分割是像素级决策边界上的每一个像素都要有判断。解决方法是改用混合损失具体做法第四章已经给出把交叉熵和 dice 按 0.4/0.6 混合或者加上 focal loss 让模型把注意力放到难分类的前景像素上。还有一个辅助手段是切片采样训练时优先采样包含胰腺或病变像素的切片避免模型每张图看到的全是背景。5.4 换一个站点数据就崩标注边界和成像设备不一致在一个数据集上 dice 已经跑到 0.8换个外部数据集直接掉到 0.4这种现象在医学图像分割领域是常态。胰腺分割尤其明显不同医院、不同 CT 设备下图像的噪声水平、分辨率和对比度不一样不同放射科医生对「胰腺边界在哪」这个问题的判断也经常有分歧。同一个数据集内部标注一致性尚可跨站点或跨标注者就会暴露。解决思路有三个层面一是评估时对边界区域做容差处理把标注边界向外膨胀 1~3 个像素再计算 dice减少标注边缘分歧对评分的干扰二是训练时加入更强的对比度扰动和弹性形变模拟不同设备的差异三是收集多站点数据做融合训练这属于数据集层面的事单靠一份数据集解决不了但要意识到指标上限可能不是模型的问题而是数据分布的问题。5.5 输入全是黑块或梯度爆炸CT数值范围没处理好模型训练几个 epoch 后 loss 变成 NaN或者输入切片显示出来是一团漆黑或一片死白多半是 CT 数值范围处理出了问题。CT 原始值是带符号的 HU范围可能从 -1024 到 3000 多如果不做 clip 直接送进网络大数值会主导梯度计算模型根本学不到软组织的信息。甚至有人在读取时把 -1024 的空气当成有效数值直接参与标准化结果就是胰腺区域被压缩到几乎没有区分度。解决方法是第三章的流程先做窗宽窗位 clip再算均值和标准差做标准化。这一步写进预处理管线后顺手把所有训练样本的数值范围打印一遍确认 clip 后的分布集中在软组织区间。如果数据集本身已经预处理成 PNG 格式数值范围通常已经在 0~255 之间那这一步就跳过但仍然要确认有没有异常的全黑或全白样本。6. 三十分钟数据集自检可视化抽查、类别统计与守门线6.1 随机抽二十张切片做叠加图拿到任何一份新分割数据集我都会先抽二十张切片做叠加图这一步比看任何文档都能更快发现问题。具体做法是随机选两三个患者在每一卷的轴向切面上均匀取几张切片把原图显示成灰度图把 mask 叠加成半透明彩色层。重点看三件事mask 是否落在正确的解剖区域有没有把周围的肠管、血管、脾脏一起标进去有没有整片整片的标注噪声。没有哪份数据集是完美的但明显标错的样本如果比例超过 5%训练前就要考虑要不要清洗。6.2 统计每个类别的像素占比可视化只能看个大概量化统计才能确认类别平衡问题有多严重。我用一个脚本统计每卷里胰腺和病变的占比import nibabel as nib import numpy as np for name in label_files: lbl np.asanyarray(nib.load(name).dataobj).astype(np.uint8) total lbl.size for cls in [1, 2]: ratio (lbl cls).sum() / total print(f{name}: class {cls} {ratio:.4%})胰腺这类小器官在整卷 CT 里的占比通常在 0.5% 到 3% 这个量级病变区域更小。如果某个样本的标注占比超过 10%说明标注很可能包括了周围组织如果所有样本的占比都趋近于零说明病灶区域标注稀疏到几乎不可学习这时候要优先考虑的是找病灶更集中的数据而不是调模型。统计结果还可以用来决定损失函数的权重占比越悬殊dice 的权重就该越大。6.3 跑一个短基线并守住门槛可视化抽查和统计都没问题后我会跑一个 30 轮左右的短基线用最朴素的 2D U-Net不加任何花哨模块。这样做不是为了刷分数而是给数据集的质量划一条「守门线」。如果验证集 dice 能稳定超过 0.5说明图像和标签对齐良好、特征可学习可以放心继续投入做 3D 模型、多尺度融合这些进阶方案如果 dice 始终在 0.2 到 0.3 徘徊先别急着改模型结构回头查数据预处理和划分逻辑多数时候问题出在数据侧而不是模型侧。我现在的习惯是一切新数据集到手先过这三步再谈训练。随机切片叠加图配合类别统计是半小时内能做完的事却省掉后面好几天的排错时间。我自己因为跳过这一步翻过车后来定了规矩才慢慢少踩坑。希望这份流程能帮你在胰腺病变分割这条路上少走几步弯路。本文还有配套的精品资源点击获取