简介面向肺部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 到 edgeMapXML 的层级结构决定了你要在哪个节点取数据。简化后大概是这样的树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, fieldnamesrows[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范围 15值越大越可能是恶性。这五个值不是硬标签而是医生主观评分。把它转成二分类不是简单阈值 3 分而是要考虑任务目标。我的习惯是分三档1、2 归为良性4、5 归为恶性3 直接剔除不参与训练。原因有两个评分 3 本身代表“不确定”硬归到哪一类都会引入噪声标注者之间的不一致在 3 分附近最严重剔除后训练集更干净。另一个做法是把 15 当作回归目标让模型学连续性这对数据量小的时候更稳但推理时要设计好阈值。characteristics里的边缘特征毛刺、分叶、光滑度字段经常被忽略但对模型可解释性有帮助。如果你想训练一个“输出结节属性”的多任务模型这些字段需要小心处理每个字段有多个取值比如spiculation不是简单的有/无而是 15 的评分。即便如此很多样本没有这个子节点缺失率可能超过 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], isClosedTrue, color(0, 0, 255), thickness2) cv2.imwrite(check_roi.png, img_bgr)这段代码把 DICOM 像素归一化到 0255再画红色闭合多边形。逻辑上cv2.polylines的坐标必须是整数二维数组所以用astype(np.int32)转换。参数层面normalize的NORM_MINMAX会拉伸 CT 窗宽对肺窗来说可能让病灶对比度不够更稳妥的是用ds.WindowCenter和ds.WindowWidth做窗宽窗位映射。做这个抽查时最好随机挑 20 个结节分别来自不同病例。如果发现边界整体偏移、旋转、放大缩小多半是 DICOM 里的 PixelSpacing 没有参与换算或者用了错误的层。如果只是个别点异常再回 XML 看 edgeMap 是否有多边形自交那属于原作者数据质量问题。4. 分类、检测、分割各要什么标签三种任务三种构造方式4.1 二分类标签malignancy 分数怎么映射成正负样本分类任务最直接的标签是malignancy。但“直接使用”不是把 15 转成 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 序列避免训练脚本里反复解析 XMLmask np.zeros((height, width), dtypenp.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。阈值不要设太死医生对边界判断差异可能导致质心偏移 23 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 上漏检了直径较大的结节但同一结节在相邻几层看得很清楚。原因大结节通常跨 510 层但某些层上边界不闭合或者与血管截面相似。如果只取中心层作为训练框模型学到的特征不稳定。解决2D 检测时把每个结节的每一层都作为正样本但框大小按该层实际边界计算。不要强行把所有层都扩成同一个最大框那样模型会混淆边界。中心层的框最接近真实尺寸边缘层的框小而偏模型反而能学到“跨层特征”。如果做 3D 检测则要确保 z 范围覆盖结节实际延伸范围并额外扩 2 层上下文。5.5 XML 解析报编码错非 UTF-8 的标注文件现象用ElementTree.parse解析某个病例的 XML直接抛出ParseError提示编码问题或非法字符。原因部分早期文件不是标准 UTF-8 编码或者文件头没有声明编码而 Python 默认按 UTF-8 读取。解决先按字节读入用unicode_escape或errorsignore处理。更好的方式是用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 里的标准环节后面才会顺利。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?