
前言
此前在复现 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) | -1 或 1-8 |
| 11 | Possibly_noisy | int | 是否可能为噪声标注 | 0 或 1 |
| 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 | 性别 | F 或 M |
| 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 的预处理有两个最关键的点:
- HU 值恢复:PNG 存储的 16-bit 像素值需要减去 32768 才能获得真实 HU 值
- 多窗口策略:不同病灶类型需要不同的 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类)只在验证集和测试集上有,训练集不提供,以避免训练时的标注偏见。
解决:三种策略:
- 纯检测任务不受影响(只用边界框,不需要类型标签)
- 需要分类的话,加载 LesaNet 的 171 类语义标签(GitHub 上的 JSON 文件)
- 或者只在 val/test 上评估分类性能
坑4:标注不完整导致假阳性虚高
现象:模型 FROC 性能远低于论文报告值,但可视化检查发现很多"假阳性"实际上是真实病灶------只是没被 PACS 书签标注。
原因:PACS 书签只标记了代表性病灶(医生觉得有临床意义的),CT 上可见的其他病灶没有被标注。训练时这些未标注病灶会被当作背景,模型学到"不要检测它们",评估时这些又被算作 FP。
解决:
- 使用 FROC 而非 mAP(FROC 对不完整标注更鲁棒)
- 考虑使用 Lesion-Harvester 迭代挖掘未标注病灶
- 在论文中明确讨论"不完整标注"对性能评估的影响
- 训练时使用 OHEM(Online Hard Example Mining)时需要特别小心------未标注病灶会被当作困难负样本
坑5:3D上下文切片的层间距各向异性
现象:用 3DCE 方案(多切片输入)时,某些患者效果极差。
原因:DeepLesion 的 z 轴层间距从 1mm 到 5mm 不等,直接堆叠相邻切片做 3D 卷积会导致各向异性------z 方向的物理间距远大于 xy 方向。
解决:
- 2.5D 方法(如 3DCE 的做法):将 3D 上下文切片作为输入通道,不做 3D 卷积
- 如果要做真正的 3D 卷积,先按
Spacing_mm_px_的 z 值做 z 轴插值重采样 - 按层间距分组报告性能(5mm 层厚的 CT 性能天然低于 1mm)
坑6:部分书签测量的是正常结构
现象:模型在正常大小的淋巴结区域反复产生假阳性,而且训练 loss 降不下去。
原因 :少量 PACS 书签实际测量的是正常结构(如正常大小的淋巴结),不是病灶。Possibly_noisy 字段标记了 35 处已知噪声,但实际噪声量可能更高。
解决:
- 训练集过滤
Possibly_noisy == 1的样本 - 考虑过滤短径 < 10mm 的标注(可能是正常结构)
- 使用标签平滑(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:
-
HU 值恢复是第一道关 ------
pixel - 32768这一行代码错了,后面全部白搭。训练前务必可视化几个样本,确认软组织、肺、骨的对比度正常。 -
多窗口策略不是可选项------DeepLesion 覆盖全身 8 个解剖分区,单一窗口必然让部分病灶"隐身"。三通道多窗口输入是最小代价的提升。
-
标注不完整是设计特性不是 Bug------PACS 书签只标记代表性病灶,评估时需要用 FROC 而非 mAP,论文中必须讨论这个影响。
-
按 Patient_index 划分是铁律 ------官方已经做了患者级划分,直接用
Train_Val_Test列即可。千万不要按图像随机 shuffle,会导致同一患者的切片跨集合泄漏。 -
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/
扩展阅读:
- 3DCE 官方代码:https://github.com/rsummers11/CADLab/tree/master/lesion_detector_3DCE
- LesaNet 标签+代码:https://github.com/rsummers11/CADLab/tree/master/LesaNet
- 原始论文:https://doi.org/10.1117/1.JMI.5.3.036501
- arXiv 预印本:https://arxiv.org/abs/1710.01766
引用数据集时请标注:
- 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