车辆检测数据集car-dataset实操指南:从格式检查到YOLO训练前的体检

发布时间:2026/10/27 16:13:58
车辆检测数据集car-dataset实操指南:从格式检查到YOLO训练前的体检

简介:一套用于YOLO车辆检测的标注数据集,面向目标检测学习者和算法工程师,解决车辆检测训练数据不足的问题。数据集中包含白天、夜间以及俯视场景的车辆图像,共两千余张图片,全部完成标注,类别统一为车辆(car)。每张图片均提供txt和xml两种标签格式,分别适用于不同训练框架,无需额外格式转换。整个压缩包共七千二百一十个文件,其中图片两千四百零三个,txt标签两千四百零四个,xml标签两千四百零三个,包体约八百零三点五八MB,文件与标签一一对应,结构清晰易用。该数据集省去了手动标注的繁琐工作,白天和夜间样本有助于增强模型在不同光照下的鲁棒性,俯视视角则可服务于停车场监控、无人机检测等场景。目前已有六千一百五十二人学习下载,是训练车辆检测模型时值得参考的现成数据集。

1. car-dataset 是什么:一个能直接喂给 YOLO 的车辆检测数据集

想做车辆检测,大部分人卡住的第一关不是模型选型,而是没有一张带着正确标注的图。car-dataset 这类车辆检测数据集,就是把道路场景图片按 YOLO 习惯整理成 images 和 labels 两个目录,每张图片对应一个同名的 txt 标签文件,里面写的是每辆车的类别编号和归一化坐标。YOLO 训练时直接读这个目录就能跑,不需要再做格式转换,这是它比零散爬虫图更有价值的地方。它适合两类人:一是在 YOLOv5/YOLOv8 上快速拉一条 baseline 做方案验证的开发者;二是刚入门车辆检测、想省掉几千张手工标注时间的学习者。但前提是得先搞清楚它里面装的是什么、边界在哪,否则训到一半才发现标签错位,返工成本比重新标注还高。

2. 选数据集先做对比:公开车辆数据集与 car-dataset 的边界差异

2.1 主流的车辆检测数据集长什么样

做车辆检测能选的数据集其实不少,但各自的标注格式和适用场景差别很大。我一般会在动手前把 KITTI、UA-DETRAC、BDD100K、COCO 这几类列出来对比一遍,再决定是自己采集还是直接复用社区整理好的 car-dataset 包。

数据集标注格式典型场景车辆目标特点上手成本
KITTIPascal VOC(XML)城市道路、高速公路大目标为主,遮挡少需要转 YOLO 格式
UA-DETRACXML(类 VOC)城市路口、交通监控小目标密集,遮挡严重需要按自己类别重映射
BDD100KJSON多城市道路,含夜间雨天场景覆盖最广格式复杂,抽取麻烦
COCOJSON日常生活照片car/truck/bus 只是其中几类类别 id 与 YOLO 不对齐
car-dataset 社区包YOLO txt(常见形态)取决于原作者采集来源通常只含车辆相关类拿来即用,但需要体检

这里要特别提醒一句:car-dataset 并没有一个统一的官方规范。社区里流传的车辆检测数据集,很多是从 KITTI 或 UA-DETRAC 抽取图片后转换打包的,也有人是完全自采自标。所以拿到手第一件事不是训练,是先确认它的来源和组成。

2.2 拿到手先判断:这份 car-dataset 是不是原生 YOLO 格式

YOLO 的标签格式极其简单:每一行五个数字,依次是类别编号(class id)、归一化中心点 x、归一化中心点 y、归一化宽、归一化高。比如一个 txt 里写着0 0.5143 0.5278 0.1832 0.2254,意思是第 0 类车辆,中心点在图片横向 51.43% 和纵向 52.78% 的位置,宽高分别占整图的 18.32% 和 22.54%。

很多人在这一步会纠结“coco80 怎么读”的问题。如果你之前用过 COCO 数据集,它的 80 个类别里 car 排在索引 2、truck 排在 7,这是 COCO 官方定义的顺序。但 YOLO 训练时不认类别名字,只认数字索引,所以一个只有 car、truck、bus 三个类的数据集的 class id 是从 0 开始自己排的。如果把 COCO 的索引直接搬过来用,car 的 id 是 2,而这个数据集里 0 才是 car,训练出来的模型自然是乱的。

