220GB 数据、32735 个病灶:DeepLesion 泛器官 CT 检测 PyTorch 从零实战

前言

此前在复现 3DCE(MICCAI 2018)的实验时,需要用 DeepLesion 数据集训练一个通用病灶检测器。说实话这个数据集比我之前做的 LIDC-IDRI 麻烦得多------LIDC 是 CT 影像 + XML 标注,结构清晰;DeepLesion 是 16-bit PNG 切片 + CSV 元数据,而且 220GB 的数据量、PACS 书签挖掘来的"不完整标注"、以及那个反直觉的文件路径命名规则,每个都能让你卡上一整天。

这篇文章记录我从零搭建 DeepLesion 检测 Pipeline 的完整过程:数据下载 → DL_info.csv 解析 → HU 值恢复 → 多窗口预处理 → 3D 上下文加载 → PyTorch Dataset → Faster R-CNN 基线 → FROC 评估。代码全部可运行,单卡 RTX 3090 环境。


一、DeepLesion 数据集速览

属性
病灶总数 32,735 个标注
CT 关键切片 32,120 张(含3D上下文共约92.8万张PNG,社区估算值)
患者数 4,427 名
数据大小 ~220 GB(56个zip压缩包)
数据格式 16-bit PNG(512×512)+ CSV(DL_info.csv)
标注方式 PACS RECIST 临床书签自动挖掘(非人工专门标注)
病灶覆盖 全身8个解剖分区(肺/肝/骨/肾/纵隔/腹部/软组织/盆腔)
许可证 无限制使用
访问方式 NIH Box 直接下载,无需注册
官方划分 患者级 70/15/15(train/val/test)

DeepLesion 最颠覆认知的一点:它的标注不是专门找人标的,而是从放射科医生近 20 年的日常 PACS 书签里自动挖出来的。医生阅片时用 RECIST 方式测量病灶直径(长径+短径),这些记录被系统性地提取为 2D 边界框。好处是零额外标注成本、规模大;代价是标注不完整------CT 上可见的病灶不一定都被标了,训练时会引入大量"假阳性"。

如果只做肺结节检测,LIDC-IDRI/LUNA16 更合适。DeepLesion 的核心价值是泛器官通用检测------一个模型同时检出全身所有类型病灶。


二、环境准备

2.1 硬件需求

任务 GPU显存 训练时间(参考)
Faster R-CNN(3通道输入) ≥12 GB 2-4小时/epoch
3DCE(27切片输入) ≥24 GB 6-8小时/epoch
数据预处理(CPU) 不需要GPU 4-8小时(全量解压+缓存)

磁盘方面:220GB 压缩包解压后 ~250GB,预处理缓存再占 ~100GB,建议预留 500GB+ 空间。

2.2 依赖安装

bash 复制代码
conda create -n deeplesion python=3.10
conda activate deeplesion

# 核心依赖
pip install torch torchvision  # PyTorch 2.x
pip install pandas numpy Pillow
pip install scikit-image scipy
pip install pycocotools         # COCO格式转换(可选)
pip install tensorboard         # 训练可视化

# 检测框架(二选一)
# 方案A: torchvision内置(轻量,适合快速验证)
# 方案B: mmdetection(功能完整,适合刷榜)

2.3 官方工具脚本

NIH 官方提供了两个关键脚本,下载后放在项目根目录:

脚本 用途
batch_download_zips.py 批量下载 56 个 zip 文件
DL_save_nifti.py 将 2D PNG 切片转换为 3D NIfTI 子卷

三、数据获取

3.1 下载策略

220GB 全量下载是最大的时间瓶颈。根据你的需求选择:

方案 大小 适用场景 来源
完整版(NIH Box) ~220 GB 正式实验/复现论文 nihcc.app.box.com/v/DeepLesion
Kaggle 子集 ~3.5 GB 快速原型验证 kaggle.com/datasets/kmader/nih-deeplesion-subset
HuggingFace 2K ~188 MB 代码调试 huggingface.co/datasets/HemanthTavva/deeplesion-balanced-2k

正式实验建议用官方的 batch_download_zips.py 多线程下载:

python 复制代码
# 官方下载脚本(简化版)
import subprocess
import os

zip_urls = [
    # 56个zip的URL列表,从readme.pdf或官方页面获取
    "https://nihcc.box.com/shared/static/xxxxx.zip",
    # ...
]

download_dir = "/data/DeepLesion/raw_zips"
os.makedirs(download_dir, exist_ok=True)

for i, url in enumerate(zip_urls):
    zip_path = os.path.join(download_dir, f"Images_png_{i+1:02d}.zip")
    if not os.path.exists(zip_path):
        subprocess.run(["wget", "-O", zip_path, url])
    # 解压后删除zip节省空间
    subprocess.run(["unzip", "-o", zip_path, "-d", "/data/DeepLesion/Images_png/"])
    os.remove(zip_path)

