简介:在计算机视觉领域,目标检测是基础且应用广泛的技术方向。训练一个可用的检测模型,数据集格式的处理往往是决定成败的细节。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、ImageSets
VOC格式的数据集结构非常固定,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 一张图一个XML:object、bndbox、difficult怎么读
VOC标注的载体是XML,里面记录图片尺寸、文件名,以及每个目标物体的类别和真实框坐标。看一个例子最快:
grep -A6 "<object>" Annotations/bus_001.xml会输出类似下面这样的内容:
<object> <name>bus</name> <bndbox> <xmin>89</xmin> <ymin>117</ymin> <xmax>312</xmax> <ymax>235</ymax> </bndbox> <difficult>0</difficult> </object>name是类别名,bndbox里是左上角坐标(xmin, ymin)和右下角坐标(xmax, ymax),单位是像素。还有一个difficult标签,这个值在目标检测任务里常被忽略,但如果值为1,代表这个目标很难辨认甚至标注可能是错的。转换到YOLO格式时我建议直接跳过difficult=1的样本,因为把它们硬塞给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,它只认每张图一个同名txt
YOLOv5、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_ok=True) 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,不能只改一行配置。difficult=1直接跳过,不做讨论。
3.3 转换过程最容易被坑的四个边界
第一个坑是坐标裁剪。很多VOC标注是从别的工具导出或者手工改过的,xmax可能比图片宽度还大,或者xmin小于0。如果不做裁剪,归一化后w会大于1,YOLO会当成非法目标直接丢弃,那张图的整个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], outline="red", width=3) output = f"check_{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.yaml:
path: 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 \ data=bus.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ device=0 \ project=runs/bus_detect \ name=yolov8n_busdata指定上一步的yaml;model=yolov8n.pt是预训练权重。YOLOv8第一次运行会去下载yolo预训练模型,也就是COCO上训好的权重。虽然COCO里有bus类,但直接用COCO权重在公交视频上推理,效果远不如用自己的数据集微调一遍。预训练权重的意义是让模型不用从零学颜色、边缘这些基础特征,收敛更快,mAP上限也更高。
epochs=100对单类小数据集来说是起步值。如果训练到50轮时val mAP还在爬,可以加到200,但要注意观察是否过拟合。imgsz=640是YOLOv8默认输入尺寸,公交车长宽比普遍超过1.5:1,后续可以在验证时再试imgsz=960。batch=16根据显存调整,常见8GB显存跑yolov8n用16没问题,如果要用yolov8m或yolov8l,batch降到8或4,同时配合梯度累积。device=0指定用第一张GPU,没有GPU就改成device=cpu,但训练速度会慢很多。
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 model=runs/bus_detect/yolov8n_bus/weights/best.pt \ source=datasets/bus/images/val \ save=True \ imgsz=640把验证集图片整个跑一遍,人工扫一眼保存下来的检测结果图,重点看两个现象:一是公交车有没有被漏检;二是背景里那些形状接近车身的路牌、广告箱有没有被误检成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缓存数据;二是保留batch=4,同时打开梯度累积accumulate=8,代价是训练变慢。如果已经训练出NaN,不用再基于那个权重继续跑,直接从最后的正常checkpoint重新训练或换随机种子。
5.3 验证集上漏检严重:公交车的目标尺寸分布太极端
现象:模型在训练集图片上表现不错,一到验证集上近处的公交车能检出,远处的小目标或者只露出半个车身的公交车全部漏掉。
原因:两个因素叠加。第一是公交车的长宽比大,默认imgsz=640在letterbox后车辆会被压扁,小目标特征丢失。第二是VOC数据集的标注里,很多bus实例是良好光照、占据画面较大面积,验证集里恰好有较多远景车辆。
解决:先试imgsz=960重新训练或直接用imgsz=960做验证,看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在推理时虽然会自适应,但如果训练时用imgsz=640,推理时却用了默认imgsz=416,小模型的特征图缩放会对坐标解码造成偏差。
解决:推理时固定imgsz=640和训练时一致。另外也要检查有没有在转换脚本里对图片做了resize但没同步更新标注坐标,“自己resize图片、却又按原始XML坐标转txt”这个操作会让框整体错位。我的习惯是:只要数据集是VOC格式来的,就永远不要手动resize原图,训练和推理的缩放全部交给YOLO的letterbox自动处理。
6. 公交车检测的精度提升技巧:从imgsz到数据增强
6.1 先做一次大图训练,再看数据增强收益
公交车检测的分辨率敏感性比常规目标高很多。imgsz=640对于一张公交车占画面1/3的图足够,但公交站场景里远处驶来的公交车可能只有几十像素高,小目标需要更大的输入分辨率。我做过不少数据集,imgsz从640提到960,mAP50提升幅度经常能达到5个百分点以上。有显存条件的话,用imgsz=1280再试一轮,但注意这时batch要减半,训练时间也会拉长。推荐的做法是:先用imgsz=640跑通流水线,再用imgsz=960比较一次,稳定后选择验证集mAP更高的一组部署。
6.2 不要手动改损失函数权重,先动数据和增强
很多人看到漏检就想去调YOLOv8的损失函数,比如加大box_loss权重。我一般不建议先动这里,因为YOLOv8默认的TAL分配策略对单类数据已经足够稳定,乱调会导致框回归过拟合训练集。真正值得花时间的是数据增强和样本扩展。如果这个bus数据集是纯VOC trainval,可以再从COCO数据集里筛出所有bus类别的图片加入训练,但要注意COCO的标注风格和VOC不同,需要统一转成YOLO格式。新增样本时优先挑遮挡、夜间、远距离的图,这比简单增加图片数量更有用。
6.3 部署前的最后一道工序
训练完成后,建议用best.pt在几段完全没参与训练的视频上测试,尤其是包含公交站台、多辆车并排、车窗反光的片段。只看mAP不够,要人工判断漏检是否出现在车辆重叠的区域。如果重叠时两个框融合成一个,检查NMS阈值,默认iou=0.45可以降到0.4来保留更多重叠框,但小心产生重复检测。
做这个数据集训练我最大的教训是:转换格式时偷懒没做可视化验证,结果训练了十几个epoch才发现label坐标全偏了。从那以后,凡是VOC格式数据,我都先转一个子集画框确认再上完整训练。一次失误浪费的时间,比踏实验证多得多。希望帮到你。
本文还有配套的精品资源,点击获取