最近在整理手里的智慧交通项目时,我把一套头盔检测数据集重新梳理了一遍:8300张图片,YOLO格式,覆盖了路口摄像头会碰到的各种场景。这套数据本来是我自己项目里用的,后来不少朋友来问头盔检测该怎么做,我发现大家缺的往往不是模型结构,而是数据本身。这篇就把我构建、清洗、标注、训练到部署这套方案的全过程写出来,包括踩过的坑,尤其是那些跑通Demo之后才会遇到的问题。
1. 头盔检测这类任务,数据到底该长什么样才够用
1.1 为什么说数据决定了目标检测的上限
头盔检测在智慧交通里属于典型的中远景目标检测任务:监控摄像头架设在路口立杆上,画面里摩托车、电动车的骑手通常只占几十到上百像素,头盔这种小目标很容易被周围环境干扰。模型结构上,YOLO系列的开源权重已经很成熟,大家拿来即用,真正拉开差距的其实是训练数据。
我见过不少朋友直接拿公开数据集跑YOLO,效果不理想就怀疑模型不行,其实问题经常出在数据分布上和实际场景不一致。公开数据集大多是平视角度的街拍照片,而路口摄像头是俯视或斜视角度,骑手在画面里的姿态、遮挡方式完全不同。8300张图听起来不算多,但如果场景覆盖合理、标注干净,效果会远好于几万张堆砌的图片。我的经验是:先搞清楚数据要解决什么问题,再决定数据怎么收集、怎么标注,这比先选模型重要得多。
1.2 智慧交通场景下数据集要满足的特殊约束
实际路口项目的图片和普通目标检测数据集有几个明显差异,整理数据时必须考虑进去:
- 拍摄角度:摄像头一般装在5到8米高的立杆上,视角是向下倾斜的,头部区域会被身体、车体遮挡一部分,标注框不能照搬平视数据集的经验。
- 背景复杂度:路口有护栏、绿化带、广告牌、其他车辆,目标检测容易把背景中的圆形物体误认为头盔。
- 光照和天气:逆光、树荫、夜间路灯、雨天反光都会改变头盔外观。同一顶头盔在不同光照下,特征差异可能比不同头盔之间的差异还大。
- 目标密集程度:红灯等待区经常多辆两轮车并排,骑手互相遮挡,这对检测器的召回能力要求很高。
基于这些约束,我在整理这套8300张数据集时,特意按"白天、黄昏、夜间、雨天"划分了场景比例,还保留了一批画面里有大量两轮车聚集的样本。宁可少收一些场景单一的图片,也要保证每个典型路口情况都有覆盖。实际项目里,如果数据里有一类场景完全缺失,模型上线后大概率会在那个场景翻车。
2. 8300张数据集的类别设计与标注质量检查
2.1 类别怎么分:两种设计思路的对比
头盔检测项目最常见的分类思路有两种,效果差别很大。
第一种是"按人头框":标注对象是骑手的头部区域,类别分为head_with_helmet和head_without_helmet。这种做法的优点是直接对应检测目标,后座乘客的头部也可以独立标注,遮挡时只要头部可见就能检出。缺点是需要标得比较细,头部在画面里很小,标注工作量大。
第二种是"按整车框":标注对象是整个两轮车(含骑手),再通过分类或属性识别来判断是否戴头盔。这种做法的优点是框大、好标,但遇到后座乘客就很尴尬,一辆车两个人都没戴头盔或只有一个人戴头盔时,整车级别的标签无法表达。
我最终选择了第一种方案,还额外加了一个person类别用来提供上下文。实际效果对比下来,按人头框训练的模型在路口场景的召回率明显更高,尤其能正确处理"骑车人戴了头盔、后座人没戴"这种很常见的情况。类别设计层面,我建议按下面这个对照表来取舍:
| 方案 | 框定对象 | 优点 | 缺点 |
|---|---|---|---|
| 按人头框 | 头部区域 | 检测直接、召回高,能区分前后座 | 标注精度要求高,小目标标签多 |
| 按整车框 | 两轮车+骑手 | 标注简单、不易漏标 | 无法表达"部分人戴头盔"的复杂情况 |
| 人头框+上下文类别 | 头部+骑手/车 | 信息最完整,适合后续扩展 | 标注类别多,训练和调参成本上升 |
2.2 数据集的场景分布与标注工具
这套数据集的场景分布我是按真实路口的统计来配比的:白天正常光照约占40%,黄昏和逆光约占20%,夜间路灯场景约占20%,雨天和阴天约占20%。每张图片的平均目标数量控制在2到6个头盔目标之间,既保留足够的正样本密度,又不会因为单张目标太多导致训练时增益失衡。
标注工具用的是LabelImg和X-AnyLabeling混着来。小项目用LabelImg够用,导出的格式是VOC XML;如果要批量处理、做半自动预标注,X-AnyLabeling可以直接挂一个YOLO模型做辅助标注,人工只负责修正,效率能提高不少。半自动预标注在8400张这种规模下非常实用,我的做法是先拿公开权重对全部图片做一轮推理,把置信度高于0.9的框自动生成初稿,人工只处理0.3到0.9之间那些拿不准的框,整体时间至少省了一半。
2.3 标注质量检查的三道关
标注质量直接影响模型的精度天花板。我踩过最大的坑是标注框不贴边:框比头部大一圈,或者只框到头盔没有框到下巴,导致模型学到的"头部"特征很飘。后来我定了三道检查流程:
- 尺寸分布检查:统计所有标注框的宽高占图片宽高的比例,头盔检测里大量框占比在2%到6%之间,如果发现很多框占比超过15%,基本是标注框把上半身也框进去了。
- 标签一致性抽检:随机抽5%的图片,逐张查看标注框与类别是否正确。重点看两类最容易出错的情况:骑手戴了帽子而不是头盔、头盔拿在手里而不是戴在头上。
- 坐标越界处理:YOLO格式要求归一化坐标在0到1之间,靠画面边缘的目标经常会出现坐标越界,需要用脚本统一做截断处理,否则训练时OpenCV读取会报错或导致样本作废。
3. 从原始标注到YOLO训练:格式转换和数据集划分的实操
3.1 为什么YOLO的标注格式是txt而不是json、xml
YOLO训练时读取的标签是每一张图片对应一个同名txt文件,文件里每行是类别序号 x_center y_center width height,坐标全部除以图像宽高做了归一化。这样做的好处是无论输入图片尺寸怎么变,标签都不用跟着改,模型内部resize时也方便处理。而COCO的JSON或VOC的XML虽然信息更丰富,训练时还是要转成这种格式,所以直接用YOLO格式能省掉很多中间环节。
3.2 COCO JSON转YOLO txt的参考脚本
如果你手里的数据是COCO格式,或者标注工具导出的是COCO JSON,下面这个转换脚本可以拿来直接用。核心逻辑就是读取标注框,把绝对坐标转换成归一化坐标,再写入txt文件:
import json import os def coco_to_yolo(coco_json_path, img_root, label_root): with open(coco_json_path, 'r', encoding='utf-8') as f: coco = json.load(f) img_map = {img['id']: img for img in coco['images']} cat_map = {cat['id']: i for i, cat in enumerate(coco['categories'])} os.makedirs(label_root, exist_ok=True) for ann in coco['annotations']: img_info = img_map[ann['image_id']] img_w, img_h = img_info['width'], img_info['height'] x, y, w, h = ann['bbox'] # COCO的bbox是左上角坐标加宽高, 转成中心点坐标 x_center = (x + w / 2) / img_w y_center = (y + h / 2) / img_h w_norm = w / img_w h_norm = h / img_h # 边界保护, 防止越界 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w_norm = min(max(w_norm, 0.0), 1.0) h_norm = min(max(h_norm, 0.0), 1.0) txt_name = os.path.splitext(img_info['file_name'])[0] + '.txt' txt_path = os.path.join(label_root, txt_name) cat_idx = cat_map[ann['category_id']] with open(txt_path, 'a') as f: f.write(f"{cat_idx} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}\n") if __name__ == '__main__': coco_to_yolo('instances_train.json', 'images', 'labels_train')转换完成后,记得检查每个txt文件是否与对应图片同名,并且确认类别编号和data.yaml里的names顺序一致。这里最容易翻车:JSON里的类别顺序是字母序,YAML里的names顺序如果没对上,训练出来的模型所有类别都会错位,表面看loss正常,实际结果完全不能用。
3.3 目录组织与train、val、test划分
YOLO工程的目录结构我建议按下面这样组织,后续无论是用Ultralytics还是自己写训练脚本都很方便:
helmet_dataset/ ├── images/ │ ├── train/ │ │ ├── img_00001.jpg │ │ ├── ... │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── helmet.yaml划分比例我用的8:1:1,也就是约6640张训练、830张验证、830张测试。划分时要注意别按文件名顺序直接切,最好先按图片所属的路口或拍摄时间段分组,再随机打乱,否则同一条路段、同一时间段的图片同时出现在训练集和验证集里,验证结果会虚高。这一步很多人忽略,等到部署到新路口才发现精度掉得厉害。
helmet.yaml的内容如下:
path: /data/helmet_dataset train: images/train val: images/val test: images/test nc: 2 names: 0: head_with_helmet 1: head_without_helmet写完后可以用一行命令验证标签和图片是否匹配,确认后就能开始训练了:
python -c "from ultralytics.data import YOLODataset; d=YOLODataset('helmet.yaml'); print(len(d))"如果数据加载正常,控制台会打印出训练集图片数量。
4. 训练配置、Loss曲线与第一次跑通的经验
4.1 YOLOv5和YOLOv8怎么选
新项目我优先选YOLOv8。理由很实际:Ultralytics的生态最省心,数据加载、增强、导出、部署链路都是现成的,遇到问题搜到的资料也最多。YOLOv5胜在稳定、历史包袱少,如果你的推理设备只支持旧版本的部署框架,选v5更稳妥。头盔检测这类单阶段检测任务,两个版本都能胜任,没必要在选型上花太多时间,先跑通一个能用的模型再优化。
考虑到目标是路口实时监测,我用的是YOLOv8n这个最小的版本作为baseline。它的参数量只有3.2M左右,在边缘设备上推理速度非常快。如果验证集mAP达不到要求,再逐步换成s、m版本。不要一开始就上大模型,后期的量化、裁剪会非常痛苦。
4.2 关键超参数怎么定
我在这套数据集上的训练配置是这样的:
yolo detect train \ model=yolov8n.pt \ data=helmet.yaml \ epochs=200 \ imgsz=640 \ batch=32 \ patience=30 \ close_mosaic=10 \ project=runs/helmet \ name=exp1几个超参的解释:
- imgsz=640:头盔是小目标,理论上用1280输入能涨点,但训练显存和推理耗时都会翻倍。我选择640起步,后面单独做高分辨率推理测试。
- batch=32:取决于显存,一般单卡12G显存可以跑到这个值。batch太小会导致BN的统计量不稳定,头盔这类小目标检测对BN很敏感。
- close_mosaic=10:YOLOv8默认使用Mosaic增强,但Mosaic生成的图片和真实场景差异较大,最后10个epoch关闭它,让模型在接近真实分布的数据上收尾,能避免最终精度波动。
- patience=30:验证集指标连续30个epoch不提升就早停,避免无效训练浪费时间。
训练过程中我习惯盯两个东西:训练集loss曲线和验证集mAP曲线。正常的曲线是loss前50个epoch下降迅速,后面趋于平缓,mAP50稳步上升。如果loss下降很快但mAP一直不动,通常意味着类别不平衡或者标签有问题,这时候先别急着调参,回去看数据。
4.3 训练阶段最容易踩的三个坑
坑一:Loss不降反升。有一次我把batch调到64,结果训练loss在前10个epoch直接爆炸,因为显存不够触发了隐性的梯度累积异常。解决办法是恢复到32,或者开启梯度累积来模拟大batch。
坑二:BN层崩溃。现象是训练中loss出现NaN,主要原因是输入图片里出现了纯黑或纯白的异常样本,或者标签越界导致计算异常。我把异常样本过滤掉之后就恢复正常了。
坑三:验证集mAP很高但实际场景很差。这是最隐蔽的坑,根源是验证集和训练集来自同一批路口,模型相当于背题了。我改用了一个完全没参与训练的新路段做测试集,mAP直接掉了10个点。这个体验让我后来做任何数据集都会刻意保留一个"未知路段"作为最终测试集。
训练结束后,验证命令也很简单:
yolo detect val model=runs/helmet/exp1/weights/best.pt data=helmet.yaml控制台会输出mAP50、mAP50-95等指标。对这个头盔检测任务,我定的及格线是mAP50不低于0.85,mAP50-95不低于0.6。如果达不到,优先检查数据问题,其次再考虑模型和超参。
5. 遮挡、小目标和夜间:误检漏检高发区的针对性优化
5.1 真实路口场景里的三类高频错误
模型训练完,真正上路口测试时,问题会集中暴露在三个方向:
- 后座乘客漏检:前座骑手戴了头盔,后座乘客没戴,但后座乘客被前座身体挡住大半,头部只露出很窄一条边,检测器经常漏掉。我最初的模型对单骑手场景的mAP有0.9,但后座乘客的召回率只有0.6左右。
- 误把头盔当水桶、花盆:路口绿化带里的水桶、石墩、圆形垃圾桶,在某些角度下和头盔轮廓很像,检测器容易给出高置信度的误检。
- 夜间灯光干扰:夜间模式下头盔反光、车灯过曝,目标和环境对比度变低,漏检率明显上升。
这些问题不是单纯调阈值能解决的,要从数据和增强两个层面去压。
5.2 数据增强怎么配合场景
YOLOv8自带的增强已经很强,但针对头盔场景,我额外做了两类增强:
一是小目标复制粘贴。我写了一个脚本,把训练集中小尺寸的头盔目标抠出来,随机粘贴到其他图片的空旷区域,同时生成新的标注。这个操作相当于把稀有小目标样本的权重提上来,对后座乘客漏检的问题改善非常明显。
二是夜间模拟增强。对白天图片做亮度降低、加高斯噪声、局部过曝,模拟夜间摄像头效果。注意增强不要做得太过,否则模型会把"黑暗"本身当成特征,反而在正常白天场景误检。我控制增强后的图片不超过训练集的30%。
还有一个容易被忽略的点:左右翻转增强。头盔本身左右对称,翻转没有问题,但如果你的项目后续要接车牌识别,翻转会导致车牌文字镜像,训练标签和后续任务矛盾。所以我训练头盔检测时开了翻转,但在同一份数据上跑车牌识别时就关了,两个任务分开处理。
5.3 用混淆矩阵和PR曲线定位问题
训练完不要只看mAP,我习惯导出混淆矩阵图来定位具体的错误模式:
yolo detect val model=best.pt data=helmet.yaml plot=True生成的confusion_matrix.png里,如果背景列上有大量头盔目标的误检,说明模型把背景当成了头盔,优先加负样本或调高置信度阈值。如果头盔类被分到未戴头盔类,说明两类特征差异不够,需要检查标注边界是否清晰。
PR曲线也要重点看。头盔检测在路口的实际运行中,我更看重召回率,因为漏掉一个未戴头盔的人比多报警一次危害更大。所以部署时我不会把置信度阈值设得很高,一般定在0.3到0.35之间,用后端逻辑过滤一部分低置信度的误报。
6. 部署到路口边缘设备时,模型压缩和推理加速的取舍
6.1 从PyTorch到ONNX再到TensorRT
训练完成后,部署到边缘设备的第一步是导出为ONNX:
yolo export model=best.pt format=onnx dynamic=True opset=12如果目标设备是NVIDIA Jetson系列,再进一步转成TensorRT引擎:
trtexec --onnx=best.onnx --saveEngine=best.engine --fp16fp16的精度损失通常很小,mAP可能只掉1到2个点,但推理速度能提升将近一倍,头盔检测这类语义明确的任务完全够用。int8量化能跑得更快,但需要校准集,而且精度可能出现5%以上的波动,如果模型本身mAP就在及格线边缘,不建议上int8。
如果是瑞芯微RK3588这类平台,需要先转成ONNX再通过RKNN-Toolkit转成rknn格式。不同平台的算子支持度有差异,我建议在项目早期就确认部署平台,宁可先用平台支持的算子把模型跑通,也不要先追求模型花哨的结构。
6.2 1080P实时推理的实测数据
我在Jetson Orin Nano上测试了YOLOv8n导出的TensorRT引擎,输入分辨率640x640,fp16下单张推理耗时约8到12毫秒,处理1080P的实时视频流完全够用,还能留出余量做多路并行。如果换成CPU推理盒,比如工业级的x86工控机,ONNX Runtime下单张大概在30到60毫秒,只能支撑单路或者两路视频流。
实测中我还发现,推理耗时不是唯一瓶颈,视频解码和后处理同样占资源。多路视频流场景下,建议把解码放在单独的线程,做帧跳过策略,比如每秒只处理10到15帧,既保证不漏掉关键画面,又给设备留出富余算力。
6.3 工程化部署的几个细节
部署阶段容易翻车的都不是模型本身,而是外围工程细节。我总结了几点:
- NMS阈值:IoU阈值用0.45到0.5都可以,置信度阈值在路口场景建议0.3起步,如果误报太多再逐步上调,不要一上来就设0.5。
- 检测框后处理:头盔检测通常只需要给后端平台输出"未戴头盔目标的中心点和置信度",不需要框的原始坐标全部上抛,减少网络传输压力。
- 数据回流:部署后定期收集新场景的误检和漏检样本,合并到训练集重新迭代。智慧交通场景的变化比想象中快,夏天遮阳帽、冬天棉帽都会让模型犯错,这类新样本往往比额外收集旧场景图片更有价值。
7. 模型效果评估与后续迭代的扩展方向
头盔检测整个链路跑通后,最终在独立测试集上的结果大致是:mAP50在0.88左右,mAP50-95在0.63左右,夜间场景召回率比白天低5到8个百分点,但通过数据增强和阈值调整,已经能保证路口管控对未戴头盔行为的有效识别。如果后续还想继续提升,可以考虑下面几个方向。
首先是在推理阶段引入SAHI切片推理。头盔是小目标,把大图切块后再推理可以明显提升召回率,代价是推理耗时增加,不适合实时场景,更适合离线批量研判。
其次是扩展类别。现有数据集只有头盔相关类别,实际智慧交通项目往往还需要同时检测车牌、车辆类型、闯红灯行为。建议在标注阶段就预留好类别扩展位,比如同时标注motorcycle、person,后续做多任务时就不用重新标注。
最后是模型蒸馏。如果希望精度高一点又不想换大模型,可以用YOLOv8m或YOLOv8l在同样数据上训练一个教师模型,再把知识蒸馏回YOLOv8n。我试过这种方式,学生模型的mAP能提升两三个点,推理速度保持原样,对边缘部署非常友好。
我个人在做了这么多头盔检测项目之后,最大的体会就是:数据不是越多越好,而是越对越好。8300张图如果分布均匀、标注干净,效果可以超过几万张堆砌出来的公开数据。先把镜头角度、光照、遮挡、小目标这些问题在数据层面解决掉,再谈模型结构改进,能少走非常多的弯路。希望这篇能把你在构建自己的头盔检测数据集时最想避开的坑都提前标出来。