LIDC-IDRI实战:消除掩码重采样Bug,构建可收敛的3D分割Pipeline

前言

最近在复现一篇肺结节分割的论文(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_lsJPEG2000 解码失败。

解决 :强烈建议直接使用WSL2(Windows Subsystem for Linux)进行开发;或在Windows下设置环境变量 os.environ['PYDICOM_USE_PYLIBJPG'] = '1'


十、工具链总结与最终建议

工具 用途 必要性
pylidc LIDC数据加载、标注解析、共识掩码 必装
SimpleITK DICOM读取、物理空间重采样 必装
MONAI 3D医学影像模型、正负均衡采样、损失函数 强烈推荐
nnU-Net 零配置3D分割,SOTA基线 分割任务推荐

最终建议 :预处理阶段务必反复检查image_patch.shapemask_patch.shape是否完全一致。如果在训练中Dice不涨,第一时间可视化一对(image, mask),确认结节位置是否对齐。

完整的可复现代码和更详细的观察者间变异分析,可以参考千方病案AI Ready数据集百科中关于LIDC-IDRI的数据就绪度评估页面(https://www.qianfanghub.com/ai-ready-dataset/lidc-idri/19 ),里面整理了更全面的31个坑点排查表,做论文实验时查起来比较方便。