3.2 数据完整性验证

python 复制代码
import os
import hashlib

# 检查MD5
with open("MD5_checksums.txt") as f:
    expected_md5 = {}
    for line in f:
        parts = line.strip().split()
        if len(parts) == 2:
            expected_md5[parts[1]] = parts[0]

# 验证关键文件
check_files = ["DL_info.csv", "Images_png"]
for fname in check_files:
    fpath = os.path.join("/data/DeepLesion", fname)
    if os.path.exists(fpath):
        print(f"✓ {fname} exists")
    else:
        print(f"✗ {fname} MISSING")

四、DL_info.csv 深度解析

4.1 18列字段全览

这是整个数据集的核心元数据文件,理解每一列的含义是正确使用数据的前提:

列号 字段名 类型 说明 示例
1 File_name str 文件名,末尾下划线替换为/即得路径 000001_01_01_109.png
2 Patient_index int 患者索引 1 (1-4427)
3 Study_index int 该患者第几次检查 3 (1-26)
4 Series_ID int DICOM序列ID 1
5 Key_slice_index int 病灶所在关键切片索引 109
6 Measurement_coordinates str RECIST双径坐标(8D) 233.5,95.0,...
7 Bounding_boxes str 2D边界框x1,y1,x2,y2 226.1,90.0,241.3,112.0
8 Lesion_diameters_Pixel str 长轴和短轴像素长度 11.97,5.10
9 Normalized_lesion_location str 归一化位置(x,y,z) 0.447,0.284,0.434
10 Coarse_lesion_type int 粗粒度类型(训练集全为-1,验证/测试集为1-8) -11-8
11 Possibly_noisy int 是否可能为噪声标注 01
12 Slice_range str 3D上下文切片范围 103,115
13 Spacing_mm_px str xyz物理间距(mm/pixel) 0.488,0.488,5
14 Image_size str 图像尺寸(宽×高) 512,512
15 DICOM_windows str 窗宽窗位(HU) -175,275
16 Patient_gender str 性别 FM
17 Patient_age float 年龄 62
18 Train_Val_Test int 官方划分 1=train, 2=val, 3=test

4.2 关键字段解析与避坑

python 复制代码
import pandas as pd
import numpy as np

df = pd.read_csv("/data/DeepLesion/DL_info.csv")
print(f"总行数: {len(df)}")  # 应为 32,735

# ===== 坑1:文件名路径解析 =====
# File_name: "000001_01_01_109.png"
# 实际路径:  Images_png/000001_01_01/109.png
# 最后一个下划线替换为 /
def parse_file_path(file_name):
    """将File_name转换为实际文件路径"""
    parts = file_name.rsplit('_', 1)  # ['000001_01_01', '109.png']
    folder = parts[0]
    filename = parts[1]
    return os.path.join("Images_png", folder, filename)

# 验证
sample_path = parse_file_path(df.iloc[0]['File_name'])
print(f"解析路径: {sample_path}")

# ===== 坑2:边界框解析 =====
def parse_bbox(bbox_str):
    """解析Bounding_boxes字符串为numpy数组"""
    return np.array([float(x) for x in bbox_str.split(',')], dtype=np.float32)

# ===== 坑3:RECIST坐标解析(8D → 边界框) =====
def parse_recist_to_bbox(meas_coords_str):
    """从RECIST测量坐标提取边界框
    坐标格式: [x11,y11,x12,y12, x21,y21,x22,y22]
    前4个: 长径两端点, 后4个: 短径两端点
    """
    coords = np.array([float(x) for x in meas_coords_str.split(',')])
    xs = coords[[0, 2, 4, 6]]  # 所有x坐标
    ys = coords[[1, 3, 5, 7]]  # 所有y坐标
    bbox = np.array([xs.min(), ys.min(), xs.max(), ys.max()], dtype=np.float32)
    return bbox

# ===== 切片范围解析 =====
def parse_slice_range(slice_range_str):
    """解析3D上下文切片范围"""
    start, end = map(int, slice_range_str.split(','))
    return list(range(start, end + 1))

# ===== 间距解析 =====
def parse_spacing(spacing_str):
    """解析物理间距 [x, y, z] mm/pixel"""
    return np.array([float(x) for x in spacing_str.split(',')], dtype=np.float32)

4.3 数据分布统计

python 复制代码
# 官方划分分布
split_counts = df['Train_Val_Test'].value_counts().sort_index()
print("=== 官方数据划分 ===")
for split, count in split_counts.items():
    name = {1: "Train", 2: "Val", 3: "Test"}.get(split, "Unknown")
    print(f"  {name}: {count} 病灶")

