☰
五类安全穿戴目标检测:VOC数据集转换与YOLOv8训练全流程
2026/9/26 2:45:03 网站建设 项目流程

简介:面向深度学习与计算机视觉开发者的VOC格式标注数据集,专门用于识别行人及绝缘靴、绝缘手套、工作衣、安全帽等安全装备,可支撑目标检测与语义分割模型的训练与验证。压缩包共1278个文件,包含637张JPG原始图像与637个配套XML标注文件,另有4个TXT说明文件,整体大小约146.83MB;图像与标注一一对应,目录结构清晰,便于直接接入TensorFlow或PyTorch训练流程。目前已有1197人浏览学习,适合工业安全智能监控、施工人员防护检测等课题的研究者与学生参考使用,也可作为入门目标检测实践的自制数据集样例。数据采用纯手工标注,准确性较为可靠,能为模型提供扎实训练材料;配合主流深度学习目标检测框架,还可进一步部署为厂区安全装备穿戴的自动化巡检与风险提醒系统,有效提升现场安全管理效率。

1. 五类安全穿戴目标VOC数据集:拿到手先别急着开训

行人、绝缘靴、绝缘手套、工作衣、安全帽这五类目标,是电力检修、变电站作业场景安全管控系统里最常用的检测对象,这套VOC已标注数据集就是围绕这五类标的物做的,图片贴近真实工地环境,阳光直射、阴影遮挡、远处小目标这些情况都能碰上。比起电力红外数据集偏设备测温的定位,这套可见光数据更贴近穿戴行为识别。想做安全帽检测、PPE穿戴识别或者电力巡检方向的算法预研,这套数据可以直接拿来喂YOLO系列。但拿到手先别急着开训——这类数据有个共性规律:行人样本数量大,绝缘靴、绝缘手套这类小目标相对少,类别不平衡和尺度问题是两个绕不开的坎。我建议先做一轮数据体检再进训练流程,后面几章就是这条体检路线的完整操作。

2. VOC标注结构拆解:三个目录与一个XML文件

2.1 JPEGImages、Annotations、ImageSets 三个目录的分工

VOC格式的目录结构在目标检测数据集里是最经典的一种,这套数据包里的目录划分也应该遵循这个约定。JPEGImages放原始图片,Annotations放每张图对应的XML标注,ImageSets/Main下放train.txt、val.txt这类划分文件。很多下载包图省事,会把train.txt和val.txt放在根目录,但本质上还是同一个东西。

目录存放内容训练流程里的角色
JPEGImages原始图像训练输入
Annotations每张图对应一个XML文件真值来源
ImageSets/Maintrain.txt、val.txt划分列表数据分配依据

图片和标注文件同名不同后缀,这是VOC的硬性约定。检查数据完整性时,先比对两个目录下的文件名是否一一对应,我一般用一条命令就能发现问题:ls JPEGImages | sed 's/\.jpg$//' | sort > /tmp/a.txt,再对Annotations做同样处理,用diff看两边差集。文件名对不上,轻则漏样本,重则训练时读到空标注文件直接报错。

2.2 读一个XML标注文件:每个字段都要心里有数

VOC的XML标注结构不复杂,但每个字段对后面的训练都有影响。用Python标准库就能直接解析,不用装额外依赖。

import xml.etree.ElementTree as ET from pathlib import Path xml_path = Path("Annotations") / "000001.xml" tree = ET.parse(xml_path) root = tree.getroot() print("filename:", root.findtext("filename")) size = root.find("size") width, height, depth = int(size.findtext("width")), int(size.findtext("height")), int(size.findtext("depth")) print("size:", width, height, depth) for obj in root.iter("object"): name = obj.findtext("name") truncated = obj.findtext("truncated") difficult = obj.findtext("difficult") box = obj.find("bndbox") xmin = int(box.findtext("xmin")) ymin = int(box.findtext("ymin")) xmax = int(box.findtext("xmax")) ymax = int(box.findtext("ymax")) print(name, "difficult:", difficult, "truncated:", truncated, "box:", (xmin, ymin, xmax, ymax))

