资讯动态

LIDC肺结节CT数据解析:从XML到Mask的完整实战

发布时间:2026/10/12 23:37:16 来源:尧图企业网站定制
简介面向使用Python处理医学影像的开发者与数据科学研究者这份LIDC相关数据说明与标签包提供了肺结节CT数据从数据解析到机器学习建模的完整配套材料。压缩包共1323个文件体积仅6.44MB以1319个XML标注文件为核心细致记录每个肺结节的三维坐标、直径与形态特征CSV元数据表管理病人ID、扫描序列等信息XLS/XLSX文件汇总结节计数与诊断标签TXT则给出下载与使用说明整体结构清晰便于按需调用。已有282人学习下载。利用pandas读取CSV、lxml解析XML、pydicom转换DICOM图像读者可直接提取形状、纹理及统计特征为后续肺结节检测、分割和肺癌预测模型提供规范化的数据基础显著降低LIDC数据集的使用门槛。无论是入门医学影像分析、复现论文实验还是构建端到端检测流程都能从中获得清晰指引。1. LIDC数据说明与标签为什么每个用CT做肺结节研究的团队都得先过这一关做医学影像的人对LIDC-IDRI这个名字应该不陌生它就是那个被反复引用的公开肺结节CT数据集。可真拿到手之后很多人第一反应是懵的文件夹里是一堆.dcm文件和几个.xml文件既没有现成的mask也没有统一的标签文件跟自然语言处理里那种“一行一条标注”的清爽格式完全不一样。这个标题要解决的就是这件事——把LIDC的原始数据讲清楚把藏在XML里的结节标签解析出来让你能把这份公开数据变成能喂给深度学习模型的训练样本。这篇文章适合正在做肺结节检测、分类或者分割任务的同学尤其是那种已经在网上下载好了数据、打开目录却不知道从哪一步开始的人。我会从数据目录结构讲到XML标签体系再给出解析坐标和生成mask的完整代码路径最后把多医生标注不一致、坐标偏移这些最容易翻车的细节掰开讲。读完你应该能做到拿到一个LIDC病例能说清里面有什么、标签在哪、怎么转成自己的训练格式。2. 理解LIDC-IDRI的原始结构从DICOM到XML的组织逻辑2.1 目录层级与文件命名一个病例文件夹里到底装了什么LIDC-IDRI的下载包通常按病例编号组织每个病例是一个独立文件夹命名类似于“LIDC-IDRI-0001”。打开后你会看到若干子目录常见的有0001、0002这类带编号的CT序列目录以及一个名为xml或者直接散落在根目录的标注文件。CT序列目录里装的是DICOM文件每一个文件对应一层CT切片文件名通常是数字编号不一定连续因为命名规则取决于原始扫描设备。很多第一次接触的人会误以为这些编号连续的dcm文件就是按顺序排列的切片实际上不一定。DICOM文件内部有一个InstanceNumber标签记录真实的切片顺序而文件名只是设备生成的ID。要重建完整的3D体积应该读取每个文件的SliceLocation或ImagePositionPatient来做排序而不是依赖文件名。这一步做错后面所有坐标转换都会跟着错。标注文件通常是LIDC-IDRI-0001.xml这样的名字位于病例根目录。这里面存的是四位放射科医生的独立标注结果包括结节的位置、轮廓坐标、特征评分以及诊断结论的部分信息。注意标注文件是XML格式不是JSON也不是CSV这意味着解析时不能简单用pandas一类的库直接读需要走XML解析的路径。一个值得注意的细节是并不是所有病例都有三个CT序列目录。有些病例可能只有一份CT扫描有些则有两次不同时期或不同重建方式的扫描。每个序列目录下还会存在一个001到999之类编号的文件夹再往里才是dcm文件。这个层级深度在不同版本的数据包里可能不一致写代码时最好递归查找而不是硬编码路径层级。2.2 DICOM头的关键字段解析标签之前必须拿到的几何信息要从XML里的坐标还原成CT图像上的真实位置只靠XML数据是不够的必须从对应的DICOM文件头里读出几项关键几何参数。第一项是ImageOrientationPatient它定义了图像的行列方向在三维空间中的朝向是一个包含6个数值的数组。第二项是ImagePositionPatient它定义了这张切片左上角像素在病人坐标系中的三维坐标。第三项是PixelSpacing表示每个像素在x和y方向上的物理尺寸单位是毫米。这三项合在一起才能把XML里记录的“二维像素坐标”映射到真实空间或者反过来判断一个结节的立体尺寸。如果只读PixelSpacing而不读方向向量遇到倾斜扫描时坐标转换就会错位。好在大多数公开的LIDC病例都是标准的轴向扫描方向向量基本是单位正交的但严谨起见代码里还是应当做一次完整的方向矩阵计算。还有一个字段是RescaleSlope和RescaleIntercept用于把原始存储值转换为真正的CT值亨氏单位。LIDC的影像数据一般已经做过转换但不同来源的副本可能保留原始值训练模型前最好统一处理一次。另一个容易被忽略的字段是SliceThickness它影响层间插值和体积计算做三维分割时尤其重要。2.3 XML标注文件的整体结构ReadMessage、Session和Nodule的层级关系打开一个LIDC的XML文件最外层是一个ReadMessage根元素里面按读取会话分组。每个readingSession对应一位医生对当前病例的一次完整标注过程。一个病例的XML里通常包含四个readingSession对应四位医生。每个会话内部标注按unblindedReadNodule元素组织这个元素就是“一个结节”的容器。unblindedReadNodule下面有两类核心子元素一类是roi包含了这个结节在每一层切片上的轮廓坐标点另一类是characteristics记录了医生对这个结节的形态学特征评分。还有一类特殊的nonNodule元素表示医生认为某个位置存在异常但并非真正结节的结构比如血管断面、炎症灶等这些通常不建议当正样本用。理解这个层级是解析标签的地基。你要找的结节标签并不在某个单独的label字段里而是散落在多个嵌套元素中。一个完整标签 轮廓坐标 特征评分 尺寸信息三者分散在XML的不同位置需要组装起来。解析完成后你得到的是一份结构化的结节清单每个结节包含医生ID、坐标序列、特征数值和良恶性评分。这才是真正可以用于训练的标签数据。3. LIDC标签体系详解四位医生评分与9分制特征3.1 结节类型与尺寸分级哪些标注值得拿来做训练LIDC的标注里存在分级概念。标注为nodule的对象里有一个noduleID属性区分不同类型的结节。具体来说3mm的结节会被赋予完整的特征评分和轮廓标注这是研究的“主菜”。3mm的结节通常只有轮廓可能没有特征评分。还有一种non-nodule标注包括血管、胸膜增厚等这些内容一般当作困难负样本或直接忽略。这个分级的实际操作意义很大。如果你做的是结节检测任务正样本应该是所有3mm且具有完整轮廓的结节负样本可以从nonNodule和正常区域里采样。如果你做的是良恶性分类那么只有带malignancy评分且医生间共识度高的结节才适合作为标签。如果不管三七二十一把所有nodule都塞进训练集最后模型的性能会非常奇怪。尺寸信息在XML里不是直接给一个直径数值而是由轮廓坐标计算得出。医生标注的轮廓点围成的区域其等效直径通常用来做三分类小于3mm、3mm到5mm、大于5mm。但要注意不同医生对同一个结节的轮廓画法可能差异很大用哪一位医生的轮廓来决定“这个结节是否纳入训练”会直接影响数据集构成。常见做法是取四位医生中最大轮廓或取平均轮廓后者更常见。3.2 七个特征维度的评分标准与标签用法每位医生对3mm结节会打七项特征分subtlety模糊程度、internalStructure内部结构、calcification钙化程度、sphericity球形度、margin边缘清晰度、spiculation毛刺征、texture纹理以及malignancy恶性度。每一项都是1到5分的整数值其中internalStructure和calcification的分数含义与其余几项不同使用时需要小心。以malignancy评分为例1分代表高度怀疑良性5分代表高度怀疑恶性3分是中间不确定地带。很多分类任务直接把malignancy作为二分类标准比如把1、2分归为良性4、5分归为恶性3分丢弃。也有做法只取评分极端的样本训练出来的模型更干净但数据量会减少需要根据任务目标取舍。calcification评分的含义也要特别说明。LIDC的钙化评分是1到6分其中6分特指“钙化分布方式为中央、弥漫或分层”中的一种情况和前面的1到5分不是同一条量纲。有人第一次用的时候把这个字段直接当1到5分处理结果特征分布图出现了一个奇怪的“6分”群体排查半天才发现是定义不同。3.3 多医生标签的一致性与金标准策略LIDC的核心特点就是四位医生独立标注同一个结节在四个readingSession里会有四份不同的轮廓和评分。怎么合成一个最终标签是整套数据处理流程里最需要动脑子的地方。最极端又简单的做法是只选一位医生的标注当标签但这样模型的性能上限会被这位医生的主观性锁死。更常见的做法是融合四位医生的轮廓生成一个平均mask然后统计特征评分的中位数或平均值作为该结节的金标准。但融合不是无脑平均。先要解决“同一个结节如何在不同医生之间对应”的问题。LIDC官方提供过一个参考标准文件里面给出了一组“典型结节”的匹配结果把四位医生的标注按位置聚成了组。不过实际使用中很多人会选择自己写聚类算法比如按结节质心坐标的距离做匹配小于某个阈值例如15mm视为同一结节。融合轮廓时有一个细节值得注意不同医生标注的切片范围可能不一样医生A标了5层医生B标了7层融合前先把所有医生的轮廓点插值或重采样到同一个坐标网格上再做逐像素投票。投票阈值的设定会影响最终mask的大小。我们常用的是“至少3位医生标注的区域算阳性”这样既能排除个别医生的离群标注又不会因为要求全员一致而让mask缩得太小。4. 从XML解析到可视化Mask完整代码实现4.1 解析路径选择自己写XML解析还是用pylidcLIDC的XML解析有现成工具可用最出名的是pylidc这个第三方库。它对LIDC数据集做了一层封装提供了pylidc.Query来查询病例和结节以及直接返回mask和特征的功能能省掉80%的脏活。但依赖pylidc有一个前置条件它内置了一个经过整理的SQLite数据库需要先运行pylidc的数据导入脚本把XML内容灌进去这一步在数据集较大的时候会消耗不少时间。如果你只是处理几十个病例或者你的场景对每条数据的处理逻辑要求完全可控我更推荐自己写XML解析。解析LIDC的XML并不复杂用Python内置的xml.etree.ElementTree就能完成逻辑清晰、无额外依赖出了问题也知道去哪查。而且自己解析一遍之后你对标签结构的理解会比直接用库深刻得多后面调参和debug都会顺手一些。我一般会两条路线都保留快速验证某个想法时用pylidc写正式的数据处理管线时用自己写的解析器。因为pylidc返回的mask已经做了坐标转换和尺寸归一适合快速跑通流程但如果你要实现自定义的标签融合策略或者要精确控制采样范围自研解析更灵活。下面的代码以自研解析为主方便你理解底层发生了什么。4.2 读取XML并提取结节轮廓坐标下面这段代码演示如何从单个病例的XML中提取所有医生的结节轮廓。我们先解析出readingSession再遍历每个unblindedReadNodule拿到轮廓点坐标和特征评分。import xml.etree.ElementTree as ET import numpy as np import pydicom def parse_lidc_xml(xml_path): tree ET.parse(xml_path) root tree.getroot() nodules [] # 每个元素是一个结节的全部标注信息 # 遍历所有 readingSession对应不同医生 for session in root.findall(readingSession): # 每个 session 里可能有多个 unblindedReadNodule for nodule in session.findall(unblindedReadNodule): nodule_dict {} nodule_dict[roi] [] # 存储每一层的轮廓点 # 读取特征评分 chars nodule.find(characteristics) if chars is not None: for tag in [malignancy, spiculation, margin, texture, sphericity, subtlety, calcification, internalStructure]: elem chars.find(tag) nodule_dict[tag] int(elem.text) if elem is not None else -1 # 读取所有 roi每层一个 for roi in nodule.findall(roi): z_index int(roi.find(imageZposition).text) # 该层在体数据中的位置 edge_map roi.find(edgeMap).text # 轮廓点的字符串表示 # 解析坐标对格式为 x,y x,y ... points [] for pair in edge_map.strip().split(): x_str, y_str pair.split(,) points.append([float(x_str), float(y_str)]) nodule_dict[roi].append({ z: z_index, points: np.array(points) }) nodules.append(nodule_dict) return nodules代码逻辑上需要注意两点第一imageZposition字段并不是切片索引而是该层在图像坐标系中的物理位置值这里先把它当作排序依据后续要换算成体数据索引。第二edgeMap里的坐标是浮点数单位是像素但坐标系原点在图像左上角x轴向右、y轴向下和DICOM的像素坐标方向一致暂不需要翻转。如果你要生成结节mask需要一个关键前提你知道该病例每个CT切片对应的ImagePositionPatient用它可以建立从XML里的imageZposition到具体dcm切片的映射关系。匹配时用浮点数相等判断会有风险我们后面避坑章节里会专门说这个问题。4.3 用pydicom读取DICOM几何信息并构建坐标映射刚才的解析只拿到了轮廓在“平面内”的坐标要把它映射到具体CT切片上还需要知道每个dcm文件的真实空间位置。下面的代码演示如何读取一个CT序列目录并建立切片位置映射表。import os import glob import pydicom def build_slice_mapping(dicom_dir): dcm_files glob.glob(os.path.join(dicom_dir, *.dcm)) slices [] for f in dcm_files: ds pydicom.dcmread(f) slices.append({ filename: f, ImagePositionPatient: list(ds.ImagePositionPatient), SliceLocation: float(ds.SliceLocation), InstanceNumber: int(ds.InstanceNumber), PixelSpacing: list(ds.PixelSpacing), RescaleSlope: float(ds.RescaleSlope), RescaleIntercept: float(ds.RescaleIntercept) }) # 按空间位置排序保证切片顺序正确 slices.sort(keylambda s: s[ImagePositionPatient][2]) return slices这里有一个实际经验单靠SliceLocation做排序有时会出问题因为不同厂商的DICOM对这个字段的填充并不一致有的可能为空或写成0。ImagePositionPatient的z分量更可靠排序结果与真实扫描顺序一致。如果两个切片之间的z间距不为常数说明扫描存在非均匀层间距后续做三维重建时要特别小心重采样。拿到切片映射表后需要做一个字典把每个切片的ImagePositionPatient的z值映射到它在排序后数组中的索引。XML里的imageZposition应该与某个切片的z值相等。遍历所有切片记录下z到索引的对应关系然后对于结节的每一层轮廓用对应的切片索引定位mask在体积数组中的z轴位置。找不到完全相等时取最接近的两个切片插值。4.4 生成结节二值Mask并保存为NIfTI或npy格式轮廓点要在三维体积上形成mask最直接的方法是先创建全零的三维数组维度为切片数行数列数然后在每个轮廓对应的切片上使用多边形填充。skimage.draw.polygon能把一组顶点围成的多边形变成二值区域填充完成后写入对应z层。from skimage.draw import polygon import numpy as np import nibabel as nib def contour_to_mask(nodule_rois, slice_mapping, rows, cols): # 初始化三维mask维度为 (z, y, x) mask np.zeros((len(slice_mapping), rows, cols), dtypenp.uint8) for roi in nodule_rois: z_value roi[z] # 建立 z值 - 切片索引的查找表 idx find_slice_index(z_value, slice_mapping) if idx is None: continue pts roi[points] # (N, 2) rr, cc polygon(pts[:, 1], pts[:, 0], shape(rows, cols)) mask[idx, rr, cc] 1 return mask def find_slice_index(z_value, slice_mapping): # 遍历切片表找最近的z best_idx None best_diff float(inf) for i, s in enumerate(slice_mapping): diff abs(s[ImagePositionPatient][2] - z_value) if diff best_diff: best_diff diff best_idx i # 阈值判断超过层间距的一半视为匹配失败 if best_diff 1.0: return None return best_idx生成三维mask后通常会保存成一个.npy文件方便直接加载。需要共享给其他工具时可以转成NIfTI格式。保存时注意方向矩阵LIDC的CT图像是标准的轴向视图但不同重建设备可能具有不同方向保存NIfTI时最好把方向信息一并写入affine矩阵否则后续在ITK-SNAP里看会翻转。这段流程跑通后你应该能从原始dcmXML得到一个三维二值mask。但请记住这只是“一位医生对一个结节”的mask。要做金标准整合需要把同一个结节在不同医生下的mask映射到同一个坐标系里然后逐像素做投票融合生成一个最终的mask和多数组标签。5. LIDC数据处理避坑指南坐标偏移、重复切片与医院数据差异5.1 坐标原点不一致导致的整体偏移现象生成的mask在视觉上整体向右下偏移了约半个像素看起来像边缘错位。原因DICOM像素坐标x,y的第0个像素位于图像左上角的中心或左上角边缘不同设备存储时对坐标原点的定义存在微妙差异。XML里记录的轮廓坐标是以图像左上角为原点的坐标而skimage做数组索引时是以像素网格顶点为原点的。这两者之间的偏移在奇数尺寸图像上会表现为固定偏移。解决在使用skimage.draw.polygon时把轮廓点x和y分别加上0.5再取整通常能消除这个偏移。如果做了坐标缩放需要重新计算偏移量并应用到缩放后的mask上。5.2 XML里的z值与任何切片的位置都对不上现象某个结节的轮廓在所有ImagePositionPatient中都找不到对应项。打印出z值列表后发现一个病例内z值是乱序的或者数值看起来像是被四舍五入过。原因LIDC的标注过程中医生使用的图像层位置可能与最终下载的CT序列不是完全同一套重建。部分病例的CT扫描存在多个序列或重建核标注只针对其中某一个序列而你的数据包里的序列不是对应那个序列。还有某些厂商在DICOM中把z值保存为整数毫米而XML记录的是带小数点的浮点值匹配失败。解决先用build_slice_mapping把所有切片按z排序并打印确认是否存在多个不连续的z区间。如果存在多个序列或多次扫描找含有切片数量最多的那个序列或者用SeriesNumber区分不同序列后分别处理。z值匹配的判定阈值放宽到层间距的一半同时打印出所有候选值人工核对。5.3 同一个结节的医生标注融合后mask比预期小很多现象四位医生的mask叠加后多数像素区域只有1票或2票票数达到3票以上的区域非常稀疏最终提取出的结节中心空洞、边缘破碎。原因医生间的轮廓画法差异比想象中大。有的医生把结节边缘画得很贴近实体有的医生会刻意留出一些间隙有的则把周围毛刺都包含进去。投票融合时如果每个医生的mask都只占真实范围的80%叠加后交集会更小。解决不要直接投票先在投票前各自对每个医生的mask做一次“膨胀”通常用半径为2到3像素的圆盘结构元素做形态学膨胀扩大每个mask的覆盖范围。然后再按3票阈值做融合得到的金标准mask更符合直觉。另外在计算最终标签时恶性肿瘤评分也应采用中位数而不是均值因为个别医生的极端评分会对平均值产生较大扰动。5.4 部分结节在三维上呈“孤岛状”出现现象某些结节生成的mask在z轴上只有1层到2层看起来不像真实结节更像噪声点。但XML里确实有这个节点标注。原因这一般发生在小结节直径小于3mm上它们本身在CT上就只有1到2个切片可见。也有一些情况是医生只在一两个层面上标了轮廓后续层面忘记标注或软件只记录了部分轮廓。解决如果做检测任务建议先过滤掉这类在z轴上只有1层的标注。如果做分类任务这类结节可以通过在mask周围裁剪一个固定大小的立方体比如24x24x24体素来保留上下文信息。不过要留意由单层标注生成的2D切片在三维模型里会引入噪声标签训练时需要根据任务决定丢还是留。5.5 不同病例的切片数不一致导致三维网络输入需要重采样现象将多个病例的mask堆叠成batch时出现维度不匹配的报错。有的病例有200层切片有的只有120层。原因LIDC病例来自不同医院、不同扫描设备扫描范围有差异切片厚度和层间距也不统一。直接输入固定尺寸的三维网络必然出问题。解决统一重采样到各向同性体素例如1.5mm x 1.5mm x 1.5mm或1mm x 1mm x 1mm或按固定切片数做中心裁剪和填充。重采样可以用scipy.ndimage.zoom但要注意对mask使用最近邻插值对CT值使用线性插值。如果做的是切片的2D模型这个问题不存在做3D模型时必须认真处理下面是常用重采样方法。import scipy.ndimage as ndimage def resample_volume(volume, old_spacing, new_spacing): # 计算各轴缩放因子 zoom_factor old_spacing / new_spacing # 对CT值使用线性插值对mask使用最近邻 resampled_ct ndimage.zoom(volume, zoom_factor, order1) return resampled_ct重采样后要注意验证一下结节的实际尺寸是否被改变用mask的物理体积计算一次若重采样前后的体积误差超过5%大概率是缩放因子计算或插值方式出了问题。6. 标签验证与一致性评估把自己的数据管线做得更可信手工解析完XML并生成mask后不要急着拿去训练。LIDC是多医生标注数据标签本身带有主观性验证阶段至少要检查三个维度一是金标准mask是否合理覆盖了所有医生的标注二是特征评分与mask内容是否一致三是训练集的类别分布是否平衡且符合研究假设。我常用的验证方法是计算每位医生mask与金标准mask之间的Dice相似系数。正常情况下每位医生与金标准的Dice应该在0.8以上如果某位医生明显低于0.7你要么是坐标偏移了要么是该医生与多数医生分歧太大。打印Dice矩阵后能直观看出哪些结节是所有医生都标注一致、哪些是分歧大的那些分歧大的结节建议从训练集中剔除。另一个值得做的验证是“质心距离检查”。把每个医生标注的结节质心与金标准mask的质心距离计算出来如果某个医生标注的质心偏离金标准超过一个结节直径大概率是XML解析时从不同序列取错了切片。这类错误单纯看Dice有时发现不了因为mask形状相似但位置不同时Dice可能还是偏高。质心距离检查更灵敏。训练前最后一步建议画出每个结节的二维中心切片图并把mask轮廓重叠显示在CT图上逐个人眼确认50到100个样本。这一步虽然费时间但能有效发现代码里的系统性错误。原因很简单自动化解析流程中的bug往往都在坐标变换上靠数值检查很难发现眼睛扫一遍却能直接看到偏移方向。到了这个阶段你已经拥有了一份可以训练的LIDC数据集标签格式从XML变成了numpy数组或NIfTI文件。后续可以按任务需求自行导出自定义的CSV或JSON格式。我在自己的流程里会把每个结节导出为一个独立文件夹包含中心坐标、尺寸、医生数量、特征向量和mask路径这样在模型迭代时不需要重新解析原始数据。这个习惯帮我躲过不少坑希望也能帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