news 2026/10/24 2:34:50

LIDC胸部CT肺结节标注解析:XML、轮廓转掩码与数据划分避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
LIDC胸部CT肺结节标注解析:XML、轮廓转掩码与数据划分避坑

简介:面向医学影像处理、肺结节检测与分割的学习者和研究者,这是一份围绕LIDC-IDRI肺部CT影像整理的数据说明与标签资料,尤其适合在Python环境下做数据集预研、标注理解与实验复现。压缩包共1323个文件、约6.44MB,主体为1319个XML格式的结节注释,另配CSV、XLS、XLSX和TXT表格与说明文件,集中呈现受试者信息、扫描元数据、结节计数及医师标注口径。已有283人学习,可作为进入LIDC-IDRI数据集的轻量入口。XML注释覆盖结节位置、数量及三维几何等字段,配合汇总表格可对照病例、序列与标注之间的关系,帮助读者在读取DICOM、解析XML、构建检测或分割训练集前,先厘清标签结构和数据组织方式,减少后续处理中的理解成本,也为课程项目、标注核对和算法实验提供可追溯的数据依据。

1. LIDC 到底是什么:一堆胸部 CT 和四个医生各自画的圈

第一次打开 LIDC 的下载目录,多数人的第一反应是“是不是下错了”:没有现成的 csv 标签表,没有 images 和 labels 两个整齐的文件夹,只有一千多个用编号命名的目录,里面塞满 .dcm 后缀的切片文件,每个病人旁边还躺着一个同名的 XML。这份资料标题里说的“数据说明和标签”,指的其实就是这两样东西——一堆胸部 CT 影像,加上四位胸部放射科医生手工画出来的结节轮廓和打分。

把它搞懂的价值在于:后面所有训练、验证、模型对比,全都建立在这份标注的口径上。口径理解偏了,网络结构调得再花哨也没用。很多人用 python 写的第一版读取脚本,跑通了、不报错、也能出图,但标签其实是错的——这是这份数据最常见的打开方式,也是最贵的坑。

2. 为什么会有这份数据集:标注比片子贵得多

2.1 医学影像的尴尬:图有的是,答案没有

医院里每天产出海量的 CT,但“这一块是不是结节、边界在哪、像不像恶性”这些答案,写在医生的报告文字里,不写在像素里。模型要学的是答案,不是图。而把答案变成模型能读的东西,成本高得吓人:一位有经验的胸部放射科医生,勾一个结节的轮廓,要一层一层地看过去,在每一层上描一圈边界,一个薄层扫描动辄一两百层,勾完一个病例常常要几十分钟。

这就造成一个很别扭的局面——影像数据不缺,带精细标注的影像数据极度稀缺。稀缺到什么程度?很多做肺结节分割的研究,翻来覆去能用的公开数据就那么几份,LIDC 是其中最常被引用的一个。它之所以能成为事实上的基准,不是因为图像有多漂亮,而是因为它把“医生的判断”这件又贵又主观的事情,批量地、结构化地保存了下来。

更关键的是,它保存的不是一个人的判断,是四个人的。这一点决定了后面所有的使用方式,也决定了它和其他“一个病例一个标签”的数据集在性质上完全不同。

2.2 四个人分别看、再一起看,这种“两轮阅片”图什么

LIDC 的标注流程分两轮。第一轮叫盲读:四位医生各自独立看片,互相看不到对方标了什么,谁也不知道别人认为这里有结节、那里没有。第二轮叫非盲读:医生能看到上一轮其他人标的位置,可以补充自己漏掉的,也可以修改自己的判断。

为什么要绕这么一圈?因为如果一开始就让医生们坐在一起商量出一个统一答案,最后只会留下一个“共识”,而把分歧抹掉了。可分歧本身是信息——同一个结节,一个人觉得是良性、一个人觉得可疑,这种不一致恰恰反映了真实世界里的判断难度。两轮设计的结果是,XML 里同时存在blindedReadNodule和unblindedReadNodule两类节点,同一个结节可能既出现一次盲读轮廓,又出现一次非盲轮廓,两次的轮廓点数还不一样。

