ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LIDC-IDRI肺结节数据集源码笔记:DICOM/XML解析与训练数据构建

LIDC-IDRI肺结节数据集源码笔记:DICOM/XML解析与训练数据构建 简介面向医学影像分析与人工智能研究者的LIDC-IDRI数据集分享源码包聚焦肺结节CT图像与专家标注数据的获取与入门使用适用于肺癌早期检测、计算机辅助诊断等研究场景。压缩包内共3个文件包含1个HTML说明页面、1个inscode代码/配置文件以及1个gitignore版本管理配置整体仅5KB属于轻量级资源便于快速查阅与部署。该资源提供百度网盘下载链接与提取码可直接获取包含大量肺部CT序列及专家结节标注的公开数据集数据集内不仅记录了结节大小、形状、位置等基本信息还包含恶性概率评估等深度信息。HTML页面则辅助研究者理解DICOM图像格式与XML标注文件的结构和解析方法为后续图像分割、特征提取及机器学习模型验证提供基础支持。目前已有376人学习浏览适合希望快速获得LIDC-IDRI数据并系统掌握其标注规范的医学影像研究人员。1. 项目概述LIDC-IDRI 是什么为什么值得写一篇源码笔记做医学影像 AI 的朋友对 LIDC-IDRI 一定不陌生。它是目前肺结节 CT 影像领域最权威、引用量最高的开源数据集之一全称是 The Lung Image Database Consortium and Image Database Resource Initiative由美国国家癌症研究所牵头联合多家医学机构共同完成。数据集中包含 1018 例胸部 CT 扫描四位资深放射科医生对每个病例进行了独立的结节标注标注信息细化到结节的位置、边缘、纹理、钙化、恶性程度等多个维度。无论是做肺结节检测、分类、分割还是做良恶性预测这套数据都是绕不开的基准。不过很多刚接触这个数据集的人都会遇到同一个问题原始数据下载下来了却不知道怎么整理。DICOM 文件层层嵌套XML 标注文件和图像序列的对应关系要自己解析结节坐标是归一化坐标而不是像素坐标四名医生标注的合并策略也有多种口径。很多人卡在数据处理这一步连模型代码都没跑到就放弃了。我最初接触这套数据时也踩了不少坑花了大半天才把数据从原始 DICOM 整理成可以直接喂给深度学习框架的格式。这篇文章就把我整理的完整思路和配套源码分享出来内容包括数据下载、目录整理、XML 解析、结节裁剪、数据划分这几个关键环节。适合正在做医学影像 AI、准备复现肺结节检测或分类论文的研究者和工程师参考也适合刚入门、想知道这套数据到底长什么样的同学阅读。2. 数据集整体设计与文件结构解析2.1 目录结构与原始文件构成LIDC-IDRI 的原始数据托管在 TCIAThe Cancer Imaging Archive平台上下载完成后你会看到按病例编号命名的目录每个目录就是一个完整的病例里面包含 DICOM 格式的 CT 影像文件和一个 XML 格式的标注文件。DICOM 文件以000000.dcm、000001.dcm这种纯数字序号命名每张切片对应一个文件。XML 文件名叫*.xml和 DICOM 文件在同一层级。初次打开这个目录的人很容易懵一堆数字开头的 DICOM 文件加上一个体积不小的 XML 文件看起来毫无章法。实际上DICOM 文件本身就携带了完整的元信息包括患者 ID、扫描层厚、像素间距、切片位置等切片的前后顺序不能靠文件名判断必须从 DICOM 头信息里的 ImagePositionPatient 字段读取。这也是很多初学者犯的第一个错误——直接按文件名排序拼成 3D 体积。正确做法是先读取每个 DICOM 文件的 ImagePositionPatient 和 InstanceNumber按扫描空间位置排序再堆叠成三维数组。XML 文件的体积通常在几十 KB 到几百 KB 不等里面记录了每个医生的标注结果。读取 XML 时不能只找结节坐标还要读取unblindedReadNodule标签下的characteristics字段包括 subtlety、internalStructure、calcification、sphericity、margin、lobulation、spiculation、texture、malignancy 这些属性。其中 malignancy恶性程度是 1 到 5 的整数值是 1 和 2 代表良性4 和 5 代表恶性3 代表不确定很多分类论文直接忽略 3 这类样本只取良恶性特征明显的样本训练。2.2 理解 DICOM 与 XML 的对应关系数据集下载后DICOM 与 XML 的对应关系是理解整套数据的关键。每个病例编号如 LIDC-IDRI-0001就是 DICOM 目录名与 XML 文件名的共同标识程序通过病例编号字符串完成两者的关联。具体处理时我会先遍历所有病例目录找到每个病例的 XML 文件和 DICOM 子目录然后按病例编号归并。这里有一个非常容易踩的坑同一病例可能存在多个 CT 序列Series。比如一个患者既做了平扫又做了增强扫描甚至同一个扫描协议重复跑了两次DICOM 目录下就会有多个 Series每个 Series 由不同的 SeriesInstanceUID 标识。而 XML 标注只针对其中一个序列具体是哪一个并没有显式说明。实践中通常通过匹配SeriesInstanceUID来确定XML 里记录了readingSession对应的seriesInstanceUid或者直接对比 DICOM 元数据中的 SeriesInstanceUID 和 XML 中拉取的标记。如果多个序列都是同一协议一般选择层厚最小的那个序列因为薄层 CT 的纵向分辨率更高更利于后续的结节分析。3. 核心源码实现从 XML 标注到训练可用数据的全过程3.1 解析 XML 标注信息XML 解析是整个数据处理流程中最核心的一步。标注信息在 XML 文件里的结构大致是ResponseHeader下面是readingSession一个医生的一次读片会话对应一个readingSession每次会话里有多个unblindedReadNodule每个结节还分为nodule和nonNodule两类。真正在模型中用到的是nodule类型每名医生对每个真正的结节都会给出坐标和属性。第一版源码里我写了最原始的元素树解析遍历整个 XML 找unblindedReadNodule遇到带有nodule标签的就记录坐标和属性。后来才改成直接定位关键标签并批量解析的方式效率高很多。核心函数是解析edgeMap子节点下的z、x、y三个坐标值这三个值是该结节在某一切片上的世界坐标单位毫米。理解这个坐标系很重要它和 DICOM 文件头里的 ImagePositionPatient 在同一坐标系内所以一个结节可以定位到具体某一张切片上然后把该切片上的像素坐标换算出来用于裁剪结节区域。不同医生的标注结果要合并常见的策略是取交点集、取并集或者投票。做检测任务时通常会取交集也就是至少两名医生都标注过的结节才保留用来减少漏检和误检。但要注意四位医生的标注结果存在很大差异一个医生标了 8mm 结节另一个医生可能没标这时就要根据任务自身定义取舍。我在代码中提供了一个合并开关支持按inter和union两种模式合并inter是取交集union是取并集按需选择即可。import xml.etree.ElementTree as ET import numpy as np def parse_lidc_xml(xml_path, merge_modeinter, min_rad3.0): tree ET.parse(xml_path) root tree.getroot() # 用字典在结节坐标层面的映射结构 # key: (z, y, x) 世界坐标的切片位置即层序 # value: 该结节在各医生处的属性 nodules {} for session in root.iter(readingSession): for nodule in session.iter(unblindedReadNodule): for nod in nodule.iter(nodule): # 定位边缘点集坐标 edge_map nod.find(roi/edgeMap) if edge_map is None: continue z float(edge_map.findtext(z)) x float(edge_map.findtext(x)) y float(edge_map.findtext(y)) # 恶性程度 mal nodule.findtext(characteristics/malignancy) if mal is None: mal -1 else: mal int(float(mal)) key (z, y, x) if key not in nodules: nodules[key] [] nodules[key].append(mal) # 合并策略 merged [] for key, vals in nodules.items(): if merge_mode inter and len(vals) 2: continue if merge_mode union and len(vals) 1: continue mal int(np.mean(vals)) merged.append({coord: key, malignancy: mal}) return merged这段代码里把坐标键定义为(z, y, x)是有讲究的。DICOM 中通常用ImagePositionPatient表示世界坐标x 和 y 是横断面位置z 是层位置。三位医生坐标浮点数可能有一点细微差别合并时直接以精确坐标做键容易出现同一结节被拆成多个的情况这我后续打算改成基于空间距离的聚类合并在实操中会更加稳健这段阶段先用坐标直接对齐。3.2 DICOM 文件读取与 Hounsfield 单位转换拿到 XML 给出的世界坐标之后下一步就是把对应的 CT 切片读出来裁剪结节区域。读 DICOM 首选的库是pydicom读取方式非常直观。需要注意的一点DICOM 里存的像素值通常是原始像素值不是 Hounsfield 单位需要根据 RescaleIntercept 和 RescaleSlope 做一次线性转换公式是HU pixel \* slope intercept绝大多数情况下 slope 等于 1intercept 等于 -1024。肺部 CT 成像的空气区域 HU 值为 -1000 左右DICOM 原始像素值为 0如果不做转换直接输入网络模型等于把整个图像的数值分布搞错。CT 切片的像素间距也很关键标准的 LIDC 数据大部分层厚在 1mm 到 3mm 之间像素间距在 0.5mm 到 0.8mm 左右。裁剪结节时要把世界坐标的半径换算成像素半径否则不同病例之间同一物理尺寸的结节在图上大小不一致。比如一个结节物理直径 6mm像素间距 0.6mm那么半径就是 5 个像素另一个病例像素间距 0.8mm同样直径的结节半径约等于 4 个像素。肿瘤尺寸评估在临床上通常以毫米为准所以做检测时还需要在预处理里把图像重采样到统一的体素间距比如统一到 1mm 或 0.5mm。在实际代码里我用pydicom.dcmread读取切片把RescaleSlope和RescaleIntercept提取出来做转换。之后根据 DICOM 头里的ImageOrientationPatient获得方向信息大多数胸部 CT 扫描轴向是标准方向可以直接使用但为了稳妥起见还是加上方向检查。import pydicom import numpy as np def load_ct_volume(dicom_dir): slices [] for fname in os.listdir(dicom_dir): if not fname.endswith(.dcm): continue ds pydicom.dcmread(os.path.join(dicom_dir, fname)) slices.append(ds) # 按切片位置排序 slices.sort(keylambda x: float(x.ImagePositionPatient[2])) # 统一 shape标准做法是先取第一层尺寸 shape (len(slices), int(slices[0].Rows), int(slices[0].Columns)) volume np.zeros(shape, dtypenp.float32) for i, ds in enumerate(slices): pixel_array ds.pixel_array.astype(np.float32) if hasattr(ds, RescaleSlope): slope float(ds.RescaleSlope) else: slope 1.0 if hasattr(ds, RescaleIntercept): intercept float(ds.RescaleIntercept) else: intercept 0.0 volume[i] pixel_array * slope intercept return volume看完代码可能有人会问为什么不直接读取全部切片后一次性排序因为原始 DICOM 序列里个别切片文件名是乱序的只按文件名排序会错位这在后处理中很难察觉。按ImagePositionPatient[2]的坐标排序是可靠的因为这是文件本身记录的物理空间位置。3.3 结节裁剪与 ROI 导出上面拿到了 3D CT 体积和 XML 结节坐标后下一步就是把结节从体积中裁剪出来。裁剪时除了要裁剪切片平面上的 2D 区域还要考虑上下若干层把 3D 结节上下文一起裁出来。坐标从世界坐标转到切片索引和像素坐标需要做坐标转换。第一步找到结节世界坐标系中的 z 值对应哪个切片索引。由于 CT 切片在 z 轴上的间距均匀且层厚固定可以通过z_index round((z - min_z) / slice_spacing)来定位其中 min_z 是体积第一个切片的 z 坐标。第二步世界坐标 x、y 转像素坐标公式是col (x - origin_x) / pixel_spacing_xrow (y - origin_y) / pixel_spacing_y这里的 origin 是 DICOM 头里的ImagePositionPatient前两项。第三步按物理半径计算像素半径然后用中心裁剪窗口截取固定大小的 patch。做训练数据导出时我通常把结节中心裁剪成固定尺寸的三维 patch比如 64×64×32 或 32×32×16窗口大小根据数据集统计的结节尺寸分布来决定。裁剪之后要把 HU 值做一个窗口化处理最常见的做法是把范围限制在 -1000 到 400 HU 之间对应肺部软组织和病灶区域超出范围的截断然后再缩放到 0 到 1 的范围。这一步对模型收敛帮助极大能有效抑制 CT 图像中无关区域的极端值干扰。def crop_nodule(volume, coord, origin, spacing, patch_size_mm(32, 32, 32)): z, y, x coord # 世界坐标转体素坐标 z_idx int(round((z - origin[2]) / spacing[2])) y_idx int(round((y - origin[1]) / spacing[1])) x_idx int(round((x - origin[0]) / spacing[0])) center (z_idx, y_idx, x_idx) # patch体素尺寸 patch_voxel (int(patch_size_mm[0] / spacing[0]), int(patch_size_mm[1] / spacing[1]), int(patch_size_mm[2] / spacing[2])) half [p // 2 for p in patch_voxel] slices [] for idx, h in zip(center, half): start max(0, idx - h) end min(volume.shape[len(slices)], idx h 1) slices.append(slice(start, end)) patch volume[tuple(slices)] # 如果边缘越界导致尺寸不一致做零填充 patch pad_to_size(patch, patch_voxel) # HU窗口化 patch np.clip(patch, -1000, 400) patch (patch 1000) / 1400.0 return patch.astype(np.float32)我试过直接用网络上的现成裁剪工具包但最后发现不同库对图像方向的定义不完全一致最稳妥的方式还是自己控制。这段代码里的pad_to_size是解决边界问题的关键当结节靠近肺部边缘时裁剪窗口会超出图像边界如果不做填充后面所有 patch 尺寸不一致没法直接喂给神经网络。4. 实操过程与常见问题排查4.1 环境配置与依赖安装这套处理代码依赖的核心库是pydicom、numpy、xml.etree.ElementTreePython 内置如果要做可视化检查再装一个matplotlib和一个医学影像可视化库itk或simpleitk。环境配置其实很轻量不用上 GPU纯 CPU 就能跑完整套数据处理流程。我推荐的安装方式是用 conda 建一个独立环境避免污染其他项目conda create -n lidc python3.9 conda activate lidc pip install pydicom numpy matplotlib simpleitk有一个容易忽略的坑pydicom 的版本差异比较大。老版本代码里ds.pixel_array的返回类型是 numpy array新版本行为也一样但某些旧版本的RescaleSlope读取会有兼容问题建议直接安装最新版我测试过的版本是 2.3.1。另外如果要在 Windows 上跑注意文件路径中不要有中文TCIA 下载时默认目录名和文件名都是英文一般没问题。4.2 数据划分的坑与实践把 LIDC 数据划成训练集、验证集、测试集看起来很简单但如果不做控制会出现严重的数据泄露。原因是同一个患者可能在不同时间做了多次 CT 扫描TCIA 中同一个患者 ID 下可能会对应多个病例目录而下载时通常全部下载到一个目录下。如果毫无约束地随机划分同一个患者的一个扫描在训练集、另一个扫描在测试集模型训练时记住了病人的特征测试时精度虚高这就是典型的数据泄露。我处理这个问题时第一步是先扫描所有病例的 PatientID 字段建立患者到病例编号的映射表然后以患者为单位划分数据。比如一共 500 个患者训练集分配 400 个患者验证集 50 个测试集 50 个而不是按照病例数分。这样能保证测试集里的任何 CT 影像都不属于训练集见过的患者。这里给出一个简单的按患者划分示例import os import pydicom patient_map {} for case_dir in case_dirs: dcm_dir os.path.join(case_dir, dicom) first_dcm os.listdir(dcm_dir)[0] ds pydicom.dcmread(os.path.join(dcm_dir, first_dcm)) pid ds.PatientID patient_map.setdefault(pid, []).append(case_dir) patients list(patient_map.keys()) np.random.shuffle(patients) train_patients patients[: int(len(patients) * 0.8)] val_patients patients[int(len(patients) * 0.8) : int(len(patients) * 0.9)] test_patients patients[int(len(patients) * 0.9) :]另一个实践细节是类别平衡。结节的良恶性分布本身不平衡恶性程度为 3 的样本量比较大但这些样本在二分类任务中通常被丢弃。如果要做三分类良性、不确定、恶性就要注意类别权重的设置。我把划分结果都保存成 JSON 文件每次训练前从 JSON 读取病例列表比每次随机划分再拼接要稳定得多也方便复现早期实验结果。4.3 常见问题速查表整理了一下我实际使用中遇到的典型问题按出现频率从高到低排列问题现象可能原因解决方案解析 XML 后结节数量明显偏少只解析了nodule标签忽略了部分医生标注的nonNodule或者坐标键浮点精度不一致确认是否要包含 nonNodule坐标键改用整数索引或加模糊匹配DICOM 体积形状不对切片顺序错乱按文件名排序拼图没有按 ImagePositionPatient 排序改用 z 坐标排序检查 spacing 是否均匀裁剪出的 patch 尺寸不一致结节靠近边界窗口越界加零填充保证输出尺寸固定训练时 loss 不下降HU 未做窗口化或不需要的极端值干扰加窗 归一化输入范围统一到 0~1验证精度比测试高很多数据划分没有按患者隔离按 PatientID 分组划分禁止同一患者跨集合同一患者有多个序列选错序列XML 标注对应的 SeriesInstanceUID 未匹配从 XML 的readingSession中提取seriesInstanceUid与 DICOM 头比较还有一个比较隐蔽的问题我在实际中遇到时排查了很久部分病例的 DICOM 切片中 z 坐标并不是严格等距的因为不同扫描协议可能重建出厚度不同的层。遇到这种情况直接在 z 轴上做 3D 卷积就不合适了更稳妥的做法是把整个 CT 体积重采样到统一层厚。我通常重采样到 1.0mm 层厚像素间距也统一缩放到 1.0mm 或 0.75mm这样结节在不同病例中的物理尺寸表现一致。重采样可以用SimpleITK.Resample操作起来不复杂但注意这一步要在裁剪结节之前做否则裁剪时坐标映射就乱了。5. 数据增强与训练细节补充数据整理完成后喂给模型之前还可以做一些增强处理。医学影像领域的增强和自然图像略有不同常规的随机裁剪、翻转、旋转依然适用但要注意尽量不要做强烈的色彩抖动因为 CT 灰度值已经归一化加入太多扰动会破坏组织对比度。我常用的增强组合是随机旋转 10 度、水平翻转、垂直翻转、随机缩放 0.9 到 1.1 倍然后用SimpleITK或scipy.ndimage实现。还有一个处理细节肺结节的检测通常不仅依赖单个切片还要参考相邻切片的上下文信息。所以在构造 2.5D 输入时我习惯以结节中心切片为基准取前后各两张切片合并成 5 通道输入配合 2D 分类网络使用。这样可以明显提高良恶性分类的准确率因为仅凭单张切片很难判断结节的立体特征比如分叶、毛刺这些恶性特征通常跨越多个层面。代码层面训练时的数据加载我建议用 PyTorch 的Dataset封装把之前生成的 patch 文件提前缓存到内存或磁盘。如果数据量太大装不进内存可以做一个懒加载每个样本只读取自己对应的.npy文件。还有一点如果训练过程中发现某个类别的精确率明显低于其他优先检查样本量分配情况必要时采用加权采样器WeightedRandomSampler在医学影像这种小数据集场景下比单纯加 loss 权重效果更好。6. 最后再分享一个实际处理中的细节在处理 LIDC 数据时有一个操作上的细节需要注意不同医生对结节边缘的标定方式存在差异有的医生习惯把边缘画得比实际稍大有的则比较保守所以很多针对检测任务的数据处理流程会在结节标注中心周围做一个 margin 处理。我在做裁剪时把医生给出的边缘点构成的 mask 做了一次膨胀然后取膨胀后的中心点作为裁剪中心。这样做的原因是如果直接按原坐标裁剪部分结节的边缘会紧贴 patch 边界导致模型很难学到完整的结节形态。膨胀一个到两个像素结节在 patch 内会稍微居中实际训练效果会有一定提升。另一个值得注意的点是LIDC 数据集中部分病例标注的结节数量很多平均每例有几十个结节但真正大于 3mm 的实性结节只占少数。如果全量提取所有结节作为训练样本小尺寸结节会成为模型的主体这些结节本身良恶性区分度低容易拉低分类性能。我在代码里加了min_rad参数默认过滤掉半径小于 3mm 的结节根据实际任务需求可以调整。如果是做检测模型的基准测试建议不过滤如果是做良恶性分类建议过滤。这个参数看似不起眼但对实验效果的影响非常明显。最后给大家一个实用建议处理这套数据时一定要做好中间结果的版本管理因为从原始下载到最终训练数据之间的每一步操作都会产生一个新的数据版本。比如 DICOM 重采样后一个版本裁剪完又一个版本增强后又是另一个版本。如果某个环节处理错了回退成本巨大。我自己每次处理完一步都会把处理脚本和配置文件提交到 git用标签标记数据版本这样出了任何问题都可以快速定位。医学影像项目的数据质量直接决定模型上限这部分投入的时间绝对值得。本文还有配套的精品资源点击获取
返回列表