做头盔检测大半年,我最深的体会是:模型换了好几个版本,涨点都不如把数据集认真重做一遍来得明显。这套YOLO智慧交通头盔检测数据集一共8300张图,前后折腾了两个月,中间报废过两批标注,也踩过不少训练阶段的坑。这篇文章准备把数据集的规划、采集、标注、训练和部署经验一次讲透,适合正在做头盔检测、或者准备从零自建数据集做小目标检测项目的读者参考。我不打算只讲"我做了什么",更想把"为什么这样做"和"哪些尝试是白费的"讲清楚。
1. 为什么先定数据规模再选模型:8300张的规划逻辑
1.1 头盔检测和常规目标检测不是一个量级的任务
最开始我也犯过"拿通用检测器直接上"的毛病。COCO数据集里一辆车、一个人往往占图片面积的很大比例,模型随便一跑就有不错的表现。但智慧交通路口的画面完全是另一回事:骑手在画面里经常只有很小的像素高度,而且大量是背影、侧身、逆光、被前车遮挡的状态。夜间抓拍里,戴盔和不戴盔的差异可能就集中在头部一圈轻微的反光轮廓上。头盔检测本质上是一个"小目标+遮挡+低对比度"三座大山叠在一起的检测任务。
这三类问题不是换网络结构就能解决的。我在项目里给团队定的第一条规矩就是:模型先用成熟的,数据必须把场景覆盖到每个角落。这也是为什么一开始就把规模定死在8300张,而不是随手攒两三千张就开练。如果只检测"头盔"这一个大类,两千张确实也能跑出来模型,但一到夜间、雨天、半盔、后座乘客这些情况立马露馅。8300张是按下文这个覆盖矩阵算出来的结果。
1.2 场景覆盖矩阵:8300张是"算"出来的,不是凑出来的
我在立项时列了一张场景覆盖矩阵,把影响头盔检测的主要维度全部拆开,然后给每个格子定目标张数:
| 场景维度 | 覆盖细项 | 计划张数 |
|---|---|---|
| 时段 | 白天 / 傍晚 / 夜间 | 约3000 / 2000 / 3300 |
| 天气 | 晴天 / 雨天 / 雾天与阴天 | 约5000 / 2200 / 1100 |
| 机位 | 高位俯拍 / 平视机位 | 约5500 / 2800 |
| 道路密度 | 单车骑行 / 多车混杂 | 约4200 / 4100 |
| 车辆类型 | 摩托车 / 电动车 | 约4400 / 3900 |
夜间规划3300张是最关键的一笔。很多项目白天效果尚可,一到晚上直接不能用,就是因为夜间样本太少。雨天和雾天同样不能省,雨滴反光、镜头水珠、低对比度都会让检测器迷茫。机位维度也容易被忽视:高位俯拍是路口的常见监控视角,平视机位则是执法记录仪和卡口相机的视角,两者的形状、尺度分布差异很大。把每个格子的大致数量加总,就是8300这个数字的来源——没有哪个场景是凑数硬塞进去的。
1.3 类别定义:被废弃的第一版五分类方案
第一版标注我定义过五类:with_helmet、without_helmet、helmet、head、motorcycle。结果开工第二天标注员就来问:一个人背影看不见头,该标head还是without_helmet?骑手戴了头盔但只露出半个脑袋,helmet和with_helmet是不是都要标?一问就乱,类别的语义边界模糊,直接导致标注一致率跌破80%。
后来砍成三类:motorcycle、with_helmet、without_helmet。头盔本体和人头不再单独拆,判断逻辑简单粗暴:画面里的人确定戴盔就标with_helmet,确定没戴盔就标without_helmet,看不清楚就只标motorcycle不标人。对业务来说最终关心的就是"未戴盔的人出现在哪里",人框叠摩托车框做过滤已经够用。这个教训很值:类别越少,边界越清晰,标注质量越高。如果确实需要区分摩托车和电动车,我建议单独做另一套数据,别混在一起抢标注资源。
2. 采集与清洗:8300张怎么凑齐,又怎么筛掉一批
2.1 三路采集渠道与抽帧策略
数据集的三路来源我都试过。第一路是合作路口的真实监控视频,不同机位、不同时段录的原始素材,用ffmpeg按时间抽帧。抽帧频率我控制在每秒1到2帧:太密,相邻帧几乎一样,数据集虚胖;太疏,又会漏掉车辆快速进出画面的瞬间。抽完帧还要用感知哈希做过近似去重,相似度超过阈值的只留一张,这一步直接砍掉了近三成冗余素材。
第二路是公开可用的图像资源,包括一些开放数据集和允许转载的图库。这里有个前提:只使用明确标注可商用的部分,逐张确认授权范围。第三路是合成渲染,用三维场景摆摩托车和人体模型,渲染不同光照与角度。合成图主要用来补夜间、雨天这类真实素材极缺的场景,我的使用比例大概是真实抽帧55%、公开资源30%、合成渲染15%。合成数据不能当主力,否则模型会学到塑料感纹理,影响真实场景的泛化。
2.2 一把尺子筛到底:清洗标准
数据清洗直接决定标注员的效率和最终质量。我定了几条硬性标准:目标小于整图高度5%的骑手不标,因为这种目标标注本身就是噪声;严重运动模糊不标;遮挡超过50%不标;画面里没有任何有效目标的图一律删掉。很多路口画面里根本没有摩托车,这类图占着数量却不贡献训练信息,还会放大背景误检。
还有一个容易忽略的坑是重复背景。同一个机位在同一个时间段连续抽帧,即使做了去重,背景依然高度相似。模型训练时看到大量相同背景,很容易对背景过拟合,一到新路口就失灵。我的做法是给每个机位每个时段设置截图数量上限,宁可选的图分布广,也不要一堆等价于同一段视频的图。
2.3 合规与匿名化:人脸和车牌的细节
智慧交通数据涉及人脸、车牌,发布前必须做匿名化。我在清洗阶段对所有清晰人脸做了高斯模糊、对车牌做了涂抹。这里有两个细节:一是模糊要在最终出图的分辨率上做,不然导出到训练尺寸后模糊区域可能缩得太小、又有可识别性;二是保留一份匿名化前后的映射记录,方便后续审计或追溯。商用项目尤其要看数据License,不能只看网站写着"免费下载"就拿来直接用。这一关不过,模型训出来也没法真正落地。
3. 标注规范和YOLO格式落地
3.1 标注流水线:初标、复核、抽检
标注工具我用的是X-AnyLabeling和LabelImg的组合。LabelImg稳定、导YOLO格式方便,适合快速框选;X-AnyLabeling带辅助分割,适合处理复杂遮挡场景。团队四个人并行,每天每人大概完成120到180张图,全部初标用了一个多月。
流水线设计成"初标、复核、抽检"三层:初标员画框,复核员重点检查类别是否正确、有没有漏标,抽检时随机抽10%的图,计算标注框的IoU一致率,低于0.85整批退回。这套流程前期很费工,但后面训练和调参省下的时间远超标注多花的成本。标注质量是数据集的命门,这一层必须舍得投入人力。
3.2 那些让标注员崩溃的边界情况
BBox定义必须白纸黑字写进规范:框要贴合目标可见轮廓,不包含明显不属于目标的背景;遮挡部分不靠脑补,框到可见边缘为止;两个目标挨得很近时,边框不能互相吃进对方区域;头盔框和人员框同时存在时,以人员检测框为准。
后座乘客是重灾区。标注员习惯只标骑手,后座乘客经常漏掉,但业务里后座不戴盔恰恰是高频违规。我在规范里专门加了一条:车上出现几个人就标几个人,不分前后排,哪怕只露出肩膀也要尽力判断。这条规则直接决定了模型在后座检测上的表现,也是我后来对比过"有这条规则"和"没这条规则"两个版本后确认有效的。
还有一种边界情况:头盔挂在车把上、放在车座上。这类是典型的hard negative,如果误标成戴盔人员,模型会以为"车把上挂个头盔=人戴了盔"。我的处理是:这类场景只标motorcycle,不标人,让模型学会"没有人形轮廓的区域不预测人头"。如果人下车站在旁边没戴盔,就正常标without_helmet。
3.3 YOLO标签格式的坑与一个自检脚本
YOLO的txt格式核心是五列:类别索引、归一化的中心点x、中心点y、框宽、框高。最容易犯的错有两个:把中心点坐标写成了左上角坐标,或者忘了归一化、直接写像素值。这两个错在训练阶段不会报错,只会让你莫名其妙不收敛或者精度很怪。
我的检查方法是一个小脚本:逐图读取txt标签,把框画回原图,随机抽几十张直接看。十秒钟就能完成一次全量自检,强烈建议每个人都跑一遍。另外类别索引从0开始,我的映射是0=motorcycle、1=with_helmet、2=without_helmet,训练代码里禁止用类别名字符串直接匹配,防止映射错位。
import os import cv2 img_dir = "images" label_dir = "labels" check_dir = "check" os.makedirs(check_dir, exist_ok=True) for name in os.listdir(img_dir): if not name.endswith(".jpg"): continue img_path = os.path.join(img_dir, name) label_path = os.path.join(label_dir, name.replace(".jpg", ".txt")) img = cv2.imread(img_path) h, w = img.shape[:2] with open(label_path, encoding="utf-8") as f: for line in f: cls, cx, cy, bw, bh = map(float, line.split()) x1 = int((cx - bw / 2) * w) y1 = int((cy - bh / 2) * h) x2 = int((cx + bw / 2) * w) y2 = int((cy + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(os.path.join(check_dir, name), img)跑完之后看到框的位置明显偏移、尺寸明显不对,那就是标签格式出了问题。早发现好过训练到一半才回过头来怀疑数据,这一步是整个流程里性价比最高的检查。
数据划分方面,8300张按8:1:1分成训练、验证、测试。划分前先按场景分层随机,保证夜间、雨天、不同机位在三个集合里的比例接近,不能直接random.shuffle,否则可能出现验证集里完全没有夜间样本的情况。测试集一旦冻结就不再改动,后续所有迭代都用它评估。
4. 训练配置与调参实录
4.1 模型和算力选型
模型我用的YOLOv8s起步,配合成熟的ultralytics训练生态,跑通后再对比YOLOv8m。也试过YOLOv5s和更新的版本,整体感觉是:头盔检测的瓶颈在小目标和遮挡,网络结构带来的差异远小于数据集质量带来的差异。版本号不是质变,数据才是。训练用的云端V100实例,batch size给到64,输入尺寸640,初次训练200个epoch大约7到9小时。如果你手里是单张消费级显卡,建议把输入尺寸降到512、batch调到16到32,先把流程跑通再加分辨率。
4.2 关键超参数:epochs、Mosaic、BN
epochs我设置300并开启早停。头盔检测的训练曲线有一个特点:mAP50涨到0.95之后看起来不动了,但mAP50-95还在缓慢爬升,这是在学精细的边界回归,不能因为mAP50不动就提前停。
Mosaic增强建议"前开后关":训练初期开着,增加样本多样性;最后几十个epoch关掉。原因是Mosaic生成的是拼贴图,边框信息是假的,一直开会让模型在真实自然图上的边界预测不稳定。这是我踩过最明显的坑,也是后面几次训练稳定涨点的关键操作。
再就是batch size和BN的配合。小batch加偏大的学习率很容易触发batch norm崩溃,表现是loss突然变成NaN,或者验证指标断崖式下跌。遇到这种情况不要先怀疑数据,先看是不是BN炸了:减小学习率、增大batch,或者冻结BN层再训练几轮。我见过不少人夜里训练起来第二天一看全白跑,多半都是这个原因。
损失函数方面,YOLO默认的CIOU对框回归已经很稳,我没有再折腾其他变种。我反而更关注类别不均衡:without_helmet的样本数大概只有with_helmet的一半,直接结果是未戴盔类的召回偏低。这时候不要盲目加权重,先统计各类别的实际业务需求,再决定要不要在损失里给未戴盔类更高的权重。
4.3 数据增强与类别平衡
默认增强里,HSV变换我保留但把幅度调低了。原因是头盔颜色对业务有意义,比如建筑工地的黄色安全帽、外卖骑手的黄色头盔,这些颜色特征是强先验。颜色偏得太离谱会让模型学歪。左右翻转可以开,上下翻转绝对不能开——没有哪辆车会倒着开。
夜间样本不足的问题,我做了一个copy-paste增强:把夜间摩托车的目标抠出来,贴到其他夜间背景上,人工生成更多夜间正样本。效果比单纯调亮度好,因为保留了真实的夜间纹理。这个操作要注意边缘融合,处理不好会生成一批贴纸图,反而污染数据集。
如果某个类别的AP特别低,比如雨天头盔,最直接的办法不是调参,而是回数据仓补样本。我给自己定的排查链路是:某个类别AP低,先看测试集里这类样本到底长什么样,再决定加数据还是调增强,而不是一上来就改loss。这条链路帮我避免了大量无效调参。
5. 评估与部署中的边界情况
5.1 指标解读:别被mAP50骗了
mAP50高不等于能上线。我通常把mAP50-95和各类AP分开看,再配合业务指标验收。头盔检测我给团队定的硬指标是:with_helmet召回率不低于0.95,without_helmet召回率不低于0.90,把戴盔者误判成未戴盔的比例控制在0.5%以内。这些业务指标比单个mAP值更有决策意义。
混淆矩阵必须看。网上经常有人问YOLO混淆矩阵"总和不是1",那是因为默认显示的是行归一化百分比,每一行代表真实类别、行和为1,列和自然不等于1。不用在归一化方式上纠结,重点要看主对角线之外的误判分布。头盔检测里最常见的错误是把戴盔者框成未戴盔,通常由半盔或者头盔颜色接近肤色引起,这类错误在混淆矩阵里集中在特定格子,一眼就能定位问题在哪一类场景。
5.2 真实路口部署暴露的问题
白天常规路口效果很好,但部署第一个月就暴露了好几个数据集没覆盖的情况:傍晚逆光时头盔边缘和天空融为一体,检测框来回抖动;雨夜路面反光把未戴盔的头部特征淹掉;还有人戴棒球帽、鸭舌帽,业务上不算头盔,模型却容易识别成戴盔。这些都是数据覆盖缺口,不是模型能力问题。
解决办法是回到数据仓。我把现场产线跑出来的失败截图整理成hard example集,按周回流训练。流程是每两周从真实部署流里抽一次失败样本,大概300到500张,人工确认后做增量训练,每次增量训练结束都重新跑固定测试集,防止"修了这个洞、漏了那个洞"。头盔检测这类安全相关场景,宁可保守一点,把漏检压到最低。
5.3 数据集不是一次性交付物
8300张是第一个冻结版本。冻结的好处是训练对比公平,项目复现有据可查。但真实项目不可能止于第一版,我把数据集做成了"基座+增量"模式:基座8300张保持不动,增量目录按批次存放新补充的hard examples,训练时基座和增量一起用,评估永远只跑冻结测试集。版本管理用简单的目录加md5清单,数据说明文件里写清楚标注规范版本、类别映射表、划分文件的校验值。这些细节看起来很琐碎,真到要复现半年前某个结果的时候,你就知道它们多重要了。
最后再说一个我自己的体会。做头盔检测数据集,最坑的其实不是采集和标注本身,而是"以为数据够了"。每次模型在某个新场景翻车,我回到原数据集里总能找到对应的覆盖缺口——不是缺时段、就是缺机位、或者缺某个特殊佩戴方式。数据集从来不是一次性交付物,它是跟着模型一起迭代生长的资产。理解了这一点,后面做任何小目标检测项目,思路都会顺很多。