简介这份资源是面向医学影像研究与算法开发者的LIDC-IDRI肺结节CT数据集配套处理工具包解决从原始XML注解到可视化掩模、真值图像及后续分析之间繁琐的数据转换与整理问题。适合从事肺结节检测、分割、特征提取及机器学习模型训练的中高级研究人员使用。压缩包共24个文件以MATLAB脚本.m为主辅以说明文档.txt及Perl/Shell辅助工具.pl/.sh整体仅182KB轻便实用。配套脚本覆盖注解解析、掩模生成、真值图像转换、像素映射回写、注解拆分及路径修正等关键环节能够帮助使用者批量组织和预处理大规模数据便于并行计算与算法评估。已有2945人学习下载对于需要快速上手LIDC-IDRI数据集、节省数据清洗时间的科研人员这份工具包能提供直接的脚本参考与可复用的处理流程是开展肺结节影像分析工作的重要基础工具。1. LIDC 肺结节 CT 数据集工具包先搞清楚它是谁再动手拿到“lidc 肺结节 CT 数据集 工具包”这个检索词的人多半不是被数据集本身吸引来的而是被“工具包”三个字惹来的。LIDC 是肺结节 CT 影像公开数据集的代称里面有 DICOM 格式的胸部 CT 序列也有一份标注医生手工写的 XML——结节在哪一层、画了什么轮廓、良恶性打了几分全在这个 XML 里。问题是标注不是天生的掩码坐标也不是标准的检测框单位还分像素坐标和物理坐标单靠裸数据很难直接喂给深度学习模型。所以真正有用的东西是“处理这个数据集的一整套工具链”目录遍历、XML 解析、坐标转掩码、标签合并、数据划分以及把这些流程固定下来的一次性脚本。这篇笔记会把这条链完整走一遍适合刚开始做肺结节检测、分类或分割的同学也适合已经在用 LIDC 但被标注格式折腾过的熟手。先说结论这个方向值得做但真正的坑不在模型而在预处理。2. 拿到 LIDC 先别跑模型目录结构、DICOM 与 XML 标注的对应关系2.1 数据集目录长什么样一个患者一个文件夹一序列一个子目录LIDC 的原始数据从影像档案库下载下来之后顶层结构是每个患者一个独立目录目录名通常是研究实例编号之类的唯一标识符。进入某个患者目录里面会有两样东西一个放 CT 影像的子目录一般是若干个子文件夹每个子文件夹对应一次 CT 采集序列另一个是 .xml 后缀的标注文件以SeriesInstanceUID.xml这种形式命名。注意一个患者目录里可能出现多个 CT 序列文件夹这不是数据重复而是同一个患者做了不同重建参数的扫描比如一次是薄层重建一次是厚层重建厚度可能差一倍甚至更多。拿到数据后建议先写个脚本扫一遍目录结构确认文件数量、命名规则和每个序列的切片数量。我一般会这样起手find /data/lidc -maxdepth 3 -type d | head -20 find /data/lidc -name *.xml | wc -l find /data/lidc -name *.dcm | wc -l第一行看目录层级和命名第二行统计 XML 数量第三行统计 DICOM 总文件数。LIDC 官方标注文件是唯一的一份一个患者目录只要有一个 XML但 DICOM 文件则可能在一个患者下有多个序列每个序列几百到上千张切片。统计结果如果出现“一个患者目录有多个 XML”或“XML 数量与患者目录数不一致”多半是下载阶段把文件放乱了一层。这个坑我在后面避坑章节会再讲。提示下载原始数据必须通过影像档案库的客户端或网页下载数据本身不受密码保护但浏览和下载都要求先签署数据使用协议这一步别跳过。2.2 标注 XML 里到底存了什么结节坐标、良恶性、四位读片医生的差异LIDC 的 XML 并不是简单的image label这种键值对而是一棵多层嵌套的树。外层是患者的元信息包括研究实例编号、CT 序列的 SeriesInstanceUID内部是readingSession也就是读片会话。每个 readingSession 代表一位医生的一次独立标注过程一份 LIDC XML 里有 4 个 readingSession正好对应 4 位读片者。每个 readingSession 下面挂着一组unblindedReadNodule和blindedReadNodule只有前者才是真正的结节标注。每个结节节点里最重要的字段是locus位置点和characteristics属性locus下面有一组x、y、z坐标其中x和y是像素坐标z是切片序号从 0 开始具体语义是这张 CT 切片上这个结节用一个或多个点标出来一个点对应一个切片。如果结节比较大、跨越多个切片locus就会出现多个这叫“多切片标注”。characteristics里则是结节形态和性质的主观评分最重要的就是malignancy取值 1 到 51 代表良性倾向5 代表恶性倾向。这里有一个常见的理解偏差很多人看到 4 个 readingSession以为每个结节会被 4 个人各标一份事实不是。4 位读片者看到的图像相同但标注行为完全独立同一个结节有的人标了有的人没标甚至有的人标成“非结节”有的人标成“结节”。所以“合并 4 个人的标注”本身就是课题不是简单的取并集。我一般会做两件事先统计每个结节的读片人数再统计各个结节良恶性评分分布。如果某个结节只有 1 个人标了它的良恶性就不要直接当标签用很容易被模型带偏。2.3 对齐 DICOM 与 XML 的唯一钥匙SeriesInstanceUIDXML 里的SeriesInstanceUID和 DICOM 文件头里的SeriesInstanceUID必须一致才能把标注挂到正确的 CT 序列上。这是整个数据集工具包最核心的对应关系。一个患者目录下如果存在多个 CT 序列就不要想当然地“按目录内文件顺序对齐”因为每个序列的切片数、层厚、像素间距都不同XML 里标注的z是切片序号而序号是相对这个序列而言的。选择哪个序列来对齐直接决定坐标有没有意义。通常的做法是import pydicom from pathlib import Path def pick_thin_series(series_dir_list): best_series None best_slice_thickness 1e9 for series_dir in series_dir_list: dicom_files list(Path(series_dir).glob(*.dcm)) ds pydicom.dcmread(dicom_files[0], stop_before_pixelsTrue) st float(ds.SliceThickness) if SliceThickness in ds else 999.0 if st best_slice_thickness: best_slice_thickness st best_series series_dir return best_series, best_slice_thickness这段代码遍历一个患者目录下的所有序列文件夹读每个序列第一张 DICOM 的SliceThickness标签选出层厚最薄的那个序列。逻辑很简单但选择它有讲究LIDC 里同一个患者可能有 1.25mm 的薄层序列和 5mm 的厚层序列薄层序列的切片更多结节在片间切得更完整医生标注的locus坐标也更多。厚层序列里一个小结节可能只落在 1 到 2 张切片上标注信息量不够。选薄层还有一个附带好处后续做 3D 网络时体素间距更均匀不用因为层厚差异做复杂的 Z 轴插值。提示读取 SeriesInstanceUID 我习惯用pydicom.dcmread(path, stop_before_pixelsTrue)它不加载像素矩阵速度快适合大批量扫描目录时用。3. 用工具包解析 LIDC读 XML 提取结节坐标并在 CT 切片上可视化3.1 最小解析脚本从 XML 抽结节坐标与良恶性解析 LIDC 的 XML 可以用 ElementTree 一行一行地揪节点也可以用开源的 LIDC 解析工具库封装好了get_nodule()、get_annotation()这层接口。说实话用开源工具库省事但有两个麻烦一是它的数据结构跟原版 XML 不完全一致出错时你得回去翻原始文件二是很多库依赖旧版本的库环境兼容容易翻车。我做预处理时通常保留两个入口骨架逻辑用标准库自己写复杂掩码生成再调用封装接口。下面这个是标准库版本的最小解析脚本import xml.etree.ElementTree as ET from pathlib import Path def parse_lidc_annotation(xml_path): tree ET.parse(xml_path) root tree.getroot() series_uid root.findtext(ResponseHeader/SeriesInstanceUid) nodules [] for session in root.findall(readingSession): for nodule in session.findall(unblindedReadNodule): nodule_id nodule.findtext(noduleId) malignancy None for char in nodule.findall(characteristics): mal_text char.findtext(malignancy) if mal_text is not None: malignancy int(float(mal_text)) loci [] for locus in nodule.findall(locus): x float(locus.findtext(x)) y float(locus.findtext(y)) z int(float(locus.findtext(z))) loci.append((x, y, z)) nodules.append({ nodule_id: nodule_id, malignancy: malignancy, loci: loci, session: id(session), }) return series_uid, nodulesseries_uid取的是ResponseHeader/SeriesInstanceUid注意大小写和标签分隔符实际文件中标签名是SeriesInstanceUid没有大写 I不同版本可能写成SeriesInstanceUID解析时建议两个都查。readingSession是复数形式标签遍历时别写成单数。characteristics里不一定每个结节都有malignancy字段某些标注只给了形态属性没给良恶性这种节点读完malignancy仍为 None后续做标签时要单独过滤。3.2 在单张切片上画框可视化验证坐标没偏坐标解析到手以后第一件事不是建训练集而是把坐标画到原始 CT 切片上人眼看一遍坐标偏移。这一步能过滤掉一堆“看起来没问题训练起来全歪”的问题。画之前要先弄清楚坐标系XML 里的x、y是像素坐标z是切片序号但这里的切片序号是按什么顺序排的DICOM 文件在磁盘上是按文件名排序z是文件的读取顺序不一定是空间的物理顺序。所以画框之前必须按ImagePositionPatient的 Z 值对 DICOM 文件重新排序然后再索引到z对应的那张切片。import pydicom import numpy as np import matplotlib.pyplot as plt def load_sorted_slices(series_dir): files sorted(Path(series_dir).glob(*.dcm)) slices [pydicom.dcmread(f) for f in files] slices.sort(keylambda s: float(s.ImagePositionPatient[2])) return slices slices load_sorted_slices(series_dir) target slices[z] image2d target.pixel_array.astype(np.float32) plt.figure(figsize(8, 8)) plt.imshow(image2d, cmapgray) for x, y in loci: plt.scatter(x, y, colorred, s30) plt.axis(off) plt.show()这里loci里可能有多个点来自同一个结节的不同切片我只取了z匹配的那一组点。如果画出来点不在肺实质区域而是落在胸腔边缘或者骨架附近第一步排查坐标系是否是空间序。第二步排查z轴原点DICOM 的 ImagePositionPatient 是从头侧开始递增还是从脚侧开始不同 CT 厂商不一样如果文件列表排序恰好跟z的递增方向相反点就会画到完全错误的解剖位置。可视化脚本还有一个用法检查同一个结节在 4 个读片会话中的坐标是否“看起来是同一个位置”。如果两个医生标的位置差出好几厘米那多半是标注标准本身分歧要人工判断是否合并。3.3 把结节区域裁成图像块切片级 ROI 提取坐标验证通过后做检测或分类任务通常先把结节区域裁成固定尺寸的 2D 图像块。这里有个选尺寸的问题LIDC 的标注坐标是一个点不提供边界框所以这个框必须自己定。我一般以(x, y)为中心裁剪一个size x size的方形区域常见做法是64x64或96x96然后对像素值做窗宽窗位处理。裁剪时要注意x、y是浮点坐标转整数索引时要取整而且要判断是否会越界。def crop_roi(image2d, cx, cy, size): h, w image2d.shape half size // 2 x1 max(0, int(cx) - half) x2 min(w, int(cx) half) y1 max(0, int(cy) - half) y2 min(h, int(cy) half) roi image2d[y1:y2, x1:x2] return roi, (x1, y1, x2, y2)裁剪函数里x1、x2是列索引y1、y2是行索引千万别写反。写入数组时顺序是[y1:y2, x1:x2]很多新手在这行把 xy 对调结果图像块是转置的模型精度莫名其妙掉一截。裁剪出来的块如果小于目标尺寸不要直接 resize先看是不是结节本身贴近边缘再用常数填充或者直接丢弃。LIDC 里靠近胸膜、靠近纵隔的结节不少直接 resize 会拉伸解剖比例建议填充。4. 把 LIDC 转成深度学习训练集标签决策、数据划分与缓存格式4.1 良恶性标签怎么定多数投票与排除中立样本LIDC 的良恶性是 4 位读片医生各自打的 1-5 分直接拿单个人的分数当标签会导致标签噪声过大因为医生之间的评分一致性本来就不高。处理方案通常是多数投票或均值阈值。但这里有一个容易被忽略的细节一个结节不一定被 4 个人都打了分可能只有 3 个人标了而且这个结节本身还被 1 个人标成了“非结节”那这个样本要不要保留在训练集里我的做法是按结节聚合所有 sessions 里的malignancy分数过滤掉只有单一评分的结节信息量不足然后分两类。一种是把 1、2 作为阴性4、5 作为阳性3 作为中立样本直接丢弃另一种是把评分均值 3 的当阳性 3 的当阴性。两种我都试过前者标签更干净但样本量损失大后者样本量大但边界模糊。如果是做分类任务我建议先走前者把 3 分样本当作单独一个类或者丢弃等模型稳定后再加入 3 分做难样本挖掘。from collections import defaultdict def aggregate_malignancy(nodules): scores defaultdict(list) for nod in nodules: if nod[malignancy] is not None: scores[nod[nodule_id]].append(nod[malignancy]) result {} for nid, vals in scores.items(): if len(vals) 2: continue mean_v sum(vals) / len(vals) if mean_v 2.5: result[nid] 0 elif mean_v 3.5: result[nid] 1 else: result[nid] -1 return result这个聚合逻辑里mean_v 2.5判阴性mean_v 3.5判阳性中间带丢弃。阈值可以按项目调整如果数据量不够可以把阈值放缩到 2.0 和 4.0但标签噪声会增大。还有一点nodule_id在多个 session 里是否指向同一结节LIDC 的 noduleId 并不是全数据集唯一同一份 XML 内不同 session 的同一结节ID 命名规则并不保证一致。更稳妥的聚合键是“位置近似 尺寸近似”但这个聚类工作比较繁琐大多数开源工具库会帮忙做这层对齐不建议自己拼字符串。4.2 结节-切片组织方式按结节为单位存 npy还是按切片单位存预处理产出物一般有两种组织方式按结节为单位保存一组切片适合做 3D 分类/检测按单张切片为单位保存适合做 2D 检测或分类。这里要对齐的是任务目标。3D 模型需要的是以结节为中心的体积块做法是取这个结节所有 locus 覆盖的切片范围再向上下各扩展若干层把范围内的像素数组堆叠成(depth, height, width)的 npy。2D 模型则直接取每个 locus 对应的单张切片 ROI。我一般会生成一个 JSON 索引文件记录每个结节的元信息、文件路径、在 npy 数组中的索引位置而不是把整个数据集压缩成一个 h5。为什么这么做LIDC 数据量大原始 DICOM 加起来几十 GB如果转换成一个 h5 文件后续做分布式训练时每次只读一小块反而麻烦。按结节存成独立文件训练时用路径列表动态加载内存占用可控数据增广也灵活。示例代码如下import json, numpy as np manifest [] for patient_dir in patient_dirs: series_uid, nodules parse_lidc_annotation(xml_path) slices load_sorted_slices(series_dir) for nod in nodules: nid nod[nodule_id] volume stack_volume(slices, nod[loci]) np.save(f/data/processed/{patient_name}_{nid}.npy, volume) manifest.append({ sample_id: f{patient_name}_{nid}, path: f/data/processed/{patient_name}_{nid}.npy, label: label_dict.get(nid, -1), z_slices: [loc[2] for loc in nod[loci]], }) with open(/data/processed/manifest.json, w) as f: json.dump(manifest, f, indent2)stack_volume按locus里的z值收集切片再扩展上下边界。注意manifest里保存了z_slices这个信息对 3D 模型后处理很有用预测时要知道结节在哪个切片范围内才方便切回原图。不加这个字段到了后处理阶段又得重新解析 XML浪费时间。4.3 训练/验证/测试划分的边界同一患者不能跨集合数据处理到最后一步是划分集合。LIDC 一个患者目录对应一个研究实例一位患者可能有多张 CT 序列但同一患者不应同时出现在训练集和验证集里。原因很直接不同序列只是重建参数不同解剖结构是同一个人的如果训练集和验证集各含这位患者的若干切片模型会学到“记人”而不是“学特征”验证指标的置信度大打折扣。这个问题在 CT 影像数据集里特别容易被忽略因为切片数量多人眼根本看不出重复。常见做法是按患者目录划分而不是按结节或按切片划分。每个目录要么全部进训练集要么全部进验证集。划分比例我一般用 8:1:1但 LIDC 样本量不算大如果分类任务正负样本不均衡可以考虑在患者层面做分层抽样先按患者标签统计保证训练和验证的患者分布大致一致。下面这个函数就是按患者做划分import random random.seed(42) ids list(patient_dirs) random.shuffle(ids) n len(ids) train_ids ids[:int(n * 0.8)] val_ids ids[int(n * 0.8):int(n * 0.9)] test_ids ids[int(n * 0.9):]这里有个细节划分要在manifest.json生成之后做因为 manifest 里每一行已经带了patient_name字段直接按这个字段过滤即可。不要按 numpy 文件列表的先后顺序取前 80%那样很容易把同患者的多个结节拆到不同集合。5. LIDC 预处理避坑最常翻车的 4 个地方5.1 病灶区域重复标注导致面积翻倍现象用解析工具把同一结节的多个 locus 画到一个掩码上面积明显比肉眼看到的结节轮廓大甚至出现两个重叠的圆。原因LIDC 的locus是多个医生对同一结节的多点标记某些结节在不同 session 里被分别标了位置点但解析时如果只是简单地把所有点都拿去生成掩码没有先做结节合并区域自然重叠。解决先按位置聚类把距离小于一定阈值的 locus 归到同一结节再生成掩码。我的阈值通常取像素间距的 2 倍左右比如原始像素间距 0.6mm阈值就是 1.2mm。另外还有一种情况是同一 session 内同一个结节标注了两个重叠的 ROI这个在原始数据里也有原因不明但效果就是面积虚高。处理办法是合并前先把“完全包含”的重复标注剔除保外不保内。5.2 重采样后坐标没跟着乘系数结节画到肺外面现象把 DICOM 重采样为 1mm 各向同性体素后结节坐标全乱了画出来跑到了肺叶外面。原因标签是像素坐标原有像素间距不是 1mm重采样改变了图像尺寸和空间位置但 XML 里的 x、y 还是旧像素索引没有乘上新旧体素间距的比值。解决重采样前先把 X、Y 轴坐标换算成物理坐标重采样后再从物理坐标映射回新像素坐标。公式很简单def resample_xy_scale(x_px, y_px, old_spacing, new_spacing): x_mm x_px * old_spacing[0] y_mm y_px * old_spacing[1] x_new x_mm / new_spacing[0] y_new y_mm / new_spacing[1] return x_new, y_newold_spacing从 DICOM 的PixelSpacing读取new_spacing是目标体素间距。这个换算要在整个人工智能预处理管线里放在最前面因为后续的裁剪、增广、归一化都依赖坐标已经映射到新空间。建议写一个专门的coordinate_transform.py模块所有坐标换算都走这个模块不要散落在各个脚本里否则迟早有漏改的地方。5.3 窗宽窗位没调对胸壁贴病灶分不清现象把裁剪出来的 ROI 直接喂给模型训练集上效果尚可验证集上 ACC 波动特别大人眼观察 ROI 图像又灰蒙蒙一片。原因CT 原始存储值通常是 12 bit 的整数范围 0-4095但肺结节的诊断需要窗宽窗位调节到合适的显示区间比如纵隔窗窗宽约 350-400 HU、窗位约 40-50 HU肺窗窗宽约 1500-2000 HU、窗位约 -500 到 -600 HU。不做窗宽窗位处理直接归一化到 0-1软组织对比度很低结节特征被压制。解决裁剪 ROI 前先把像素数组转为 HU 值减去RescaleIntercept再做截断。常见做法是同时保留两个窗一个肺窗一个纵隔窗分别归一化后叠成双通道输入或者只取其中一个做单通道。如果只做单通道我推荐肺窗因为结节是含气组织边界在肺窗下更清晰。截断后再做 Min-Max 归一化把 [-1000, 400] 映射到 [0, 1]。代码如下def window_image(image, window_width, window_level): hu image * float(target.RescaleSlope) float(target.RescaleIntercept) lower window_level - window_width / 2 upper window_level window_width / 2 hu_clipped np.clip(hu, lower, upper) return (hu_clipped - lower) / (upper - lower)这个函数里window_width和window_level是参数肺窗常设(1500, -600)纵隔窗常设(400, 40)。不同的开源库里宽窗位命名可能不同但底层公式都是level - width/2到level width/2。注意RescaleSlope和RescaleIntercept是从 DICOM 文件头读出来的每个序列都要单独读不能拍脑袋用默认值。5.4 过滤过大过小的标注LIDC 里有大量“不是结节”现象训练集里出现很多异常样本——标注点落在血管分叉、胸膜增厚、肺纹理上模型学到一堆假阳性特征。原因LIDC 的标注里除了明确结节之外还有一种non-nodule类别而且数量不小。标注医生把形态不像结节但又不放心的病灶也标出来供后续研究参考。如果解析 XML 时只看unblindedReadNodule节点会把部分非结节也纳入进来。解决处理 XML 时先查malignancy字段是否缺失缺失的通常就是非结节点再查结节体积长径小于 3mm 的微小结节建议丢弃这类结节标注位置误差大后续重采样容易丢失。这个问题最隐蔽的是有的非结节也带有malignancy属性、也有完整的locus人眼都未必分辨得出来。我的检查方法是统计所有训练样本的 ROI 图像按体积排序看最小的一批是不是大量落在血管截面或肺纹理边缘。如果是就按直径过滤。过滤阈值用标尺轨迹上的最大直径字段LIDC 里每个结节节点有maximumDiameter属性直接用这个字段过滤即可。6. 样本转换的收尾技巧把切片序号转回物理坐标给模型预测做反向映射数据被转成 npy 或 h5 之后所有训练迭代都在标准化空间里进行但最后做验证时模型输出的预测框必须回到原始 DICOM 坐标才能跟医生标注对比也才能在原图上画框复核。这个反向映射是预处理工具包最容易“功亏一篑”的一个环节很多同学在预处理时用z做索引到了推理时却忘了把z映射回物理切片号。我常用的做法是每个患者单独生成一个坐标映射表记录z_index - ImagePositionPatient[2] - slice_filename训练集生成时就一并存下来。这样推理阶段拿到模型输出的(x, y, z)先在映射表里找到物理 Z 值再通过PixelSpacing转成实际毫米坐标。反推代码def convert_prediction_to_mm(pred_x, pred_y, pred_z, spacing, origin): x_mm origin[0] pred_x * spacing[0] y_mm origin[1] pred_y * spacing[1] z_mm origin[2] pred_z * spacing[2] return x_mm, y_mm, z_mmorigin来自 DICOM 的ImagePositionPatientspacing来自PixelSpacing。如果图像做过重采样这两组值要用重采样后的新值替换而不是再用原始 DICOM 的。把这段映射逻辑放到coordinate_transform.py里和前面的正向转换函数放一起正向反向一一对应从根上避免单位混用。这个技巧还可以扩展如果训练时做了 3D 裁剪和归一化推理时要把模型输出的归一化坐标还原到裁剪前坐标系再走上面一层物理坐标转换。两个转换栈叠起来很容易乱所以我的习惯是把每一次空间变换都同步记录关键参数写进对应的 manifest 条目里而不是用一套全局参数硬套所有样本。LIDC 一个数据集处理下来预处理脚本改七次、重跑数据集五六回很正常但坐标变换这条链一旦理顺后面训练和评估会省出大把时间。希望这段整理能帮到你少走一段弯路。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?