正确的检查方式是先找这个数据集有没有带 data.yaml 或 names 文件。完整的 YOLO 数据目录里一定会有类似这样的内容:

train: dataset/images/train val: dataset/images/val nc: 3 names: ['car', 'truck', 'bus']

如果手头的 car-dataset 只给了图片和 txt,没有 names,那就要小心了:txt 里的 0 到底代表什么,只能靠抽图人工确认。我的做法是随机抽十张图,把标签画上去看框对应的是什么车,再反推 class id 的含义。

注意:class id 与 names 的对应关系是这个数据集里最容易出错、也最不容易被发现的坑,训练过程可能完全不报错,但推理结果全错。

2.3 按应用场景选数据集,别只看数量

“数据集越大越好”在车辆检测这里不成立。一个做高速卡口车牌抓拍的项目,如果喂给它一堆城市路口拥堵场景的数据,模型训练出来后误检率会高到没法上线。我的建议是先把自己的部署场景拆成三个维度:道路类型(高速/城市/乡村)、光照条件(白天/夜间/逆光)、拍摄视角(平视/俯视/斜视),再去匹配数据。

如果做的是城市路口交通监控,UA-DETRAC 的密集车辆和遮挡情况是最接近的;如果做的是自动驾驶前视感知,KITTI 和 BDD100K 的视角更合适;如果只是做一个 demo 验证 YOLO 的流程能不能走通,那直接从 car-dataset 社区包开始就好。在这个阶段不用纠结数据量是几千还是几万,先把场景对齐了,训练出来的东西才有参考价值。

3. 从零备好 car-dataset:图片清洗、去重与目录规范化

3.1 第一步先做图片完整性检查,别把损坏文件喂给训练器

从网上下载的车辆图片数据集,最常见的翻车点是里面混着损坏的 JPEG 文件。YOLO 训练时如果碰到无法解码的图片,有的框架直接崩,有的框架会跳过后默默少算一张,导致训练集和标签对不上。所以拿到数据后的第一个脚本一定是图片完整性检查。

import os from PIL import Image bad_list = [] for root, _, files in os.walk("raw_images"): for f in files: p = os.path.join(root, f) if not f.lower().endswith((".jpg", ".jpeg", ".png")): continue try: with Image.open(p) as im: im.verify() # 只校验文件结构,不加载像素,速度快 except Exception: bad_list.append(p) print("损坏图片数量:", len(bad_list)) for p in bad_list: print(p)

这段脚本的逻辑是遍历整个原始图片目录,对每张图片调用im.verify()去校验文件头和解码结构。这里有个关键参数选择:用的是verify()而不是load(),因为verify()只检查文件格式和完整性,不会把整张图读进内存,几千张图跑完也就是几十秒的事。如果后续要做更严格的检查(比如检测图像是否全黑、全灰),再改成load()并统计像素分布。

3.2 去重:相同帧和近似帧会让验证集指标虚高

车辆数据集的图片高度自相似,特别是从视频里抽帧得到的数据,同一辆车在相邻两帧里几乎一模一样。如果这些重复帧同时进了训练集和验证集,训练出来的模型在验证集上的 mAP 会高得离谱,一到真实场景就打回原形。这个去重步骤经常被人跳过,但恰恰是让指标失真最严重的因素之一。

我用的是感知哈希(dHash)做去重,它比 MD5 更实用:MD5 只能找出完全相同的文件,而车辆图片之间往往只是裁剪、调色上的小差异,感知哈希可以把这些“看起来差不多”的图也找出来。

from PIL import Image def dhash(img, size=8): gray = img.convert("L").resize((size + 1, size), Image.LANCZOS) bits = [] for row in range(size): for col in range(size): left = gray.getpixel((col, row)) right = gray.getpixel((col + 1, row)) bits.append(1 if left > right else 0) return bytes( int("".join(str(b) for b in bits[i:i+8]), 2) for i in range(0, len(bits), 8) ) def hamming(a, b): return sum(bin(x ^ y).count("1") for x, y in zip(a, b)) # 使用示例:遍历目录,计算每张图的 dhash, # 两两比较汉明距离,小于 5 判定为重复图像

