做医学影像分割的朋友应该都绕不开LiTS——肝肿瘤分割挑战数据集。我第一次接触它是在一个腹部器官分割的练习项目里当时天真地把.nii文件读出来就直接往PyTorch的DataLoader里塞跑出来的分割图基本“一片黑”肿瘤区域的Dice掉到惨不忍睹。折腾了几天才发现问题根本不在网络结构而在于数据读取链路有太多细节没有处理到位。这篇文章就把我从LiTS原始文件到PyTorch可训练数据集的完整流程摊开讲数据集结构长什么样、NIfTI怎么读、肝脏和肿瘤标签到底怎么解析、三维CT怎么切成训练切片、DataLoader怎么配才能不拖后腿。每一步都会给出可以直接用的代码和踩坑记录。不管你是刚接触医学影像分割的新手还是想在自己框架里复现LiTS baseline的老手应该都能从里面找到有用的东西。1. 先摸清LiTS数据集的底细文件结构、标注约定和图像性质1.1 一场肝肿瘤分割挑战赛留下的宝贵资产LiTS全称是Liver Tumor Segmentation Challenge也就是肝肿瘤分割挑战赛。它提供的不是那种已经裁剪好的小图而是一百多份完整的腹部CT扫描和对应的逐体素标注。训练集和测试集加起来将近两百个病例每个病例都是一个三维体数据。每份CT扫描保存为一个NIfTI文件常见扩展名是.nii或者.nii.gz。它本质上是一个三维数组外面包了一层描述体素物理间距和空间方向的头信息。简单理解就是“一堆二维切片的堆叠”但每一层切片之间的间隔并不固定这也是后续处理里最大的坑之一。标注文件是相同尺寸的三维数组数值含义很直接0代表背景1代表肝脏区域2代表肿瘤区域。数据文件内容数据类型数值含义volume-xxx.nii腹部CT扫描int16 / float32HU值范围通常在-1024到3000segmentation-xxx.nii逐体素标注uint80背景1肝脏2肿瘤刚拿到数据时别急着写训练脚本先写一段脚本统计每个volume的shape、spacing、标注分布。我习惯把这类信息保存成一个CSV后面做训练集验证集划分、检查异常病例都会用到。尤其要关注那些标注里完全没有肿瘤的病例它们并不少见因为扫描对象可能确实没有肝脏病灶这部分数据对肿瘤分割来说是纯背景样本。1.2 标签值里藏着一个很多人会搞错的细节LiTS标签的官方说明是0为背景1为肝脏区域2为肿瘤区域。但要警惕的是肝脏分割评估时要求把肿瘤区域也算作肝脏的一部分。用大白话说语义上“肝脏”包含了“肿瘤”就像说一个器官本身包含了长在它里面的病灶一样。所以在预处理阶段你必须先把任务定义想清楚如果做的是全肝脏二分类掩码应该是label 0也就是把1和2都当作前景否则你训出来的肝脏区域会在肿瘤位置出现空洞提交到官方评测时肝脏Dice会低得离谱。如果做的是肿瘤二分类掩码就直接取label 2。如果做多类别语义分割背景、肝实质、肿瘤三类互斥那label本身已经是互斥编码了可以直接当作三分类的GT使用不需要额外转换。我见过不止一个新手直接把label当成“背景、肝脏、肿瘤”三分类标签丢给网络最后肝脏Dice虚高、肿瘤Dice飘忽不定源头就是没理解这个包含关系。建议在预处理脚本里把label 0和label 2都各存一份npy不同任务各取所需。1.3 CT图像的“脾气”HU值不是RGBLiTS的图像不是自然图像CT值的物理单位是HUHounsfield Unit空气大约是-1000水是0软组织的范围一般在-100到300之间。如果直接把原始HU值当作类似RGB的输入喂给网络模型会很难收敛因为数值范围太宽而且大量体素是毫无信息的空气。处理CT影像时常规做法是先做窗宽窗位截断把有诊断价值的HU范围保留下来超出部分直接截断。对于腹部软组织我常用的截断范围是[-200, 200]也有人用[-150, 250]区别不大。截断之后再缩放到[0, 1]区间网络输入的数据分布就会稳定很多。还有一点容易被忽略不同扫描设备的重建参数不同导致同一个器官在不同volume里的HU分布存在偏移。所以归一化尽量不要在全局范围内做mean-std而是每个volume单独做min-max缩放或z-score这样更稳健。2. 读NIfTI文件的两套方案NiBabel与SimpleITK怎么选、坐标系要注意什么2.1 NiBabel与SimpleITK的API差异读取LiTS的NIfTI文件主流方案有两个NiBabel和SimpleITK。它们都能读但返回的数组轴顺序完全相反这一点足以让很多人抓狂。import nibabel as nib import SimpleITK as sitk import numpy as np # 方式一NiBabel nii_img nib.load(volume-0.nii) data_nib nii_img.get_fdata().astype(np.float32) print(data_nib.shape) # 通常输出 (512, 512, N)也就是把z方向放在最后一个维度 # 方式二SimpleITK sitk_img sitk.ReadImage(volume-0.nii) data_sitk sitk.GetArrayFromImage(sitk_img) print(data_sitk.shape) # 通常输出 (N, 512, 512)z方向在第一个维度get_fdata()返回的数组是浮点类型内存开销比原始int16大不少但浮点精度对后续插值计算更友好。SimpleITK的GetArrayFromImage()返回的是numpy数组轴顺序是(z, y, x)和NiBabel截然相反。如果你两个库混着用一定要在代码里做一次显式的轴转换否则某个volume刚开始训练就变成“倒着”的输入。2.2 轴顺序统一比纠结坐标系更重要NIfTI文件头里藏着affine矩阵它定义了体素坐标到解剖坐标的变换。对于日常训练来说你并不需要在每个batch里都去计算世界坐标但你必须确保全流程的数组轴顺序是一致的。我个人的习惯是把所有数据统一转成(z, y, x)顺序也就是shape[0]是轴向切片方向。这样在PyTorch Dataset里想取某一层切片直接data[slice_idx]就行代码逻辑非常直观。如果你用NiBabel读出了(512, 512, N)那就transpose(2, 0, 1)如果你用SimpleITK读出了(N, 512, 512)那已经符合(z, y, x)不用动。至于affine矩阵我主要在重采样和可视化调试时使用。比如用nibabel.affine把体素坐标映射到真实空间确认某个切片大致对应人体的哪个方位。网络本身感知不到坐标但如果预处理阶段方向弄反了模型照样会学到bug。2.3 重采样不做这一步模型基本学废LiTS数据最大的问题之一是不同CT扫描的spacing差异非常大。有的层厚是0.7mm有的是3mm甚至5mm都有。如果直接把原始体素喂给网络同一个肿瘤在不同病例里占据的体素数量可能差好几倍模型学习的会是“体素数量”而不是“真实物理尺寸”泛化能力会大打折扣。重采样的目标是把所有volume统一到各向同性分辨率我通常用1mm x 1mm x 1mm。这样做的好处是后续2D切片采样时每一层切片的物理厚度一致3D网络使用各向同性卷积核也更合理。def resample_to_spacing(image_sitk, target_spacing(1.0, 1.0, 1.0), interpolatorsitk.sitkLinear, default_value-1024.0): original_spacing image_sitk.GetSpacing() original_size image_sitk.GetSize() target_size [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetSize(target_size) resampler.SetOutputSpacing(target_spacing) resampler.SetOutputOrigin(image_sitk.GetOrigin()) resampler.SetOutputDirection(image_sitk.GetDirection()) resampler.SetInterpolator(interpolator) resampler.SetDefaultPixelValue(default_value) return resampler.Execute(image_sitk)这里有一个关键提醒CT图像重采样时用线性插值或三线性插值默认填充值设为-1024空气的HU值但对应的分割标注重采样时必须用最近邻插值插值器是sitk.sitkNearestNeighbor否则标签会出现小数或非整数破坏类别语义。重采样之后我习惯再做一个背景裁剪把全是空气和床板的外围区域裁掉减少数据量。腹部CT的原始尺寸是512x512但真正有器官的部分通常集中在中间区域。裁剪到256x256甚至更小既能省内存又能让网络把注意力放在有效区域。3. 肝脏和肿瘤的标签处理多类标签构造、HU裁剪与归一化细节3.1 预处理流水线从原始NIfTI到干净npy在写Dataset之前我强烈建议把原始NIfTI做一次离线预处理保存成npy文件。这样训练过程中不再重复读取大文件和做重采样速度提升非常明显。我的预处理流水线如下用NiBabel或SimpleITK读入CT和标注。将CT和标注都转换成(z, y, x)顺序的numpy数组。对CT做spacing重采样同时对标注做最近邻重采样。对CT做HU截断clip(-200, 200)再缩放到[0, 1]。根据肝脏和肿瘤的并集区域做背景裁剪保留一定边缘。保存为image_xxx.npy和label_xxx.npy。def preprocess_volume(ct_path, seg_path, save_dir, volume_id): ct sitk.ReadImage(ct_path) seg sitk.ReadImage(seg_path) ct_resampled resample_to_spacing(ct, (1.0, 1.0, 1.0), sitk.sitkLinear) seg_resampled resample_to_spacing(seg, (1.0, 1.0, 1.0), sitk.sitkNearestNeighbor) ct_array sitk.GetArrayFromImage(ct_resampled).astype(np.float32) seg_array sitk.GetArrayFromImage(seg_resampled).astype(np.uint8) ct_array np.clip(ct_array, -200, 200) ct_array (ct_array 200) / 400.0 # 缩放到约[0,1] np.save(f{save_dir}/image_{volume_id}.npy, ct_array) np.save(f{save_dir}/label_{volume_id}.npy, seg_array)注意ct_array的z方向现在是第一维后面取切片时要保持一致。裁剪背景可以放在重采样之后、保存之前具体坐标可以通过标注的包围盒计算出来。3.2 类别不平衡到底有多严重LiTS里肿瘤区域通常只占整个CT体积的很小一部分。很多病例的肿瘤体素数只有几百到几千而整个volume的体素数量是几百万甚至上千万。如果你的训练样本直接从所有切片里均匀采样绝大多数样本都是纯背景模型很容易陷入“全预测背景”的局部最优。统计一下你的训练集有多少切片完全不包含肝脏有多少切片包含肿瘤这个比例会直接影响采样策略。以我的经验包含肿瘤的切片可能只占总切片的5%-10%这就是为什么必须在采样阶段做人为干预而不是只在loss函数里做类别权重。3.3 多类标签与Loss函数的配合如果你要做肝实质肿瘤的多类分割loss的设定也要跟着标签语义走。纯Dice Loss在多器官多类场景下容易因为小器官占比低而不稳比较稳妥的做法是Dice Loss配合加权交叉熵其中肿瘤类别的权重可以设到5甚至10。还有一个我在实际项目中用着很顺的方案把任务拆成两个二分类子任务——肝脏二分类和肿瘤二分类两个分支共享编码器。这样肿瘤分支的梯度不会被肝脏这类大器官淹没训练稳定性更好。代价是推理时多一个分支的输出但对于论文复现和工程落地来说完全可接受。4. 从三维CT到训练切片采样策略、索引表和Dataset实现4.1 为什么不能“无脑全切片”很多人第一次处理LiTS时会写一个Dataset把每个volume的所有轴向切片都当作训练样本。听起来很合理但实际训练时问题很大相邻切片之间高度相似随机打乱后的训练集实际上包含大量重复信息模型过拟合速度极快同时纯背景切片占比过高验证集的Dice指标也很容易被背景切片拉高造成“看起来很高、实际分割质量很差”的假象。更稳妥的做法是构建一个切片索引表把每个切片的类型提前标记出来再通过采样器做加权。我在预处理阶段会为每个volume的每个切片计算标注信息slice_records [] for vol_idx in range(num_volumes): label np.load(f{save_dir}/label_{vol_idx}.npy) # (z, y, x) for z in range(label.shape[0]): slice_label label[z] if (slice_label 2).any(): category tumor elif (slice_label 0).any(): category liver else: category background slice_records.append((vol_idx, z, category))这样每个样本变成了(volume_id, slice_id, category)三元组。训练时我们可以根据category做加权采样而不是简单地在DataLoader里shuffle。4.2 加权采样让模型多看肿瘤切片加权采样的核心思想很简单让肿瘤切片、肝脏切片、背景切片以合适的比例出现在每个epoch里。我这里给一组常用权值肿瘤切片权重8肝脏切片权重4背景切片权重1。具体数值需要根据你的模型和任务调整但整体思路是提高肿瘤切片的出现频率。from torch.utils.data import WeightedRandomSampler weights [] for _, _, category in slice_records: if category tumor: weights.append(8.0) elif category liver: weights.append(4.0) else: weights.append(1.0) sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue)这里有一点必须注意WeightedRandomSampler自身已经具备随机性所以在DataLoader里不要再设置shuffleTrue否则会双重打乱导致采样器的加权效果被稀释。4.3 自定义Dataset的完整骨架接下来是Dataset类的核心实现。我倾向于在__init__阶段完成索引表的构建在__getitem__里只负责加载单张切片、做裁剪和数据增强。如果设备内存足够可以把预处理好的volume全部加载到内存这样训练时的IO压力会小很多。class LiTSDataset(Dataset): def __init__(self, slice_records, save_dir, crop_size256, is_trainTrue): self.slice_records slice_records self.save_dir save_dir self.crop_size crop_size self.is_train is_train self.cache {} def _load_volume(self, vol_idx): if vol_idx not in self.cache: image np.load(f{self.save_dir}/image_{vol_idx}.npy) label np.load(f{self.save_dir}/label_{vol_idx}.npy) self.cache[vol_idx] (image, label) return self.cache[vol_idx] def __len__(self): return len(self.slice_records) def __getitem__(self, index): vol_idx, z, _ self.slice_records[index] image, label self._load_volume(vol_idx) image_slice image[z].copy() # (y, x) label_slice label[z].copy() image_slice, label_slice self._crop_or_pad(image_slice, label_slice) if self.is_train: image_slice, label_slice self._augment(image_slice, label_slice) image_tensor torch.from_numpy(image_slice).unsqueeze(0).float() label_tensor torch.from_numpy(label_slice).long() return image_tensor, label_tensor_crop_or_pad这一步我通常是随机裁剪一个crop_size区域并且裁剪中心偏向有标注的位置。对于LiTS来说因为肝脏和肿瘤主要集中在腹部中央随机裁剪其实就能覆盖大部分有效区域。如果你的数据分布比较多样可以用标注的质心引导裁剪位置让每个样本都能看到病灶。4.4 数据增强不能只对图像做掩码要同步2D切片的数据增强和自然图像很像但有一条铁律图像和标注必须做完全相同的变换。我常用的增强组合是随机水平翻转、垂直翻转随机旋转角度控制在±10度以内随机弹性形变配合掩码同步插值随机对比度、亮度扰动图像专用掩码不做def _augment(self, image, label): if random.random() 0.5: image np.flip(image, axis0) label np.flip(label, axis0) if random.random() 0.5: image np.flip(image, axis1) label np.flip(label, axis1) # 旋转、缩放等可以继续在上面扩展 return image.copy(), label.copy()对于数据增强实操体验是先做基础翻转和随机裁剪收益最高花哨的弹性形变如果实现不谨慎反而容易引入虚假模式。等基线上来了再逐步加入更复杂的增强一步步验证效果。5. DataLoader高效运转的关键多进程配置、缓存设计与样本加权5.1 多进程worker与随机种子管理PyTorch DataLoader的num_workers直接决定了数据加载速度但多进程会引入一个隐蔽问题每个worker会复制一份Dataset对象如果Dataset在__init__里做了大量文件IO每个worker都会各自加载一份内存很容易爆掉。正确的做法是在worker_init_fn里给每个worker设置独立的随机种子避免数据增强结果在所有worker之间完全一致。def worker_init_fn(worker_id): np.random.seed(np.random.get_state()[1][0] worker_id) train_loader DataLoader( train_dataset, batch_size16, samplertrain_sampler, num_workers8, pin_memoryTrue, worker_init_fnworker_init_fn, )pin_memoryTrue在GPU训练时能减少CPU到GPU的拷贝时间。如果GPU使用率总是上不去先检查是不是num_workers太少其次检查是不是数据加载时做了太多重复IO。5.2 缓存设计内存换速度但要控制上限预处理后的volume每个npy文件可能从几十MB到一两百MB不等。加载一百多个volume进内存在32GB内存的机器上压力不算小。我常用的是LRU缓存最多缓存8到10个volume配合采样器的随机性缓存命中率已经足够高。from collections import OrderedDict class VolumeCache: def __init__(self, capacity8): self.capacity capacity self.cache OrderedDict() def get(self, vol_idx): if vol_idx not in self.cache: image np.load(f{save_dir}/image_{vol_idx}.npy) label np.load(f{save_dir}/label_{vol_idx}.npy) self.cache[vol_idx] (image, label) if len(self.cache) self.capacity: self.cache.popitem(lastFalse) return self.cache[vol_idx]如果内存实在紧张还有一个办法np.load(..., mmap_moder)用内存映射的方式读取npy文件按需从磁盘加载对应切片的数据。缺点是需要自己控制好底层numpy对象的生命周期否则文件句柄容易泄漏。5.3 训练集与验证集的划分必须按volume切LiTS的131个训练病例划分验证集时绝对不能用切片维度随机切那样同一个CT的前后切片会同时出现在训练集和验证集里验证指标会虚高。正确做法是把volume级别的索引打乱比如留出10到15个volume作为验证集其余作为训练集。我在项目里通常采用90训练、10验证、1作为调试样例的比例。按volume划分之后再为验证集单独构建切片索引表。验证集不需要做加权采样也不做数据增强只做中心裁剪或者直接缩放。all_vol_ids list(range(131)) random.seed(42) random.shuffle(all_vol_ids) val_vol_ids set(all_vol_ids[:10]) train_vol_ids set(all_vol_ids[10:])随机种子固定在42保证实验结果可复现。这条经验看似基础但在LiTS这种体积大、样本量小的数据集上随机种子的选择甚至会影响最终指标波动建议尽早锁定。6. 实操复盘重采样、方向、内存三个坑以及我最终使用的稳定配置6.1 第一个坑方向装反了模型学了个寂寞最开始我用NiBabel读数据统一转成(z, y, x)但预处理时又用SimpleITK重采样直接返回了(z, y, x)顺序我一时疏忽把重采样后的数组又做了一次transpose(2, 0, 1)。结果是切片变成了“左右颠倒、轴向错乱”的输入模型跑了20个epoch也不见收敛。排查了很久才发现是轴顺序被我转了两遍。这条经验值得单独强调每一步数据处理都要打印shape和基本的可视化结果。强烈建议在预处理阶段对每个volume输出一张“中间结果示意图”至少确认三视图方向的正确性不然训练崩了都不知道源头在哪。6.2 第二个坑没有重采样肿瘤Dice一直上不去第一次跑通训练流程后我在验证集上的肿瘤Dice只有0.4左右一开始以为是网络容量不够。后来统计了验证集中病例的spacing发现很多CT的层厚在3mm到5mm之间模型看到的肿瘤形态和1mm各向同性重采样后的形态完全不是一回事。统一重采样到1mm后肿瘤Dice明显提升。重采样的另一个好处是让2D切片不再“厚薄不均”。如果你直接对原始切片做训练切片之间的物理信息不一致模型很难学到稳定的组织边界。这个教训让我把重采样设成了所有CT预处理的第一步优先级高于一切数据增强。6.3 第三个坑DataLoader内存爆炸有一版配置我把所有volume都加载进Dataset实例里num_workers设置成16结果四块GPU训练机器直接内存爆掉。原因很简单16个worker每个worker都会复制一份包含全部volume的Dataset对象内存开销直接乘了16倍。后来我改成LRU缓存控制num_workers8并用np.load(mmap_moder)兜底内存占用才稳定下来。建议你在自己的机器上先跑一个epoch看内存峰值再调参不要盲目堆worker数量。6.4 我最终采用的稳定配置参考把上面所有经验沉淀下来下面是我在LiTS相关项目里用的比较顺的一套配置不一定适合所有场景但可以作为一个起点项目配置预处理重采样到1mm各向同性HU截断[-200,200]归一化到[0,1]存储格式image_xxx.npy label_xxx.npy轴顺序统一(z, y, x)shape[0]为轴向切片方向采样策略切片索引表 WeightedRandomSampler肿瘤8:肝脏4:背景1输入尺寸随机裁剪256x256增强水平/垂直翻转、±10度旋转、对比度扰动数据加载LRU缓存8个volumenum_workers8pin_memoryTrue划分按volume切分9:1训练验证随机种子固定42Loss肿瘤分支Dice Loss 加权交叉熵肿瘤类别权重5这套配置在常规分割网络上的表现比较稳定也不至于让机器资源捉襟见肘。如果你要跑3D网络思路会不一样通常需要随机裁剪3D patch比如128x128x64并且在采样时保证每个patch包含足够的肿瘤区域。整体逻辑仍然是先统计标注分布、再设计针对性采样不管2D还是3D数据读取的底层链路是相通的。回到标题那件事用PyTorch读取LiTS数据集真的不只是写一个Dataset类那么简单从文件解析、重采样、标签语义到采样策略每一步都会影响最终分割效果。我把这些经验整理出来就是希望你在拿到LiTS之后能少踩几个我已经踩过的坑把精力真正放在网络设计和调参上。如果你按这套流程走下来再回去看最初跑出来的“一团黑”分割结果一定会有完全不同的体会。