有个细节很容易被忽略:那个 1 到 5 的恶性程度评分,只在非盲阶段才给。盲读的时候医生只负责“指出这里有没有东西、边界在哪”,不负责下判断。所以如果你只看blindedReadNodule节点,会发现里面根本没有特征打分——不是数据缺了,是设计如此。

提示:做恶性程度相关的任务时,先确认自己读的是非盲节点。用盲读节点去找打分,会得到一片空值,然后误以为数据质量有问题。

2.3 目录里到底躺着什么:影像、XML 和一份说明

一份典型的 LIDC 病例,结构大致是这样:一个以编号命名的病人目录,里面是影像序列文件夹,再往里是逐层的 .dcm 文件;同级的另一个位置放着一个 XML,记录这位病人的全部阅片结果。看着简单,实际拆的时候有三个坎。

第一个坎,目录层级不统一。有的病人只扫过一次,一层就到底;有的病人有多次检查,中间会多出一层日期命名的目录。写脚本时如果假设“病人目录下第一层就是切片”,遇到多次检查的病例就会把两套序列混在一起。

第二个坎,一个病例里可能不止一个序列。除了真正的薄层扫描,还常常夹着定位像——就是那种只有一两张、层厚很厚、用来给技师定位的图。它同样是 .dcm,同样能被读出来,但它不是用来诊断的。正确的做法是先把所有切片按序列标识分组,再挑出层数最多的那个薄层序列。

第三个坎,层厚不一致。不同病例的重建参数不一样,薄的接近亚毫米,厚的到两三毫米。这个差别直接影响后面所有以“毫米”为单位的量:结节的体积、Z 方向的间距、重采样的目标尺度。拿到数据先统计一遍层厚分布,比拿到就开训要省事得多。

目录/文件里面是什么容易踩的地方
病人目录(编号命名)一个病例的全部内容编号不是连续自增,别用 for 循环猜
序列子目录一次扫描的逐层 .dcm可能混入定位像,需按序列分组筛选
单张 .dcm一层 CT 加元数据层厚、像素间距、灰度变换参数都在头信息里
同名 XML四位医生的阅片标注编码、层级、缺失节点都要单独处理

3. 拆开一条标注看:结节、轮廓、特征评分是怎么组织的

3.1 从 LidcReadMessage 往下钻:会话、结节、ROI、点

XML 是层层嵌套的,理解它的形状比记住标签名更重要。最外层是一个读片消息节点,往下是若干“读片会话”,每个会话对应一位医生的一次阅片;会话下面挂着结节;结节下面挂着 ROI;ROI 下面挂着点。四层结构,一层比一层细。

一段真实结构大概是这个样子:

<LidcReadMessage> <readingSession> <unblindedReadNodule> <noduleID>Nodule 001</noduleID> <characteristics> <subtlety>4</subtlety> <malignancy>3</malignancy> </characteristics> <roi> <imageZposition>-117.5</imageZposition> <imageSOP_UID>1.3.6.1.4.1.9590...</imageSOP_UID> <inclusion>true</inclusion> <edgeMap><xCoord>132</xCoord><yCoord>241</yCoord></edgeMap> <edgeMap><xCoord>135</xCoord><yCoord>243</yCoord></edgeMap> </roi> </unblindedReadNodule> </readingSession> </LidcReadMessage>

这段片段里藏着几个必须记住的事实。readingSession是一位医生的,不是整个病例的,所以同一个病例下会有四个会话;noduleID只是这位医生自己编的号,不同医生的“Nodule 001”几乎肯定不是同一个结节,绝对不能拿编号跨会话对齐;characteristics只在非盲节点里出现;roi代表一层,一个结节有多少层就有多少个roi;edgeMap是一个点,一圈轮廓由几十个点按顺序连成。

所以“一个结节”这个概念在这份数据里是分层的:物理上是同一团东西,数据上却可能是四条互不相干的记录,需要你自己去合并。合并的依据不是编号,是空间位置——把不同医生的轮廓换算到同一个坐标系里,看它们是否重叠、中心点距离是否在阈值内。这一步做得好不好,直接决定你后面拿到的是“三百个结节”还是“一千个结节”。

