
简介面向医学图像目标检测场景这套肺结节CT图像数据集已按YOLOV5标准目录结构整理含images与labels子文件夹无需转换即可直接投入训练。数据共248张图像其中训练集220张、测试集28张图像均为标准JPG格式每张均配有同名的TXT标注文件目标类别仅肺结节一种可直接用于YOLO系列模型的训练与验证。压缩包共499个文件以248个JPG图像与248个TXT标签为主体另附1个类别字典文件和1个Python可视化脚本可随机绘制并保存单张图像的边界框无需修改参数即可快速核验标注质量。整包大小约5.14MB轻巧易用适合医学影像检测方向的学习者、研究者作为基准数据、实验测试或课程设计素材。目前已有990人浏览学习可大幅节省数据整理与格式转换时间。1. 拿到肺结节CT目标检测数据集后第一件事为什么是检查目录而不是训练医学图像之目标检测数据集YOLOV5目录格式——尤其是肺结节CT图像目标检测数据集——听上去像是开箱即用的交付物实际却常带着一堆“历史遗留问题”。YOLOv5只认images/和labels/配对、坐标归一化到 0~1 的 txt 格式类别编号、图片名、data.yaml 任何一处错位训练流程都会在启动阶段就崩掉。而大多数CT原始数据是DICOM序列加XML或JSON标注真正能被train.py直接读取的少之又少。这篇笔记会沿着“目录检查 → 格式转换 → 训练调参 → 反向校验”的顺序把肺结节CT图像目标检测数据集整理成YOLOv5可用格式的完整路径和坑讲清楚。适合刚拿到医学图像数据集、准备训练自己的目标检测模型的同学也适合替医学影像团队做落地的算法工程师。2. 从DICOM到YOLOv5目录格式CT切片的坐标体系与标注格式变迁2.1 CT影像为什么必须经过窗宽窗位映射才能用于YOLOv5训练CT图像不是普通照片它存的是体素CT值范围通常在 -1024 到 3071 Hounsfield单位Hu之间。如果你直接把pixel_array存成PNG得到的是一张灰阶范围极宽、对比度极低的图肺结节和血管在视觉上糊成一片。YOLOv5输入要求的是uint8的0~255灰度或RGB图所以必须先做映射。常见做法是用“肺窗”参数做线性窗宽窗位映射。肺窗窗宽1500 Hu、窗位-600 Hu可以把-1350~150 Hu的CT值映射到0~255让肺实质和结节对比度拉开。纵隔窗则适合看大血管和病灶浸润但对小结节不友好。import pydicom import numpy as np ds pydicom.dcmread(CT_slice_001.dcm) pixels ds.pixel_array.astype(np.float32) # 部分DICOM有RescaleSlope/RescaleIntercept必须还原成真实CT值 if hasattr(ds, RescaleSlope) and hasattr(ds, RescaleIntercept): pixels pixels * float(ds.RescaleSlope) float(ds.RescaleIntercept) window_width 1500 # 肺窗宽 window_level -600 # 肺窗位 lower window_level - window_width / 2 upper window_level window_width / 2 clipped np.clip(pixels, lower, upper) image_8bit ((clipped - lower) / (upper - lower) * 255).astype(np.uint8)这段代码解决“读出来是灰蒙蒙”的问题。window_width和window_level是重点参数直接影响后续目标检测模型的输入分布。如果你拿到的数据集已经提供PNG也要确认它到底用的什么窗宽窗位最好在预处理阶段统一而不是把不同窗参数、不同设备的切片混在一起训练不然模型会在推理时被CT设备的差异性带偏。2.2 YOLOv5目录格式的三条硬性规定YOLOv5目录格式的核心只有三条。第一图像和标签文件分开放在images和labels目录下且文件名必须一一对应slice_001.png对slice_001.txt。标签文件不存在时该图会被当作纯背景负样本参与训练。第二每个txt文件里每行代表一个标注框格式是class_id x_center y_center width height全部除以图像宽高归一化。例如一个100x200像素的图像里标注框左上角在(20,30)右下角在(60,80)那么转换结果是0 0.4000 0.2750 0.4000 0.2500计算方式是x_center(2060)/2/2000.4注意这里分母200是图像宽度y_center(3080)/2/2000.275分母200是高度宽度为40/2000.2这里我算错了应该是 (60-20)/2000.2。刚才给的0.400是中心点相对到左边的距离而宽度占0.2需要更正。避免误导我在正文里用正确示例。更准确来说0 0.4 0.275 0.2 0.25其中0.4是x中心相对值(2060)/24040/1000.40.275是y中心相对值(3080)/25555/2000.2750.2是宽度相对值(60-20)/1000.20.25是高度相对值(80-30)/2000.25。第三data.yaml中写明数据集根路径、训练/验证子目录、类别数和类别名。类别编号必须和txt第一列一致这是最常见的翻车点。为了降低混淆我列一张格式对照表数据形式坐标基准典型内容DICOM原始像素左上角起点像素绝对坐标矩阵数值不含归一化Pascal VOC XML左上角起点绝对像素xmin,ymin,xmax,ymaxCOCO JSON左上角起点绝对像素bbox: [x, y, width, height]YOLOv5 txt左上角为0右下角为1class 中心x 中心y 宽 高2.3 拿到数据集先做清点再动手转换在转换之前我会先统计文件数量、图像分辨率、每个类别的实例数。很多医学数据集的类别说明和实际标注并不一致比如文档写着“良性0恶性1”但txt里第一列只有0和2中间编号全空。下面这段脚本可以快速清点import os import collections label_dir labels cls_counter collections.Counter() empty_files [] for fname in os.listdir(label_dir): fpath os.path.join(label_dir, fname) with open(fpath, r, encodingutf-8) as f: lines [line.strip() for line in f if line.strip()] if not lines: empty_files.append(fname) continue for line in lines: cls_counter[line.split()[0]] 1 print(类别编号 - 实例数:, dict(cls_counter)) print(空标签文件数量:, len(empty_files))这段脚本能第一时间发现两个问题某个类别没有样本或存在大量空标签。空标签对应的是“正常切片”在YOLOv5中作为负样本是合理的但如果空文件数量异常多就要检查是不是标注解析时漏读了一大批数据。这个清点步骤应该发生在任何格式转换之前。3. 把肺结节CT数据整理成YOLOv5可训练的目录脚本化转换流程3.1 建立目录结构并约定命名无论原始数据是XML还是JSON最终都要落到同样的磁盘结构。先创建固定的images/train、images/val、labels/train、labels/val四个目录。mkdir -p lung_nodule_yolo/images/train mkdir -p lung_nodule_yolo/images/val mkdir -p lung_nodule_yolo/labels/train mkdir -p lung_nodule_yolo/labels/val命名建议使用patientID_series_slice.png这样的可追溯命名例如P003_2_044.png对应标签P003_2_044.txt。文件名中的空格和中文一定要先清理掉否则YOLOv5的Dataloader在Linux上会跳过Windows上可能直接报编码错误。这一步看似基础实则很多整理工作都败在文件名不一致。3.2 标注文件转YOLO txt坐标归一化与越界处理下面给出一套从Pascal VOC XML转YOLO txt的最小可用脚本。如果你的数据是COCO JSON或医学专用格式如NIfTIJSON只要按同样的思路把读取部分换掉即可。# voc2yolo.py import os from PIL import Image from xml.etree import ElementTree as ET # 类别映射表按实际数据集修改 class_dict { nodule: 0, benign_nodule: 1, malignant_nodule: 2, } def voc_to_yolo(xml_path, img_width, img_height): 将单个Pascal VOC XML标注转换为YOLO格式的文本行。 返回字符串列表每行一个目标。 tree ET.parse(xml_path) root tree.getroot() lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in class_dict: continue cls_id class_dict[name] bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) # 将坐标限制在图像范围内防止标注框越界导致训练时出现负坐标 xmin max(0, min(xmin, img_width)) ymin max(0, min(ymin, img_height)) xmax max(0, min(xmax, img_width)) ymax max(0, min(ymax, img_height)) x_center (xmin xmax) / 2.0 / img_width y_center (ymin ymax) / 2.0 / img_height width (xmax - xmin) / img_width height (ymax - ymin) / img_height # 过滤宽高为0的废标注避免训练loss出现NaN if width 0 or height 0: continue lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}) return \n.join(lines) if __name__ __main__: xml_dir xml_annotations out_dir labels_raw image_dir images_raw os.makedirs(out_dir, exist_okTrue) for xml_name in os.listdir(xml_dir): if not xml_name.endswith(.xml): continue stem os.path.splitext(xml_name)[0] xml_path os.path.join(xml_dir, xml_name) img_path os.path.join(image_dir, stem .png) if not os.path.exists(img_path): print(缺少对应图片:, img_path) continue # 读取最终PNG尺寸必须与喂给YOLOv5的图像尺寸一致 with Image.open(img_path) as im: w, h im.size yolo_text voc_to_yolo(xml_path, w, h) out_path os.path.join(out_dir, stem .txt) with open(out_path, w, encodingutf-8) as f: f.write(yolo_text \n)这段脚本里有几个关键参数值得注意。class_dict的键必须和XML里object/name的字符串一字不差。医学数据里常见nodule、non_nodule、benign、malignant混用宁可先打印一遍所有XML里的name集合再定映射表。img_width和img_height取的是最终PNG的尺寸。如果CT原图是512x512但保存时为了节省空间压缩成了256x256那么XML里原有坐标必须同步缩放否则框整体偏移。最稳妥的做法是先统一图像分辨率再用统一后的尺寸做归一化。脚本过滤了宽或高为零的框。医学标注偶尔会出现坐标完全相同的点如果保留YOLOv5会在计算损失时除以接近零的值导致训练过程出现 NaN。3.3 编写data.yaml并解释路径陷阱一个典型的肺结节数据集data.yaml长这样# lung_nodule.yaml path: /data/lung_nodule_yolo train: images/train val: images/val nc: 3 names: 0: nodule 1: benign_nodule 2: malignant_nodule第一行path建议用绝对路径。YOLOv5的train.py在解析时会把path与train/val拼接成/data/lung_nodule_yolo/images/train。如果path写成相对路径而你的终端当前目录不在YOLOv5根目录下就会报Dataset not found或No labels found。我自己的习惯是所有实验都先写绝对路径训练完再决定要不要改成相对路径归档。另外names的顺序必须与转换脚本里的class_id一致。0: nodule表示所有txt文件中第一列为0的都是普通结节。如果转换脚本里benign_nodule映射成了1而data.yaml中1却写成malignant_nodule那模型训练时会把良性结节样本强行拟合到恶性标签上验证集上两个类别的mAP都会乱掉。3.4 训练前用自检脚本兜底训练花费很长时间不值得为了一个低级路径错误反复启动。训练前把下面脚本跑一遍能过滤掉八成低级问题。import os from PIL import Image image_dir /data/lung_nodule_yolo/images/train label_dir /data/lung_nodule_yolo/labels/train for fname in os.listdir(image_dir): stem os.path.splitext(fname)[0] label_path os.path.join(label_dir, stem .txt) if not os.path.exists(label_path): print(缺少标签文件:, fname) continue with open(label_path) as f: for line in f: parts line.strip().split() if len(parts) ! 5: print(字段数量错误:, label_path) break cx, cy, bw, bh map(float, parts[1:]) if not (0 cx 1 and 0 cy 1 and 0 bw 1 and 0 bh 1): print(坐标越界:, label_path, line.strip()) break这个脚本验证三件事标签文件是否存在、行格式是否为5个字段、归一化坐标是否在有效范围内。如果出现越界原因通常是对图像resize时只改了图像文件没有同步改坐标转换公式。这一步骤不消耗GPU但能省下排查训练崩溃的时间。4. 用YOLOv5训练自己的肺结节检测模型命令与关键参数4.1 环境与预训练权重的取舍YOLOv5的训练环境配置相对固定基本按官方requirements.txt安装即可。conda create -n yolov5 python3.8 -y conda activate yolov5 cd yolov5 pip install -r requirements.txt这里要注意Python版本不需要强行上最新3.8~3.10都能跑重点是把torch版本和CUDA版本匹配好。亲测在PyTorch 1.13、CUDA 11.7的组合下YOLOv5训练CT图像没有遇到特殊兼容问题。预训练权重建议从yolov5s.pt开始它参数量最小能快速验证数据流程是否走通。如果小模型跑通后mAP上不去再换yolov5m.pt或yolov5l.pt不要一上来就用大模型因为医学数据集通常只有几百到几千张切片大模型容易过拟合。4.2 训练命令与超参详解跑通数据流程的最小训练命令是python train.py --data lung_nodule.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 100 \ --cache命令行里几个参数在肺结节场景下需要认真权衡。--img 640控制输入分辨率。CT原始切片常见是512x512你用640相当于放大了1.25倍对小结节有一定帮助但会额外消耗显存。如果原始切片是1024x1024高分辨率数据我一般倾向直接把--img设为1024不使用640。对于肺结节这种小目标输入分辨率宁高勿低。--batch 16在16G显存下比较安全。如果显存只有8G降到8更稳。医学数据集本来就小单batch的波动对loss影响较大不建议为了省时间盲目调大batch。--epochs 100对于几百张切片的小数据集100轮往往还会欠拟合但超过300轮又会过拟合。确定轮数的办法是看results.png里验证集loss在哪个epoch开始反弹。--cache将图像预加载进RAM能避免训练时频繁磁盘IO尤其是图像是几百KB的大PNG时提升明显。缺点是内存占用高内存只有16G时慎用。--seed 7固定随机种子方便复现对比实验。医学论文复盘时这是最基本的要求。补充一个常见超参表参数肺结节场景建议原因--img512或1024与原始CT分辨率贴近保留小结节细节--batch8~16显存与loss稳定性折中--epochs100~200小数据集过拟合风险高--workers4~8图像解码快避免数据加载变瓶颈4.3 训练结果怎么看怎么决定下一步训练结束后YOLOv5会在runs/train/exp下输出一堆指标图。第一优先看results.png里面的mAP0.5和mAP0.5:0.95是整体效果第二看confusion_matrix.png它能揭示某个类别是否被大量预测成背景或其他类别。在肺结节场景我最关注的不是mAP峰值而是在置信度阈值0.1下的召回率。原因是漏检是临床最不能接受的错误。如果验证集上小结节类别的AP只有0.2而大结节类别AP达到0.8说明模型对大结节学得好但小结节的尺寸已经低于特征的表达能力。这种情况下先不要急着换模型架构回到数据层检查小结节标注框是否过小、是否在resize时丢失、类别数量是否支撑训练。数据级优化往往比换模型更有效。5. 肺结节CT目标检测数据集的常见问题现象、原因与排查路径5.1 图像和标签文件名不匹配现象训练启动后频繁打印No labels found in xxx.jpg大量图片被当成负样本参与训练。验证集mAP可能还行但训练集loss曲线一直在高位抖动。原因图片文件与txt文件的basename不一致。常见于转换脚本用stem xml_name.replace(.xml,)但图像文件名用的是另一套命名比如加了下划线后缀。解决用shell循环快速找出没有对应标签的图片再统一重命名到一套规范名称。cd /data/lung_nodule_yolo/images/train for img in *.png; do base${img%.png} if [ ! -f ../../labels/train/${base}.txt ]; then echo 缺失标签: $img fi done建议在训练脚本里加入这个检查每次开始训练前自动执行。YOLOv5的Dataloader会跳过无标签图像但它不会告诉你这些图其实就是正样本切片的漏标来源。5.2 类别编号错位导致良性结节被识别成恶性现象训练loss正常但推理结果中所有框的类别都集中在某一个类上另一类完全不出现。原因转换脚本里的class_id编号与data.yaml的names顺序错位。比如转换时把benign映射为1malignant映射为2但data.yaml的names列表把1写成malignant导致模型学到的是“看到良性样本就去拟合恶性标签”。解决用清点脚本打印每个class_id的实例数量随机抽查两个txt标签的原文内容。不要只信数据集的README以实际文件中的第一列数字为准。如果发现大量1的样本而文档说1是背景说明标注方在导出了YOLO格式后可能又换过类别顺序。5.3 同一个病人的切片被拆进训练集和验证集现象验证集mAP高达0.9以上但换到新CT设备或新病人上时模型漏检严重尤其是小结节几乎全军覆没。原因划分数据集时按“切片”随机分配。同一个病人的连续CT切片既有在训练集里的也有在验证集里的。模型在训练时已经见过同一病灶在不同切片下的形态验证集相当于开卷考试。解决按病人ID分组后再划数据集保证同一个病人的所有切片只出现在同一个集合。用GroupShuffleSplit是标准做法。from sklearn.model_selection import GroupShuffleSplit import numpy as np # 假设每个文件名是 patientID_slice.png file_list [...] # 所有文件路径 patient_ids [os.path.basename(f).split(_)[0] for f in file_list] gss GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(gss.split(file_list, groupspatient_ids)) train_files [file_list[i] for i in train_idx] val_files [file_list[i] for i in val_idx]groups参数指定了分组键GroupShuffleSplit会保证同一个patient_id下的所有切片不会被拆散。这是医学影像数据与自然图像数据集之间最重要的区别也是不少论文评审会盯着看的细节。5.4 小结节在resize时被直接抹掉现象训练前可视化标注时框还清晰可见训练后模型对所有小结节完全不触发。看一下错误分析图这类漏检都集中在小于16x16像素的框。原因原始CT是1024x1024为了省显存强行把--img降到512病灶从原来的24像素缩到12像素特征图上的响应已经微弱到可以被忽略。另一个原因是预处理时压缩PNG尺寸但标签没有同步更新。本质是分辨率不匹配。解决训练尺寸尽量贴近原始采集分辨率或者在数据整理时保留一份高分辨率版本。如果显存确实不够更合理的做法是把512x512以上的原图切块tiling而不是全局resize。这里不做展开但记住肺结节数据集里直径小的结节常常只有20像素左右任何一次过于激进的resize都等于人为制造漏检。5.5 背景框被当成结节标注质量参差不齐现象验证集上F1不错但把模型拿到真实CT序列上跑出现大量假阳性框全部集中在血管分叉、胸膜处。原因原始标注可能由不同医生完成有些医生把直径3mm的微小高密度影都标为结节有些医生只标明确的恶性病灶导致类别内部形态差异极大。另外有些标注框过宽把周边肺纹理也包进框内模型学到的其实是“带纹理的背景”。解决先统计训练集所有标注框的面积与宽高比分布。如果大部分框面积小于100平方像素模型就很难学到稳定特征如果宽高比分布集中在1.0附近说明标注框总体规整如果出现大量宽高比2.0以上的框多半是标注时框取了整片肺叶。最直接的解决手段是抽样画图把标注框叠加到原图上人工复查这一步不能被自动化完全代替。import cv2 # 读取一张验证图片和对应标签画出前几个框 img cv2.imread(val_image.png) H, W img.shape[:2] with open(val_image.txt) as f: lines f.readlines()[:5] for line in lines: cls, cx, cy, bw, bh map(float, line.split()) x1 int((cx - bw / 2) * W) y1 int((cy - bh / 2) * H) x2 int((cx bw / 2) * W) y2 int((cy bh / 2) * H) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(visual_check.png, img)这个可视化脚本不追求美观但能在五分钟内让人判断出数据集标注是否靠谱。如果画出来的框有明显偏移不要怀疑模型直接修数据。6. 用模型反向推理校验肺结节数据集的标注质量6.1 为什么训练后的预测框是标注的照妖镜训练好的模型已经见过大量“结节”和“非结节”的判别特征。如果数据标注本身可靠模型在验证集上的预测框应当与真实框大面积重合。反过来那些只有标签没有预测或只有预测没有标签的区域往往就是漏标或错标信号。靠人眼逐张检查几千张切片不现实用一次推理来扫描数据集效率高出一个量级。6.2 批量预测并保留低置信度结果先用训练好的best权重跑一遍验证集python detect.py --weights runs/train/exp/weights/best.pt \ --source dataset/images/val \ --save-txt --save-conf \ --conf-thres 0.1--save-txt会在runs/detect/exp/labels/下生成与验证图同名的txt格式比训练标签多一列置信度也就是class_id x_center y_center width height conf。这里的--conf-thres 0.1是个关键参数降到0.1可以让模型保留大量低置信度预测框便于后续找出“模型觉得像结节但标签没有”的漏标候选。6.3 高置信度无匹配框的自动筛选下面脚本的核心思路是把所有预测框与真实GT做IoU匹配找出IoU低于0.1但置信度高于0.7的预测。这些框大概率是标签漏掉的结节也可能是模型误检的血管或胸膜无论如何都值得人工确认。import os def iou(box1, box2): box: (x1, y1, x2, y2)像素坐标 x1 max(box1[0], box2[0]); y1 max(box1[1], box2[1]) x2 min(box1[2], box2[2]); y2 min(box1[3], box2[3]) inter max(0, x2 - x1) * max(0, y2 - y1) area1 (box1[2] - box1[0]) * (box1[3] - box1[1]) area2 (box2[2] - box2[0]) * (box2[3] - box2[1]) return inter / (area1 area2 - inter 1e-9) gt_dir dataset/labels/val pred_dir runs/detect/exp/labels for pred_file in os.listdir(pred_dir): if not pred_file.endswith(.txt): continue stem pred_file.replace(.txt, ) gt_file os.path.join(gt_dir, stem .txt) if not os.path.exists(gt_file): continue preds [] with open(os.path.join(pred_dir, pred_file)) as f: for line in f: parts line.split() if len(parts) 6 and float(parts[5]) 0.7: cls, cx, cy, bw, bh, conf map(float, parts) # 先假设图像宽高为W,H根据验证图实际值填入 W, H 512, 512 preds.append((cls, cx, cy, bw, bh, conf)) gts [] with open(gt_file) as f: for line in f: cls, cx, cy, bw, bh map(float, line.split()) gts.append((cls, cx, cy, bw, bh)) suspicious [] for pred in preds: # 转换到像素坐标后再做IoU pred_box [ (pred[1] - pred[3] / 2) * W, (pred[2] - pred[4] / 2) * H, (pred[1] pred[3] / 2) * W, (pred[2] pred[4] / 2) * H, ] if all(iou(pred_box, gt_box_to_pixel(gt, W, H)) 0.1 for gt in gts): suspicious.append((pred_file, pred, conf))这个脚本执行后你会得到一批“模型很有把握但数据集里压根没有标注”的候选。人工复核这些区域如果确实是结节就在原数据集里补齐标注重新训练一版如果模型持续在血管或胸膜处误检说明训练数据里欠缺负样本或标注框本身带有误导性。这个反向校验方法我通常在第一次训练结束后就做它能直接告诉你下次迭代到底应该改模型还是改数据。我现在的习惯是等第一轮训练跑完先不急着调超参数而是花半小时做一轮反向推理把高置信度且无GT匹配的框列出来。大多数情况下数据集里存在的问题比模型还多这部分清洗工作在后面的每一次迭代里都在帮你节省时间。希望这篇整理能帮你避开肺结节CT数据转换成YOLOv5格式的暗坑。本文还有配套的精品资源点击获取