简介:面向医学影像处理与Python数据分析人群的LIDC数据集说明与标签文件,适用于肺结节检测、分割和特征提取研究中的前期数据准备与结构理解,尤其适合具备一定编程基础的研究生或算法工程师。压缩包共1323个文件,大小仅6.44MB,包含1319个XML格式的结节标注,以及CSV、XLS、XLSX、TXT等辅助文档;XML逐例记录了病人ID、扫描序列、结节三维坐标、直径和放射科医生评级,表格文件中含有元数据与全库结节统计,TXT则给出原始数据下载说明。已有282人学习下载。借助pydicom、lxml、pandas等Python库,可快速解析CT影像与标注信息,梳理由结节位置、尺寸、纹理特征构成的标签体系,为后续构建机器学习或深度学习模型提供可直接使用的数据输入。这些文件能有效减少整理原始LIDC数据的时间,帮助研究者将精力集中在模型设计和实验验证上。
1. LIDC 不只是数据集,更是一套带“标准答案”的肺结节标注体系
LIDC(Lung Image Database Consortium)在肺结节 AI 检测这个方向几乎是绕不开的基准。很多团队第一次做结节检测、良恶性分类、甚至三维分割,拿来练手的第一份公开数据都是它。它的特殊之处在于:不光是给了 CT 影像,还给了四位放射科医生对每个结节的独立标注,包括轮廓坐标、良恶性评分、内部结构、钙化情况等。也就是说,它把“金标准”的构建过程也公开了。适合谁用?做医学影像深度学习的新手、要跑基线实验的算法工程师、以及需要标准评测集的科研人员。这篇笔记会把数据目录、XML 标签结构、DICOM 转 nii.gz、标签转 mask 的完整链路拆开讲,最后落到验证方法和几个我实际踩过的坑上。
2. 先把家底摸清:LIDC 的目录结构与文件格式
2.1 两种常见的数据形态:TCIA 原始包与重打包版
LIDC 数据集一般通过 TCIA(The Cancer Imaging Archive)公开。它有两种获取形态:一种是直接从 TCIA 拉下来的原始 DICOM 文件,按病例(Patient)和采集序列(Study/Series)层级存放;另一种是社区里有人做过预处理的重打包版,直接把每个病例整理成一个 .nii.gz 文件,附带一个 CSV 或 XML 文件记录结节信息。我建议第一次跑流程的从业者直接用原始 DICOM 包,因为重打包版虽然省事,但各家用的重采样参数、窗宽窗位甚至方向处理都不同,拿来做自己项目的 baseline 容易引入隐性偏差。原始包虽然文件数量多、读取慢,但信息完整,后面无论你要做二维检测还是三维分割,都有回旋余地。
原始包的顶层通常是这样组织:
LIDC-IDRI/ ├── LIDC-IDRI-0001/ │ ├── 1.3.6.1.4.1.14519.5.2.1.6279.6001.xxxx/ │ │ ├── 1.3.6.1.4.1.14519.5.2.1.6279.6001.xxxx/ │ │ │ └── *.dcm │ └── 1.3.6.1.4.1.14519.5.2.1.6279.6001.yyyy/ │ └── *.dcm └── LIDC-IDRI-0002/每个病例目录下可能有一个或多个序列目录,序列目录下才是真正的 DICOM 切片。这里要特别注意:同一个病例可能同时存在“薄层”和“厚层”两个序列,通常薄层(层厚 ≤ 1.25mm)才是做结节检测主流的选择,厚层数据一般用于对照。如果你直接遍历所有 .dcm 文件不区分序列,后面重建三维体时会把不同层厚的切片混在一起,重建出来的体积在 Z 轴上是错乱的。我在第一次处理时就犯过这个错,后来强制规定:每个序列单独建一个子目录,所有处理脚本都基于序列级路径去读。
2.2 DICOM 与 XML:两个必须分开对待的文件体系
LIDC 的标签并不存在 DICOM 文件里,而是单独打包在一个 XML 文件里。每个病例对应一个 XML 文件,命名通常是LIDC-IDRI-xxxx.xml。这个 XML 里记录了所有结节的边界、属性、医生评分,是整套标注的核心。而 DICOM 文件只负责图像本身。也就是说,要做训练样本,必须自己写代码把 XML 里的相对坐标换算到 DICOM 的像素坐标,再和图像叠加。
XML 的顶层结构大致如下:
<?xml version="1.0" encoding="UTF-8"?> <ReadSession> <ResponseHeader>...</ResponseHeader> <ResponseBody> <LIDCReadSession ...> <StudyInstanceUID>...</StudyInstanceUID> <SeriesInstanceUID>...</SeriesInstanceUID> <ReadingSession>...</ReadingSession> </LIDCReadSession> </ResponseBody> </ReadSession>这里的关键是SeriesInstanceUID:它把 XML 和具体的某个 DICOM 序列对应起来。一个病例如果有多个序列,XML 里会有多个<SeriesInstanceUID>块,每块下面才是该序列里找到的结节标注。解析的时候你先匹配 UID 再往下读,才不会张冠李戴。
2.3 病例目录的命名规则:SeriesInstanceUID 才是锚点
很多新手会按“LIDC-IDRI-0001”这个病例 ID 去关联图像和标签。但更准确的做法是:病例 ID 只用来定位病人,序列 UID 才是真正连接图像和标签的钥匙。因为一个病例下可能有多个序列,每个序列的命名是一长串 DICOM UID,比如1.3.6.1.4.1.14519.5.2.1.6279.6001.298806379830352084031707658958。在代码里,我习惯把所有序列 UID 先扫出来,然后与 XML 里的 SeriesInstanceUID 做交集,只处理能匹配上的序列:
import os import pydicom from pathlib import Path def collect_series_uids(patient_dir): series_uids = {} for dcm_path in Path(patient_dir).rglob("*.dcm"): try: ds = pydicom.dcmread(dcm_path, stop_before_pixels=True) suid = ds.SeriesInstanceUID series_uids.setdefault(suid, []).append(dcm_path) except Exception: continue return series_uids这段代码用stop_before_pixels=True只读元数据,速度快很多,因为一个病例的 DICOM 文件动辄几百个,完整读像素会非常耗时。series_uids以序列 UID 为主键,值为该序列下所有切片路径。后面跟 XML 标签关联时,直接拿这个字典来查匹配。
import xml.etree.ElementTree as ET def parse_series_from_xml(xml_path): tree = ET.parse(xml_path) root = tree.getroot() series_list = [] for session in root.iter("LIDCReadSession"): reading = session.find("ResponseBody/LIDCReadSession") # 实际结构根据命名空间可能略有差异 for uid_elem in session.iter("SeriesInstanceUID"): series_list.append(uid_elem.text) return set(series_list)这里有个很容易踩的坑:LIDC 的 XML 文件带了命名空间,不同版本解析方式不太一样。我一般用.iter("SeriesInstanceUID")这种忽略层级路径的方式,直接遍历节点文本,比严格按 XPath 拿更稳。拿到序列 UID 集合后与 DICOM 扫描结果做差集,就能快速发现哪些序列没有对应标签、哪些标签找不到图像。
匹配逻辑其实一句话:只有图像序列 ∈ XML 序列的样本才有资格进训练集。那些只标了但没图像、或有图像没标的病例,直接跳过比强行补标签更安全。
3. 把 XML 拆成可用标签:结节属性与坐标解析
3.1 从 XML 里拆出结节的坐标与 malignancy
LIDC 的 XML 里,每个结节由一组<unblindedReadNodule>描述,内部包含noduleID、characteristics、roi三个核心部分。roi里是一系列边缘点坐标,这些坐标描述的是结节的轮廓——不是中心点,也不是框。每个标记医生给出一组自己的轮廓点,同一结节在不同医生那里坐标不完全相同。
关键字段主要有这几个:
| 字段 | 含义 | 取值范围 |
|---|---|---|
| malignancy | 恶性程度评分 | 1(良性)到 5(恶性) |
| subtlety | 微妙程度 | 1 到 5 |
| internalStructure | 内部结构 | 1 软组织 / 2 液体 / 3 脂肪 / 4 空气 |
| calcification | 钙化情况 | 1 到 6 |
| sphericity | 球形度 | 1 到 5 |
| margin | 边缘描述 | 1 到 5 |
| lobulation | 分叶 | 1 到 5 |
| spiculation | 毛刺 | 1 到 5 |
| texture | 纹理 | 1 到 5 |
| diameter | 平均直径(mm) | 浮点数 |
这里malignancy是最常用的标签。很多分类任务直接把 1/2 当良性、4/5 当恶性、3 剔除,也有把 3 归为良性的做法。这个阈值决策会直接影响实验结论,最好在论文里写清楚,否则实验复现时别人跟你对不上。
轮廓坐标在 XML 里以xCoord和yCoord形式出现,它们是在该序列 DICOM 的二维切片上的坐标,单位是像素。注意!这些坐标不是物理坐标(毫米),而是图像坐标,且 Z 轴方向由该切片在序列里的位置决定。roi里还会带上imageZPosition或imageSopUid来指明是哪一个切片,解析时要保留这个对应关系。
3.2 标注者不一致时怎么办:多数表决与共识掩码
四位医生标注同一个结节,轮廓和评分都可能有差异。处理方式通常有两种:
第一种是结节级多数表决。对malignancy字段,取四位医生评分的平均值或中位数作为该结节的最终标签。我一般建议取中位数,因为评分是离散值,平均数容易出现 2.75 这种不好归类的数字。第二种是轮廓级合并。常见做法是把所有医生的轮廓点都保留,取并集区域作为结节的粗分割掩码。这种“宽松掩码”对检测任务好使,漏检率低;如果要精确分割,就得拿每位医生的标注单独训练或者做某种投票。
我习惯的做法是:
import numpy as np def majority_vote_malignancy(scores): # 剔除无效评分后再取中位数 valid = [s for s in scores if s is not None and s > 0] if not valid: return None return int(np.median(valid))注意这里的经验:s > 0的过滤很关键,因为 XML 里未评分的医生节点会把字段留空或置 0,直接参与计算会把中位数拉低。
3.3 用 python 把 XML 转成 COCO 风格的 JSON
如果要跑现成的检测框架,把 LIDC 转成 COCO 格式是最省事的互通方式。COCO 的 JSON 核心是images、annotations、categories三个数组,转换时把每个结节轮廓做成一个多边形segmentation,同时附上bbox和category_id。如果结节在某张切片上只有轮廓点、没有检测框,可以用cv2.boundingRect从轮廓点生成:
import json import cv2 import numpy as np def nodule_to_coco_ann(nodule_id, image_id, contour_points, malignancy): contour = np.array(contour_points, dtype=np.int32).reshape(-1, 2) x, y, w, h = cv2.boundingRect(contour) flatten = contour.flatten().tolist() return { "id": nodule_id, "image_id": image_id, "category_id": 1 if malignancy >= 4 else 0, "bbox": [int(x), int(y), int(w), int(h)], "area": int(cv2.contourArea(contour)), "segmentation": [flatten], "iscrowd": 0, }这里把 malignancy ≥ 4 视为恶性、否则良性,是极简的二分类划分。cv2.boundingRect返回的 bbox 是轴对称矩形,跟医学上常用的直径标注不同——COCO 评测用的是 IoU,而结节检测的评测有时用中心距离或体积重合率,注意别把评测口径混了。
转换完成后,建议做一个自检:随机挑几个结节,把轮廓画到对应 DICOM 切片上,肉眼确认轮廓位置没有错位。这一步虽然土,但能发现坐标轴搞反、切片序号错位、DICOM 重采样后坐标没同步改等一大批隐蔽问题。我第一次转完愣是对着一个结节轮廓在天上飘了半小时才反应过来是重采样之后忘同步改坐标了。
4. 用 Python 重建三维体并生成 Mask,管道可以这样搭
4.1 读取 DICOM 系列并重建三维体
拿到一个序列的全部切片后,重建三维体最好用pydicom配合numpy,而不是直接上SimpleITK。原因很简单:LIDC 的切片之间不一定严格按 ImagePositionPatient 排序,直接用SimpleITK.ReadImage可能得到乱序的体数据。手动排序更可控。
import pydicom import numpy as np from pathlib import Path def load_series(slice_paths): slices = [] for path in slice_paths: ds = pydicom.dcmread(path) slices.append(ds) # 按切片位置排序 slices.sort(key=lambda ds: float(ds.ImagePositionPatient[2])) return slices排序键用的是ImagePositionPatient里的 Z 坐标。这是最可靠的排序依据,不要用文件名排序,文件名在 TCIA 里经常没有规律。读完后,把每个切片的像素数组沿 Z 轴堆叠成三维数组:
def series_to_volume(slices): pixel_data = [ds.pixel_array for ds in slices] volume = np.stack(pixel_data, axis=0) # shape: (Z, H, W) return volume这里有个性能提醒:pixel_array返回的是原始像素值,单位取决于 DICOM 的RescaleSlope和RescaleIntercept。LIDC 的像素值是原始的 CT 值,需要转成亨斯菲尔德单位(HU)再使用。
4.2 把 XML 坐标映射到像素坐标并生成 Mask
XML 里的xCoord/yCoord是像素坐标,但它是基于原始 DICOM 的。如果你对图像做过裁剪、重采样或归一化缩放,坐标必须同步变换。标准的映射顺序是:先按 DICOM 的RescaleSlope/Intercept转 HU,再做窗宽窗位裁剪或归一化,最后才把坐标映射到处理后的图像上。
def apply_hu_windowing(volume, slope, intercept, window_width, window_level): volume = volume * slope + intercept lower = window_level - window_width / 2 upper = window_level + window_width / 2 volume = np.clip(volume, lower, upper) return volume参数说明:LIDC 常见窗宽窗位组合是窗宽 1500、窗位 -600(肺窗)。但做检测任务时不建议裁剪到太窄的窗宽,因为结节内部的钙化和软组织细节在窄窗下会丢失。我一般用窗宽 1500~2000、窗位 -600 这个区间,做 2D 检测时也见过有人用 400/40(纵隔窗),但结节边缘对比度会差很多。
坐标映射的代码逻辑是这样的:
scale_x = new_width / original_width scale_y = new_height / original_height new_x = round(x * scale_x) new_y = round(y * scale_y)这个映射看着简单,但最容易漏的是旋转:如果 DICOM 采集时病人躺的方向不是标准的,ImageOrientationPatient里的方向余弦会告诉你是否需要翻转。LIDC 的多数序列方向是标准的,但不要默认所有序列都标准,写代码时加一个判断题:
orientation = ds.ImageOrientationPatient # 如果方向余弦不等于 [1,0,0,0,1,0],就得做翻转或转置翻车场景很典型:不做方向判断,mask 画出来跟实际解剖位置差 90 度。我在一个模型里踩过这个坑,模型训练 loss 降得很好,验证时发现结节位置全偏了,最后排查到 DICOM 方向矩阵上。
4.3 输出 nii.gz 与 mask 的完整流程
把 CT 体积和 mask 存成 nii.gz,是为了后续直接喂给 nnUNet 或 MONAI 这类框架。nii.gz 的好处是自带 affine 矩阵,能记录物理坐标信息,转出来之后不用再回头找 DICOM。
import nibabel as nib def save_nii(volume, affine, path): nii_img = nib.Nifti1Image(volume, affine) nib.save(nii_img, path) # 生成 mask:把每个结节的轮廓填充为 1,背景为 0 mask = np.zeros_like(volume, dtype=np.uint8) for contour_points in nodule_contours: # 将轮廓填充到对应切片 cv2.fillPoly(mask[slice_idx], [contour_points], 1)注意fillPoly是在任意一张二维切片上填充,需要先把 Z 轴切片索引算准。XML 里的roi提供了imageSopUid来定位切片,最稳的方法是用这个 UID 去 DICOM 里查对应切片号,而不是按 Z 坐标排序后数顺序。因为两个序列的 Z 方向可能相反,同样物理位置排序出来的索引会不一致。
4.4 保存所有中间产物到一个项目目录
processed/ ├── LIDC-IDRI-0001/ │ ├── volume.nii.gz │ ├── mask.nii.gz │ ├── nodule_info.csv │ └── metadata.json └── LIDC-IDRI-0002/每个病例的metadata.json记录原始 DICOM 的 slope、intercept、方向矩阵、切片厚度、像素间距等关键参数。这些参数在复现实验时极其重要,换台机器重新处理数据时能省掉大量排查时间。
5. 避坑与常见问题:五个我交过学费的 LIDC 预处理现场
5.1 亏一:CT 值没转 HU,模型直接废掉
- 现象:训练时 loss 收敛慢且抖动剧烈,模型输出概率始终在 0.5 附近晃。
- 原因:
pixel_array返回的是设备原始值,LIDC 里很多序列的原始值与 HU 不是一一对应关系,有的甚至带负值偏移,不做slope * pixel + intercept转换,输入分布就被整体平移了。 - 解决:统一在加载阶段先转 HU,再进后续流程。我习惯把这一步写成一个全局函数,所有数据加载都必须经过它,不接受绕过的路径。
5.2 亏二:把 XML 的坐标当成物理坐标
- 现象:mask 画出来后,结节的轮廓在图像里整体偏移,越靠近图像边缘偏得越多。
- 原因:XML 里的坐标是像素索引,而 DICOM 的坐标原点在图像左上角,但部分序列的
PixelSpacing不为 1,直接拿像素坐标当毫米坐标去和物理空间对齐就会偏。 - 解决:所有坐标运算统一走“先取像素坐标 → 再做缩放/裁剪变换”的流程。如果要用物理坐标,必须手动乘
PixelSpacing并加上ImagePositionPatient的偏移,不要简写成一个乘法。
5.3 亏三:忽略 DICOM 方向余弦导致 mask 翻转
- 现象:验证时发现预测 mask 与原图重叠度很高但左右颠倒。
- 原因:
ImageOrientationPatient指示了采集方向,部分序列不是标准方向,直接按数组索引堆叠会得到镜像体。 - 解决:读序列时统一读取方向矩阵,生成体数据时把非标准方向的维度翻转成标准朝向。一个通用做法是保存时写入 affine,后续用 nibabel 加载时自动对齐。
5.4 亏四:结节直径标注当成半径用
- 现象:预处理时把
diameter字段当作半径做膨胀,结果生成 mask 区域是真实结节的 4 倍大。 - 原因:LIDC 的
diameter字段是平均直径,不是半径。字面含义和直觉相反,不仔细看注释很容易写反。 - 解决:用到
diameter的地方一律除以 2 再参与半径运算。建议在代码注释里写清楚“这是直径”,防止几个月后自己回来改代码时再看错一次。
5.5 亏五:多个序列不区分层厚混在一起重建
- 现象:三维重建后体数据在 Z 轴上有明显的“错层”,切片之间间隔不一致,模型训练报维度错误。
- 原因:一个病例下同时有 1.25mm 和 2.5mm 层厚序列时,代码把所有
.dcm文件全读进来,不同层厚的切片堆叠在同一个 volume 里。 - 解决:按序列 UID 区分读取,处理前先打印序列信息,人工确认当前处理的是薄层序列再继续。层厚信息在 DICOM 里是
SliceThickness字段,读取时一并输出。
6. 进阶:构建可复现的训练验证划分,让实验不翻车
6.1 病例级划分与结节级划分的区别
做肺结节任务时,数据划分必须按病例(Patient)粒度来切,不能按结节粒度切。原因很直观:同一个病人的多个结节在影像特征上高度相似,如果把同一病人的结节同时分到训练集和验证集,模型会“作弊”——它可能通过记住病人特征来间接记住结节特征,验证集的指标虚高。我见过有人按结节划分后报告 AUC 0.96,换按病例划分后一测只有 0.88,差距就是这么来的。
import pandas as pd from sklearn.model_selection import GroupKFold df = pd.read_csv("nodule_info.csv") # 假设 df 每个结节一行,且有 patient_id 字段 gkf = GroupKFold(n_splits=5) for train_idx, val_idx in gkf.split(df, groups=df["patient_id"]): train_set = df.iloc[train_idx] val_set = df.iloc[val_idx]GroupKFold的参数要讲清楚:groups是病例 ID 列表,长度一定要跟df行数一致,否则会报索引错误。输出后建议检查一下 val_set 里的patient_id是否与 train_set 完全没有交集,这一步用集合运算检查一下,一秒搞定但能防住大坑。
6.2 用 pandas 维护数据清单,防止数据泄漏
处理完所有病例后,维护一张总清单非常关键。清单至少包含这些列:病例 ID、序列 UID、切片数、层厚、像素间距、结节数量、结节最大直径、恶性评分中位数、文件路径、mask 路径。后续做模型训练时直接用这张表,避免每次都要重新扫 DICOM 目录。更重要的是,这张表能让你在实验复现时快速回溯某个数据是从哪个原始文件来的。
data_records = [] for patient in patient_list: records = build_patient_record(patient) data_records.extend(records) summary_df = pd.DataFrame(data_records) summary_df.to_csv("lidc_processed_index.csv", index=False)build_patient_record函数返回该病例所有结节的摘要。表里的“结节数量”如果是 0,通常表示该病例只有“非结节”(non-nodule)标注,这种病例在构建检测任务时可以作为纯负样本用,但别删掉。
6.3 校准:mask 与图像层面对齐的自动化检查
训练前做一次自动对齐校验,能省下后面排查 bug 的大量时间。校验逻辑很简单:随机抽 N 个病例,对每个病例的每个结节做一次坐标反向映射——把 mask 里的非零区域取出来,计算它的质心像素坐标,再和 XML 原始坐标对比,看误差是否在可接受范围内。
def validate_alignment(volume, mask, original_coords, tol_px=3): # 找 mask 中非零坐标的质心 ys, xs = np.where(mask > 0) if len(xs) == 0: return False centroid_x = np.mean(xs) centroid_y = np.mean(ys) orig_centroid = np.mean(original_coords, axis=0) dist = np.sqrt((centroid_x - orig_centroid[0])**2 + (centroid_y - orig_centroid[1])**2) return dist < tol_px这个校验函数把误差容忍度设为 3 像素,因为重采样和整数取整会带来小偏差,超过这个范围就要停下来找原因。我一般会在处理完一批数据后跑一遍全量校验,把失败病例单独列出来人工检查,而不是直接删掉。删掉可能把罕见但有效的病例丢掉,得不偿失。
最后一个技巧:把整个预处理管道做成幂等的——同一份输入跑两次输出完全一致。具体做法是把处理后的中间文件(如 volume.nii.gz、mask.nii.gz)单独存到 processed 目录,原始 DICOM 目录不写任何东西。这样即使脚本改了,只要 processed 目录存在,就不需要重新处理。从那以后我每次处理医学影像数据都强制走一遍这个流程:先列序列清单、再做坐标映射、再保存 nii.gz 与元数据、最后跑对齐校验。这套习惯帮我挡住了不少后期训练时的玄学问题。希望帮到你。
本文还有配套的精品资源,点击获取