# 粗粒度类型分布(仅val/test有标注)
print("\n=== 病灶类型分布(val+test)===")
val_test = df[df['Train_Val_Test'].isin([2, 3])]
type_names = {1:"Bone", 2:"Abdomen", 3:"Mediastinum", 4:"Liver",
              5:"Lung", 6:"Kidney", 7:"Soft Tissue", 8:"Pelvis"}
type_counts = val_test['Coarse_lesion_type'].value_counts().sort_index()
for t, c in type_counts.items():
    if t != -1:
        print(f"  {t} ({type_names.get(t, '?')}): {c}")

# 病灶直径统计
diameters = df['Lesion_diameters_Pixel_'].apply(
    lambda x: float(x.split(',')[0])
)
print(f"\n=== 病灶长径统计(像素)===")
print(f"  均值: {diameters.mean():.1f}")
print(f"  中位数: {diameters.median():.1f}")
print(f"  范围: {diameters.min():.1f} - {diameters.max():.1f}")

# 噪声标注
noisy = df[df['Possibly_noisy'] == 1]
print(f"\n=== 噪声标注: {len(noisy)} 处 ===")

五、数据预处理Pipeline

5.1 核心预处理逻辑

DeepLesion 的预处理有两个最关键的点:

  1. HU 值恢复:PNG 存储的 16-bit 像素值需要减去 32768 才能获得真实 HU 值
  2. 多窗口策略:不同病灶类型需要不同的 CT 窗宽窗位,单一窗口会让部分病灶"看不见"
python 复制代码
import os
import numpy as np
import pandas as pd
from PIL import Image

class DeepLesionPreprocessor:
    """DeepLesion 数据预处理管道"""

    def __init__(self, img_root, csv_path,
                 use_multi_window=True,
                 context_slices=3):
        """
        Args:
            img_root: Images_png 根目录
            csv_path: DL_info.csv 路径
            use_multi_window: 是否使用三通道多窗口输入
            context_slices: 3D上下文切片数(关键切片两侧各取N层)
        """
        self.img_root = img_root
        self.df = pd.read_csv(csv_path)
        self.use_multi_window = use_multi_window
        self.context_slices = context_slices

    def load_hu(self, png_path):
        """加载16-bit PNG并转换为HU值
        关键: pixel_value - 32768 = HU
        """
        pixel = np.array(Image.open(png_path), dtype=np.float32)
        hu = pixel - 32768.0
        return hu

    def apply_window(self, hu, center, width):
        """应用CT窗宽窗位归一化到[0,1]"""
        w_min = center - width / 2
        w_max = center + width / 2
        hu_clipped = np.clip(hu, w_min, w_max)
        return (hu_clipped - w_min) / (w_max - w_min)

    def get_multi_window_image(self, hu):
        """三通道多窗口: 软组织窗 + 肺窗 + 骨窗
        这是3DCE论文中验证有效的策略,不同窗口凸显不同组织
        """
        # 软组织窗: 中心40, 宽度400
        soft = self.apply_window(hu, center=40, width=400)
        # 肺窗: 中心-600, 宽度1500
        lung = self.apply_window(hu, center=-600, width=1500)
        # 骨窗: 中心400, 宽度1800
        bone = self.apply_window(hu, center=400, width=1800)
        # 堆叠为三通道 (3, H, W)
        return np.stack([soft, lung, bone], axis=0)

    def get_single_window_image(self, hu, window_str=None):
        """单通道: 使用CSV中记录的DICOM窗宽窗位"""
        if window_str:
            center, width = map(float, window_str.split(','))
        else:
            center, width = 40, 400  # 默认软组织窗
        return self.apply_window(hu, center, width)

    def load_3d_context(self, row):
        """加载关键切片及其3D上下文
        返回: (num_slices, H, W) 的HU值堆叠
        """
        fname = row['File_name']
        folder = fname.rsplit('_', 1)[0]
        key_slice = int(fname.rsplit('_', 1)[1].replace('.png', ''))

        # 从Slice_range获取完整范围
        slice_start, slice_end = map(int, row['Slice_range'].split(','))

        # 如果只需要少量上下文(如3DCE用3/9/27切片)
        # 从关键切片两侧均匀采样
        if self.context_slices > 0:
            all_slices = list(range(slice_start, slice_end + 1))
            key_idx = all_slices.index(key_slice) if key_slice in all_slices else len(all_slices) // 2
            # 均匀采样 context_slices*2+1 个切片
            n_needed = self.context_slices * 2 + 1
            if len(all_slices) >= n_needed:
                indices = np.linspace(
                    max(0, key_idx - self.context_slices * 3),
                    min(len(all_slices) - 1, key_idx + self.context_slices * 3),
                    n_needed, dtype=int
                )
                selected = [all_slices[i] for i in indices]
            else:
                selected = all_slices
        else:
            selected = [key_slice]

        # 加载切片
        volumes = []
        for s in selected:
            png_path = os.path.join(self.img_root, folder, f"{s}.png")
            if os.path.exists(png_path):
                hu = self.load_hu(png_path)
                volumes.append(hu)
            else:
                # 文件缺失时用关键切片填充
                key_path = os.path.join(self.img_root, folder, f"{key_slice}.png")
                if os.path.exists(key_path):
                    volumes.append(self.load_hu(key_path))

        if len(volumes) == 0:
            return None

        return np.stack(volumes, axis=0)  # (D, H, W)

    def process_row(self, row):
        """处理单行数据: 返回(image, bbox, meta)
        image: (C, D, H, W) 或 (D, H, W)
        bbox: [x1, y1, x2, y2] 像素坐标
        """
        # 加载3D上下文
        volume = self.load_3d_context(row)
        if volume is None:
            return None

        # 多窗口或单窗口预处理
        if self.use_multi_window:
            # 对每个切片应用多窗口
            processed = []
            for i in range(volume.shape[0]):
                multi = self.get_multi_window_image(volume[i])  # (3, H, W)
                processed.append(multi)
            image = np.stack(processed, axis=1)  # (3, D, H, W)
        else:
            window_str = row.get('DICOM_windows', None)
            image = np.stack([
                self.get_single_window_image(volume[i], window_str)
                for i in range(volume.shape[0])
            ], axis=0)  # (D, H, W)

        # 解析边界框
        bbox = parse_bbox(row['Bounding_boxes'])

        # 元数据
        spacing = parse_spacing(row['Spacing_mm_px_'])
        meta = {
            'patient_id': row['Patient_index'],
            'spacing': spacing,
            'lesion_type': row['Coarse_lesion_type'],
            'diameters': parse_bbox(row['Lesion_diameters_Pixel_']),
            'is_noisy': row['Possibly_noisy'],
            'key_slice_idx': int(row['Key_slice_index']),
        }

        return image, bbox, meta

