简介:一套基于BTCV公开数据集的腹部14器官医学图像分割2D切片数据已整理完成,面向医学影像算法开发者、科研人员及相关专业学生,提供可直接用于模型训练与验证的规范化数据。数据按轴位面、冠状面、矢状面三个方向组织,每个方向均包含images原始切片与masks标签掩膜目录;制作时已剔除ROI区域占比不足3%的切片,对CT图像采用窗宽窗位对比度拉伸,并统一缩放为512×512像素。标签定义清晰,0为背景,1~13分别对应脾脏、双肾、胆囊、食道、肝脏、胃、主动脉、下腔静脉、门静脉和脾静脉、胰腺及左右肾上腺,可直接适配多器官分割任务。资源包为7z格式,共2000个文件,其中1998个PNG构成图像与掩膜数据,另含show.py可视化脚本和classes.json类别配置文件,包体约854.92MB,目录结构便于按方向检索。目前已有1290人学习下载,适合需要标准医疗分割切片数据的初学者和研究者快速上手。
1. 医学图像分割数据集怎么选:BTCV 14 器官切片数据的落地价值
真正做过腹部多器官分割的人,很少上来就拿 3D 体数据直接训练。显存不够、标注颗粒度不统一、切面朝向混乱,任何一个都够折腾一周。我拿到这套 BTCV 14 器官切片数据时,第一反应是检查它对切面的处理方式:轴位面、冠状面、矢状面各生成一组 2D 图像和对应 mask,去掉了 ROI 区域占比小于 3% 的无效切片,CT 已做 windowing 对比度拉伸,全部缩放到 512×512。换句话说,绕开 3D 显存瓶颈的预处理环节已经替你完成。适合刚起步做医学图像分割的工程师,或者想在 2D 网络里快速验证腹部多器官方案的从业者,把精力放到模型训练而不是数据清洗上。
2. 数据组织与标签体系:三个切面 1141/5691/6811 张切片的构成
2.1 轴位、冠状、矢状:三个切面的解剖含义与目录组织
把 BTCV 的 3D CT 体数据想象成一个长方体的体素块:x 轴对应病人左右方向,y 轴对应前后方向,z 轴对应头脚方向。沿不同方向抽片,得到的 2D 视图完全不同。沿 z 轴逐层抽片得到轴位面,也就是从上往下看身体,临床读片最常用;沿 y 轴逐层抽片得到冠状面,相当于从前往后看;沿 x 轴逐层抽片得到矢状面,能清楚看到脊柱两侧的对称关系。
这份资源就是按这个思路组织的。每个切面一个目录,里面分成 images 和 masks 两个文件夹,images 存放预处理后的 2D CT 切片,masks 存放同名同尺寸的标签图。文件名完全对应,像 img0009_315.png 这种命名,前半段是卷号,后半段是切片序号,在 masks 目录里能找到同名文件。验证两个目录是否配套,只需要比对文件名集合。
3D 体数据切成 2D 图后,每个器官在单张切片上只表现为截面。肝脏、脾脏这类大器官在轴位面上截面大,肾上腺这种小结构只在少数切片上出现。这也就是为什么资源在切完之后又做了一道 ROI 过滤——把标注区域占整张图比例不足 3% 的切片丢弃,滤掉绝大多数“几乎看不见器官”的无效层。源 CT 本身一般是 512×512 的重建矩阵,但不同厂商略有差异,统一缩放到 512×512 后,网络输入尺寸不需要再猜,训练时能省掉不少 batch padding 的麻烦。
2.2 classes.json 与 0-13 标签映射
标签体系是这套资源最核心的部分。背景是 0,其余 13 个类按解剖位置依次排开,对应关系如下表:
| 标签值 | 含义 | 标签值 | 含义 |
|---|---|---|---|
| 0 | 背景 | 7 | 胃 |
| 1 | 脾脏 | 8 | 主动脉 |
| 2 | 右肾 | 9 | 下腔静脉 |
| 3 | 左肾 | 10 | 门静脉和脾静脉 |
| 4 | 胆囊 | 11 | 胰腺 |
| 5 | 食道 | 12 | 右肾上腺 |
| 6 | 肝脏 | 13 | 左肾上腺 |
这个顺序不是随意编排的,直接沿用 BTCV 官方的 label 顺序。classes.json 里保存的就是这份映射表,可能是数组也可能是字典。我一般先打开看一眼结构,再决定用classes[1]还是classes['1']取名字,几秒就能确认。要特别提醒的是,mask 图里的像素值就是这些整数,不是 one-hot 编码后的多通道图,所以加载时要用cv2.IMREAD_UNCHANGED,不能用默认三通道方式,否则会把 label 读成 RGB 再转灰度,值域全乱。
2.3 从文件数量反推切片分布
三个切面的图片数量差异很大:x 轴 1141 张,y 轴 5691 张,z 轴 6811 张。这个差异本身就说明问题——矢状面经过 ROI 过滤后保留的切片最少,轴位面最多。腹部 CT 的 z 方向覆盖范围最大,所以轴位切片数量合理;而矢状面方向身体左右宽度最小,器官截面占比更容易低于 3%,被滤掉的切片比例自然最高。
可以写一个几行的脚本验证这个假设,同时检查数据配对是否完整。遍历三组目录后,把两边的图片名集合做差,任何一边多出来的文件就是配对问题:
from pathlib import Path for axis in ['x', 'y', 'z']: img_dir = Path(f'{axis}/images') mask_dir = Path(f'{axis}/masks') img_names = {p.name for p in img_dir.glob('*.png')} mask_names = {p.name for p in mask_dir.glob('*.png')} print(f'{axis} axis: images={len(img_names)}, masks={len(mask_names)}') print('only in images:', sorted(img_names - mask_names)[:5]) print('only in masks:', sorted(mask_names - img_names)[:5])逻辑说明:脚本把两个目录的文件名转成集合,集合差运算能一次性找出不配对文件。前三行用于确认数量与资源描述一致,后两行定位具体是无 mask 的图还是无图的 mask。我第一次核对 z 轴时,就靠这个脚本抓到一批后缀大小写不一致的文件(PNG与png),Windows 上不敏感、Linux 上却会直接报文件找不到,训练循环里才爆出来。建议拿到数据先跑一遍,几秒钟的事。
参数说明:文件都假设是 PNG 格式,如果资源里存在.jpg或.npy,把glob('*.png')改成对应后缀;[:5]只是输出前 5 个防止刷屏,全量名单可以去掉切片。脚本不要写死绝对路径,用相对路径组织三个切面目录,后续换机器不用改代码。
2.4 切片数量差异对训练采样策略的影响
1141 张矢状面与 6811 张轴位面如果直接混合训练,模型会被动把“见过更多的轴位面”当作先验,导致矢状面切片上的分割精度明显偏低。常见做法是每个 batch 里按轴均匀采样,或者对矢状面做短边重采样增强。这个问题等模型跑起来再发现就晚了,最好在网络结构设计阶段就决定要不要做轴向对齐。
我一般会为每个轴单独维护一个采样权重,让三个轴在一个 epoch 内被采到的次数接近,而不是按文件数量自然分布。这样做的代价是每个 epoch 实际看到的样本数变少,但对小切面方向的性能提升非常直接。如果你的目标只是轴位面,那把 x、y 轴当作辅助训练集也是一种可选方案,辅助集不参与最终评估,只用来做预训练。
3. 预处理链路:windowing 对比度拉伸与 3% ROI 过滤的实现
3.1 windowing:从 CT 值到可视灰度
CT 图像里每个像素是组织对 X 线衰减的量化值,单位是 HU,范围通常在 -1024 到 3000 以上。如果把这个宽范围直接线性压到 0-255,内脏对比度会非常差,因为软组织只集中在很窄的一段 HU 区间里。windowing 做的事是只保留某个感兴趣区间,把区间外的值截断,区间内映射到可视灰度:
import numpy as np def apply_windowing(volume, window_center=50, window_width=400): lower = window_center - window_width / 2.0 upper = window_center + window_width / 2.0 out = (volume - lower) / (upper - lower) out = np.clip(out, 0.0, 1.0) return (out * 255).astype(np.uint8)逻辑说明:分母window_width控制对比度,分子volume - lower先把区间下界平移到 0,再除以窗宽归一化到 0-1,最后乘 255 变成 8bit 灰度。低于下界的值全部变成 0,高于上界的全部变成 255,腹部软组织的层次就这样展开。
参数说明:腹部平扫常用的窗位在 30-60 HU、窗宽在 300-500 HU。如果是门静脉期增强扫描,窗位固定 50、窗宽放到 400 附近,肝脏和门静脉的灰度层次都会比较清楚。但这份资源里已经做过了这一步,你拿到的是处理后的结果,不要重复再做一次 windowing,否则灰度被二次压缩,对比度反而劣化。我自己踩过一次,把两套灰度分布不一致的数据混进训练集,测试集精度掉了两个点才排查到根因。
3.2 ROI 占比不足 3% 的切片过滤
这一步发生在切完切片之后。每张切片对应一张 mask,统计 mask 中所有非零像素占整张图的比例,低于阈值就丢弃:
import cv2 import numpy as np def keep_slice(mask_path, threshold=0.03): mask = cv2.imread(mask_path, cv2.IMREAD_UNCHANGED) if mask is None: return False non_zero = np.count_nonzero(mask) total = mask.shape[0] * mask.shape[1] ratio = non_zero / total return ratio >= threshold逻辑说明:np.count_nonzero统计像素值不为 0 的数量,除以总像素数得到 ROI 面积占比,函数返回 True 表示保留这张切片。判别阈值为 3%,低于它时切片里最大器官的截面积也只有一两百个像素,网络很难从中学到特征,还会作为难例拖慢收敛。
参数说明:threshold是 float,0.03 对应 3%。如果做小器官专项(比如只分左右肾上腺),可以调到 0.01,否则过少的肾上腺切片会被全部滤掉;反过来做脾脏或肝脏,0.03 就够了。建议先用这个脚本跑一遍三组目录,记录每张切片的 ratio 分布,再决定阈值是否需要调整,而不是直接信任一个固定数字。
3.3 统一缩放到 512×512 时 mask 的插值策略
缩放图像和 mask 是两条路:CT 灰度图用双线性插值保平滑,mask 用最近邻插值避免引入非法标签。如果给 mask 也做双线性,边缘像素会插出 0.5、2.4 这样的中间值,后续交叉熵计算时这些非法类别会让 loss 直接报错,或者悄悄改变类别权重。
import cv2 def resize_pair(img, mask, size=(512, 512)): img_resized = cv2.resize(img, size, interpolation=cv2.INTER_LINEAR) mask_resized = cv2.resize(mask, size, interpolation=cv2.INTER_NEAREST) return img_resized, mask_resized逻辑说明:cv2.resize的size参数是目标尺寸,顺序是 (宽, 高),也就是 (512, 512)。图像用线性插值保留器官边缘的灰度过渡,mask 用最近邻保证输出像素值只来自原图的整数标签,不会产生新类别。
参数说明:如果后面要做随机裁剪增强,建议先缩放到比 512 更大的尺寸(比如 560),训练时再随机裁剪 512,给模型一点平移不变性;如果资源已经固定 512×512,跳过这一步,改用在线水平翻转即可。这是一个取舍:在线旋转通常会给 mask 引入伪影,所以 2D 切片场景我一般只用翻转和轻微亮度扰动,不用旋转。
4. 加载与可视化:show.py 与 img0009_315.png 里藏着什么
4.1 文件名的语义与同步关系
文件名 img0009_315.png 包含两段信息:img0009 是卷号,315 是切片序号。同一卷下切片序号应当是连续的,这种命名其实帮了大忙,后续做 3D 重建时只要按序号排序就能恢复空间顺序。正因为如此,我拿到数据的第一件事就是确认每个卷号的切片数连续、没有中间缺号。
我在另一份数据上吃过亏,切片序号从 315 直接跳到 318,缺的两层实际上是空层,模型在训练时没见过这种空洞,推理阶段碰到类似分布输出就抖得厉害。查这个问题不复杂,按卷号分组、对切片序号排序,然后检查相邻差值是否都为 1,几行脚本就能完成。可视化之前先做这一步,能避免后面拿着一套断裂的数据反复调模型。
4.2 show.py:一份合格的可视化代码该做三件事
资源里自带的 show.py,是打包者为了让刚下载的人快速验证数据内容而写的。一份在分割任务里真正好用的可视化脚本,至少应该做三件事:显示图像、显示 mask、叠加显示。单独看图像只能确认切片内容,单独看 mask 只能确认标签涂得对不对,只有把半透明 mask 叠在 CT 灰度图上,才能判断器官边界是否与图像解剖结构对齐。
show.py 的调用方式通常就是python show.py --img x/images/img0009_315.png --mask x/masks/img0009_315.png这类命令行,图像以灰度显示,mask 以彩色标签叠加。常见做法是给每类分配固定颜色:肝脏用暗红、脾脏用紫、肾脏用黄绿,人眼核对边界时效率高不少。如果没有 show.py,用下面的代码也能实现同样的叠加效果:
import cv2 import numpy as np img = cv2.imread('x/images/img0009_315.png', cv2.IMREAD_GRAYSCALE) mask = cv2.imread('x/masks/img0009_315.png', cv2.IMREAD_UNCHANGED) color_mask = np.zeros((*mask.shape, 3), dtype=np.uint8) color_mask[mask == 6] = [60, 20, 220] # 肝脏 color_mask[mask == 1] = [220, 20, 60] # 脾脏 overlay = cv2.addWeighted(cv2.cvtColor(img, cv2.COLOR_GRAY2BGR), 0.7, color_mask, 0.3, 0) cv2.imwrite('overlay.png', overlay)逻辑说明:读取灰度图后转成三通道 BGR,掩码按标签值刷成不同 RGB 颜色,再用addWeighted做加权叠加,权重分别是 0.7 和 0.3。这样背景保持原灰度,器官区域透出颜色,边界一眼就能看出有没有错位。mask == 6就是肝脏;如果要叠加全部标签,可以遍历 1 到 13 给每类配一个颜色。
参数说明:cv2.IMREAD_UNCHANGED是这里的关键参数,不加时 OpenCV 会按 8 位三通道读取;本资源的 label 是 0-13 的单通道 PNG,用IMREAD_UNCHANGED才能保持原值。addWeighted的权重可以反过来调,想更清楚看器官就把 0.3 调成 0.5,但太透明时边界不容易分辨。
4.3 用像素统计验证 mask 是否“干净”
可视化只是肉眼层面,我还会用脚本统计 mask 的离散值域,确认没有越界标签。这个验证在分割项目里的重要性不亚于数据本身:
import cv2 import numpy as np from pathlib import Path mask_dir = Path('z/masks') unique_tags = set() for mask_path in mask_dir.glob('*.png'): mask = cv2.imread(str(mask_path), cv2.IMREAD_UNCHANGED) if mask is None: continue unique_tags.update(np.unique(mask).tolist()) print('所有出现的标签值:', sorted(unique_tags))逻辑说明:遍历一个切面下的全部 mask,把每张图里出现的像素值归并到集合,最后打印全集。理论上这个集合应当是 0 到 13 的子集,而且对于腹部多器官数据集,应出现几个主要器官而不是只剩背景。如果输出只有 0,说明加载方式错了,多半是读成三通道再转灰度;如果出现大于 13 的值,说明某张 mask 存在非法标注或混入其他数据源。
参数说明:update和tolist保证 Python 集合操作正常;如果数据量太大,可以抽样前 200 张,但抽样只能证明样本干净,不能替代全量扫描。这个脚本建议在第一次跑训练前执行,也是一线团队常说的“数据准入”环节。
5. 避坑与排查:5 个高频问题的现象、原因与解决办法
5.1 现象:mask 加载后全黑,或类别数异常
现象:用 show.py 显示 mask,大部分区域是黑的,或者某些器官边界出现黑白噪声。
原因:最常见是用了cv2.imread('mask.png')默认三通道读取,随后又转灰度。单通道 PNG 被强制按三通道读后,每个通道值仍是原值,但转灰度时会按 BGR 权重换算,0-13 的小整数会被映射到 0-10 左右的灰阶,看起来就是接近全黑。
解决:统一用cv2.IMREAD_UNCHANGED读 mask,并且打印np.unique(mask)核对值域。这类问题在网络输入阶段很难发现,因为归一化到 0-1 后 0 和 255 的差异被缩小,真正爆出来是在 loss 计算时类别数对不上。
5.2 现象:三个切面里同一器官的形状对不上
现象:轴位面上肝脏轮廓很大,矢状面上同一个病例看不到肝脏,甚至左右肾在两个方向上的形状差异让人觉得标注错了。
原因:这不是资源错误,而是同一 3D 体积在不同方向切片上的截面天然不同。肝脏在轴位面是最大截面,在矢状面就窄得多;左肾和右肾在冠状面和矢状面上的投影也完全不一样。腹部多器官分割用 2D 网络做时,最容易在这里产生“标注不对”的误判。
解决:按轴位、冠状、矢状分开建模,或者用多轴输入的网络结构,而不是把三个轴混进一个模型当作同一分布。常见做法是三个切面各训一个编码器,下游融合成 3D 输出,比单轴向模型更可靠。如果资源已经固定 512×512,先把 z 轴一个轴跑通,再考虑融合 x、y 轴。
5.3 现象:mask 边界像锯齿,比 CT 图像边缘更难看
现象:某器官边缘在 CT 灰度图上还算平滑,在 mask 里却是一格一格的,甚至出现 1 像素宽的缺口。
原因:如果 mask 缩放到 512×512 时用了线性插值,边缘像素会被插成小数再取整,产生明显阶梯;此外原 3D 标注本身可能就是在低分辨率体素上画的,转到 2D 后边缘天然粗糙。
解决:缩放 mask 一律用最近邻插值,代码见 3.3 节的resize_pair。如果锯齿来自原始标注,不要用形态学开闭运算去后处理,那会改变器官面积,影响 Dice 评估的公平性。训练时用在线数据增强比事后平滑更合适。
5.4 现象:胰腺和肾上腺在预测结果里几乎总是缺失
现象:训练后模型对肝脏、肾脏分得很准,但胰腺、肾上腺的输出几乎是一团零。
原因:ROI 3% 过滤后,胰腺和肾上腺切片占比远低于肝脏,类别极度不平衡;再加上这两类体积小、边界模糊,模型在交叉熵主导下很容易把它们当成背景。
解决:给 loss 加类别权重,或改用 Dice 类 loss。对这类小器官,权重按训练集里像素占比的倒数来设置是个可操作起点:
import numpy as np counts = np.bincount(mask.ravel(), minlength=14) epsilon = 1e-6 weights = 1.0 / (counts + epsilon) weights = weights / weights.sum()逻辑说明:np.bincount统计 0-13 每个类别的像素数,取倒数后小器官获得大权重,再归一化避免数值过大。传入损失函数时,交叉熵的weight参数直接填这份数组即可。
参数说明:minlength=14保证缺失的类也占一个位置,否则数组长度会随数据变化。epsilon防止除零。训练时还要观察验证集上的小器官 Dice,否则训练损失降得漂亮,胰腺和肾上腺却可能完全没有被学习到。
5.5 现象:训练损失忽高忽低,同一个 case 不同 epoch 的表现抖动大
现象:loss 曲线不是平滑下降,而是周期性波动,切到批次里某个特定卷时损失明显变大。
原因:文件名里卷号相同的切片在随机 batch 里被分到不同迭代,不同卷的灰度分布和器官形态差异又大,模型每遇到一个没见过的卷就重新适应一遍。
解决:按卷号拆分验证集,别让同一个卷的切片同时出现在训练和验证里。这比调参更根本,也直接影响测试指标的可信度。周期抖动如果还伴随显存溢出,可以把 batch size 降到 8 并开启梯度累积,但首先要保证数据切分是按卷进行的。图像分割里随机打乱图片按行拆分是最常见的翻车点,尤其是医学序列数据,同一个患者的相邻切片高度相似,不按卷拆分会造成严重的信息泄漏。
6. 进阶技巧:2D 切片的批处理、3D 重建与验证闭环
常见的坑是拿到 2D 切片后就把 3D 空间信息全丢掉。我会把切片序号和卷号保存成索引表,训练完成后用同一套索引把 2D 预测卷回 3D 体积,这样既能做体积计算,又能回到原始空间里定量验证。
做法分两步。第一步,在数据加载器里维护一张表,记录每张 PNG 对应的 (卷号, 轴向, 切片序号),比如文件名 img0009_315.png 对应('img0009', 'z', 315);第二步,推理时按卷号收集所有切片预测,按序号排序后沿深度方向拼接,得到 512×512×N 的三维概率图。下面这段代码演示从文件名恢复顺序并堆叠:
import numpy as np import cv2 from pathlib import Path def parse_name(name): parts = name.stem.split('_') return parts[0], int(parts[1]) # 卷号, 切片序号 def stack_slices(pred_dir): items = [parse_name(p) for p in Path(pred_dir).glob('*.png')] items.sort(key=lambda x: x[1]) slices = [ cv2.imread(str(Path(pred_dir) / f'{vid}_{idx}.png'), cv2.IMREAD_UNCHANGED) for vid, idx in items ] return np.stack(slices, axis=-1), items逻辑说明:parse_name返回卷号和整数序号,排序用整数而不是字符串,避免315排在99前面的字典序错误。np.stack沿最后一个维度叠加,得到的三维数组最后一维对应切片方向。返回的items是排序后的索引表,方便后续与病例信息对齐。
参数说明:如果三个切面混在一起重建,parse_name还需要返回轴向字段,因为不同轴向的序号不能互相排序;例如x/img0009_200.png和z/img0009_200.png序号相同但物理位置完全不同。我只在 z 轴做 3D 重建时直接按序号排序,x、y 轴的重建需要配合原始 spacing 重采样,建议先把 z 轴的闭环跑通再扩展另外两轴。
这套验证闭环我建立得很晚。最早用 2D 切片训练时只看切片上的 Dice,模型整体分数不低,但医生说体积计算差得离谱,我才意识到是轴向坐标混淆的问题。从那以后,我每拿到一个切片数据集都会先跑三件事:文件名解析脚本、索引连续性检查、mask 值域全量扫描,全部通过再进训练管线。三维重建的闭环每个项目强制走一遍,多花半天时间,也不愿再靠视觉直觉猜坐标。希望帮到你。
本文还有配套的精品资源,点击获取