dHash 的核心思路是把图片缩小成 9×8 的灰度图,比较相邻像素的亮度变化,得到一个 64 位的哈希值。两张图越相似,哈希值的汉明距离越小。size=8是精度参数,数值越大对细节敏感度越高,但也会放过一些轻微变形的重复帧;我一般在车辆数据集上把阈值定在 5 左右,这个值能识别出同一段视频里抽出的相近帧,又不会把不同车道的相似场景误判成重复。

3.3 目录结构规范化:images 与 labels 必须严格同名配对

YOLO 系列框架对数据目录的约定是统一的:图片放 images,标签放 labels,两个目录下再分 train 和 val 子目录。图片和标签是靠文件名关联的,images/train/000001.jpg必须对应labels/train/000001.txt,后缀不同没关系,但主文件名必须一模一样。

mkdir -p dataset/images/train dataset/images/val mkdir -p dataset/labels/train dataset/labels/val

目录建好后,把清洗和去重后的图片按比例复制进 train 和 val。这里我一般用脚本处理而不是手动拖拽,原因是在图片数量上千以后,手动操作很容易漏掉某张图的标签文件,而 YOLO 训练时如果一张图没有对应标签,默认会把它当背景图处理——这个“背景图”会直接影响 loss 的计算,但你看日志基本发现不了。

import os import random import shutil random.seed(42) img_list = sorted(os.listdir("images_clean")) random.shuffle(img_list) val_num = int(len(img_list) * 0.2) for i, name in enumerate(img_list): stem = os.path.splitext(name)[0] src_img = os.path.join("images_clean", name) src_txt = os.path.join("labels_clean", stem + ".txt") if not os.path.exists(src_txt): continue # 没有标签的图直接丢弃,不混入数据集 split = "val" if i < val_num else "train" shutil.copy(src_img, os.path.join("dataset/images", split, name)) shutil.copy(src_txt, os.path.join("dataset/labels", split, stem + ".txt")) print("处理完成,train/val 已拆分")

代码里的两个关键点:一是random.seed(42),固定随机种子保证每次拆分的集合一致,方便复现训练结果;二是遇到没有对应标签的图片直接跳过,而不是复制进数据集当背景。这一步和很多人的习惯不同——有人会把没标签的图当 negative sample 用,这在特定场景是有效策略,但对一个刚拿到的 car-dataset 来说,我建议先保持干净,只保留有标签的样本。

4. 把标注转成 YOLO 格式:VOC 转 txt 的脚本与三个关键参数

4.1 从手工标注开始:LabelImg 保存成 VOC XML

如果拿到的数据需要自己标注,最常见的工作流是用 LabelImg 画框。把图片目录打开后,选择 PascalVOC 格式保存,每张图会生成一个同名 XML 文件,里面记录了size/width、size/height以及每个object的bndbox坐标。这个格式和 KITTI 的标注规格相近,所以从 KITTI 抽取的车辆数据集也常常以 XML 形式散落在网上。

这个步骤本身没什么技术含量,但有一个操作细节值得注意:标注时框要贴合车辆边缘,尤其是车头和车尾这类轮廓分明的部位。如果框松了,每辆车都多出一圈背景,模型学习到的特征里就会混入路面信息;如果框紧了,车灯、车牌这些关键特征被切掉,检测精度同样上不去。一两张图看不出差别,几百张图累积起来就是几个点的 mAP 差距。

4.2 VOC 转 YOLO 的转换脚本:归一化、类别映射、坐标校验缺一不可

拿到 XML 标注后,接下来是最容易出错的一步:把xmin, ymin, xmax, ymax这种绝对像素坐标转换成 YOLO 需要的归一化中心点坐标。转换公式本身不复杂,但实际写脚本时有两个经常会踩的坑:类别 id 怎么从标签名字映射成数字,以及 XML 里的宽高和图片实际尺寸不一致时怎么办。

