简介:本资源面向工业视觉检测方向的算法工程师、高校学生与科研人员,提供一套可直接用于YOLO系列目标检测训练的工业指针仪表数据集,帮助解决真实工业场景下仪表读数识别与定位的样本获取难题。压缩包共2000个文件,约427.86MB,包含1000张真实场景采集的高质量图片,以及对应的VOC(xml)、COCO(json)和YOLO(txt)三种格式标签,另附yaml配置文件、Python划分脚本与多份HTML教程文档,覆盖环境搭建、训练流程与数据集划分等环节。目前已有259人学习下载。读者可获得开箱即用的标注数据与格式转换结果,借助附带的训练集、验证集、测试集划分脚本按需切分数据,并参考Windows与Linux双平台的YOLO环境搭建及训练案例教程快速跑通流程,适合作为工业仪表检测项目的基线数据与入门实践素材。
1. 工业指针仪表检测:1000 张图 + 三格式标签到底能不能直接开训
工厂里那些老式指针表——压力表、温度表、电流表——读数全靠人眼盯,一个巡检工一天要走几万步、抄几百个表盘。想用 YOLO 做自动读数,第一道坎从来不是模型结构,而是数据:网上开源的多是生活场景仪表,工业现场的表盘反光、油污、指针细如发丝,直接拿 COCO 预训练权重去推,mAP 能掉到个位数。这个标题给的东西恰好卡在痛点上:1000 张工业指针仪表图片,配 VOC、COCO、YOLO 三种格式标签,外加划分脚本和训练教程。它解决的是「从零标注到能跑通第一版检测器」这段最耗人力的路,适合两类人——手上有现场图片但不会转格式的产线工程师,以及想快速验证仪表检测方案再决定要不要投入标注的算法同学。1000 张不算多,但作为冷启动和格式打样的底料,够用。
2. 三种标签格式的取舍:VOC、COCO、YOLO 各管什么
2.1 为什么一个数据集要同时给三种格式
很多人第一反应是「给一种不就行了」,实际做工业项目时,三种格式对应三条不同的下游链路。VOC 的 XML 是标注工具的原生输出,LabelImg、Labelme 导出的就是它,保留它等于保留了标注的「原始凭证」,后面要改框、要复核、要加类别,都从 XML 改最稳。COCO 的 JSON 是评测和迁移的事实标准,pycocotools 算 mAP、做 COCO 预训练权重微调、接 Detectron2 或 MMDetection,都认这个格式。YOLO 的 txt 是训练时读得最快的,一行一个目标,归一化坐标,Ultralytics 系列直接吃。
三种格式并存的价值在于:你不用为了换个框架重新标一遍。我见过太多团队,标注时用 LabelImg 出 XML,训练时手写脚本转 txt,转错了坐标归一化基准,训了两天才发现框全偏了。有现成的三格式对照,至少能拿同一张图交叉验证转换对不对。
2.2 三种格式的字段对照与转换基准
先把坐标规则说清楚,这是后面所有脚本的地基。
| 格式 | 单目标表示 | 坐标基准 | 类别字段 | 典型文件 |
|---|---|---|---|---|
| VOC | xmin,ymin,xmax,ymax | 绝对像素 | name 标签 | Annotations/*.xml |
| COCO | [x,y,w,h] | 绝对像素,左上角+宽高 | category_id 数字 | annotations/*.json |
| YOLO | cx,cy,w,h | 归一化到 0~1 | class_id 数字,行首 | labels/*.txt |
关键差异有两个。第一,VOC 和 COCO 用绝对像素,YOLO 用归一化,转换时必须除以图片宽高,而且要用每张图自己的尺寸,不能拿一个固定值糊弄。第二,COCO 的 bbox 是左上角加宽高,VOC 是左上角加右下角,两者换算差一个减法。指针仪表这类目标通常细长,宽高比极端,归一化时如果图片尺寸读错,框会横向或纵向拉伸,训练时 loss 降不下去,看预测框就是一条歪线。
2.3 用脚本把 VOC 批量转成 YOLO 与 COCO
下面这段是我常用的转换骨架,输入是 VOC 的 XML 目录和图片目录,输出 YOLO txt 和一份 COCO json。实际项目里我会把它拆成两个函数分别调用,方便单独重跑。
import os import json import xml.etree.ElementTree as ET from PIL import Image # 类别映射:按你的实际类别改,顺序决定 class_id CLASSES = ["pointer_meter"] CLASS_TO_ID = {c: i for i, c in enumerate(CLASSES)} def voc_to_yolo(xml_dir, img_dir, out_dir): os.makedirs(out_dir, exist_ok=True) for xml_file in os.listdir(xml_dir): if not xml_file.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, xml_file)) root = tree.getroot() # 图片尺寸从 XML 的 size 节点读,比重新打开图片快 size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in CLASS_TO_ID: continue bbox = obj.find("bndbox") xmin = float(bbox.find("xmin").text) ymin = float(bbox.find("ymin").text) xmax = float(bbox.find("xmax").text) ymax = float(bbox.find("ymax").text) # 归一化:中心点 + 宽高,全部除以图片尺寸 cx = (xmin + xmax) / 2.0 / w cy = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{CLASS_TO_ID[name]} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") txt_name = os.path.splitext(xml_file)[0] + ".txt" with open(os.path.join(out_dir, txt_name), "w") as f: f.write("\n".join(lines)) def voc_to_coco(xml_dir, img_dir, out_json): coco = {"images": [], "annotations": [], "categories": []} for i, c in enumerate(CLASSES): coco["categories"].append({"id": i, "name": c}) ann_id = 0 img_id = 0 for xml_file in os.listdir(xml_dir): if not xml_file.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, xml_file)) root = tree.getroot() size = root.find("size") w = int(size.find("width").text) h = int(size.find("height").text) img_name = os.path.splitext(xml_file)[0] + ".jpg" img_id += 1 coco["images"].append({"id": img_id, "file_name": img_name, "width": w, "height": h}) for obj in root.findall("object"): name = obj.find("name").text if name not in CLASS_TO_ID: continue bbox = obj.find("bndbox") xmin = float(bbox.find("xmin").text) ymin = float(bbox.find("ymin").text) xmax = float(bbox.find("xmax").text) ymax = float(bbox.find("ymax").text) ann_id += 1 # COCO 用左上角 + 宽高,绝对像素 coco["annotations"].append({ "id": ann_id, "image_id": img_id, "category_id": CLASS_TO_ID[name], "bbox": [xmin, ymin, xmax - xmin, ymax - ymin], "area": (xmax - xmin) * (ymax - ymin), "iscrowd": 0 }) with open(out_json, "w") as f: json.dump(coco, f) if __name__ == "__main__": voc_to_yolo("Annotations", "JPEGImages", "labels") voc_to_coco("Annotations", "JPEGImages", "instances.json")逻辑说明:voc_to_yolo逐 XML 解析,读 size 节点拿宽高,对每个 object 算归一化中心点和宽高,写一行 txt。voc_to_coco维护 images 和 annotations 两个列表,image_id 和 annotation_id 各自自增,bbox 转成左上角加宽高。参数上,CLASSES必须和标注时的 name 完全一致,大小写、下划线都不能差,否则if name not in CLASS_TO_ID会静默跳过,最后标签是空的还不报错。cx:.6f保留六位小数是 YOLO 训练的常见精度,够用且不冗长。
提示:转换完一定抽 5 张图做可视化回验,把 YOLO txt 画回原图,和 VOC 的框叠一起看是否重合。这一步能挡掉 90% 的坐标基准错误。
3. 划分脚本怎么写:训练集、验证集、测试集的比例与坑
3.1 划分比例不是拍脑袋,看你的目标
1000 张图怎么分,取决于你要干什么。如果只是验证「YOLO 能不能检出指针仪表」这个可行性,7:2:1 就够,训练 700、验证 200、测试 100。如果后面要调参、做消融,验证集要留厚一点,6:2:2 更稳。工业场景还有个特殊点:同一块表可能被拍了多张,如果按图片随机分,同一块表的不同角度会同时出现在训练和验证集里,验证指标虚高,上线就翻车。正确做法是按「表」或按「拍摄批次」分组划分,同一块表只进一个集合。
3.2 一个带分组逻辑的划分脚本
import os import random import shutil from collections import defaultdict def split_dataset(img_dir, label_dir, out_dir, ratios=(0.7, 0.2, 0.1), group_key=None, seed=42): random.seed(seed) imgs = [f for f in os.listdir(img_dir) if f.lower().endswith((".jpg", ".png", ".jpeg"))] # group_key 是提取分组标识的函数,比如按文件名前缀分组 groups = defaultdict(list) for img in imgs: key = group_key(img) if group_key else img groups[key].append(img) keys = list(groups.keys()) random.shuffle(keys) n = len(keys) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) splits = { "train": keys[:n_train], "val": keys[n_train:n_train + n_val], "test": keys[n_train + n_val:] } for split, gkeys in splits.items(): img_out = os.path.join(out_dir, "images", split) lbl_out = os.path.join(out_dir, "labels", split) os.makedirs(img_out, exist_ok=True) os.makedirs(lbl_out, exist_ok=True) for k in gkeys: for img in groups[k]: shutil.copy(os.path.join(img_dir, img), os.path.join(img_out, img)) lbl = os.path.splitext(img)[0] + ".txt" src_lbl = os.path.join(label_dir, lbl) if os.path.exists(src_lbl): shutil.copy(src_lbl, os.path.join(lbl_out, lbl)) print(f"train {n_train} groups, val {n_val} groups, test {n - n_train - n_val} groups") if __name__ == "__main__": # 按文件名下划线前的部分分组,比如 meter01_1.jpg 和 meter01_2.jpg 同组 split_dataset("JPEGImages", "labels", "dataset", group_key=lambda x: x.split("_")[0])逻辑说明:先把图片按group_key聚成组,打乱的是组而不是单张图,这样同一块表的多张图必然落在同一个 split。ratios是组级别的比例,不是图片级别,所以最终图片数比例会有小幅波动,这是正常的。seed固定保证可复现。参数上,group_key是核心,如果你的文件名没有规律,就得额外维护一张「图片到表 ID」的映射表,用字典查。
注意:划分完检查一下三个 split 的类别分布,指针仪表如果只有一类,看每类的目标数量是否均衡;如果有多类(比如不同量程的表),别让某一类全挤进训练集。
3.3 划分后必须做的两项校验
第一项,路径校验。YOLO 训练时读的是images/train和labels/train的对应关系,图片和标签必须同名同数量。写个三行脚本比对两边文件名集合是否相等,差一个都会在训练时报「找不到标签」或者静默当负样本。
第二项,空标签校验。有些图可能标完发现没有目标,或者转换时类别名对不上被跳过,生成了空 txt。空 txt 在 YOLO 里是合法的负样本,但如果你本意是有目标的,那就是 bug。统计一下空 txt 的比例,超过 5% 就要回去查转换。
4. 用这份数据跑通 YOLO 训练:配置、参数与验证
4.1 目录结构与 data.yaml
Ultralytics 系列的目录约定是 images 和 labels 平级,各自下面分 train/val/test。data.yaml 指向这三个路径和类别名。
path: ./dataset train: images/train val: images/val test: images/test nc: 1 names: ["pointer_meter"]nc是类别数,names顺序必须和转换时的CLASSES一致,否则 class_id 对不上,训练能跑但预测的类别名全错。path用相对路径时,注意从哪个目录启动训练,我一般写绝对路径省心。
4.2 训练命令与关键参数
yolo detect train \ data=dataset/data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=20 \ project=runs/meter \ name=exp1参数说明:model从 nano 起步,1000 张图用大模型容易过拟合,n 或 s 足够。imgsz=640是通用起点,指针仪表目标小的话可以试 960,但显存和速度要权衡。batch=16看显存,8G 卡跑 640 的 n 模型 16 没问题。lr0=0.01是 SGD 的常见初始学习率,用 Adam 的话降到 0.001。patience=20是早停,验证指标 20 轮不升就停,省时间。
4.3 训练过程看什么指标
重点盯三个:metrics/mAP50、metrics/mAP50-95、train/box_loss。mAP50 涨到 0.9 以上说明框基本对了,mAP50-95 低说明框的精度不够,可能是标注框偏松。box_loss 如果一直震荡不降,回去查标签坐标。指针仪表这类细长目标,如果 mAP50 卡在 0.6 左右上不去,大概率是标注框把整个表盘框进去了而不是只框指针,模型学的是表盘不是指针。
提示:训练完用
yolo detect predict在测试集上跑一遍,把预测结果和真值叠图看。指标好看但叠图错位,说明验证集和测试集分布不一致,划分有问题。
5. 避坑与排查:这份数据集最容易翻车的 5 个地方
现象一:训练 loss 从第一轮就是 nan。原因通常是 YOLO txt 里有坐标超过 1 或者为负,归一化时图片宽高读成了 0。解决:写脚本扫一遍所有 txt,检查每行后四个数是否都在 0~1 之间,有越界的打印文件名回去查对应 XML 的 size 节点。
现象二:mAP 一直是 0,但 loss 在降。原因是 data.yaml 的 names 和标签里的 class_id 对不上,或者类别名拼写不一致导致转换时全被跳过,txt 是空的。解决:统计 labels/train 下非空 txt 的数量,如果是 0,回去核对 CLASSES 和 XML 里的 name。
现象三:验证集指标很高,测试集一塌糊涂。原因是划分时同一块表的多张图被分到了不同集合,验证集泄漏。解决:用 3.2 的分组划分脚本重划,按表或按批次分组。
现象四:预测框整体偏移一个固定方向。原因是 VOC 转 YOLO 时用了统一的图片尺寸而不是每张图自己的尺寸,或者 XML 里的 size 和实际图片尺寸不一致。解决:转换脚本里加一句用 PIL 打开图片核对宽高,和 XML 的 size 比对,不一致的以实际图片为准。
现象五:小目标指针完全检不出。原因是 imgsz 太小,指针在 640 下只有几个像素。解决:提高 imgsz 到 960 或 1280,或者在数据增强里关掉 mosaic,mosaic 会把小目标进一步缩小。也可以考虑切图推理,把大图切成小块分别检测再合并。
6. 从 1000 张到产线可用:增量标注与模型迭代的一个技巧
1000 张跑通只是起点,真正上线要面对的是现场光照变化、表盘老化、新表型。我的习惯是拿第一版模型去现场跑推理,把置信度在 0.3~0.6 之间的「模糊样本」自动截出来,这批样本是模型最不确定的,人工复核一遍加进训练集,比随机再标 1000 张的收益高得多。这个流程叫主动学习,落地时用一个脚本就能串起来。
from ultralytics import YOLO import os import shutil model = YOLO("runs/meter/exp1/weights/best.pt") uncertain_dir = "uncertain" os.makedirs(uncertain_dir, exist_ok=True) results = model.predict("field_images", conf=0.25, iou=0.5, stream=True) for r in results: boxes = r.boxes if boxes is None or len(boxes) == 0: continue confs = boxes.conf.tolist() # 有目标落在不确定区间,就截出来人工复核 if any(0.3 <= c <= 0.6 for c in confs): shutil.copy(r.path, os.path.join(uncertain_dir, os.path.basename(r.path)))逻辑说明:conf=0.25放低阈值先召回,再在结果里筛 0.3~0.6 的框。stream=True是流式推理,处理大批量图片时省内存。截出来的图人工复核后,用同样的 VOC 流程标注、转换、并入训练集,重训一版。这个循环跑三轮,现场 mAP 通常能从 0.7 提到 0.9 以上。
参数上,不确定区间的上下界可以调,0.3~0.6 是经验值,模型越准可以收窄到 0.4~0.55。iou=0.5是 NMS 阈值,指针仪表目标不密集,0.5 够用,密集场景要降到 0.3 防止漏检。
我自己的教训是:别指望一次标 1000 张就能覆盖所有工况,现场的表比你想象的花样多。真正省时间的做法是先用这 1000 张把流程跑通,然后带着模型去现场采不确定样本,让模型告诉你它还缺什么。这套数据集的划分脚本和训练教程,价值不在那 1000 张图本身,而在于它把「格式转换—划分—训练—验证」这条链路给你铺平了,你只需要把现场图片按同样的结构塞进去,就能开始迭代。希望帮到你。
本文还有配套的精品资源,点击获取