YOLO公交车检测实战:从VOC数据集转换到模型训练与部署
发布时间:2026/9/23 14:09:14 作者:尧图编辑部 阅读量:1,286

简介面向YOLO系列模型的公交车检测专用数据集源自PASCAL VOC2012训练验证集仅保留bus这一个类别为实时目标检测模型的训练与评估提供干净、直接的数据支撑适用于交通监控、智能网联汽车等场景。压缩包共1402个文件容量约55.82MB其中包含467张JPEG原图、467个XML详细标注文件以及468个TXT标签文件XML标注记录了边界框坐标、尺寸与图像关联信息TXT标签则提供精简的框位置与类别标识两种格式均可兼容主流YOLO训练流程。该数据集已有643人浏览学习适合对目标检测已有基本认知、希望针对公交车类别做专项训练和参数调优的开发者或研究人员。利用这份数据可免去从大型通用数据集中人工筛选和转换标注的耗时步骤快速搭建公交车检测实验也可结合YOLOv3、YOLOv8等不同版本对比模型效果深入理解单类别检测任务的精度与实时性权衡。1. 只看标题以为很简单公交车检测数据集为什么值得认真跑一遍 YOLOYOLO 公交车检测数据集在实作里属于那种“越是觉得稳的类目越容易翻车”的类型目标外观高度统一、角度集中在侧视、背景基本是城市道路和站台看起来要比通用检测简单得多。bus_VOCtrainval2012.zip 这个文件名拆开就是三个事实数据来自 Pascal VOC 2012 的 trainval 划分只保留公交车这一个类目标注格式是 VOC 的 xml。对刚入门目标检测、想跑通一整套训练流程的新手来说它足够小够干净适合拿来理解数据集格式、迁移学习和损失曲线对已经跑过 COCO 这类多类别数据的人来说它又能让你把注意力全部放在误检、置信度门限和部署效果上而不是被 80 个类别的输出干扰。下面按“数据集结构 → VOC 转 YOLO → 训练参数 → 踩坑 → 验证”的顺序把它完整走一遍。2. bus_VOCtrainval2012 在设计上解决了什么问题2.1 公交车检测和通用目标检测的区别通用目标检测数据集动辄八十个类别模型要同时学会区分猫、狗、车、椅子输出层的类别维度很大一个误检很难说清是分类头的问题还是回归框的问题。公交车检测只有一类输出只有一个 class id看起来是把问题大幅简化了但实际上把矛盾全挤到了“什么是正样本”上哪些车算公交、哪些车不算。公交车的场景约束非常固定视角基本是路边侧视或路口斜视车辆外观在城市客车和长途客车之间变化尺寸跨度却大得离谱——近处的巴士可能占掉半张图远处路口的公交只有几十个像素。这个分布特点是通用数据集给不了你的训练体验。跑一次单类数据集的完整流程你能直观感受到数据分布决定性能上限这件事模型结构再换、训练轮数再加也只是在这个上限上往回收一点。另外一个容易被忽视的地方是bus 这个类目在 VOC 里常年和 truck、car 构成困难样本。很多人拿到数据集后的第一个想法是“只有一个类应该很好训”但训练完一测发现卡车、渣土车甚至大型 SUV 都被框成了公交。这说明数据处理时不能简单“只留 bus 删掉其他”把容易混淆的类目当作负样本信息留下来是这类单类数据集真正值得琢磨的设计点。2.2 从文件名拆出来的信息trainval、2012、VOC 格式bus_VOCtrainval2012 这个名字不是随便起的三个关键信息直接决定你后面的脚本怎么写。Trainval 表示训练验证集的划分已经给你了。Pascal VOC 2012 的 trainval 是社区沿用了很久的标准划分包含大约几千张图片其中带公交车的图片是它的一个子集。trainval 等于 train 加 val也就是说你不需要自己拿随机种子去切数据直接用原始划分就能做训练和验证对比实验的基线也因此是稳定的。2012 指的是 Pascal VOC 2012 这个版本。VOC 2012 本身有 train、val、test 三个部分test 的标注不公开所以社区普遍用 trainval 来做训练和验证。这也是为什么网上各种“VOC 转 YOLO”的脚本大多默认你手里是 trainval 而不是完整的 test。VOC 格式指的是标注文件是 xml每个对象用 bndbox 节点存 xmin、ymin、xmax、ymax 四个绝对像素坐标。这个格式和 YOLO 训练的 txt 标签有本质差异一个是绝对坐标一个是相对坐标一个是 xml 树一个是每行一个对象的纯文本。所以拿到 zip 后的第一件事永远是格式转换不能跳过。2.3 这份数据集适合谁跑通它意味着什么三种人最适合拿它练手。第一种是刚开始学 YOLO、还不清楚数据流怎么走的初学者这份数据只有单一类目标签出错时定位快不会一错错在某个冷门类别上。第二种是正在做交通相关项目、需要先跑一个单类基线的工程师公交专用道抓拍、站台人流统计、卡口车型分类这类场景直接拿它做预训练或基准测试都合适。第三种是做对比实验的人固定数据集、固定 trainval 划分实验的可比性比自采数据强得多。跑通这份数据意味着你掌握了目标检测训练的一套可复用流程理解数据集目录结构写转换脚本把 VOC xml 转成 YOLO txt配置数据集 yaml跑训练看损失曲线用验证集调置信度门限最后导出权重做部署。后面换成任何自采数据集流程都一样变的只是类目数量和路径配置。3. 把 VOC 标注转成 YOLO txt 格式转换脚本与四个边界坑3.1 为什么必须转换YOLO 要求的标签格式长什么样YOLOv5、YOLOv8 训练时读取的标签不是 xml而是每张图片对应一个同名 txt 文件。txt 里每一行代表一个目标格式是class x_center y_center width height其中 x_center、y_center、width、height 全部是相对于图像宽高的归一化值范围在 0 到 1 之间。VOC 的 xml 里存的是 xmin、ymin、xmax、ymax 这四个绝对像素坐标转换要做两件事把绝对坐标改成中心点加宽高再分别除以图像宽高完成归一化。这里有个隐藏问题归一化后的中心点坐标理论上一定在 0 到 1 之间但宽度和高度有可能因为标注越界而超过 1训练时 YOLO 会直接报错或忽略这个框。所以转换脚本里必须做越界裁剪不能简单算完四个数就写文件。3.2 转换脚本一段走完从 xml 到 txt 的完整流程我不建议去找现成的通用转换工具很多工具转换时会顺带改类名、过滤标签反而引入看不见的问题。自己写一段小脚本最可控。这份数据只保留 bus 类所有 bus 对象的类 id 统一设成 0脚本逻辑很简单# voc2yolo_bus.py # 用法: python voc2yolo_bus.py --xml_dir Annotations --img_dir JPEGImages --out_dir labels import os import argparse import xml.etree.ElementTree as ET from PIL import Image def convert(xml_path, img_dir, out_dir): tree ET.parse(xml_path) root tree.getroot() # 注意: 不要完全信任 xml 里的 filename 字段, 有些标注用的是绝对路径 img_name root.find(filename).text.strip() img_path os.path.join(img_dir, img_name) # 图像宽高必须从图片读取, 而不是从 xml 的 size 节点读 # 因为 xml 里记录的尺寸偶尔会和实际图片不一致, 会导致归一化坐标全部偏移 w_img, h_img Image.open(img_path).size yolo_lines [] for obj in root.findall(object): cls_name obj.find(name).text.strip().lower() if cls_name ! bus: # 只保留 bus, 其他类目全部丢弃 continue box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) # 防御性裁剪: 坐标偶尔会超出图像边界 xmin max(0.0, min(xmin, w_img - 1)) xmax max(0.0, min(xmax, w_img - 1)) ymin max(0.0, min(ymin, h_img - 1)) ymax max(0.0, min(ymax, h_img - 1)) # 转中心点 宽高, 再归一化 cx (xmin xmax) / 2.0 / w_img cy (ymin ymax) / 2.0 / h_img w (xmax - xmin) / w_img h (ymax - ymin) / h_img # 类 id 固定为 0, 因为数据集只有 bus 一个类 yolo_lines.append(f0 {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) # 输出文件名必须和图片名保持完全一致, 只换扩展名 label_name os.path.splitext(img_name)[0] .txt with open(os.path.join(out_dir, label_name), w) as f: f.writelines(line \n for line in yolo_lines) if __name__ __main__: parser argparse.ArgumentParser(descriptionVOC xml 转 YOLO txt) parser.add_argument(--xml_dir, requiredTrue, help存放 xml 的目录, 通常是 Annotations) parser.add_argument(--img_dir, requiredTrue, help存放图片的目录, 通常是 JPEGImages) parser.add_argument(--out_dir, requiredTrue, help输出 txt 标签的目录) args parser.parse_args() os.makedirs(args.out_dir, exist_okTrue) for fn in os.listdir(args.xml_dir): if fn.endswith(.xml): convert(os.path.join(args.xml_dir, fn), args.img_dir, args.out_dir) print(转换完成)脚本里最需要注意的一点是图像宽高必须用 PIL 从图片文件读取而不是从 xml 的 size 节点读。原因在于部分标注工具的 size 节点写的是缩略图尺寸直接拿来做归一化分母会导致标签整体偏移几个百分点训练时 loss 降不到底。然后是越界裁剪。VOC 标注里偶尔会出现 xmax 超出图片宽度一两个像素的情况如果不做裁剪归一化后的 w 会略大于 1YOLO 训练时对这类框的处理不可预期有的版本会忽略有的版本会报错白白浪费时间排查。调用方式很简单python voc2yolo_bus.py --xml_dir Annotations --img_dir JPEGImages --out_dir labels执行完后labels 目录下每个与 JPG 同名的 txt 文件就生成了。整个转换脚本不需要依赖 labelimg 之类的工具因为这份数据是已经标注好的不是让你重新打标。3.3 转换完成后的三个自检动作转换完不检查直接训练是最容易浪费时间的做法。我一般做三道检查加起来不到一分钟# 1) 统计标签行数, 看看有没有文件是空的(说明该图没有 bus 目标) find labels -name *.txt | xargs wc -l | sort -n | head -5 # 2) 检查归一化坐标有没有越界, 越界值会打印出来 awk {for(i2;i5;i) if($i0 || $i1) print FILENAME, $0} labels/*.txt # 3) 随机抽几张图, 把框画回去肉眼看定位准不准 python -c import cv2, os, random samples random.sample(os.listdir(labels), 5) for name in samples: img cv2.imread(os.path.join(JPEGImages, name.replace(.txt, .jpg))) h, w img.shape[:2] for line in open(os.path.join(labels, name)): _, 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(check_ name.replace(.txt, .jpg), img) print(已生成 5 张可视化检查图) 第一道检查看空文件空文件说明图片集里有不含公交车的图这是正常现象。第二道检查看坐标越界如果 awk 一个值都没打印出来基本可以放心训练。第三道检查最关键把所有标签画回图上肉眼确认框应该紧贴车身而不是偏出半个车长。3.4 转换环节的四个边界坑第一个坑是 xml 里的 filename 字段不可信。有些标注工具会写绝对路径有些会写带子目录的相对路径直接用会导致图片打开失败。我一般干脆忽略 filename 字段用 xml 文件名去找同名图片这样最稳。第二个坑是目录名大小写和解压差异。VOC 官方结构里是 JPEGImages 和 Annotations但网上流传的网盘资源解压后经常被改成小写或者多套一层目录。YOLO 训练时找不到图片的报错信息往往不直观直接显示验证集图片数为 0排查半天才发现是路径大小写问题。拿到 zip 先 ls 看目录结构再写转换脚本这个习惯能省一小时。第三个坑是同一张图里两辆公交车重叠时的去重问题。xml 里是两个 object转换后就是两行不要合并也不要过滤。但训练时数据增强中的 mosaic 会把多张图拼在一起重叠框会被增强出各种奇怪形状所以训练配置里对数据量不大的单类场景我会把 mosaic 的概率控制在 0.5 以下。第四个坑是 difficult 标签的处理。VOC 里有些对象标注了 difficult1表示这个目标难以辨认。转换时要不要保留业界没有统一标准我的默认做法是保留。公交车的轮廓相对清晰很少被标成 difficult但如果哪天拿到的数据里 difficult 占比超过 5%就要小心这批标注是不是本身质量很差。4. 用 YOLOv8 训练自己的公交检测模型从 yaml 到损失曲线4.1 模型选型为什么先试 yolov8n 和 yolov8s公交车检测是典型固定场景部署摄像头固定在路侧或站台算力资源往往有限。我一般第一版先跑 yolov8n 和 yolov8s 两个尺寸做对比而不是直接上 yolov8m 或 yolov8l。原因有两层。第一数据规模不大单类目标相似度高大模型在几百张图上很容易过拟合mAP50 反而不如小模型。第二固定场景检测器最终要部署到边缘设备上n 和 s 的权重体积小、推理帧率高项目落地的可能性大得多。如果小模型精度不够再加到 m 也不迟这个顺序比一上来就无脑上大模型要合理。模型体积训练速度适合场景yolov8n最小最快算力紧张试跑流程yolov8s中等快固定场景主力yolov8m较大中等n和s精度不够时再试4.2 数据集 yaml 和训练命令YOLOv8 需要一份 yaml 文件描述数据集的路径和类别信息。把第三章转换好的标签和图片按目录整理好yaml 写成这样# bus.yaml # 数据集根目录, 建议写绝对路径, 避免训练时路径歧义 path: E:/dataset/bus_VOCtrainval2012 train: images/train # 训练图片目录 val: images/val # 验证图片目录 nc: 1 # 类别数, bus 单类 names: [bus] # 类别名列表, 顺序必须和标签里的类 id 对应有一点要注意第三章生成的 labels 目录要和 images 目录保持同级。YOLO 训练时默认规则是图片在 images/train 下标签就在 labels/train 下文件名一一对应。如果你把标签和图片混在同一个目录里需要显式指定标签路径否则会报找不到标签文件的错误。整理好目录后训练命令很简单# 使用 COCO 预训练权重, 迁移学习训练 100 轮 yolo detect train databus.yaml modelyolov8n.pt epochs100 imgsz640 batch16 device0batch 参数按显存调整。6GB 显存跑 yolov8n 用 batch16 基本没问题如果显存到 8GB 可以把 imgsz 提到 960或者把 batch 加到 24。对这份数据来说更多样本下imgsz 提高对公交车小目标的收益比加大 batch 更明显因为远处路口的公交在 640 分辨率下可能只有 30 个像素。epochs 也不一定要 100数据量小的时候配合早停。YOLOv8 默认会保留训练过程中最好的验证权重所以可以放心开到 200 轮让它自己停下来。4.3 迁移学习是这份数据的隐形成本训练命令里 modelyolov8n.pt 代表的是从 COCO 预训练权重继续训练而不是从零开始。这个选择在公交数据集上几乎起决定性作用。预训练权重在 COCO 上已经见过大量车辆类目对车身的纹理、边缘、光照变化有很强的先验bus 和 truck 在底层特征上高度相似模型只需要在最后一层微调类别的区分边界。从零训练的话几百张公交图根本不足以让模型学出稳定的车身特征训练轮数再高也只是在噪音上拟合。这也是为什么我前面说直接用这份数据训练隐形成本是“你默认用了迁移学习”。如果手里是 .yaml 格式的网络结构文件而不是 .pt 权重那是在跑从零训练效果会差很多。4.4 训练时看什么box loss、cls loss、dfl loss 和过拟合信号训练过程中终端会每轮打印一组指标很多人只看 mAP50 涨没涨我则主要看三个 lossbox loss、cls loss、dfl loss。box loss 是预测框和真实框之间的回归误差反映框得准不准。cls loss 是分类置信度的交叉熵损失反映有没有把公交认成其他东西。dfl loss 是 YOLOv8 引入的分布焦点损失用来让框的边更贴合目标边界它对小目标的框定位尤其敏感。正常的训练曲线是前 20 轮三个 loss 快速下降20 到 60 轮进入平台期之后只有微小波动。如果 train 的 loss 一直在降但 val 的 mAP 不涨甚至下降这是过拟合的典型信号说明模型在死记训练集公交车特征没有被泛化出来。处理方法有三个按优先级排调低 epochs 配合早停、把 mosaic 增强概率降下来、换回更小的模型。也可以适当关注验证集的 mAP50 和 mAP50-95但单类公交车的 mAP50 很容易冲到 90 以上mAP50-95 更有区分度它考察的是不同 IoU 阈值下的综合表现。两个指标差得太多比如 mAP50 有 0.95 但 mAP50-95 只有 0.6说明框虽然都能框住但边界贴合度不够此时优先调 box loss 相关的超参数而不是继续加训练轮数。5. 公交数据集训练中的 5 个常见坑现象、原因、解决5.1 标签文件匹配不上训练日志里 Images 和 Labels 数量对不上现象训练启动时提示 found 300 images and 0 labels或者图片数正常但标签数明显少于图片数。原因YOLO 训练要求图片和标签目录按照 images/train 和 labels/train 的约定组织而且文件名必须完全一致。最常见的两个问题一是图片和标签目录层级不对标签被放在了别的位置二是文件扩展名不一致图片是 .jpg转换脚本输出的标签是 .JPG 同名文件而 Linux 下大小写敏感直接匹配不上。解决先核对目录结构再用一段小命令检查同名文件匹配率# 统计 images/train 下有对应标签文件的图片占比 cd E:/dataset/bus_VOCtrainval2012 ls images/train | sed s/\.[^.]*$// | sort /tmp/img_names.txt ls labels/train | sed s/\.[^.]*$// | sort /tmp/lbl_names.txt comm -23 /tmp/img_names.txt /tmp/lbl_names.txt | head -10看到输出里的文件名基本就是缺失标签或名字不一致的元凶。我的习惯是在转换脚本里统一用 os.path.splitext 取主名再强制拼 .txt 后缀从源头杜绝大小写不一致。5.2 小目标公交车漏检imgsz 固定 640 的代价现象近处的公交检测得很好mAP 看起来也不错但实际视频里远处路口的公交经常漏检尤其当公交车是画面里很小的目标时。原因imgsz640 时一张 1920x1080 的画面被压缩到 640 宽远处一个 100 像素高的公交缩到 30 像素左右特征几乎丢失。这是小目标检测的老问题不是模型结构的错是输入分辨率的物理限制。解决把 imgsz 提到 960 或者 1280小目标召回率会明显上升。代价是训练时间和显存占用同步上升验证时也必须用同样的 imgsz否则推理和训练的分辨率不一致性能会出现肉眼可见的下降。如果显存实在不够可以先保持 640 训练后期用更大分辨率做几次微调这也是常见做法。注意不要只盯着 mAP 看小目标漏检在 mAP 里权重很低可视化验证几张大图往往比指标更直观。5.3 mAP 很高但实际推理全是误检现象验证集 mAP50 超过 0.9拿实拍照片一测路边的黑色轿车、广告牌上的公交车图片甚至垃圾桶都被圈了出来。原因数据集里全部是有公交车的正样本图模型没见过足够多的“没有公交车的场景”导致背景特征被当成车身特征。另一个常见原因是推理时置信度阈值默认取 0.25对这个样本分布来说阈值偏低。解决第一从原始 VOC 数据里筛出部分不含公交车的图片生成空标签 txt 放进训练集让模型学会输出空白。第二部署时把置信度阈值往上提0.4 到 0.45 往往能过滤掉大量背景误检。第三检查推理代码里的图像预处理确保 BGR 和 RGB 通道顺序一致这个低级错误能让你调一下午阈值都找不到原因。5.4 公交车和卡车、渣土车互相认错现象验证集指标正常但视频里侧翻的卡车、绿色的渣土车会被识别成公交车尤其拉货的重卡从侧面看和公交车的轮廓非常接近。原因VOC 2012 里 bus 和 truck 本来就是一对难分类别框住车身时外形高度相似。这个问题在单类数据里被放大了数据里只保留 bus没有把其他车辆标注出来truck 在训练中被当作背景处理所以模型学到的是“车身像这种形状就输出 bus”而不是“这是公交车不是卡车”。解决把容易出现混淆的 truck 样本作为困难负样本显式加入数据集。做法是把不含公交车的 truck 图片放进训练集同时保证它们是空标签让模型知道“不是所有面包车形状都是公交”。另一个做法是保留 xml 里的 truck 目标把它标成背景类或单独一个类参与训练但那样会改变标签类别数要根据项目实际来权衡。5.5 训练到一半 loss 变 NaN现象前几轮训练正常突然某轮 loss 变成 nan之后所有指标都失效模型输出为空。原因学习率过大是最常见的原因尤其是用高 batch 配合默认学习率在小型数据集上训练时梯度直接爆炸。另一个原因是数据里混了损坏的图片比如全黑图、全白图、被截断的 jpg模型读到的特征向量异常。解决先按住模型不动把数据清洗做在前面。跑一段脚本逐张用 PIL 打开图片确认没有文件损坏或全黑全白图。然后检查学习率在优化器配置里把初始学习率下调一个量级比如从默认的 0.01 改成 0.001。yolov8 训练时还可以关掉 amp 混合精度试试fp16 在小数据集上偶尔会出现不稳定的情况。遇到 NaN 先怀疑数据和学习率不要急着换网络结构这是最省时间的排查顺序。6. 一个便宜的验证技巧扫一遍置信度门限再定部署阈值训练完拿到 best.pt 之后大多数人直接拿默认置信度 0.25 去跑实拍然后对着误检图抓瞎。我一般会在验证集上做一次置信度门限扫描花十分钟找到这个模型真正适合的工作点。核心思路是置信度阈值调低召回率高但误检多阈值调高误检少但漏检多。公交车检测这类固定场景误检比漏检更让人头疼——误检会在站台大屏上制造虚假报警。所以阈值应该往高调但不一定是 0.5 最好要看你的验证集统计结果。对 bus 数据集可以快速跑一次验证并手动对比几个阈值# 用不同置信度阈值跑验证集, 记录各自的 mAP 和召回率 yolo detect val modelruns/detect/train/weights/best.pt databus.yaml conf0.25 yolo detect val modelruns/detect/train/weights/best.pt databus.yaml conf0.40 yolo detect val modelruns/detect/train/weights/best.pt databus.yaml conf0.55把三次结果里 mAP50 和每张图平均检测框数量抄到一张表里置信度阈值mAP50每张图平均框数观察结果0.250.931.8部分实拍图出现背景误检0.400.911.1误检明显减少漏检不明显0.550.870.9开始丢失远处小目标公交如果 0.40 和 0.55 之间的 mAP 下降不超过 3 个点而误检少了三分之一部署时我会选 0.45 到 0.5。实际部署时再根据现场反馈微调值班人员嫌误检多就往高调嫌漏检多就往低调这个权衡没有统一答案。这个动作虽然朴素但特别实用。以前我拿到权重就默认 0.25 直接部署被实拍误检折磨过两次之后才养成了“先扫一遍门限再定阈值”的习惯。公交检测这种长期运行的固定场景项目宁可阈值高一点漏掉几个远处目标也不能让后台满屏飘误检框毕竟后台报警的信任一旦被消耗掉整个系统的价值就打折了。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取