指针仪表数据集:VOC xml校验、YOLO转换与读数落地 简介指针仪表数据集面向目标检测任务适合机器学习研究者、算法开发者及需要仪表读数识别项目实践的中高级学习者用于训练与评估指针仪表定位及关键读数信息提取模型。压缩包共785个文件以783张jpg仪表图像为主另含2个json格式的标注与数据划分文件整体约950.3MB可支撑训练集、测试集的组织与模型泛化能力验证。已有303人学习说明该数据在仪表检测与读数识别方向具有一定参考价值。图像样本覆盖多种表盘与拍摄视角配合标注文件可开展目标框、指针方向及读数关联信息的解析训练集用于模型学习测试集便于检验未见样本上的检测效果。包内结构清晰适合按类别与用途检索能为自动化仪表读数、智能巡检等场景的算法复现与实验对比提供基础数据。1. 指针仪表数据集解压之后训练集、测试集和 xml 哪个先看一份指针仪表数据集通常长这样几个按日期或设备编号命名的文件夹里面是现场拍的 JPG旁边躺着同名 xml再往外一层是 train 和 test 两个目录。不少人的动作是直接找 train 丢进模型三天后回来发现 test 里一半表盘压根没标过或者 xml 里画的是整块表盘、模型却要在指针上出框返工成本比从头做还高。指针仪表数据集的难度从来不在模型而在「训练集和测试集是不是同一批工况」「xml 里的框到底代表什么」。这篇按我自己做工业巡检项目的顺序讲先把 xml 校验清楚再转格式、切分复核训练完把检测框变成读数。适合手里已经有一份带 xml 的仪表数据、准备做表盘检测或指针读数识别的人。2. 指针仪表 xml 标注拆开看字段、类别与框边界的三个必查项2.1 一条 VOC xml 里哪些字段真的会影响训练先看一条典型标注别急着写解析器先把字段语义对上annotation foldergauge_0412/folder filenamegauge_0412_0031.jpg/filename size width1920/width height1080/height depth3/depth /size object namedial/name difficult0/difficult bndbox xmin512/xminymin300/yminxmax1100/xmaxymax890/ymax /bndbox /object object namepointer/name bndbox xmin760/xminymin430/yminxmax812/xmaxymax640/ymax /bndbox /object /annotation真正决定训练效果的只有四个东西filename能不能和图片对上、size宽度高度是不是真实图像尺寸、object/name类别名决定类别映射、bndbox坐标本身。folder、path、segmented、difficult这些字段在绝大多数检测训练里是冗余的但path里带着的绝对路径偶尔会暴露原始采集机的目录结构——如果这份数据要往外分发先把它清掉。三个必查项按优先级排第一filename与磁盘文件是否严格一致。带空格、大小写不同、后缀写成.JPG都会让后面的转换脚本静默跳过最后标签数量少了你还以为是模型的问题。常见做法是在统计脚本里把filename统一strip()并按小写比对。第二size是否等于真实图片尺寸。这条最容易被忽略。图片被批量压缩过比如长边从 1920 缩到 1280xml 里的 size 没跟着改转换出来的归一化坐标会整体偏小框全挤向左上角。稳妥做法是转换时以cv2.imread读到的真实尺寸为准xml 里的 size 只用来交叉验证。第三bndbox的坐标基准。VOC 惯例是 1-based 闭区间即xmin从 1 开始、xmax是框内最后一个像素。有些标注工具输出的是 0-based 连续坐标。差 1 个像素对检测几乎无影响但一套数据里两种基准混用就必须统一——最怕的是转换脚本一半减一、一半没减。至于类别名别假设它只叫 dial 和 pointer。我见过的命名有Dial、dial_area、gauge、meter、表盘甚至同一个文件夹里两种写法并存。.lower()之后做一次Counter统计比任何猜测都省事。2.2 用 xml.etree 跑一遍全量统计类别、框尺寸、缺图标注看一眼就够了全量统计必须跑脚本。下面这段是通用的体检脚本标准库就够不引入额外依赖import collections from pathlib import Path import xml.etree.ElementTree as ET XML_DIR Path(annotations/xml) # 原始 xml 目录 IMG_DIR Path(images/all) # 与 xml 对应的原图目录 cls_stat collections.Counter() # 类别 - 目标数 area_stat collections.Counter() # 框面积分档 missing_img, bad_box, parse_err, empty_xml [], [], [], [] for xp in sorted(XML_DIR.glob(*.xml)): try: root ET.parse(xp).getroot() except ET.ParseError as e: # 半截文件、编码损坏都在这里暴露 parse_err.append((xp.name, str(e))) continue fname (root.findtext(filename) or ).strip() if not fname or not (IMG_DIR / fname).exists(): missing_img.append(xp.name) continue size root.find(size) W, H int(size.findtext(width)), int(size.findtext(height)) objs root.findall(object) if not objs: # 负样本 xml要单独统计 empty_xml.append(xp.name) continue for obj in objs: name (obj.findtext(name) or ).strip().lower() cls_stat[name] 1 bb obj.find(bndbox) x1, y1 float(bb.findtext(xmin)), float(bb.findtext(ymin)) x2, y2 float(bb.findtext(xmax)), float(bb.findtext(ymax)) if x2 x1 or y2 y1: # 坐标反向标注时拖反了 bad_box.append((xp.name, name, reverse)) continue if x1 0 or y1 0 or x2 W or y2 H: bad_box.append((xp.name, name, out_of_range)) w, h x2 - x1, y2 - y1 area_stat[tiny if w * h 32 * 32 else normal] 1 print(类别分布 :, dict(cls_stat)) print(框面积档 :, dict(area_stat)) print(空 xml :, len(empty_xml)) print(缺图 :, len(missing_img), missing_img[:5]) print(坏坐标 :, len(bad_box), bad_box[:5]) print(解析失败 :, len(parse_err), parse_err[:5])逐项说明这段脚本的判断逻辑。parse_err优先看它意味着文件根本不完整后面所有的数量对账都会被它带偏。missing_img用的是root.findtext(filename)而不是文件名 stem是因为有些工具的 xml 名和里面的 filename 并不一致必须以 xml 内声明的为准才符合推理时的对应关系。area_stat里 32×32 这个阈值不是标准只是我习惯拿它区分「正常框」和「可能是刻度线被错标成目标」的小框——指针细长的时候短边很容易掉到 10 像素以下这类框在 640 输入下基本消失。参数上可调的是XML_DIR和IMG_DIR其余不用动。跑完把四个数字记下来后面转换完要拿它对账。2.3 表盘框、指针框与刻度区标注边界怎么定才不返工统计干净了接下来是标准问题也是返工最多的地方。同一个人隔一周标同一批图表盘框的松紧都能差出 10 个百分点。我的约定是这样的表盘框dial贴着表盘外圈玻璃或金属边框的内沿宁紧勿松把背景反光、螺丝、旁边的管线全部排除在外。理由很实际——这个框的唯一用途是裁剪下游找圆心、做二值化都在裁剪图里进行框大了多出来的背景会让霍夫圆检测直接选错圆心。指针框pointer如果要做只框指针的可视主体从转轴到针尖不要为了「框得好看」而往两头各扩十几像素那会让指针方向和框的长轴方向对不上。刻度区要不要单独标一类取决于你的读数方案。走「检测框 角度法」这条路的刻度线不标也行用标定角度线性映射就够走「关键点 刻度读数」这条路的刻度必须标而且要用关键点格式而不是矩形框。两种方案混着标是最麻烦的转换脚本里还得判断哪些 object 转框、哪些转点。还有一个坑多人标注时一定要先标 20 张做交叉验证算一下同一个表盘两个人框的 IoU。IoU 低于 0.85 就说明标准没对齐这时候直接开工模型学到的是标注者之间的分歧不是表盘的边界。提示先拿 20 张图做双人交叉标注IoU 达标再批量开工比标完 2000 张再返工便宜得多。3. 把指针仪表 xml 转成 YOLO txt归一化脚本与四个边界坑3.1 目录怎么摆images、labels 与 train/test 的对应关系转换之前先把目录定死后面所有脚本都按这个结构走能省掉大量路径拼接的调试时间路径内容说明dataset/images/train/训练图从原 train 里再切出 val 后的剩余部分dataset/images/val/验证图从 train 按工况分层切 15%dataset/images/test/测试图原 test全程不参与调参dataset/labels/train/YOLO txt与 images/train 同名同数量dataset/labels/val/YOLO txt同上dataset/annotations_xml/原始 xml只做留档不参与训练dataset/data.yaml数据集描述类别名与路径这里有个关键决定原始数据给的 train/test 不要盲目相信。工业现场的数据集test 往往是从同一段采集视频里连续抽帧出来的相邻帧只差几毫秒表盘角度几乎没变。这种 test 的指标会虚高到 0.98上线后遇到新表型立刻掉到 0.6。我的处理是从 test 里按「设备型号 / 光照条件 / 拍摄距离」三个维度做一次分组统计如果某一组在 train 里完全没出现那它才是真正有价值的测试集如果三个维度上都和 train 高度重叠就把它降级成 val另外从现场补一批新工况的图做 test。val 的切分同理按工况分层抽样不要用random.shuffle一刀切。表盘倾斜角度、指针位置、曝光强度这三个维度各分三档每档按比例抽切出来的 val 才有代表性。3.2 转换脚本逐段讲1-based 转 0-based、越界裁剪、类别映射下面是核心转换函数两个参数xml_path和img_path分别指向一条标注和它对应的原图import math import xml.etree.ElementTree as ET from pathlib import Path import cv2 # 顺序必须和 data.yaml 的 names 完全一致改这里就要改 yaml CLASSES [dial, pointer] def voc_to_yolo(xml_path: Path, img_path: Path): root ET.parse(xml_path).getroot() img cv2.imread(str(img_path)) if img is None: raise FileNotFoundError(img_path) H, W img.shape[:2] # 以真实图像尺寸为准不信任 xml 的 size lines [] for obj in root.findall(object): name (obj.findtext(name) or ).strip().lower() if name not in CLASSES: # 未知类别显式跳过不要静默给 0 print(skip unknown class:, name, xml_path.name) continue cid CLASSES.index(name) bb obj.find(bndbox) # VOC 惯例 1-based 闭区间 - 0-based 连续坐标 x1 float(bb.findtext(xmin)) - 1 y1 float(bb.findtext(ymin)) - 1 x2 float(bb.findtext(xmax)) y2 float(bb.findtext(ymax)) # 越界裁剪宁可切掉几像素也不产出 1 的归一化坐标 x1, x2 max(0.0, x1), min(float(W), x2) y1, y2 max(0.0, y1), min(float(H), y2) # 退化框直接丢留着会在训练时把 loss 拉爆 if x2 - x1 2 or y2 - y1 2: continue cx (x1 x2) / 2.0 / W cy (y1 y2) / 2.0 / H bw (x2 - x1) / W bh (y2 - y1) / H lines.append(f{cid} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) return lines逻辑上分四步读图拿真实尺寸、按 name 映射类别、坐标基准转换、归一化输出。参数上只有CLASSES需要你改顺序错了模型学出来的类就是乱的——这类错误在训练曲线上完全看不出来loss 照降只是预测框的类别标签反了很难发现。所以CLASSES一定要和data.yaml里的names逐字对照一遍。cv2.imread那一步是刻意的开销。有人为了快直接用 xml 里的 size 做归一化遇到图片被压缩过的批次就整体偏移。读一次图多花几十毫秒能省掉后面半天的排查。批量驱动部分for split in [train, val, test]: xml_dir Path(fannotations/{split}) for xml_path in sorted(xml_dir.glob(*.xml)): stem xml_path.stem img_path Path(fimages/{split}/{stem}.jpg) if not img_path.exists(): print(no image:, stem); continue labels voc_to_yolo(xml_path, img_path) out Path(flabels/{split}/{stem}.txt) out.parent.mkdir(parentsTrue, exist_okTrue) # 没有目标也要写空文件表示这是一张负样本 out.write_text(\n.join(labels), encodingutf-8)空文件那行值得单说。YOLO 系训练里一张图对应一个 txttxt 为空代表「这张图确认没有目标」是合法的负样本但如果 txt 文件不存在有些实现会当成「标签缺失」直接跳过这张图。含背景无表盘的图对抑制误检很有用别顺手删。3.3 转换后的三道自检数量对账、坐标越界、空标签转换跑完不算完三道自检必须过。第一道是数量对账命令行就够# 三个数字必须完全一致不一致就是有图片或 xml 被跳过了 ls images/train | wc -l ls labels/train | wc -l ls annotations/train/*.xml | wc -l第二道是坐标合法性把越界和字段数错误一次扫出来from pathlib import Path bad [] for p in Path(labels).rglob(*.txt): for i, line in enumerate(p.read_text(encodingutf-8).splitlines()): if not line.strip(): continue parts line.split() if len(parts) ! 5: # 检测格式必须是 5 列 bad.append((p.name, i, 字段数, len(parts))) continue cid int(parts[0]) cx, cy, w, h map(float, parts[1:]) if not (0 cx 1 and 0 cy 1 and 0 w 1 and 0 h 1): bad.append((p.name, i, 归一化越界)) elif cx - w / 2 -1e-6 or cx w / 2 1 1e-6: bad.append((p.name, i, 横向出框)) # 中心对但框超出图像边界 elif cy - h / 2 -1e-6 or cy h / 2 1 1e-6: bad.append((p.name, i, 纵向出框)) print(问题行:, len(bad)) for row in bad[:10]: print(row)这里特意多查了「中心合法但框出边界」的情况。裁剪逻辑写得糙的时候cx落在 0 到 1 之间是必然的但w可能大到让cx - w/2变成负数训练时框会贴到图像边缘之外回归目标本身就错了。第三道是空标签统计按 split 分别算空 txt 占比。全空说明路径配错了占 30% 以上说明这批数据里有大量无表盘图得回去看采集是不是空拍。第三道之后还有一件事容易被跳过抽 5 张图把转换后的框画回去看一眼。写个十行的可视化脚本红色画 dial、绿色画 pointer肉眼扫一遍比任何数字都直观。4. 指针仪表检测训练输入尺寸、增强开关与 mAP 之外的三个指标4.1 从 train 里再切一份 val切分比例与分层依据前面提过 val 要分层切具体落地是这样。先把 train 里每张图的元信息整理成一张表表盘在图中占的短边像素数、指针角度如果已有标注就粗算没有就用人工抽样的 200 张估个分布、图像平均亮度。然后按这三个维度各分三档九个格子每格抽 15%凑成 val。比例上 15% 是常见起点。数据量少于 500 张时val 占比可以压到 10%把更多样本留给训练数据量超过 5000 张20% 也可以接受因为此时 val 的统计波动已经够小。关键是 val 一旦切定就不再变动所有超参对比都对着同一份 val否则模型选型的依据就变成了噪声。test 集的唯一用途是最后出一份数字。整个调参过程中不打开、不看、不参与早停这是我自己定的规矩破了这条规矩最后报出去的数字就没有意义。4.2 必须手动改的四组训练参数默认配置在通用数据集上调得不错但指针仪表有几个特性会让默认值翻车。下面这四组是每次都要动的参数建议起点为什么要改imgsz表盘短边的 1.5~2 倍向上取到 32 的倍数表盘在整图里占比常常不到 15%直接 resize 到 640 后指针只剩几个像素mosaic0.0 ~ 0.3四图拼接会把本来就小的表盘再缩一半指针彻底消失degrees5 ~ 10现场手持拍摄有轻微倾斜小角度旋转有益大角度会把表盘转出画面fliplr0.0水平翻转后表盘上的数字全部镜像指针与刻度字的相对关系被破坏imgsz这条最容易低估。如果一份数据里表盘的平均短边是 380 像素而原图是 1920×1080那么 resize 到 640 之后表盘只剩 127 像素指针宽度不到 3 像素卷积核基本抓不到。这时候要么把imgsz提到 960 或 1280要么改成两级方案先训一个粗定位模型找到表盘区域再在裁剪图上训第二个模型。后者的显存开销小很多代价是多一次推理。mosaic和degrees是两个此消彼长的参数。表盘在图中占比大的数据集mosaic 开到 0.5 也无所谓占比小的mosaic 一定要压低甚至直接关掉。旋转同理倾斜 3 度以内的增强对应的是手持抖动倾斜 30 度对应的是你在教模型认识一个现场不存在的场景。学习率和轮次反而没那么挑。lr0从 0.01 起小数据集1000 张以下用 0.005 更保险epochs给 200靠早停收别指望 30 轮就收敛——表盘这种低纹理目标前 50 轮的 box loss 下降会慢得让你怀疑数据有问题。4.3 指标怎么读漏检率、中心误差与分工况评估mAP50到 0.9 以上之后这个数字就没什么信息量了继续盯它只会让你错过真正的问题。我一般看三个东西表盘检测的召回率。在读数链路里表盘漏检等于完全无法出读数误检还可以靠后续规则过滤。所以 dial 类的 recall 要单独拉出来看目标是 0.99 以上。如果 recall 卡在 0.95回去看漏检的那 5% 是什么工况——多半是强反光、表盘被遮挡一角或者拍摄距离过远。这些样本补进训练集的收益比调任何超参都大。表盘中心点误差。把预测框和标注框的中心点算欧氏距离除以标注框的短边得到归一化中心误差。这个指标直接决定下游找圆心的难度经验上要压到 0.05 以内。分工况的分组指标。按前面切分 val 用的三个维度分组分别算 recall 和中心误差。整体 0.97、但「远距离 强光」这一组只有 0.72这种结论才有指导意义——你会立刻知道下一批要补采什么样的数据。评估脚本不用写太复杂在验证集上跑完推理把每张图的文件名、预测框、真值框导成一张 CSV再用 pandas 按分组标签做groupby就行。分组标签在建 val 的时候顺手存下来后面一直能用。5. 指针仪表数据集排查五类翻车现场的现象、原因与解决5.1 数据与标注侧的两类问题训练 loss 正常下降验证 mAP 始终是 0。现象是所有类别的 AP 都是 0但框看起来很合理。原因基本只有一种data.yaml里的names顺序和转换脚本里的CLASSES不一致或者nc写成了类别数但 names 少写了一行。模型预测的类别 id 落在未定义的区间评估时匹配不上任何真值。解决方法是转换完成后立刻打印一次CLASSES和 yaml 逐行对照把这一步固化成流程而不是靠记性。提示 No labels found训练直接不启动。现象是日志里报找不到标签但ls labels/train | wc -l明明有几千个文件。原因通常是路径层级对不上训练框架按「把images替换成labels」的规则找 txt如果你的目录是images/train/xxx.jpg而标签在labels/xxx.txt少了一层 train替换后就找不着。还有一种情况是 txt 文件名带上了原扩展名变成xxx.jpg.txt。解决办法是把目录结构调整成镜像关系images/train对应labels/train文件名保持一致且不含扩展名。5.2 训练与评估侧的两类问题测试集 mAP 0.97换一批现场图掉到 0.6。现象是离线指标漂亮得不像话上线就崩。原因是训练集和测试集来自同一段连续采集帧间只差几毫秒切分本质上是对同一批样本的重复采样属于数据泄漏。解决方法是按采集批次而不是按帧切分同一个视频片段、同一台设备、同一个时间段拍的所有图只能落在同一个 split 里。已经切错的宁可重新划一遍也不要拿这份虚高的指标去汇报。指针框总是框在表盘正中间的一小块。现象是预测出来的 pointer 框又短又方不像指针。原因是标注时多个人对「指针从哪开始」理解不同有人从转轴标起有人只标了针尖那一段训练时模型学到的是两者的平均——一个居于中间的短框。解决方法是先做双人交叉标注验证把指针框的起点固定为转轴可见边缘、终点固定为针尖写进标注规范里然后对已有标注做一次抽检短边小于表盘短边 30% 的 pointer 框全部重标。5.3 上线侧的一类问题xml 的 size 和真实图片对不上框整体偏移。现象是转换完画回去看所有框都往左上角偏了一截偏移量和图片缩放比例成正比。原因是图片在某次整理中被批量缩放过xml 没同步更新。解决办法是转换脚本坚持用cv2.imread读到的真实尺寸做归一化同时把 xml 里的 size 和真实尺寸比对不一致的样本记进日志单独处理——这类样本的坐标本身可能也需要按比例换算不能只改分母。注意这五类问题里有四类在训练开始前就能发现前提是转换完一定把框画回去看五张图。6. 从检测框到读数把角度误差压到 1 度以内的验证习惯检测只是中间产物真正交付的是读数。我用得最多的是角度法拿 dial 框裁出表盘区域在裁剪图里找指针方向再通过标定角度线性映射到量程。核心函数不长但参数很挑import cv2, math, numpy as np def pointer_angle(crop_bgr): h, w crop_bgr.shape[:2] cx, cy w / 2.0, h / 2.0 gray cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2GRAY) gray cv2.GaussianBlur(gray, (5, 5), 0) # 深色指针在浅色表盘上用反相 OTSU 拿到指针与刻度的前景 _, mask cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) lines cv2.HoughLinesP( mask, 1, np.pi / 360, threshold30, # 累加器阈值噪声多就往上调 minLineLengthint(min(w, h) * 0.25), # 至少四分之一表盘直径 maxLineGap6, ) if lines is None: return None best, best_len None, 0.0 for x1, y1, x2, y2 in lines[:, 0]: # 只保留过圆心附近的线段滤掉刻度线和外框 num abs((y2 - y1) * cx - (x2 - x1) * cy x2 * y1 - y2 * x1) den math.hypot(y2 - y1, x2 - x1) 1e-6 if num / den 0.08 * min(w, h): continue L den if L best_len: best, best_len (x1, y1, x2, y2), L if best is None: return None x1, y1, x2, y2 best # 屏幕坐标 y 向下取负号换到数学坐标系 return math.degrees(math.atan2(-(y2 - y1), x2 - x1)) % 360.00.08 * min(w, h)这个容差是这条链路里最需要调的参数。表盘圆心和框中心一般差不了几个像素容差给太小指针线因为轻微弯曲被全滤掉给太大外圈的刻度线会混进来选到最长的那根刻度角度直接跑偏几十度。我是按表盘半径的 8% 起试在验证集上扫一遍 0.05 到 0.15挑角度误差中位数最低的那个。角度拿到之后不能直接当读数必须标定。找两张图一张指针明确指在零位一张明确指在满量程分别跑出theta_zero和theta_full之后按(theta - theta_zero) % 360 / ((theta_full - theta_zero) % 360) * range线性映射。跨 0 度的那一段一定要取模不然会算出负值。表盘如果是非线性的有些压力表刻度前密后疏就在零位、25%、50%、75%、满量程各标一个点做分段线性插值。验证习惯上我每次交付前一定做同一件事抽 20 张图人工读数写在纸上再和脚本结果逐个比对算绝对误差的中位数和 95 分位。中位数能压到满量程的 0.5% 以内、95 分位在 1.5% 以内这套链路才算能用。有一次我省了这一步客户拿回来的第一批数据里有一半读数偏了整整一格查下来是指针尖端有个高光点霍夫线被它拽偏了几度——这种翻车只有拿人工读数对着看才能发现任何离线的检测指标都照不出来。希望帮到你。本文还有配套的精品资源点击获取