简介:医学图像分割数据集:CT图像pancreas 2D分割数据,面向医学影像分析与深度学习分割任务,适合研究胰腺器官分割、ROI定位及多视角切片建模的开发者。数据集从轴位面(横端面)、冠状面、矢状面切分出2D图像,切片时去除ROI占比不足3%的样本,统一缩放为512×512,图像与标签均为PNG格式;mask中0为背景、1为胰腺,可直接用于模型训练。资源共2000个文件,以1998个PNG图像/掩膜为主,另有说明txt与可视化脚本show.py,压缩包约140.96MB。三个切面分别含480、970、468张图及对应标签,合计1918对样本,并经数字图像处理增强,利于提升分割鲁棒性。已有802人学习下载,可利用show.py快速查看mask效果,适合作为2D医学分割数据集及数据增强参考。
1. CT胰腺2D分割:横端面、冠状面、矢状面切分的数据集到底在解决什么
CT胰腺是腹部CT里公认难啃的一块:胰腺躲在后腹膜,紧贴十二指肠和脾血管,在常规窗口下跟周围组织对比度不高,形态还千差万别。把CT体数据按横端面、冠状面、矢状面三个方向切成2D切片,配上逐像素的胰腺掩码,再通过数据增强把样本量放大,得到的就是这类二维分割数据集。它解决的问题很具体:让U-Net、DeepLab这代语义分割模型在标注样本有限的情况下,先把胰腺分割跑通。适合医学影像算法工程师、影像组学研究者和做腹部CT辅助诊断的团队使用,也是入门医学图像分割时最容易上手的一类数据形态。
2. 横端面、冠状面、矢状面切分的2D数据:三个视角怎么切、各有什么用
2.1 三个切面在胰腺分割里的分工
胰腺在解剖上是一条横卧在后腹膜的条状腺体,头部被十二指肠环抱,体部横跨腹主动脉前方,尾部伸向脾门。横端面(轴位)是CT扫描的原始采集方向,也是临床读片的默认视角,胰腺在这个方向上通常呈现为拉长的梭形或类圆形结构,头和尾的边界信息最清晰。
冠状面把体数据沿前后方向重排,能同时看到胰头、钩突与十二指肠降段的关系,这对判断胰腺与肠管粘连、识别边界很关键。矢状面则是左右方向的重排,能看到胰腺前缘与胃后壁、后缘与左肾静脉的层次关系。三视图的价值在于:2D模型只看到单张切片时,冠状面和矢状面能提供横端面上缺失的上下、前后的上下文信息。很多团队做法是三个切面分别训练三个模型,最后做预测融合;也有团队把三种切片混在一起当训练集,让一个模型看更多样貌,后面我会讲这两种路线的坑。
2.2 用SimpleITK把DICOM序列切成2D切片
正式切分前,先明确一个基本认知:CT原始数据是三维体数据,不是一堆孤立图片。DICOM序列在内存里本来就是按层厚堆叠的横端面切片,所以横端面2D数据可以零成本直接取;冠状面和矢状面则需要按另外两个方向重新索引体素数组。常见做法是先用SimpleITK读取DICOM序列和对应的胰腺掩码,统一方向后转成numpy数组再切。
import SimpleITK as sitk import numpy as np from pathlib import Path def load_patient(ct_dir, mask_path): # 读取一个患者全部DICOM切片,返回3D体数据和掩码 reader = sitk.ImageSeriesReader() reader.SetFileNames(reader.GetGDCMSeriesFileNames(str(ct_dir))) reader.MetaDataDictionaryArrayUpdateOn() reader.LoadPrivateTagsOn() ct = reader.Execute() mask = sitk.ReadImage(str(mask_path)) # 关键一步:把CT和掩码统一到LPS坐标系,防止掩码错位 ct = sitk.DICOMOrient(ct, "LPS") mask = sitk.DICOMOrient(mask, "LPS") return ct, mask def dump_2d_slices(ct, mask, out_root): # SimpleITK转numpy时顺序是(Z, Y, X),不要记反 ct_arr = sitk.GetArrayFromImage(ct) mask_arr = sitk.GetArrayFromImage(mask) out_root = Path(out_root) # 横端面:固定Z轴,每一层就是一张横端面切片 z_index = [] for z in range(ct_arr.shape[0]): img = ct_arr[z] lbl = mask_arr[z] if lbl.max() == 0: continue # 跳过完全不含胰腺的空层,减少无效样本 np.save(out_root / f"axial_ct_{z:04d}.npy", img) np.save(out_root / f"axial_mask_{z:04d}.npy", lbl) z_index.append(z) # 冠状面:固定X轴,沿患者左右方向切 for x in range(ct_arr.shape[2]): img = ct_arr[:, :, x] # 形状为(Z, Y) lbl = mask_arr[:, :, x] if lbl.max() == 0: continue np.save(out_root / f"coronal_ct_{x:04d}.npy", img) np.save(out_root / f"coronal_mask_{x:04d}.npy", lbl) # 矢状面:固定Y轴,沿患者前后方向切 for y in range(ct_arr.shape[1]): img = ct_arr[:, y, :] # 形状为(Z, X) lbl = mask_arr[:, y, :] if lbl.max() == 0: continue np.save(out_root / f"sagittal_ct_{y:04d}.npy", img) np.save(out_root / f"sagittal_mask_{y:04d}.npy", lbl) return z_index这段代码的逻辑要这样理解:ct_arr.shape的第一个维度是Z轴方向,即患者头到脚的方向,所以固定第一个下标取出来的就是横端面。冠状面是把三维数组沿左右方向切开,固定第二个维度的下标则得到矢状面。每个方向我都用掩码是否含非零值做了过滤,避免把大量只有背景的空白切片写进数据集。
这里有一个隐含参数值得注意:掩码是numpy数组里像素级为0/1的标签,转存后保持不变,但intensity CT值是原始HU值。数据增强时图像和掩码必须用同一套几何变换参数,不能各自独立随机,否则掩码和图像对不上。另外文件名里的zxy就是原始体数据坐标,保留它是为了后面把2D预测结果映射回3D体积做融合,不要图省事用随机ID覆盖掉。
2.3 冠状面和矢状面的重采样坑
直接按numpy下标切冠状面或矢状面有一个隐藏问题:三个方向的体素间距不一致。腹部CT的平面内分辨率通常是0.6~0.9mm,层厚则常见1mm或1.5mm甚至更厚,所以冠状面和矢状面切出来的2D图像,在垂直方向拉伸程度和横端面完全不同。如果不做重采样,同一个患者在同一模型输入尺寸下,横端面训练出来的语义分割特征跟矢状面的特征尺度不在一个量级。
注意:三视图混合训练如果想收敛稳定,建议先把冠状面和矢状面重采样到与横端面一致的层内分辨率。简单做法是用SimpleITK的Resample把体数据spacing统一为各向同性,比如0.8×0.8×0.8mm,然后再切片。
实际切分时,我一般会另存一个记录文件,把每个2D切片对应的原始spacing和坐标索引写进去。这个存档在推理阶段做预测投影时会救命,相当于给每个2D样本留了后悔药。很多开源数据集只给PNG和掩码,不给坐标元数据,导致后人做3D重建时无法对齐,这是切分数据时最容易留的坑。
3. 从原始CT到可训练数据集:掩码组织、切片筛选与预处理
3.1 数据集的最小文件构成
一个能直接训练的分割数据集,最保险的结构是image和mask两个根目录对齐,文件名一一对应。陷阱在于:很多人把PNG放在一个目录、nii.gz放在另一个目录,目录名却叫train和val,训练脚本一旦按文件顺序索引就会错位。下面这张表格给出了推荐的组织方式。
| 目录/文件 | 内容 | 说明 |
|---|---|---|
| imagesTr/ | 训练集2D切片,npy或PNG | 每张图是单通道CT值或窗口转换后的灰度图 |
| masksTr/ | 与imagesTr一一对应的掩码 | 0为背景,1为胰腺,2可扩展为肿瘤 |
| imagesVa/ | 验证集2D切片 | 不允许做任何增强 |
| masksVa/ | 验证集掩码 | 同上 |
| metadata.json | 每个切片的患者ID、方向、体素间距、原坐标 | 推理融合必须字段 |
掩码标签的约定直接影响损失函数。胰腺分割最常见的输出是0/1二分类,但如果你的CT里有胰腺肿瘤,建议扩展为0/1/2的语义分割标签,对应背景、正常胰腺、病灶。类别的设定必须在切分前决定,不然等增强做完再改标签,所有文件名和增强参数都要返工。
3.2 切片筛选:不是所有slice都要进训练
胰腺只占据整个腹部CT的一小部分,一个500层的体数据里,真正含胰腺的层位往往只有40到80层。我把切片筛选的策略定为三步走:第一步用掩码非零判断,完全不包含胰腺的层直接丢弃;第二步保留胰腺面积占比在1%到60%之间的层,因为占比太小时前景噪声过强,占比太大的层通常是胰腺横跨最大截面,连续高层模型容易过拟合到特定形态;第三步按患者维度划分训练验证集,确保同一个患者的切片全部归入同一侧。
很多公开的息肉分割数据集、肺分割数据集的制作也遵循同一套逻辑,切片筛得越干净,类别不平衡问题越轻。筛选后如果发现正样本还是太少,再考虑第4章的数据增强,而不是靠调loss。
筛选的代码可以做成一个独立脚本,跑完输出统计报告,每层保留与否、掩码面积占比、所在原始体数据Z坐标都落到CSV里。这样训练出现异常时,能直接回溯到某一层去检查原始输入,而不是在模型黑匣子里找原因。
3.3 窗宽窗位与归一化:把CT值变成模型能消化的输入
CT图像的单位是HU,但不同设备、不同扫描协议的CT值分布差异很大。胰腺实质通常位于软组织窗内,常见窗位是-150到250HU,也就是把该范围内的CT值映射到0到255的灰度。这个步骤不是可选项,不统一窗宽窗位的模型会学到设备的像素分布特征,换个医院的数据就翻车。
import numpy as np def ct_to_gray(ct_slice, window_center=-100, window_width=400): # 腹部软组织窗:窗位-100,窗宽400,映射到[0,255] low = window_center - window_width / 2 high = window_center + window_width / 2 gray = (ct_slice - low) / (high - low) gray = np.clip(gray, 0.0, 1.0) return (gray * 255).astype(np.uint8) # 归一化到零均值单位方差,供网络输入 def normalize_to_tensor(gray): arr = gray.astype(np.float32) / 255.0 mean, std = arr.mean(), arr.std() + 1e-8 return (arr - mean) / std窗位-100配合窗宽400是一个经验起点,覆盖了胰腺、肝脏、脾脏、肾脏等实质脏器的典型HU范围。注意clip放在了映射之后,防止钙化和金属伪影把灰度炸穿。归一化用的是整张切片的均值和标准差,而不建议在全数据集上算一个固定均值,因为腹部CT里胃肠道气体、骨骼和造影剂的比例每例都不同,全数据集归一化会把正常软组织之间的对比度压没。
4. 数据增强:让胰腺2D分割在少样本下稳住
4.1 增强策略怎么选:几何增强与强度增强分开想
数据增强的核心原则是:让模型看到不违反解剖规律的合理变化,而不是无限制的随机变形。对2D胰腺分割,我把增强分成两类。几何增强负责让模型对位置和姿态鲁棒,包括平移、小角度旋转、左右翻转、轻微缩放;强度增强负责让模型对CT值波动鲁棒,包括对比度增益、高斯噪声、平滑模糊。
很多公开的分割数据集增强方案会加入弹性形变,但胰腺分割要克制:胰腺形态个体差异本来就大,弹性形变过强会把细长的胰腺体部扭曲成奇怪形状,模型反而学不到真实边界。上下翻转这类操作在腹部CT上要直接禁用,因为人体头足方向有明确的解剖含义,翻转后模型会把胰头当成胰尾。左右翻转相对安全,但也要在临床标签确认的前提下使用。
随机裁剪是增强里最容易被忽视的一环。胰腺占全图比例小,裁剪窗口稍大就会把胰腺裁掉一半,稍小又等于没增强。我通常把裁剪范围限制在原图面积的70%以上,并且裁完后检查掩码面积占比,小于阈值的样本重新裁,避免生成一堆几乎没有前景的废样本。
4.2 一组可复现的Python增强实现
下面这组实现不依赖大型框架,numpy加scipy就能跑,适合先集成到数据处理管线里,后续再换albumentations都不影响整体流程。
import numpy as np from scipy.ndimage import rotate, zoom def apply_geometric(img, mask, angle_deg, flip_lr, zoom_factor): # 几何变换:图像用线性插值,掩码用最近邻插值 if flip_lr: img = np.fliplr(img) mask = np.fliplr(mask) img = rotate(img, angle_deg, axes=(1, 0), reshape=False, order=1, mode="nearest") mask = rotate(mask, angle_deg, axes=(1, 0), reshape=False, order=0, mode="nearest") # 缩放后中心裁剪回原尺寸 h, w = img.shape pad_h, pad_w = int(h * (zoom_factor - 1)), int(w * (zoom_factor - 1)) img_pad = np.pad(img, ((pad_h, pad_h), (pad_w, pad_w)), mode="constant", constant_values=img.min()) mask_pad = np.pad(mask, ((pad_h, pad_h), (pad_w, pad_w)), mode="constant", constant_values=0) zh, zw = int(h * zoom_factor), int(w * zoom_factor) img_zoom = zoom(img_pad, (zoom_factor, zoom_factor), order=1) mask_zoom = zoom(mask_pad, (zoom_factor, zoom_factor), order=0) ch, cw = img_zoom.shape top, left = (ch - h) // 2, (cw - w) // 2 return img_zoom[top:top + h, left:left + w], mask_zoom[top:top + h, left:left + w] def apply_intensity(img, gain, noise_sigma): # 强度增强:只动图像,不动掩码 img = img * gain img = img + np.random.default_rng().normal(0.0, noise_sigma, size=img.shape) return np.clip(img, 0.0, 255.0) def augment_sample(ct_gray, mask): rng = np.random.default_rng() angle = rng.uniform(-18, 18) flip = rng.random() < 0.5 zoom_f = rng.uniform(0.9, 1.15) gain = rng.uniform(0.85, 1.2) sigma = rng.uniform(0.0, 6.0) img, mask = apply_geometric(ct_gray, mask, angle, flip, zoom_f) img = apply_intensity(img, gain, sigma) return img, mask逻辑说明:apply_geometric把旋转、翻转和缩放解耦,每次增强的强度由一个随机数生成器统一产生,这样图像和掩码的变换参数天然一致。rotate用了reshape=False,旋转后图像尺寸不变,边界用最近邻模式填充,避免旋转产生大片黑边干扰模型。掩码缩放必须用order=0,也就是最近邻插值,否则0/1边界会出现插值出来的中间值,训练时计算交叉熵会得到一堆莫名其妙的梯度。
参数说明里最关键的是旋转角度和缩放范围。胰腺在腹部CT里的朝向大体是沿腹主动脉横向走行,个体间偏差通常在15度以内,角度上限设在18度已经足够覆盖大部分形态变化。缩放范围设在0.9到1.15,低于这个范围会让胰腺占图比例失真,高于这个范围则胰腺被裁剪掉的概率大增。噪声sigma控制在6以内,CT软组织窗下的真实噪声水平一般就在2到8HU之间。
4.3 增强参数怎么定:先看验证集再定强度
增强参数不是越大越好。我给一个可量化的检查方法:增强完随机抽200个样本,统计掩码面积占比分布,与原始训练集的掩码面积占比分布对比,两边的均值和方差不能显著偏离。如果增强后出现了大量面积占比小于1%的样本,说明缩放下限太低或者裁剪边界把胰腺切没了,这类样本会让模型产生一种错觉,胰腺偶尔就是一小点,边界因此会变得很不稳定。
数据增强方法之间也有叠加优先级。先做几何变换,再做强度变换,最后做归一化,这是固定的执行顺序。先归一化再做强度增强会破坏原本的零均值单位方差分布,模型看到的数据统计特性在验证集上对不上,测试结果天然比训练差一截。另外,强度增强只作用于图像,掩码始终不变;几何增强则必须同时作用在图像和掩码上,这个对应关系一旦写错,数据集的标签就是脏的。
5. 胰腺2D分割数据集制作与训练中的五个坑及排查
5.1 增强把器官形状搞坏,模型边界全是锯齿
现象:训练损失收敛正常,但预测结果里胰腺边缘出现大量不规则突起,形态明显不符合解剖。
原因:弹性形变或网格变形类增强强度太大,把胰腺的条状结构扭曲成了块状。我见过有人把医学图像分割常用的弹性形变参数直接套用到胰腺上,结果胰腺体部每隔几层就被拧成一个结,模型在形变和原始形态之间学了一个折中的形状。
解决:对胰腺这类条状器官,把弹性形变的位移场幅值控制在2到4个像素以内,或直接禁用弹性形变,只保留刚性旋转和缩放。分割模型真正需要抵御的是患者位置和扫描层面的偏移,不是器官被捏变形。
5.2 三视图数据混用导致模型不收敛
现象:把横端面、冠状面、矢状面切片放在一起训练,训练loss振荡明显,验证集DSC始终上不了0.8。
原因:三个切面虽然都是2D图像,但纹理方向和空间上下文完全不同。横端面里胰腺被腹主动脉和脊柱包围,冠状面里胰腺是横卧长条,矢状面里则是竖立短条。模型在同样的卷积核下要同时拟合三种完全不同的纹理分布,学习目标冲突。
解决:两种路线任选其一。第一种是三个切面各训一个模型,推理时把三个概率图在3D坐标上融合;第二种是保持三视图混合但把切片来源作为额外通道或辅助标签输入,让网络显式知道当前样本来自哪个方向。我倾向第一种,工程上最直接,三视图的优势还能在融合里保存下来。数据混合时也必须做好按切面分组的shuffle,batch内尽量同方向,否则BN统计量会来回震荡。
5.3 掩码和原图错位,模型在错误标签上还收敛了
现象:训练时DSC曲线一路走高,但把预测结果叠加到原始CT上看,分割区域整体偏移了几个像素,静脉壁被当成胰腺边缘。
原因:最常见的是没有统一DICOM方向和重采样参数。读取的CT是LPS,掩码是RAS,或者掩码被3D Slicer重新插值过一次但没有写回坐标信息,直接按numpy下标取切片就会出现系统性错位。更隐蔽的原因是CT和掩码的spacing不一样,一个0.7×0.7×1.0,另一个1.0×1.0×1.0,numpy取出来的形状都对,但空间对应关系已经错了。
解决:读入后先用DICOMOrient把CT和掩码统一方向,再检查GetSpacing()和GetOrigin()是否一致。切分前随机挑5个患者各抽5张切片,把掩码以半透明红色叠加在灰度图上保存,肉眼检查边缘连续性。这是最笨也最可靠的方法,建议做成数据管线里的固定检查步骤。
5.4 窗宽窗位不统一,模型学的是像素值不是解剖
现象:源数据来自多台CT设备,训练集和验证集DSC差异不大,但换一个医院的数据预测结果一落千丈。
原因:CT值是设备校准过的物理量,但不同扫描协议下软组织对比度仍然有差别。如果数据预处理没有统一的窗宽窗位映射,模型实际上把设备特定的灰度分布当成了解剖特征,一旦遇到灰度分布不同的新数据就翻车。
解决:在数据集中给所有切片统一执行软组织窗映射,映射参数写死在预处理脚本里,不允许在训练脚本里二次调整。对体外验证,保留10%来自特定设备的病例做外部测试,用来验证模型学到的到底是解剖结构还是设备特征。
5.5 数据泄漏:增强样本混进验证集,指标虚高
现象:训练集DSC大约0.85,验证集DSC反而更高达到0.88,明显不对劲;或者验证集指标波动极小。
原因:数据增强在划分训练验证集之后才执行,但代码里增强与划分的顺序写反了。或者同一个患者的多个切片被增强后,原版切片分到了训练集,增强版却进了验证集,验证集里混入训练样本的近亲。
解决:先按患者维度划分原始切片到训练验证两侧,再做任何增强处理,验证集文件一旦生成就冻结,不参与任何增强流程。增强后的样本文件名要带上aug_前缀和种子编号,便于排查时定位泄漏来源。每次训练前检查train和val文件列表是否存在相同MD5,这个检查虽然笨,但能拦截大多数低级泄漏。
6. 用2D模型做3D体积预测:三视图融合的落地方案
这个方向的终局不是停在2D分割,而是把三个切面的预测结果映射回原始3D体数据,融合出一颗完整的胰腺。基本思路是:训练时记录每个2D切片的原始坐标和方向,推理时让三个方向的模型对同一个体数据各输出一套概率图,最后在体素坐标上做平均或投票。
import numpy as np import SimpleITK as sitk def fuse_predictions(vol_shape, axial_prob, coronal_prob, sagittal_prob, axial_idx, coronal_idx, sagittal_idx): # 初始化三套3D概率体积 prob_sum = np.zeros(vol_shape, dtype=np.float32) weight = np.zeros(vol_shape, dtype=np.float32) # 横端面预测体积:坐标就是Z轴 for z in axial_idx: prob_sum[z] = axial_prob[z] weight[z] += 1.0 # 冠状面预测体积:把冠状面的概率图写回X轴对应位置 for x in coronal_idx: prob_sum[:, :, x] += coronal_prob[x].T weight[:, :, x] += 1.0 # 矢状面预测体积:写回Y轴对应位置 for y in sagittal_idx: prob_sum[:, y, :] += sagittal_prob[y].T weight[:, y, :] += 1.0 fused = prob_sum / np.maximum(weight, 1.0) return fused # 把融合概率图转成掩码并写回nii fused_mask = (fused > 0.5).astype(np.uint8) out_img = sitk.GetImageFromArray(fused_mask) out_img.CopyInformation(ct_img) sitk.WriteImage(out_img, "pancreas_fused.nii.gz")融合前先把三个方向的概率图重采样到同一个体素网格,再用CopyInformation把原始CT的空间信息写回。判断融合质量不要只看DSC,要同时看体积误差:胰腺体积小,DSC差几个点对应的体积误差可能很大。我用HD95和相对体积差两个指标看边界与整体形态,比单看DSC靠谱。
这个方向值不值得投入,我的判断是值得。胰腺2D分割数据集的制作成本远低于3D标注,三视图融合后能达到接近3D模型的边界质量。我的血泪经验是早期做融合时嫌麻烦,直接把三个方向的预测图用插值resize到同一尺寸平均,结果边界全是错位伪影,后来老老实实记录坐标才解决。数据切分时多存一个坐标索引,是你给自己留的后悔药。希望帮到你,别在坐标上再摔一次。
本文还有配套的精品资源,点击获取