这段代码把一张图里所有目标的类别名和边框坐标打出来。bndbox里是左上角(xmin, ymin)和右下角(xmax, ymax)的绝对像素值,这是VOC和YOLO格式最根本的差异点。difficult=1表示这个目标本身模糊难辨,训练时一般要跳过;truncated表示目标被图像边界截断,如果数据集里这类样本占比高,训练时imgsz不要设太小,否则截断目标的信息在下采样里丢得更狠。

提示:标注工具的版本差异会导致name字段大小写不统一,比如Safety_Helmet和safety_helmet同时存在。第一时间统一,否则后面类别映射必然错位。

2.3 先跑一个统计脚本,摸清每一类的真实标注量

训练翻车十次有八次是没做数据体检。类别数量分布、目标尺度分布这两个指标,直接决定后续训练参数怎么设。拿到数据包后,我第一件事永远是跑统计脚本。

import xml.etree.ElementTree as ET from pathlib import Path from collections import Counter, defaultdict ann_dir = Path("Annotations") xml_files = list(ann_dir.glob("*.xml")) cls_counter = Counter() size_stats = defaultdict(list) for xml_file in xml_files: tree = ET.parse(xml_file) root = tree.getroot() size = root.find("size") img_w = int(size.findtext("width")) img_h = int(size.findtext("height")) for obj in root.iter("object"): name = obj.findtext("name").strip() cls_counter[name] += 1 box = obj.find("bndbox") w = int(box.findtext("xmax")) - int(box.findtext("xmin")) h = int(box.findtext("ymax")) - int(box.findtext("ymin")) size_stats[name].append((w, h)) print("类别数量:", dict(cls_counter)) for cls, boxes in size_stats.items(): ws = [b[0] for b in boxes] hs = [b[1] for b in boxes] print(f"{cls}: 框数 {len(boxes)}, " f"平均宽 {sum(ws)/len(ws):.1f}, 平均高 {sum(hs)/len(hs):.1f}")

输出里重点看两处:第一处是类别数量,如果行人有两千个框、绝缘手套只有三百个,这就是典型的类别不平衡,后面训练时要针对性处理;第二处是平均宽高,如果手套和绝缘靴的平均边长在40像素以下,imgsz用默认640会非常吃力,必须考虑加大输入尺寸或者上切片推理。这一步跑完,你对这套数据的真实情况就有底了,而不是光看压缩包的大小猜。

3. 把VOC转成YOLO格式:归一化脚本与三个转换边界

3.1 为什么YOLO要的是txt而不是XML

YOLO系列训练时不认XML,它要的是每个标注文件对应一个txt,每行五个数:class_id x_center y_center width height,坐标全部归一化到0到1之间。这和VOC的绝对像素坐标是两套体系。

归一化坐标的好处是让模型跟图像分辨率解耦,不管输入是640还是1280,标签值都在同一个量纲里。市面上YOLO工具链基本都遵循这个约定。如果用的是Ultralytics的YOLOv8,数据集目录里放一份images和labels,再配一个data.yaml就能开训。很多标注工具导出时可以直接选YOLO格式,但手里这套已经是VOC XML,再用脚本批量转换是最可控的路线,顺便能把脏数据过滤掉。

3.2 先扫出XML里真实的类别名

不同标注团队对同一类目标的命名习惯不一样。行人可能叫person也可能叫pedestrian,安全帽可能叫safety_helmet也可能叫helmet或者hard_hat。在写转换脚本之前,先把所有XML里的name字段扫一遍,确定实际存在的类别名集合。

import xml.etree.ElementTree as ET from pathlib import Path names = set() for xml_file in Path("Annotations").glob("*.xml"): for obj in ET.parse(xml_file).getroot().iter("object"): names.add(obj.findtext("name").strip()) print(sorted(names))

