五类安全穿戴目标检测:VOC数据集转换与YOLOv8训练全流程
简介面向深度学习与计算机视觉开发者的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格式最根本的差异点。difficult1表示这个目标本身模糊难辨训练时一般要跳过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而不是XMLYOLO系列训练时不认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_okTrue) 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), encodingutf-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_helmetpath是数据集根目录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 \ modelyolov8s.pt \ data/data/ppe/ppe.yaml \ epochs100 \ imgsz640 \ batch16 \ device0 \ patience20 \ lr00.01 \ projectruns/ppe \ nameexp1modelyolov8s.pt意思是加载COCO预训练权重在这个基础上做迁移学习而不是随机初始化从零训起。电力场景的目标和COCO里的person有一些重叠迁移学习能省下不少训练时间。imgsz640是默认分辨率如果第2.3节统计出绝缘手套的平均框宽小于50像素建议直接用960但显存占用会明显上涨需要量力而行。batch16在12GB显存的显卡上是安全值显存不够降到8。patience20表示验证集指标连续20轮不提升就提前停止训练能避免无效的空转。lr00.01是初始学习率迁移学习场景下这个取值是相对稳妥的默认值。首次训练前可以用一条命令快速验证数据集格式是否正确yolo detect train modelyolov8s.pt datappe.yaml epochs1。跑一个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设为0data.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推理接口对应参数是iou0.35和conf0.25。注意这个调整只影响推理不用重新训练。6. 小目标漏检的后悔药切片推理验证与调优模型训练完mAP50好看不等于现场好用。绝缘靴和绝缘手套这类小目标经过模型的多层下采样在最后几层特征图里只剩下几个像素的响应整图推理时很难从背景里捞出来。我的习惯是训练结束后不急着部署先用切片推理过一遍大图和现场视频把模型在小目标上的真实水平摸清楚。切片推理的思路是把一张大图按固定尺寸切成若干块每块分别送入模型检测再把所有块的检测结果合并回去。切块之后目标在图像里的相对面积变大小目标的特征不再会被下采样吃掉。这里直接用一个成熟的开源库sahi它专门做切片推理支持Ultralytics的模型。from sahi import AutoDetectionModel from sahi.predict import get_sliced_prediction det_model AutoDetectionModel.from_pretrained( model_typeultralytics, model_pathruns/ppe/exp1/weights/best.pt, confidence_threshold0.25, image_size640, devicecuda:0, ) result get_sliced_prediction( imageJPEGImages/000001.jpg, detection_modeldet_model, slice_height256, slice_width256, overlap_height_ratio0.2, overlap_width_ratio0.2, ) result.export_visuals(export_dirsliced_out/)slice_height和slice_width是切块的尺寸小目标密集时用256比640效果更稳。overlap_ratio设为0.2保证目标不会被切成两半后丢掉切块之间会有重复预测sahi会自动用NMS合并。运行前先pip install sahi。环境里如果没有GPUdevice参数改成cpu也能跑只是慢一些。切片推理在所有小目标检测场景里都适用不只是这套数据。第一次跑完对比整图推理和切片推理在绝缘靴上的检出数量你会发现差距非常明显。从那以后我每次训练完都会先对几张典型大图和一段现场视频做切片推理确认小目标真实水平后再决定要不要调大imgsz或者换m档模型而不是直接看best.pt就收工。希望帮到你。本文还有配套的精品资源点击获取