5.2 批量预处理与缓存

python 复制代码
import os
import numpy as np
from tqdm import tqdm

preprocessor = DeepLesionPreprocessor(
    img_root="/data/DeepLesion/Images_png",
    csv_path="/data/DeepLesion/DL_info.csv",
    use_multi_window=True,
    context_slices=3  # 3DCE默认: 关键切片±3层 = 7切片
)

cache_dir = "/data/DeepLesion/cache"
os.makedirs(cache_dir, exist_ok=True)

df = preprocessor.df

for idx in tqdm(range(len(df)), desc="预处理"):
    row = df.iloc[idx]
    patient_id = row['Patient_index']
    split = row['Train_Val_Test']
    cache_path = os.path.join(cache_dir, f"split{split}", f"patient_{patient_id}", f"lesion_{idx:06d}.npz")
    os.makedirs(os.path.dirname(cache_path), exist_ok=True)

    if os.path.exists(cache_path):
        continue

    try:
        result = preprocessor.process_row(row)
        if result is not None:
            image, bbox, meta = result
            np.savez_compressed(
                cache_path,
                image=image.astype(np.float16),  # fp16节省空间
                bbox=bbox.astype(np.float32),
                meta=str(meta)
            )
    except Exception as e:
        print(f"处理 lesion_{idx} 失败: {e}")

六、构建 PyTorch Dataset

6.1 检测任务 Dataset

DeepLesion 的核心任务是2D目标检测(边界框回归),即使使用了3D上下文,检测仍然在关键切片的2D平面上进行:

python 复制代码
import os
import json
import numpy as np
import pandas as pd
import torch
from torch.utils.data import Dataset
from PIL import Image