3.2 轮廓点不是像素掩码:坐标、层号与 inclusion 标记

很多人第一次看到xCoord、yCoord会以为这是已经填好的区域,其实它只是一个多边形的顶点。一个结节在一层上的轮廓,可能是二十个点、五十个点,也可能是八十个点,取决于医生描得细不细。要把顶点变成掩码,得自己按顺序连起来再填充,画线和填充的结果差得非常远。

坐标本身的含义也要注意:xCoord是列索引,yCoord是行索引,从 0 开始计数。这和矩阵习惯的 (行, 列) 是反的,和图像库习惯的 (x, y) 是一致的。写代码时array[y, x]还是array[x, y],凭直觉写十次错八次。

imageZposition是这一层的空间位置,imageSOP_UID是这一层的唯一标识。两个都能用来定位切片,但对齐时优先用后者。原因是 Z 位置可能出现重复值、小数精度不一致,甚至有些病例的层间距并不均匀,用位置做键会撞车。用切片标识去匹配文件,是一对一的,稳得多。

inclusion这个标记容易被完全忽略。当它为 false 时,意思是医生在非盲阶段认为这一层其实不属于这个结节,主动把它排除掉了。如果你不判断这个字段,把标注过又被撤销的层也算进去,掩码就会在结节上下多出一两片模糊的“尾巴”,训出来的模型边界会偏松。

提示:小于 3 毫米的结节通常只有一个位置记录,没有轮廓,也没有特征打分。它们不是漏标,是被规则排除在精细标注之外的。

3.3 九个特征和那个 1 到 5 的恶性程度分

非盲节点里挂着一组特征,覆盖边缘是否光滑、是否有分叶、是否有毛刺、内部结构、钙化程度、球形度、纹理、显著度,以及恶性程度。除了内部结构、钙化、纹理这几个是类别型取值,其余大多是 1 到 5 的等级。

恶性程度是其中最常被拿来当标签用的一个:1 表示极不可能是恶性,5 表示极可能是恶性,中间三档留给了“不好说”。问题在于,四个医生给同一个结节的打分经常不一样,差一两分是常态,差两三分也不算罕见。这不是数据脏,这是医学判断本身的性质。

于是“标签怎么定”就变成了一个必须自己拍板的设计题。常见做法有三种:取四位医生的中位数,得到一个相对稳健的序数标签;取均值,把它当成连续值做回归;或者划一条线,比如大于等于 4 算阳性,做二分类。三种做法没有对错,但必须写清楚,因为同一个数据集、同一个模型,换个聚合方式,指标能差好几个点。

还有一种用法容易被低估:把四位医生的分歧度本身当作标签。分歧大,往往意味着这个病例确实难;训练时降低这类样本的权重,或者把它们单独拎出来做误差分析,都比强行凑一个“标准答案”更诚实。

4. 最容易搞混的四组概念:别把轮廓当掩码,别把打分当概率

4.1 轮廓和掩码:一个是多边形,一个是布尔矩阵

易混点在于名字。“标注”这个词既被用来指医生画的圈,也被用来指训练用的掩码图,于是很多人默认拿到 XML 就等于拿到掩码。

之所以会混,是因为在大多数图像分割数据集里,标签直接就是一张和原图同尺寸的图,读进来就能用。LIDC 不是这样,它存的是矢量轮廓,压缩率高、精度高,但必须经过栅格化才能变成掩码。

区分方法很直接:看数据里有没有“面积”。轮廓只有顶点序列,没有面积;掩码是一个和切片同尺寸的矩阵,每个像素非 0 即 1。如果你手里的东西能直接喂给分割损失函数,那是掩码;如果需要按顺序连线再填充,那是轮廓。栅格化时还有个细节,多种子填充和逐层独立填充的结果可能不一致,同一个结节在不同层上的轮廓点顺序也未必是同一个绕行方向,遇到自交多边形时要先做一遍修复。

