简介:面向肺部CT影像分析与Python开发者的LIDC数据说明与标签资源,用于解决LIDC-IDRI数据集中标注文件格式多样、字段含义不清、难以直接用于建模的问题,适合在肺结节检测、分割以及机器学习项目前期做数据梳理和预处理时参考。压缩包内共有1323个文件,大小仅6.44MB,其中1319个XML文件为核心标注信息,逐一给出了每个CT切片上结节的三维坐标、直径和形状描述;其余CSV、XLS、XLSX表格分别保存了病人标识、扫描序列和放射科医生对结节的计数统计,TXT文件则包含原始影像数据的下载说明及使用注意事项。目前该资源已有282人学习,适合希望快速掌握LIDC标签结构并搭建个人预处理流程的入门与进阶研究者。通过阅读表格和XML样例,读者可以弄清字段间的关系,并借鉴说明中的思路,借助pandas、lxml和pydicom等Python库读取和转换DICOM影像,从而把精力集中在特征提取和模型训练上,减少数据整理带来的负担。
1. LIDC 数据到底是什么:一份标注里藏着两套“真相”
第一次打开 LIDC 相关数据的人,十有八九会被它的存储方式吓一跳:一整个病例的 CT 图像打包成几十个 DICOM 文件,旁边再放一个 XML 文件,标注就藏在那一堆嵌套标签里。这个数据集的价值在于,它不是只有一个“标准答案”,而是多位医生对同一份 CT 的独立标注。换句话说,同一个结节在 XML 里可能出现好几遍,每一遍都属于不同医生的判断,这些判断彼此不一定一致。这份“不一致”恰恰是训练鲁棒模型最值钱的地方。本文适合那些已经拿到或准备申请 LIDC 数据、正卡在“怎么把 XML 变成能喂给模型的标签”这一步的从业者,内容会按原始结构、解析脚本、标签构造和踩坑顺序展开。
2. 拆解 LIDC 原始数据:DICOM 序列与 XML 标注的对应关系
2.1 目录结构:一次下载下来是什么样
LIDC 数据一般按病例(Patient)组织,每个病例一个文件夹,里面包含 DICOM 文件和对应的 XML。常见的目录长这样:
LIDC/ └── patient_id/ ├── 000001.dcm ├── 000002.dcm ├── ... ├── 000200.dcm └── annotation.xmlDICOM 文件是 CT 的轴向切片,每个文件代表一个层,按 InstanceNumber 排序后就是连续的体数据。annotation.xml 记录的是医生在阅片过程中画出的结节边界、主观评分和形态特征。
必须理解的一个点:DICOM 和 XML 之间存在一个隐式关联。XML 里每个 ROI 都会引用一个imageSop_UID,这个 UID 和 DICOM 文件里的 SOPInstanceUID 是对应的。解析时不能只按文件名对齐,正确做法是先读 DICOM 的 SOPInstanceUID,再在 XML 里查找匹配的 ROI。我见过不少人直接按文件顺序硬切,结果 slice 对错,标注画到空气上。
目录层面还有一个容易忽略的地方:有的病例包含多个 Series(例如平扫和增强),但 XML 通常只对应其中一个 Series。实际操作中我会用SeriesInstanceUID先锁定 XML 涉及的那个序列,再过滤 DICOM 文件,避免把不相关序列混进训练集。
2.2 XML 里如何描述一个肺结节:从 readingSession 到 edgeMap
XML 的层级结构决定了你要在哪个节点取数据。简化后大概是这样的树:
<patient> <study> <series> <readingSession> <unblindedReadNodule> <roi> <imageSop_UID>...</imageSop_UID> <imageZposition>...</imageZposition> <edgeMap> <x>...</x> <y>...</y> </edgeMap> </roi> </unblindedReadNodule> <nonNodule>...</nonNodule> </readingSession> </series> </study> </patient>一个readingSession代表某位医生的一次独立阅片会话。会话里所有标注都以unblindedReadNodule为节点,每个节点是一个结节。结节的边界信息不在节点属性里,而在它的多个roi子节点中:每个roi对应一个 CT 切片,edgeMap里的 x、y 坐标串起来就是边界多边形。
这里有个反直觉的点:edgeMap只记录边界点,不记录这个结节是“实心还是空泡”。形态特征全部挂在unblindedReadNodule的characteristics里,比如分叶、毛刺、钙化,但这些字段可能缺失,如果直接拿它们当分类输入,你会发现大量样本没有值,需要单独做缺失处理。
写一个小脚本把结构打印出来,最容易看出哪些字段是空的:
import xml.etree.ElementTree as ET tree = ET.parse("annotation.xml") root = tree.getroot() for pat in root.findall(".//patient"): for reading in pat.findall(".//readingSession"): reader = reading.find("readerID").text for nod in reading.findall("unblindedReadNodule"): nod_id = nod.get("id") rois = nod.findall("roi") print(reader, nod_id, len(rois)) for roi in rois[:1]: sop = roi.find("imageSop_UID").text zpos = roi.find("imageZposition").text edge_x = [int(v.text) for v in roi.findall(".//edgeMap/x")] edge_y = [int(v.text) for v in roi.findall(".//edgeMap/y")] print(" sop:", sop, "z:", zpos, "points:", len(edge_x))这段代码遍历每位医生会话里的每个结节,打印标注者 ID、结节 ID、ROI 数量和第一个 ROI 的边界点数。输出后你会看到不同结节的 ROI 数量差异很大:有的只在一层切片上有边界,有的横跨十几层,后者才可能是真正需要处理的立体结节。逻辑上,edge_x和edge_y必须成对读取,因为它们共同构成一个闭合多边形。参数层面的重点在于zpos,它的单位是毫米而不是层序号,如果你的程序按切片文件名顺序当 z 用,后面做体素归一化时会错位。
2.3 坐标体系:毫米与像素同时出现,先分清再动手
LIDC 的坐标体系是一个经典的混合体。ROI 里的edgeMap坐标是 CT 图像上的像素坐标,x 代表列,y 代表行,起始点是 0。而imageZposition是 DICOM 里 ImagePositionPatient 的第三个值,单位是毫米。也就是说,二维平面用的是像素整数,第三维用的是毫米浮点数。
这两套单位混用,最常见的结果是:你拿着 x、y 像素坐标找 DICOM 像素没有错,但当你把 z 毫米当成层号去索引数组时,轻则偏移几层,重则完全落在结节外面。正确的做法是从 DICOM 的 ImagePositionPatient 里读出每一层的 z 值,建立“z 毫米 -> 数组索引”的映射,再去找离imageZposition最近的那一层。如果 CT 层厚是 1 mm,映射是线性的;如果是 1.5 mm 或非均匀重建,就不能用简单除法。
给一个非常小的建议:解析后的中间结果先保存成统一的坐标单位,我习惯把所有 z 先转为层索引,因为后续切片、做 2D 检测、取 patch 都基于层索引更顺。只有当需要计算物理距离(比如结节真实直径)时才用毫米。两套坐标同时保留在数据结构里,但用字段名区分好,不要在一个变量里混存。
3. 自己动手生成标签:从 XML 到 CSV/JSON 的完整流程
3.1 最小解析脚本:读取 nodule 与 roi
清楚了 XML 结构,下一步就是把标注落成结构化表。下面这段脚本完成三件事:读取所有结节、对每个 ROI 计算最小外接矩形、按结节 ID 汇总成一行。这一步产出的 CSV 已经可以直接用于 2D 目标检测。
import xml.etree.ElementTree as ET import csv def parse_nodules(xml_path): tree = ET.parse(xml_path) rows = [] for nod in tree.findall(".//unblindedReadNodule"): nod_id = nod.get("id") for roi in nod.findall("roi"): xs = [int(v.text) for v in roi.findall("./edgeMap/x")] ys = [int(v.text) for v in roi.findall("./edgeMap/y")] if not xs or not ys: continue sop = roi.find("imageSop_UID").text z = float(roi.find("imageZposition").text) x_min, x_max = min(xs), max(xs) y_min, y_max = min(ys), max(ys) rows.append({ "nodule_id": nod_id, "sop_uid": sop, "z_mm": z, "x_min": x_min, "x_max": x_max, "y_min": y_min, "y_max": y_max, "area_px": (x_max - x_min) * (y_max - y_min), }) return rows rows = parse_nodules("annotation.xml") with open("nodules.csv", "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=rows[0].keys()) writer.writeheader() writer.writerows(rows)这段脚本把每个二维 ROI 变成一行记录,x_min/x_max/y_min/y_max是包围盒边界,area_px是像素面积。逻辑上要注意:每个结节有多个 ROI,所以一个结节会对应多行,还没合并;nodule_id在同一文件内唯一,但不同文件的同一病变不一定 ID 相同,跨文件汇总时不能直接拿它当全局主键。参数层面,roi.findall("./edgeMap/x")的路径写法需要你的 XML 里 edgeMap 是 roi 的直接子节点,如果有中间层,改成.//edgeMap/x更保险。
这个脚本不做任何坐标截断。对于靠近图像边缘的结节,边界点可能落在合法坐标范围内,但包围盒可能超出 DICOM 宽度,后续可视化会越界,应在最终数据处理里 clamp 到图像尺寸。
3.2 标签清洗:恶性程度、直径与边缘特征怎么取舍
XML 里真正被大量实验使用的字段是malignancy,范围 1~5,值越大越可能是恶性。这五个值不是硬标签,而是医生主观评分。把它转成二分类不是简单阈值 3 分,而是要考虑任务目标。
我的习惯是分三档:1、2 归为良性,4、5 归为恶性,3 直接剔除,不参与训练。原因有两个:评分 3 本身代表“不确定”,硬归到哪一类都会引入噪声;标注者之间的不一致在 3 分附近最严重,剔除后训练集更干净。另一个做法是把 1~5 当作回归目标,让模型学连续性,这对数据量小的时候更稳,但推理时要设计好阈值。
characteristics里的边缘特征(毛刺、分叶、光滑度)字段经常被忽略,但对模型可解释性有帮助。如果你想训练一个“输出结节属性”的多任务模型,这些字段需要小心处理:每个字段有多个取值,比如spiculation不是简单的有/无,而是 1~5 的评分。即便如此,很多样本没有这个子节点,缺失率可能超过 30%,不能用 0 填充,要单独加一个“未知”类别。
直径字段在 XML 里没有直接暴露为单一标量,但可以从 ROI 包围盒估算。更稳定的是取所有 ROI 中最大面积包围盒的等效直径,而不是第一层的直径,因为结节最大径往往不出现在几何中心层。用面积反推直径的公式是d = 2 * sqrt(area / pi),如果要做物理尺寸,别忘了乘上像素间距(PixelSpacing)。
3.3 一致性校验:把标注画回 DICOM 看一眼
解析后的标签如果直接拿去训练,错误会在几轮迭代后才暴露,排查成本极高。我强烈建议在生成 CSV 后立刻做一次可视化抽查,加载 DICOM 图像,把 edgeMap 点连成多边形,叠加在图像上保存为截图。
import pydicom import cv2 import numpy as np ds = pydicom.dcmread("000012.dcm") img = ds.pixel_array.astype(np.float32) img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) img_bgr = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) # 假设已经从 XML 取出当前 SOP 对应的边界点 xs = [212, 215, 220, 218, 210] ys = [150, 148, 155, 162, 158] pts = np.column_stack([xs, ys]).astype(np.int32) cv2.polylines(img_bgr, [pts], isClosed=True, color=(0, 0, 255), thickness=2) cv2.imwrite("check_roi.png", img_bgr)这段代码把 DICOM 像素归一化到 0~255,再画红色闭合多边形。逻辑上,cv2.polylines的坐标必须是整数二维数组,所以用astype(np.int32)转换。参数层面,normalize的NORM_MINMAX会拉伸 CT 窗宽,对肺窗来说可能让病灶对比度不够,更稳妥的是用ds.WindowCenter和ds.WindowWidth做窗宽窗位映射。
做这个抽查时,最好随机挑 20 个结节,分别来自不同病例。如果发现边界整体偏移、旋转、放大缩小,多半是 DICOM 里的 PixelSpacing 没有参与换算,或者用了错误的层。如果只是个别点异常,再回 XML 看 edgeMap 是否有多边形自交,那属于原作者数据质量问题。
4. 分类、检测、分割各要什么标签:三种任务三种构造方式
4.1 二分类标签:malignancy 分数怎么映射成正负样本
分类任务最直接的标签是malignancy。但“直接使用”不是把 1~5 转成 0/1 那么简单。你需要先确定训练单位:是以结节为单位,还是以切片为单位。以结节为单位时,一个结节可能跨多层,每层外观差异很大,通常会取最大层或合成多视角;以切片为单位时,同一结节的多层会变成多条样本,容易出现信息泄漏——训练集和验证集切分时要保证同一结节的所有层都落在同一侧。
面对多位医生评分不一致的情况,我见过两种靠谱的聚合方式。第一种是取多数投票:如果 4 位医生中存在两个 4 分、两个 5 分,归为恶性;两个 2 分、两个 3 分,一般剔除。第二种是取均值后映射:均值大于等于 3.5 为恶性,小于等于 2.5 为良性,中间剔除。第一种更贴近临床共识,第二种更简单。实际效果差别不大,关键是你要在文档里写明规则,否则复现的人会困惑。
正负样本不均衡是必然的,良性结节数量显著多于恶性。训练时建议用加权采样或者pos_weight调整,而不是简单粗暴地复制恶性样本。如果你做的是 3D 分类网络,输入块大小建议覆盖结节周围至少 32 像素的上下文,仅裁剪结节本身会丢失胸膜、血管等关键背景信息。
4.2 检测标签:从 ROI 集合还原结节框
目标检测训练需要 axis-aligned bounding box。LIDC 的标注是边缘点,你必须先转框。2D 检测直接在每一层生成框,3D 检测则要把不同层的框合成一个 3D 框。
下面代码演示如何把一个结节的多个 ROI 合成一个 3D 框:
def nodule_to_3d_box(rois, slice_indices): x_mins, y_mins, z_mins = [], [], [] x_maxs, y_maxs, z_maxs = [], [], [] for roi, slice_idx in zip(rois, slice_indices): xs = [int(v.text) for v in roi.findall("./edgeMap/x")] ys = [int(v.text) for v in roi.findall("./edgeMap/y")] x_mins.append(min(xs)); x_maxs.append(max(xs)) y_mins.append(min(ys)); y_maxs.append(max(ys)) z_mins.append(slice_idx); z_maxs.append(slice_idx) return [min(x_mins), min(y_mins), min(z_mins), max(x_maxs), max(y_maxs), max(z_maxs)]这段代码把每个 ROI 的最小包围盒沿 z 方向合并,最终输出的四元组(x_min, y_min, z_min, x_max, y_max, z_max)可以直接用做 3D 检测网络的目标。逻辑上要注意:不同医生画出的边缘点不完全重合,框的大小会差几像素,合并时先统一到同一坐标系统,否则会有系统性偏移。参数层面,slice_indices要传入 z 毫米映射后的层索引,不能直接拿 ROI 在 XML 里的顺序当索引。
还有一个检测任务独有的坑:一个结节在 CT 上可能只出现在两三层,而模型输入步长是 4 或 8,框的高度只有 3 层,下采样后可能缩没。处理办法有两个,一是对小结节做上采样训练,二是把 3D 框的 z 范围向外扩到至少等于网络步长。扩框会增加误检,但总比漏检好。
4.3 分割标签:edgeMap 边界点如何变成 mask
分割任务需要像素级 mask,而 LIDC 的 edgeMap 只是边界折线,需要填充成实心区域。用 OpenCV 的fillPoly是最直接的方式。需要注意,不同医生对同一结节的边界可能相差很大,分割任务需要决定用谁的轮廓作为 GT。
常见做法是取多个医生的标注做并集(union),因为并集对边界区域的覆盖更完整,训练出的模型不容易只学会某一位医生的风格。另一种做法是每个医生单独生成一个 mask,让模型输出多个通道,但这会显著增加模型复杂度,早期实验不建议。
生成 mask 的代码推荐放在解析阶段完成,输出为 NIfTI 或 PNG 序列,避免训练脚本里反复解析 XML:
mask = np.zeros((height, width), dtype=np.uint8) polygons = [] for roi in nodule_rois: xs = [int(v.text) for v in roi.findall("./edgeMap/x")] ys = [int(v.text) for v in roi.findall("./edgeMap/y")] polygons.append(np.column_stack([xs, ys]).astype(np.int32)) cv2.fillPoly(mask, polygons, 1)这段代码把同一结节的所有 ROI 多边形全部填充到一张 mask,值为 1。逻辑上,如果这些 ROI 来自不同切片,它们其实应该分别写入对应深度的 mask 体,而不是全画在二维图上;这里只展示单层情况,多层循环即可。参数层面,fillPoly要求每个 polygon 是一个 N×2 的 int32 数组,坐标顺序没有强制要求,自交多边形也能填充,但结果可能不是你想要的形状。
做分割训练前,一定要检查 mask 是否有孤立小点。偶发的孤立点通常来自 edgeMap 里多余的坐标,用形态学开运算去掉即可。如果 mask 里出现大范围断裂或空洞,问题多半出在填充时把多边形坐标缩放错了。
5. LIDC 标签避坑指南:新手最容易翻车的 5 个细节
5.1 同一结节被多个医生标注,取谁不取谁?
现象:解析后统计结节数量,发现一个 CT 里有 8 个结节,但实际横断面只有 3 个,剩下都是同一个结节被不同医生标注的副本。
原因:LIDC 的readingSession之间互相独立,没有给跨医生标注做显式关联,同一病变在不同会话里会生成不同 ID,边界点也不完全一致。
解决:用空间位置聚类。我通常把所有结节的质心坐标换算成物理坐标,然后做距离匹配,两个结节质心距离小于一个阈值(比如 5 mm)就认为是同一病变。匹配后再重新指派一个全局 ID。阈值不要设太死,医生对边界判断差异可能导致质心偏移 2~3 mm,设 3 mm 容易拆散,设 6 mm 又可能合并相邻结节,需要根据自己的 CT 层厚微调。
5.2 坐标单位不一致:x/y 是像素,z 是毫米
现象:把标签画进 3D 数组时,结节位置沿 z 轴偏了大约几十层,但二维切片上又看不出问题。
原因:imageZposition的单位是毫米,而三维数组的索引必须是层号。不同病例的层厚、层间距不同,直接拿 z 当索引就会错。
解决:读取每个 DICOM 的 ImagePositionPatient,构建z_mm -> instance_index映射。实际写代码时,每个病例都要先建一个排序后的 z 列表,再用np.argmin(np.abs(z_list - target_z))找最近一层。注意层厚大于 z 间距时,这层索引可能对应空白重建层,需要过滤。
5.3nonNodule到底要不要进负样本?
现象:训练二分类器时,负样本数量爆炸,而且测试时模型把正常组织误报为结节。
原因:XML 里除了unblindedReadNodule,还有nonNodule节点。很多人把nonNodule直接当负样本,但这些标注本身也是医生标出的“疑似但不是结节”区域,和完全正常的肺组织不同。用它做负样本会让模型学出“有边界的东西就是结节”的倾向。
解决:nonNodule只作为难负样本,不全部加入。我会随机抽样不超过结节数量一半的nonNodule进训练,同时在采样时避开与结节重叠的区域。真正的负样本应该从没有标注的区域随机裁剪,配合难负样本挖掘使用。
5.4 跨层结节的 2D 框只取一层会漏
现象:模型在单层 CT 上漏检了直径较大的结节,但同一结节在相邻几层看得很清楚。
原因:大结节通常跨 5~10 层,但某些层上边界不闭合,或者与血管截面相似。如果只取中心层作为训练框,模型学到的特征不稳定。
解决:2D 检测时把每个结节的每一层都作为正样本,但框大小按该层实际边界计算。不要强行把所有层都扩成同一个最大框,那样模型会混淆边界。中心层的框最接近真实尺寸,边缘层的框小而偏,模型反而能学到“跨层特征”。如果做 3D 检测,则要确保 z 范围覆盖结节实际延伸范围,并额外扩 2 层上下文。
5.5 XML 解析报编码错:非 UTF-8 的标注文件
现象:用ElementTree.parse解析某个病例的 XML,直接抛出ParseError,提示编码问题或非法字符。
原因:部分早期文件不是标准 UTF-8 编码,或者文件头没有声明编码,而 Python 默认按 UTF-8 读取。
解决:先按字节读入,用unicode_escape或errors="ignore"处理。更好的方式是用ET.fromstring(data)并显式指定编码:
with open("annotation.xml", "rb") as f: raw = f.read() # 优先尝试 UTF-8,失败则用 GBK 或 latin-1 兜底 for encoding in ["utf-8", "gbk", "latin-1"]: try: text = raw.decode(encoding) break except UnicodeDecodeError: continue root = ET.fromstring(text)这段代码在解码失败时自动尝试下一个编码。逻辑上,latin-1几乎不会失败,但也可能解出乱码,所以最好在解码后先print(root.tag)验证。参数层面,如果你发现只有个别文件编码异常,不建议全局换编码,而是单独把这些文件抽出来处理,保证主流程仍是标准的 UTF-8。
6. 验证标签的小技巧:把你的标注画回 CT 图像上
前面提到过可视化校验,但它值得单独展开,因为这是整个标签处理流程里性价比最高的一步。把每个结节的包围盒、边界多边形和 malignancy 分数直接画在 CT 原图上,再按病例翻一遍,绝大多数错误一眼就能看出,根本不用等模型训练完。
我常用的脚本会同时画三样东西:绿框是当前医生的 ROI 包围盒,红线是另一位医生的边界,左上角写 malignancy 分数。这样一眼能看出跨医生的一致性。如果两个边界之间空隙过大,可能是匹配错了;如果分数和边界形状明显矛盾(例如边界极不规则却给了 1 分),要考虑原始标注本身是否可靠。
除此之外,建议把所有可视化结果按结节 ID 命名,存成一个子目录。这样在清洗数据时可以直接按名字筛选,不用重新跑解析。对大批量数据,还可以把可视化脚本接上小图索引:生成一个 HTML,每个结节一行,放上中心层截图和分数,半小时能过完 100 个病例。这比一张张打开图片高效得多。
我自己的习惯是:XML 解析脚本写完后,先不急着做任何训练,强制自己把所有病例可视化一遍。第一遍通常是崩溃的——不是坐标错位,就是漏了某个字段。这些错误在训练时根本不会报错,只会默默污染模型。只有把可视化这一步固定成 pipeline 里的标准环节,后面才会顺利。希望帮到你。
本文还有配套的精品资源,点击获取