ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

指针仪表检测实战:XML转YOLO、数据集划分与训练避坑

指针仪表检测实战:XML转YOLO、数据集划分与训练避坑

简介:面向目标检测学习与指针仪表识别任务的数据集,适合机器学习研究者、算法开发者及需要开展仪表定位与读数提取实验的学生使用。包内以783张jpg仪表图像为主体,另附2个json文件,压缩包约950.3MB,共785个文件,图像可用于训练与测试,json通常承载标注或说明信息,便于按训练集、测试集组织实验。标题标注包含XML标注文件,实际包内文件类型以jpg与json为主,使用前建议先核对目录结构与标注格式。已有303人学习,说明该数据在相关任务中具备一定参考价值。读者可据此搭建指针仪表检测流程,训练模型学习仪表位置、指针方向与读数区域,并用独立测试图片检验泛化能力,为自动化仪表读数、能源管理等应用提供数据基础。

1. 指针仪表检测先解决数据问题:一份带 XML 的训练集与测试集

做指针仪表目标检测的团队,十有八九在数据上出过问题:表盘玻璃反光被框进去、同一块表在训练集和测试集各出现一次、XML 里的 xmax 刚好等于图片宽度。

这份指针仪表数据集把训练集、测试集和 XML 标注文件分开存放,适合直接做表盘定位与表计分类,也适合新手把 VOC 转 YOLO 的链路跑通。

它不是只给几张样图的演示包,而是能支撑一次完整训练和验证的数据组织方式;做巡检、读数或表计状态识别的,可以把它当标注底稿,再决定扩样方向。

2. 目标检测标签转换:从 XML 到 YOLO 的坐标换算与训练集、测试集骨架

拿到这份指针仪表数据集,第一件事不是直接开训练,而是把 XML 和图片的对应关系验一遍。数据集已经给了训练集、测试集和 XML,但 XML 只是标注的中间态,目标检测训练入口多数吃的是归一化后的 txt 标签。下面按我平时拆包的顺序走:先读一张 XML 确认字段,再批量转 YOLO,然后检查训练集和测试集有没有重叠,最后把目录和 data.yaml 固定下来。

2.1 先读一张 XML:字段、尺寸与类别名

批量转换前先抽一张 XML 读出来,不要一上来就写循环。常见 VOC 风格的字段就那几层:filename 对应图片名,size 里的 width/height 是归一化分母,object/name 是类别名,bndbox 里是左上角和右下角像素坐标。先把一张读对,后面批量转换才有的放矢。

字段含义转换时要注意
filename对应图片文件名必须和 images 下的文件名一致,包括大小写
size/width、height原图像素尺寸归一化分母,不能用缩放后的尺寸
object/name类别名要和 data.yaml 的 names 索引对齐
bndbox/xmin、ymin、xmax、ymax像素框VOC 常见左上/右下,注意边界闭区间
from pathlib import Path import xml.etree.ElementTree as ET def read_voc_xml(xml_path): root = ET.parse(xml_path).getroot() size = root.find("size") w = int(size.findtext("width")) h = int(size.findtext("height")) objs = [] for obj in root.findall("object"): name = obj.findtext("name").strip() box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) objs.append((name, xmin, ymin, xmax, ymax)) return w, h, objs if __name__ == "__main__": # 先换成你包里的真实路径,只读一张 w, h, objs = read_voc_xml("raw/train/annotations/0001.xml") print("尺寸:", w, h) print("目标:", objs)

这段脚本只做读取,不写文件,目的是把尺寸和类别名先看清楚。参数上重点看三个地方:xml_path 要指向训练集里真实存在的标注;w、h 必须来自 XML 的 size,不要用图片查看器里的显示尺寸代替;name 读出来后要 strip,避免尾部空格把类别名变成两个。如果 size 缺失,不要猜,直接用图片读宽高,再把同一套尺寸写回转换逻辑。

2.2 坐标换算:VOC 的左上右下到 YOLO 的中心宽高

