简介:这份资源面向计算机视觉开发者、交通安全算法研究者及高校相关课题学生,提供摩托车与电动车骑乘人员佩戴头盔的检测数据集,可用于目标检测模型训练、头盔合规性识别与智能交通场景落地。压缩包共收录2000个文件,以VOC格式的XML标注文件为主,每个XML对应一张图片的边界框与类别信息,可直接接入YOLO、Faster R-CNN等主流检测框架,整体包体约159.84MB,配套2514张图片,覆盖多种骑行姿态与光照条件。目前已有658人学习下载,数据规模与标注质量在同类资源中较为突出。读者可借此快速构建训练与验证集,省去自行采集与标注的成本,并围绕头盔佩戴与否的二分类或多类别检测任务展开实验,验证模型在真实道路场景下的识别效果,为后续调优与部署提供可靠的数据基础。
1. 2514 张头盔检测数据集:从 VOC 标注到 YOLO 训练,这条路值不值得走
手上有个 2514 张图片、VOC 格式标注的摩托车电动车佩戴头盔检测数据集,第一反应往往是「直接丢进 YOLOv8 跑一遍」。但真做过交通场景检测的人都知道,头盔检测的难点从来不在模型结构,而在数据本身——小目标密集、遮挡严重、正负样本极度不均衡。2514 张这个量级,说多不多,说少也够训一个能用的基线,关键看你怎么处理 VOC 标注、怎么划分数据集、怎么调 anchor 和输入分辨率。这篇文章面向的是手里已经拿到这份数据集、或者正在评估要不要投入这个方向的算法工程师和项目落地人员。我会把 VOC 转 YOLO 的完整脚本、训练参数怎么设、哪些坑会让 mAP 直接掉十个点,全部拆开讲清楚。看完你至少能判断:这份数据集能不能撑起你的业务场景,以及怎么用最短路径跑出一个可用的头盔检测模型。
2. VOC 格式的头盔标注长什么样:先看懂再动手
2.1 VOC 标注文件的结构与头盔类别的命名陷阱
VOC 格式的标注是一张图对应一个 XML 文件,文件名和图片名一致,放在 Annotations 目录下。每个 XML 里包含图片尺寸、目标类别和边界框坐标。头盔检测数据集常见的类别命名有几种:helmet、with_helmet、without_helmet、head、person。不同来源的数据集命名差异很大,有的只标了戴头盔的头,有的把没戴头盔的头也标成head,还有的把人和头盔分开标。拿到数据集第一件事不是写训练脚本,而是先统计类别分布。
import os import xml.etree.ElementTree as ET from collections import Counter # 统计 VOC 标注中所有类别及其出现次数 def count_voc_classes(annotation_dir): class_counter = Counter() xml_files = [f for f in os.listdir(annotation_dir) if f.endswith('.xml')] for xml_file in xml_files: tree = ET.parse(os.path.join(annotation_dir, xml_file)) root = tree.getroot() for obj in root.findall('object'): name = obj.find('name').text.strip() class_counter[name] += 1 return class_counter # 替换为你的 Annotations 目录路径 counter = count_voc_classes('./Annotations') for cls_name, count in counter.most_common(): print(f"{cls_name}: {count}")这段脚本遍历所有 XML 文件,统计每个类别名出现的总次数。跑完之后你会看到类似helmet: 3200、head: 1800这样的分布。如果发现without_helmet只有几百个,而helmet有几千个,那正负样本比例可能超过 5:1,训练时就需要做类别加权或者过采样。另一个常见问题是同一个数据集里混用了helmet和with_helmet两种写法,这会导致模型学出两个语义几乎相同的类别,mAP 计算时互相干扰。遇到这种情况,统一重命名成一种。
2.2 图片尺寸分布与标注框宽高比的统计
VOC XML 里的size字段记录了图片的宽高,bndbox记录了每个目标的坐标。在转 YOLO 格式之前,先统计一下图片尺寸分布和标注框的宽高比,这直接决定你训练时的imgsz设多少、anchor 怎么调。
import xml.etree.ElementTree as ET import os import matplotlib.pyplot as plt def analyze_bbox_stats(annotation_dir): widths, heights, ratios = [], [], [] xml_files = [f for f in os.listdir(annotation_dir) if f.endswith('.xml')] for xml_file in xml_files: tree = ET.parse(os.path.join(annotation_dir, xml_file)) root = tree.getroot() size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) for obj in root.findall('object'): bbox = obj.find('bndbox') xmin = float(bbox.find('xmin').text) xmax = float(bbox.find('xmax').text) ymin = float(bbox.find('ymin').text) ymax = float(bbox.find('ymax').text) bw = xmax - xmin bh = ymax - ymin widths.append(bw / img_w) # 相对宽度 heights.append(bh / img_h) # 相对高度 ratios.append(bw / bh if bh > 0 else 0) print(f"标注框总数: {len(widths)}") print(f"相对宽度 中位数: {sorted(widths)[len(widths)//2]:.4f}") print(f"相对高度 中位数: {sorted(heights)[len(heights)//2]:.4f}") print(f"宽高比 中位数: {sorted(ratios)[len(ratios)//2]:.4f}") print(f"宽高比 5%分位: {sorted(ratios)[int(len(ratios)*0.05)]:.4f}") print(f"宽高比 95%分位: {sorted(ratios)[int(len(ratios)*0.95)]:.4f}") analyze_bbox_stats('./Annotations')跑完这段,如果相对宽度中位数小于 0.05,说明大部分头盔目标在图中占比很小,属于小目标检测范畴。YOLOv8 默认的imgsz=640在这种情况下可能不够,头盔在 640 分辨率下可能只有 20 到 30 个像素宽,特征提取会非常吃力。常见做法是把imgsz提到 960 甚至 1280,但显存占用会成倍增加。另一个思路是用 SAHI 切片推理,把大图切成小图分别检测再合并,这个后面会讲。宽高比中位数如果在 0.8 到 1.2 之间,说明头盔框接近正方形,默认 anchor 基本能覆盖;如果 5% 分位低于 0.4 或 95% 分位高于 2.5,就需要重新聚类 anchor。
3. 把 VOC 转成 YOLO 格式:转换脚本与四个边界坑
3.1 坐标归一化与类别映射的完整转换代码
YOLO 格式要求每张图对应一个 txt 文件,每行是class_id x_center y_center width height,全部归一化到 0 到 1 之间。VOC 的坐标是绝对像素值,左上角和右下角。转换时最容易出错的是归一化分母用错——宽度除以图片宽度,高度除以图片高度,不能搞反。
import os import xml.etree.ElementTree as ET import shutil # 类别映射:根据你的数据集实际类别修改 CLASS_MAP = { 'helmet': 0, 'without_helmet': 1, # 如果有 head 类别且需要保留,加在这里 } def voc_to_yolo(annotation_dir, image_dir, output_label_dir, output_image_dir): os.makedirs(output_label_dir, exist_ok=True) os.makedirs(output_image_dir, exist_ok=True) xml_files = [f for f in os.listdir(annotation_dir) if f.endswith('.xml')] skipped = 0 for xml_file in xml_files: tree = ET.parse(os.path.join(annotation_dir, xml_file)) root = tree.getroot() size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) # 图片文件名:VOC 中 filename 字段可能与 xml 文件名不同 filename = root.find('filename').text img_src = os.path.join(image_dir, filename) if not os.path.exists(img_src): # 尝试用 xml 文件名匹配图片 base = os.path.splitext(xml_file)[0] for ext in ['.jpg', '.jpeg', '.png', '.bmp']: candidate = os.path.join(image_dir, base + ext) if os.path.exists(candidate): img_src = candidate filename = base + ext break else: skipped += 1 continue lines = [] for obj in root.findall('object'): name = obj.find('name').text.strip() if name not in CLASS_MAP: continue cls_id = CLASS_MAP[name] bbox = obj.find('bndbox') xmin = float(bbox.find('xmin').text) xmax = float(bbox.find('xmax').text) ymin = float(bbox.find('ymin').text) ymax = float(bbox.find('ymax').text) # 边界裁剪:防止标注超出图片范围 xmin = max(0, min(xmin, img_w)) xmax = max(0, min(xmax, img_w)) ymin = max(0, min(ymin, img_h)) ymax = max(0, min(ymax, img_h)) if xmax <= xmin or ymax <= ymin: continue # 跳过无效框 x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h width = (xmax - xmin) / img_w height = (ymax - ymin) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}") if not lines: skipped += 1 continue # 写 label 文件 label_name = os.path.splitext(filename)[0] + '.txt' with open(os.path.join(output_label_dir, label_name), 'w') as f: f.write('\n'.join(lines)) # 复制图片 shutil.copy(img_src, os.path.join(output_image_dir, filename)) print(f"转换完成,跳过 {skipped} 个无效样本") voc_to_yolo('./Annotations', './JPEGImages', './labels', './images')这段代码做了几件关键的事:第一,处理了filename字段和实际图片名不一致的情况,这是 VOC 数据集里非常常见的坑,有些标注工具生成的 XML 里filename写的是原始文件名,但实际保存时改了名。第二,对边界框做了裁剪,防止标注超出图片范围导致归一化后坐标大于 1。第三,跳过了空标注文件,这些文件如果保留,训练时会被当成负样本,但实际上是标注遗漏,会误导模型。第四,类别映射用字典控制,方便你增删类别。
3.2 训练集验证集划分与 data.yaml 配置
转换完成后,需要把图片和标签划分成训练集和验证集。常见比例是 8:2 或 7:3。划分时要注意:同一个视频序列的连续帧不能同时出现在训练集和验证集里,否则验证集指标会虚高。如果数据集里没有序列信息,至少保证随机种子固定,方便复现。
import os import random import shutil def split_dataset(image_dir, label_dir, output_dir, val_ratio=0.2, seed=42): random.seed(seed) images = [f for f in os.listdir(image_dir) if f.lower().endswith(('.jpg', '.jpeg', '.png'))] random.shuffle(images) split_idx = int(len(images) * (1 - val_ratio)) train_images = images[:split_idx] val_images = images[split_idx:] for subset, img_list in [('train', train_images), ('val', val_images)]: img_out = os.path.join(output_dir, 'images', subset) lbl_out = os.path.join(output_dir, 'labels', subset) os.makedirs(img_out, exist_ok=True) os.makedirs(lbl_out, exist_ok=True) for img_name in img_list: base = os.path.splitext(img_name)[0] label_name = base + '.txt' shutil.copy(os.path.join(image_dir, img_name), os.path.join(img_out, img_name)) label_src = os.path.join(label_dir, label_name) if os.path.exists(label_src): shutil.copy(label_src, os.path.join(lbl_out, label_name)) print(f"训练集: {len(train_images)} 张, 验证集: {len(val_images)} 张") split_dataset('./images', './labels', './dataset', val_ratio=0.2)划分完之后生成data.yaml,这是 YOLOv8 训练时必须的配置文件。路径建议用绝对路径,相对路径在不同工作目录下容易翻车。
# data.yaml path: /absolute/path/to/dataset train: images/train val: images/val names: 0: helmet 1: without_helmetnames里的类别顺序必须和转换时的CLASS_MAP完全一致,否则模型学出来的类别会错位。这个错误很隐蔽,训练 loss 看起来正常下降,但推理时类别全乱。
3.3 转换后必须做的三项校验
转换脚本跑完不代表数据就对了。我一般会做三项校验:第一,随机抽 20 张图,用脚本把 YOLO 格式的框画回原图,肉眼确认框的位置和类别是否正确。第二,统计每个类别的实例数,确认和 VOC 统计结果一致。第三,检查有没有图片没有对应的 label 文件,或者 label 文件为空。
import cv2 import os import random def visualize_yolo_labels(image_dir, label_dir, output_dir, num_samples=20): os.makedirs(output_dir, exist_ok=True) images = [f for f in os.listdir(image_dir) if f.lower().endswith(('.jpg', '.png'))] samples = random.sample(images, min(num_samples, len(images))) for img_name in samples: img_path = os.path.join(image_dir, img_name) img = cv2.imread(img_path) h, w = img.shape[:2] label_path = os.path.join(label_dir, os.path.splitext(img_name)[0] + '.txt') if not os.path.exists(label_path): print(f"缺失 label: {img_name}") continue with open(label_path) as f: for line in f: parts = line.strip().split() if len(parts) != 5: continue cls_id, xc, yc, bw, bh = int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) x1 = int((xc - bw/2) * w) y1 = int((yc - bh/2) * h) x2 = int((xc + bw/2) * w) y2 = int((yc + bh/2) * h) color = (0, 255, 0) if cls_id == 0 else (0, 0, 255) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, str(cls_id), (x1, y1-5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imwrite(os.path.join(output_dir, img_name), img) visualize_yolo_labels('./images', './labels', './vis_check')跑完打开vis_check目录,重点看几种情况:框有没有明显偏移、有没有把背景框进去、密集场景下框有没有重叠错乱。这一步花十分钟,能省掉后面几小时的排查。
4. YOLOv8 训练头盔检测:参数怎么设、显存怎么省
4.1 从预训练权重开始的训练命令与关键参数
数据准备好之后,训练本身反而是最简单的环节。YOLOv8 的命令行接口很直接,但参数设不对,mAP 可能差十几个点。下面是我在 2514 张头盔数据集上验证过的一套配置。
yolo detect train \ data=./dataset/data.yaml \ model=yolov8s.pt \ epochs=150 \ imgsz=960 \ batch=16 \ lr0=0.01 \ lrf=0.01 \ momentum=0.937 \ weight_decay=0.0005 \ warmup_epochs=3 \ warmup_momentum=0.8 \ box=7.5 \ cls=0.5 \ dfl=1.5 \ hsv_h=0.015 \ hsv_s=0.7 \ hsv_v=0.4 \ degrees=0.0 \ translate=0.1 \ scale=0.5 \ shear=0.0 \ perspective=0.0 \ flipud=0.0 \ fliplr=0.5 \ mosaic=1.0 \ mixup=0.0 \ copy_paste=0.0 \ patience=30 \ device=0 \ workers=8 \ project=./runs \ name=helmet_yolov8s逐个说关键参数。model=yolov8s.pt是权衡速度和精度的选择,如果显存够可以上yolov8m.pt,但 2514 张的数据量用 s 就够了,m 容易过拟合。imgsz=960是因为头盔目标偏小,640 下小目标召回率明显偏低,960 是精度和显存的平衡点。batch=16在 8GB 显存下跑 960 分辨率基本是上限,如果爆显存就降到 8 并配合accumulate做梯度累积。lr0=0.01是 SGD 的初始学习率,用 AdamW 的话要降到 0.001。lrf=0.01是最终学习率因子,余弦退火到初始值的 1%。warmup_epochs=3让模型在前 3 个 epoch 慢慢升学习率,避免一开始就震荡。
数据增强方面,mosaic=1.0是 YOLOv8 默认开启的,对密集小目标很有效,但最后 10 个 epoch 建议关掉,让模型在真实分布上收敛。mixup和copy_paste在头盔检测上我一般不开,因为头盔和人的空间关系比较固定,mixup 会破坏这种关系。fliplr=0.5水平翻转是安全的,flipud=0.0垂直翻转不要开,现实中不会出现倒着骑车的人。scale=0.5允许 0.5 到 1.5 倍的缩放,对尺度变化有帮助。
4.2 显存不够时的三个降级方案
960 分辨率加 batch 16,8GB 显存基本吃满。如果只有 6GB 甚至 4GB,有三个降级路径。第一,降imgsz到 640,但小目标召回会掉,需要配合 SAHI 切片推理补偿。第二,降batch到 8 或 4,同时加accumulate=2或4,梯度累积能在小 batch 下模拟大 batch 的效果,但 BatchNorm 的统计量会有偏差,训练时间也会变长。第三,换更小的模型,yolov8n.pt参数量只有 s 的三分之一左右,精度会掉几个点但速度翻倍。
# 6GB 显存配置:640 分辨率 + batch 8 + 梯度累积 yolo detect train \ data=./dataset/data.yaml \ model=yolov8s.pt \ epochs=150 \ imgsz=640 \ batch=8 \ accumulate=2 \ ...还有一个省显存的技巧是开启 AMP 混合精度,YOLOv8 默认就是开的,不用手动设。如果发现训练时显存波动很大,把workers从 8 降到 4,数据加载的并行度降低能减少显存峰值。
4.3 训练过程中的指标解读与早停判断
训练启动后,重点盯三个指标:metrics/mAP50(B)、metrics/mAP50-95(B)和val/box_loss。mAP50 是 IoU 阈值 0.5 时的平均精度,头盔检测一般能到 0.85 以上算可用。mAP50-95 更严格,能到 0.55 以上就不错。如果 mAP50 在涨但 mAP50-95 不涨,说明框的位置还不够准,可以调大box损失权重或者检查标注质量。
val/box_loss如果持续上升而train/box_loss持续下降,就是过拟合的信号。2514 张数据量不算大,过拟合很常见。对策有三个:加大数据增强、加weight_decay、或者提前停。YOLOv8 的patience=30表示 30 个 epoch 验证指标不提升就自动停,这个值在 150 epoch 的总量下比较合理。
另一个容易忽略的是cls_loss。如果cls_loss降不下去,检查类别是否均衡。头盔检测里without_helmet通常比helmet少,可以在data.yaml里加类别权重,或者对少数类做过采样。YOLOv8 本身不直接支持类别权重,需要改损失函数或者用copy_paste增强少数类。
5. 头盔检测的避坑与排查:那些让 mAP 掉十个点的细节
5.1 坑一:标注框把整个头框进去,模型学了个寂寞
现象:训练 loss 正常下降,mAP50 能到 0.8 左右,但推理时发现模型把没戴头盔的头也检测成helmet,或者框的位置明显偏大,把肩膀也框进去了。
原因:VOC 标注里helmet类别的框如果画得太大,把整个头部甚至肩膀都包含进去,模型学到的是「人头」特征而不是「头盔」特征。2514 张里如果有几百张是这种标注,模型就会混淆。
解决:写脚本统计每个类别的框面积分布,把明显偏大的框筛出来人工复查。头盔框的相对面积中位数一般在 0.02 到 0.08 之间,如果某个类别的中位数超过 0.15,大概率是标注问题。修正标注后重新训练,mAP50-95 通常能提升 3 到 5 个点。
5.2 坑二:验证集里混入了训练集的近似帧
现象:验证集 mAP 很高,能到 0.92,但拿真实场景的视频跑推理,漏检和误检都很严重。
原因:数据集如果是从视频抽帧来的,相邻帧差异极小。随机划分时,第 N 帧在训练集,第 N+1 帧在验证集,模型相当于见过验证集的「近似副本」,指标虚高。
解决:按视频序列划分,同一个视频的所有帧只出现在训练集或验证集之一。如果数据集没有序列信息,用图片的感知哈希做去重,把相似度高于阈值的图片分到同一侧。这个操作会让验证集指标下降几个点,但真实场景表现会好很多。
5.3 坑三:小目标在 640 分辨率下特征消失
现象:大头盔检测没问题,远处的小头盔全部漏检,mAP50 在小目标子集上只有 0.3 左右。
原因:YOLOv8 的 P3 特征图 stride 是 8,640 输入下 P3 是 80x80,一个 20 像素宽的头盔在 P3 上只有 2 到 3 个像素,特征几乎消失。
解决:三个方案。第一,把imgsz提到 960 或 1280,P3 变成 120x120 或 160x160,小目标特征保留更多。第二,用 SAHI 切片推理,把 1920x1080 的图切成 640x640 的块分别检测再合并,小目标在切片里变成大目标。第三,改模型结构,加一个 P2 检测头,stride 为 4,但 YOLOv8 官方不支持,需要自己改代码。
5.4 坑四:类别不均衡导致少数类被模型忽略
现象:helmet的 AP 有 0.9,without_helmet的 AP 只有 0.5,模型倾向于把所有头都预测成helmet。
原因:helmet实例数远多于without_helmet,分类损失被多数类主导,少数类的梯度被淹没。
解决:第一,对without_helmet做过采样,在data.yaml里重复列出少数类图片,或者用copy_paste把少数类目标粘贴到其他图上。第二,调cls损失权重,从默认的 0.5 提到 0.8 到 1.0,让分类损失在总损失中占比更大。第三,用 Focal Loss 替代 BCE,但 YOLOv8 默认不支持,需要改损失函数。我一般先用过采样加调cls权重,能解决大部分情况。
5.5 坑五:数据增强把头盔和人的空间关系搞乱
现象:训练时 mAP 正常,但推理时模型对「人骑车戴头盔」这种组合的检测不稳定,有时候只检测到头盔检测不到人,有时候反过来。
原因:mixup和mosaic增强会随机拼接图片,可能把头盔和人的空间关系打乱。mixup把两张图按透明度叠加,头盔可能叠到另一个人的身上,模型学到的空间关系是错的。
解决:mixup直接关掉,mosaic保留但在最后 10 个 epoch 关掉。copy_paste如果要用,只粘贴同类目标,不要把头盔粘贴到没有人的背景上。数据增强的目的是增加多样性,不是制造不合理场景。
6. 从能跑到好用:SAHI 切片推理与模型导出
训练出一个 mAP50 0.88 的模型只是起点,真正部署到边缘设备或者视频流上,还有两件事要做:小目标推理优化和模型导出。
SAHI 切片推理是我在头盔检测上验证过最有效的提点手段。原理很简单:把大图切成有重叠的小图,每张小图单独推理,再把结果合并。对于 1920x1080 的监控画面,直接缩放到 640 推理,小头盔只有几个像素;切成 640x640 的块,每个块里的头盔相对变大,检测率明显提升。
from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction # 加载 YOLOv8 模型 detection_model = AutoDetectionModel.from_pretrained( model_type='yolov8', model_path='./runs/helmet_yolov8s/weights/best.pt', confidence_threshold=0.3, device='cuda:0' ) # 切片推理:slice 640x640,重叠 128 像素 result = get_sliced_prediction( 'test_image.jpg', detection_model, slice_height=640, slice_width=640, overlap_height_ratio=0.2, overlap_width_ratio=0.2 ) result.export_visuals(export_dir='./sahi_output')slice_height和slice_width设成和训练imgsz一致,640 训练就用 640 切片。overlap比例 0.2 是经验值,太小会在切片边界漏检,太大则重复检测多、后处理慢。SAHI 的合并逻辑是 NMS 加框合并,对于跨切片的同一个目标,取置信度最高的框。实测在 1080p 监控画面上,SAHI 比直接缩放到 640 推理的小目标召回率提升 15 到 25 个百分点,代价是推理时间增加 3 到 5 倍。如果对实时性要求高,可以只对画面下半部分做切片,因为头盔目标通常出现在画面中下部。
模型导出方面,如果部署到 NVIDIA 边缘设备,导 TensorRT 是首选。YOLOv8 的导出命令很直接:
yolo export model=./runs/helmet_yolov8s/weights/best.pt format=engine half=True imgsz=960half=True开启 FP16 量化,速度提升接近一倍,精度损失通常在 1 个点以内。imgsz必须和训练时一致,否则 anchor 匹配会出问题。导出后拿 100 张验证集图片跑一遍 TensorRT 和 PyTorch 的对比,确认 mAP 差异在可接受范围内。如果部署到瑞芯微或者地平线这类国产芯片,导出 ONNX 后用厂商工具链转换,注意算子兼容性,YOLOv8 的SiLU激活在某些工具链上需要替换成ReLU。
最后说一个我踩过的坑:导出 TensorRT 时如果imgsz设成动态尺寸,推理时第一次调用会触发引擎编译,耗时可能几十秒。生产环境一定要用固定尺寸导出,并且在服务启动时先跑一次预热推理,把编译时间消化在启动阶段。这个细节在压测时如果没注意,会被当成性能问题排查半天。希望帮到你。
本文还有配套的精品资源,点击获取