class DeepLesionDetectDataset(Dataset):
    """DeepLesion 病灶检测Dataset(2D + 3D上下文)"""

    def __init__(self, csv_path, img_root, split="train",
                 use_cache=True, cache_dir=None,
                 context_slices=3, use_multi_window=True,
                 transform=None):
        """
        Args:
            csv_path: DL_info.csv路径
            img_root: Images_png根目录
            split: "train" / "val" / "test"
            use_cache: 是否使用预处理缓存
            cache_dir: 缓存目录
            context_slices: 3D上下文切片数
            use_multi_window: 多窗口三通道输入
            transform: 数据增强
        """
        self.df = pd.read_csv(csv_path)
        split_map = {'train': 1, 'val': 2, 'test': 3}
        self.df = self.df[self.df['Train_Val_Test'] == split_map[split]].reset_index(drop=True)

        # 过滤噪声标注(测试集建议保留以保持官方一致性)
        if split == "train":
            self.df = self.df[self.df['Possibly_noisy'] == 0].reset_index(drop=True)

        self.img_root = img_root
        self.use_cache = use_cache
        self.cache_dir = cache_dir
        self.context_slices = context_slices
        self.use_multi_window = use_multi_window
        self.transform = transform

    def __len__(self):
        return len(self.df)

    def __getitem__(self, idx):
        row = self.df.iloc[idx]

        # 解析文件路径
        fname = row['File_name']
        folder = fname.rsplit('_', 1)[0]
        key_slice_name = fname.rsplit('_', 1)[1]

        # ===== 加载图像 =====
        if self.use_cache and self.cache_dir:
            # 从缓存加载
            split_num = row['Train_Val_Test']
            patient_id = row['Patient_index']
            cache_path = os.path.join(
                self.cache_dir, f"split{split_num}",
                f"patient_{patient_id}", f"lesion_{idx:06d}.npz"
            )
            if os.path.exists(cache_path):
                data = np.load(cache_path, allow_pickle=True)
                image = data['image'].astype(np.float32)
                bbox = data['bbox']
            else:
                image, bbox = self._load_from_raw(row)
        else:
            image, bbox = self._load_from_raw(row)

        # ===== 构建目标标注 =====
        target = {
            'boxes': torch.from_numpy(bbox).float().unsqueeze(0),  # (1, 4)
            'labels': torch.ones(1, dtype=torch.long),  # 只有一类: lesion
            'image_id': torch.tensor([idx]),
        }

        # ===== 数据增强 =====
        if self.transform:
            image, target = self.transform(image, target)

        # 确保维度: (C, H, W) 或 (C, D, H, W)
        if image.ndim == 2:
            image = image[np.newaxis, ...]  # (1, H, W)
        elif image.ndim == 3 and image.shape[0] not in [1, 3]:
            # (D, H, W) → 取关键切片作为2D输入
            key_idx = image.shape[0] // 2
            image = image[key_idx][np.newaxis, ...]

        return image, target

    def _load_from_raw(self, row):
        """从原始PNG文件加载"""
        fname = row['File_name']
        folder = fname.rsplit('_', 1)[0]
        key_slice = int(fname.rsplit('_', 1)[1].replace('.png', ''))

        # 加载关键切片
        png_path = os.path.join(self.img_root, folder, f"{key_slice}.png")
        pixel = np.array(Image.open(png_path), dtype=np.float32)
        hu = pixel - 32768.0

        # 多窗口或单窗口
        if self.use_multi_window:
            soft = self._apply_window(hu, 40, 400)
            lung = self._apply_window(hu, -600, 1500)
            bone = self._apply_window(hu, 400, 1800)
            image = np.stack([soft, lung, bone], axis=0)  # (3, H, W)
        else:
            window_str = row.get('DICOM_windows', '-175,275')
            center, width = map(float, window_str.split(','))
            image = self._apply_window(hu, center, width)[np.newaxis, ...]

        # 解析bbox
        bbox = np.array([float(x) for x in row['Bounding_boxes'].split(',')],
                       dtype=np.float32)

        return image, bbox

    def _apply_window(self, hu, center, width):
        w_min = center - width / 2
        w_max = center + width / 2
        return np.clip((hu - w_min) / (w_max - w_min), 0, 1).astype(np.float32)

6.2 DataLoader 配置

python 复制代码
from torch.utils.data import DataLoader

def collate_fn(batch):
    """自定义collate: 目标检测任务需要变长目标列表"""
    images = []
    targets = []
    for img, tgt in batch:
        images.append(img)
        targets.append(tgt)
    images = torch.stack(images)
    return images, targets

train_dataset = DeepLesionDetectDataset(
    csv_path="/data/DeepLesion/DL_info.csv",
    img_root="/data/DeepLesion/Images_png",
    split="train",
    use_multi_window=True,
)

val_dataset = DeepLesionDetectDataset(
    csv_path="/data/DeepLesion/DL_info.csv",
    img_root="/data/DeepLesion/Images_png",
    split="val",
    use_multi_window=True,
)

train_loader = DataLoader(
    train_dataset, batch_size=8, shuffle=True,
    num_workers=4, pin_memory=True, collate_fn=collate_fn
)

val_loader = DataLoader(
    val_dataset, batch_size=8, shuffle=False,
    num_workers=4, pin_memory=True, collate_fn=collate_fn
)

print(f"训练集: {len(train_dataset)} 样本")
print(f"验证集: {len(val_dataset)} 样本")

# 验证一个batch
for images, targets in train_loader:
    print(f"Image batch: {images.shape}")  # (8, 3, 512, 512)
    print(f"First target boxes: {targets[0]['boxes']}")
    print(f"First target labels: {targets[0]['labels']}")
    break

七、Faster R-CNN 基线模型

7.1 使用 torchvision 内置实现

快速验证版本------将关键切片复制为3通道输入(模拟3切片上下文),用标准 Faster R-CNN:

python 复制代码
import torch
import torch.nn as nn
from torchvision.models.detection import fasterrcnn_resnet50_fpn
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor

def build_model(num_classes=2, pretrained=True):
    """
    构建Faster R-CNN模型
    num_classes=2: 背景(0) + 病灶(1)
    """
    # 加载COCO预训练模型
    model = fasterrcnn_resnet50_fpn(pretrained=pretrained)

    # 替换分类头(COCO 91类 → 2类)
    in_features = model.roi_heads.box_predictor.cls_score.in_features
    model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)

    return model

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = build_model(num_classes=2).to(device)

# 优化器
params = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.SGD(params, lr=0.005, momentum=0.9, weight_decay=0.0005)
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.1)

7.2 训练循环

python 复制代码
from tqdm import tqdm
import time

num_epochs = 50

for epoch in range(num_epochs):
    model.train()
    total_loss = 0.0
    n_batches = 0

    pbar = tqdm(train_loader, desc=f"Epoch {epoch+1}/{num_epochs}")
    for images, targets in pbar:
        images = [img.to(device) for img in images]
        targets = [{k: v.to(device) for k, v in t.items()} for t in targets]

        # Faster R-CNN内置loss计算
        loss_dict = model(images, targets)
        loss = sum(loss for loss in loss_dict.values())

        optimizer.zero_grad()
        loss.backward()
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0)  # 梯度裁剪
        optimizer.step()

        total_loss += loss.item()
        n_batches += 1
        pbar.set_postfix({'loss': f'{loss.item():.4f}'})

    scheduler.step()
    avg_loss = total_loss / n_batches

    # 验证
    if (epoch + 1) % 5 == 0:
        model.eval()
        val_scores = []
        with torch.no_grad():
            for images, targets in tqdm(val_loader, desc="Validating"):
                images = [img.to(device) for img in images]
                outputs = model(images)
                val_scores.append(outputs)

        print(f"\nEpoch {epoch+1}: avg_loss={avg_loss:.4f}, lr={scheduler.get_last_lr()[0]:.6f}")

    # 保存checkpoint
    if (epoch + 1) % 10 == 0:
        torch.save({
            'epoch': epoch,
            'model_state_dict': model.state_dict(),
            'optimizer_state_dict': optimizer.state_dict(),
            'loss': avg_loss,
        }, f"checkpoint_epoch{epoch+1}.pth")

print("训练完成")

八、FROC 评估实现

DeepLesion 官方使用 FROC(Free-response ROC)作为核心指标------在给定每张图像假阳性数(FPs/image)下的灵敏度。标准评估点为 0.5, 1, 2, 4, 8, 16 FPs/image:

python 复制代码
import numpy as np
from collections import defaultdict

def compute_iou(box1, box2):
    """计算两个边界框的IoU"""
    x1 = max(box1[0], box2[0])
    y1 = max(box1[1], box2[1])
    x2 = min(box1[2], box2[2])
    y2 = min(box1[3], box2[3])
    inter = max(0, x2 - x1) * max(0, y2 - y1)
    area1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
    area2 = (box2[2] - box2[0]) * (box2[3] - box2[1])
    union = area1 + area2 - inter
    return inter / union if union > 0 else 0

def evaluate_froc(model, data_loader, device,
                  iou_threshold=0.5,
                  fp_rates=[0.5, 1, 2, 4, 8, 16]):
    """
    计算FROC灵敏度

    Args:
        model: 训练好的检测模型
        data_loader: 测试集DataLoader
        iou_threshold: IoU阈值判定TP/FP
        fp_rates: 评估的FP率列表

    Returns:
        sensitivities: {fp_rate: sensitivity}
    """
    model.eval()

    all_predictions = []  # [(score, is_tp), ...]
    total_gt = 0
    n_images = 0

    with torch.no_grad():
        for images, targets in tqdm(data_loader, desc="FROC评估"):
            images = [img.to(device) for img in images]
            outputs = model(images)

            for i, output in enumerate(outputs):
                pred_boxes = output['boxes'].cpu().numpy()
                pred_scores = output['scores'].cpu().numpy()
                gt_boxes = targets[i]['boxes'].cpu().numpy()

                total_gt += len(gt_boxes)
                n_images += 1

                # 按分数降序排列预测
                order = np.argsort(-pred_scores)
                matched_gt = set()

                for idx in order:
                    score = pred_scores[idx]
                    pred_box = pred_boxes[idx]

                    # 检查是否匹配任意GT
                    max_iou = 0
                    best_gt = -1
                    for j, gt_box in enumerate(gt_boxes):
                        if j in matched_gt:
                            continue
                        iou = compute_iou(pred_box, gt_box)
                        if iou > max_iou:
                            max_iou = iou
                            best_gt = j

                    is_tp = max_iou >= iou_threshold
                    if is_tp and best_gt >= 0:
                        matched_gt.add(best_gt)

                    all_predictions.append((score, is_tp))

    # 按分数降序排列所有预测
    all_predictions.sort(key=lambda x: -x[0])

    # 累积TP和FP
    tp_cumsum = 0
    fp_cumsum = 0
    sensitivities = {}

    for fp_rate in fp_rates:
        target_fp = fp_rate * n_images
        tp_count = 0
        fp_count = 0

        for score, is_tp in all_predictions:
            if is_tp:
                tp_count += 1
            else:
                fp_count += 1

            if fp_count >= target_fp:
                break

        sensitivities[fp_rate] = tp_count / total_gt if total_gt > 0 else 0.0

    # 打印结果
    print("\n===== FROC 评估结果 =====")
    print(f"总GT病灶数: {total_gt}")
    print(f"总图像数: {n_images}")
    for fp_rate, sens in sensitivities.items():
        print(f"  {fp_rate} FPs/image: 灵敏度 = {sens:.4f} ({sens*100:.2f}%)")

    avg_sens = np.mean(list(sensitivities.values()))
    print(f"\n  平均灵敏度: {avg_sens:.4f}")

    return sensitivities