4.2 恶性程度评分和恶性概率:一个是主观打分,一个是模型输出

易混点在于数字范围。医生给的是 1 到 5 的整数,模型输出的也常常是 0 到 1 之间的一个小数,两者都叫“恶性”,很容易被当成同一件事的不同精度。

之所以会混,是因为任务形式看起来一样:都是判断这个结节有多危险。但性质完全不同。评分是人给出的主观序数,它只有序关系,没有等距关系——1 到 2 的差距和 4 到 5 的差距在语义上并不相等。概率是模型在某个假设下算出来的数,它有明确的可比性,但不代表真实世界的发生率。

区分方法:看这个数是谁给的、能不能做算术。评分可以比较大小、取中位数,但直接求平均再当成概率用是不合适的;概率可以算期望、算校准曲线,但它永远只是模型的观点,不是医生的观点。做分类任务时如果用评分卡阈值生成标签,记得在论文或报告里写清楚阈值取在哪,否则别人复现不出你的数字。

4.3 像素坐标和毫米坐标:差了像素间距这一步

易混点在于单位。轮廓点是一串整数,看着像坐标,其实那是像素索引;而层厚、结节直径、体积这些量,讨论时用的都是毫米。

之所以会混,是因为两者在代码里长得一模一样,都是数字,都不带单位。等你算体积的时候才发现,直接把像素个数当体积累加,得到的数既不是立方毫米也不是立方厘米,什么都不是。

区分方法:记住一条换算链。像素索引乘以像素间距得到平面内的毫米坐标,层号乘以层厚得到 Z 方向的毫米坐标,三者相乘才是物理体积。像素间距和层厚都写在每张切片的头信息里,不同病例不一样,绝对不能写死成常数。还有一个坑:有些扫描的层间距和层厚并不相等,重建时做过插值,这时候用层厚算 Z 间距会偏。真要算准,老老实实从每层的空间位置做差再取中位数。

4.4 “至少三位医生标注”和“所有结节”:共识结节是筛选出来的

易混点在于“结节数量”这个数。同一份数据,有人说是三百多,有人说是上千,数字对不上,往往就是口径不同。

之所以会混,是因为数据里同时存在两种东西:所有被任何一位医生标记过的位置,和被至少三位医生共同认可的结节。前者数量多,包含大量只有一个人觉得可疑的小东西;后者数量少,但可信度高。论文里说“我们用了某某数据集的一千个结节”,你得追一句:是全部标记,还是共识标记。

区分方法:看这个结节被几条记录覆盖。同一位置被四位医生都圈了,是强共识;被三位圈了,是常用口径;只有一位圈了,通常当噪声或困难样本处理。做检测任务时用全部标记,召回率会好看但假阳性高;做分割任务时用共识结节,边界质量更稳但样本量骤减。两种口径都能发文章,前提是写明白。

5. 用 Python 把 XML 标注变成能训练的表

5.1 先把病人级别的清单拉出来

动手解析之前,先建立一张病人级别的总表。这张表不需要读像素,只读目录和 XML,几十秒就能跑完,但能帮你提前发现一堆问题:哪些病例的 XML 是空的、哪些只有一位医生标注、哪些结节数量异常多。

import os import xml.etree.ElementTree as ET def scan_cases(root_dir): rows = [] for name in sorted(os.listdir(root_dir)): case_dir = os.path.join(root_dir, name) if not os.path.isdir(case_dir): continue # XML 不一定在病人目录的第一层,用递归找更稳 xml_files = [] for cur, _, files in os.walk(case_dir): xml_files += [os.path.join(cur, f) for f in files if f.lower().endswith('.xml')] if not xml_files: rows.append({'case': name, 'xml': None, 'sessions': 0}) continue tree = ET.parse(xml_files[0]) sessions = len(tree.getroot().findall('readingSession')) rows.append({'case': name, 'xml': xml_files[0], 'sessions': sessions}) return rows

