ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LiTS数据集预处理:3D CT切片转2D分割数据集全流程

LiTS数据集预处理:3D CT切片转2D分割数据集全流程 简介面向医学影像分割研究者的肝肿瘤CT切片数据集基于LiTS公开数据从131个病例中沿冠状面切出2D图像并剔除ROI占比不足5%的低信息量切片适合用于训练和评估肝脏及肿瘤分割模型。资源共2000个文件主体为1998张PNG格式图像与对应掩膜另含classes.txt类别说明和show.py可视化脚本压缩包整体674.1MB。数据按训练/测试划分训练集包含10937张图像及同名mask测试集含4686组mask灰度为0背景、1肝脏、2肿瘤阈值图可直接作为分割标签。配套的Python可视化脚本可快速叠加展示原图与掩膜便于核查标注质量或生成论文示例图。目前已有1111人学习下载适合医学图像处理入门及肝肿瘤分割模型复现。 拿到LiTSLiver 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 volumeshape大致是(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类别数据集”我建议保留三份信息mask0背景、1肝脏、2肿瘤供多类别任务训练foreground0背景、1前景肝脏肿瘤合并供二分类或先定位肝脏用tumor_only0背景、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_okTrue) os.makedirs(OUTPUT_MASK, exist_okTrue) WINDOW_MIN, WINDOW_MAX -200, 250 for idx in range(1, 132): img_path os.path.join(INPUT_DIR, fvolume-{idx}.nii) mask_path os.path.join(INPUT_DIR, fsegmentation-{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, fcase{idx:03d}_z{z:04d}.png)) Image.fromarray(slice_mask).save( os.path.join(OUTPUT_MASK, fcase{idx:03d}_z{z:04d}_mask.png)) if idx % 20 0: print(fprocessed {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), dtypenp.float32) mask np.array(Image.open(output/masks/case001_z0100_mask.png), dtypenp.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, cmapgray) axes[0].set_title(Original) axes[1].imshow(mask, cmapgray, vmin0, vmax2) axes[1].set_title(Mask) axes[2].imshow(overlay.astype(np.uint8)) axes[2].set_title(Overlay) plt.savefig(check_case001_z0100.png, dpi150, bbox_inchestight)这段代码把类别1肝脏染成偏红色的半透明效果类别2肿瘤染成亮黄色。看叠加结果时重点检查两件事一是mask边界是否贴合组织边缘二是肿瘤区域是否落在肝脏内部。如果肿瘤标注飘到肝脏外面再往后做的一切工作都是错的。4.2 用灰度直方图辅助检查CT值分布是否正常除了视觉叠加还可以从CT值分布上做快速体检。正常情况下肝脏区域的灰度值集中在40~60HU映射后对应一个中等亮度的峰肿瘤区域略低背景空气接近0或纯黑。如果某张图的直方图完全没有任何组织峰全是黑白两极分布大概率是窗口参数写错了或者读到了错误的文件。plt.hist(img.ravel(), bins256, range(0, 255)) plt.title(Gray Histogram of Slice) plt.savefig(hist_case001_z0100.png, dpi150)这一招在批量检查时特别有用。我在实际项目中写过一个简单脚本自动统计所有切片灰度图的均值、方差把异常值打印出来很快就发现有两个case的方向矩阵和别人不一样导致切片后图像是翻转的。直方图和叠加图配合起来能过滤掉绝大多数低级错误。4.3 批量生成缩略拼图快速浏览整个case单个slice检查没问题之后还可以把一个case的所有切片拼成一张大图快速确认该case从z轴看过去的整体结构是否连续。肝肿瘤在连续切片上应该是有逻辑的渐进变化如果中间突然出现大片缺失或异常形状那就要回头检查是不是某个切片保存出了问题。imgs [] for z in range(0, img_arr.shape[0], 3): # 每隔3张取一张拼图不至于太密 imgs.append(Image.open(foutput/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.BILINEARmask必须用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_channels1。另外标签图不能做归一化时用ImageNet的mean/std那是针对自然图像的统计值对CT图不适用。正确做法是输入图归一化到[0,1]mask保持0/1/2整数Loss里用CrossEntropyLoss时忽略ignore_index-1或者直接计算三类的交叉熵。还有一个小建议mask保存时最好压缩成png格式不要用jpgjpg是有损压缩会在类别边界产生伪影。我见过有人为了省空间把标签图存成jpg结果边界处的类别全花了重新切片才救回来。png虽然大一点但无损且完全够用。6. 一次完整的切片质量检查流程最后分享一个我目前项目里固定的检查流程每一步都要过确认没问题再进入训练环节。第一步随机抽5个case每个case抽3张slice做单张叠加可视化人工确认肝脏边界和肿瘤边界是否贴合。这一步解决“切出来但切错了”的问题。第二步检查保存的mask中每个类别的像素统计。先验证肿瘤区域一定落在肝脏区域内也就是类别2的像素位置在mask里同时满足“该位置类别不为0且属于肝脏语义区域”。这个可以通过简单逻辑实现如果存在像素值为2但周围没有类别1的区域说明标注被切碎了多半是空间对齐出了问题。第三步做一次train/val split后把两个集合里所有mask中类别像素比例分别统计出来确保肿瘤类别在两个集合中都有足够的样本量。如果验证集里恰好没有几张带肿瘤的切片后面的评估指标会很难看调模型时也感觉像盲人摸象。最后再啰嗦一点LiTS的数据量看起来很大但真正含肿瘤的切片只占一小部分。切片时做好质量控制、空标签过滤、类别统计比后面在Loss和网络结构上花心思解决类别不平衡要省力得多。毕竟数据决定上限模型只是逼近这个上限的方式。本文还有配套的精品资源点击获取
返回列表