简介:面向交通道路目标检测任务的多类别标注数据集,覆盖车辆、行人、自行车与摩托车等常见交通参与者,适合计算机视觉初学者入门实践,也适合自动驾驶、智慧交通等方向的开发者在真实道路场景下进行模型训练与算法验证。压缩包约129.73MB,内含2000个XML标注文件,均采用VOC格式记录目标边界框与类别标签,方便按需转换为YOLO、COCO等主流框架所需格式,可直接作为训练集或验证集的标注来源。该资源由pbymw8iwm整理,已有590人浏览/学习。对缺乏现成标注数据的项目而言,这批文件能显著减少人工标注与格式整理的时间成本;配合对应原始图片即可完成多类别检测数据集的构建,支持车辆、行人、自行车和摩托车等目标的识别实验,帮助开发者在真实道路场景中快速迭代检测模型,提升研发效率。
1. 一个VOC格式标注包,距离可训练的识别数据集还差几步?
解压你手上这个zip,看到Annotations、JPEGImages、ImageSets三个目录,很多人会下意识觉得“数据集有了,训练只是时间问题”。实际上VOC格式只是把标注从图像里抽出来存成XML,它没有约定类别ID,没有约定训练集和验证集的划分方式,甚至不保证每个XML里的size字段和JPG真实宽高一致。交通场景里最典型的坑,是把“人”和“摩托车”混在一起标注,或者把骑在自行车上的人单独标成person,导致模型在推理时反复横跳。2998张原始图片在当下目标检测数据集里不算大,但足够对YOLOv8n、YOLOv5s这类模型做迁移学习微调;前提是你先把VOC解析、类别映射、数据体检这三件事做扎实。这里就从解析这套数据开始,讲清VOC格式怎么转YOLO、怎么查标注脏数据,以及怎么用真实道路监控视频验证最终识别效果。
2. VOC格式不只是XML:2998张图片如何通过目录和标签对上号
2.1 目录结构、XML标签与图片尺寸的对应关系
拿到zip解压后,先不要急着写训练脚本,确认目录结构是否完整。VOC2007以后的标准结构要求有三个目录:JPEGImages存放所有原始图片,命名如000001.jpg;Annotations存放同名XML,每个XML对应一张图;ImageSets/Main存放train.txt、val.txt或trainval.txt,每行一个不带后缀的文件名。先手动验证图片和XML数量是否一致,避免后续训练时找不到对应标签:
# 检查图片数与XML数(常见不一致原因:漏标或混入缩略图) find JPEGImages -type f -name '*.jpg' | wc -l find Annotations -type f -name '*.xml' | wc -l逻辑说明:wc -l统计的是文件个数,不是真实样本数。后续划分训练集时,以JPEGImages和Annotations两个目录里都存在的文件名为准,不要直接信任ImageSets里的列表,因为某些数据集打包时会把没标注的图片也写进train.txt。
VOC的XML不只记录框坐标,还同时保存了图片物理尺寸和目标的场景属性。看一个典型的object片段:
<annotation> <folder>JPEGImages</folder> <filename>road_1023.jpg</filename> <size> <width>1920</width> <height>1080</height> <depth>3</depth> </size> <object> <name>car</name> <pose>Unspecified</pose> <truncated>0</truncated> <difficult>0</difficult> <bndbox> <xmin>412</xmin> <ymin>320</ymin> <xmax>731</xmax> <ymax>540</ymax> </bndbox> </object> </annotation>size里的width和height决定后续归一化坐标的分母;truncated标记目标是否被图像边界切掉;difficult标记模糊或极小的困难样本。不同训练框架对这两个字段的处理方式不统一,这份数据集没有明确说明是否保留了这些过滤规则,所以解析时要自己决定是否参与训练。
下面列出XML关键字段对训练的作用:
| 字段 | 含义 | 训练时的作用 |
|---|---|---|
size/width | 图像像素宽 | 归一化坐标的分母 |
size/height | 图像像素高 | 归一化坐标的分母 |
object/name | 类别名 | 必须映射成固定class_id |
bndbox/xmin | 左上角x像素 | 转YOLO时算中心点 |
bndbox/ymax | 右下角y像素 | 与图像高比较是否越界 |
truncated | 是否被裁切 | 决定是否过滤目标 |
difficult | 是否困难样本 | 影响mAP统计口径 |
2.2 用Python把一张VOC XML解析成结构化字典
解析VOC XML推荐用xml.etree.ElementTree,不用正则去抠标签,因为要同时拿到图片元信息和目标列表。下面这段解析函数可以直接复用:
import xml.etree.ElementTree as ET def parse_voc_xml(xml_path: str) -> dict: tree = ET.parse(xml_path) root = tree.getroot() info = { "filename": root.findtext("filename"), "width": int(root.findtext("size/width")), "height": int(root.findtext("size/height")), "depth": int(root.findtext("size/depth")), "objects": [], } for obj in root.findall("object"): box = obj.find("bndbox") name = obj.findtext("name") if name is None or box is None: continue info["objects"].append({ "name": name, "xmin": float(box.findtext("xmin")), "ymin": float(box.findtext("ymin")), "xmax": float(box.findtext("xmax")), "ymax": float(box.findtext("ymax")), "truncated": int(obj.findtext("truncated") or 0), "difficult": int(obj.findtext("difficult") or 0), }) return info # 使用示例 if __name__ == "__main__": result = parse_voc_xml("Annotations/road_1023.xml") print(result["filename"], result["width"], result["height"]) print(result["objects"])逻辑说明:root.findtext("size/width")使用ElementTree的分层路径语法,直接取到size节点下的width值,避免写多层循环。对bndbox做了空值防御,因为一些标注工具导出的XML可能缺少子节点;坐标统一转成float,整数和小数字符串都能正确读入。
参数说明:obj.findtext("truncated") or 0的写法是为了处理某些XML里该字段为空字符串的情况,为空时按0处理。parse_voc_xml返回的字典里保留了truncated和difficult,这两个字段在后续转YOLO时,可以用来决定是否需要过滤困难样本。
2.3 CVAT和labelme导出的VOC与手标VOC的差异
CVAT标注工具可以一键导出VOC格式,但导出目录通常会多一个label_map.txt,标注顺序不保证和XML里object出现顺序一致。labelme更常见的是导出JSON,需要先转成VOC再交给下游。打开这份数据时,如果看到类别名是person_car这类复合词,或者同一张图里既出现car又出现vehicle,说明源头标注时类别体系不统一。处理办法是建立一张类别映射表,把同义词归一化,而不是直接在脚本里写死类别名。
这一步的核心是:VOC格式保证了标注的可读性,但并没有保证类别名的规范性。所以下一章的转换脚本里,类别字典必须由你根据这份数据实际出现的name集合来生成。
3. 转成YOLO才能真正训练:VOC坐标到YOLO归一化坐标的映射与脚本
3.1 为什么不能直接把VOC XML交给YOLOv8训练
YOLOv8的Ultralytics框架接收的是每张图片对应的txt文件,txt每行是class_id x_center y_center width height,四个浮点数都是归一化到0-1区间的相对值。VOC的bndbox是像素绝对值,如果直接除以训练时的resize尺寸,框会整体偏移。此外,Ultralytics需要一个data.yaml声明类别名称和数据集路径,VOC的ImageSets/Main只提供文件名列表,不提供类别名。
VOC转YOLO本质上是坐标单位转换、类别ID固定、数据集划分三件事同时完成。常见做法是先扫描全部XML,收集所有出现过的类别名,按出现顺序生成字典,然后在转换时使用同一本字典,防止训练集和验证集类别ID对不上。下面列出字段的换算关系:
| VOC bndbox字段 | YOLO txt列 | 换算公式 |
|---|---|---|
xmin, xmax | x_center | ((xmin + xmax) / 2) / width |
ymin, ymax | y_center | ((ymin + ymax) / 2) / height |
xmax - xmin | width | (xmax - xmin) / width |
ymax - ymin | height | (ymax - ymin) / height |
注意:所有除法里的width和height都必须来自XML里的size字段,不能来自图像文件的实际尺寸,除非你已经核实两者一致。第4章会专门讲不一致时怎么处理。
3.2 一个可复用的VOC转YOLO脚本
下面这段脚本直接跑在解压后的数据集根目录,会生成yolo_labels/目录和train.txt、val.txt、test.txt:
from pathlib import Path import random import xml.etree.ElementTree as ET BASE_DIR = Path(".") ANN_DIR = BASE_DIR / "Annotations" OUT_DIR = BASE_DIR / "yolo_labels" OUT_DIR.mkdir(exist_ok=True) # 第一步:扫描全部XML,收集类别名 class_names = [] for xml_file in sorted(ANN_DIR.glob("*.xml")): root = ET.parse(xml_file).getroot() for obj in root.findall("object"): name = obj.findtext("name") if name not in class_names: class_names.append(name) class_to_id = {name: idx for idx, name in enumerate(class_names)} print("类别映射:", class_to_id) # 第二步:遍历XML并写YOLO txt image_names = [] for xml_file in sorted(ANN_DIR.glob("*.xml")): root = ET.parse(xml_file).getroot() width = int(root.findtext("size/width")) height = int(root.findtext("size/height")) lines = [] for obj in root.findall("object"): name = obj.findtext("name") box = obj.find("bndbox") xmin = float(box.findtext("xmin")); ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")); ymax = float(box.findtext("ymax")) if xmax <= xmin or ymax <= ymin: continue x_center = ((xmin + xmax) / 2.0) / width y_center = ((ymin + ymax) / 2.0) / height w = (xmax - xmin) / width h = (ymax - ymin) / height lines.append(f"{class_to_id[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") if lines: img_stem = xml_file.stem out_path = OUT_DIR / f"{img_stem}.txt" out_path.write_text("\n".join(lines) + "\n") image_names.append(img_stem) # 按8:1:1划分,随机种子固定 random.seed(42) random.shuffle(image_names) n = len(image_names) train_imgs = image_names[:int(n * 0.8)] val_imgs = image_names[int(n * 0.8):int(n * 0.9)] test_imgs = image_names[int(n * 0.9):] for split, names in [("train", train_imgs), ("val", val_imgs), ("test", test_imgs)]: with open(f"{split}.txt", "w") as f: f.writelines([f"JPEGImages/{name}.jpg\n" for name in names])逻辑说明:脚本先做了全量扫描来构建class_to_id,这一步不能省。如果只浏览几个XML就手写类别列表,很容易漏掉某个只在十几张图里出现的类别,训练时遇到没见过的class_id就会跳过或报错。生成YOLO txt时顺手过滤了xmax <= xmin或ymax <= ymin的退化框,这类框在手工标注里常见,保留进训练会让损失函数出现无意义的梯度。
参数说明:随机种子固定为42,确保两次运行划分一致;如果想改成7:2:1,把int(n * 0.8)、int(n * 0.9)换成对应比例即可。写入train.txt的是相对路径JPEGImages/xxx.jpg,Ultralytics会拿它和data.yaml中的path拼接成绝对路径,因此脚本必须在解压后的根目录运行。
接下来还要生成data.yaml,类别顺序必须和class_to_id保持一致:
path: ./ train: train.txt val: val.txt nc: 4 names: ['person', 'car', 'bicycle', 'motorcycle']这里的names列表顺序如果和脚本输出的class_to_id不一致,训练时类别就会错位,属于最常见的数据集配置事故。
3.3 类别顺序与class_id的坑:人、车辆与两轮车的映射典型案例
类别顺序固定在class_to_id字典里,字典的键就是XML里的name。如果这份数据里混入了大写Person、CAR或bike,可以在第一遍扫描后做一次别名合并:
alias_map = {"Person": "person", "CAR": "car", "bike": "bicycle"} for i, name in enumerate(class_names): class_names[i] = alias_map.get(name, name) # 去重且保持顺序 class_names = list(dict.fromkeys(class_names))常见误操作是把class_to_id打印出来后硬编码进脚本,第二次解压数据集时发现顺序变了,导致标签全部错位。正确做法是每次重新扫描,并把转换后的classes.txt和data.yaml放在一起存档。训练时如果出现class not found或loss突然变成nan,第一反应是打开data.yaml确认nc与真实类别数一致,再检查YOLO txt里每一行class_id是否落在0到nc-1之间。
4. 训练前的数据体检:2998张原始图片里的无效框与类别分布统计
4.1 统计每张图和每个类别的目标数量
数据标注完成不等于数据可用。用一个简单的统计脚本可以快速回答三个问题:每一类有多少目标、平均每张图有几个目标、哪些图片没有任何有效标注。
from collections import Counter, defaultdict import xml.etree.ElementTree as ET from pathlib import Path ann_dir = Path("Annotations") image_counter = Counter() # 每个类别的目标总数 per_image_count = defaultdict(int) empty_xml = [] for xml_file in sorted(ann_dir.glob("*.xml")): root = ET.parse(xml_file).getroot() objs = root.findall("object") if not objs: empty_xml.append(xml_file.name) for obj in objs: name = obj.findtext("name") image_counter[name] += 1 per_image_count[xml_file.stem] += 1 print("类别分布:", image_counter) print("单图目标数均值:", sum(per_image_count.values()) / len(per_image_count)) print("空标注XML数量:", len(empty_xml))逻辑说明:Counter记录每个类别的出现次数,per_image_count用XML的文件名作为key,统计每张图片的目标数量,再用总和除以图片数得到平均每图框数。如果空XML数量占比超过1%,要单独检查这些图片,因为它们在训练时会被当作负样本,而实际场景可能确实没有目标,所以不是必须删除。
参数说明:脚本里的ann_dir可以指到任何VOC格式数据集目录。再补充一行输出单图目标数最多的5个文件名,通常对应十字路口或公交站,这类拥挤场景在评估时最容易拉低AP。
4.2 找出width=0、越界、坐标反序的无效标注
有四种标注错误很难从预览图上发现,但会在训练时造成明显AP下降。可以用下面的校验函数批量检查:
def validate_bbox(root): W = int(root.findtext("size/width")) H = int(root.findtext("size/height")) problems = [] for i, obj in enumerate(root.findall("object")): box = obj.find("bndbox") xmin = float(box.findtext("xmin")); ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")); ymax = float(box.findtext("ymax")) if xmin >= xmax or ymin >= ymax: problems.append(f"object {i}: 反序或面积为0") if xmin < 0 or ymin < 0 or xmax > W or ymax > H: problems.append(f"object {i}: 越界") if xmax - xmin < 2 or ymax - ymin < 2: problems.append(f"object {i}: 小于2px") return problems越界框最危险,因为YOLO归一化时不会报错,但训练时锚点匹配会把大量背景噪声当成前景。2998张原始图片如果来自不同摄像头,画幅比例可能不一样,有的XML写1920x1080,图片实际是1920x1200,这种情况下所有框的坐标虽然有效,但整体分母用错了,需要统一校正。
| 错误类型 | 现象 | 处理方式 |
|---|---|---|
| 宽度或高度为0 | 归一化后width=0,训练loss变nan | 过滤该框 |
| 坐标越界 | bbox超出图像边界 | 裁剪到图像范围内或删除 |
size字段与图片不一致 | 所有框位置整体偏移 | 以图片真实尺寸重新归一化 |
| 类别名同义词 | class_id混乱 | 统一类别映射表 |
一种处理办法是使用PIL打开图片获取真实宽高,与XML字段做对照:
from PIL import Image img = Image.open("JPEGImages/road_1023.jpg") real_w, real_h = img.size xml_w = int(ET.parse("Annotations/road_1023.xml").getroot().findtext("size/width")) if real_w != xml_w: print("size字段不一致,需要按图片真实宽高重新归一化")提示:出现
size字段不一致时,建议以图片真实宽高为准重新计算所有归一化坐标,而不是手动改XML。VOC格式里size字段本身由标注工具生成,某些批量标注脚本会沿用模板尺寸导致错位。
4.3 用CVAT或labelme复核数据时要注意的四个坑
用CVAT打开这些XML重新检查,常见的坑有四个:一是CVAT导入VOC时会带上自带的标签列表,如果XML里有未预设的类别名,它会直接跳过该目标;二是标注控制台默认只显示difficult=0的目标,需要手动开启才看得到被过滤样本;三是labelme导出的VOC不支持truncated字段,导入导出后该字段会丢失;四是复查时使用框追踪功能会创建插值帧,可能修改原始XML。
更安全的做法是用程序批量修正类别名,而不是在图形界面里一个个改。直接遍历XML的object/name节点,替换成最终规范名,再重新执行第3章的转换脚本。
4.4 类别分布极端不平衡时的处理建议
如果统计结果里motorcycle只有几十个目标,car有两万多个,直接训练会让模型偏向多数类。常见做法有三种:对少数类做欠采样或过采样,把包含该类别的图片多复制一份到训练目录;或者使用focal loss;或者对少数类做马赛克增强。对于2998张图的小数据集,我的建议是先用原始分布训练一版,看各类的AP差距,再决定要不要做重采样,不要一上来就人为均衡。因为交通场景里背景比例本来就高,过度均衡会让模型对稀疏类别产生误检。
5. 将VOC数据落到真实场景:YOLOv8n微调与视频抽帧验证
5.1 最小可训练流程
有了train.txt、val.txt和data.yaml,训练命令可以很简洁:
yolo detect train data=data.yaml model=yolov8n.pt epochs=20 imgsz=640 batch=16这里给的是20轮,不是最优解,但足够验证数据质量和训练流程。2998张图片在batch=16下每个epoch约150步,20轮约3000步,单卡RTX 3060约30分钟跑完;显存不足可以改成batch=8并加上amp=True。训练完成后权重在runs/detect/train/weights/best.pt。
使用yolov8n.pt预训练权重是微调而不是从头训练。如果这份数据集的类别与COCO有重叠,学习率不宜调太大,默认的0.001左右通常安全。
5.2 用视频抽帧做实际场景回归
验证集评估只是第一步,实际道路监控画面会被逆光、遮挡、小目标淹没。常见做法是取一段没参与训练的视频,按固定时间间隔抽帧,用训练好的模型推断:
import cv2 from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") cap = cv2.VideoCapture("crossroad.mp4") fps = cap.get(cv2.CAP_PROP_FPS) frame_ids = [int(i * fps) for i in range(0, 30, 2)] # 每2秒取一帧 for fid in frame_ids: cap.set(cv2.CAP_PROP_POS_FRAMES, fid) ok, frame = cap.read() if not ok: continue results = model(frame, conf=0.25, iou=0.5, verbose=False) # 打印每帧检测到的目标数量 print(fid, len(results[0].boxes.cls))逻辑说明:conf=0.25是置信度阈值,iou=0.5是NMS的IoU阈值。交通场景里小目标居多,conf调低到0.2会看到更多误检,调高到0.5会漏掉远处的行人。可以在视频上叠加检测框,同时打印每帧计数,和人工计数结果做对比。
5.3 数据集划分按视频片段分组
这组数据的2998张原始图片如果来自连续视频抽帧,按文件名随机划分会让训练集和验证集出现同一辆车、同一个人的连续帧,导致验证指标虚高。正确做法是先把文件名按前缀分组,再按组划分:
from itertools import groupby def split_by_group(image_names, prefixes): groups = [] for key, group in groupby(image_names, key=lambda x: x.split("_")[0]): groups.append(list(group)) random.shuffle(groups) train_groups = groups[:int(len(groups) * 0.8)] val_groups = groups[int(len(groups) * 0.8):] return train_groups, val_groups这里用下划线前缀作为场景ID,如果原始文件名是road001_0001.jpg这种,前缀road001就是一个场景片段。这样划分后,模型无法通过时间相邻帧记忆答案,验证mAP才接近真实路测水平。最后记住一个底线:交通检测任务里漏检比误检后果更严重,调参时优先看person和motorcycle两类在黄昏、背光条件下的召回率,而不是只看整体mAP。
本文还有配套的精品资源,点击获取