这段代码只做三件事:遍历病人目录、递归找 XML、数一数有几个读片会话。sessions这个字段很关键,它等于标注这位病人的医生数量,正常是 4,少数病例是 1 到 3。先把分布打印出来,你就知道有多少病例达不到“至少三位医生”的共识口径,后面筛样本时心里有数。xml_files[0]这里取了第一个,如果一个病人有多个 XML,需要按业务规则合并,别默认只有一个。

5.2 解析 XML:结节、切片、轮廓点一次拿全

接下来把嵌套结构摊平成一张表。思路是从会话往下钻,把每个结节的特征和它覆盖的层都收进来。

def parse_nodules(xml_path): root = ET.parse(xml_path).getroot() out = [] for s_idx, session in enumerate(root.findall('readingSession')): for tag in ('unblindedReadNodule', 'blindedReadNodule'): for nod in session.findall(tag): char = nod.find('characteristics') # 恶性程度只在非盲节点里有,读不到就是 None mal = char.findtext('malignancy') if char is not None else None slices = [] for roi in nod.findall('roi'): # inclusion=false 表示医生撤销了这一层,必须跳过 if roi.findtext('inclusion') != 'true': continue pts = [(int(p.findtext('xCoord')), int(p.findtext('yCoord'))) for p in roi.findall('edgeMap')] if not pts: continue slices.append({ 'z': roi.findtext('imageZposition'), 'sop': roi.findtext('imageSOP_UID'), 'points': pts, }) out.append({ 'session': s_idx, # 第几位医生 'phase': tag, # 盲读还是非盲读 'nodule_id': nod.findtext('noduleID'), 'malignancy': int(mal) if mal else None, 'slices': slices, }) return out

几个参数值得单独说。session用一个递增的序号代替医生身份,因为原始数据里医生是匿名的,序号只保证同一病例内不重复,跨病例不可比,别拿它当“某位专家的水平”去分析。phase保留下来,方便你只取非盲节点做有监督任务。nodule_id只是原样记录,绝不能用来做跨会话的关联。slices里同时存了z和sop,前者方便排序,后者用于精确匹配切片文件。

跑完这一步,你会得到一张长表:每一行是“某位医生在某轮阅片里对某个结节的一次记录”。这时候再按空间位置做聚类,才能把四条记录合并成一个物理结节。

5.3 轮廓转掩码时最容易翻车的三件事

第一件是坐标系方向。xCoord是列、yCoord是行,画到矩阵上必须先转成(行, 列)。第二件是填充方式。要按顺序把顶点连成闭合多边形再填充,只画轮廓线的话,掩码面积会接近 0,损失函数直接失去意义。第三件是层对齐。用切片标识去匹配文件,比用 Z 位置稳。

import numpy as np def contour_to_mask(points, shape): """points: [(x, y), ...];shape: (rows, cols)""" mask = np.zeros(shape, dtype=np.uint8) rows, cols = shape # 先做一次裁剪,防止个别点越界导致整层失败 pts = np.array([(min(max(y, 0), rows - 1), min(max(x, 0), cols - 1)) for x, y in points], dtype=np.int32) # 这里调用任意一个多边形填充函数,用 pts 生成闭合区域写进 mask return mask

shape从对应切片的像素矩阵拿,不要用固定值。裁剪那一步看着多余,但边界上的结节轮廓确实会有点落在图像外,不裁就会在填充时抛异常,一个病例直接中断。填充完成后建议顺手检查一下面积占比,占比超过整幅图一半的,多半是坐标搞反了。

5.4 划分数据集要按病人切,不能按切片切

这是最隐蔽也最致命的一个坑。同一个病人有上百层切片,相邻层之间几乎一模一样。如果按切片随机划分训练集和验证集,同一个结节的上一层在训练集、下一层在验证集,模型只要“记住”这团东西的形状就能拿高分,指标漂亮得不像话,换一批新病人立刻原形毕露。

正确做法是以病人为单位整体划分,训练集里的病人和验证集里的病人完全不重叠。分层抽样时按“是否有共识结节”“恶性程度分布”“层厚”这几个维度分层,能避免切出来的验证集全是简单样本。做完之后一定回头验一遍:两个集合的病人编号有没有交集,这一步花十秒,能省掉后面无数次自我怀疑。

