简介:面向使用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解析还是用pylidc
LIDC的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(key=lambda 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), dtype=np.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里看会翻转。
这段流程跑通后,你应该能从原始dcm+XML得到一个三维二值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, order=1) 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路径,这样在模型迭代时不需要重新解析原始数据。这个习惯帮我躲过不少坑,希望也能帮到你。
本文还有配套的精品资源,点击获取