1. 认识LiTS:别把医学影像当成普通图片
LiTS这个肝肿瘤分割挑战数据集,在医学CT分割领域几乎是绕不开的入门选择。很多人第一次用PyTorch跑它,数据下载下来就懵了:不是一张张PNG/JPG,而是一堆.nii.gz文件,用PIL.Image.open()直接报错;就算用nibabel读出来,shape又是三维的(512, 512, 400)这种体数据,和数据集的标注对不上。这篇文章就从“读取数据集”这件事出发,把LiTS的文件格式、标签语义、Dataset写法、DataLoader调参和预处理细节完整拆一遍。适合正在做肝肿瘤分割、或者想自己写一套医学影像3D数据加载管线的同学参考。
先说结论:LiTS本质上是一堆CT体数据,标准的加载方式不是“读图片”,而是“读一个3D数组,再按你的模型输入去切”。想跑通它,绕不开NIfTI格式、体素间距、HU值归一化这些概念。
1.1 数据集构成与目录结构
LiTS的训练集包含约130例腹部CT增强扫描,每例是一整个3D体数据,由几百张轴向切片堆叠而成,尺寸通常是512 x 512 x N,其中N根据扫描范围不同,大概在300到600之间。公共测试集约70例,因为标注不公开,大家平时做模型验证都是自己从训练集里按病例划分,很少真的去用官方测试集。
下载解压后,常见的目录结构是这样:
lits/ ├── train/ │ ├── volume-0.nii.gz │ ├── segmentation-0.nii.gz │ ├── volume-1.nii.gz │ ├── segmentation-1.nii.gz │ └── ...有些镜像站或预处理版本会直接给.npy,但绝大多数原始数据还是NIfTI。命名可能不同,比如volume-X.nii和seg-X.nii,不过规律都一样:每个病例一个图像文件、一个标注文件,文件名里的编号对应同一个患者。
一个很容易踩的坑是:所有CT值和标注值都不能直接被matplotlib当作普通灰度图显示。CT值是亨氏单位(HU),背景空气大约是-1000,骨骼能到上千。直接imshow一张切片,看起来要么黑成一团,要么白花花一片。这提醒我们,读取数据之后的第一件事一定是“把CT值转换到模型能理解的范围内”,而不是先急着喂网络。
1.2 标签语义:0、1、2到底代表什么
LiTS的标注文件是3D标签体,每个体素的取值只有三个:0代表背景,1代表肝脏实质,2代表肝肿瘤。这里有个非常经典的误解:很多人以为标签1表示“整个肝脏区域”,于是在算肝脏分割指标时直接对mask == 1做Dice。实际上在原始标注里,类别是互斥的,肿瘤体素的标签是2,肝实质体素的标签才是1。
所以:
- 如果你做的是“全肝脏分割”,标签应该取
mask > 0,把1和2合并; - 如果你做的是“肝肿瘤分割”,标签只取
mask == 2; - 如果你做的是“肝脏+肿瘤多类别分割”,才保留0、1、2三类原样。
这三个需求对应三种不同的标签预处理,开始写代码之前必须先想清楚。否则模型训练完了,后处理阶段才发现标签定义理解错了,整个实验等于白做。
另外,某些病例里可能没有肿瘤标注,只有肝脏标注;一些病例的标注在边缘切片上会突然消失。这些数据分布问题也会影响后续的采样策略,在后面章节会具体说。
2. 读取方案怎么定:先想清楚这三件事
写Dataset之前,我建议先回答三个问题:数据能否全量放内存?是直读NIfTI还是先转格式?Dataset类的边界在哪里?这三个问题决定了你的数据管线是稳定高效,还是动不动就卡死。
2.1 全量进内存还是边读边用
一个LiTS体积数据如果是512 x 512 x 400,用float32存储,单单图像就是512 x 512 x 400 x 4字节,约400MB。标注用uint8大约100MB。130个训练病例全部常驻内存,至少需要60GB以上。这还没算数据增强、patch复制、模型梯度占用的显存和内存。普通单机训练机器基本扛不住。
所以正确的思路是:不要一次性把所有病例读进内存,而是按需加载,或者用内存映射。我的做法是先将每个病例离线转成.npy,再用np.load(path, mmap_mode='r')读取。这样显存和内存都能维持在一个可控水平,切片级别的随机访问也非常快。
2.2 NIfTI直读还是离线转格式
两种方案在工程上都可行,但适用场景不一样:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 在线直读NIfTI | 代码简单,不用额外转换;affine和spacing信息完整 | 每个epoch反复解压,I/O开销大;nibabel的get_fdata()会把整个体数据读进内存 | 调试阶段、小数据量、临时跑通 |
| 离线转npy/npz | 读取快,支持mmap,训练稳定 | 占用额外磁盘,转换脚本要维护 | 正式训练、多次实验 |
| 离线切成2D切片 | 直观,方便接2D网络 | 丢失z方向上下文,无法用于3D模型;切片数量巨大 | 2D baseline、快速验证 |
我自己的经验是:第一次跑通可以用直读,但一旦准备正式训练,立刻写转换脚本把每个病例的image和label都转成.npy,同时把spacing等元信息存到JSON里。后面所有实验都基于npy,速度提升非常明显。
2.3 Dataset类的职责边界
torch.utils.data.Dataset只负责一件事:根据索引返回一个样本对(image_tensor, label_tensor)。不要在里面做batch拼接,也不要在里面做太重的分布式采样逻辑。DataLoader负责shuffle、batch、多进程读取,collate_fn只在需要自定义拼接方式时才动它。
很多初学者喜欢在Dataset里直接调用增强库、又做随机裁剪又做随机缩放,结果每个worker里都缓存了一堆中间结果,内存爆炸。我的建议是:Dataset里只做“加载、预处理、采样、基本变换”,数据增强放到独立的transform函数里,保持结构清晰,也方便复现。
3. 写一个能跑通的LiTS Dataset
下面直接给一套可用的实现。先给基础版:按2D切片读取,适合CT影像的2D分割。再给3D patch采样版,适合UNet这类3D模型。
3.1 基础实现:逐切片读取
import os import numpy as np import torch from torch.utils.data import Dataset import nibabel as nib from functools import lru_cache @lru_cache(maxsize=8) def load_case(vol_path, seg_path): """读取图像和标注,只保留数组。maxsize控制缓存病例数。""" vol = nib.load(vol_path).get_fdata().astype(np.float32) seg = nib.load(seg_path).get_fdata().astype(np.uint8) return vol, seg class LiTSSliceDataset(Dataset): def __init__(self, vol_dir, seg_dir, clip_lower=-200, clip_upper=200, need_norm=True): self.vol_dir = vol_dir self.seg_dir = seg_dir self.clip_lower = clip_lower self.clip_upper = clip_upper self.need_norm = need_norm vol_files = sorted(os.listdir(vol_dir)) seg_files = sorted(os.listdir(seg_dir)) self.samples = [] for vf, sf in zip(vol_files, seg_files): # 先用nibabel读header,拿shape,不加载数据 v_shape = nib.load(os.path.join(vol_dir, vf)).shape s_shape = nib.load(os.path.join(seg_dir, sf)).shape if v_shape != s_shape: raise ValueError(f"{vf} and {sf} shape mismatch: {v_shape} vs {s_shape}") depth = v_shape[-1] # 假设最后一维是z方向 for d in range(depth): self.samples.append((vf, sf, d)) def __len__(self): return len(self.samples) def __getitem__(self, idx): vf, sf, d = self.samples[idx] vol_path = os.path.join(self.vol_dir, vf) seg_path = os.path.join(self.seg_dir, sf) vol, seg = load_case(vol_path, seg_path) img = vol[:, :, d] label = seg[:, :, d] # CT值clip到窗宽窗位范围 img = np.clip(img, self.clip_lower, self.clip_upper) if self.need_norm: img = (img - self.clip_lower) / (self.clip_upper - self.clip_lower) img_t = torch.from_numpy(img).unsqueeze(0).float() label_t = torch.from_numpy(label).long() return img_t, label_t这段代码有几个关键点:
lru_cache把最近读过的8个病例缓存住,避免每个切片都重新解压整个NIfTI,训练速度能快很多。__init__里只读shape,不真正加载数值。如果一次性把所有数据读入,内存立刻爆掉。- 返回的图片shape是
(1, H, W),给2D卷积直接用;标签是(H, W),nn.CrossEntropyLoss可以直接吃long类型的label。
不过这个版本有个明显问题:上面全靠“最后一维是z方向”的假设。实际上NIfTI的存储方向和组织不一定完全一致,最好在离线预处理阶段把体积数据和标注统一重采样成相同方向,这样后面就不用反复操心。
3.2 3D Patch采样:怎么避开空白区域
LiTS的肿瘤区域往往只占整个体积的很小一部分,如果按整图训练,背景类占绝对主导,模型会严重偏向预测背景。3D分割里更常见的做法是随机裁剪3D patch,并且让patch中心有较大概率落在肝脏或肿瘤区域。
核心思路:
class LiTSPatchDataset(Dataset): def __init__(self, vol_dir, seg_dir, patch_size=(96, 96, 96), foreground_ratio=0.8): self.patch_size = patch_size self.foreground_ratio = foreground_ratio self.cases = ... # 保存所有case的路径 # 先把所有case id存起来 self.case_ids = [] def __getitem__(self, idx): # 随机选一个case case_id = np.random.randint(len(self.case_ids)) vol_path = ... seg_path = ... vol, seg = load_case(vol_path, seg_path) shape = np.array(vol.shape) patch = np.array(self.patch_size) # 保证patch不会超出边界 valid_coords = np.argwhere(seg > 0) if len(valid_coords) > 0 and np.random.rand() < self.foreground_ratio: # 以前景体素为中心 center = valid_coords[np.random.randint(len(valid_coords))] else: # 随机中心 center = np.random.randint(0, shape, size=3) center = np.clip(center, patch // 2, shape - patch // 2) start = center - patch // 2 end = start + patch img_patch = vol[start[0]:end[0], start[1]:end[1], start[2]:end[2]] seg_patch = seg[start[0]:end[0], start[1]:end[1], start[2]:end[2]] # 后续归一化、增强、转tensor ...有了patch采样,一个batch里每个样本都是固定大小的3D块,数据分布也更合理。注意start要确保是整数,center边界要clip,否则很容易出现索引越界。
3.3 增强和归一化的顺序别搞反
很多教程把增强直接写在Dataset里,而且做的是“整张图归一化之后,再对每个patch做一次min-max”。这里有个隐蔽问题:同一个病例的不同patch,因为灰度分布不同,会被归一化到不同的亮度范围,模型学到的对比度关系就乱了。
正确顺序应该是:
- 先做全局归一化。如果是用
clip + 固定公式,那所有patch用的是同一套映射,没问题。 - 再做空间增强,比如随意翻转、旋转、缩放。对
label要用最近邻插值。 - 最后做强度扰动,比如亮度、对比度、高斯噪声,这些放在归一化之后会更安全。
一句话总结:凡是用到全局统计量的操作,尽量放在采样之前;凡是对单个patch独立的随机变换,放在归一化之后。
4. 预处理细节:CT值、重采样和Mask对齐
4.1 为什么必须做体素间距重采样
LiTS来自多个医疗中心,扫描协议不同,体素间距不完全一致。常见的有0.6 x 0.6 x 1.0mm、0.7 x 0.7 x 1.5mm等,甚至z轴层厚能达到3mm。如果不重采样,同一个肿瘤在一个病例里占10x10x10体素,在另一个病例里占30x30x30体素,模型会把“物理尺寸”和“像素数量”混淆,感受野对应的实际空间范围不一致,分割精度会明显受影响。
我的惯例是把所有训练和测试数据统一重采样到1.0 x 1.0 x 1.0mm。使用SimpleITK实现:
import SimpleITK as sitk def resample_image_to_spacing(image_sitk, new_spacing=(1.0, 1.0, 1.0), is_label=False): resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(new_spacing) resampler.SetOutputDirection(image_sitk.GetDirection()) resampler.SetOutputOrigin(image_sitk.GetOrigin()) orig_spacing = image_sitk.GetSpacing() orig_size = image_sitk.GetSize() new_size = [ int(round(orig_size[i] * orig_spacing[i] / new_spacing[i])) for i in range(3) ] resampler.SetSize(new_size) if is_label: resampler.SetInterpolator(sitk.sitkNearestNeighbor) else: resampler.SetInterpolator(sitk.sitkLinear) return resampler.Execute(image_sitk)给image用线性插值,给label用最近邻插值,这条规则在3D分割里永远不要违反。最近邻能保证标签数值仍然是0、1、2,不会出现“插值出来一个0.7的标签”这种荒谬结果。
这里有个额外坑:SimpleITK.GetSize()返回的顺序是(x, y, z),而numpy数组的shape是(z, y, x)。用GetArrayFromImage转数组时要意识到坐标轴顺序的区别,否则重采样后的数据和mask再回存时,很容易出现转置错位。
4.2 CT值归一化:窗宽窗位和z-score
CT值的原始单位是HU,直接作为神经网络输入不太合适。常见两步走:先clip到某个组织窗口,再做线性归一化或z-score。
肝脏增强CT常用的窗位在40~60HU,窗宽在300~400HU。以窗位50、窗宽350为例,对应的动态范围是[50-175, 50+175],也就是[-125, 225]。我实验里比较省心的做法是先clip到[-200, 200],再线性拉伸到[0,1],这个范围在大多数公开腹部CT数据上表现稳定。
如果要用z-score,注意不要直接在原始CT上算均值和方差,因为大片的空气背景(-1000HU)会把均值拉低、方差拉高,统计量失去意义。更稳妥的是在clip之后、且最好只在前景区域计算均值和方差,或者对全clip后的体积计算。如果嫌麻烦,统一用固定窗口线性归一化也不会差。
4.3 Mask对齐:任何变换都要最近邻
标签和图像必须经历完全相同的空间变换,否则坐标一错,全是无效监督。这个原理大家都懂,实际操作里仍然容易翻车,尤其是用torchvision.transforms时。
举个具体例子:RandomAffine默认用双线性插值,直接用在label上会生成一堆中间值,比如0.2、1.7。所以要么自己对label单独设置最近邻插值,要么干脆不用torchvision的仿射变换,改用scipy.ndimage或SimpleITK对image和label做同一套参数的空间变换。
最保险的做法是:先拿到旋转矩阵或偏移量,再分别对image和label执行变换,其中label强制order=0。这个细节值得单独写成一个函数,不要每次手写。
5. DataLoader带来的坑与排查
5.1 num_workers不是越大越好
num_workers开8、16听起来很快,但在LiTS这类大体积数据上容易适得其反。每个worker会复制一份进程内存,如果缓存了病例数组,内存会成倍上涨。此外,频繁的磁盘I/O和NIfTI解压反而会成为瓶颈。
我的建议是:
- 调试阶段
num_workers=0,方便打断点; - 正式训练从
num_workers=4开始,观察内存和CPU占用再往上加; - 如果机器内存紧张,优先保证
lru_cache的maxsize不要太大,比如2或4。
另外,pin_memory=True在GPU训练时能减少数据传输的等待时间,但这个设置只在你真正用GPU训练时才有意义。
5.2 显存不够时应该怎么办
很多同学跑3D UNet,batch size设成4,把一张12GB的显卡直接打爆。LiTS的单个体数据太大,3D模型的显存压力主要集中在patch size和batch size上。
一般做法是把patch尺寸降到64或96,batch size降到1或2,先用梯度累积模拟大batch。如果还不行,再考虑:
- 用混合精度训练,显存占用能减少约40%;
- 把一部分层放到CPU执行,虽然慢但能跑;
- 减少通道数,例如把UNet的第一层通道数从64降到32。
这些属于训练层面的调整,但错误根源往往是数据读取时没有控制patch大小,所以数据管线设计时要提前留好这个开关。
5.3 我遇到过的几个典型报错
FileNotFoundError: volume-10.nii.gz
这通常不是文件真的不存在,而是os.listdir后字符串排序的问题。"volume-10"会排在"volume-2"前面。解决方法是提取数字编号后排序:
import re def numeric_key(filename): match = re.search(r"(\d+)", filename) return int(match.group(1)) if match else -1 files = sorted(os.listdir(dir_name), key=numeric_key)shape mismatch
图像和标注shape对不上。有些数据源的segmentation文件多了一个channel维度,比如(512, 512, 400, 1),用np.squeeze去掉;还有的标注只有一部分切片,需要按病例单独检查。DataLoader worker exited unexpectedly
多半是内存不够、文件句柄占用过多或/dev/shm不足。先降num_workers,再检查是否有NIfTI文件句柄没有被关闭。nibabel.load只是打开文件对象,如果反复加载不释放,文件描述符会耗尽。用lru_cache缓存并限制缓存数量能缓解。全部label都是0
如果切片集中在体积的上下边缘,很可能都是背景。要么在构建样本列表时过滤掉完全没有前景的切片,要么在loss里给前景类更高权重。我习惯在__getitem__里不做过滤,而是在构建self.samples时只保留seg.sum() > 0的切片,这样能大幅减少无效训练。
LiTS的读取整个链路其实不复杂,复杂的是所有细节相互咬合。文件格式、归一化、重采样、采样策略、DataLoader参数,每一个环节出问题,表现出的症状都像“模型训练效果差”,但根子可能全在数据读取上。我个人踩过几次坑之后的习惯是:拿到一个新数据集,先写一个离线转换脚本,把NIfTI转成规范化后的npy,同时把spacing、label分布、病例编号都记录清楚。后续所有实验都在这套干净的数据上跑,省心得多。按照这个流程把LiTS跑通之后,换到其他CT影像数据也只需要改改路径和类别数,整套数据管线可以直接复用。