set会自动去重,把所有出现过的类别名都打印出来。看到结果后,再定义类别映射表,把同义的不同拼写映射到同一个id。我按最常见的标注习惯给一份默认映射,实际以你扫出来的结果为准:

"person": 0, "pedestrian": 0 "insulated_boots": 1, "rubber_boots": 1 "insulated_gloves": 2 "work_clothes": 3 "safety_helmet": 4, "helmet": 4, "hard_hat": 4

这一步千万别省。直接拿别人现成的映射表套用,遇到XML里有个Safety helmet带空格的,转换脚本就会漏掉这一类目标,而且不会有任何报错。

3.3 坐标转换与过滤脚本

转换脚本的核心就三件事:读XML、算归一化坐标、写txt。但实际跑的时候,越界坐标和脏框才是最大的坑。

import xml.etree.ElementTree as ET from pathlib import Path ann_dir = Path("Annotations") img_dir = Path("JPEGImages") out_dir = Path("labels") out_dir.mkdir(exist_ok=True) cls_map = { "person": 0, "pedestrian": 0, "insulated_boots": 1, "rubber_boots": 1, "insulated_gloves": 2, "work_clothes": 3, "safety_helmet": 4, "helmet": 4, "hard_hat": 4 } min_size = 3 # 过滤宽或高小于3像素的脏框 for xml_file in ann_dir.glob("*.xml"): tree = ET.parse(xml_file) root = tree.getroot() img_name = root.findtext("filename") size = root.find("size") img_w = int(size.findtext("width")) img_h = int(size.findtext("height")) txt_path = out_dir / (xml_file.stem + ".txt") lines = [] for obj in root.iter("object"): name = obj.findtext("name").strip() if name not in cls_map: print(f"跳过未知类别 {name} in {xml_file.name}") continue if obj.findtext("difficult") == "1": continue box = obj.find("bndbox") xmin = int(box.findtext("xmin")) ymin = int(box.findtext("ymin")) xmax = int(box.findtext("xmax")) ymax = int(box.findtext("ymax")) # 校正越界坐标,防止负值或超出图片宽高 xmin = max(0, xmin) ymin = max(0, ymin) xmax = min(img_w, xmax) ymax = min(img_h, ymax) if xmax <= xmin or ymax <= ymin: continue w = xmax - xmin h = ymax - ymin if w < min_size or h < min_size: continue x_center = (xmin + w / 2) / img_w y_center = (ymin + h / 2) / img_h w_norm = w / img_w h_norm = h / img_h lines.append(f"{cls_map[name]} {x_center:.6f} {y_center:.6f} {w_norm:.6f} {h_norm:.6f}") txt_path.write_text("\n".join(lines), encoding="utf-8")

几个关键点说明一下。min_size = 3是我习惯用的过滤阈值,比3像素还小的框放到训练里只会变成噪声,让模型去拟合根本没法辨认的目标。difficult字段在VOC里表示难例,转YOLO标签前跳过是标准做法。越界校正必须做,人工标注时鼠标一抖就可能把xmax拖到图片宽度之外,不修正的话YOLO训练会报边界错误,或者框的位置发生偏移。归一化公式里分子用的是中心点坐标,除以图片宽高后得到0到1之间的值,这跟图片分辨率无关,也是YOLO能支持多尺度训练的前提。

转换完成后,labels目录里每个txt的文件名应该和对应的图片文件名一致。可以用一条命令快速核查:find labels -name "*.txt" | wc -l和find Annotations -name "*.xml" | wc -l对比数量。数量对不上,就去翻print出来的未知类别日志。

3.4 转换结果自检:拿一张图照着框画一遍

转换脚本跑完不代表万事大吉,我见过不少人直接开训,训练到一半发现某个框偏了十万八千里。把txt里的归一化坐标画回图片上,肉眼比对一遍,这一步两分钟能解决的问题能省后面排查两小时。

