简介:面向施工工地、工厂等安全监管场景的YOLO目标检测数据集,聚焦人员是否规范佩戴安全帽、穿着反光衣与工作服这一实际需求,可用于训练与验证实时着装检测模型,适合计算机视觉入门者、算法工程师及安全信息化项目开发者参考使用。压缩包共收录2000个文件,其中1999个txt为逐图标注文件,记录目标类别与边界框坐标,另有1个yaml配置文件,用于声明数据集路径与类别名称,整包约952.16MB,规模足以支撑常规训练与精度验证。目前已有1622人学习下载。借助这批标注数据,读者可快速搭建检测训练流程,复现违规着装识别与告警抓拍的完整链路,并结合自有监控画面做迁移微调;配套的类别配置也便于直接接入OpenCV+YOLO推理脚本,减少数据整理与格式转换的重复工作。
1. 一份 YOLO 安全帽/反光衣数据集,值不值得拆开先看一眼
我见过太多人拿到安全帽/反光衣数据集的第一反应是解压、改个 data.yaml、直接开训,跑完一看 mAP50 有 0.9,兴冲冲拿到现场一测,逆光下漏掉一半,远处塔吊上的人直接识别成"未戴帽"。问题八成不在模型,而在数据。这份按 YOLO 标签格式整理的安全帽、反光衣、工作服识别数据集,价值不在图片数量,而在于它把"戴没戴"这件事拆成了可训练的框级标注——安全帽、人头、反光衣、人体几类各自独立成框,边界清楚,才能让模型学到"帽子在头上"和"帽子拿在手里"的区别。适合做厂区、工地、电力巡检、仓储作业这类佩戴合规检测的团队,也适合拿它当 YOLO 检测链路练手的数据工程样本。但前提是,你得先把它拆开体检,而不是当黑匣子喂进去。
2. 拆包与标签体检:先把这本账对平再谈训练
数据集落地第一步永远不是训练,是核对。YOLO 检测的标注是"一图一 txt、一行一目标",格式看着简单,错起来却悄无声息:txt 少一个、图片多了几张、坐标归一化除错了宽高,训练过程不会报错,只会让指标莫名其妙地低。下面三个动作,我一般会在一小时内走完,后面省下的调试时间远不止这些。
2.1 目录结构与标注文件的对齐关系
标准布局是 images 和 labels 平行,split 名一一对应:
dataset/ ├── images/ │ ├── train/ val/ test/ └── labels/ ├── train/ val/ test/关键约束只有一条:images/train/a.jpg必须对应labels/train/a.txt,同名不同后缀。txt 里每行五个字段——class_id x_center y_center width height,后四个都是除以图宽图高之后的 0~1 浮点数,不是像素。这一点搞错的人不少,尤其是从 VOC 转过来的,很容易把像素坐标直接塞进去,模型照样能训,但框会全部挤在左上角一小块。
提示:空 txt 文件不是错误,是负样本(背景图),对抑制"见人就报未戴帽"的误检很有用,体检时单独计数,别当垃圾删掉。
另外要确认 data.yaml 里的names顺序和 txt 里的 class_id 严格对齐。我见过一次 0 和 1 写反的,helmet 和 head 互换,训练曲线一切正常,结果模型把安全帽判成未戴,白白烧了两天卡。
2.2 标签体检脚本:类别分布、越界与过小框
下面这段脚本做四件事:类别计数、缺失标签、坐标越界、过小框筛查。放在数据集根目录跑即可。
# check_labels.py —— YOLO 数据集标签体检 from pathlib import Path from collections import Counter import math ROOT = Path("dataset") IMG_EXTS = {".jpg", ".jpeg", ".png", ".bmp", ".webp"} # 必须和 data.yaml 的 names 顺序一致 NAMES = ["helmet", "head", "vest", "person"] def to_label(img: Path) -> Path: parts = list(img.parts) parts[parts.index("images")] = "labels" # images -> labels return Path(*parts).with_suffix(".txt") cls_cnt = Counter() missing, bad, tiny = [], [], [] empty_num = 0 for split in ["train", "val", "test"]: for img in (ROOT / "images" / split).rglob("*"): if img.suffix.lower() not in IMG_EXTS: continue lp = to_label(img) if not lp.exists(): missing.append(str(img)) continue rows = [r.strip() for r in lp.read_text(encoding="utf-8").splitlines() if r.strip()] if not rows: empty_num += 1 # 负样本,合法 continue for r in rows: f = r.split() if len(f) != 5: bad.append((str(lp), "字段数!=5")); break cid = int(float(f[0])) if cid < 0 or cid >= len(NAMES): bad.append((str(lp), f"类别越界:{cid}")); break x, y, w, h = map(float, f[1:]) if not all(0.0 <= v <= 1.0 for v in (x, y, w, h)): bad.append((str(lp), f"坐标越界:{r}")); break if w * h < 1e-4: # 面积占比 < 0.01% tiny.append((str(lp), r)) cls_cnt[NAMES[cid]] += 1 print("类别分布:", dict(cls_cnt)) print("负样本(空txt):", empty_num, "| 缺标签图:", len(missing)) print("格式异常:", len(bad), "| 过小框:", len(tiny)) for item in bad[:10]: print(" BAD", item)几个参数和经验值:1e-4这个面积阈值对应 640 图上大约 6×6 像素,比它还小的框基本是标注噪声,肉眼都难判断戴没戴,留着只会拉低回归质量。bad只要非零就必须处理,因为 Ultralytics 一脉的训练链路遇到坏行通常静默跳过,你不会收到任何报错。
拿到cls_cnt后看两件事:一是 head 和 helmet 的数量比,正常场景下未戴帽的人远少于戴帽的人,比例到 1:5 以上就得考虑不平衡处理;二是 vest 类的占比,反光衣标注经常只在正面清晰时才有,侧面和背面缺失会训练出"只认正面"的模型。
2.3 从 VOC / COCO 转 YOLO 的坐标换算
手里如果混着 XML 或 COCO json,先统一成 YOLO 再合并,别在训练时做多格式适配。VOC 转 YOLO 的核心是"先裁到图内,再归一化":
import xml.etree.ElementTree as ET from pathlib import Path NAMES = ["helmet", "head", "vest", "person"] def voc_to_yolo(xml_path: Path): root = ET.parse(xml_path).getroot() W = float(root.findtext("size/width")) H = float(root.findtext("size/height")) lines = [] for obj in root.iter("object"): name = obj.findtext("name") if name not in NAMES: continue # 未在映射表里的类直接丢弃 cid = NAMES.index(name) b = obj.find("bndbox") x1, y1 = float(b.findtext("xmin")), float(b.findtext("ymin")) x2, y2 = float(b.findtext("xmax")), float(b.findtext("ymax")) # 关键:裁剪到图像范围,负坐标会让归一化后的值变成负数 x1, x2 = max(0.0, x1), min(W, x2) y1, y2 = max(0.0, y1), min(H, y2) if x2 - x1 < 1 or y2 - y1 < 1: continue cx, cy = (x1 + x2) / 2 / W, (y1 + y2) / 2 / H w, h = (x2 - x1) / W, (y2 - y1) / H lines.append(f"{cid} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}") return linesCOCO 的换算同理,只是bbox存的是左上角加宽高,需要cx = x + w/2再除图宽。转换完一定要用 2.2 的脚本再跑一遍,因为转换脚本最常翻车的地方是图片尺寸读错——有些 XML 头里写的 size 和实际图片不一致,稳妥做法是直接用 PIL 读图片真实尺寸,而不是信标注文件里的字段。
3. 训练前的数据工程:划分、增强与类别不平衡
体检过了只是证明数据"没错",接下来要让它"有用"。这一步是在数据层面做调整:怎么划、怎么增强、怎么补负样本。三个决定做对,同样的模型能多出好几个点的实际召回。
3.1 训练/验证/测试划分:别让相邻帧泄漏进去
如果数据集已经给了 split,先别急着信。工地数据很多是从监控视频抽帧来的,同一段视频里相隔几秒的两帧几乎一样。随机打乱划分的话,训练集里有第 100 帧、验证集里有第 101 帧,模型等于提前看过答案,验证 mAP 能虚高十几个点,上线一测原形毕露。
判断方法很简单:看文件名。cam01_000123.jpg、cam01_000124.jpg这种带连续编号的,就是抽帧。正确做法是按"组"划分,把同一段视频、同一个源目录的所有帧绑在一起走:
import hashlib, shutil from pathlib import Path SRC = Path("dataset/raw") DST = Path("dataset/cleaned") EXTS = {".jpg", ".jpeg", ".png", ".webp"} def group_key(p: Path) -> str: # cam01_000123 -> cam01;若无下划线则整名归为一组 return p.stem.rsplit("_", 1)[0] if "_" in p.stem else p.stem def split_of(g: str) -> str: h = int(hashlib.md5(g.encode()).hexdigest()[:8], 16) % 10 return "train" if h < 8 else ("val" if h < 9 else "test") for img in SRC.rglob("*"): if img.suffix.lower() not in EXTS: continue g = group_key(img) s = split_of(g) # 同组必定进同一个 split for sub in ("images", "labels"): tgt = DST / sub / s / img.name tgt.parent.mkdir(parents=True, exist_ok=True) if sub == "images": shutil.copy2(img, tgt) else: lp = Path(*[("labels" if p == "images" else p) for p in img.parts]).with_suffix(".txt") if lp.exists(): shutil.copy2(lp, tgt) else: tgt.write_text("") # 缺标签按负样本补空文件用 md5 取模而不是random.shuffle,是为了可复现——同一份数据重复跑,划分结果完全一致,团队里两个人的实验才能对比。比例 8:1:1 是常见起点,数据量小于两千张时改成 7:2:1,或者直接做 5 折交叉验证看指标波动区间,比单次划分的绝对值更有参考意义。
3.2 针对小目标与逆光场景的增强策略
安全帽在画面里通常很小。一个 1080p 的工地远景,几十米外的人头可能就 12×12 像素。这时候首要动作不是增强,而是提分辨率:imgsz从 640 提到 960 或 1280,小目标召回往往能涨 5~10 个点,代价是显存和推理耗时翻倍。这一步比调任何增强参数都划算。
增强里最容易翻车的是颜色。反光衣的识别线索很大程度就是荧光黄、荧光橙这类高饱和颜色,hsv_h开大到 0.05 以上,模型可能学会把"任意亮色块"当反光衣。我一般这样配:
| 参数 | 建议值 | 理由 |
|---|---|---|
| hsv_h | 0.010~0.015 | 色相扰动小,保住黄/橙的颜色语义 |
| hsv_s | 0.4~0.7 | 饱和度扰动可大,兼顾阴天/背光褪色 |
| hsv_v | 0.4~0.6 | 亮度扰动大,模拟逆光和夜间补光 |
| mosaic | 0.8,最后 15 轮关掉 | 提升小目标出现频率,但会缩小目标 |
| mixup | 0~0.1 | 检测任务慎用,容易造出物理上不合理的重叠 |
| scale | 0.5 | 缩放幅度大一点,覆盖远近不同景别 |
| degrees | 0~5 | 监控视角基本水平,大角度旋转不真实 |
mosaic 是把四张图拼成一张,能让小目标在拼接后被"放大"出现在画面中间,对安全帽这类小框很有效。但它同时会让单张图里的目标尺度变小,所以训练末段必须close_mosaic关掉,让模型回归真实分布——这条几乎是标配,不关的话最后几轮指标会突然抖一下。
还有一种情况是夜间和逆光样本少。与其硬增强,不如在数据层面补:同一场景的白天帧做 gamma 变换生成伪夜间图(v通道压暗、加轻微噪声),比让模型从零学光照不变性省事得多。
3.3 类别不平衡与负样本的补法
工地场景的天然分布是:戴帽的人多、未戴帽的少、穿反光衣的更少。直接训出来的模型倾向于"看见人就报戴帽",因为这样损失最低。处理手段有三种,按性价比排序:
第一是补负样本。把有人的场景但没有任何违规目标的图片,做成空 txt 放进去,比例控制在正样本的 5%~10%。这类图专门教模型"这场面没问题,别报警",对压误检立竿见影。很多人忽略这一点,结果模型在空场景里也能框出一堆不存在的帽子。
第二是重采样。把 no_helmet / head 这类稀有类的图片复制一份参与训练,注意要复制"整图+整标注",不能只复制框,否则同一张图里的其他目标会被重复计数。采样倍率控制在 2~3 倍,再多就是过拟合这几张图。
第三才是损失层面加权,Ultralytics 一脉没有直接暴露类别权重参数,常规做法是自定义损失或在 dataloader 里做加权采样。我的经验是,前两招做到位,第三招很少需要动。
顺带说一个类别设计的坑:helmet(安全帽)和head(人头)这两类在空间上高度重叠,IoU 常年大于 0.5。如果你的业务只需要判断"戴没戴",其实可以把 head 当成未戴帽的唯一类,把 helmet 单独一类,而不要同时标注 person 和 head,否则 NMS 阶段会互相压制,出现莫名其妙的漏框。
4. YOLO 训练链路落地:配置、超参与日志判读
数据理顺之后,训练本身反而是最短的一段。但配置写错、显存爆掉、看不懂日志这三件事,足以让一个新手在第一天就放弃。这一章把配置拆到字段级,把显存降级路径列清楚。
4.1 data.yaml 与训练命令的写法
data.yaml 只有六个字段,但有两个容易写错:
# configs/ppe.yaml path: /data/ppe # 数据集根目录,绝对路径最稳 train: images/train # 相对 path,不要写 ./images/train val: images/val test: images/test names: 0: helmet 1: head 2: vest 3: personpath用绝对路径,是因为训练脚本可能在别的目录启动,相对路径一错就是"找不到图片",而报错信息往往只写数据集为空,排查半天。train和val写相对路径时不要带./,部分版本解析会出问题。names的键必须是整数且从 0 连续,中间断号会让类别 id 错位。
训练命令:
yolo detect train \ data=configs/ppe.yaml \ model=yolov8s.pt \ imgsz=960 \ epochs=120 \ batch=16 \ workers=8 \ device=0 \ patience=30 \ optimizer=SGD lr0=0.01 lrf=0.01 \ hsv_h=0.012 hsv_s=0.5 hsv_v=0.5 \ mosaic=0.8 close_mosaic=15 \ scale=0.5 degrees=3 \ cache=ram \ project=runs/ppe name=v1 exist_ok=False逐项说:imgsz=960是为小目标让路,显存不够时优先降 batch 而不是降分辨率。patience=30是早停轮数,验证指标 30 轮不涨就停,避免无意义烧卡。lr0=0.01配合 SGD 是检测任务的稳妥起点,换成 AdamW 时降到 0.001 量级。cache=ram把图片预加载进内存,数据量在几千张时能明显提速,内存小于 32G 就改cache=disk。exist_ok=False防止两次实验写进同一个目录互相覆盖权重。
4.2 超参与显存不够时的降级路径
显存爆掉是最高频的报错,CUDA out of memory一出现,很多人的第一反应是把 batch 砍到 4,结果 BN 统计不稳,指标掉得很难看。正确的降级顺序是:
- 先降
batch,从 16 到 8,再不行到 4; - 再开
amp=True(混合精度,默认开),如果手动关了要打开; - 再降
imgsz,960 到 768 再到 640,这是最后手段,因为直接影响小目标; - 最后才动模型规模,yolov8s 换 yolov8n。
batch 小于 8 时,把optimizer换成 AdamW 并降低学习率,小 batch 下 SGD 的梯度噪声太大。另外workers设成 CPU 核数的 0.75 倍左右,设太高反而因为进程切换变慢,Windows 上还要注意多进程启动的保护问题,一般设 0 或 2。
注意:
close_mosaic的值必须小于epochs,设成 15 表示最后 15 轮关闭 mosaic。如果 epochs 只有 30,最后一半时间都在真实分布上训,前面的增强收益会被冲淡,这时候把 epochs 提到 100 以上更合理。
4.3 训练日志怎么看、什么时候该停
日志里真正要看的是四组数:box_loss、cls_loss、dfl_loss(分布式焦点损失,管框回归质量)、metrics/mAP50、metrics/mAP50-95。判断标准不是绝对值,是两条曲线的相对走势。
正常收敛是三条 loss 都单调下降并逐渐平缓,mAP50 上升后进入平台。如果cls_loss还在降而box_loss开始往上翘,说明分类在过拟合、定位在退化,通常是标注框不一致导致的——同一批图里有人框得紧、有人框得松,回归目标自相矛盾。这时候该回去复查标注规范,而不是调学习率。
如果train/box_loss持续下降而val/box_loss从第 N 轮开始上升,就是标准过拟合,处理顺序是:先加数据(补负样本、补稀有场景),再加大增强,最后才考虑减模型容量或加 weight_decay。
还有一个隐蔽信号:mAP50 很高但 mAP50-95 很低。说明框"大概位置对了"但"边界不准",这在安全帽这种小目标上很常见,本质是标注框尺度不统一。可以统计一下每类框的宽高分布,如果 helmet 类的宽度分布出现明显的双峰,那基本就是两批人标的。
断点续训用resume=True指向last.pt所在目录即可,它会连优化器状态和学习率调度一起恢复。别用model=last.pt重开训练,那样只加载权重不恢复调度,学习率会从头开始,曲线会突然掉一截,很多人以为是模型坏了。
5. 避坑与排查:标注、增强、评估里最常见的五个翻车点
这一章是我自己在不同项目里反复踩过的,按"现象 → 原因 → 解决"记录,遇到对应症状直接对照查。
5.1 数据与标注类问题
现象一:训练能跑,但 mAP 长期卡在 0.3 以下,且各类指标都很差。原因:坐标没有归一化,或者归一化时用了错误的宽高(比如把图片的 EXIF 旋转后的尺寸当成了原始尺寸)。YOLO 只接受 0~1 的值,超出范围不会报错,框会跑到图像外。 解决:跑 2.2 的体检脚本,看bad里的坐标越界项。同时用 PIL 读一遍图片真实尺寸,和转换时用的宽高比对,尤其是手机拍摄的竖图,EXIF 里带旋转标记,OpenCV 读出来是横的、PIL 读出来是竖的,两边不一致就全错。
现象二:模型对戴帽的人框得很准,但几乎从不输出"未戴帽"类。原因:类别极度不平衡,加上 helmet 和 head 空间重叠,NMS 阶段 head 框被 helmet 框压制掉了。 解决:先看cls_cnt里 head 类的绝对数量,少于 helmet 的 1/8 就要重采样;再检查 NMS 的 IoU 阈值,默认 0.7 在高度重叠的类别上偏严,可以提到 0.75~0.8 观察变化;如果业务只需要"是否违规",考虑合并成"帽子/人头"两类,去掉 person 类减少干扰。
现象三:验证集指标漂亮,换一个摄像头画面全崩。原因:数据划分时相邻帧泄漏,或者整个数据集只来自一两个机位,模型学到了机位特征而不是目标特征。 解决:按 3.1 的分组方式重划,确认验证集里没有和训练集同源的帧;再从新机位的视频里抽 200 帧做独立测试集,这个数字才是真实水平。
5.2 训练与部署类问题
现象四:训练到一半 loss 变 NaN,或者框突然全部塌缩到一个点。原因:学习率过高,或者标注里存在宽或高为 0 的退化框。退化框会让宽高回归的 log 计算出负无穷。 解决:体检脚本里加一条w <= 0 or h <= 0的过滤,这类框直接丢弃;学习率降到 0.005 重跑,配合warmup_epochs=3让前几轮平滑起步。已经出现 NaN 的权重没法救,只能从头训。
现象五:训练指标 0.9,部署到边缘设备上推理,漏检翻倍。原因:训练用了 letterbox 填充到 960×960,部署时预处理直接 resize 成 960×960 没保持长宽比,目标被拉伸变形;或者训练开了 TTA、部署没开;再或者量化成 INT8 后小目标的激活值被截断。 解决:核对训练和推理的预处理必须完全一致,重点是 letterbox 的灰边填充;量化时保留检测头的输出层为 FP16,只量化主干;上线前用同一批图分别跑 PyTorch 和部署引擎,逐图对比框数和类别,差异超过 3% 就回去查预处理。
6. 上线前的验证:mAP 之外的两个数字
mAP 是排序指标,回答的是"整体上框得准不准",但业务方问的是"漏了几个、误报几次"。这两个问题 mAP 答不了,得自己做阈值定标和分场景切片。
具体做法是把测试集按业务关心的维度切成几个子集——白天/夜间、近景/远景、正面/背面、单人/密集——每个子集单独跑一遍,输出不同置信度下的召回和误报数。下面这段扫描能直接给出拐点:
from ultralytics import YOLO import json model = YOLO("runs/ppe/v1/weights/best.pt") rows = [] for conf in [0.15, 0.25, 0.35, 0.45, 0.55, 0.65]: m = model.val(data="configs/ppe.yaml", split="test", conf=conf, iou=0.6, verbose=False) rows.append({ "conf": conf, "precision": round(float(m.box.mp), 4), "recall": round(float(m.box.mr), 4), "mAP50": round(float(m.box.map50), 4), }) print(json.dumps(rows, ensure_ascii=False, indent=2))这段脚本扫的是全局阈值,重点看召回从哪个点开始掉崖。合规检测类业务通常宁可误报不可漏报,所以阈值要选在召回还没明显下滑的位置,比如 0.25 而不是 0.5。选定之后,再到各子集上单独验证一遍,夜间子集的召回如果比白天低 20 个点以上,说明数据里夜间样本不足,得回去补数据而不是继续调参。
还有两个容易被忽略的细节。一是推理尺寸,训练用 960 部署就尽量保持 960,改成 640 会直接损失小目标召回,省下的那点算力不值。二是 NMS 的 IoU 阈值,密集工位场景下人头挨得近,0.7 会把相邻的两个头合并成一个,可以适当提到 0.75,同时观察误检是否上升。
血泪经验是:我曾经拿着 0.92 的 mAP 去汇报,现场演示时一个逆光工位连续漏了三帧,场面很难看。从那以后我每次交付前都强制走一遍——分场景切片、逐子集定阈值、PyTorch 与部署引擎逐图对比,三样缺一不可,宁可多花半天,也别在现场赌运气。这份数据集的用法也是同理,把它当起点而不是终点,补上你自己场景的那部分样本,模型才真正属于你。希望帮到你。
本文还有配套的精品资源,点击获取