
简介本资源为YOLO肺结节目标检测数据集面向医学影像检测方向的算法学习者与课程实践者解决肺结节检测任务中数据获取难、标注格式不统一的问题。数据来自真实场景图像质量高、场景丰富经labelimg精细标注同时提供vocxml、cocojson与yolotxt三种格式标签可直接接入YOLO系列模型训练。压缩包共约2000个文件以1986个xml标注文件为主另含html教程文档、txt列表与py脚本整体约77.56MB目录按格式分文件夹存放便于快速定位与转换。资源附赠环境搭建、训练教程及数据集划分脚本可按需生成训练集、验证集与测试集并输出ImageSets下的划分列表。已有403人学习下载适合希望快速跑通肺结节检测流程、对比不同标签格式或复现训练配置的读者参考使用。1. 肺结节检测数据集到手后先搞清楚三件事再动手拿到一个标注好的肺结节数据集最怕的不是数据量不够而是格式不统一、划分不合理、训练参数拍脑袋。标题里这个包给了 5000 张图片外加 VOC、COCO、YOLO 三种格式标签和划分脚本看起来省事但如果你直接解压就往 YOLO 里一扔大概率会在类别映射、坐标越界和验证集泄漏上翻车。肺结节检测和常规目标检测有个本质区别结节在 CT 切片上通常只占几十个像素背景占绝对主导正负样本极度不平衡所以数据管线怎么搭直接决定模型是学到结节还是学到背景。这篇内容面向已经拿到数据集、准备跑通训练并评估效果的从业者从格式转换、划分策略、训练配置到推理验证把每一步的参数含义和踩坑点讲清楚。如果你还在犹豫这个方向值不值得做先看一个事实肺结节公开数据集普遍偏小5000 张带三种格式标签的成品包能帮你省掉至少两周的标注和清洗时间值得认真跑一轮基线。2. 三种标签格式的差异与转换VOC、COCO、YOLO 到底该用哪个2.1 三种格式的坐标体系和适用场景VOC 格式用 XML 存储每个目标一个object节点坐标是左上角和右下角的绝对像素值xmin, ymin, xmax, ymax。COCO 格式用单个 JSON 文件管理所有图片坐标是[x, y, width, height]的绝对像素值并且有独立的categories字段做类别映射。YOLO 格式最简洁每张图对应一个.txt文件每行是class_id x_center y_center width height全部归一化到 0 到 1 之间。肺结节检测里如果你用 Ultralytics 系的 YOLO 模型训练直接用 YOLO 格式最省事如果你要做多模型对比比如拿 Faster R-CNN 或 DETR 跑基线COCO 格式更通用VOC 格式则适合老一代的 SSD 或 Faster R-CNN 实现。三种格式之间可以无损转换前提是类别映射和图片尺寸对得上。格式坐标类型类别存储典型框架肺结节场景注意点VOC绝对像素XML 内嵌SSD、Faster R-CNN小目标多XML 解析要防越界COCO绝对像素独立 JSONDETR、MMDetectionJSON 大文件注意iscrowd字段YOLO归一化txt 行内Ultralytics YOLO归一化前必须确认图片宽高2.2 从 VOC 转 YOLO 的脚本与四个边界坑下面这个脚本是我常用的 VOC 转 YOLO 实现核心逻辑是解析 XML、读取图片尺寸、归一化坐标、写入 txt。代码里加了越界裁剪和空标签处理这两个点在肺结节数据里特别常见。import os import xml.etree.ElementTree as ET from PIL import Image # 类别映射根据你的数据集实际类别修改 CLASS_MAP {nodule: 0} def voc_to_yolo(xml_dir, img_dir, out_dir): os.makedirs(out_dir, exist_okTrue) for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, xml_file)) root tree.getroot() # 图片文件名可能和 xml 不同从 xml 里取 filename root.find(filename).text img_path os.path.join(img_dir, filename) if not os.path.exists(img_path): print(fmissing image: {filename}) continue with Image.open(img_path) as im: w, h im.size lines [] for obj in root.findall(object): cls_name obj.find(name).text if cls_name not in CLASS_MAP: continue bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # 边界裁剪防止标注越界导致归一化后超出 0-1 xmin max(0, min(xmin, w - 1)) ymin max(0, min(ymin, h - 1)) xmax max(0, min(xmax, w - 1)) ymax max(0, min(ymax, h - 1)) if xmax xmin or ymax ymin: continue x_center (xmin xmax) / 2.0 / w y_center (ymin ymax) / 2.0 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{CLASS_MAP[cls_name]} {x_center:.6f} {y_center:.6f} {bw:.6f} {bh:.6f}) # 即使没有目标也写空文件YOLO 需要背景图参与训练 out_path os.path.join(out_dir, os.path.splitext(filename)[0] .txt) with open(out_path, w) as f: f.write(\n.join(lines)) voc_to_yolo(Annotations, JPEGImages, labels)逻辑说明先建立类别名到 id 的映射遍历 XML 时只保留映射内的类别。坐标裁剪是必须的肺结节标注有时会贴着图像边缘不裁剪会导致归一化值小于 0 或大于 1YOLO 训练时直接报错或静默丢弃。空标签文件要保留否则模型会把所有图都当成有目标背景抑制能力下降。参数说明CLASS_MAP根据你的classes.txt修改肺结节通常只有一类但有些数据集会分实性结节、磨玻璃结节那就需要多类映射。x_center和y_center保留 6 位小数足够YOLO 内部会再处理。2.3 COCO 转 YOLO 时最容易忽略的iscrowd和面积过滤COCO 格式转 YOLO 的逻辑类似但有两个额外坑。第一COCO 的annotations里有iscrowd字段值为 1 表示这是密集区域标注不是单个目标肺结节数据里一般不会出现但如果你混用了其他数据集必须过滤掉。第二COCO 的bbox是[x, y, width, height]转 YOLO 时要先算xmax x width再归一化。import json import os from PIL import Image def coco_to_yolo(coco_json, img_dir, out_dir): os.makedirs(out_dir, exist_okTrue) with open(coco_json, r) as f: data json.load(f) # 建立 image_id 到文件名的映射 img_id_to_info {img[id]: img for img in data[images]} # 建立 category_id 到连续 id 的映射 cat_ids sorted([c[id] for c in data[categories]]) cat_id_to_idx {cid: idx for idx, cid in enumerate(cat_ids)} # 按 image_id 聚合标注 from collections import defaultdict img_anns defaultdict(list) for ann in data[annotations]: if ann.get(iscrowd, 0) 1: continue img_anns[ann[image_id]].append(ann) for img_id, info in img_id_to_info.items(): filename info[file_name] w, h info[width], info[height] lines [] for ann in img_anns.get(img_id, []): x, y, bw, bh ann[bbox] if bw 1 or bh 1: continue # 过滤极小框肺结节里可能是噪声 x_center (x bw / 2.0) / w y_center (y bh / 2.0) / h nw bw / w nh bh / h cls_idx cat_id_to_idx[ann[category_id]] lines.append(f{cls_idx} {x_center:.6f} {y_center:.6f} {nw:.6f} {nh:.6f}) out_path os.path.join(out_dir, os.path.splitext(filename)[0] .txt) with open(out_path, w) as f: f.write(\n.join(lines))逻辑说明iscrowd过滤是硬性要求否则密集标注会被当成单个大框肺结节模型会学到错误的尺度分布。bw 1 or bh 1的过滤针对的是标注噪声肺结节数据里偶尔会出现 1 到 2 像素的框这种框对训练只有负面影响。参数说明cat_id_to_idx把 COCO 的原始类别 id 映射成从 0 开始的连续整数YOLO 要求类别 id 必须连续。如果你的数据集只有一类这个映射就是{1: 0}或类似。3. 数据集划分脚本为什么随机划分在肺结节检测里会翻车3.1 随机划分的泄漏风险与分层策略肺结节数据有个特点同一个病人的多次 CT 扫描可能产生多张切片这些切片在视觉上高度相似。如果你用随机划分同一个病人的切片可能同时出现在训练集和验证集里验证集精度会虚高实际部署时性能掉一大截。这就是数据泄漏肺结节检测里最常见的翻车点之一。正确的做法是按病人 ID 划分保证同一个病人的所有切片只出现在一个集合里。如果数据集没有提供病人 ID退而求其次的做法是按图像相似度聚类后再划分但这需要额外计算。标题里提到的划分脚本我建议你先检查它是不是按病人 ID 分的如果不是自己改一版。import os import random import shutil from collections import defaultdict def split_by_patient(img_dir, label_dir, out_dir, train_ratio0.7, val_ratio0.2, seed42): random.seed(seed) # 假设文件名格式为 patientID_sliceID.png按 patientID 分组 patient_to_files defaultdict(list) for fname in os.listdir(img_dir): if not fname.lower().endswith((.png, .jpg, .jpeg)): continue patient_id fname.split(_)[0] patient_to_files[patient_id].append(fname) patients list(patient_to_files.keys()) random.shuffle(patients) n len(patients) n_train int(n * train_ratio) n_val int(n * val_ratio) splits { train: patients[:n_train], val: patients[n_train:n_train n_val], test: patients[n_train n_val:] } for split, plist in splits.items(): img_out os.path.join(out_dir, split, images) lbl_out os.path.join(out_dir, split, labels) os.makedirs(img_out, exist_okTrue) os.makedirs(lbl_out, exist_okTrue) for pid in plist: for fname in patient_to_files[pid]: shutil.copy(os.path.join(img_dir, fname), os.path.join(img_out, fname)) lbl_name os.path.splitext(fname)[0] .txt lbl_src os.path.join(label_dir, lbl_name) if os.path.exists(lbl_src): shutil.copy(lbl_src, os.path.join(lbl_out, lbl_name)) print(ftrain patients: {len(splits[train])}, val: {len(splits[val])}, test: {len(splits[test])}) split_by_patient(images, labels, dataset)逻辑说明按病人 ID 分组后打乱病人顺序再按比例切分保证同一病人的切片不会跨集合。seed固定后结果可复现方便对比不同模型的公平性。参数说明train_ratio和val_ratio根据数据量调整5000 张图如果病人数在 500 左右7:2:1 比较合理。如果病人数很少比如只有 50 个那验证集和测试集各留 5 个病人就够不要再按比例切。3.2 划分后的完整性校验三个必须检查的指标划分完不要直接开训先跑一遍校验。第一检查每个集合的图片数和标签数是否一致YOLO 允许背景图没有标签文件但如果你用的是空 txt 方案那数量应该完全一致。第二检查类别分布训练集和验证集的结节数量占比不能差太多否则验证指标波动会很大。第三检查图片尺寸分布肺结节 CT 切片常见 512x512 或 1024x1024如果混了不同尺寸训练时的 resize 策略要统一。import os from collections import Counter def check_split(dataset_root): for split in [train, val, test]: img_dir os.path.join(dataset_root, split, images) lbl_dir os.path.join(dataset_root, split, labels) imgs set(os.path.splitext(f)[0] for f in os.listdir(img_dir)) lbls set(os.path.splitext(f)[0] for f in os.listdir(lbl_dir)) only_img imgs - lbls only_lbl lbls - imgs cls_counter Counter() total_boxes 0 for lbl in os.listdir(lbl_dir): with open(os.path.join(lbl_dir, lbl)) as f: for line in f: parts line.strip().split() if len(parts) 5: cls_counter[int(parts[0])] 1 total_boxes 1 print(f[{split}] images{len(imgs)}, labels{len(lbls)}, fonly_img{len(only_img)}, only_lbl{len(only_lbl)}, fboxes{total_boxes}, cls_dist{dict(cls_counter)}) check_split(dataset)逻辑说明only_img和only_lbl应该为空或只有少量背景图差异。cls_dist打印类别分布如果某一类在验证集里为 0那这个类别的 AP 无法计算需要重新划分。参数说明这个脚本不修改数据只做检查建议每次划分后都跑一遍花不了几秒钟但能避免后面训练几小时才发现问题。4. YOLO 训练配置肺结节小目标检测的参数怎么设4.1 输入分辨率与 anchor 的匹配关系肺结节在 CT 切片上通常只有 5 到 30 像素如果你用 YOLO 默认的 640 输入经过 32 倍下采样后结节在特征图上只剩不到 1 个像素检测头根本学不到。常见做法是把输入分辨率提到 1024 或 1280同时调整 anchor 尺寸让最小 anchor 覆盖 8 到 16 像素的目标。Ultralytics YOLO 支持在训练配置里指定imgszanchor 可以自动计算但自动计算的 anchor 是基于你的数据集分布如果结节普遍偏小自动 anchor 可能仍然偏大。我一般会手动检查一遍 anchor 和结节尺寸的匹配度。# data.yaml path: ./dataset train: train/images val: val/images test: test/images nc: 1 names: [nodule]# 训练命令关键参数逐条说明 yolo detect train \ datadata.yaml \ modelyolov8s.pt \ imgsz1024 \ epochs200 \ batch8 \ lr00.001 \ lrf0.01 \ warmup_epochs5 \ cos_lrTrue \ patience30 \ cacheTrue \ device0参数说明imgsz1024是肺结节检测的起步值显存不够就降到 768但不要再低。batch8配合 1024 分辨率8G 显存大概能跑12G 以上可以加到 16。lr00.001比默认的 0.01 小一个量级因为小目标对学习率更敏感太大容易震荡。patience30表示 30 个 epoch 验证指标不提升就早停肺结节数据容易过拟合早停能省时间。cacheTrue把图片缓存到内存5000 张 1024x1024 的图大概占 15G 内存内存不够就设cachedisk。4.2 损失函数里小目标的权重调整YOLO 的损失由 box loss、cls loss 和 dfl loss 组成。肺结节检测里box loss 对小目标的位置误差非常敏感因为同样的绝对误差在小目标上对应的 IoU 下降更大。常见做法是提高 box loss 的权重或者用 NWD 这类专门针对小目标的损失替代 IoU 系列。如果你不想改损失函数至少要把box的权重调高。Ultralytics 的配置里可以通过box参数调整默认是 7.5肺结节场景可以试 10 到 12。但不要调太高否则分类会欠拟合。# 如果要在 Ultralytics 里自定义损失权重修改 hyp.yaml # box: 10.0 # cls: 0.5 # dfl: 1.5逻辑说明box权重提高后模型更关注定位精度适合结节这种位置比类别更重要的场景。cls权重降低是因为肺结节通常只有一类分类任务本身很简单不需要太强的分类信号。参数说明这些权重没有理论最优值需要根据验证集的 mAP50 和 mAP50-95 来调。如果 mAP50 高但 mAP50-95 低说明定位不够准继续加 box 权重如果两者都低可能是学习率或数据问题先别动损失权重。4.3 数据增强的取舍哪些增强对肺结节有害YOLO 默认开启 mosaic、mixup、copy-paste 等增强。mosaic 把四张图拼成一张对常规目标检测很有效但肺结节的背景是 CT 切片拼接后的图像在解剖结构上不合理模型可能学到虚假的上下文关系。我的经验是mosaic 可以开但mosaic0.5而不是默认的 1.0mixup 直接关掉mixup0.0copy-paste 对小结节有好处可以保留但概率设低。翻转和旋转要谨慎。CT 切片有固定的方向语义上下翻转后的图像在医学上不合理但左右翻转通常可以接受因为肺的左右对称性。旋转角度不要超过 15 度否则结节形态失真。# 增强参数建议 mosaic: 0.5 mixup: 0.0 copy_paste: 0.1 degrees: 10.0 flipud: 0.0 fliplr: 0.5 scale: 0.3参数说明flipud0.0禁用上下翻转fliplr0.5保留左右翻转。scale0.3表示随机缩放范围是 0.7 到 1.3 倍肺结节尺寸变化不大不需要太激进的缩放。5. 训练过程排查与推理验证loss 不降、mAP 虚高怎么办5.1 常见训练异常的现象、原因与解决现象一box loss 从第一个 epoch 就接近 0mAP 也接近 0。原因通常是标签格式错误比如归一化坐标没做或者类别 id 从 1 开始而不是 0。解决方法是随机抽几张图的 txt 文件手动算一下坐标是否在 0 到 1 之间类别 id 是否是 0。现象二训练 loss 正常下降但验证 mAP 始终在 0.1 以下。原因可能是验证集和训练集的分布差异太大比如训练集全是实性结节验证集全是磨玻璃结节。解决方法是检查划分脚本是否按病人 ID 分层如果没分层重新划分。现象三mAP50 很高但 mAP50-95 很低。这是小目标检测的典型现象说明模型能找到结节但定位不够准。解决方法是提高输入分辨率、调高 box loss 权重、或者换用 NWD 损失。现象四训练到一半 loss 突然变成 NaN。原因通常是学习率太大或者数据里有异常值。解决方法是降低lr0到 0.0005同时检查标签里有没有坐标超出 0 到 1 的情况。现象五推理时一张图检出几百个框。原因通常是conf阈值设得太低或者模型过拟合了背景噪声。解决方法是把conf从默认的 0.25 提到 0.5同时检查训练集里有没有大量空标签图。5.2 推理验证用混淆矩阵和 PR 曲线定位问题训练完后不要只看 mAP 数字跑一遍验证集推理生成混淆矩阵和 PR 曲线。混淆矩阵能告诉你模型把结节误判成背景的比例PR 曲线能看出在不同置信度下的召回率变化。肺结节检测里召回率比精确率更重要因为漏检的代价比误检大。yolo detect val \ modelruns/detect/train/weights/best.pt \ datadata.yaml \ imgsz1024 \ conf0.001 \ iou0.5 \ plotsTrue参数说明conf0.001是为了画 PR 曲线时保留所有预测框实际部署时再根据曲线选阈值。plotsTrue会生成混淆矩阵、PR 曲线和 F1 曲线保存在runs/detect/val目录下。5.3 导出 ONNX 做部署前的数值对齐检查如果你打算把模型部署到 TensorRT 或其他推理引擎先导出 ONNX 并做数值对齐。YOLO 导出 ONNX 时要注意opset版本和动态轴设置肺结节检测通常用固定输入尺寸不需要动态轴。yolo export \ modelruns/detect/train/weights/best.pt \ formatonnx \ imgsz1024 \ opset12 \ simplifyTrue逻辑说明opset12兼容性最好simplifyTrue会做图优化减少冗余算子。导出后用 ONNX Runtime 跑一张测试图和 PyTorch 的输出对比如果差异超过 1e-3检查是否有不支持的自定义算子。参数说明imgsz1024必须和训练时一致否则 anchor 匹配会错位。如果部署环境只支持 640那训练时就应该用 640不要训练 1024 再导出 640。6. 把 5000 张图的基线跑稳之后下一步往哪走基线跑通的标准是验证集 mAP50 稳定在 0.6 以上混淆矩阵里结节的召回率超过 0.7推理速度满足你的部署要求。如果达不到先别急着换模型回头检查数据划分和标签质量。我见过太多人一上来就换 YOLOv8 到 YOLOv11结果发现是验证集里混了训练集的病人。基线稳了之后有三个方向可以继续推。第一用 NWD 或 Wise-IoU 替换默认的 CIoU这两个损失对小目标的定位提升明显改起来也不复杂Ultralytics 的损失函数在loss.py里替换bbox_iou的计算方式就行。第二把输入分辨率从 1024 提到 1280同时用切片推理处理大图肺结节在 1280 下的召回率通常比 1024 高 3 到 5 个百分点。第三如果数据量允许把单类检测扩展成多类区分实性、部分实性和磨玻璃结节临床价值更高但需要重新检查标签里有没有对应的类别标注。验证改进是否有效的方法很简单固定随机种子固定数据划分只改一个变量跑三次取平均。如果 mAP50 提升不到 1 个百分点那这个改进可能只是噪声。我自己的习惯是任何改动先在 10% 的子集上跑 20 个 epoch有信号再上全量这样一轮实验从半天压缩到一小时。最后说一个血泪教训肺结节检测的评估指标不要只看 mAP一定要看敏感度 at 特定假阳性率。临床上更关心的是在每张切片 1 到 2 个假阳性的前提下能检出多少结节。这个指标比 mAP 更接近实际需求也更能暴露模型在低置信度区域的排序能力。希望帮到你。本文还有配套的精品资源点击获取