YOLO 标签一行是class_id cx cy w h,其中 cx、cy、w、h 都是相对图像宽高的归一化值。换算本身不复杂:cx 等于 xmin 和 xmax 的中点除以宽,cy 等于 ymin 和 ymax 的中点除以高,w 和 h 是框的宽高除以图像宽高。容易出问题的是边界:xmax 可能等于图片宽度,像素索引又从 0 开始,不裁就会得到大于 1 的归一化值。

from pathlib import Path import xml.etree.ElementTree as ET # 类别顺序一旦定下就不要随意改,label 第一列靠它映射 CLASSES = ["gauge"] def voc_to_yolo(xml_path, out_dir, classes=CLASSES): root = ET.parse(xml_path).getroot() size = root.find("size") w = float(size.findtext("width")) h = float(size.findtext("height")) lines = [] for obj in root.findall("object"): name = obj.findtext("name").strip() if name not in classes: # 遇到未知类直接报错,别静默跳过 raise ValueError(f"未知类别 {name},文件 {xml_path}") box = obj.find("bndbox") xmin = float(box.findtext("xmin")) ymin = float(box.findtext("ymin")) xmax = float(box.findtext("xmax")) ymax = float(box.findtext("ymax")) # 裁到像素边界,避免 xmax 等于 w 时归一化越界 xmin = max(0.0, min(xmin, w - 1)) ymin = max(0.0, min(ymin, h - 1)) xmax = max(0.0, min(xmax, w - 1)) ymax = max(0.0, min(ymax, h - 1)) if xmax <= xmin or ymax <= ymin: continue cx = (xmin + xmax) / 2.0 / w cy = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{classes.index(name)} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") out_path = out_dir / f"{xml_path.stem}.txt" out_path.write_text("\n".join(lines), encoding="utf-8") if __name__ == "__main__": out_dir = Path("gauge_dataset/train/labels") out_dir.mkdir(parents=True, exist_ok=True) for xml_path in Path("raw/train/annotations").glob("*.xml"): voc_to_yolo(xml_path, out_dir)

这段逻辑的重点是“先裁再归一化”,不是直接套公式。参数上,CLASSES的顺序就是 label 第一列,必须和后面的 data.yaml 完全一致;w - 1和h - 1是像素索引的保守裁法,如果你的标注工具导出的 xmax 是连续边界、允许等于 w,那就统一裁到[0, w],但不要一半文件按闭区间、一半按连续边界处理。另一个参数是无效框处理:我一般会先统计被跳过的数量,超过 1% 就停下来看源数据,而不是让脚本一声不吭地丢掉。

提示:转换完抽 20 张图把 txt 画回图上目检,重点看贴边表盘和密集小表,别只看 loss。

2.3 训练集与测试集拆分:先查重叠,再谈比例

数据集自带 train/test 不代表一定干净。现场采集常见连拍,同一块表在几秒内拍五六张,文件名不同、画面几乎一样;如果随机划分,训练集和测试集就会共享同一块表的纹理,验证指标会虚高。先做文件名交集检查,再做分组划分,顺序不能反。

from pathlib import Path train_stems = {p.stem for p in Path("gauge_dataset/train/images").glob("*.jpg")} test_stems = {p.stem for p in Path("gauge_dataset/test/images").glob("*.jpg")} print("训练集数量:", len(train_stems)) print("测试集数量:", len(test_stems)) print("重叠数量:", len(train_stems & test_stems))

这段只做集合交集,逻辑很直接:训练集和测试集的图片主文件名如果出现同一个 stem,说明至少有一对图片同名,后面必须人工确认是不是同一张图或同一块表。参数上要注意扩展名,jpg、jpeg、png 要分别统计,或者统一转成小写后再取 stem;如果包里的图片命名带_left、_right这类后缀,先确认它们是不是同一张原图切出来的,别把切片当独立样本。

