简介:面向工业自动化仪表读数场景,压缩包聚焦倾斜图像矫正与指针识别任务,适合从事机器视觉、仪表自动检测的开发者或相关方向学习者使用。包内共6个文件,包含3个MATLAB脚本与3张示例图片(jpg/jpeg),总计仅154KB,脚本分别对应矫正、边缘检测、分割与读数识别等环节,图片可用来验证算法在不同倾斜条件下的表现。目前已有417人学习下载,可快速获取一套精简的仪表识别参考实现。资源涵盖透视变换矫正、Hough变换定位指针、图像分割以及基于深度学习的读数预测等关键步骤,既提供可直接运行的.m脚本,也展示了数据增强与模型训练的扩展思路,能够帮助读者理解仪表读数识别系统的完整搭建方法,并在此基础上针对自身场景进行二次开发。
1. 指针仪表识别,第一关不是识别,是先把图像摆正
工业现场的指针仪表读数,视觉链路很短:找表盘、找指针、角度换数值。真正让链路断掉的往往不是识别,而是表盘在画面里歪了——相机光轴没正对表盘时,圆形表盘被压成椭圆,指针角度跟着偏,读数误差随倾斜角急剧放大。做过指针识别的人都有体会:图像倾斜矫正做好了,后面的流程才顺理成章;摆不正,调多少阈值都在补歪地基。
下面按一条可复现的路径讲:椭圆拟合反推表盘姿态做倾斜矫正,指针检测、刻度基准定位、仪表读数识别,最后给合成数据验证方法。代码基于 OpenCV + NumPy,面向巡检图片、变电站表计这类倾斜图像,读完能独立搭起最小可用方案,也知道每层失败该看什么。
2. 图像倾斜矫正:从椭圆参数反推表盘姿态
2.1 倾斜分三类,处理方式完全不同
表盘在图像里的"歪"要分清楚。第一类是平面内旋转,表盘整体绕相机光轴转了一个角度,刻度跟着转,但形状还是正圆,用仿射旋转就能摆正。第二类是平面外倾斜,相机光轴和表盘平面法线有夹角,圆的投影变成椭圆,这种必须用透视变换。第三类是两者叠加,也是现场最常见的情况。
判断依据很简单:对表盘外缘做椭圆拟合,长短轴比值接近 1 就是平面内旋转;比值明显小于 1 说明存在平面外倾斜,倾斜角约等于 arccos(短轴/长轴)。这个几何关系是图像倾斜矫正的出发点。一个常见误用是只对图像做旋转或缩放,那治不好平面外倾斜,必须按椭圆参数重建透视变换。
提示:HoughCircles 对平面外倾斜超过 15° 的图经常漏检,倾斜越明显,越应该直接走椭圆拟合分支。
2.2 用霍夫变换兜底定位表盘,失败再走椭圆拟合
先试最快的一条路:HoughCircles 直接找圆。
import cv2 import numpy as np img = cv2.imread("meter.jpg") h, w = img.shape[:2] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) circles = cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp=1.2, # 累加器分辨率,1.2 表示累加器尺寸为原图的 1/1.2 minDist=h // 4, # 两个圆心之间的最小距离,防止同一圆重复检出 param1=150, # Canny 高阈值,边缘噪声大时适当降低 param2=90, # 圆心累加阈值,越大越可靠但越容易漏检 minRadius=int(min(h, w) * 0.15), maxRadius=int(min(h, w) * 0.45), ) if circles is not None: cx, cy, r = np.round(circles[0][0]).astype(int)HoughCircles 返回多个候选圆时,通常第一个是累加值最高的。若画面里同时出现多个表盘,按半径接近 min(h, w)/4 的尺寸筛选更稳。dp、param1、param2 是三个最影响结果的参数,固定机位下建议拿 10 张不同光照的图各跑一遍,找一个所有图都能出圆的折中值。
| 参数 | 作用 | 推荐起点 | 调参方向 |
|---|---|---|---|
| dp | 累加器与输入图分辨率比 | 1.2 | 图越大 dp 越大,换取速度 |
| minDist | 圆心最小间距 | h//4 | 多个圆粘连时调大 |
| param1 | Canny 高阈值 | 150 | 背景纹理多时降到 100~120 |
| param2 | 圆心累加阈值 | 90 | 假圆多则调大,漏检则调小 |
| min/maxRadius | 半径搜索范围 | 0.15h~0.45h | 按表盘在画面中的占比收窄 |
HoughCircles 对倾斜图像经常漏检或检出偏小的圆,因为椭圆在累加器里凑不齐足够的圆心票数。这个场景直接切到轮廓加椭圆拟合,几乎不受倾斜影响:
edges = cv2.Canny(gray, 50, 150) contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 只保留面积足够大的轮廓,过滤刻度和文字形成的小碎片 contours = [c for c in contours if cv2.contourArea(c) > np.pi * (0.1 * min(h, w)) ** 2] contour = max(contours, key=cv2.contourArea) # fitEllipse 返回 (中心, (长轴, 短轴), 旋转角) center, axes, phi = cv2.fitEllipse(contour) cx, cy = map(int, center) major, minor = sorted(axes, reverse=True) # 短轴/长轴比值的反余弦,就是表盘平面相对相机光轴的倾斜角 tilt_cos = minor / major tilt_deg = float(np.degrees(np.arccos(np.clip(tilt_cos, 0.0, 1.0))))椭圆拟合只依赖边缘形态,不需要表盘是完整正圆,所以倾斜和部分遮挡场景比霍夫圆稳。代价是对轮廓质量敏感,背景里若有形状接近椭圆的区域会直接拟合到错误目标。常见做法是先用面积和长短轴比例限制候选轮廓,再取最大那个。
2.3 椭圆四顶点映射到正圆,完成透视矫正
拿到椭圆参数后,把椭圆长短轴的四个端点映射到正圆的四个端点,用 getPerspectiveTransform 解出单应矩阵:
def unwarp_dial(img, center, axes, phi): cx, cy = map(int, center) major, minor = axes phi_rad = np.deg2rad(phi) cos_p, sin_p = np.cos(phi_rad), np.sin(phi_rad) # 椭圆上的四个特征点:长轴两端、短轴两端 src = np.float32([ [cx + major / 2 * cos_p, cy + major / 2 * sin_p], [cx - major / 2 * cos_p, cy - major / 2 * sin_p], [cx - minor / 2 * sin_p, cy + minor / 2 * cos_p], [cx + minor / 2 * sin_p, cy - minor / 2 * cos_p], ]) # 目标:以长轴半径为半径的正圆上的四个点 R = major / 2.0 dst = np.float32([ [cx + R, cy], [cx - R, cy], [cx, cy + R], [cx, cy - R], ]) M = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(img, M, (img.shape[1], img.shape[0]))src 里前两个点是长轴端点,后两个是短轴端点,顺序与 dst 一一对应即可,不用管方向;getPerspectiveTransform 只要求四点不共线。这个映射在数学上不是严格的投影逆变换,但对圆盘类表盘是工程上足够好的近似:它能同时纠正平面外倾斜造成的椭圆变形和刻度压缩。倾斜只来自俯仰或偏航单一方向时,矫正后刻度分布误差可控制在 1% 以内;两个方向都有倾斜时误差略升,通常不影响后续读数识别。若只是平面内旋转,用 getRotationMatrix2D 配合 warpAffine 就够,别上透视变换。
2.4 矫正效果的量化校验
矫正完不要直接往下走,先量化验证。用圆形度 4πA/P² 判断表盘外缘是否接近正圆:
def circularity_of_largest(binary): contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return 0.0 c = max(contours, key=cv2.contourArea) area = cv2.contourArea(c) perim = cv2.arcLength(c, True) return 4 * np.pi * area / (perim * perim)正圆的圆形度为 1.0;未矫正的倾斜表盘通常在 0.75~0.92;矫正后应稳定在 0.95 以上。低于这个值,先查椭圆拟合是否咬住了外缘,再看 warpPerspective 输出是否被边界截断。这个校验值建议写进日志,和后面的读数一起落盘,方便回溯是哪一层引入的误差。
3. 指针识别:从矫正图像里提取指针方向
3.1 先做颜色分割,再排除表盘中心转轴
倾斜矫正完成后,表盘变成正圆,指针识别回到一个相对标准的子问题:在圆盘内找一条指向特定角度的线段。最常见的指针是红色和黑色。红色指针用 HSV 两个区间取并集,因为红色在 H 通道跨 0° 边界;黑色指针则走灰度阈值加连通域过滤。
hsv = cv2.cvtColor(warped, cv2.COLOR_BGR2HSV) # 红色在 H 通道跨 0° 边界,必须取两个区间 mask_red = cv2.inRange(hsv, (0, 80, 60), (10, 255, 255)) mask_red |= cv2.inRange(hsv, (170, 80, 60), (180, 255, 255)) # 表盘中心转轴区域会干扰角度计算,直接挖掉 cv2.circle(mask_red, (cx, cy), max(5, int(r * 0.10)), 0, -1) mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) mask_red = cv2.morphologyEx(mask_red, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))OPEN 先消掉孤立噪点,CLOSE 再把指针上的细缝和暗点补上。中心圆盘的半径取 r 的 10% 左右,具体值看转轴黑影实际大小;挖太大可能切掉短指针的根部。
| 指针颜色 | H 范围 | S 下限 | V 下限 | 典型干扰 |
|---|---|---|---|---|
| 红色 | 0~10 与 170~180 | 80 | 60 | 红色刻度线、防伪标记 |
| 黑色 | 任意 | 任意 | 0~90(亮度) | 阴影、刻度文字 |
| 白/银 | 任意 | 0~30 | 200 以上 | 表玻璃反光 |
黑色指针比红色难处理,因为刻度和数字也是黑色。常见做法是灰度阈值二值化后按连通域过滤,保留细长比高、面积最大的那块区域:
_, dark = cv2.threshold(gray_w, 90, 255, cv2.THRESH_BINARY_INV) num, labels, stats, _ = cv2.connectedComponentsWithStats(dark, 8) best_area = 0 ptr_mask = np.zeros_like(dark) for i in range(1, num): x, y, bw, bh, area = stats[i] if area < 50: continue # 指针细长,宽高比面积比大于 3 的才可能是指针 if (bw * bh / area > 3.0) and (area > best_area): best_area = area ptr_mask = np.where(labels == i, 255, 0).astype(np.uint8)连通域过滤的假设是指针与刻度不相连。如果指针搭在长刻度线上导致连通域合并,先用半径 r 的 3~5% 的结构元素做一次 OPEN 断开细连接,再跑连通域。
3.2 骨架化加最远点法确定指针方向
指针掩膜出来后,方向计算有两种可靠写法。第一种是对掩膜做骨架化,把指针缩成单像素线,再取离圆心最远的骨架点作为指针尖端:
from cv2.ximgproc import thinning def pointer_direction_farthest(mask, cx, cy): skel = thinning(mask) # Zhang-Suen 细化,输出单像素骨架 ys, xs = np.where(skel > 0) if len(xs) < 10: return None, None dx = xs - cx dy = ys - cy dist = np.hypot(dx, dy) tip = int(np.argmax(dist)) # 离圆心最远的骨架点 angle = np.degrees(np.arctan2(dy[tip], dx[tip])) return angle, dist[tip]不推荐用掩膜质心算角度:指针通常中心对称,质心落在转轴附近,角度对分割误差极其敏感。最远点法的假设是指针尖端一定在指针上且离圆心最远,只要骨架没有断头或毛刺,角度就稳定。thinning 在 opencv-contrib-python 里;如果环境里装的是基础版,可以反复用 3x3 结构元素腐蚀直到面积不再明显减少,得到的粗骨架取最远点,方向误差也能接受,只是慢一些。
第二种是 fitLine 拟合所有指针点。它对遮挡更鲁棒,指针被阴影盖住一段时仍能拟合方向,但遇到弧形指针会偏。两个方法都跑,角度差超过 3° 就认为分割有问题,回去查 HSV 阈值。这个交叉验证能拦截相当一部分误检。
3.3 刻度基准定位:径向线段绕圆心聚类
读数识别需要知道零刻度和满刻度的角度。借助 HoughLinesP 在表盘刻度环区域找径向短线,再按每条线段中点的极角聚成几簇:
# 只保留表盘外缘 0.6R~0.85R 的环形区域做直线检测 ring = np.zeros_like(gray_w) cv2.circle(ring, (cx, cy), int(r * 0.85), 255, -1) cv2.circle(ring, (cx, cy), int(r * 0.60), 0, -1) lines = cv2.HoughLinesP(edges & ring, 1, np.pi / 360, 40, minLineLength=int(r * 0.15), maxLineGap=3) def mid_angle(line, cx, cy): x1, y1, x2, y2 = line[0] mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0 return (np.degrees(np.arctan2(my - cy, mx - cx)) + 360.0) % 360.0 angles = sorted(mid_angle(l, cx, cy) for l in lines)环形区域限制很关键,直接在整个表盘上跑 HoughLinesP,指针本身和刻度数字都会被检出来。拿到所有刻度的极角后,按角度差做一维聚类,间隔小于 8° 的归为同一刻度位置的多次命中,取每簇中值;最长或面积最大的簇通常是整十刻度,配合表盘量程就能定位零点和满刻度。这一步不识别刻度上的数字,只依赖几何位置。
3.4 角度卷绕与刻度区间计算
零点刻度和满刻度可能跨过 -180°/180° 边界,例如零点在正上方偏左、满度在正上方偏右,直接做减法会得到接近 360° 的错误跨度。正确的区间计算是找角度集合的最大间隙:
def scale_interval(scale_angles_deg): a = np.deg2rad(np.sort(np.array(scale_angles_deg) % 360.0)) a = np.concatenate([a, [a[0] + 2 * np.pi]]) gaps = np.diff(a) gap_idx = int(np.argmax(gaps)) start = a[(gap_idx + 1) % (len(a) - 1)] # 最大间隙后的第一个刻度 span = 2 * np.pi - gaps[gap_idx] return np.degrees(start) % 360.0, np.degrees(span)返回的 start 是零刻度角度,span 是零点到满度的顺时针跨度。间隙最大处就是刻度不覆盖的区域,从它后面那个刻度开始,连续转过的角度就是量程跨度。把这两个值连同表盘量程(比如 0~1.6 MPa)记下来,仪表读数识别就只剩角度映射了。
4. 仪表读数识别:角度到数值的映射与干扰排除
4.1 线性映射公式与越界保护
指针角度、起始刻度角度、刻度跨度三者确定后,读数就是纯算术。关键在越界处理,指针可能停在刻度区间之外:
def angle_to_value(pointer_angle, scale_start, scale_span, v_min, v_max): rel = (pointer_angle - scale_start) % 360.0 if rel <= scale_span: ratio = rel / scale_span elif rel - scale_span < 360.0 - rel: ratio = 1.0 # 在满刻度外侧,钳到满量程 else: ratio = 0.0 # 在零刻度外侧,钳到零点 return v_min + ratio * (v_max - v_min)rel 先统一到 [0, 360),落在跨度内就正常映射;落在跨度外说明指针在刻度的空白区,此时比较它与两端刻度的距离,就近钳位。这个逻辑对 270° 大弧度的压力表也成立,前提是 scale_span 本身小于 360°。识别完成后把 pointer_angle、scale_start、scale_span 一起写日志,方便复现。
绝大多数表盘是线性刻度。遇到非线性刻度(压力表起步段压缩、燃油表不线性),改用插值表:
# 每一行是 (刻度值, 指针角度),角度按实际表盘标定 lut = np.array([ (0.0, 15.0), (0.4, 60.0), (0.8, 105.0), (1.2, 150.0), (1.6, 195.0), ]) value = np.interp(pointer_angle, lut[:, 1], lut[:, 0])np.interp 要求第二列单调递增,标定时跨 0° 的角度必须先统一到连续区间,否则插值结果会跳变。刻度少的表盘建议多采几个点,插值密度直接决定非线性段的精度。
4.2 反光、阴影与中心黑影的排除顺序
现场图片的表玻璃反光和指针阴影是读数识别最大的噪声源。处理顺序固定为:先倾斜矫正,再灰度转换和中值滤波,最后才做分割。反光区域亮度通常在 240 以上且成片出现,用亮度掩膜剔除:
glare = cv2.inRange(gray_w, 240, 255) mask_red[glare > 0] = 0中心转轴黑影与黑色指针的区分要按连通域来做:把掩膜按连通域拆分后,保留面积最大且最长距离在 0.15R~0.9R 之间的那一个。长度小于 0.15R 的连通域大概率是转轴或螺丝,大于 0.9R 说明指针已伸到刻度环外面,两者都不可信。
| 现象 | 常见原因 | 检查点 |
|---|---|---|
| 读数整体偏大或偏小 | 刻度起始角标错 | 核对 scale_start 与零点实际位置 |
| 读数在相邻帧间抖动 | 指针分割不稳定 | HSV 阈值、反光掩膜是否随光照变化 |
| 固定角度偏差 | 透视矫正残余 | 圆形度是否低于 0.95 |
4.3 叠加可视化,把错误定位到具体环节
读数标错时,第一反应是看叠加图,而不是改阈值。把指针方向、刻度区间、读数值画回矫正后的图像:
def draw_debug(img, cx, cy, r, angle, scale_start, scale_span, value): out = img.copy() ex = int(cx + r * 0.75 * np.cos(np.deg2rad(angle))) ey = int(cy + r * 0.75 * np.sin(np.deg2rad(angle))) cv2.arrowedLine(out, (cx, cy), (ex, ey), (0, 255, 0), 2) for a in (scale_start, scale_start + scale_span): sx = int(cx + r * 0.9 * np.cos(np.deg2rad(a))) sy = int(cy + r * 0.9 * np.sin(np.deg2rad(a))) cv2.line(out, (cx, cy), (sx, sy), (0, 0, 255), 2) cv2.putText(out, f"{value:.3f}", (cx - r, cy - r - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) return out绿色箭头是识别到的指针方向,红色半径是刻度区间两端。箭头没落在指针上,问题在分割或骨架;箭头在指针上但读数偏,问题在刻度基准。把矫正前、矫正后、叠加图三张并排存到 debug 目录,批量跑测试集时按文件名排错,比逐张弹窗高效。
5. 用合成变形样本回归验证的实用技巧
5.1 生成带真值的倾斜图像测试集
没有标注数据时,可以自己合成:拿一张摆正的表盘图,套上已知的旋转角和平面外倾斜角,生成一组"输入图加真值读数"的配对样本,用来回归测试整条链路。
def synthesize(frame, rotate_deg, tilt_deg): h, w = frame.shape[:2] # 平面内旋转 M = cv2.getRotationMatrix2D((w / 2, h / 2), rotate_deg, 1.0) rotated = cv2.warpAffine(frame, M, (w, h)) # 平面外倾斜:上边缘向中心收拢,模拟绕水平方向轴的投影 k = np.cos(np.deg2rad(tilt_deg)) dw = int(w * (1 - k) / 2) src = np.float32([[0, 0], [w - 1, 0], [w - 1, h - 1], [0, h - 1]]) dst = np.float32([[dw, 0], [w - 1 - dw, 0], [w - 1, h - 1], [0, h - 1]]) P = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(rotated, P, (w, h))对 rotate_deg 取 0、30、60,tilt_deg 取 5、10、15、20 各生成一张,跑完整流程后对比输出值与原始读数,算平均绝对误差。误差超过量程的 2% 说明倾斜矫正或刻度定位有系统偏差,不要在单张图上反复调参,先回到圆形度校验那一步看矫正是否达标。
5.2 三个最值得先调的参数
第一个是 HoughCircles 的 param2。固定机位下用多张不同光照的图片,找全部都能稳定出圆的最小值,避免为了单张图调成过拟合。第二个是 HSV 的 V 通道下限。白天和夜间补光条件下 V 分布差异很大,条件允许时分别标定一组,不要用一个阈值硬扛全天候。第三个是骨架化之前的结构元素大小。指针细、刻度密时,3x3 结构元素容易让骨架伸进刻度线;先对掩膜做一次腐蚀再细化,比换算法更简单有效。
5.3 输出可信度,而不是只输出读数
最后给识别结果加一个质量分,方便巡检系统决定是否采信:
def quality_score(mask, skel, cx, cy, r): ys, xs = np.where(skel > 0) if len(xs) == 0: return 0.0 max_len = float(np.max(np.hypot(xs - cx, ys - cy))) area = cv2.countNonZero(mask) / (np.pi * r * r) if not (0.15 < max_len / r < 0.90): return 0.0 if not (0.01 < area < 0.15): return 0.5 return min(1.0, (max_len / r - 0.15) / 0.6)长度比低于 0.15 说明检到的是转轴黑影,高于 0.9 说明指针与刻度环粘连;面积比超范围说明掩膜里混入大量噪声,直接判低分。把质量分、指针角度、刻度区间三条信息一起落盘,质量分低时保留原图并标记人工复核。这样整套链路在精度之外还具备可审计性,现场排障时能顺着日志快速定位是矫正、分割还是刻度基准出了问题。
本文还有配套的精品资源,点击获取