简介一套面向物联网与自动化场景的仪表数据读取技术资料包聚焦传统指针式水表的自动识别与读数解析。内容围绕“passagegmd”算法思路覆盖图像采集、预处理、指针定位、刻度识别与数据解析等核心环节适合希望用计算机视觉替代人工抄表的开发者、学生或公用事业技术人员。压缩包共34个文件总大小7.42MB包含31张JPG表盘测试图片、2个Python脚本模板匹配法、计算刻度值以及1份docx方案文档图片样本可用于算法调试与效果验证。目前已有349人学习/下载。读者可结合代码与文档跑通表盘图像到刻度输出的完整流程理解模板匹配法在指针仪表读数中的实现细节掌握刻度计算的核心逻辑为后续开发水表识别系统或扩展其他指针类仪表读取提供直接参考。1. 指针式表盘读数为什么 passagegmd 能把水表识别做成一条流水线摄像头拍回来的指针式水表照片十张有八张是歪的反光把指针拦腰截断刻度线糊成一团。过去靠模板匹配和霍夫找线换个光照就崩后期维护全是血泪。passagegmd 这类掩码分割思路把问题拆成了两段先用检测框出表盘并透视校正再用掩码把指针、刻度线、量程区域分开最后用几何换算读数。这条链路把原来“找特征点”的黑匣子变成了“分割 角度映射”的白盒流程哪一步出问题都能单独排查。这篇就按这条链路完整写一遍包含可抄的代码和参数配置适合做抄表自动化、工厂仪表点检的工程师。2. 先检测后纠正把歪着拍的水表表盘端平2.1 表盘检测用 YOLO 就够了光照变化交给分割去扛水表图片进来第一步不是分割而是把表盘从背景里抠出来。很多新手直接上分割模型结果表盘外的水管、墙面、反光干扰全部被当成前景后处理能把你逼疯。常见做法是先跑一个目标检测模型框出表盘再调用分割模型去做部件级识别两个模型各管一段调试起来也方便。我用的是 YOLO 系列做表盘检测训练成本极低。数据标注只需要一类water_meter标注完存成dial.yaml# dial.yaml path: ./dataset/dial train: images/train val: images/val nc: 1 names: 0: water_meter训练命令我一般直接写进脚本不手敲。这里贴一段最简配置from ultralytics import YOLO # 用预训练权重起步比从零训收敛快很多 model YOLO(yolov8n.pt) model.train( datadial.yaml, epochs100, imgsz640, batch16, patience20, # 验证集指标 20 轮不涨就早停 save_dir./runs/dial, )检测模型选yolov8n不是越大越好。表盘检测只出一类目标而且表盘在画面里通常是主体用 nano 级别就够推理在 CPU 上也能跑到每秒十帧以上。imgsz640是速度和精度的折中如果相机距离表盘很近导致框切边可以提到 800代价是训练和推理变慢。检测推理这一步注意conf和iou两个参数。抄表场景画面干净conf0.5就够如果表盘被玻璃反光遮住一半导致置信度掉到 0.4 以下宁可漏检也不要误检因为后面透视校正会把你带偏results model.predict(meter_001.jpg, conf0.5, iou0.45) boxes results[0].boxes.xyxy[0].cpu().numpy() # [x1, y1, x2, y2] x1, y1, x2, y2 [int(v) for v in boxes] crop img[y1:y2, x1:x2]拿到检测框之后先别急着送分割模型。实际项目里摄像头安装角度千奇百怪有的俯视 60 度有的侧视 30 度表盘在框内是椭圆的直接分割出的指针角度和刻度位置全是畸变的。所以下一步是透视校正把椭圆表盘还原成正圆。2.2 透视校正不是四角点就完事圆拟合才是正解透视校正的典型做法是找表盘外圈的四个点算单应性矩阵然后 warp。听起来简单但手动标四个角点这种事在这个场景下就是给自己埋雷表盘外圈有缺口、有螺丝、有阴影四个点稍微偏移 2 个像素校正后表盘就是瘪的后面角度全废。我用的是圆拟合法。水表表盘外圈是一个明确的圆用霍夫圆检测先拿到圆心和半径再从圆上取四个等分点构造源四点转成目标四方块import cv2 import numpy as np gray cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) # 先做中值滤波去掉表盘玻璃上的细碎反光 gray_blur cv2.medianBlur(gray, 5) edges cv2.Canny(gray_blur, 50, 150) h, w gray.shape[:2] min_r int(h * 0.4) # 表盘半径下限 max_r int(h * 0.6) # 表盘半径上限 circles cv2.HoughCircles( edges, cv2.HOUGH_GRADIENT, dp2, minDistint(h * 0.4), param1100, param230, minRadiusmin_r, maxRadiusmax_r, )dp2是累加器分辨率减半能显著减少误检代价是圆心位置精度略降但对于后续校正足够。param230是累加器阈值设低了会出现一堆假圆设高了表盘有反光时检测不到一般从 30 开始调。拿到圆心(cx, cy)和半径r后从 12 点方向开始顺时针取四个点src_pts np.array([ [cx, cy - r], # 12 点方向 [cx r, cy], # 3 点方向 [cx, cy r], # 6 点方向 [cx - r, cy], # 9 点方向 ], dtypenp.float32) rect_size int(2 * r) dst_pts np.array([ [0, 0], [rect_size, 0], [rect_size, rect_size], [0, rect_size], ], dtypenp.float32) M cv2.getPerspectiveTransform(src_pts, dst_pts) warped cv2.warpPerspective(crop, M, (rect_size, rect_size))src_pts和目标dst_pts的顺序必须一致都是顺时针且从同一方向开始否则矫正完表盘是旋转 90 度的指针角度映射时又得兜圈子。四个源点直接取圆上等分点比手动点外圈四角稳定得多它不依赖表盘边框的加工精度只要表盘外圈本身是圆形的就能校正到位。校正后的warped图基本是正圆表盘后续分割和读数才能站得住。这一步做完如果发现表盘仍然有椭圆残留大概率是霍夫圆检测把半径计算偏了检查minRadius和maxRadius范围是否覆盖真实表盘。3. 用 passagegmd 做表盘部件分割掩码比关键点更抗遮挡3.1 为什么这里不用关键点回归——遮挡与粘连表盘部件识别有两种路线一种是检测指针尖端和刻度线的关键点一种是输出分割掩码。关键点这条路在干净场景下很快但水表实拍图里反光、玻璃划痕、指针和刻度线相互遮挡是常态。关键点一旦被反射光吞掉回归出来的坐标就直接飘移纠错成本极高。passagegmd 走的是掩码分割路线模型接收图像和一个或多个提示点直接输出每个表盘部件的像素级掩码。它和传统分割模型最大的区别是带 prompt 交互你可以点一个表盘中心的坐标作为提示点模型就知道要去分割这个表盘区域也可以在同一个推理里给多个提示点一次性拉出指针、刻度线、量程块。这样表盘部件之间的遮挡关系被保留在掩码里而不是压缩成一个关键点坐标抗干扰能力明显更强。另一个实际原因是关键点检测需要给每个部件单独标注点一气标几百张图眼睛就花掩码标注虽然在单张上更耗时但一个训练集可以复用多种表型实际抄表项目里往往就是几十张图做模板标定掩码的方式更划算。3.2 passagegmd 推理流程prompt 点与类别掩码分割模型我用 passagegmd 的预训练权重做迁移。它的输入是 BGR 图像加提示点输出是按类别排列的掩码图。对水表识别我约定通道顺序为0: pointer, 1: scale_line, 2: range_region这里的range_region是量程块即刻度线围成的扇形区域from passagegmd import PassageGMD model PassageGMD(ckptpassagegmd_water.pt, devicecuda:0) # 提示点取校正后表盘的中心 prompt_points [(int(rect_size / 2), int(rect_size / 2))] labels [1] # 1 表示前景提示 mask model.predict( imagewarped, # 校正后的正圆表盘 prompt_pointsprompt_points, labelslabels, cls_threshold0.45, # 类别置信度阈值 mask_threshold0.5, # 掩码二值化阈值 ) # mask 形状: (3, H, W) pointer_mask mask[0] 0.5 scale_mask mask[1] 0.5 range_mask mask[2] 0.5cls_threshold控制类别过滤低于这个值的类别掩码直接置零。水表表盘里有的是老式湿式表刻度线年久发黄类别置信度低可适当降到 0.4但降到 0.35 以下就会出现刻度线被识别成指针的翻车现场。mask_threshold控制掩码二值化的敏感度0.5是经验安全值低于 0.4 时掩码边缘毛刺会非常多后处理要做大量清理。提示点可以给多个。比如碰到表盘里有干扰字符可以额外加一个负样本提示点让模型知道那一块不算部件。这个交互方式是 passagegmd 比传统分割模型顺手的核心原因不用改训练数据就能在推理时告诉模型避开干扰。3.3 把掩码回收成指针 MASK 与刻度骨架模型输出的是阵列掩码不能直接算角度需要先做形态学清理和结构提取。我用两步先对指针掩码做闭运算把反光造成的断笔接上再用连通域提取拿到指针区域的外接几何。import cv2 import numpy as np # 闭运算接上细断裂核大小要看表盘分辨率 kernel np.ones((5, 5), np.uint8) pointer_bin cv2.morphologyEx(pointer_mask.astype(np.uint8), cv2.MORPH_CLOSE, kernel) # 只保留最大连通域滤掉误判的小噪点 contours, _ cv2.findContours(pointer_bin, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: pointer_contour max(contours, keycv2.contourArea) pointer_only np.zeros_like(pointer_bin) cv2.drawContours(pointer_only, [pointer_contour], -1, 1, thickness-1)画外音说一下kernel大小校正后表盘图如果是 500x5005x5的核足够接住细裂缝如果图缩到 200x200同样粗细的裂缝可能跨 3 个像素核改成3x3就好。核过大会把指针和刻度线粘在一起后期角度计算直接把刻度当成指针的一部分读数偏得离谱。刻度线掩码我做了骨架化处理。骨架化的好处是让每根刻度线退化为单像素线这样霍夫直线检测的参数不用反复调from skimage.morphology import skeletonize # 骨架化前先去掉孤立噪点否则会把噪点拉成长线 scale_bin cv2.morphologyEx(scale_mask.astype(np.uint8), cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) skeleton skeletonize(scale_bin 0) lines cv2.HoughLinesP( (skeleton * 255).astype(np.uint8), rho1, thetanp.pi / 180, threshold30, minLineLength10, # 短于 10 像素的线段直接丢弃 maxLineGap5, )minLineLength10这个参数在 500x500 的表盘图上刚好能滤掉数字字符的碎线段如果表盘分辨率是 1000x1000建议提到 20。刻度线在掩码里断成两截时maxLineGap5会把它们接回来超过 8 就小心了量程块的边缘会被当成刻度线接进来产生假直线。这节做完整理出两样东西一块干净的指针掩码、一组刻度线。下一步就是把它们换算成读数。4. 从掩码到读数指针角度、量程映射与小数位处理4.1 从像素坐标到指针方向角atan2 里的象限坑指针掩码算角度我不用最小外接矩形而是用 PCA 主方向。最小外接矩形对细长指针还行但指针一旦带尾巴或者头部有阴影矩形方向会被尾巴带偏十几度。PCA 对像素分布求协方差矩阵主特征向量就是指针长轴方向更抗噪points np.column_stack(np.nonzero(pointer_only 0)) # 每行是 (y, x) # 去中心化后算协方差 mean points.mean(axis0) cov np.cov(points.T) w, v np.linalg.eig(cov) # 最大特征值对应的特征向量即主方向 main_dir v[:, np.argmax(w)] # 用投影判断哪一端是指针尖 proj (points - mean) main_dir tip points[np.argmax(proj)] # 投影值最大的一端是针尖 # 以 12 点为 0 度顺时针为正 angle_deg np.arctan2(-(tip[0] - mean[0]), tip[1] - mean[1]) * 180.0 / np.pi angle_deg (angle_deg 90.0) % 360.0这里有两个坑每一个都值得单独说。第一个坑是坐标轴。np.nonzero返回的是(y, x)顺序不是(x, y)。atan2第一参数传的是y方向差值而图像坐标系y是向下的所以必须取负号才能保证角度按表盘顺时针增长。我第一次跑的时候顺序没留意指针明明指向 3 点算出来却在 9 点方向因为角度符号反了。第二个坑是零度对齐。表盘的 0 刻度不一定在正上方但角度公式默认 12 点为 0 度。解决办法是引入一个偏置角offset_angle在前面算出的angle_deg基础上减掉它offset_angle 10.0 # 0 刻度线偏离正上方的角度部署时标定一次 calibrated_angle (angle_deg - offset_angle) % 360.0offset_angle是每次部署时必须重新标定的因为相机安装角度一变它就变。标定方法很简单把表盘数值拨到 0 位或者整数位跑一次上面的角度计算把输出值记下来写进配置。不要想着自动算手动标一次最稳。4.2 量程标定 JSON 与角度→读数的线性映射角度算出来之后需要知道它对应多少数值。这一步依赖量程标定做法是把每个表盘的起止刻度和量程存成一个 JSON 文件。同一个型号的水表可以共用一份换型号重新标定{ type: water_meter_dn15, min_angle: 15.0, max_angle: 345.0, scale: 10.0, unit: m3, multiplier: 1.0 }min_angle是量程起点刻度对应的角度max_angle是量程终点刻度对应的角度。读数计算逻辑如下import json def angle_to_reading(angle_deg: float, calib: dict) - float: # 角度范围归一化注意跨零情况 span (calib[max_angle] - calib[min_angle]) % 360.0 # 指针角度同样做环绕保证两个角度的差值落在 [0, 360) pos (angle_deg - calib[min_angle]) % 360.0 ratio pos / span ratio max(0.0, min(1.0, ratio)) return ratio * calib[scale] * calib[multiplier] with open(calib_dn15.json, r) as f: calib json.load(f) reading angle_to_reading(calibrated_angle, calib) print(fcurrent reading: {reading:.3f} m3)multiplier这个字段是给小数位红针准备的。水表和电表不一样的地方在于它有 0.1 位红指针一圈也就是主轴走 0.1 立方米。如果你只听主指针读数会少一个量级。常见做法是把红针区域单独裁出来用小量程 JSON 算 0.1 位读数再和主针读数相加sub_calib { min_angle: 0.0, max_angle: 360.0, scale: 1.0, multiplier: 0.1, } sub_reading angle_to_reading(calibrated_angle_red, sub_calib) total_reading reading sub_reading注意如果主表盘量程跨过了 0 度起点比如min_angle330、max_angle30这个跨零场景是减法求差最容易算错的地方。我上面的angle_to_reading已经用取模处理了跨零别自己手写差值然后判断是否大于 360那样写十有八九逢月初就翻车。5. 水表读取实战避坑反光、断笔、零位和小数红针5.1 反光把指针拦腰截断掩码碎成两段角度跑偏现象指针掩码在反光位置断裂最大连通域只剩半截指针PCA 算出主方向指向 10 点实际读数该是 2 点。原因表盘玻璃反光区域的灰度与背景接近passagegmd 把反光处判成了背景掩码出现空洞。解决第一步是打光调整侧前方 45 度打光可以大幅减少正反射第二步加形态学闭运算用宽于裂缝的核把断口接起来。如果这两个都解决不了就把反光区域裁掉在提示点里加一个负样本提示点告诉模型那边不是指针让模型把完整指针从周围区域推理出来。5.2 表盘校正后还是椭圆角度计算系统性偏差现象校正后的表盘图肉眼看着有些扁指针角度整体偏 5 到 8 度。原因手动标注表盘外圈四角点时没有落在同一个圆上或者霍夫圆检测的半径不匹配导致四点映射时表盘被拉伸。解决用圆拟合代替手动标四点就是 2.2 节的HoughCircles方案。如果圆检测不稳定可以换椭圆拟合用掩码外轮廓上的点拟合椭圆再把椭圆长轴作为圆的直径从椭圆上取四点做等分。从那以后我每次见到表盘图先问一句“这是不是圆的”不是圆不做点数。5.3 零刻度不在 12 点方向角度映射全线偏移现象表盘 0 刻度在 11 点方向角度公式按 12 点算读数偏一格甚至两格。原因不同厂家水表表盘印刷的起点位置不统一有的在 10 点有的在 11 点半。解决部署时手动标定offset_angle把指针指向 0 刻度时的角度写死。标定过程不要偷懒一定要用表盘图片实际跑一遍角度计算拿输出值存进 JSON。你要问能不能自动标定能但自动标定属于模型训练时的事推理阶段手动标一次最可靠因为光线和安装角度一起变。5.4 红针干扰主针读数每次多出 0.1现象输出值比人工抄表大 0.1 到 0.2且误差不固定。原因红针所在子表盘与主表盘在同一个图上分割模型有时把红针区域误分割成主指针的一部分导致主指针角度偏向红针方向。解决给 passagegmd 增加一个red_pointer类别单独分割红针或者把红针区域从主表盘图中裁掉做成子图单独识别。实际项目里选后者更多因为不需要重新训练模型只需在前处理里定位子表盘中心裁一个正方形 ROI再跑一遍分割和角度计算。5.5 mask_threshold 调太低刻度线粘连成实心块现象刻度线掩码连成一片霍夫直线检测输出几十条乱七八糟的线段刻度起点终点定位失败。原因mask_threshold设到 0.3 附近把刻度线附近的量程区域背景也当作前景相邻刻度线边缘融合。解决把mask_threshold回到 0.5 以上同时把量程区域掩码暂时忽略掉只用刻度线掩码本身做骨架化和HoughLinesP。另外检查minLineLength要是刻度线本身长度只有 15 像素minLineLength20会把有效刻度也滤掉经验值是取刻度线长度的三分之二。6. 一个让批量读数误差收敛的小习惯模板标定与 PCA 方向验证批量识别水表读数时误差不会自己暴露必须有一套验证流程。我每次交付前都会跑一轮对照脚本把模型读数与人工读数放在同一个 CSV 里然后计算 MAPE 和最大绝对误差。这个习惯帮我抓出过很多莫名其妙的坑包括上面说的红针干扰和零位偏移。import csv import numpy as np preds, truths [], [] with open(readings.csv, r) as f: reader csv.DictReader(f) for row in reader: preds.append(float(row[model])) truths.append(float(row[manual])) preds np.array(preds) truths np.array(truths) valid truths 0.01 # 过滤人工读数接近 0 的样本 mape np.mean(np.abs(preds[valid] - truths[valid]) / truths[valid]) * 100 max_err np.max(np.abs(preds[valid] - truths[valid])) print(fMAPE: {mape:.2f}%, max abs error: {max_err:.3f} m3)这套脚本每次跑完看一眼MAPE 超过 5% 就直接看错误样本的掩码图。我通常会把指针掩码和刻度线掩码叠加在原图上存下来排查时一目了然。比对着数字猜根因强太多。最后分享一个小技巧验证指针角度时除了 PCA 主方向可以顺带把v[:, np.argmin(w)]打印出来这是短轴方向。正常情况下短轴方向的像素方差应该远小于长轴。如果两者比值大于 0.6说明指针掩码已经胖成圆饼了多半是指针和刻度线粘连在一起这时候算出的角度根本没有参考意义。我一般会把这个比值作为置信度指标超过阈值就判定为读取失败输出None让后端去重拍而不是硬给一个错误读数。从那以后我每次上线新点位都会强制走一遍这套流程透视校正 → 分割 → PCA 角度 → 量程映射 → 对照人工读数。多跑这几步能省下后期夜间值班处理误报的时间。希望帮到你。本文还有配套的精品资源点击获取