8.1 对标3DCE基线

3DCE 原始论文报告的 FROC 结果(注意:原始论文使用 5 FPs/image 作为主指标)供对比参考:

FPs/image 0.5 1 2 4 5 8 16
3DCE (27 slices) --- --- --- --- 81.1% --- ---

补充:部分后续改进工作(如引入自监督预训练)在 4 FPs/image 下可达到 85% 以上,但 3DCE 原始论文的官方基线为 81.1% @ 5 FPs/image。如果你的模型在这个点上明显低于该值,优先检查预处理(尤其是 HU 值恢复和多窗口策略)。


九、实测踩坑全记录

坑1:像素值未减32768导致HU值错误(致命)

现象:训练 loss 异常高,模型完全不收敛。可视化输入图像发现全是亮白色,组织对比度消失。

原因:DeepLesion 的 PNG 以 uint16 存储,像素范围 0, 65535。真实 HU 值 = 像素值 - 32768。如果不做这一步,所有 HU 值整体偏移 32768,窗宽窗位归一化完全失效。

解决

python 复制代码
# 错误写法(直接归一化像素值)
# img_normalized = pixel / 65535.0  # ← 绝对不要这样做

# 正确写法
hu = pixel.astype(np.float32) - 32768.0
img_normalized = apply_window(hu, center=40, width=400)

坑2:文件名路径拼接错误

现象FileNotFoundError,或者加载到了错误的图像。

原因File_name 格式为 000001_01_01_109.png,但实际文件结构是 000001_01_01/109.png------最后一个下划线要替换为路径分隔符。

解决 :用 rsplit('_', 1) 正确切分:

python 复制代码
folder = fname.rsplit('_', 1)[0]      # "000001_01_01"
filename = fname.rsplit('_', 1)[1]     # "109.png"
path = os.path.join(img_root, folder, filename)

坑3:训练集无病灶类型标签(官方设计如此)

现象 :想在训练集上做多类检测,发现 Coarse_lesion_type 列全是 -1。

原因:这是数据集的设计决策------粗粒度类型标注(8类)只在验证集和测试集上有,训练集不提供,以避免训练时的标注偏见。

解决:三种策略:

  1. 纯检测任务不受影响(只用边界框,不需要类型标签)
  2. 需要分类的话,加载 LesaNet 的 171 类语义标签(GitHub 上的 JSON 文件)
  3. 或者只在 val/test 上评估分类性能

坑4:标注不完整导致假阳性虚高

现象:模型 FROC 性能远低于论文报告值,但可视化检查发现很多"假阳性"实际上是真实病灶------只是没被 PACS 书签标注。

原因:PACS 书签只标记了代表性病灶(医生觉得有临床意义的),CT 上可见的其他病灶没有被标注。训练时这些未标注病灶会被当作背景,模型学到"不要检测它们",评估时这些又被算作 FP。

解决

  1. 使用 FROC 而非 mAP(FROC 对不完整标注更鲁棒)
  2. 考虑使用 Lesion-Harvester 迭代挖掘未标注病灶
  3. 在论文中明确讨论"不完整标注"对性能评估的影响
  4. 训练时使用 OHEM(Online Hard Example Mining)时需要特别小心------未标注病灶会被当作困难负样本

坑5:3D上下文切片的层间距各向异性

现象:用 3DCE 方案(多切片输入)时,某些患者效果极差。

原因:DeepLesion 的 z 轴层间距从 1mm 到 5mm 不等,直接堆叠相邻切片做 3D 卷积会导致各向异性------z 方向的物理间距远大于 xy 方向。