6. 什么时候该用 LIDC,什么时候别硬上

它最适合的场合,是“练手”和“做基线”。想验证一个分割网络能不能收敛、想比较两种检测头的召回差异、想跑通一条从原始标注到掩码再到训练的完整链路,这份数据的成本很低:格式统一、社区讨论多、出问题容易查到原因。教学场景里它也好用,四个医生的分歧天然就是一个讨论“标注噪声”的活教材,学生能看到真实标注不是干净的,而不是被喂一份理想化的数据。

它还适合做小样本、弱监督、标注不确定性这类偏方法的研究。因为同一批数据上有多个独立判断,你可以很方便地构造“用少量共识标注 + 大量分歧样本”的实验,这在只有一个标签的数据集上做不了。

不适合的场合也很明确。它不适合做临床级别的诊断验证,影像来源、设备参数、人群构成都相对集中,模型在这上面表现好,不代表换个场景还行。它也不适合研究非结节性病变,标注只围绕结节展开,肺气肿、纤维化、胸腔积液这些在这份数据里没有对应标签。如果你的问题是“整幅扫描有没有异常”,它给不了答案。还有一类需求要提前想清楚:想要现成的、开箱即用的二分类标签表,那这份数据会让你失望,它给的是原始判断,聚合规则要你自己定。

场景合适度原因
肺结节检测/分割的基线对比高标注精细、口径公开、可比性强
标注噪声与多标注者一致性研究高同一结节有多位医生的独立判断
教学与流程演示高格式统一、资料齐全
跨中心泛化能力验证低影像来源相对集中
非结节性肺病分析低标注体系不覆盖
直接当临床诊断依据不可标注是研究用途的主观判断

我第一次用这份数据时,图省事按切片随机划分,验证集交并比冲到 0.9 以上,兴奋了整整两天。后来换成按病人划分,数字掉到 0.6 出头,那两天白高兴了。从那以后我每次拿到多标注、多层的数据集,都强制先走一遍“病人级清单 → 会话统计 → 划分校验”这三步,不管多急。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/24 2:34:43

ADAS项目甘特图:功能安全与实车验证强耦合的工程化排程方法

简介&#xff1a;本资源是一份面向ADAS&#xff08;高级驾驶辅助系统&#xff09;项目管理初学者与嵌入式/汽车电子工程师的甘特图实践模板&#xff0c;聚焦解决多环节协同开发中的进度规划与任务可视化难题。压缩包共7个文件&#xff0c;含4个HTML页面&#xff08;用于交互式甘…

作者头像 李华
网站建设 2026/10/24 2:33:32

Java银行系统:事务一致性与账户状态机实战

简介&#xff1a;这是一份面向Java初学者与高校课程实践者的银行管理系统教学项目&#xff0c;聚焦面向对象编程核心能力训练&#xff0c;覆盖账户管理、存取款、转账、余额查询等典型银行业务逻辑&#xff0c;帮助学习者深入理解封装、继承、多态及线程安全等关键概念。资源包…

作者头像 李华
网站建设 2026/10/24 2:32:47

西浦半导体研究生一些学习资源的记录

基本电路:电路 石群老师主讲【80集全 带目录】 通俗易懂,讲得十分仔细,【适用于大学学习 考研备考】 邱关源电路第五版 电路考研_哔哩哔哩_bilibili模拟电路:【4K精品】模拟电子技术基础 上交大 郑益慧主讲(精校版字幕,模拟电路/模电 讲课水平略胜华成英,视频质量完爆清…

作者头像 李华
网站建设 2026/10/24 2:31:57

拆了一下 DeepSeek Harness 的插件架构

DeepSeek Harness&#xff08;命令是 dsh&#xff09;是 DeepSeek 开源的 agent harness&#xff0c;MIT 协议&#xff0c;目前处于开发者预览阶段。它值得看的地方不是功能列表&#xff0c;而是组织方式&#xff1a;整个产品没有一段"核心代码"可以改&#xff0c;所…

作者头像 李华