简介:这是面向视觉目标检测学习者与算法工程师的车辆检测标注数据集,包含 bus、car、suv、taxi、truck 五类常见车辆。图片按类别前缀统一命名,并同时提供 txt 与 xml 两套标注文件,适合用于 YOLO 系列、SSD 或 Faster R-CNN 等模型的训练与效果验证,也可作为数据格式转换练习的素材。资源共 2000 个文件,其中原始图片 699 张 jpg,对应标注 699 个 xml 与 700 个 txt,压缩包整体约 466.3MB。xml 为 PASCAL VOC 标注格式,txt 为 YOLO 格式归一化坐标标注,两者分别存放在独立文件夹中,便于按需选用。目前已有 961 人学习下载,常用于车辆检测的教学实验与小型项目验证。下载后可直接获得成体系的车辆检测数据,既可用于训练自定义检测模型,也可对比两种主流标注格式的差异,省去自行采集与转换标注的时间成本。
1. 为什么说“VOC 各种类型车辆检测数据集”不是下载即用的成品
深夜一点,val 集里两百辆卡车被模型认成轿车,mAP 掉到 0.33。打开 xml 一看,训练集 bus 只有 117 张,car 却有一万八千张——这就是接触“VOC 各种类型车辆检测数据集”的人最常见的开局。它指的不是某个打包好的压缩包,而是一套以 Pascal VOC 的目录结构和 xml 标注为核心、覆盖多类车辆对象的检测数据组织方案,通常包含 car、bus、truck、motorcycle、bicycle 等类别,服务于 YOLO、Faster R-CNN、SSD 这类检测模型的训练。适合谁?手上有图像或视频素材,想做成能被复现、能迭代、能换模型不换标注的数据资产的人。先记住一个反直觉结论:网上能下到的“VOC 车辆数据集”,大概率只有 JPEGImages 和 Annotations,没有划分好的 train/val,类别也可能只有 car 一类,剩下全靠自己补。
2. VOC 数据集的目录结构与车辆类别划分:从 Annotations 到 labelmap 的落地约定
2.1 一个规范的 VOC 目录:JPEGImages、Annotations 与 ImageSets 的分工
VOC 格式的核心不是单个 xml 文件,而是整套目录约定。做车辆检测时,我一般会先按下面的结构把数据落盘,后面对接任何训练框架都靠这套结构兜底:
VOCdevkit/ └── VOC2007/ # 年份只是协议标识,不影响解析 ├── JPEGImages/ # 原始图像,统一 .jpg,文件名与 xml 一一对应 ├── Annotations/ # 每张图像一个 .xml,Pascal VOC 标注 ├── ImageSets/ │ └── Main/ # train.txt / val.txt / trainval.txt / test.txt │ # 每行一个不带后缀的文件名,例如 000001 └── labels/ # 非 VOC 标准目录,很多团队自己加,放 YOLO txtJPEGImages 和 Annotations 必须严格同名,ImageSets/Main 里的 txt 只写文件名不带扩展名,这是 VOC 协议给训练脚本的接口。一个典型血泪经验是:有人把标注文件存成 .XML 大写后缀,Linux 下 ET.parse 找不到文件,排查半天才发现是大小写问题。图像统一转成 .jpg 而不是 png,不只是省空间,还因为很多检测框架按扩展名判断解码路径,混着用容易在 dataloader 报错。VOC 没有强制图像尺寸,但同一批训练数据最好控制在接近的分辨率范围,否则 batch 里一边是 1920×1080,一边是 640×480,resize 后目标尺度差异会直接反映在 AP 上。
2.2 车辆类别怎么从 VOC 的 20 类缩到我们自己的 labelmap
Pascal VOC 原生 20 类里,跟车辆强相关的只有 bicycle、bus、car、motorbike,aeroplane 偶尔算作空中目标,truck、van、pickup 这些在 VOC 里没有独立类别,全部并进 car。做多类型车辆检测时,这点最需要提前约定:
| 自定义类别 | 来源 | 落地建议 |
|---|---|---|
| car | VOC 的 car | 注意 VOC 把 SUV、三厢轿车全算 car,训练前按业务决定是否拆分 |
| bus | VOC 的 bus | 直接可用 |
| truck | VOC 无对应,需从 car 重标或从其他数据集引入 | 卡车和客车容易混淆,标注规范里要写清厢式货车算 truck |
| motorcycle | VOC 的 motorbike | 类别名保留 motorcycle,映射时做一次改名 |
| bicycle | VOC 的 bicycle | 直接可用 |
| van | VOC 无对应,需扩展标注 | 封闭厢式车在街景中出现率高,建议单列 |
确定了 labelmap 后,第一步是写一个干净的映射表,而不是直接改 xml。我习惯把映射独立成文件,比如生成一个 label_map.json,转换脚本只读它。原因很简单:后续加类别、合并类别时只改一处,不用重新遍历标注。
2.3 为什么不直接下“现成交付的 VOC 车辆包”
很多第三方发布的 VOC 车辆数据集,实际是用脚本从 VOC trainval 里筛出 car、bus 等类别打包的。直接用有三个风险:一是分布不对齐,VOC 的 car 大量是街景角落里几十像素的小目标,而你的业务可能是高速卡口大目标;二是类别覆盖不够,van、truck 基本没有;三是来源不可追溯,xml 可能是爬虫抓的图重新标注的,商用授权无法证明。这也是 BDD100K、UA-DETRAC、KITTI 这些自动驾驶数据集更常被选作底料的原因——不是它们标注更准,而是它们的类别定义和场景分布更接近真实车辆检测任务。需要注意的是,这些数据集的标注格式都不是 VOC,BDD100K 是 json 风格,KITTI 是自定义 txt,UA-DETRAC 带轨迹属性,全部要走到第 4 章的转换流程。另外 CCPD 这类数据集框的是车牌不是整车,训练整车检测时别混用,它的 bbox 范围完全不是你要的目标。
3. 从零搭建车辆数据集:数据来源选型、抽帧初筛与 VOC 标注生成
3.1 公开车道数据集怎么选:先看类别分布,再看标注质量
搭建车辆数据集最省力的路径,是在公开自动驾驶数据集基础上做类别裁剪和清洗,而不是从零找图标注。我接触过的几个常用来源如下:
| 数据集 | 规模量级 | 车辆类别覆盖 | 标注格式 | 适合方向 |
|---|---|---|---|---|
| BDD100K | 10 万帧 | car、truck、bus、bike、rider 等 | BDD json 风格 | 夜间、雨天、城区复杂场景占比高 |
| UA-DETRAC | 约 14 万帧 | car、bus、van 等 | 带轨迹的框 | 城市路口、遮挡严重场景 |
| KITTI | 约 1.5 万张 | car、truck、van、tram | KITTI label | 城郊、高速,目标视距变化大 |
| 自行采集 | 看资源 | 完全可控 | 任意 | 业务强相关的特殊车型 |
选型逻辑不是哪个数据集大就选哪个。夜间场景的检测任务优先看 BDD100K,因为它的夜间帧占比明显高于 KITTI;路口遮挡多就选 UA-DETRAC;只跑结构化道路,KITTI 就够用。我的建议是:主数据源选 1 个,辅助数据源选 1 个,用辅助源补充主源缺失的类别和天气组合。注意这些数据集图像分辨率差异很大,BDD100K 是 1280×720,KITTI 是 1242×375,训练前要么统一 resize,要么在转换时保留原始尺寸让 dataloader 自行处理。
3.2 视频抽帧:别让连续帧淹掉你的硬盘
如果自己采集视频,第一步是抽帧。常见错误是把视频所有帧都存下来,一段 10 分钟 30fps 的视频就是 18000 张,九成是重复背景。我一般按间隔抽帧,同时把视频文件名写进帧名,方便后面做时序划分:
import cv2 import os def sample_frames(video_path, out_dir, sample_interval=30): cap = cv2.VideoCapture(video_path) os.makedirs(out_dir, exist_ok=True) idx = 0 base = os.path.splitext(os.path.basename(video_path))[0] while True: ret, frame = cap.read() if not ret: break if idx % sample_interval == 0: out_file = os.path.join(out_dir, f"{base}_{idx:06d}.jpg") cv2.imwrite(out_file, frame) idx += 1 cap.release() print(f"processed {idx} frames, saved to {out_dir}") # 参数说明: # sample_interval=30 表示每 30 帧取 1 帧,30fps 视频下约每秒取 1 张 # 帧名带视频名和帧号,是为了后续按时间段切 train/val,防止信息泄漏sample_interval 取多少要看目标运动速度。高速公路场景 10 帧取 1 帧可能漏掉快速变道的瞬间,城市路口 30 帧取 1 帧足够。保守做法是先抽一版,人工扫一遍,如果同一个车在相邻帧里位置几乎没变,就加大间隔。抽完帧之后建议跑一遍清晰度筛选,把模糊帧删掉。OpenCV 的 Laplacian 方差是个便宜的指标,方差低于阈值的帧直接丢,不要留给标注员浪费时间。
3.3 使用 LabelImg 标注并生成标准 VOC xml
标注阶段我仍推荐 LabelImg,它原生支持 PascalVOC 和 YOLO 两种导出格式。选 PascalVOC 导出,因为 xml 带图像尺寸信息,后续转 YOLO、COCO 都方便:
<annotation> <folder>VOC2007</folder> <filename>000001.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>car</name> <bndbox> <xmin>100</xmin> <ymin>150</ymin> <xmax>350</xmax> <ymax>400</ymax> </bndbox> </object> </annotation>标注规范里必须写清楚 bndbox 贴多紧。我的约定是:四边贴着车体外轮廓,含后视镜但不含车牌突出部分;被遮挡超过一半的目标不标;路灯下阴影不算车体。LabelImg 导出后,xml 散落在各个目录,需要归拢到标准 VOC 结构。一个很简单的脚本就能完成:
import shutil import os from pathlib import Path def collect_to_voc(src_dirs, voc_root): jpg_dir = Path(voc_root) / "JPEGImages" xml_dir = Path(voc_root) / "Annotations" jpg_dir.mkdir(parents=True, exist_ok=True) xml_dir.mkdir(parents=True, exist_ok=True) for src in src_dirs: for img in Path(src).glob("*.jpg"): xml = img.with_suffix(".xml") if xml.exists(): shutil.copy(img, jpg_dir / img.name) shutil.copy(xml, xml_dir / xml.name) else: print(f"missing xml: {img}")这段脚本做两件事:把 jpg 和 xml 配对复制进标准目录;发现缺 xml 的图直接打日志。missing xml 列表要人工确认,很多时候是标注漏了而不是文件损坏。归拢之后,强烈建议跑一次 xml 完整性校验,解析每个文件、检查 bndbox 是否在图像范围内,这能挡掉后面训练时最常见的崩溃来源。
4. VOC 转 YOLO 与 COCO:归一化参数、类别映射与防泄漏划分
4.1 为什么训练前必须做格式转换
VOC 标注能直接喂给 torchvision 自带的 Faster R-CNN,但 YOLOv5/YOLOv8、MMDetection 都不吃 xml。YOLO 系列要求每个标注文件是 txt,每行格式为“类别序号 中心点x 中心点y 宽 高”,且 x、y、w、h 都是相对图像宽高的归一化数值;MMDetection 的常用配置则要求 COCO json 格式。范围再广一点,如果你用 MMRotate 做旋转框检测,那需要的是 DOTA 格式,VOC 的 axis-aligned 框转过去后基本要重新标。所以格式转换不是可选项,而是从数据到训练闭环的第一道工序。转换脚本本身不难,难在类别映射的一致性:VOC 里 class 是字符串,YOLO 里是整数,COCO 里是 id+name 的结构,三套体系一旦没对齐,模型训练得越久越难查错。这也是我强调 labelmap 单文件管理的原因。
4.2 VOC 转 YOLO:一个脚本吃透归一化和类别映射
YOLO 训练目录通常是 images/ 和 labels/ 平级,每张 jpg 对应一个同名的 txt。下面是核心转换逻辑:
import xml.etree.ElementTree as ET from pathlib import Path def voc_to_yolo(xml_path, out_dir, class_map): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) lines = [] for obj in root.iter("object"): name = obj.find("name").text if name not in class_map: 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) # 计算中心点坐标和宽高,全部除以图像宽高做归一化 xc = (xmin + xmax) / 2 / w yc = (ymin + ymax) / 2 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{class_map[name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") out_path = Path(out_dir) / (Path(xml_path).stem + ".txt") out_path.write_text("\n".join(lines)) return len(lines)class_map 是从类别名到整数编号的字典,比如{"car": 0, "bus": 1, "truck": 2, "motorcycle": 3, "bicycle": 4, "van": 5}。这里有个细节:xmin、ymin 是 int 类型,但读出来一定要转 float 再运算,否则除以宽高后直接舍零,小目标会全部变成面积为 0 的框。out_path只写了 txt,没写 jpg,因为 YOLO 训练时是从图片目录反向找同名 txt,不是从 txt 找图片。批量处理时我习惯每个 xml 做完后把结果追加到一个 all_labels.txt 统计表,方便后面数各类别数量。
4.3 VOC 转 COCO:json 结构与 seg 字段的补零技巧
如果走 MMDetection 或 Detectron2,需要 COCO json。VOC 转 COCO 的关键是 categories、images、annotations 三项的组织方式。可以直接把 COCO 的 segmentation 字段写成矩形轮廓[xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax],COCO API 能正常读,只是没有精细分割而已。核心代码如下:
def voc_to_coco(xml_dir, class_map, output_json): coco = {"images": [], "annotations": [], "categories": []} for name, cid in class_map.items(): coco["categories"].append({"id": cid, "name": name}) ann_id = 1 for idx, xml_path in enumerate(Path(xml_dir).glob("*.xml")): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") w, h = int(size.find("width").text), int(size.find("height").text) coco["images"].append({ "id": idx, "file_name": root.find("filename").text, "width": w, "height": h }) for obj in root.iter("object"): name = obj.find("name").text if name not in class_map: continue box = obj.find("bndbox") xmin = int(box.find("xmin").text) ymin = int(box.find("ymin").text) xmax = int(box.find("xmax").text) ymax = int(box.find("ymax").text) coco["annotations"].append({ "id": ann_id, "image_id": idx, "category_id": class_map[name], "bbox": [xmin, ymin, xmax - xmin, ymax - ymin], "area": (xmax - xmin) * (ymax - ymin), "iscrowd": 0, "segmentation": [[xmin, ymin, xmax, ymin, xmax, ymax, xmin, ymax]] }) ann_id += 1 with open(output_json, "w") as f: json.dump(coco, f)bbox 用的是xmin, ymin, width, height,不是 xmin、ymin、xmax、ymax,这个顺序写错最常见的表现是训练时 loss 不降、mAP 始终在 0.1 以下。categories 的 id 要严格按 class_map 来,而不是直接沿用 COCO 原生的 80 类 id——很多现成脚本会默认 car 是 2,但你的模型只检测车辆时类别下标是从 0 重排的。
4.4 划分 train/val/test:防信息泄漏的按片段拆分
划分是最容易把数据做脏的一步。常见操作是random.shuffle所有文件名后按比例切,这对独立图片数据集问题不大,但对视频抽帧数据是灾难:同一辆车出现在连续帧里,可能一帧在 train、一帧在 val,mAP 虚高到离谱,部署时直接崩。正确做法是按视频片段划分:
import random from collections import defaultdict def split_by_video(file_list, train_ratio=0.7, val_ratio=0.2, seed=42): random.seed(seed) video_groups = defaultdict(list) # 文件名格式为 {video_name}_{frame_idx}.jpg for f in file_list: video_name = f.split("_")[0] video_groups[video_name].append(f) videos = list(video_groups.keys()) random.shuffle(videos) n_train = int(len(videos) * train_ratio) n_val = int(len(videos) * val_ratio) train_files = [] val_files = [] test_files = [] for i, video in enumerate(videos): if i < n_train: train_files.extend(video_groups[video]) elif i < n_train + n_val: val_files.extend(video_groups[video]) else: test_files.extend(video_groups[video]) return train_files, val_files, test_filesseed 固定下来,保证每次划分结果一致。比例上我一般用 7:2:1,验证集至少覆盖每个类别 20 张以上;类别本身就少的 bus 如果验证集里只有 3 张,评估结果方差会大到没有任何参考价值。划分完成后把 list 写入 txt 文件,同时打印各类别在 train/val 中的数量分布,不均衡的就在这里发现,别等训练完才追。
5. 车辆检测数据集的 5 个排查避坑点:xml 解析、类别失衡与验证集泄漏
5.1 ET.parse 报错的隐藏原因:BOM 头和相对路径
现象:批量转换时ET.ParseError发生在某个 xml,但用浏览器打开一切正常。 原因:一是 xml 被人用带 BOM 的编码保存过,解析器不认识开头的不可见字符;二是 xml 里 filename 写的是「./images/000001.jpg」这种相对路径,转换脚本直接拿来拼接目录就找不到文件。 解决:转换前先做一次全线校验。用xmllint --noout Annotations/*.xml快速暴露解析问题;对 filename 字段统一Path(x).name只取文件名。遇到 BOM 问题,在代码里用utf-8-sig读文件再转存一次即可。这个坑在你自己写脚本合并多批标注时极容易出现,LabelImg 导出的原始文件没问题,问题都出在后处理阶段。
5.2 类别失衡:bus 只有 117 张,car 有一万八千张,模型直接摆烂
现象:训练后 val 集上 car 的 AP 有 0.85,bus 只有 0.12,整体 mAP 被平均得很难看。 原因:数据分布天然倾斜,街景里轿车数量碾压客车。模型学不到 bus 的判别特征,不是它笨,是它见的 bus 样本太少。 解决:先统计每个类别的数量做公示,如果最少的类别和最多的差一个数量级,就要处理而不是硬训。简单有效的手段是类别加权采样:dataloader 里给样本分配权重,bus 的采样概率调到 car 的 5 到 10 倍。注意这不是让模型重复看同一张图,而是降低 car 在每一轮 iteration 中的占比。对 bus 单独做增强也有用:hsv 扰动、上下翻转、随机裁剪后粘贴到街景背景里。粘贴时要处理透视,直接矩形贴上去会让模型学到边框特征。
5.3 转换时没过滤 difficult 和截断目标
现象:训练时 loss 下降正常,但验证集 AP 在 0.4 附近上不去。 原因:VOC xml 里 object 下有difficult、truncated、occluded字段,很多转换脚本直接忽略。结果是把被树挡了 90% 的车、训练集里只有半个车身的目标全当正样本,模型被噪声标签反复拉扯。 解决:转换前过滤。我一般把difficult等于 1 的目标直接跳过不输出;truncated大于 0.5 的如果有其他完整样本兜底就删,如果已经是稀缺类别就单独建一个 hard_examples.txt 备用。只对occluded做记录不做过滤,因为遮挡目标在真实场景中很常见,全删了会导致模型对遮挡不鲁棒。
5.4 resize 图像后 xml 坐标没同步缩放
现象:训练开启imgsz=640后一切正常,但自己写预处理脚本时,图像缩到 640×480,坐标还是原图 1920×1080,框全部错位。 原因:xml 里的坐标是绝对像素值,和具体图像一一绑定。任何对图像尺寸的改动都要同步更新这两个坐标。 解决:统一在转换脚本里把图像和标注同时处理,不要先 resize 图片、后改 xml。我自己踩过这个坑后,把规则定为「resize 必须发生在格式转换之前」,一旦进入 YOLO 的归一化坐标阶段,image 的原始宽高就不该再改。若必须改,用new_x = x * new_w / old_w的公式重算四个值,别手动估。
5.5 验证集泄漏带来的假高分
现象:训练记录显示 mAP 0.85,模型一出实验室就翻车,现场视频里车都漏检。 原因:train 和 val 里混入了同一视频片段的相似帧。车辆外观、背景、光照几乎一致,模型实际上是在做记忆而不是做泛化。更隐蔽的泄漏是同一地点不同时间段的图像,比如停车场固定车位上的同一辆车反复出现。 解决:划分时严格按视频片段、时间戳、地理位置元数据做分组。正则表达式解析文件名把视频 ID 提取出来分组划分;如果是网络爬的独立图片,那就控制来源域名,同一域名的图像默认放同一集合。划分完成后做一个检查:随机抽 20 对 train/val 图像,看是否有完全相同的车出现在两张图里。另外,训练完成后对比 train mAP 和 val mAP,如果差值超过 15 个点,优先怀疑泄漏而不是模型过拟合。
6. 用转换后的 VOC 车辆数据集跑通 YOLOv8:data.yaml、训练命令与按类别看 AP
转换完成后,最后一个闭环是把数据喂进 YOLOv8 跑一遍训练并验证效果。项目根目录下面应该同时存在 images/ 和 labels/,data.yaml 按这个方式写:
path: /path/to/your_dataset train: images/train val: images/val nc: 6 names: ['car', 'bus', 'truck', 'motorcycle', 'bicycle', 'van']YOLOv8 会自动在同等路径下寻找 labels/train 下的 txt 文件。nc 必须和 names 长度一致,这两个值由 labelmap 生成,不要手写数字。训练用如下命令:
yolo detect train \ data=data.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=640 \ batch=16一个几百到几千张规模的数据集,s 模型在单张消费级显卡上两三个小时能跑完。训练结束后看runs/detect/train/下的 confusion_matrix.png 和 results.png,先确认 loss 曲线没有发散,再看混淆矩阵里 truck 和 bus 是否互混。验证阶段直接跑:
yolo detect val \ model=runs/detect/train/weights/best.pt \ data=data.yaml终端输出的 Per Class 表会列出每个类别的 AP50 和 AP50-95。我现在的习惯是:数据集交付前先跑一版「完全默认参数」的 baseline,不看调参效果,只看数据和标注质量。如果 car 的 AP50 在 0.9 以上但 bus 只有 0.3,先回去补 bus 样本,不要动模型结构;如果所有类别都低,检查第 5 章里的遮挡过滤和划分泄漏。数据质量过关之前调任何超参都是自我安慰。希望帮到你。
本文还有配套的精品资源,点击获取