ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

VOC转YOLO格式训练公交车检测模型:从数据集到YOLOv8实战

2026/10/1 3:04:02 拓冰建站 浏览量
VOC转YOLO格式训练公交车检测模型:从数据集到YOLOv8实战 简介在计算机视觉领域目标检测是基础且应用广泛的技术方向。训练一个可用的检测模型数据集格式的处理往往是决定成败的细节。VOC格式是历史悠久的标注标准而当今流行的YOLO模型则使用更简洁的归一化txt标注。将VOC格式转换为YOLO格式并针对公交车这类特定目标进行训练是解决交通场景智能监控等实际需求的有效路径。本文围绕bus_VOCtrainval2012数据集详细拆解了VOC标注结构、转换脚本的编写、坐标边界问题处理以及YOLOv8训练的参数配置并总结了常见的训练翻车场景与调优手段帮助工程师快速构建高精度的公交车检测模型。通过数据准备与参数调整可有效提升真实复杂场景下的检测精度。1. 拿到bus_VOCtrainval2012.zip先别急着解压去训练做公交车检测的从业者很多是直接拿一个YOLO预训练权重在视频上跑结果换到公交站、停车场这种场景就频繁翻车——漏检、框偏、重叠车辆混在一起。问题大多数不在网络结构而在于训练数据。这个bus_VOCtrainval2012.zip是从PASCAL VOC2012的trainval集合里把bus类别单独抽出来做成的数据集专门喂给YOLO系模型做公交车检测。它解决了两个头疼问题一是省去自己标注几十上百张公交车的工时二是把检测目标从20类收窄成1类模型不用在类别间纠结。这篇文章就顺着“数据集长什么样→VOC转YOLO格式→训练参数→踩坑→提精度”这五步讲清楚手上有这块数据但没有完整训练经验的人照着做就能把模型跑起来。2. bus_VOCtrainval2012.zip里装了什么VOC标注结构拆解2.1 解压后的目录结构JPEGImages、Annotations、ImageSetsVOC格式的数据集结构非常固定bus_VOCtrainval2012.zip解压开后核心就是三个目录JPEGImages放原图Annotations放和原图同名的XML标注文件ImageSets/Main里放着划分用的train.txt和val.txt。这个txt里面不是图片路径而是不带扩展名的文件名列表YOLO训练时需要通过这些文件名去JPEGImages和Annotations里找对应的图和标注。在动手转换之前先解压并确认目录是否完整mkdir -p bus_data unzip bus_VOCtrainval2012.zip -d bus_data cd bus_data ls -F # 正常会看到 Annotations/ ImageSets/ JPEGImages/ head -5 ImageSets/Main/train.txt用head看一眼train.txt能确认它是纯文件名列表。接下来统计一下XML数量和图片数量是否一致不一致的话后面转换阶段会漏样本echo 图片数量$(ls JPEGImages | wc -l) echo 标注文件数量$(ls Annotations | wc -l)一般来说图片数量和XML数量是相等的。如果发现XML文件比图片多或少先不要急着转换优先检查是不是有损坏文件或者隐藏目录。这个zip是从完整VOC2012里按bus类别筛出来的所以不是每个XML都一定包含bus也可能保留了少量其他类别后面转换脚本里会按类别过滤。2.2 一张图一个XMLobject、bndbox、difficult怎么读VOC标注的载体是XML里面记录图片尺寸、文件名以及每个目标物体的类别和真实框坐标。看一个例子最快grep -A6 object Annotations/bus_001.xml会输出类似下面这样的内容object namebus/name bndbox xmin89/xmin ymin117/ymin xmax312/xmax ymax235/ymax /bndbox difficult0/difficult /objectname是类别名bndbox里是左上角坐标(xmin, ymin)和右下角坐标(xmax, ymax)单位是像素。还有一个difficult标签这个值在目标检测任务里常被忽略但如果值为1代表这个目标很难辨认甚至标注可能是错的。转换到YOLO格式时我建议直接跳过difficult1的样本因为把它们硬塞给YOLO只会增加训练噪声。一个XML里可能有多个object节点代表同一张图里多个公交车。转换脚本要用循环遍历所有object不能只取第一个。2.3 为什么把bus单独抽出来比直接用完整VOC更省心在完整VOC2012的trainval集合里bus类别属于样本量偏少的类几十个类别混在一起模型训练时会花很多精力去区分“bus vs truck vs car”这种细粒度差异留给定位本身的容量反而变小。bus_VOCtrainval2012.zip把类别收窄到以公交车为主模型只需要学“什么是公交车”和“公交车在哪”背景误检率也会明显下降。但这样做有一个副作用因为类别单一数据增强和负样本策略格外重要。如果训练时看到的大量图片都是公交车居中摆放、占据画面大部分面积模型到了公交站台那种多辆车并排、互相遮挡的场景就会退化。所以trainval划分不是让你直接一股脑训练而是提醒你保留一部分没有参与训练的图片做验证最好是那些包含多辆车、复杂背景的。这也是为什么后续转格式时train.txt和val.txt一定要严格分开。3. 把VOC标注转成YOLO训练格式转换脚本与四个边界坑3.1 YOLO不读XML它只认每张图一个同名txtYOLOv5、YOLOv8甚至更早的YOLOv3训练数据格式高度统一图片放在images目录标注放在labels目录每张图对应一个同名txt文件。txt每一行代表一个目标格式是“类别id 归一化中心x 归一化中心y 归一化宽 归一化高”。全部都是用图片宽高归一化到0~1之间的小数而不是像素坐标。VOC里的XML写的是像素坐标所以必须写脚本做一次坐标计算。转换公式很简单中心坐标 cx (xmin xmax) / 2 / image_width中心坐标 cy (ymin ymax) / 2 / image_height宽度 w (xmax - xmin) / image_width高度 h (ymax - ymin) / image_height只要除的是这张图真实宽高而不是统一resize后的尺寸转换出来的坐标就和原图是一一对应的。YOLO训练时自己会做letterbox缩放不用你在这里改。3.2 可直接拿来用的VOC转YOLO脚本下面这个脚本是我在做过几次VOC数据集转换后沉淀下来的版本支持指定类别过滤、跳过difficult、把坐标裁剪到图片范围内。import os import xml.etree.ElementTree as ET # 按需修改这段配置 DATASET_ROOT bus_data # zip解压后的根目录 IMAGES_DIR os.path.join(DATASET_ROOT, JPEGImages) ANNOTS_DIR os.path.join(DATASET_ROOT, Annotations) IMAGESETS_DIR os.path.join(DATASET_ROOT, ImageSets, Main) LABELS_OUT os.path.join(DATASET_ROOT, labels) # 输出YOLO txt的目录 # 只保留bus类别其他类别跳过如果该数据集里有truck/car也想一起训练把id加上 CLASS_TO_ID {bus: 0} def convert_annotation(xml_path, img_w, img_h): tree ET.parse(xml_path) root tree.getroot() lines [] for obj in root.iter(object): name obj.find(name).text if name not in CLASS_TO_ID: continue diff 0 difficult_node obj.find(difficult) if difficult_node is not None: diff int(difficult_node.text) if diff 1: continue cls_id CLASS_TO_ID[name] bndbox obj.find(bndbox) # 有的标注格式是xmin/ymin/xmax/ymax也有用x0/y0/x1/y1这里按VOC标准 x1 float(bndbox.find(xmin).text) y1 float(bndbox.find(ymin).text) x2 float(bndbox.find(xmax).text) y2 float(bndbox.find(ymax).text) # 边界裁剪避免标注越界导致训练时框坐标出现负值或大于1 x1 max(0.0, min(x1, img_w)) y1 max(0.0, min(y1, img_h)) x2 max(0.0, min(x2, img_w)) y2 max(0.0, min(y2, img_h)) if x2 x1 or y2 y1: continue cx ((x1 x2) / 2.0) / img_w cy ((y1 y2) / 2.0) / img_h w (x2 - x1) / img_w h (y2 - y1) / img_h lines.append(f{cls_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}) return lines def convert_split(split_file, output_txt): os.makedirs(LABELS_OUT, exist_okTrue) with open(os.path.join(IMAGESETS_DIR, split_file), r) as f: image_ids [line.strip() for line in f if line.strip()] count 0 image_paths [] with open(output_txt, w) as out_f: for img_id in image_ids: img_path os.path.join(IMAGES_DIR, img_id .jpg) xml_path os.path.join(ANNOTS_DIR, img_id .xml) if not os.path.exists(img_path) or not os.path.exists(xml_path): print(f[warning] 跳过缺失文件: {img_id}) continue # 读图片真实宽高VOC的XML里也有size节点但不一样之处会用XML里的 # 更保险不过为了计算准确推荐从图片本身读取 from PIL import Image with Image.open(img_path) as im: img_w, img_h im.size lines convert_annotation(xml_path, img_w, img_h) label_file os.path.join(LABELS_OUT, img_id .txt) with open(label_file, w) as lf: lf.write(\n.join(lines)) image_paths.append(img_id) count len(lines) print(f{split_file} 共处理 {len(image_paths)} 张图写入 {count} 个目标标注) return image_paths convert_split(train.txt, bus_train_paths.txt) convert_split(val.txt, bus_val_paths.txt)逻辑说明脚本先从ImageSets/Main读入划分列表再逐张图片解析XML。读取图片宽高用的是Pillow而不是XML里的size节点原因是有些VOC XML记录的尺寸和真实图片不一致以真实尺寸为准才能避免转换出来的归一化坐标全部偏移。convert_annotation函数只保留CLASS_TO_ID里定义的类别如果这个zip里混入了其他类别名这里会自动过滤掉。参数说明CLASS_TO_ID {bus: 0}表示把bus映射成类别0。如果后面想扩充数据集加入其他类别需要在这里补充比如{bus: 0, truck: 1, car: 2}。但要注意类别id是全局统一的改一次就要彻底重新生成labels不能只改一行配置。difficult1直接跳过不做讨论。3.3 转换过程最容易被坑的四个边界第一个坑是坐标裁剪。很多VOC标注是从别的工具导出或者手工改过的xmax可能比图片宽度还大或者xmin小于0。如果不做裁剪归一化后w会大于1YOLO会当成非法目标直接丢弃那张图的整个label文件就空了。上面脚本里统一做了min/max裁剪宁可让框缩小一点也要保证坐标合法。第二个坑是空label文件。如果一张图里所有的object都被difficult跳过或者坐标全被裁没了YOLO训练遇到空txt会直接报错吗实际上YOLOv8会把这张图当成纯背景图参与训练不会崩溃。但如果你用脚本统计mAP这张图会变成漏检。处理方式是保持空文件存在而不是删除图片这样训练集和图片数量始终对得上。第三个坑是文件名不对齐。VOC时代的图片后缀一般是.jpg但也有.png和.JPG。转换脚本里写的是img_id .jpg如果原图是.png就会跳过。最好先确认JPEGImages里有没有非jpg文件再决定要不要用glob匹配同名任意后缀。我在第一次处理时就是没看后缀结果train转换出200多张图val只有零头训练曲线一直不稳。第四个坑是中文路径。如果数据集放在C:\项目\bus_data这种路径下PIL和xml解析都可能报编码错误。解决方案很简单把整个目录固定到纯英文路径比如D:\datasets\bus然后再跑转换。这个问题在Linux服务器上不明显在Windows上几乎是必现。3.4 转换后必须做一次可视化验证转换完不要急着配置训练先用工具随机抽几张图把txt标注画在图上看看框的位置对不对。最快捷的方法是保存一张对比图python - EOF from PIL import Image, ImageDraw import glob, random labels_dir bus_data/labels images_dir bus_data/JPEGImages files glob.glob(labels_dir /*.txt) for label_file in random.sample(files, 3): img_id label_file.split(/)[-1][:-4] img Image.open(f{images_dir}/{img_id}.jpg).convert(RGB) draw ImageDraw.Draw(img) with open(label_file) as f: for line in f: cid, cx, cy, w, h map(float, line.split()) x1 (cx - w/2) * img.width y1 (cy - h/2) * img.height x2 (cx w/2) * img.width y2 (cy h/2) * img.height draw.rectangle([x1, y1, x2, y2], outlinered, width3) output fcheck_{img_id}.jpg img.save(output) print(f已保存 {output}) EOF如果框在公交车轮廓上贴合得还可以再进入训练步骤如果框明显偏移或者变成了一条线回头查XML的坐标原始值大概率是标签写错了。可视化这一步花不了几分钟能省下后面每次训练完排查结果的时间。4. 用YOLOv8训练公交车检测最小训练命令与关键参数4.1 先按YOLOv8的目录规范重新组织数据YOLOv8和YOLOv5一样要求图片和标注分开放而且训练集和验证集都要单独建目录。转换脚本已经生成了labels目录但里面同时混着train和val的txt需要分别复制到对应子目录。mkdir -p train_images train_labels val_images val_labels # 按之前生成的图片路径列表复制 while read id; do cp bus_data/JPEGImages/$id.jpg train_images/; done bus_train_paths.txt while read id; do cp bus_data/labels/$id.txt train_labels/; done bus_train_paths.txt while read id; do cp bus_data/JPEGImages/$id.jpg val_images/; done bus_val_paths.txt while read id; do cp bus_data/labels/$id.txt val_labels/; done bus_val_paths.txt这一步看起来啰嗦但很有必要。YOLOv8的train接口对数据集路径要求很死如果train和val的图片目录没有和labels目录保持同级训练时一多半的时间会因为在磁盘上找不到标注而报错。把目录复制成下面这种结构最稳妥datasets/bus/ images/ train/*.jpg val/*.jpg labels/ train/*.txt val/*.txt bus.yaml注意image和labels目录是并列的YOLOv8默认会根据images/train自动找labels/train不用在yaml里重复配置labels路径。4.2 编写bus.yaml一个类别也要写全创建datasets/bus/bus.yamlpath: datasets/bus train: images/train val: images/val nc: 1 names: 0: bus说明path是相对yaml文件的路径如果你在datasets/bus目录下执行训练命令可以写一个点path: .但我更推荐写绝对路径或从训练时的工作目录出发的相对路径避免因为终端当前目录不同而找不到数据。nc和names长度必须一致否则YOLOv8会直接报invalid class count。4.3 训练命令逐项拆解cd datasets/bus yolo train \ databus.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ device0 \ projectruns/bus_detect \ nameyolov8n_busdata指定上一步的yamlmodelyolov8n.pt是预训练权重。YOLOv8第一次运行会去下载yolo预训练模型也就是COCO上训好的权重。虽然COCO里有bus类但直接用COCO权重在公交视频上推理效果远不如用自己的数据集微调一遍。预训练权重的意义是让模型不用从零学颜色、边缘这些基础特征收敛更快mAP上限也更高。epochs100对单类小数据集来说是起步值。如果训练到50轮时val mAP还在爬可以加到200但要注意观察是否过拟合。imgsz640是YOLOv8默认输入尺寸公交车长宽比普遍超过1.51后续可以在验证时再试imgsz960。batch16根据显存调整常见8GB显存跑yolov8n用16没问题如果要用yolov8m或yolov8lbatch降到8或4同时配合梯度累积。device0指定用第一张GPU没有GPU就改成devicecpu但训练速度会慢很多。4.4 训练过程中要看什么loss曲线和mAP不是只看最终值训练启动后终端会实时打印每个epoch的box_loss、cls_loss、dfl_loss以及metrics/precision(B)、metrics/recall(B)、metrics/mAP50(B)、metrics/mAP50-95(B)。常见观察方法是前三轮loss大跌、精度快速上升是正常的说明预训练权重在快速适配新数据集。如果loss一直横盘不动优先回头查data.yaml和labels目录是不是空的。mAP50-95在单类数据集上通常不会像COCO那么多类别那么高公交车检测更看重mAP50也就是IOU阈值0.5下的表现这个在工程里更能反映“检测框有没有落在车身上”。训练结束后的产物在runs/bus_detect/yolov8n_bus/weights/下best.pt是按验证集mAP保存的最佳权重last.pt是最后一轮权重。实际部署时用best.pt不要用last.pt。4.5 验证集上跑一次推理确认模型吃到了数据yolo predict modelruns/bus_detect/yolov8n_bus/weights/best.pt \ sourcedatasets/bus/images/val \ saveTrue \ imgsz640把验证集图片整个跑一遍人工扫一眼保存下来的检测结果图重点看两个现象一是公交车有没有被漏检二是背景里那些形状接近车身的路牌、广告箱有没有被误检成bus。这个环节比只看mAP数字更真实因为mAP是一个统计量会掩盖单张图上的极端错误。5. 公交车数据集训练时的5个典型翻车现场避坑与排查5.1 图片和标注对不上loss正常但mAP一直是0现象训练过程loss下降很平滑但验证集mAP一直是0或者低得离谱。预测出来的框全部在图片边缘。原因最常见的是转换脚本里读取图片尺寸和实际图片尺寸不一致。例如XML里size节点写的是缩略图的尺寸但JPEGImages里是原图尺寸转换后归一化坐标全部被压缩了模型学到的框位置全是错的。另一种可能是labels目录里的txt内容和images目录不是同名对应YOLO会跳过找不到label的图片模型实际能看到的标注很少。解决先检查labels下txt数量是不是等于images训练图片数量再用上面3.4节的可视化脚本抽几张图看框的位置是否由“明显贴边”变为“贴着车身”。如果框位置错误重新确认代码里读的宽高是原图宽高而不是XML里的size节点。5.2 BN崩溃loss突然变NaN或剧烈震荡现象训练到几十轮后loss曲线突然冒出几个尖峰甚至变成NaN后续所有epoch都恢复不过来了。这属于训练过程中最让人头疼的“玄学”问题之一但在这个数据集上原因非常明确。原因batch太小。单类bus数据集的图片尺寸变化大如果显存不够把batch压到4甚至2批量归一化层的统计量估计就不稳定。特别是车身上有大面积反光、窗口纹理时激活值波动大BN的running mean被带偏最终梯度爆炸。解决至少把batch开到8最好16。如果你的显卡跑不了16两个办法一是换更小的模型yolov8n跑不动就换yolov8n的tensorrt版或者用--cache缓存数据二是保留batch4同时打开梯度累积accumulate8代价是训练变慢。如果已经训练出NaN不用再基于那个权重继续跑直接从最后的正常checkpoint重新训练或换随机种子。5.3 验证集上漏检严重公交车的目标尺寸分布太极端现象模型在训练集图片上表现不错一到验证集上近处的公交车能检出远处的小目标或者只露出半个车身的公交车全部漏掉。原因两个因素叠加。第一是公交车的长宽比大默认imgsz640在letterbox后车辆会被压扁小目标特征丢失。第二是VOC数据集的标注里很多bus实例是良好光照、占据画面较大面积验证集里恰好有较多远景车辆。解决先试imgsz960重新训练或直接用imgsz960做验证看mAP是否提升。如果提升明显后续部署也统一使用960输入。注意改成960后batch要相应减半例如从16降到8否则显存会爆。5.4 混淆矩阵总和为什么不是1别被这个数字带偏现象训练结束后打开confusion_matrix.png发现每一行或者每一列加起来不是1有人会以为模型统计错误实际上这个困惑在目标检测里非常常见。原因YOLO的混淆矩阵统计的是图片上所有预测框和真实框的匹配关系除了bus这一类之外还有“background”这一类。预测到背景上的框会被算进background真实目标没有检测到也会算进background。因此矩阵行列归一化之后每个类别对应的那行可能远小于1因为很多预测框被背景吸收了。再加上VOC里有difficult标注被过滤验证集里实际参与评估的GT数量比label文件里看到的要少总和自然不是1。解决要看的不是每个行列总和而是查results.csv里的metrics/precision(B)和metrics/recall(B)。如果precision和recall都高说明模型误检和漏检都不多矩阵合计数不必管。如果实在要清理混淆矩阵可以在val.py里关闭background类统计但这对模型调节没有实际意义。5.5 训练完推理框偏移数据增强和标注归一化打架现象训练集和验证集上mAP都很好但拿同一张测试图用yolo predict跑框和车身的贴合度不如训练时看到的可视化结果很多框是歪的。原因最常见的是predict时输入尺寸和训练时不一致导致letterbox计算出的缩放比不同。YOLOv8在推理时虽然会自适应但如果训练时用imgsz640推理时却用了默认imgsz416小模型的特征图缩放会对坐标解码造成偏差。解决推理时固定imgsz640和训练时一致。另外也要检查有没有在转换脚本里对图片做了resize但没同步更新标注坐标“自己resize图片、却又按原始XML坐标转txt”这个操作会让框整体错位。我的习惯是只要数据集是VOC格式来的就永远不要手动resize原图训练和推理的缩放全部交给YOLO的letterbox自动处理。6. 公交车检测的精度提升技巧从imgsz到数据增强6.1 先做一次大图训练再看数据增强收益公交车检测的分辨率敏感性比常规目标高很多。imgsz640对于一张公交车占画面1/3的图足够但公交站场景里远处驶来的公交车可能只有几十像素高小目标需要更大的输入分辨率。我做过不少数据集imgsz从640提到960mAP50提升幅度经常能达到5个百分点以上。有显存条件的话用imgsz1280再试一轮但注意这时batch要减半训练时间也会拉长。推荐的做法是先用imgsz640跑通流水线再用imgsz960比较一次稳定后选择验证集mAP更高的一组部署。6.2 不要手动改损失函数权重先动数据和增强很多人看到漏检就想去调YOLOv8的损失函数比如加大box_loss权重。我一般不建议先动这里因为YOLOv8默认的TAL分配策略对单类数据已经足够稳定乱调会导致框回归过拟合训练集。真正值得花时间的是数据增强和样本扩展。如果这个bus数据集是纯VOC trainval可以再从COCO数据集里筛出所有bus类别的图片加入训练但要注意COCO的标注风格和VOC不同需要统一转成YOLO格式。新增样本时优先挑遮挡、夜间、远距离的图这比简单增加图片数量更有用。6.3 部署前的最后一道工序训练完成后建议用best.pt在几段完全没参与训练的视频上测试尤其是包含公交站台、多辆车并排、车窗反光的片段。只看mAP不够要人工判断漏检是否出现在车辆重叠的区域。如果重叠时两个框融合成一个检查NMS阈值默认iou0.45可以降到0.4来保留更多重叠框但小心产生重复检测。做这个数据集训练我最大的教训是转换格式时偷懒没做可视化验证结果训练了十几个epoch才发现label坐标全偏了。从那以后凡是VOC格式数据我都先转一个子集画框确认再上完整训练。一次失误浪费的时间比踏实验证多得多。希望帮到你。本文还有配套的精品资源点击获取