import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_path, out_txt, map_id): tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find("size/width").text) img_h = int(root.find("size/height").text) lines = [] for obj in root.iter("object"): name = obj.find("name").text if name not in map_id: continue # 不在目标类别里的目标直接忽略 cls_id = map_id[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) x_center = ((xmin + xmax) / 2) / img_w y_center = ((ymin + ymax) / 2) / img_h bbox_w = (xmax - xmin) / img_w bbox_h = (ymax - ymin) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {bbox_w:.6f} {bbox_h:.6f}") with open(out_txt, "w") as f: f.write("\n".join(lines)) # 使用示例:只保留 car/truck/bus 三个类别 label_map = {"car": 0, "truck": 1, "bus": 2} voc_to_yolo("000001.xml", "000001.txt", label_map)

这个脚本需要重点理解三个参数。第一是归一化的基准:img_w和img_h必须来自 XML 里记录的尺寸,而不是脚本运行时重新用 PIL 读取的尺寸。如果 XML 是从原始分辨率标注的,而图片后又被压缩过,两者不一致时转换出的坐标全部偏移,这类错误在可视化阶段才能发现。第二是坐标精度:.6f保留六位小数,在 1920×1080 的分辨率下足够精确到像素级,再多的位数没有意义。第三是类别过滤策略:map_id里不存在的类别直接跳过,这保证转换出来的 txt 里不会混入行人、骑车人这类无关目标。

4.3 训练集验证集划分:视频帧数据不要随机切分

第 3 章里的随机划分脚本适用于零散图片,但如果你的 car-dataset 是从视频抽帧得到的,随机划分会出现严重问题。同一个视频片段里的相邻帧背景几乎一样,模型在训练集里见过这一帧,验证集里出现它的下一帧时,相当于直接把答案泄露给了模型。验证集的 mAP 看着高,部署到新场景立刻崩。

正确的做法是按视频片段分组进行划分。假设文件名格式是highway_01_0001.jpg,其中highway_01是视频片段编号,就用这个前缀作为分组单位:

import os import random import shutil from collections import defaultdict random.seed(42) video_frames = defaultdict(list) for name in os.listdir("images_clean"): prefix = name.rsplit("_", 1)[0] # 例如 highway_01 video_frames[prefix].append(name) video_ids = list(video_frames.keys()) random.shuffle(video_ids) val_num = int(len(video_ids) * 0.2) for i, vid in enumerate(video_ids): split = "val" if i < val_num else "train" for name in video_frames[vid]: stem = os.path.splitext(name)[0] shutil.copy( os.path.join("images_clean", name), os.path.join("dataset/images", split, name), ) shutil.copy( os.path.join("labels_clean", stem + ".txt"), os.path.join("dataset/labels", split, stem + ".txt"), )

按片段分组后,同一个视频的帧只会出现在 train 或 val 其中一个集合里。这个处理会损失一部分训练样本量,但换来的是验证指标的可靠性。我见过太多项目为了省事随机切分,最后花了两周调参才发现是验证集泄漏导致的虚假高分。

5. 训练前的数据集体检:五类常见标注坑与排查手段

5.1 坑一:class id 与 names 顺序对不上,训练不报错但推理全错

这是 car-dataset 最容易踩的坑,也是最难发现的一个。现象是训练过程完全正常,loss 正常下降,验证集的 mAP 看起来也不错,但实际推理时,一辆小轿车被识别成卡车,或者被识别成公交车。

原因是数据集的 txt 里 class id=0 代表 car,而训练配置的 data.yaml 里names: ['truck', 'car', 'bus'],框架不关心名字,只按索引对齐。检查方法很简单,在训练前统计一下标签里出现了哪些类别 id:

