简介:面向医学影像分割研究者的肝肿瘤CT切片数据集,基于LiTS公开数据从131个病例中沿冠状面切出2D图像,并剔除ROI占比不足5%的低信息量切片,适合用于训练和评估肝脏及肿瘤分割模型。资源共2000个文件,主体为1998张PNG格式图像与对应掩膜,另含classes.txt类别说明和show.py可视化脚本,压缩包整体674.1MB。数据按训练/测试划分,训练集包含10937张图像及同名mask,测试集含4686组,mask灰度为0背景、1肝脏、2肿瘤,阈值图可直接作为分割标签。配套的Python可视化脚本可快速叠加展示原图与掩膜,便于核查标注质量或生成论文示例图。目前已有1111人学习下载,适合医学图像处理入门及肝肿瘤分割模型复现。 拿到LiTS(Liver Tumor Segmentation Challenge)数据集的人,八成都会经历同一个阶段:下载解压完一百多个nii文件,兴奋地打开看一眼,然后突然不知道该干什么。直接拿3D体数据训练,显存分分钟被撑爆,模型输入尺寸也对不上;转成2D来处理,又搞不清楚切片规则、标签怎么对齐、类别怎么划分。这篇文章就是把这些环节完整走一遍——从LiTS原始数据的格式、3类别切片的设计思路,到切片代码、标签文件的组织方式,再到可视化验证和实际踩坑,一次性说清楚。
这套流程不仅适用于LiTS,对其他医学图像分割数据(CT、MRI的nii格式数据)也基本通用。如果你正在做肝肿瘤分割、腹部器官分割,或者刚下载好LiTS正愁怎么下手,这篇文章可以直接当操作手册用。
1. LiTS原始数据到底长什么样:动手前先搞清楚三件事
1.1 131例CT和手动标注的基本约定
LiTS挑战赛的训练集一共131例腹部CT增强扫描,每例包含两个关键文件:一个是volume-XX.nii,是原始的CT体数据;另一个是segmentation-XX.nii,是对应的标注文件。CT体数据存储的是一个三维数组,单位是亨氏单位(HU),反映组织对X射线的衰减程度。标注文件则是逐体素(voxel)标记了每个位置属于哪一类。
这里要特别注意原始标注的类别定义。LiTS的标签值默认是:
| 标签值 | 含义 |
|---|---|
| 0 | 背景 |
| 1 | 肝脏 |
| 2 | 肝肿瘤 |
严格来说,肝肿瘤区域也是肝脏组织的一部分,但公开数据集的标注把肿瘤单独拆成了一个类别,方便做肿瘤分割评估。所以标题里说的“3类别”,指的就是0、1、2这三个值。很多初次接触的人会误解成“肝脏1类、肿瘤1类、其他器官1类”,其实不是,背景、肝脏实质、肿瘤,一共三类。
1.2 为什么非切不可:显存、Shape匹配与标注映射
直接拿完整nii文件训练3D网络,听着很理想,但现实很骨感。一个典型的LiTS volume,shape大致是(512, 512, 400~600),也就是切片数在几十到几百张不等,单个体素尺寸约0.7mm左右。这种量级的数据,即使下采样到128x128x128,一个batch的显存占用也非常可观,普通消费级显卡基本跑不动。
另一个更麻烦的问题是形状不统一。131例数据的分辨率、切片厚度、扫描范围都不一样,直接喂给模型会疯狂报维度错误。相比之下,切片到2D层面后,每个slice都是独立的(H, W)矩阵,统一resize到256x256或者512x512非常方便,标签也能直接用像素值一一对应。这也是为什么很多经典2D分割框架(比如U-Net)处理CT数据时,都默认先从轴向切片开始。
2. 3类别切片的核心设计:关于类别映射、切片方向和空洞切片
2.1 “3类别”在切片任务中的实际操作口径
切完片之后,每一张2D图像的像素值仍然沿用0、1、2的编码方式。但这里有一个很容易踩的坑:如果不加处理,直接保存的mask里,类别1(肝脏)的像素数量会远远大于类别2(肿瘤),两者比例可能差几十倍。所以在切片阶段就要想清楚,任务目标是“肝脏和肿瘤一起分割”,还是“只分割肿瘤”。
如果做的是肿瘤分割,常见做法是先取肝脏区域作为感兴趣区域,再在肝脏区域内区分肿瘤和非肿瘤。对应到切片代码里,可以在保存mask时额外生成一份“肝脏+肿瘤合并”的标签文件,也就是把1和2都当成前景,值为1;肿瘤单独作为另一个通道。标题里说的“3类别数据集”,我建议保留三份信息:
mask:0背景、1肝脏、2肿瘤,供多类别任务训练;foreground:0背景、1前景(肝脏+肿瘤合并),供二分类或先定位肝脏用;tumor_only:0背景、1肿瘤,供专门做肿瘤细分任务用。
这样一份切片数据集可以适配多种训练需求,不用以后重新切片。
2.2 轴向切片为主,但也要考虑切片间距和覆盖范围
最常用的切片维度是从z轴方向切,也就是把三维体数据沿轴向(从头顶到脚底)切成一张张横断面图像。这样切出来的slice和临床上医生看的CT轴向图像一致,和大多数2D分割模型的预训练权重也匹配。用SimpleITK读数据时,GetArrayFromImage返回的数组顺序是(z, y, x),所以循环的时候直接遍历第一维就行。
切片间距不需要额外处理,LiTS原始数据本来就是逐层扫描的,但要注意有些case的z轴层数和肝脏覆盖范围差异很大。先查看每个case的label中非零区域的数量,统计一下肿瘤出现在哪些z范围内,可以避免把大量不含任何前景的切片也纳入训练集。这个问题我会在第5章详细展开。
3. 切片代码实现:从nii到干净整齐的png序列
3.1 环境依赖和目录结构
切片这一步推荐用SimpleITK读nii,用numpy做数组运算,最后用PIL保存图片。SimpleITK在处理医疗影像格式时非常稳定,能自动处理方向、spacing等元信息,比直接读nii的二进制更省心。
我习惯的目录结构是这样:
LiTS/ ├── TrainingData/ │ ├── volume-1.nii │ ├── segmentation-1.nii │ └── ... output/ ├── images/ │ ├── case001_z0000.png │ ├── case001_z0001.png │ └── ... ├── masks/ │ ├── case001_z0000_mask.png │ └── ... └── split/ ├── train.txt └── val.txtimages放CT灰度图,masks放对应标签图,split放训练验证集划分文件。这样结构清晰,后续做Dataset类或者直接用ImageFolder加载都很方便。
3.2 核心切片代码与逐段解释
import os import numpy as np import SimpleITK as sitk from PIL import Image INPUT_DIR = "LiTS/TrainingData" OUTPUT_IMG = "output/images" OUTPUT_MASK = "output/masks" os.makedirs(OUTPUT_IMG, exist_ok=True) os.makedirs(OUTPUT_MASK, exist_ok=True) WINDOW_MIN, WINDOW_MAX = -200, 250 for idx in range(1, 132): img_path = os.path.join(INPUT_DIR, f"volume-{idx}.nii") mask_path = os.path.join(INPUT_DIR, f"segmentation-{idx}.nii") img = sitk.ReadImage(img_path) mask = sitk.ReadImage(mask_path) img_arr = sitk.GetArrayFromImage(img) # (z, h, w) mask_arr = sitk.GetArrayFromImage(mask) # (z, h, w) for z in range(img_arr.shape[0]): slice_img = img_arr[z].astype(np.float32) slice_mask = mask_arr[z].astype(np.uint8) # CT值窗宽窗位裁剪,映射到0-255灰度 clipped = np.clip(slice_img, WINDOW_MIN, WINDOW_MAX) gray = ((clipped - WINDOW_MIN) / (WINDOW_MAX - WINDOW_MIN) * 255) gray = gray.astype(np.uint8) # 保存原始0/1/2标签 Image.fromarray(gray).save( os.path.join(OUTPUT_IMG, f"case{idx:03d}_z{z:04d}.png")) Image.fromarray(slice_mask).save( os.path.join(OUTPUT_MASK, f"case{idx:03d}_z{z:04d}_mask.png")) if idx % 20 == 0: print(f"processed {idx}/131")这段代码做了三件事:读数据、Hounsfield窗口裁剪、保存图像和mask。第一件事没什么好说的,重点在窗口裁剪。
CT值是HU单位,范围通常从-1000到3000以上,直接保存成8位png会把软组织细节全部压缩丢失。肝脏实质的CT值一般在40~60HU,肿瘤区域因为血供差异会略低一些,大概在20~40HU,而背景空气是-1000HU。用[-200, 250]这个窗口可以把腹部软组织的对比度拉到最大,背景和骨骼区域分别被压成纯黑和纯白,视觉上更清楚,模型训练时也能减少无关信息的干扰。
这里要特别提醒一句:窗口裁剪只影响保存的图像,不影响mask。mask保存时直接用原始像素值0/1/2就行,但png是8位单通道,值大于255会被截断,所以slice_mask先转成uint8是安全的,因为标签最大就是2。
3.3 标签文件与数据集划分
切片完成之后,除了图像和mask,还应该生成一份数据集清单文件,方便后续训练时直接读路径。我一般会这样生成:
import glob files = sorted(glob.glob("output/images/*.png")) with open("output/train.txt", "w") as f: for path in files: if "case001" in path or "case002" in path: continue f.write(os.path.abspath(path) + "\n")这样做的好处是,训练时只需要读一个txt文件,按行切分batch,不需要每次去遍历目录。验证集可以留10到20个case的切片,建议按case留而不是按slice留,否则同一个case的相邻切片会在训练集和验证集里同时出现,导致验证指标虚高。
4. 可视化代码:切片结果对不对,两三行代码就能看出来
4.1 单张叠加可视化:原图上直接检查mask边界
切片代码写完之后,千万别急着训练,先随机抽几张图看一眼。医学图像分割的标签错误非常隐蔽,如果切片时坐标偏移了一个维度、或者mask和image来自不同的case,肉眼不检查根本发现不了。
最简单有效的可视化方式是把mask以半透明色块叠加在灰度图上:
import matplotlib.pyplot as plt import numpy as np from PIL import Image img = np.array(Image.open("output/images/case001_z0100.png"), dtype=np.float32) mask = np.array(Image.open("output/masks/case001_z0100_mask.png"), dtype=np.uint8) overlay = np.stack([img] * 3, axis=-1) # 肝脏区域标为红色,肿瘤区域标为黄色 overlay[..., 0][mask == 1] = 255 overlay[..., 2][mask == 1] = 80 overlay[..., 0][mask == 2] = 255 overlay[..., 1][mask == 2] = 200 fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img, cmap="gray") axes[0].set_title("Original") axes[1].imshow(mask, cmap="gray", vmin=0, vmax=2) axes[1].set_title("Mask") axes[2].imshow(overlay.astype(np.uint8)) axes[2].set_title("Overlay") plt.savefig("check_case001_z0100.png", dpi=150, bbox_inches="tight")这段代码把类别1(肝脏)染成偏红色的半透明效果,类别2(肿瘤)染成亮黄色。看叠加结果时重点检查两件事:一是mask边界是否贴合组织边缘,二是肿瘤区域是否落在肝脏内部。如果肿瘤标注飘到肝脏外面,再往后做的一切工作都是错的。
4.2 用灰度直方图辅助检查CT值分布是否正常
除了视觉叠加,还可以从CT值分布上做快速体检。正常情况下,肝脏区域的灰度值集中在40~60HU(映射后对应一个中等亮度的峰),肿瘤区域略低,背景空气接近0或纯黑。如果某张图的直方图完全没有任何组织峰,全是黑白两极分布,大概率是窗口参数写错了,或者读到了错误的文件。
plt.hist(img.ravel(), bins=256, range=(0, 255)) plt.title("Gray Histogram of Slice") plt.savefig("hist_case001_z0100.png", dpi=150)这一招在批量检查时特别有用。我在实际项目中写过一个简单脚本,自动统计所有切片灰度图的均值、方差,把异常值打印出来,很快就发现有两个case的方向矩阵和别人不一样,导致切片后图像是翻转的。直方图和叠加图配合起来,能过滤掉绝大多数低级错误。
4.3 批量生成缩略拼图,快速浏览整个case
单个slice检查没问题之后,还可以把一个case的所有切片拼成一张大图,快速确认该case从z轴看过去的整体结构是否连续。肝肿瘤在连续切片上应该是有逻辑的渐进变化,如果中间突然出现大片缺失或异常形状,那就要回头检查是不是某个切片保存出了问题。
imgs = [] for z in range(0, img_arr.shape[0], 3): # 每隔3张取一张,拼图不至于太密 imgs.append(Image.open(f"output/images/case001_z{z:04d}.png")) concat = Image.new("RGB", (512 * 8, 512 * (len(imgs) // 8 + 1)), 0) for i, im in enumerate(imgs): concat.paste(im.convert("RGB"), (512 * (i % 8), 512 * (i // 8))) concat.save("case001_contact_sheet.png")这种方法适合做一次性全量巡检,比单张看效率高得多。
5. 切片实操中的几个隐蔽坑,每一个都让我返工过
5.1 方向矩阵和spacing不一致导致的翻转错位
LiTS数据虽然是公开数据集,但不是所有case都用完全相同的扫描参数。SimpleITK的ReadImage会保留origin、direction、spacing这些元信息,GetArrayFromImage返回的数组已经按固定约定排列成了(z, y, x)。大部分情况下直接切没问题,但有个别case的direction矩阵不是单位矩阵,保存出来的png在临床上可能是左右翻转的。
我的建议是切片前统一检查一下img.GetDirection(),如果和标准的(1, 0, 0, 0, 1, 0, 0, 0, 1)不一致,先做一次sitk.DICOMOrient把图像转到统一方向,再转numpy数组。这一步能避免很多看起来莫名其妙的不对齐问题。
5.2 mask插值问题:resize时的最近邻原则
如果切片后还需要统一resize到256x256或512x512,有一个铁律:图像可以用双线性或双三次插值,mask绝对只能用最近邻插值。原因很简单,双线性插值会在类别边界产生“0.5”这种中间灰度值,比如肝脏和肿瘤交界处可能会出现像素值为1.5的伪标签,保存成uint8后变成1或2,但位置和形状已经错了。最近邻插值不会产生新数值,能严格保住边界。
用PIL的resize时,图像用Image.BILINEAR,mask必须用Image.NEAREST。如果用opencv,对应的是cv2.INTER_LINEAR和cv2.INTER_NEAREST。这个细节不知道坑了多少人。
5.3 空标签切片要不要保留:处理类别不平衡的第一道关
LiTS原始数据里有很多slice是完全不含肝脏和肿瘤的,比如扫描范围覆盖了腹部下方或肺部区域。如果把这些空标签切片全部保留,训练时背景类别的像素数量会占绝对主导,模型很容易收敛到“全预测为背景”,肿瘤根本学不出来。
常见处理方案有两个:一是直接丢弃不含肝脏的切片,只保留mask中前景像素数大于某个阈值(比如50)的slice;二是保留但下采样——把所有空标签切片随机抽取10%~20%,其余丢弃。第二种方案的好处是模型能看到正常解剖结构,降低假阳性。具体选哪种,取决于你的任务目标是器官定位还是肿瘤精细分割。
我实际操作时,会在切片循环里加这样一句:
slice_mask = mask_arr[z].astype(np.uint8) if (slice_mask > 0).sum() < 50: continue # 丢弃几乎不含前景的切片5.4 切片后数据集与训练框架对接时的格式约定
切片保存成png之后,很多训练框架默认会用三通道RGB加载图像,但CT灰度图是单通道,如果不做处理,加载进来会是三通道重复的灰度图,模型输入层得改成in_channels=1。另外,标签图不能做归一化时用ImageNet的mean/std,那是针对自然图像的统计值,对CT图不适用。正确做法是输入图归一化到[0,1],mask保持0/1/2整数,Loss里用CrossEntropyLoss时忽略ignore_index=-1,或者直接计算三类的交叉熵。
还有一个小建议:mask保存时最好压缩成png格式,不要用jpg,jpg是有损压缩,会在类别边界产生伪影。我见过有人为了省空间把标签图存成jpg,结果边界处的类别全花了,重新切片才救回来。png虽然大一点,但无损且完全够用。
6. 一次完整的切片质量检查流程
最后分享一个我目前项目里固定的检查流程,每一步都要过,确认没问题再进入训练环节。
第一步,随机抽5个case,每个case抽3张slice,做单张叠加可视化,人工确认肝脏边界和肿瘤边界是否贴合。这一步解决“切出来但切错了”的问题。
第二步,检查保存的mask中每个类别的像素统计。先验证肿瘤区域一定落在肝脏区域内,也就是类别2的像素位置,在mask里同时满足“该位置类别不为0,且属于肝脏语义区域”。这个可以通过简单逻辑实现:如果存在像素值为2但周围没有类别1的区域,说明标注被切碎了,多半是空间对齐出了问题。
第三步,做一次train/val split后,把两个集合里所有mask中类别像素比例分别统计出来,确保肿瘤类别在两个集合中都有足够的样本量。如果验证集里恰好没有几张带肿瘤的切片,后面的评估指标会很难看,调模型时也感觉像盲人摸象。
最后再啰嗦一点,LiTS的数据量看起来很大,但真正含肿瘤的切片只占一小部分。切片时做好质量控制、空标签过滤、类别统计,比后面在Loss和网络结构上花心思解决类别不平衡,要省力得多。毕竟数据决定上限,模型只是逼近这个上限的方式。
本文还有配套的精品资源,点击获取