import cv2 from pathlib import Path labels_dir = Path("labels") img_dir = Path("JPEGImages") names = ["person", "insulated_boots", "insulated_gloves", "work_clothes", "safety_helmet"] colors = [(0, 0, 255), (0, 255, 0), (255, 0, 0), (0, 255, 255), (255, 255, 0)] img_path = img_dir / "000001.jpg" img = cv2.imread(str(img_path)) h, w = img.shape[:2] for line in (labels_dir / "000001.txt").read_text().strip().splitlines(): cid, 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), colors[int(cid)], 2) cv2.putText(img, names[int(cid)], (x1, y1 - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, colors[int(cid)], 1) cv2.imwrite("check_000001.jpg", img)

画框后重点看三处:一是框有没有明显偏移,比如框住了人但标签写的是绝缘手套;二是类别标签有没有错位,比如安全帽的框标成了person;三是小目标有没有被误删。如果随机抽五张图都没问题,再进训练流程。这一步是血泪经验换来的,我早期跳过自检直接训练,结果模型在验证集上mAP虚高,部署到现场才发现标签和图像内容根本没对齐。

4. 用YOLOv8训练自己的数据集:yaml配置、关键参数与结果判读

4.1 data.yaml 怎么写:路径、类别名、train/val划分

数据转换完之后,接下来就是配Ultralytics YOLOv8的训练入口。这个入口就是data.yaml,它告诉训练器数据放哪、有几类、类别叫什么。

path: /data/ppe train: images/train val: images/val names: 0: person 1: insulated_boots 2: insulated_gloves 3: work_clothes 4: safety_helmet

path是数据集根目录,train和val是相对根目录的图像路径,对应的标签目录会自动从images换成labels,这是YOLO工具链的约定,不需要在yaml里显式写labels路径。names的顺序必须和第3章定义的cls_map完全一致,否则模型训练时类别就错乱了。

注意:path最好写绝对路径。写相对路径的话,训练命令必须在path目录下执行,很多新手在这上面翻车。

目录结构上,我习惯把train和val的图片分开放,train图片放images/train,对应标签放labels/train。如果数据包自带的ImageSets/Main里已经有划分,直接用现成的划分文件;没有的话,自己按第5章的按场景划分方法生成。

4.2 训练命令:从预训练权重开始而不是从零

训练命令本身不长,但参数的含义得讲清楚,尤其batch、imgsz和patience这三个。

yolo detect train \ model=yolov8s.pt \ data=/data/ppe/ppe.yaml \ epochs=100 \ imgsz=640 \ batch=16 \ device=0 \ patience=20 \ lr0=0.01 \ project=runs/ppe \ name=exp1

model=yolov8s.pt意思是加载COCO预训练权重,在这个基础上做迁移学习,而不是随机初始化从零训起。电力场景的目标和COCO里的person有一些重叠,迁移学习能省下不少训练时间。imgsz=640是默认分辨率,如果第2.3节统计出绝缘手套的平均框宽小于50像素,建议直接用960,但显存占用会明显上涨,需要量力而行。batch=16在12GB显存的显卡上是安全值,显存不够降到8。patience=20表示验证集指标连续20轮不提升就提前停止训练,能避免无效的空转。lr0=0.01是初始学习率,迁移学习场景下这个取值是相对稳妥的默认值。

首次训练前可以用一条命令快速验证数据集格式是否正确:yolo detect train model=yolov8s.pt data=ppe.yaml epochs=1。跑一个epoch,训练器会打印出每个类别的目标数量,如果某个类别的instances是0,说明转换环节有目标被漏掉了,先回去查第3章的转换日志,别把时间浪费在逐步排查上。

4.3 训练过程看什么:loss曲线、mAP50、混淆矩阵

训练结束后,输出目录runs/ppe/exp1/下有一堆可视化结果,我按优先级看三个文件。第一个是results.png,里面包含train loss和val loss曲线。如果loss曲线在某个epoch后掉头向上,说明过拟合已经开始,回头用早停前的权重。第二个是confusion_matrix.png,这是最能暴露问题的一张图。重点关注绝缘靴和绝缘手套这两个类别的行,如果对角线上的数值明显低于其他类别,说明这两个小目标类别的召回率不够,需要调整输入尺寸或者数据增强策略。第三个是PR_curve.png,看曲线下面积大的类别是哪些,跟混淆矩阵互相印证。

很多人只看mAP50一个数字,但PPE场景里的帽子错检和行人漏检是两种完全不同的严重程度。对一个安全管控系统来说,安全帽漏检会导致未戴帽的工人被放过,这是比误报严重得多的问题。所以检视结果时,我会把每一类的precision和recall单独列出来看,而不是只看总量。

4.4 模型档位选择:n、s、m三个档位的取舍

YOLOv8系列有n、s、m、l、x几个档位,参数从少到多,精度和推理成本也跟着涨。在电力穿戴检测这个场景里,我一般只推荐前三个。

档位特点在这个数据集上的建议
yolov8n速度最快,精度最低只用来验通流程
yolov8s精度和速度平衡优先选择
yolov8m小目标表达能力更强绝缘靴、手套漏检多时升级

如果第2.3的统计结果显示小目标占比高,直接用s起步会更稳。n档在边缘设备上做实时推理可以用,但作为训练起点容易让小目标检测效果打折扣。m档对小目标的特征表达更好,代价是训练和推理时间增加。我的一般路径是s跑通验证,再上m看收益,不推荐一上来就用x档位,训练慢且提升不明显。

5. 常见问题排查:标注数据从训练到验证的五个坑

说实话,在电力安全穿戴检测上拆过的数据包不少,训练跑崩的案例里,根因多半不是模型结构,而是标注数据在转换和划分阶段埋下的雷。下面五条是按出现频率排的,每条都是实际踩过的坑。

5.1 类别错位整整齐齐:训练loss正常但预测全错

现象:训练过程中loss正常下降,但推理时person被识别成safety_helmet,所有类别整体错了一位,错得特别规整。

原因:txt里的class_id和data.yaml的names顺序对不上。比如转换脚本的cls_map把person设为0,data.yaml里0却写成了safety_helmet,模型训练的标签和语义标签完全错位。

解决:把第3.2节扫出来的类别名映射表,和data.yaml里的names列表逐行对照,确认0到4每个id对应同一个类别名。正确做法是,先定names顺序,再按这个顺序生成cls_map,别两头分开定义。

5.2 转换时报错或class_id超出范围

现象:训练启动时报class id out of range之类的错误,或者某个类别的实例数变成0。

原因:XML里存在未知类别名。如果转换脚本里没有做name not in cls_map的过滤,而是用defaultdict库自动分配新id,类别映射表会被撑爆,class_id直接超出数据集的类别数。

解决:用第3.2节的扫描脚本重新列出全部name字段,重点看是否有大小写混用、首尾空格这些脏值。比如"Helmet"和"helmet"是两个类别,"person "和"person"也是两个类别。把替换规则写进cls_map后再跑转换,跑完重新统计每个类别的框数量确认没漏。

5.3 绝缘靴和绝缘手套的mAP怎么都上不去

现象:其他三类mAP到了80以上,绝缘手套只有50上下,差距稳定且明显。调大epoch数也没用。

原因:这类目标本身尺度小,又叠加样本数量少。640分辨率下经过模型多次下采样,到最后一层特征图上手套区域只剩几个像素,特征基本丢失。类别不平衡让模型把注意力都放在了行人这类大目标上。

解决:先试imgsz提升到960或1280,显存占用会涨,但小目标特征保留会更完整。再用mosaic增强配合更高的copy_paste增强参数。如果还不行,推理阶段上切片推理,这个在第6章展开。

5.4 验证指标虚高:同一场景的连续帧被同时分进train和val

现象:训练loss正常,验证mAP高得离谱,但一部署到现场视频马上漏检。

原因:这类数据集很多是从连续视频里按帧抽取的,相邻帧内容几乎一样。如果随机划分train和val,同一段视频的相似帧会同时出现在两边,模型相当于提前见过验证集,指标虚高是必然的。

解决:按场景分组划分,保证同一个场景的所有帧只落在train或val单侧。如果原始数据的图片文件名里带场景号或目录名,就用它做分组依据。

from pathlib import Path import random img_paths = list(Path("images").glob("*.jpg")) scene_ids = sorted({p.parent.name for p in img_paths}) random.seed(42) random.shuffle(scene_ids) val_count = int(len(scene_ids) * 0.2) val_scenes = set(scene_ids[:val_count]) train_files = [p for p in img_paths if p.parent.name not in val_scenes] val_files = [p for p in img_paths if p.parent.name in val_scenes] with open("train.txt", "w") as f: f.write("\n".join(str(p) for p in train_files)) with open("val.txt", "w") as f: f.write("\n".join(str(p) for p in val_files))

按场景ID划分后,val指标虽然会降一点,但那才是模型上现场后的真实水平。划分完记得跑一下两个文件列表的图片数量占比,20%左右是常规做法。

5.5 NMS把挨在一起的工人框合并成一个

现象:几个工人站在一起区域重叠,推理结果只出一个框,人数明显少检。

原因:NMS的执行逻辑是,两个框的IOU超过阈值就认为它们是同一个目标,保留置信度高的那个。工人密集站立时,相邻目标的框高度重叠,默认阈值0.45就误杀了。这是后处理问题,不是数据质量问题。

解决:推理时把NMS的IOU阈值降到0.35到0.3,让判定更严格,重叠的框不会被轻易合并。同时把置信度阈值提高到0.25以上,避免调低IOU后出现一堆低质量的重复框。如果用的Ultralytics推理接口,对应参数是iou=0.35和conf=0.25。注意这个调整只影响推理,不用重新训练。

6. 小目标漏检的后悔药:切片推理验证与调优

模型训练完,mAP50好看不等于现场好用。绝缘靴和绝缘手套这类小目标,经过模型的多层下采样,在最后几层特征图里只剩下几个像素的响应,整图推理时很难从背景里捞出来。我的习惯是,训练结束后不急着部署,先用切片推理过一遍大图和现场视频,把模型在小目标上的真实水平摸清楚。

切片推理的思路是把一张大图按固定尺寸切成若干块,每块分别送入模型检测,再把所有块的检测结果合并回去。切块之后目标在图像里的相对面积变大,小目标的特征不再会被下采样吃掉。这里直接用一个成熟的开源库sahi,它专门做切片推理,支持Ultralytics的模型。

from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction det_model = AutoDetectionModel.from_pretrained( model_type="ultralytics", model_path="runs/ppe/exp1/weights/best.pt", confidence_threshold=0.25, image_size=640, device="cuda:0", ) result = get_sliced_prediction( image="JPEGImages/000001.jpg", detection_model=det_model, slice_height=256, slice_width=256, overlap_height_ratio=0.2, overlap_width_ratio=0.2, ) result.export_visuals(export_dir="sliced_out/")

slice_height和slice_width是切块的尺寸,小目标密集时用256比640效果更稳。overlap_ratio设为0.2,保证目标不会被切成两半后丢掉,切块之间会有重复预测,sahi会自动用NMS合并。运行前先pip install sahi。环境里如果没有GPU,device参数改成cpu也能跑,只是慢一些。

切片推理在所有小目标检测场景里都适用,不只是这套数据。第一次跑完,对比整图推理和切片推理在绝缘靴上的检出数量,你会发现差距非常明显。从那以后我每次训练完,都会先对几张典型大图和一段现场视频做切片推理,确认小目标真实水平后再决定要不要调大imgsz或者换m档模型,而不是直接看best.pt就收工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询