☰
矿井安全帽检测实战:COCO JSON数据集转换与YOLOv8训练调优
2026/10/2 3:21:10 网站建设 项目流程

简介:面向矿山安全监控与工业视觉场景的COCO格式标注数据集,覆盖安全帽、指示器、人员、自救器四类目标,可用于目标检测、实例分割模型训练与算法验证,实测识别率达96.3%。包体共2000个文件,包含1997张矿井现场JPG图像与3个JSON标注文件,压缩包大小约294.93MB;JSON为标准COCO格式,包含类别信息、边界框与分割标注,可直接对接YOLO、MMDetection等常用框架。目前已有492人学习下载。数据集以真实井下环境图像为主,兼顾不同光照、角度和遮挡条件,既可作为算法落地的训练样本,也可用于模型鲁棒性测试;3个JSON文件已按典型划分组织,便于快速划分训练/验证集,配套博客对标注细节和文件组织进行了说明,能帮助使用者高效开展安全帽佩戴检测、人员定位及救援设备监测等任务。

1. 矿井下带 COCO JSON 标注的数据集:为什么安全帽检测要拿它起步

做矿山安全行为识别,第一道坎永远是数据:井下低照度、粉尘多、设备遮挡密,通用场景训出来的安全帽模型下井就翻车。这套以 COCO JSON 格式带标注发布的矿井下数据集,覆盖安全帽、指示器、人、自救器四类目标,报告识别率 96.3%。对正在做矿山智能巡检、安全帽佩戴检测、井下人员管理的算法工程师和研究者来说,它解决的是两件事:一是拿一套真实井下分布的数据做基线,二是先跑通从 JSON 标注到训练验证的完整流程,省掉从截帧清洗到人工标注的原始积累。下面按我实际做这个方向的顺序,把格式、转换、训练和踩坑一次讲透。

2. 拆解 COCO JSON 标注:五个顶层字段与一段类别统计代码

2.1 COCO 标注的五个顶层字段,井下数据集里怎么存

COCO JSON 不是一种固定 schema,而是一个结构约定。绝大多数目标检测数据集只要挂在 COCO 名下,顶层就会包含五个字段:info、licenses、images、annotations、categories。矿井这份数据集也不例外,只是把categories换成了安全帽、人、自救器和指示器这四类。

info里一般写数据集的描述、版本和 contributor,拿到数据集第一件事就是看这里,确认是不是你要的井下场景,别等训练完才发现混了地面矿区的帧。licenses声明的授权方式决定你能不能拿它做商用项目,如果是内部标注没公开授权,落地前要找数据提供方补手续。images是图像表,每一条记录id、file_name、width、height,后面做转换脚本全靠它做图片尺寸映射。annotations是核心的框表,每个目标一条记录:image_id指向图像,category_id指向类别,bbox是[x, y, width, height]四元组,左上角坐标加宽高,area在过滤小目标时有用,iscrowd标记重叠目标。categories是类别表和supercategory的映射。

打开这种 JSON 不要用记事本,几十兆的文件记事本直接卡死,VSCode 可以预览,但真要统计或者清洗,老老实实写 Python。习惯用命令行的,jq '.categories' instances_mining.json也能快速看类别结构。记住一个原则:COCO JSON 是给程序读的,不是给人读的。

2.2 读 COCO JSON:类别统计脚本与四个可调点

假设解压后的目录结构长这样:annotations/instances_mining.json是标注文件,images/下是全部井下监控帧。我先跑一段最基础的统计脚本,确认四类目标的数量分布和有无空图。

import json from collections import Counter from pathlib import Path coco_path = Path("annotations/instances_mining.json") with open(coco_path, encoding="utf-8") as f: coco = json.load(f) # categories 的 id 在不同工具里可能从 0 或 1 开始,先打印出来看 cat_map = {c["id"]: c["name"] for c in coco["categories"]} print("类别映射:", cat_map) # 统计每个类别的标注框数量 ann_counter = Counter() per_image = Counter() for ann in coco["annotations"]: ann_counter[cat_map[ann["category_id"]]] += 1 per_image[ann["image_id"]] += 1 for name, count in ann_counter.most_common(): print(f"{name}: {count} 个框") # 找出没有标注的图片 img_ids = {img["id"] for img in coco["images"]} ann_img_ids = {ann["image_id"] for ann in coco["annotations"]} empty_imgs = img_ids - ann_img_ids print("无标注图片数:", len(empty_imgs))