如果需要在扩样后重新划分,我一般按“表编号”或“拍摄场景”分组,而不是按图片随机抽。下面这个脚本用文件名前两段做组键,再用组键的哈希决定去训练集还是测试集,保证同一组不会跨集。

from pathlib import Path import hashlib def group_key(stem): # 文件名形如 gauge_001_20240501_01,取前两段做表编号 parts = stem.split("_") return "_".join(parts[:2]) if len(parts) >= 2 else stem def split_by_group(xml_dir, test_ratio=0.2): groups = {} for xml_path in Path(xml_dir).glob("*.xml"): key = group_key(xml_path.stem) groups.setdefault(key, []).append(xml_path.stem) train, test = [], [] for key in sorted(groups): # 用哈希而不是随机数,保证每次划分结果一致 bucket = test if int(hashlib.md5(key.encode()).hexdigest(), 16) % 100 < test_ratio * 100 else train bucket.extend(groups[key]) return train, test if __name__ == "__main__": train, test = split_by_group("raw/train/annotations", test_ratio=0.2) print("训练组样本:", len(train)) print("测试组样本:", len(test))

这段的逻辑是“先聚组、再分桶”,group_key决定什么算同一块表。参数上,test_ratio=0.2是测试集比例,样本少的时候不要低于 0.15,否则验证指标抖动很大;hashlib.md5只是拿来做稳定分桶,不涉及安全用途。文件名没有表编号时,换成感知哈希或拍摄时间分组,核心原则只有一个:同一块表、同一场景的连拍图不能跨集。

2.4 目录骨架与 data.yaml:让路径和类别索引一次对齐

转换和划分都验完后,再把文件放进训练入口认的目录。常见结构是 images 和 labels 分开放,train 和 test 各一套。不要一边训练一边改目录,路径错一次,后面排查成本很高。

mkdir -p gauge_dataset/{train,test}/{images,labels}

这条命令只建骨架,不搬文件。参数上,gauge_dataset是数据集根目录,train和test分别对应训练与验证;实际拷贝时用cp或mv把图片和同名 txt 成对放进去,图片和标签的主文件名必须一致,比如0001.jpg对应0001.txt。如果后面还要加类别,先在这个骨架上加,不要直接改训练命令里的路径。

path: ./gauge_dataset train: train/images val: test/images names: 0: gauge

这份 yaml 的逻辑是把根目录、训练图片相对路径、验证图片相对路径和类别索引一次写清。参数上,path用绝对路径最稳,相对路径容易受启动目录影响;train和val是相对path的路径,不要重复写根目录;names的索引就是 label 第一列,0 对应 gauge,如果 XML 里还有 pointer、scale 这类 name,先统计全量类别再按顺序补,不要先写nc再回头改标签。

3. 指针表盘训练配置:分辨率、增强与验证指标的取舍

表盘这类目标有几个固定约束:接近正方形、边缘有刻度、反光多、尺度跨度大。训练配置没有万能表,但可以先守住两条:小目标召回不能太差,验证集不能只靠 mAP 好看。下面这套起点适合单类表盘检测,改参数时先动分辨率和增强,再动学习率。

3.1 输入分辨率与长宽比:表盘不是行人

表盘外接框接近 1:1,训练入口默认 letterbox 到正方形,形变风险不大;真正的矛盾在尺度。一张巡检图里表盘可能只占几十分之一,640 输入下小表只剩二三十像素,经过 backbone 下采样后特征很弱。分辨率不是越大越好,显存和过拟合都要看,但小表占比高时,640 往往不够。

场景imgsz理由风险
表盘占画面 1/3 以上640速度快,显存低远处小表容易漏
表盘占画面 1/10 左右960召回和速度比较平衡显存上涨,batch 要降
远距离巡检图、多表同框1280 或切图保留刻度和表盘边缘训练慢,小样本易过拟合

