简介:面向工地安全帽自动识别与监督场景,YOLOv3安全帽检测完整资源包内含训练好的模型权重、推理代码及配套数据集,适用于算法工程师、安防项目开发者和目标检测初学者快速上手。模型基于数千张标注图片训练150轮,mAP与召回率均超过90%,PR曲线、loss曲线等评估图表已经保存在runs文件夹内,便于核对训练质量与后续调优。资源包共166个文件,压缩后约546.6MB,以Python脚本、YAML模型配置、pt权重文件为主,同时包含检测效果图、评估结果表格、训练日志以及Dockerfile等环境辅助内容,文件组织较清晰,已有642人学习浏览。部署时只需要准备YOLOv5环境即可直接运行,既可用于工地安全帽检测的快速落地,也可作为目标检测训练与调优的参考样例,有效减少重复训练成本。
1. 一张训练好的YOLOv3模型,为什么到现在还是安全帽检测的性价比之选
在智慧工地项目里,安全帽佩戴检测看着比人脸检测简单,实际翻起车来一点不含糊:帽子在监控画面里经常只有二三十个像素,还要扛住逆光、反光和密集人流。YOLOv3这套“训练好的模型+代码+数据集”方案,价值在于你不必从零攒数据和调结构,拿上权重就能在普通工控机上跑到实时,而且v3的多尺度检测对这类小目标依然比很多轻量网络可靠。v5、v8如今讨论度很高,但不少安监产线至今还在用v3做安全帽识别,图的就是模型小、依赖少、换硬件成本低。这篇按“模型为什么管用→数据怎么准备→推理与训练参数→实测中的坑→验证方法”来写,适合智慧工地集成工程师、赶安全帽检测作业的学生,以及想快速接入安全帽识别能力的开发者。
2. YOLOv3能检测安全帽的关键设计:多尺度特征与anchor匹配
YOLOv3能在这个场景里被长期选用,不单是速度快,而是它的多尺度检测头和anchor机制正好对着安全帽检测的痛点:目标小、形状固定、变化有限。下面把这两个点拆开讲,顺带给出anchor重聚的可跑代码。
2.1 三个尺度的检测头,分别管哪类目标
YOLOv3通过Darknet53主干输出三张不同分辨率的特征图:32倍下采样得到13x13,16倍得到26x26,8倍得到52x52。13x13感受野最大,负责大目标;52x52感受野最小,专门负责小目标。安全帽在1080P工地画面里常常只有20x20到40x40像素,比如一个人站在几十米外的脚手架下,整个人可能才占100x200像素,帽子部位就剩下二十来个像素。这种目标在13x13特征图上可能只落到一两个格子上,特征信息被压缩得很狠,漏检几乎是必然的。真正起作用的是52x52那层,它对小目标的响应更敏感。
这也是为什么安全帽检测里,把输入分辨率从416提到608往往比更换更强的主干网络更直接。YOLOv3的计算量相对温和,608输入在普通GPU上依然能跑到25到35帧,放在工地监控场景完全够用。很多人一上来就纠结要不要换YOLOv5s甚至v8n,实际对比下来,v3在同等输入尺寸下对密集小目标的召回率并没有被拉开多少,而部署链路的复杂度却低了一个量级。如果你手头是一份darknet格式的v3权重,这个结论会更明显。
还有一点容易忽略:三个尺度之间不是各管各的。当同一个目标同时被两个尺度的检测头响应时,后处理要把三层的输出合并到一起再做NMS。很多“为什么帽子被框了两层”的问题,根源都在这一层合并逻辑上写错了。后面第5章会专门讲这个坑。
2.2 为什么必须重新聚类anchor:一个可跑的最小实现
YOLOv3每个尺度配置3个anchor,共9个,官方默认值是从COCO数据集上聚出来的。COCO有80类目标,形状跨度从自行车到人、到猫狗都有,宽高比差异极大。安全帽的形态则非常固定,绝大多数是正圆顶或微椭圆顶,宽高比集中在0.8到1.5之间。直接沿用COCO的anchor,等于让模型的学习起点偏离真实分布,训练轮数不够时最典型的表现就是检测框跟帽子贴不齐,框偏大或偏小,置信度还不高。
常见做法是在训练前用你的标注框重新跑一次k-means,距离度量用1减去IoU,而不是欧氏距离。欧氏距离会被大尺寸目标主导,而安全帽场景里小目标占比高,用IoU距离更合理。下面是一个可以直接丢进项目里跑的最小实现,读取YOLO格式的标签txt,输出9个聚类后的anchor。
import numpy as np from pathlib import Path def load_wh(label_dir): boxes = [] for txt in Path(label_dir).glob("*.txt"): for line in txt.read_text().strip().splitlines(): # YOLO格式:class cx cy w h,宽高是归一化后的值 parts = line.split() boxes.append([float(parts[3]), float(parts[4])]) return np.array(boxes) def iou_dist(wh, anchor): # 用 1 - IoU 作为距离,越相似的框距离越近 w = np.minimum(wh[:, 0], anchor[0]) h = np.minimum(wh[:, 1], anchor[1]) inter = w * h union = wh[:, 0] * wh[:, 1] + anchor[0] * anchor[1] - inter return 1 - inter / np.maximum(union, 1e-6) def kmeans_anchor(wh, k=9, iters=100): idx = np.random.choice(len(wh), k, replace=False) anchors = wh[idx] for _ in range(iters): dists = np.stack([iou_dist(wh, a) for a in anchors], axis=1) labels = dists.argmin(axis=1) new_anchors = [] for i in range(k): cluster = wh[labels == i] new_anchors.append(cluster.mean(axis=0) if len(cluster) else anchors[i]) anchors = np.array(new_anchors) # 按面积从小到大排序,小anchor对应52x52层,大的对应13x13层 anchors = sorted(anchors, key=lambda a: a[0] * a[1]) return np.array(anchors) # 用法:label_dir 指向存放所有训练标签txt的目录 wh = load_wh("labels/train") new_anchors = kmeans_anchor(wh, k=9) print(new_anchors)逻辑说明:load_wh把每个标注框的归一化宽高收集成数组,标注框越多的数据集,聚类结果越稳定。距离函数里用最小宽高乘积算交集,避免了anchor和真实框尺寸差异过大时IoU分母为0的情况。迭代100次对几万框的数据集已经能收敛,如果框特别多可以加到200次。
参数说明:k=9对应YOLOv3三尺度各3个anchor,如果改成了其他尺度数量就要对应调整。聚类出来的9个值按面积从小到大排序后,前3个写入cfg文件的第一个yolo层(对应52x52),中间3个写第二个yolo层,最大3个写第三个yolo层。这个顺序一旦错位,训练初期loss会非常难看,因为小目标的先验被设成了大框。
2.3 安全帽检测的类别设计:单类还是双类
这是很多人拿到安全帽数据集后会纠结的问题:类别只标“戴帽”,还是再标一个“未戴帽/头部”。两种做法的差异不在模型结构,而在下游业务逻辑。
如果只做单类检测,模型只输出“帽子”框,那么“有没有戴”的判断逻辑是“有框=戴,无框=未戴”。这个逻辑在漏检面前很脆弱:远处的人帽子太小没被检出,系统就误判成了未戴帽。双类方案里,模型同时输出“戴帽”和“未戴帽”两类框,判断依据变成两个类别的得分比较,漏检时两个类别都不出框,至少可以触发“无法确认”的中间态,而不是直接误报。我一般建议安监项目用双类,代价只是数据集里需要额外标注一批头部区域,以及最后输出层filters从24改成27。
# 单类(classes=1):filters = 3 * (1 + 5) = 24 # 双类(classes=2):filters = 3 * (2 + 5) = 27 # 修改obj.cfg里倒数三层yolo附近的filters数值参数说明:filters的计算公式是3乘(类别数加5),这个5是坐标的4个值加上一个置信度。改动之后,权重文件必须和cfg对应,否则加载时维度不匹配会直接报错。双类方案在推理时的后处理也要同步改,最后一个维度不再只有一个分类得分,而是两个类别分别的得分。
3. 从数据集到可训练格式:标注转换与正负样本划分
模型能识别安全帽的前提是数据格式完全对得上。这一段是整个流程里时间占比最高、也最容易出错的部分,建议按顺序做:先确认类别字段,再转格式,最后处理样本划分。
3.1 数据集的结构与类别映射:先看标注,再动代码
安全帽数据集的标注格式五花八门,常见的有VOC的xml、COCO的json、以及直接给YOLO的txt。拿到数据集第一件事不是训练,而是把类别字段全部列出来看一眼。很多公开安全帽数据集里包含两类标签,一类是戴帽正样本,一类是未戴帽的head负样本;还有一类只标了正样本,背景里的未戴帽人员根本没标注。后者用于验证集会虚高,因为漏标的目标不会被记为误检;用于训练则会让模型对“该出框但没标”的区域产生困惑。
典型YOLO工程目录长这样:images/train和labels/train平级,标签文件名和图片名一一对应,每个实例一行,内容是class cx cy w h,宽高都是除以图片宽高后的归一化值。在动手转换之前,先写个三行的统计脚本,把每个类别的框数量、宽高分布打印出来,确认类别ID和名称的映射关系。这一步能省掉后面大量的返工。
3.2 VOC格式转YOLO:转换脚本与四个边界坑
安全帽数据集里存量最大的是VOC格式,也就是每张图配一个xml,里面用bndbox记录xmin、ymin、xmax、ymax。转换的核心逻辑是把绝对坐标除以图片宽高,得到归一化的中心点和宽高。下面这段脚本可以直接用,需要自己准备的只有class_map,把xml里的类别名映射成数字ID。
import xml.etree.ElementTree as ET from pathlib import Path def voc_to_yolo(xml_path, out_path, class_map): tree = ET.parse(xml_path) root = tree.getroot() size = root.find("size") if size is None: raise ValueError(f"{xml_path} 缺少 size 字段") img_w = int(size.find("width").text) img_h = int(size.find("height").text) lines = [] for obj in root.iter("object"): name = obj.find("name").text.strip() if name not in class_map: # 不在映射表里的类别直接跳过,不写进标签 continue box = obj.find("bndbox") if box is None: raise ValueError(f"{xml_path} 的 {name} 目标缺少 bndbox") x1 = float(box.find("xmin").text) y1 = float(box.find("ymin").text) x2 = float(box.find("xmax").text) y2 = float(box.find("ymax").text) w = (x2 - x1) / img_w h = (y2 - y1) / img_h if w <= 0 or h <= 0: # 退化框直接跳过,否则训练时会产出nan continue x_c = (x1 + x2) / 2 / img_w y_c = (y1 + y2) / 2 / img_h lines.append(f"{class_map[name]} {x_c:.6f} {y_c:.6f} {w:.6f} {h:.6f}") out_path.write_text("\n".join(lines))逻辑说明:每个目标读出来之后做三件事,一是类别不在映射表就跳过,二是宽高为0的退化框跳过,三是坐标全部除以图片尺寸归一化。前三行对xml的健壮性做了检查,size缺失或bndbox缺失时直接抛异常,而不是静默跳过,这样批量转换时能立刻暴露问题文件。
这里踩过的四个边界坑,值得单独记一下。
第一是xmin和xmax完全相等的退化框,归一化后宽为0,YOLO训练时这个loss会直接变成nan,而且一个nan会把整个batch拖垮。第二是xml里的size字段,有些数据集从标注工具导出时size缺失或者宽高写反,会导致所有框的位置整体偏移。第三是类别名带空格,比如helmet这种,不strip的话映射永远匹配不上。第四是bndbox缺失,常见于半成品标注,处理方式应该是记录文件名,转换完统一回头补标,而不是放过去。
3.3 训练集与验证集划分:按视频片段分,不按帧乱分
工地视频采集的数据集有一个隐性风险:连续帧之间高度相似。如果把同一段视频里的相邻帧随机分到训练和验证集两侧,验证集的难度就被严重高估,模型见过训练帧后,验证帧几乎等于开卷考试。常见做法是先按视频片段或按拍摄时间分组,把整组划入训练或验证,在组层面做8比2划分。没有视频分组信息时,至少按目录前缀划分,保证同一场景不同时段的画面不会两边都出现。
类别平衡也是安全帽场景里被低估的问题。现场回收的数据往往是未戴帽样本远多于戴帽样本,因为工地上总有那么几个不戴帽子的角落被反复拍到。直接训练会让模型偏向输出负类,表现为整体准确率很高,但戴帽的那一类召回率低得可怜。解决思路是重采样:对少类样本做随机复制或增强,让两类数量比例控制在1:1到1:2之间。增强手段对安全帽这类刚性目标很有效,随机翻转、HSV扰动、随机裁剪都行,就是不要用太大角度的旋转,帽子倒过来在语义上就不是真实场景了。
4. 用训练好的模型跑通安全帽检测:推理命令与训练参数
模型文件拿到手之后,第一步不是急着推理,而是确认手里这四类文件齐不齐:weights、cfg、obj.names、obj.data。四者缺一不可,缺一个都会在运行时以很离谱的方式暴露出来。
4.1 文件组成与加载前的三个核对点
weights是训练产物,cfg是网络结构定义,obj.names是类别名列表,obj.data指向训练和验证集的路径。加载前核对三处:第一,cfg里的网络层级和weights是不是同一次训练产出,中断训练后保存的中间权重很可能和最终改过的cfg不一致;第二,obj.names的类别顺序和训练时的class_id要对上,顺序错了输出框的标签就会张冠李戴;第三,obj.data里的路径是否还指向当前机器上的位置,数据集目录换过之后,路径失效会报出莫名其妙的文件读取错误。
如果你手上的模型是darknet格式,直接用darknet命令最省事。如果只有cfg和weights,没有darknet可执行文件,也可以用OpenCV的dnn模块读,这套方式在OpenCV 4.x下都能兼容。
4.2 图片、视频与摄像头推理:darknet命令和OpenCV两种方式
darknet的推理命令是三段式:detector test/demo,接着是data、cfg、weights,最后是输入。下面是用训练好的模型跑图片、视频和摄像头的完整命令。
# 单张图片推理,结果会保存到predictions图片里 ./darknet detector test obj.data obj.cfg backup/helmet_final.weights \ images/0687.jpg -thresh 0.4 # 视频推理,输出带检测框的新视频 ./darknet detector demo obj.data obj.cfg backup/helmet_final.weights \ site_input.mp4 -out_filename site_result.mp4 -thresh 0.4 # 摄像头实时检测,-c 0是第一个摄像头 ./darknet detector demo obj.data obj.cfg backup/helmet_final.weights \ -c 0 -thresh 0.4逻辑说明:detector test只跑单张图,适合验证权重和cfg是否匹配;detector demo跑视频流或摄像头,-out_filename指定输出视频路径,不加则只显示窗口。-thresh是置信度阈值,darknet默认0.25,安全帽场景建议手动调到0.4或0.5,后面第5章会展开讲原因。
如果你的项目已经基于Python,用OpenCV的dnn模块加载也是常见做法。优势是省掉darknet的编译过程,坏处是推理速度比darknet原版略慢,且部分层的优化不如原版。核心调用代码如下。
import cv2 import numpy as np net = cv2.dnn.readNet("backup/helmet_final.weights", "cfg/helmet.cfg") net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU) threshold_conf = 0.4 threshold_nms = 0.45 def detect_helmets(img): h_img, w_img = img.shape[:2] blob = cv2.dnn.blobFromImage(img, 1/255.0, (416, 416), swapRB=True) net.setInput(blob) outs = net.forward(net.getUnconnectedOutLayersNames()) boxes, scores = [], [] # 遍历三个尺度的输出,合并坐标和得分 for out in outs: for row in out[0]: obj_conf = row[4] class_scores = row[5:] * obj_conf conf = float(np.max(class_scores)) if conf < threshold_conf: continue cx, cy, bw, bh = row[:4] * np.array([w_img, h_img, w_img, h_img]) x1, y1 = int(cx - bw/2), int(cy - bh/2) boxes.append([x1, y1, int(bw), int(bh)]) scores.append(conf) idx = np.asarray(cv2.dnn.NMSBoxes(boxes, scores, threshold_conf, threshold_nms)).ravel() return [boxes[i] for i in idx]逻辑说明:blobFromImage把输入图缩放到416x416并做归一化,swapRB=True是因为OpenCV读图是BGR顺序,而darknet权重是按RGB训练的。forward拿到三个尺度的输出列表,每个输出里坐标是相对于416输入图的像素值,所以要乘回原图宽高再画框。最后对全部候选框做一次NMS,才是最终结果。
参数说明:threshold_conf控制哪些框保留,threshold_nms控制重叠框的合并力度。安全帽场景下0.45的NMS阈值是经验值,太小会漏掉紧挨着的两个帽子,太大会把同一顶帽子的多框残留下来。
4.3 训练参数与低显存跑法:重新训练时的一份基准配置
如果拿到手的模型和代码满足不了你的场景,需要自己重新训练,这里给一份可以照抄的基准配置。
# obj.cfg 关键参数 batch=64 subdivisions=16 width=608 height=608 learning_rate=0.001 burn_in=1000 max_batches=800逻辑说明:batch是每次更新权重的样本量,subdivisions把batch切成小份喂给GPU,解决显存不够的问题。width和height用608而不是416,安全帽小目标多,大输入尺寸直接带来召回率提升。learning_rate用0.001,burn_in是热身步数,让模型在头1000步从一个很小的学习率逐步涨到目标值,避免开局梯度爆炸。max_batches设成超过800,双类场景建议跑到1200以上。
低显存跑模型是很多人实际面对的硬约束。训练侧靠subdivisions解决:4G显存跑608输入时,把batch设64、subdivisions设32,每次实际只算2张图的梯度,累计到64张再更新权重,显存占用和batch=2差不多,训练效果接近batch=64。推理侧如果也只是CPU工控机,OpenCV dnn方式通常能在40到80毫秒完成一帧608输入,415输入能到30毫秒以内。再想提速就得做int8量化,但量化后小目标召回率会掉,这个取舍得自己标数据跑一轮验证,别信网上的“量化无损”说法。
5. 安全帽检测部署的常见坑:现象、原因与处理办法
这部分全是实战里反复踩出来的坑,每一条都按“现象→原因→解决办法”的排查思路写。当你发现检测结果不对劲时,先对照这里找方向。
5.1 漏检:远处的小安全帽完全被忽略
现象:画面里远处站着的人明明戴了帽子,模型就是不出框,走近了又能检测到。
原因:本质是正样本里远距离小目标占比太少。工地摄像头区域固定,远处区域被采集到的样本量天然少,模型没见过足够多的小帽子,自然学不会在52x52特征图上响应它们。另一个因素是输入分辨率设得低,比如416下远处帽子可能只剩10x10像素,特征图上一个格子都盖不满。
解决办法分三步走:先把输入尺寸提到608;再统计一下训练集里小目标(宽高小于32像素)的占比,如果低于10%就针对性补采或对小目标样本做复制增强;最后在后处理里对52x52层的输出单独降低置信度阈值,比如从0.4降到0.3,对另外两层保持不动。
5.2 误检:把反光、警示牌当成安全帽
现象:红色警示牌、蓝色塑料桶、地面上的黄色反光带都被框成“戴帽”。
原因:安全帽颜色鲜明、形状规则,模型很容易学到颜色和边缘特征的强相关,而不是帽子的完整结构。工地场景里恰好有大量同色系的硬质物体,它们出现在训练负样本里的次数太少,模型没学会抑制。
解决办法:第一优先级是收集这些硬负样本,也就是“长得像帽子但不是帽子”的图片,补进训练集重训一轮。第二优先级是把置信度阈值抬高到0.5以上,能压住一部分低分误检,但代价是远处真实小帽子的分数也可能低于阈值,造成漏检上升,所以只做应急用。第三种做法是给模型增加一个上下文约束,比如只在人体上半身区域内检测帽子,工程上实现成本不高,效果却立竿见影。
5.3 训练时loss横盘不降或反升
现象:训练到一两万步,loss曲线一直横在某个高位,或者突然往上跳一大截然后下不来。
原因:最常见是学习率和burn_in不匹配,burn_in太小导致前几百步梯度方向震荡剧烈;其次是anchor没按数据集重新聚类,模型在错误的先验上反复修正,收敛不到好的局部最优;还有一种情况是标注数据里混进了坐标全为0或宽高为nan的坏样本。
解决办法:先检查obj.cfg里learning_rate是不是0.001、burn_in是不是1000附近;再对照第2章的聚类脚本重新算一遍anchor并更新到cfg;最后用下面这条命令扫描标签文件里的非法行。
awk '{ if ($3<=0 || $4<=0 || $5<=0 || $6<=0) print FILENAME, $0 }' labels/train/*.txt逻辑说明:这条命令遍历训练标签目录下所有txt,把宽高或坐标出现非正数的行打印出来,文件名和内容一起可见。跑完之后把这些文件挑出来检查,多半是标注软件导出时漏了字段或者框退化成了点。
5.4 一张图上出现大量重复框
现象:同一顶帽子被框三四次,框的位置略有偏移,置信度都还不低。
原因:三个尺度的检测头同时响应了同一个目标,而后处理代码里只对部分输出层做了NMS,或者NMSIoU阈值设得太高。排在后面的麻烦是相邻两个人的帽子离得近,NMS阈值过高把两个不同目标的框也合并成了一个,导致人数统计错误。
解决办法:确认后处理时把所有输出层的候选框收集到一起,统一做一次NMS,IoU阈值用0.45而不是0.6。排查时可以单独打印每个尺度的输出数量,如果52x52层独占了绝大多数候选框,说明小尺度特征过于活跃,考虑提高该层的置信度阈值。
5.5 权重文件和cfg不匹配
现象:加载权重后推理结果全是乱框,甚至程序直接崩溃,报出层维度不匹配的错误。
原因:训练中途保存的checkpoint和最终版cfg往往对不上,尤其是改过类别数或anchor之后没重新训练完,却拿旧权重配新cfg加载。
解决办法:先数cfg里每个yolo层前面那个卷积层的filters是不是等于3乘(类别数加5),不是就直接改掉;再把训练日志里最后一次成功保存的权重找出来,不要用中断那个时间点的。权重和cfg这种绑定关系,跟数据集结构一样属于“黑匣子”问题,改了其中一个就必须重新训练一轮,没有捷径。
6. 验证模型的真正方式:分场景边界行为与阈值校正
验证安全帽检测模型,最忌讳只拿几十张现场图看一眼就下结论。真正有参考价值的验证是按场景分组,分别量化你的模型在每个子场景下的表现。
6.1 分场景验证矩阵
建议按下面这张表组织验证集,每个子场景至少200张图,把指标单独统计。
| 场景 | 关键难点 | 建议指标 |
|---|---|---|
| 正常佩戴 | 基准能力 | mAP、IoU>0.5召回率 |
| 远处小目标 | 帽子小于32像素 | 小目标子集召回率 |
| 逆光/强光 | 颜色偏移、过曝 | 误检数/100张 |
| 夜间 | 低光照、噪点多 | 漏检率 |
| 密集人群 | 遮挡与重叠 | 重复框数、漏检率 |
| 未戴帽负样本 | 假阳性 | 误检率 |
每个子场景单独算指标,比看一个整体的mAP有用得多。你会发现系统整体mAP可能有0.85,但夜间误检数高得离谱,或者远处小目标的召回率只有0.4。这些差异在整体指标里会被稀释,一旦进入正式交付就会以投诉的形式回到你头上。
6.2 用阈值扫描找最优平衡点
模型调阈值不是靠感觉随便改,而是跑一趟阈值扫描,把0.3到0.6之间每隔0.05的置信度阈值都算一遍,画出召回率和误检数的变化曲线,再根据业务侧更在意漏检还是误检去选点。比如安监项目里漏检可接受度低,就选召回率最高且误检率不超过每百张5个的阈值;如果要求“不打扰工人”,就选误检最少的那个点。
我自己习惯把这个扫描结果存成表格,跟着模型版本一起归档。下次更新训练数据或改模型结构时,拿新版和旧版在同一个验证集上对比,一目了然。这个方向做了几轮之后,你会发现在算法参数上能优化的空间越来越小,真正的提升都来自坏样本的收集和验证集的扩充。希望这些踩过的路能帮到你。
本文还有配套的精品资源,点击获取