解决

  1. 2.5D 方法(如 3DCE 的做法):将 3D 上下文切片作为输入通道,不做 3D 卷积
  2. 如果要做真正的 3D 卷积,先按 Spacing_mm_px_ 的 z 值做 z 轴插值重采样
  3. 按层间距分组报告性能(5mm 层厚的 CT 性能天然低于 1mm)

坑6:部分书签测量的是正常结构

现象:模型在正常大小的淋巴结区域反复产生假阳性,而且训练 loss 降不下去。

原因 :少量 PACS 书签实际测量的是正常结构(如正常大小的淋巴结),不是病灶。Possibly_noisy 字段标记了 35 处已知噪声,但实际噪声量可能更高。

解决

  1. 训练集过滤 Possibly_noisy == 1 的样本
  2. 考虑过滤短径 < 10mm 的标注(可能是正常结构)
  3. 使用标签平滑(label smoothing)或噪声鲁棒损失函数

坑7:窗宽窗位选择不当

现象:某些病灶类型检测性能异常低------比如肺结节在软组织窗下几乎不可见。

原因 :DeepLesion 的 DICOM_windows 列记录了每张图像的原始窗宽窗位,但不同部位的 CT 使用不同窗口(肺窗 vs 软组织窗 vs 骨窗),单一窗口无法覆盖所有病灶类型。

解决:多窗口策略------将同一张 CT 分别用三种窗口归一化,堆叠为三通道输入:

通道 窗位 窗宽 凸显组织
Ch0 40 400 软组织(肝/肾/纵隔)
Ch1 -600 1500 肺(肺结节/肺转移)
Ch2 400 1800 骨(骨转移/硬化性病变)

这是 3DCE 论文中验证有效的策略,能显著提升多类型病灶的综合检测性能。


十、工具链与模型选型总结

工具/框架 用途 必要性
pandas DL_info.csv 解析 必装
Pillow 16-bit PNG 读取 必装
torchvision Faster R-CNN 基线 推荐
mmdetection 完整检测框架 进阶推荐
SimpleITK z轴重采样(3D任务) 按需
CADLab/3DCE 官方基线代码 复现3DCE必看

模型选型建议

需求 推荐模型 灵敏度(官方报告) 说明
快速验证 Faster R-CNN (3ch) ~75-80% @ 4FPs torchvision内置,1天可跑完
入门复现 3DCE (27 slices) 81.1% @ 5 FPs 官方代码开源,2-3天可复现
精度优先 P3D 88.55% @ 4FPs 自监督3D预训练,需多卡
多任务 MULAN 85.22% @ 4FPs 检测+标注+分割联合

十一、技术总结

几个关键 takeaway:

  1. HU 值恢复是第一道关 ------pixel - 32768 这一行代码错了,后面全部白搭。训练前务必可视化几个样本,确认软组织、肺、骨的对比度正常。

  2. 多窗口策略不是可选项------DeepLesion 覆盖全身 8 个解剖分区,单一窗口必然让部分病灶"隐身"。三通道多窗口输入是最小代价的提升。

  3. 标注不完整是设计特性不是 Bug------PACS 书签只标记代表性病灶,评估时需要用 FROC 而非 mAP,论文中必须讨论这个影响。

  4. 按 Patient_index 划分是铁律 ------官方已经做了患者级划分,直接用 Train_Val_Test 列即可。千万不要按图像随机 shuffle,会导致同一患者的切片跨集合泄漏。

  5. 3D 上下文用 2.5D 而非真 3D------由于层间距各向异性严重,直接 3D 卷积效果不如 2.5D(多切片作为通道输入)。3DCE 论文也验证了这一点。

完整的数据字典(18列字段详解)、DAIMS 数据就绪度评估、以及更全面的 7 个坑点排查表,可以参考千方病案 AI Ready 数据集百科的 DeepLesion 页面:https://www.qianfanghub.com/ai-ready-dataset/deeplesion/17 ------里面还包含了 LesaNet 171 类语义标签的使用方法和外部验证矩阵,做论文实验时查起来比较方便。

相关资源

对于做医疗 AI 的朋友,数据集选型往往是个痛点------不同数据集的标注标准、适用任务、获取方式分散在各处论文和存档库中,查阅成本很高。

我们把 DeepLesion 数据集以及全球主流医疗 AI 数据集(涵盖 CT、MRI、病理、超声、多模态等)整理成了 AI-Ready Dataset 条目库,以统一的 Wikipedia 式结构呈现,方便研究者快速比对和选型:

Qianfanghub AI-Ready Dataset:https://www.qianfanghub.com/ai-ready-dataset/

扩展阅读

引用数据集时请标注

  • Yan K, Wang X, Lu L, Summers RM. "DeepLesion: automated mining of large-scale lesion annotations and universal lesion detection with deep learning." J Med Imaging 5(3):036501, 2018. DOI: 10.1117/1.JMI.5.3.036501