这段脚本的逻辑不复杂:先建立category_id到类别名的映射,再遍历所有标注,按类别计数,同时统计每张图的目标数量。参数上要注意两点。第一,encoding="utf-8"必须带,Windows 下默认编码可能不是 UTF-8,json 里如果带中文类别名,不加这个参数直接报错。第二,Counter统计完以后重点看两个数:类别分布是否极端不平衡、空图数量是否过大。安全帽和人在井下监控里通常每个视频都有,但自救器和指示器可能只在特定区域出现,数量会少一个量级,这是后面训练类别不平衡的根源。

顺带看一眼per_image的平均目标密度。我遇到过一份井下数据集,平均每帧只有 1.2 个目标,这种稀疏数据训练出来的模型泛化能力很差,因为模型没见过密集遮挡场景。如果统计出来平均目标数小于 2,后续要考虑把多帧拼接成一张图再训练,或者干脆补标注。

2.3 标注质量自检:bbox 越界、面积为 0、类别 ID 从 0 开始

标注工具导出的 COCO JSON 经常带着三个隐蔽问题:bbox 越界、面积字段和实际框不匹配、类别 ID 不从 1 开始。先解释类别 ID:COCO 官方规定id从 1 开始,但很多标注工具导出时从 0 开始。你的框架如果按 0 处理没问题,但有些老版本解析器默认从 1 开始,就会出现安全帽这一类整个消失的诡异现象。拿到数据集先打印categories就为了确认这件事。

bbox 越界更常见。井下监控画面里,人刚从画面边缘走进来时,标注员经常把框画到图像外面,于是 bbox 出现负坐标或者超出宽高。训练时归一化到 0~1 之后,负坐标变成负数,loss 直接 NaN。最稳妥的做法是转换时做一次越界检查:

bad_boxes = 0 for ann in coco["annotations"]: x, y, w, h = ann["bbox"] img = next(i for i in coco["images"] if i["id"] == ann["image_id"]) if x < 0 or y < 0 or w <= 0 or h <= 0: bad_boxes += 1 continue if x + w > img["width"] or y + h > img["height"]: bad_boxes += 1 print("越界或非法框数量:", bad_boxes)

逻辑是按image_id找到对应图像尺寸,然后逐项判断 bbox 是否合法。这个检查和面积无关,area字段经常不准,尤其是标注工具自动计算和人工调整框之后不重算的情况。筛选小目标时直接用w * h重新算面积,不要信 JSON 里的area。

3. 把 COCO JSON 转成 YOLO 可训练格式:转换脚本与划分策略

3.1 为什么检测训练器更喜欢 YOLO txt 而不是直接用 COCO

ultralytics 这类框架能直接吃 COCO JSON,但内部还是会先把它拆成每张图一个 label txt 再进 dataloader。既然早晚要拆,我一般选择在转换阶段自己拆,而不是依赖框架内置的解析器。原因有三个:第一,内置解析器不支持类别重映射,如果标注文件的category_id不连续,转出来类别就乱了;第二,转换时可以做框过滤和越界处理,框架内置的逻辑不会帮你检查这些;第三,拆出来的 txt 标签是纯文本,换框架、做数据增强、人工抽检都方便,不用每次都被 COCO 解析器版本卡住。

YOLO 的 txt 格式每行对应一个目标,字段是class x_center y_center width height,全部归一化到 0~1 之间。归一化坐标系是图像宽高比例,不是像素坐标,很多新手在这里翻车,把像素坐标直接写进 txt,训练时模型永远收敛不了。记住一句口诀:COCO 是像素绝对坐标,YOLO 是归一化相对坐标。

3.2 COCO JSON 转 YOLO txt:转换脚本与归一化边界

下面这段脚本是我常用的转换逻辑,可以直接复制改路径用。

import json from pathlib import Path def coco_to_yolo(coco_path, out_dir): with open(coco_path, encoding="utf-8") as f: coco = json.load(f) # 按 categories 出现顺序建映射,保证类别 ID 从 0 开始且连续 cat_ids = sorted({c["id"] for c in coco["categories"]}) cat_map = {cid: i for i, cid in enumerate(cat_ids)} print("重映射后的类别:", {i: c["name"] for c in coco["categories"] for i in [cat_map[c["id"]]]}) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) for img in coco["images"]: w, h = img["width"], img["height"] anns = [a for a in coco["annotations"] if a["image_id"] == img["id"]] if not anns: continue # 空图不生成 txt,后续训练会跳过 txt_path = out_dir / (Path(img["file_name"]).stem + ".txt") lines = [] for a in anns: cls_id = cat_map[a["category_id"]] x, y, bw, bh = a["bbox"] # 像素坐标转归一化坐标 cx, cy = (x + bw / 2) / w, (y + bh / 2) / h nw, nh = bw / w, bh / h # 越界裁剪到 [0, 1],防止 loss 异常 cx, cy = max(0, min(1, cx)), max(0, min(1, cy)) nw, nh = max(0, min(1, nw)), max(0, min(1, nh)) if nw < 1e-6 or nh < 1e-6: continue lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") if lines: txt_path.write_text("\n".join(lines), encoding="utf-8") print(f"处理完成,共 {len(coco['images'])} 张图")

