
前言
最近在复现一篇肺结节分割的论文(nnU-Net在LUNA16上的实验),需要从LIDC-IDRI原始数据开始构建训练集。论文里一笔带过的"数据预处理"四个字,实际操作起来全是坑------XML标注解析、四医师共识掩码、HU值窗口、层厚异质性......每个环节都能卡你半天。
特别声明:本文修正了网上大多数教程中"掩码未重采样导致训练失效"的致命Bug,并提供了物理空间精确对齐的正负样本均衡方案。代码全部可运行,环境是单卡RTX 3090。
一、LIDC-IDRI 数据集速览
先放一张核心信息表,后续所有操作都围绕这些参数展开:
| 属性 | 值 |
|---|---|
| 患者数 | 1,010名 |
| CT扫描数 | 1,018例(8名患者各含2次扫描) |
| DICOM图像总数 | 244,527张 |
| 数据大小 | ~133 GB(DICOM)+ 8.62 MB(XML标注) |
| 标注方式 | 4位放射科医师两阶段独立标注(盲读→非盲复核) |
| ≥3mm结节数 | 2,669个(≥1位医师标记)/ 928个(4/4医师一致) |
| 数据格式 | DICOM(影像)+ XML(标注) |
| 许可证 | CC BY 3.0(可商用) |
| 访问方式 | TCIA注册后免费下载,无需DUA |
LIDC-IDRI最独特的地方在于:四位医师的标注被完整保留,不强制共识。这意味着你可以研究观察者间变异------这在其他医学影像数据集里几乎没有。2,669个≥3mm结节中,只有34.7%被四位医师全部标记,这个数字直接决定了你的模型上限。
二、环境准备
2.1 硬件需求
| 任务 | GPU显存 | 训练时间(参考) |
|---|---|---|
| 结节分割(3D U-Net) | ≥24 GB | 48-72小时 |
| 结节分类(3D ResNet) | ≥12 GB | 12-24小时 |
| 数据预处理(CPU) | 不需要GPU | 2-4小时 |
我的配置:RTX 3090(24GB)+ 64GB内存 + 500GB SSD。133GB的DICOM数据建议放SSD上,机械硬盘读取速度会拖慢整个预处理流程。
2.2 依赖安装
bash
# 创建conda环境
conda create -n lidc python=3.10
conda activate lidc
# 核心依赖
pip install torch torchvision # PyTorch 2.x
pip install pylidc # LIDC-IDRI专用数据加载库
pip install pydicom # DICOM文件读取
pip install SimpleITK # 医学图像处理(重采样)
pip install monai # 医学影像深度学习框架(用于正负均衡采样)
pip install numpy pandas matplotlib scipy
pip install scikit-image
2.3 pylidc配置
pylidc是这个数据集的灵魂工具,它封装了XML标注解析、结节聚类、共识掩码生成等所有繁琐操作。安装后需要配置数据路径:
bash
# 创建配置文件
mkdir -p ~/.pylidcrc
cat > ~/.pylidcrc << 'EOF'
[dicom]
path = /path/to/your/LIDC-IDRI
warn = True
EOF
path指向你下载的LIDC-IDRI根目录(包含LIDC-IDRI-0001/、LIDC-IDRI-0002/等子目录的那个文件夹)。
三、数据获取
3.1 下载方式
LIDC-IDRI托管在TCIA(The Cancer Imaging Archive)上,有三种下载方式:
| 方式 | 工具 | 适用场景 |
|---|---|---|
| NBIA Data Retriever | 官方GUI客户端 | 全量下载(推荐) |
| TCIA REST API | 命令行/脚本 | 选择性下载 |
| 第三方镜像 | Kaggle / OpenDataLab | 国内用户加速 |
国内用户直接从TCIA下载133GB会很慢,建议用OpenDataLab镜像或者Kaggle上的社区子集。
3.2 验证数据完整性
下载完成后,先验证一下数据是否完整:
python
import pylidc as pl
# 扫描所有患者,构建本地数据库
pl.scan_all()
# 查询患者数量
scans = pl.query(pl.Scan).all()
print(f"总扫描数: {len(scans)}") # 应该是1018
# 查看第一个扫描的基本信息
scan = scans[0]
print(f"患者ID: {scan.patient_id}")
print(f"切片数: {scan.slice_zvals.shape[0]}")
print(f"层厚: {scan.slice_thickness:.2f} mm")
print(f"像素间距: {scan.pixel_spacing:.3f} mm")
print(f"标注医师数: {len(scan.reads)}") # 应该是4
四、数据加载与探索
4.1 理解标注结构
LIDC-IDRI的标注逻辑是理解整个数据集的关键:
1个CT扫描 (Scan)
└── 4位医师的读取会话 (Read)
└── 每位医师独立标记的结节 (Annotation)
├── ≥3mm结节: 有逐层轮廓 + 9维特征评分
└── <3mm结节: 仅有质心坐标
9维特征评分包括:精细度、内部结构、钙化、球形度、边缘、分叶、毛刺、纹理、恶性概率。每个维度1-5分(内部结构1-4,钙化1-6)。
4.2 pylidc基本操作与共识掩码生成
python
import pylidc as pl
from pylidc.utils import consensus
import numpy as np
scan = pl.query(pl.Scan).first()
clusters = scan.cluster_annotations()
# 获取第一个结节团
cluster = clusters[0]
print(f"该结节被 {len(cluster)} 位医师标记")
# ===== 关键:共识掩码生成 =====
# level=0.5 表示:某像素被≥50%的医师标记为结节,则该像素为前景
mask, padding = consensus(cluster, level=0.5)
z_min, z_max, y_min, y_max, x_min, x_max = padding
print(f"掩码形状: {mask.shape}")
print(f"在原始CT中的物理坐标范围: {padding}")
五、数据预处理Pipeline(修正核心)
这是全文最关键的修正。原版常见的错误是:只对CT图像做了重采样,而对掩码(Mask)直接丢弃或简单缩放,导致送入网络的图像和标签Z轴维度不匹配,模型永远无法收敛。
正确逻辑:先将局部共识掩码填充到一个与原始CT同尺寸的全零矩阵中,然后对图像和掩码同时进行重采样,确保两者在物理空间上完美对齐。
python
import numpy as np
import SimpleITK as sitk
import pylidc as pl
from pylidc.utils import consensus
class LIDCPreprocessor:
"""LIDC-IDRI 数据预处理管道(物理空间精确对齐版)"""
def __init__(self, target_spacing=(1.0, 1.0, 1.0),
hu_min=-1000, hu_max=400):
"""
Args:
target_spacing: 重采样目标间距 (mm), 各向同性1mm
hu_min: HU窗口下限(肺窗)
hu_max: HU窗口上限
"""
self.target_spacing = np.array(target_spacing, dtype=np.float32)
self.hu_min = hu_min
self.hu_max = hu_max
def _resample_pair(self, volume, mask_full, scan):
"""
对图像(线性插值)和掩码(最近邻插值)同时进行各向同性重采样
返回维度严格一致的 (img_resampled, mask_resampled)
"""
# 原始spacing: SimpleITK内部顺序是 (X, Y, Z)
orig_spacing = np.array([
scan.pixel_spacing,
scan.pixel_spacing,
scan.slice_thickness
], dtype=np.float32)
# 计算目标尺寸 [X, Y, Z]
resize_factor = orig_spacing / self.target_spacing
new_size = np.round(np.array(volume.shape[::-1]) * resize_factor).astype(np.int64).tolist() # [X, Y, Z]
# --- 1. 重采样图像 (线性插值) ---
img_sitk = sitk.GetImageFromArray(volume.astype(np.float32))
img_sitk.SetSpacing(orig_spacing.tolist())
resampler = sitk.ResampleImageFilter()
resampler.SetSize(new_size)
resampler.SetOutputSpacing(self.target_spacing.tolist())
resampler.SetInterpolator(sitk.sitkLinear)
resampler.SetOutputOrigin(img_sitk.GetOrigin())
resampler.SetOutputDirection(img_sitk.GetDirection())
img_res = resampler.Execute(img_sitk)
# --- 2. 重采样掩码 (最近邻插值,防止标签模糊) ---
mask_sitk = sitk.GetImageFromArray(mask_full.astype(np.uint8))
mask_sitk.SetSpacing(orig_spacing.tolist())
resampler.SetInterpolator(sitk.sitkNearestNeighbor)
mask_res = resampler.Execute(mask_sitk)
return sitk.GetArrayFromImage(img_res), sitk.GetArrayFromImage(mask_res) # 转回 (Z, Y, X)
def process_scan(self, scan, consensus_level=0.5, context_pad=15):
"""
处理单个扫描:返回结节列表,每个元素为(image_patch, mask_patch)
Args:
scan: pylidc.Scan对象
consensus_level: 共识等级 (0.5=至少50%医师一致)
context_pad: 裁剪时的额外边界体素
Returns:
list of (image_3d, mask_3d) 元组
"""
# Step 1: 获取HU值并归一化到[0,1]
volume = scan.to_volume().astype(np.float32)
volume = np.clip(volume, self.hu_min, self.hu_max)
volume = (volume - self.hu_min) / (self.hu_max - self.hu_min)
nodule_patches = []
clusters = scan.cluster_annotations()
for cluster in clusters:
if len(cluster) < 2: # 只保留≥2位医师标记的结节
continue
# Step 2: 生成原始分辨率的局部共识掩码
mask_local, padding = consensus(cluster, level=consensus_level)
z_min, z_max, y_min, y_max, x_min, x_max = padding
# Step 3: 将局部掩码放入全尺寸的零矩阵中(关键修复点!)
mask_full = np.zeros(volume.shape, dtype=np.uint8)
# 防止padding越界(极少发生,但加上安全保护)
z_min, z_max = max(0, z_min), min(volume.shape[0], z_max)
y_min, y_max = max(0, y_min), min(volume.shape[1], y_max)
x_min, x_max = max(0, x_min), min(volume.shape[2], x_max)
mask_full[z_min:z_max, y_min:y_max, x_min:x_max] = mask_local[
:z_max-z_min, :y_max-y_min, :x_max-x_min
]
# Step 4: 同步重采样图像和全尺寸掩码(物理空间对齐)
vol_res, mask_res = self._resample_pair(volume, mask_full, scan)
# Step 5: 从重采样后的掩码中动态提取边界框
coords = np.where(mask_res > 0)
if len(coords[0]) == 0:
continue # 重采样后结节消失(极小目标),跳过
z_indices, y_indices, x_indices = coords
z_min_r = max(0, z_indices.min() - context_pad)
z_max_r = min(vol_res.shape[0], z_indices.max() + context_pad)
y_min_r = max(0, y_indices.min() - context_pad)
y_max_r = min(vol_res.shape[1], y_indices.max() + context_pad)
x_min_r = max(0, x_indices.min() - context_pad)
x_max_r = min(vol_res.shape[2], x_indices.max() + context_pad)
image_patch = vol_res[z_min_r:z_max_r, y_min_r:y_max_r, x_min_r:x_max_r]
mask_patch = mask_res[z_min_r:z_max_r, y_min_r:y_max_r, x_min_r:x_max_r]
# 过滤掉尺寸太小或没有前景的无效patch
if image_patch.shape[0] >= 16 and mask_patch.sum() > 10:
nodule_patches.append((image_patch, mask_patch))
return nodule_patches
批量预处理并缓存
python
import os
import numpy as np
from tqdm import tqdm
preprocessor = LIDCPreprocessor(target_spacing=(1.0, 1.0, 1.0))
cache_dir = "/path/to/cache/lidc_processed"
os.makedirs(cache_dir, exist_ok=True)
scans = pl.query(pl.Scan).all()
for scan in tqdm(scans, desc="预处理进度"):
patient_id = scan.patient_id
cache_path = os.path.join(cache_dir, f"{patient_id}.npz")
if os.path.exists(cache_path):
continue
try:
patches = preprocessor.process_scan(scan, consensus_level=0.5)
if len(patches) > 0:
images = [p[0] for p in patches]
masks = [p[1] for p in patches]
np.savez_compressed(cache_path, images=images, masks=masks, patient_id=patient_id)
except Exception as e:
print(f"处理 {patient_id} 失败: {e}")
六、构建PyTorch Dataset(引入正负均衡采样)
这里不再使用虚假的球体掩码,而是直接加载真实的预处理好数据。同时,为了解决肺结节这种极度稀疏的小目标问题,我们引入MONAI的RandCropByPosNegLabel,确保训练时每个Batch中正负样本的比例为1:1。
python
import os
import numpy as np
import torch
from torch.utils.data import Dataset
from monai.transforms import (
Compose, RandCropByPosNegLabel, RandAffine,
RandGaussianNoise, RandFlip, ToTensor
)
class LIDCNoduleDataset(Dataset):
"""LIDC-IDRI 肺结节3D分割Dataset(带正负均衡采样)"""
def __init__(self, cache_dir, split="train",
patch_size=(48, 64, 64),
pos_ratio=0.5,
is_training=True):
"""
Args:
cache_dir: 预处理缓存目录
split: "train" / "val" / "test"
patch_size: 3D patch大小 (D, H, W)
pos_ratio: 训练时裁到正样本(结节)的概率
is_training: 是否启用数据增强
"""
self.cache_dir = cache_dir
self.patch_size = patch_size
self.is_training = is_training
# 加载所有缓存文件(此处省略按患者划分的详细代码,建议使用GroupShuffleSplit)
all_files = sorted([os.path.join(cache_dir, f) for f in os.listdir(cache_dir) if f.endswith('.npz')])
self.files = all_files # 实际使用时请替换为划分后的子集
# 建立索引:记录每个npz文件中包含的patch数量
self.sample_indices = []
for f_idx, fpath in enumerate(self.files):
with np.load(fpath, allow_pickle=True) as data:
for p_idx in range(len(data['images'])):
self.sample_indices.append((f_idx, p_idx))
# 定义数据增强流水线
if is_training:
self.transform = Compose([
# 核心:按正负标签比例随机裁剪
RandCropByPosNegLabel(
spatial_size=patch_size,
label_key='mask',
pos=pos_ratio,
neg=1-pos_ratio,
num_samples=1,
image_key='image'
),
RandAffine(prob=0.3, rotate_range=(0.1, 0.1, 0.1), scale_range=(0.1, 0.1, 0.1), mode='bilinear'),
RandFlip(prob=0.5, spatial_axis=0),
RandFlip(prob=0.5, spatial_axis=1),
RandFlip(prob=0.5, spatial_axis=2),
RandGaussianNoise(prob=0.1, mean=0.0, std=0.01),
ToTensor(keys=['image', 'mask'])
])
else:
# 验证集:只做中心裁剪或直接转为Tensor,不引入随机性
self.transform = Compose([
ToTensor(keys=['image', 'mask'])
])
def __len__(self):
# 训练时为了每个epoch有固定步数,可以设定一个较大的迭代次数
return len(self.sample_indices) if not self.is_training else 3000
def __getitem__(self, idx):
f_idx, p_idx = self.sample_indices[idx % len(self.sample_indices)]
data = np.load(self.files[f_idx], allow_pickle=True)
# 加载真实图像和掩码(都是三维数组)
image = data['images'][p_idx].astype(np.float32)
mask = data['masks'][p_idx].astype(np.float32)
# 添加通道维度 (C, D, H, W) 以适配MONAI
image = np.expand_dims(image, axis=0)
mask = np.expand_dims(mask, axis=0)
# 应用裁剪和数据增强
sample = self.transform({'image': image, 'mask': mask})
return sample['image'], sample['mask']
DataLoader配置
python
from torch.utils.data import DataLoader
train_dataset = LIDCNoduleDataset(cache_dir="/path/to/cache", split="train", patch_size=(48, 64, 64))
val_dataset = LIDCNoduleDataset(cache_dir="/path/to/cache", split="val", is_training=False)
train_loader = DataLoader(train_dataset, batch_size=4, shuffle=True, num_workers=4, pin_memory=True)
val_loader = DataLoader(val_dataset, batch_size=4, shuffle=False, num_workers=4, pin_memory=True)
print(f"训练集总patch数: {len(train_dataset)}")
七、3D分割模型基线
复现论文时不需要从头搭U-Net,用MONAI提供的现成实现即可:
python
import torch
import torch.nn as nn
from monai.networks.nets import UNet
from monai.losses import DiceLoss
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = UNet(
spatial_dims=3,
in_channels=1,
out_channels=1,
channels=(16, 32, 64, 128, 256),
strides=(2, 2, 2, 2),
num_res_units=2,
).to(device)
# 损失函数:Dice + BCE 组合
dice_loss = DiceLoss(sigmoid=True, to_onehot_y=False)
bce_loss = nn.BCEWithLogitsLoss()
def combined_loss(pred, target):
return 0.5 * dice_loss(pred, target) + 0.5 * bce_loss(pred, target)
optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5)
scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6)
训练循环(省略完整Epoch打印,与原文一致)
python
# 此处参考原文的训练循环代码,注意将loss替换为combined_loss即可
八、评估指标实现
python
from scipy.ndimage import distance_transform_edt
def dice_coefficient(pred, gt, smooth=1e-6):
pred, gt = pred.flatten(), gt.flatten()
return (2.0 * (pred * gt).sum() + smooth) / (pred.sum() + gt.sum() + smooth)
def hausdorff_distance_95(pred, gt, spacing=(1.0, 1.0, 1.0)):
if pred.sum() == 0 or gt.sum() == 0: return float('inf')
pred_surface = pred ^ distance_transform_edt(pred) > 0
gt_surface = gt ^ distance_transform_edt(gt) > 0
pred_dist = distance_transform_edt(~pred_surface, sampling=spacing)
gt_dist = distance_transform_edt(~gt_surface, sampling=spacing)
all_dist = np.concatenate([pred_dist[pred_surface], gt_dist[gt_surface]])
return np.percentile(all_dist, 95)
九、实测踩坑全记录(新增第9坑)
下面是我在复现过程中实际遇到的9个坑,每个都附了解决方案:
坑1:pylidc的scan_all()卡死或报错
现象 :长时间无响应或UnicodeDecodeError。
解决 :在~/.pylidcrc中将warn设为False;若仍报错,在WSL2环境下运行。
坑2:consensus()返回的mask与CT volume尺寸不匹配
解决 :padding变量才是坐标范围,始终用padding裁剪CT,不要假设mask尺寸。
坑3:HU值窗口选择错误导致模型不收敛
解决:肺结节必须使用肺窗(-1000到+400),代码中已标准化处理。
坑4:层厚异质性导致3D模型性能波动
解决:重采样到统一的各向同性间距(1.0mm),或者在训练时按层厚分层报告。
坑5:Reader ID不可跨扫描追踪
解决:只能做按扫描的观察者间分析,不能做跨扫描的医师级分析。
坑6:8名重复患者导致数据泄漏
解决 :必须按患者ID划分数据集,使用GroupShuffleSplit确保同一患者的所有扫描在同一子集。
坑7:<3mm微小结节和≥3mm非结节混在一起
解决 :用annotation.is_nodule属性过滤,或通过cluster_annotations()后检查轮廓是否存在。
坑8:重建核对纹理特征的影响
解决:训练前检查DICOM头中的重建核信息,按重建核分层报告性能;或只使用Standard核的数据。
坑9(新增):掩码未随图像同步重采样(致命Bug)
现象:训练Loss下降,但验证集Dice始终为0或极低,可视化发现预测和标签在Z轴完全错位。
解决:这是本文修正的核心。必须先将局部掩码填充到全尺寸矩阵中,然后对图像(线性插值)和掩码(最近邻插值)同时进行重采样,确保物理空间严格对齐。
坑10(新增):Windows环境下pydicom解码报错
现象 :jpeg_ls 或 JPEG2000 解码失败。
解决 :强烈建议直接使用WSL2(Windows Subsystem for Linux)进行开发;或在Windows下设置环境变量 os.environ['PYDICOM_USE_PYLIBJPG'] = '1'。
十、工具链总结与最终建议
| 工具 | 用途 | 必要性 |
|---|---|---|
| pylidc | LIDC数据加载、标注解析、共识掩码 | 必装 |
| SimpleITK | DICOM读取、物理空间重采样 | 必装 |
| MONAI | 3D医学影像模型、正负均衡采样、损失函数 | 强烈推荐 |
| nnU-Net | 零配置3D分割,SOTA基线 | 分割任务推荐 |
最终建议 :预处理阶段务必反复检查image_patch.shape和mask_patch.shape是否完全一致。如果在训练中Dice不涨,第一时间可视化一对(image, mask),确认结节位置是否对齐。
完整的可复现代码和更详细的观察者间变异分析,可以参考千方病案AI Ready数据集百科中关于LIDC-IDRI的数据就绪度评估页面(https://www.qianfanghub.com/ai-ready-dataset/lidc-idri/19 ),里面整理了更全面的31个坑点排查表,做论文实验时查起来比较方便。