参数上,imgsz不是固定值,要按验证集里表盘像素面积统计来定:先算每张图标注框的宽高,按 10%、25%、50% 分位数看,低于 40 像素的样本占比超过两成,就把输入拉到 960 以上,或者切图训练。切图时注意重叠保留,表盘被切成两半对检测帮助不大。

3.2 小目标与密集刻度:检测框只到表盘,不到指针

这里要先把任务边界说清:目标检测框的是表盘外接矩形,不是指针,也不是刻度。下游要读数,常见做法是两阶段:先检表盘,再裁图做关键点、语义分割或传统读数算法。把指针也塞进检测类别,标注成本高、框不稳定,训练出来容易抖。

小目标密集时,先看数据分布,不要急着加 anchor。表盘尺度跨度大,anchor 只能缓解一部分,真正有效的是提高输入分辨率、切图、补小目标样本。验证集里单独统计小、中、大目标的 recall,比只看总 mAP 更能发现问题;如果小目标 recall 明显低,先补数据,再调imgsz,最后才考虑模型结构。

3.3 数据增强的边界:旋转、透视与亮度

表盘增强有两个容易过界的地方:旋转和透视。现场安装确实有倾斜,但 90 度旋转会破坏表盘语义,指针方向也失去意义;透视过强会把圆表拉成椭圆,模型学到的是形变噪声。亮度、对比度可以放开一点,表盘玻璃反光、逆光、欠曝都是现场常态。

参数建议起点原因
degrees5~10模拟轻微安装倾斜,别做 90 度
perspective0~0.0005轻微侧视可以,过大破坏圆表形状
hsv_h0.015轻微色偏,不要大幅改色相
hsv_s0.3~0.5表盘颜色差异
hsv_v0.2~0.4逆光、欠曝、反光
mosaic0.3~0.5增加小目标,但会拼出不自然表盘
close_mosaic10~20最后阶段关掉,稳定收敛

参数上,mosaic不是越高越好。表盘是规则圆形,四图拼接后容易出现半截表盘、比例异常的表盘,训练后期关掉mosaic能让模型回到真实分布。degrees超过 15 就要谨慎,除非你的现场确实有横装、竖装混用,并且标注框能覆盖旋转后的表盘。

3.4 训练参数与验证指标:先看 recall,再看 mAP

下面这条命令以常见的 YOLO 训练入口为例,把数据配置、输入尺寸和增强参数串起来。模型选小的先跑通,不要一上来上大模型;表盘检测的数据量通常不大,小模型加高分辨率往往比大模型加 640 更实用。

yolo detect train \ model=yolo11n.pt \ data=gauge_dataset/data.yaml \ imgsz=960 \ epochs=120 \ batch=8 \ patience=30 \ lr0=0.01 \ degrees=8 \ perspective=0.0005 \ hsv_h=0.015 \ hsv_s=0.4 \ hsv_v=0.3 \ mosaic=0.4 \ close_mosaic=15 \ project=runs_gauge \ name=exp01

这条命令的逻辑是:data指到上一步的 yaml,imgsz统一训练和验证输入,增强参数只作用在训练集,project和name固定输出目录,方便对比实验。参数上,batch=8是 960 输入下的保守值,显存不够就降到 4 或开梯度累积;patience=30表示验证指标 30 轮不升就早停,样本少时不要设太小;lr0=0.01是常见起点,微调预训练模型可以降到 0.001 到 0.005。训练完先看验证集的 recall 和混淆矩阵,再看 mAP50、mAP50-95;表盘漏检比框稍微偏一点更致命,recall 不达标先回去查小目标样本和标注边界。

4. 避坑与排查:从 XML 到训练链路的五条问题记录

这条链路我跑过多次,真正费时间的不是模型,而是标注和路径。下面五条都是实际拆包时容易遇到的,每条按“现象、原因、解决”记,方便对照排查。

4.1 标注与转换层面的三个问题