参数说明:cat_map用排序后的category_id重新编号,是为了让类别 ID 从 0 开始连续递增,data.yaml里的names列表要按这个顺序写。cx和cy是中心点坐标,由x + bw / 2算出,/w和/h是归一化。最后一段max(0, min(1, ...))做边界裁剪,目的就是处理 2.3 里提到的越界框。裁剪而不是丢弃,是因为多数越界框只是边缘目标的一部分,丢掉会损失标注信息。

执行完检查一下输出目录,抽几张图看看 txt 里的坐标是否大致落在对应目标上。这一步不要省,我就见过转换脚本把x + bw / 2写成x - bw / 2的,所有框偏移了整整一个框宽,训练出来 mAP 直接掉 20 个点。

3.3 训练集/验证集划分:按视频源分组而不是随机打散

划分数据集是决定模型真实水平的关键步骤,也是最容易被忽视的一步。井下视频是连续帧,同一场景的相邻帧非常相似。如果随机划分,训练集和验证集会混入同一个视频的相似帧,验证集 mAP 虚高,部署时换个机位立刻现原形。

正确做法是按视频源分组。假设文件名形如cam01_000123.jpg,cam01是摄像头编号,000123是帧号,那按前缀划分就能保证同一摄像头的帧全部进同一个集合。

import random from pathlib import Path random.seed(42) frames = list(Path("images").glob("*.jpg")) video_groups = {} for p in frames: video_id = p.stem.split("_")[0] video_groups.setdefault(video_id, []).append(p) video_ids = list(video_groups.keys()) random.shuffle(video_ids) split_idx = int(len(video_ids) * 0.8) train_videos = set(video_ids[:split_idx]) val_videos = set(video_ids[split_idx:]) train_txt = Path("train.txt") val_txt = Path("val.txt") with train_txt.open("w", encoding="utf-8") as ft, val_txt.open("w", encoding="utf-8") as fv: for vid, paths in video_groups.items(): target = ft if vid in train_videos else fv for p in paths: target.write(str(p.resolve()) + "\n")

逻辑是把视频 ID 作为分组键,先对视频列表做 shuffle,再按 8:2 切分,最后把所有帧路径写入对应的 txt。参数上,random.seed(42)保证可复现,换 seed 划分结果会变。如果数据集本身是按井下不同巷道采集的,建议在视频 ID 之外再把巷道 ID 也作为分组依据,否则不同巷道的光照条件差异会直接泄漏到验证集里。

3.4 面向矿井场景的增强参数:低照度、粉尘与贴脸遮挡

井下数据集的典型问题是光照分布不均匀:有的摄像头对着采掘面,亮度尚可;有的对着巷道深处,基本是暗光。模型要在这种环境下稳定工作,数据增强不能照搬 COCO 预训练那套。

我通常用 ultralytics 训练时这样调整增强参数:关闭mosaic到 0.5,而不是用默认的 1.0。井下摄像头安装高度固定,安全帽这类小目标的尺度变化没有自然场景那么剧烈,过强的 mosaic 会把目标切成碎片,反而让模型学到残缺目标。degrees调到 5,因为摄像头几乎不旋转,旋转增强幅度太大产生的样本不符合真实分布。hsv_h设 0.02、hsv_s设 0.4、hsv_v设 0.2,给模型一点颜色扰动空间,应对不同巷道的色温差异,但又不能太大,否则暗光下安全帽的黄色会被扭曲成别的颜色,模型把黄色当核心特征就麻烦了。

需要说明的是,mosaic这类增强开关是在训练配置里传的,不同版本的 ultralytics 参数名略有差异,但hsv_h、hsv_s、mosaic、degrees这些是通用参数,改数值即可。

4. 训练到 96.3% 的配置参数:从 YOLOv8 到矿井场景调优

4.1 先确认指标口径:96.3% 是 mAP@0.5 还是准确率

目标检测领域说的"识别率"是个模糊说法,落到指标上有三个常见口径:分类准确率、mAP@0.5、mAP@0.5:0.95。报告里的 96.3%,大概率是mAP@0.5,也就是 IoU 阈值取 0.5 时所有类别的平均精度。这个指标对定位精度要求不苛刻,框稍微偏一点也能判对,所以看起来数值很高。

