简介:本资源为YOLO肺结节目标检测数据集,面向医学影像检测方向的算法学习者与课程实践者,解决肺结节检测任务中数据获取难、标注格式不统一的问题。数据来自真实场景,图像质量高、场景丰富,经labelimg精细标注,同时提供voc(xml)、coco(json)与yolo(txt)三种格式标签,可直接接入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 | 绝对像素 | 独立 JSON | DETR、MMDetection | JSON 大文件,注意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_ok=True) 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(f"missing 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 或大于 1,YOLO 训练时直接报错或静默丢弃。空标签文件要保留,否则模型会把所有图都当成有目标,背景抑制能力下降。
参数说明: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_ok=True) 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_ratio=0.7, val_ratio=0.2, seed=42): 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_ok=True) os.makedirs(lbl_out, exist_ok=True) 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(f"train 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)}, " f"only_img={len(only_img)}, only_lbl={len(only_lbl)}, " f"boxes={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 支持在训练配置里指定imgsz,anchor 可以自动计算,但自动计算的 anchor 是基于你的数据集分布,如果结节普遍偏小,自动 anchor 可能仍然偏大。我一般会手动检查一遍 anchor 和结节尺寸的匹配度。
# data.yaml path: ./dataset train: train/images val: val/images test: test/images nc: 1 names: ["nodule"]# 训练命令,关键参数逐条说明 yolo detect train \ data=data.yaml \ model=yolov8s.pt \ imgsz=1024 \ epochs=200 \ batch=8 \ lr0=0.001 \ lrf=0.01 \ warmup_epochs=5 \ cos_lr=True \ patience=30 \ cache=True \ device=0参数说明:imgsz=1024是肺结节检测的起步值,显存不够就降到 768,但不要再低。batch=8配合 1024 分辨率,8G 显存大概能跑,12G 以上可以加到 16。lr0=0.001比默认的 0.01 小一个量级,因为小目标对学习率更敏感,太大容易震荡。patience=30表示 30 个 epoch 验证指标不提升就早停,肺结节数据容易过拟合,早停能省时间。cache=True把图片缓存到内存,5000 张 1024x1024 的图大概占 15G 内存,内存不够就设cache=disk。
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 可以开,但mosaic=0.5而不是默认的 1.0;mixup 直接关掉,mixup=0.0;copy-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参数说明:flipud=0.0禁用上下翻转,fliplr=0.5保留左右翻转。scale=0.3表示随机缩放范围是 0.7 到 1.3 倍,肺结节尺寸变化不大,不需要太激进的缩放。
5. 训练过程排查与推理验证:loss 不降、mAP 虚高怎么办
5.1 常见训练异常的现象、原因与解决
现象一:box loss 从第一个 epoch 就接近 0,mAP 也接近 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 \ model=runs/detect/train/weights/best.pt \ data=data.yaml \ imgsz=1024 \ conf=0.001 \ iou=0.5 \ plots=True参数说明:conf=0.001是为了画 PR 曲线时保留所有预测框,实际部署时再根据曲线选阈值。plots=True会生成混淆矩阵、PR 曲线和 F1 曲线,保存在runs/detect/val目录下。
5.3 导出 ONNX 做部署前的数值对齐检查
如果你打算把模型部署到 TensorRT 或其他推理引擎,先导出 ONNX 并做数值对齐。YOLO 导出 ONNX 时要注意opset版本和动态轴设置,肺结节检测通常用固定输入尺寸,不需要动态轴。
yolo export \ model=runs/detect/train/weights/best.pt \ format=onnx \ imgsz=1024 \ opset=12 \ simplify=True逻辑说明:opset=12兼容性最好,simplify=True会做图优化,减少冗余算子。导出后用 ONNX Runtime 跑一张测试图,和 PyTorch 的输出对比,如果差异超过 1e-3,检查是否有不支持的自定义算子。
参数说明:imgsz=1024必须和训练时一致,否则 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 更接近实际需求,也更能暴露模型在低置信度区域的排序能力。希望帮到你。
本文还有配套的精品资源,点击获取