现象:转换脚本跑完,部分标签的 cx 接近 1,训练时提示框越界,或者画回图上框贴边缺失。
原因:VOC 的 xmax 有时等于图片宽度,像素索引从 0 开始,归一化前没裁;也有脚本先归一化再裁,顺序反了。
解决:像素坐标先裁到[0, w-1],归一化后再裁到[0,1];转换完抽 20 张画框目检,贴边表盘重点看。

现象:训练 loss 正常降,验证 mAP 长期为 0 或极低,混淆矩阵只有背景。
原因:XML 里的 name 是Gauge、gauge带空格,或者写成了meter,data.yaml 里只写gauge,脚本按类别索引映射时错位或跳过。
解决:先全量统计 name 的 set 和频次,统一小写、去空格;固定 classes 列表顺序;转换时遇到未知类直接报错,不要静默 continue。

现象:测试集 mAP50 很高,换一批现场图掉得厉害。
原因:同一块表的连拍图被分到训练集和测试集,文件名不同但画面几乎一样,验证指标虚高。
解决:按表编号或拍摄场景分组划分;训练前跑文件名交集和缩略图相似度检查;同一组连拍只进一个集合。

4.2 训练与评估层面的两个陷阱

现象:标注框整体转了 90 度,或者框和表盘明显错位。
原因:手机拍摄带 EXIF Orientation,图片查看器按显示方向展示,XML 也按显示方向标;转换脚本按原始像素读,方向没统一。
解决:统一用ImageOps.exif_transpose重存图片,确认 XML 的 width/height 与重存后的图片一致;或者转换前按 EXIF 交换宽高和坐标,二选一,不要混用。

现象:大表盘 recall 很高,远处小表盘只有一半左右,验证集里小框断断续续。
原因:640 输入下小表盘只剩二三十像素,下采样后特征太弱;mosaic 又让尺度分布更乱。
解决:提高imgsz到 960 或 1280,或切图训练;单独统计小目标样本比例和 recall;先补小目标数据,再考虑 anchor 和模型结构。

5. 验收与进阶:用中心点偏移反推检测框质量

mAP 是排序指标,能告诉你整体好不好,但表盘检测还要看框稳不稳。下游读数阶段,表盘中心偏一点,裁图就会把指针切掉;宽高比被拉扁,圆表会变成椭圆。我一般额外统计两个量:中心点偏移和宽高比误差。中心偏移用归一化坐标算欧氏距离,0.02 以内说明表盘中心基本没飘;宽高比误差超过 15%,就要回去看标注框是不是把玻璃反光或支架也框进去了。

import numpy as np def center_offset(pred, gt): # pred/gt: [cx, cy, w, h],归一化坐标 dx = pred[0] - gt[0] dy = pred[1] - gt[1] return (dx ** 2 + dy ** 2) ** 0.5 def aspect_error(pred, gt): pred_ratio = pred[2] / max(pred[3], 1e-6) gt_ratio = gt[2] / max(gt[3], 1e-6) return abs(pred_ratio - gt_ratio) / max(gt_ratio, 1e-6) offsets = [center_offset(p, g) for p, g in zip(preds, gts)] ratios = [aspect_error(p, g) for p, g in zip(preds, gts)] print("中心偏移中位数:", round(float(np.median(offsets)), 4)) print("偏移超过 0.05 的比例:", round(float(np.mean(np.array(offsets) > 0.05)), 4)) print("宽高比误差中位数:", round(float(np.median(ratios)), 4))

这段统计的逻辑是把检测框和真值框都当成归一化中心宽高,逐对算中心距离和宽高比相对误差。参数上,0.05 是报警线,不是合格线;表盘任务里中心偏移中位数最好压到 0.02 以下,宽高比误差中位数控制在 0.1 左右。统计时只取匹配上的框,没匹配上的先算漏检,不要混进偏移统计里掩盖问题。

从那以后,我每次拿到新的表计数据,都先抽 20 张画框、跑一遍中心偏移统计,再开训练;这一步比调参更能提前发现问题。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进