自己复现时,不要拿验证集里挑几张图数对错,要跑完整的验证流程。用 ultralytics 执行:

yolo detect val model=runs/detect/train/weights/best.pt data=mining.yaml

命令行会输出mAP50(B)和mAP50-95(B)两个关键值。mAP50对齐报告的 96.3%,mAP50-95是 IoU 从 0.5 到 0.95 步进 0.05 的平均,这个数通常在 0.6~0.75 之间。如果mAP50能到 0.96 而mAP50-95只有 0.6,说明框的位置还行但精度不足,部署时如果业务方要求框贴合目标,需要调高 IoU 阈值或者换更大模型。

验证集本身也要干净,如果验证集里混了没有标注的帧,或者标注框质量差,mAP50会失真。这也是为什么前面强调空图过滤和越界检查要在转换时一并处理。

4.2 GPU 训练配置:YOLOv8 在矿井数据上的训练参数

井下数据集规模通常不会太大,几百到两三千张图是常态。模型方面,yolov8s 起步就够用,数据量少于一千张时用 yolov8m 反而容易过拟合。输入尺寸建议直接上 1280,不要用默认的 640。井下监控普遍是 1080p,一个戴帽人头在画面里大约 40×50 像素,缩到 640 就只有 20×25,已经进入小目标范畴,检测难度陡增。

yolo detect train \ data=mining.yaml \ model=yolov8s.pt \ epochs=100 \ imgsz=1280 \ batch=16 \ lr0=0.005 \ mosaic=0.5 \ degrees=5 \ hsv_h=0.02 \ hsv_s=0.4 \ hsv_v=0.2 \ patience=20

mining.yaml的内容是数据路径和类别名,注意类别顺序必须和转换脚本里的cat_map重映射结果一致:

path: /path/to/mining_dataset train: images/train val: images/val names: 0: helmet 1: person 2: self_rescuer 3: indicator

参数说明:batch=16是在 24GB 显存下的常见选择,显存小就降到 8,但不要低于 4,否则 BatchNorm 统计不稳定,井下暗光帧尤其明显。lr0=0.005配合默认的 warmup 3 epochs,预训练权重不容易被冲坏。patience=20表示 20 个 epoch 验证集 mAP 不涨就早停,井下数据噪声大,早停阈值比通用场景放宽一点,避免在第 30 个 epoch 因为指标波动被误停。

4.3 类别不平衡与置信度阈值:小目标"指示器"的处理

四类目标里,安全帽和人的数量通常最多,自救器和指示器少。类别不平衡直接表现为两个类 AP 很高,另外两个 AP 很低。先看results.csv里每个类别的 AP,如果indicator的 AP 明显低于其他类,需要针对性处理。

一个常见做法是给少数类做复制粘贴增强:从训练集中把指示器目标连同背景 patch 裁下来,随机粘贴到其他图上。这种增强对静止安装在设备上的指示器有效,因为它在真实场景里的姿态变化本来就小。另一招是提升这类目标的置信度阈值权重,让模型对指示器的召回更激进。推理时通过conf参数控制:

yolo detect predict model=best.pt source=val_images conf=0.25 iou=0.5 imgsz=1280

conf=0.25是全局阈值,如果指示器类别漏检严重,可以把阈值降到 0.2,代价是安全帽类会多出一些误检。更精细的做法是分两类跑推理,安全帽和人的conf用 0.3,指示器单独跑一次conf=0.15,再把结果合并。虽然麻烦一点,但四类目标的语义差异太大,一个全局阈值很难同时满足小目标和常规目标。

5. 矿井数据训练避坑:四条从标注到收敛的血泪经验

5.1 现象:json 解析正常,训练完 helmet 类 AP 为 0

原因:标注文件里的category_id从 0 开始编号,训练框架内部解析时把它当成无效类别直接过滤。矿井数据集来源复杂,不同标注工具导出的 COCO 格式在类别起始编号上不统一。

解决:训练前先在转换脚本里打印categories的完整映射,确认id起始值。转换到 YOLO 格式时用sorted重新编号,让类别 ID 从 0 开始连续排布,同时在data.yaml里按同样顺序写names。我碰到过最隐蔽的情况是categories列表里的id是[0, 2, 5, 8],直接映射到 YOLO 后类别变成[0, 1, 2, 3],中间的类别名全错位了,所以重映射那一步不能省。

5.2 现象:训练时 loss 出现 NaN,或验证时检测框整体偏移