awk '{print $1}' dataset/labels/train/*.txt | sort -u

这个命令会打印出训练集标签里所有的 class id。然后随机挑几张图片,用可视化脚本画框确认 id=0 对应的到底是什么车型。我一般会把这个检查结果直接贴在 data.yaml 旁边做注释,防止过两周自己回来都忘了当时是怎么对齐的。

5.2 坑二:坐标越界或宽高为负,导致 loss 突然变 NaN

训练到一半 loss 飞掉,这是最让人头疼的情况。现象是前几百轮 loss 正常下降,突然某一步变成 NaN,之后再也回不来。很多人以为是学习率太大,把学习率调低重新跑,结果还是在同一个位置崩掉。

真正原因往往是标签数据里混进了非法坐标:比如转换脚本没处理xmax < xmin的情况,或者某些框超出了图片边界导致归一化后的坐标大于 1。这类数据在数据集较大的时候只有零星几条,但 PyTorch 在计算 loss 时只要碰到一个 NaN 就会把梯度全部污染。写一个全量扫描脚本很有必要:

import os def validate_labels(label_dir): bad_records = [] for txt_name in sorted(os.listdir(label_dir)): txt_path = os.path.join(label_dir, txt_name) with open(txt_path) as f: for line_no, line in enumerate(f, 1): parts = line.strip().split() if len(parts) != 5: bad_records.append((txt_name, line_no, "列数不是5")) continue try: cls, xc, yc, w, h = map(float, parts) except ValueError: bad_records.append((txt_name, line_no, "存在非数字")) continue if xc <= 0 or yc <= 0 or w <= 0 or h <= 0: bad_records.append((txt_name, line_no, "坐标或宽高非正数")) if xc > 1 or yc > 1 or w > 1 or h > 1: bad_records.append((txt_name, line_no, "归一化值超过1")) return bad_records bad = validate_labels("dataset/labels/train") for rec in bad[:20]: print(rec) print("非法记录总数:", len(bad))

这段脚本逐行解析每个 txt,检查三个条件:行内是不是正好五个数字、坐标和宽高是不是正数、归一化后的值是不是在合理范围内。发现非法记录后,直接打开对应图片确认是标注错了还是转换脚本的 bug,千万别图省事把整份标签都删掉重来。

5.3 坑三:漏标和误标让验证指标彻底失真

手动标注的数据集里漏标几乎不可避免,但漏标比例超过一定阈值后,mAP 就会变成完全不可信的指标。现象是验证集 mAP 很高,但在实际场景里测试时全是误报。

这里的机制很多人没想清楚:模型把一辆没有标注的车检出来,在验证集里会被判成 false positive,拉低 precision。但模型在训练时又确实学到了“这就是车”的特征。所以漏标严重的数据集上,模型会陷入一种矛盾——它对车的召回能力很强,但验证集不承认这些检测结果。这时 mAP 低不代表模型差,mAP 高也不代表模型好,整个指标沦为摆设。

解决漏标没有捷径,只能靠人工抽查加可视化。随机抽一百张验证集图片,把标签画上去,看框是不是覆盖了所有明显的车辆目标。如果发现某张图里明显有三辆车但只标了一辆,说明这批标注质量有问题,要么补标,要么把这张图踢出验证集。这个工作很枯燥,但这是数据集上线前的必要体检。

5.4 坑四:EXIF 旋转信息导致标注框偏离实际位置

手机或行车记录仪拍的照片经常带着 EXIF 旋转信息。图片存储时像素矩阵没转,但查看器会根据 EXIF 自动转过来显示。PIL 默认读图时不会应用 EXIF 旋转,直接用标注软件打开时标注软件转了,而训练时框架读图没转,两边的坐标系就差了 90 度或 180 度。

现象是训练 loss 下不去,可视化时发现框的位置和车辆完全对不上。处理方式是在标注和转换之前,先把所有图片统一转正:

from PIL import Image, ImageOps import os for root, _, files in os.walk("raw_images"): for f in files: if not f.lower().endswith(".jpg"): continue p = os.path.join(root, f) img = Image.open(p) img = ImageOps.exif_transpose(img) # 把旋转信息写入像素 img.save(p, quality=95)

ImageOps.exif_transpose会把 EXIF 里的旋转角度真正应用到像素数据上,再保存时旋转信息就固化到像素里了。这个处理必须在任何转换脚本之前做,否则后面所有坐标计算都是基于错误的图像方向。

5.5 坑五:样本不平衡,白天多夜间少导致夜间检测崩溃

这是一个场景分布问题而不是标注问题。车辆数据集里白天晴天的高质量图片最容易采集,夜间、雨天、逆光场景稀缺。现象是白天测试效果很好,稍微到晚上就完全失效。

如果目标是做全天候部署的车辆检测,光靠数据增强里的亮度抖动是不够的。亮度增强只能模拟暗环境,但夜间车辆检测的特征和白天完全不同——车灯是主要视觉锚点,车身的颜色和轮廓都看不清。我建议先统计一下数据集中图片的时段分布,如果夜间占比低于 20%,考虑从 BDD100K 这类全时段数据集里专门抽取夜间样本补进来。这一步会直接决定模型能否在真实场景落地。

6. 用可视化和损失曲线验证数据集能不能直接开训

6.1 最直接的验证:把标签画到图上,人工扫一遍

数据集的验证工作里,可视化永远比任何统计指标都直观。把 labels 里的 txt 转换成 OpenCV 能画的矩形框,批量生成拼图检查。这里我写了一个小脚本,会把验证集里每个 txt 对应的图片和框输出成一张张检查图:

import cv2 import os def draw_labels(img_path, txt_path, names, out_path): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls, xc, yc, bw, bh = map(float, parts) x1 = int((xc - bw / 2) * w) y1 = int((yc - bh / 2) * h) x2 = int((xc + bw / 2) * w) y2 = int((yc + bh / 2) * h) # y1/y2 越界时截断到图像边界,避免绘制报错 x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w, x2), min(h, y2) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(img, names[int(cls)], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) cv2.imwrite(out_path, img) names = ["car", "truck", "bus"] draw_labels( "dataset/images/val/000001.jpg", "dataset/labels/val/000001.txt", names, "check_000001.jpg", )

上面代码里的画框坐标是从归一化坐标反算出来的,和训练时 YOLO 内部的计算方式一致。如果这个脚本画出来的框和车辆边缘明显不贴合,说明转换脚本或 EXIF 处理存在问题。检查时重点关注三件事:框是不是贴住车辆边缘、有没有明显漏掉大目标、class id 对应的类别名是不是对的。

6.2 用损失曲线反查标注质量

可视化确认过后,我会先跑一个短训练,然后看训练日志里的损失曲线。这里有几个经验性的判断:train loss 下降但 val loss 降不动,排除模型容量问题后,多半是标注噪声太大——框的位置不准确,模型在训练时反复学习错误的回归目标;val loss 和 train loss 都降得很好,但 val mAP 的召回率明显偏低,则要怀疑验证集漏标。

YOLO 的损失函数由分类损失、置信度损失和框回归损失三部分组成,如果框回归损失在训练后期持续震荡不收敛,回到数据集里检查框的宽高分布。车辆目标的框宽高比通常在 0.5 到 2.0 之间,如果出现大量异常细长的框,大概率是转换时坐标算错了。

6.3 一个一直沿用的习惯:先跑 200 张的快速训练再上全量

我现在的流程是拿到任何一个 car-dataset,先不急着全量训练。从训练集里随机抽 200 张图,分辨率压到 640,batch size 设 8,跑 20 个 epoch。这个量级的训练只要十几分钟,但能暴露绝大多数数据集问题:loss 是否稳定下降、有没有 NaN、可视化输出是否正常。

全量训练的时间成本高,如果在跑了十个小时之后才发现标签错了,整个项目节奏都被打乱。我之前就跳过可视化直接全量训练过一个卡口车辆检测模型,三天后才发现转换脚本把宽和高写反了——损失曲线看起来一切正常,因为模型学会了把错误的数据也“拟合”得很好。那个模型最后只能全部推倒重来,这种后悔药是没法吃的。先用小样本验证流程,再投入全量训练,看起来多了一步,实际是效率最高的路径。希望这个流程能帮你在车辆检测数据集上少走一些弯路,至少从拿到数据的第一天起,就先做一次体检再开训。

本文还有配套的精品资源,点击获取