原因:标注框里有负坐标或者超宽超高的越界框,转换时没有做归一化裁剪。COCO 格式里 bbox 是像素坐标,负值在除以图像宽高后会变成负数,损失函数计算时直接异常。

解决:在转换脚本的归一化之后显式做max(0, min(1, ...))裁剪。注意不要只裁剪中心坐标,宽度和高度同样要裁剪。另外检查有没有w或h为 0 的空框,这类框在标注工具里通常是被误拖拽产生的,直接丢弃。我在 2.3 里的越界检查脚本就是为这个问题准备的,每次拿到新数据集先跑一遍再转换。

5.3 现象:验证集 mAP 很高,但部署到新摄像头上漏检严重

原因:划分数据集时随机打散,同一视频的相似帧同时出现在训练集和验证集里,验证结果虚高。井下视频帧间差异小,随机划分的数据泄漏在数值上可以轻松拉高 5 个点的 mAP。

解决:按视频源分组划分,训练集和验证集不共享任何摄像头或巷道的数据。更进一步,把不同光照条件的巷道编号作为分组键下的二级分类,确保验证集覆盖暗光、逆光、粉尘多种场景,而不是只有和训练集相近的亮度分布。部署前用一段全新视频做测试,这段视频的帧绝不能进过训练集或验证集。

5.4 现象:指示器类别 AP 只有 0.2,其余三类都在 0.95 以上

原因:指示器目标体积极小,只占画面的 0.1%~0.5%,且数量远少于安全帽,训练时对 loss 的贡献被淹没。更麻烦的是它经常和设备边缘重叠,标注框画得参差不齐。

解决:不要只调置信度阈值,先从数据下手。把指示器的训练样本做复制粘贴增强,数量翻到超过安全帽的 1/3 再训练。如果增强后 AP 还是上不去,检查标注框是否包含太多背景——指示器本身可能是圆形或方形指示灯,标注员用矩形框标注时习惯留一圈余量,导致框内目标占比过低。用标注的segmentation掩码重新生成紧致外接框,比手工调整快得多。

6. 用混淆矩阵定位漏洞:硬帧回归清单比总 mAP 更可靠

6.1 先读 confusion_matrix.png 再看 mAP

模型训练完,很多人只看 mAP 数字就收工了。mAP 是四类目标的平均值,掩盖了类别间差异。训练目录下会生成confusion_matrix.png,这个图比 mAP 有用得多。重点看两个位置:一是主对角线上的颜色深度,二是"安全帽"行里哪一列有亮色块。如果安全帽大量被预测成 person,说明模型学到的是"头顶有人形轮廓"这个特征,而不是安全帽本身的颜色和形态,暗光下大概率翻车。

井下数据的混淆矩阵还有一个特征:self_rescuer 和 person 容易互相串。自救器通常挂在人腰侧,框比较小,模型经常把它当做人身上的一个无关 patch。这类误检靠调 NMS 很难解决,正确的做法是回到数据层面,补一些自救器在腰部、背在身上的不同角度样本。

6.2 一组固定暗光帧的回归脚本

跑完主实验以后,我会固定一组难帧做回归:从全量数据里挑 10 帧覆盖暗光、逆光、粉尘、密集遮挡,每帧必须是模型曾经漏检或误检过的。这组帧不进训练集,每次调参都跑一遍,看改进方向对不对。

from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") hard_frames = [ "hard/dark_01.jpg", "hard/overlap_02.jpg", "hard/dust_03.jpg", "hard/backlight_04.jpg", ] for p in hard_frames: res = model.predict(p, conf=0.25, iou=0.5, imgsz=1280) for box in res[0].boxes: cls_id = int(box.cls[0]) print(f"{p}: {model.names[cls_id]} {box.conf[0]:.2f}")

这段代码只做一件事:逐帧预测,把类别名和置信度打出来。参数上conf=0.25是主实验的固定阈值,跑回归时不要随意改,改了结果没法对比。判断标准很简单——上次漏检的这帧这次检出没有,上次误检的这次还在不在。如果总 mAP 涨了但硬帧回归变差了,说明模型是在用整体分布的提升掩盖局部场景的退化,这轮调参就是失败的。

我现在做井下检测项目,已经养成先跑硬帧回归再看 mAP 的习惯。96.3% 这个数字可以作为项目起步的基线,但要拿到现场真正稳定可用,靠的还是反复在一个固定难帧集上去验证和打磨。这个框架搭好,后面无论是换模型、调增强还是补数据,每一步都有据可依。希望帮到你。

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

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

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

立即咨询