简介:这是一份面向计算机视觉初学者的水表数字识别项目,基于OpenCV实现刻度定位与数字读取,适合学习图像预处理、边缘检测和OCR识别流程的开发者参考。压缩包共13个文件,其中包含1个Python主程序、7张水表测试图片、4个XML配置文件及1个工程模块文件,整体体积仅1.32MB,结构紧凑、便于快速上手。已有593人学习下载。资源提供可直接运行的完整源码与多张测试图像,可帮助读者观察不同光照、角度下识别效果的差异;XML配置涵盖界面布局与检测参数,便于追踪和调整。阅读代码可逐步理解灰度转换、二值化、滤波、Canny边缘检测、轮廓提取以及Tesseract数字识别等关键步骤,还可结合连通域分析实现字符分割。对于希望动手实现智能抄表、仪表读数自动采集的开发者,这是一份实用的参考样例,可在此基础上扩展改进。
1. 一块圆表盘,凭什么让数字识别翻车
Circle_水表识别_数字识别,这个名字拆开看就是一条完整的工业视觉流水线:先用 Circle 定位圆形的表盘区域,再从表盘里把滚轮数字区域抠出来,最后对滚动中的数字做识别。很多人一上来就套 mnist 手写数字识别的经验,结果在真实水表上跑得稀烂——因为水表滚轮上的数字是印刷体,不是手写体,而且它带玻璃反光、带遮挡、带倾角,还有每时每刻都可能停在两个数字中间的半格状态。这套方案适合谁?适合做抄表机器人、摄像头直读水表、或是在产线上做表盘读数自动录入的工程师。它解决的核心问题只有一个:让算法在室外、低照度、表盘玻璃脏污的条件下,也能稳定读出那个容易读错的数字。下文我会按定位、区域提取、数字识别、参数调优、疑难排查的顺序,把这条链路完整拆开。
2. Circle 定位:先找圆,再谈读数
2.1 为什么不做整图识别,非要先画一个圆
水表表盘是一个圆形区域,但工业相机拍回来的图通常是矩形的,里面除了表盘还有管道、墙面、阴影。如果直接对整张图做数字识别,背景里的干扰会大量进入分类器或模板匹配的比对范围,误识别率会明显上升。Circle 这一步的意义是把表盘从背景里“切”出来,让后续的识别只在圆形 ROI 内进行,减少无关特征对结果的影响。
另一个原因是透视。相机安装位置不可能保证完全正对表盘,画面里会出现椭圆或斜圆。先定位圆形并做几何校正,可以消除透视造成的字符拉伸。我常用的做法是用 HoughCircles 做粗定位,再在圆形 ROI 内对表盘边缘做一次椭圆拟合,用拟合得到的椭圆参数去仿射变换校正图像。这一步不是花架子,它直接决定了后续滚轮数字提取的稳定性。
2.2 HoughCircles 参数怎么设才不飘
OpenCV 的 HoughCircles 是一个经典函数,但很多人第一次调用后发现要么检不出圆,要么检出一堆假圆。问题往往出在三个参数上:dp、param2 和 minRadius/maxRadius。下面是能稳定跑通表盘定位的写法:
import cv2 import numpy as np def locate_dial_circle(image): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray = cv2.medianBlur(gray, 5) # 去掉表盘玻璃上的细颗粒噪声 circles = cv2.HoughCircles( gray, cv2.HOUGH_GRADIENT, dp=1, minDist=100, # 同一表盘只保留一个圆心,距离阈值取表盘直径的一半 param1=100, # Canny 高阈值 param2=60, # 累加器阈值,越小越容易出假圆 minRadius=int(gray.shape[1] * 0.2), maxRadius=int(gray.shape[1] * 0.5) ) if circles is None: return None circles = np.round(circles[0, :]).astype(int) # 优先选半径最大且落在图像中心区域的圆,排除管道接口的假圆 h, w = gray.shape best = None for x, y, r in circles: if abs(x - w / 2) < w * 0.3 and abs(y - h / 2) < h * 0.3: if best is None or r > best[2]: best = (x, y, r) return best这段代码里有两个容易被忽略的点。dp=1 表示累加器分辨率与原图一致,不要为了提速调成 2,因为表盘边缘在降采样后容易丢。param2 是决定假圆数量的关键,我一般从 80 开始往下调,在测试集上把检不出圆和假圆的比例调到可接受的平衡点,而不是直接抄网上常用的 30。minRadius 和 maxRadius 用图像尺寸比例做约束,比写死像素值更稳,因为不同相机的分辨率不一样。
2.3 椭圆拟合:把圆拉正,给后续识别铺路
HoughCircles 对倾斜视角的表盘只能给出近似圆,但水表表盘拍出来往往是椭圆。用椭圆拟合校正后的图会让滚轮数字区域变成规整的矩形,模板匹配的稳定性会明显提升。常见做法是在圆形 ROI 内做边缘检测,再把边缘点集喂给 fitEllipse。
def correct_perspective(image, circle): x, y, r = circle mask = np.zeros(image.shape[:2], dtype=np.uint8) cv2.circle(mask, (x, y), r, 255, -1) edges = cv2.Canny(image, 80, 150) contour_points = cv2.findNonZero(cv2.bitwise_and(edges, edges, mask=mask)) if contour_points is None or len(contour_points) < 5: return image, None ellipse = cv2.fitEllipse(contour_points) center, axes, angle = ellipse # 取椭圆外接矩形的角点做透视变换,把椭圆区域拉成近似正圆 box = cv2.boxPoints(ellipse) box = np.float32(box) side = max(axes) dst = np.float32([[0, 0], [side, 0], [side, side], [0, side]]) M = cv2.getPerspectiveTransform(box, dst) corrected = cv2.warpPerspective(image, M, (int(side), int(side))) return corrected, ellipse注意这一步用的是透视变换而不是仿射变换,因为表盘倾斜本质上是透视失真,只做旋转缩放是不够的。变换后表盘被拉成近似正方形,滚轮区域的位置就稳定下来,后续提取数字区时可以用固定的相对坐标,省去每次重新定位的麻烦。如果你发现校正后的表盘仍然有弧线,说明椭圆拟合的边缘点混入了刻度线,需要把 Canny 的下阈值调高一些,把细小的刻度边缘滤掉。
3. 从表盘里抠出滚轮数字区:比想象中多一步“去反光”
3.1 滚轮区域为什么不能用固定坐标一裁了事
水表表盘玻璃会反光,不同角度的光照会在滚轮上方形成一条高光带。如果直接用固定坐标裁剪滚轮区域,反光带会横穿数字,导致二值化后数字断裂。更麻烦的是,在同一个机位下,不同水表表盘的滚轮位置会有轻微偏移,因为表盘印刷和机械装配本身存在公差。
我一般的做法是,先在上一步校正后的图上做一次“区域粗定位”:用形态学操作在垂直方向投影,找到滚轮数字区域的上下边界。滚轮区域有数字,垂直方向的梯度特征明显强于表盘其余部分,投影曲线会出现一个峰。
def find_roller_region(corrected_image): gray = cv2.cvtColor(corrected_image, cv2.COLOR_BGR2GRAY) # 只保留表盘中心区域,去掉外圈刻度干扰 h, w = gray.shape center_roi = gray[int(h*0.2):int(h*0.8), int(w*0.3):int(w*0.7)] # 垂直梯度投影,滚轮区域的边缘密度最高 sobel_y = cv2.Sobel(center_roi, cv2.CV_64F, 1, 0, ksize=3) grad_mag = np.absolute(sobel_y) row_profile = grad_mag.sum(axis=1) # 用滑动窗口找投影最大的连续区间 win_size = int(center_roi.shape[0] * 0.15) best_start = 0 best_score = 0 for i in range(len(row_profile) - win_size): score = row_profile[i:i+win_size].sum() if score > best_score: best_score = score best_start = i # 映射回原图坐标 y1 = int(h*0.2) + best_start y2 = y1 + win_size return center_roi, y1, y2这个做法比固定坐标稳的原因在于:滚轮数字的垂直边缘在梯度图里永远是局部最强的特征,无论是暗光还是反光,垂直边缘都还在。win_size 取表盘高度的 15%,基本能覆盖一个滚轮数字的高度。拿到 y1/y2 后,再在这个区间内做水平投影,就能进一步把每个数字的左右边界分离出来,为后续单个数字识别做准备。
3.2 反光区域的处理:二值化前先做亮度补偿
滚轮区域的玻璃反光是最常遇到的问题。直接二值化,反光区域会变成一大块白色,数字被吞掉。常见做法是用形态学闭运算估计背景亮度,再和原图做差,消除光照不均。
def remove_highlight(roller_roi): gray = cv2.cvtColor(roller_roi, cv2.COLOR_BGR2GRAY) # 用大核闭运算估计背景光照 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (25, 25)) background = cv2.morphologyEx(gray, cv2.MORPH_CLOSE, kernel) # 原图减背景,得到均匀光照下的数字图 compensated = cv2.subtract(background, gray) return compensated注意这里用的是 background 减去 gray,不是 gray 减去 background。因为水表滚轮底色是白色或浅灰色,数字是黑色,在灰度图上数字区域比背景暗。闭运算估计出的 background 在数字位置会被腐蚀掉一部分,所以 background 在数字处偏暗,减去原始 gray 后反而是数字区域变成高值,背景变成接近 0,这样二值化后数字是白色、背景是黑色,和模板库的方向保持一致。
如果反光特别严重,闭运算核要加大到 35 甚至 45,否则背景估计会把数字边缘也吞进去。调完之后用固定阈值 127 做二值化就够了,不需要上 Otsu,因为补偿后的像素分布已经比较集中,Otsu 反而可能受残留高光的干扰,把阈值拉偏。
3.3 切分数字:水平投影 + 宽度约束
滚轮数字是等宽的印刷体,每个数字宽度基本相同。切分时先做水平投影找各数字的左右边界,再用宽度约束过滤掉刻度线的干扰。
def split_digits(binary_roi): # 水平投影:每列白色像素数量 col_proj = binary_roi.sum(axis=0) // 255 digit_regions = [] in_digit = False start = 0 for i, val in enumerate(col_proj): if val > 3 and not in_digit: in_digit = True start = i elif val <= 3 and in_digit: in_digit = False end = i width = end - start if width > 8 and width < binary_roi.shape[1] * 0.4: digit_regions.append((start, end)) # 按宽度排序取中位数,过滤掉过宽或过窄的干扰块 widths = [end - start for start, end in digit_regions] if len(widths) < 2: return digit_regions median_w = np.median(widths) filtered = [(s, e) for s, e in digit_regions if 0.6 * median_w < (e - s) < 1.4 * median_w] return filteredval > 3 这个阈值是经验值,表盘玻璃上如果还残留少量噪声点,投影值会在 1 到 2 之间,不会超过 3。宽度过滤的意思是:滚轮数字的宽度彼此接近,而刻度线、小数点这类干扰的宽度要么特别窄要么特别宽,用中位数法把它们排除。这一步切出来的每个小图,就直接喂给下一步的数字识别模块。
4. 数字识别:模板匹配还是 CNN,不是拍脑袋决定的
4.1 Mnist 手写数字识别的经验在这里为什么不管用
很多人看到数字识别第一反应是套 mnist 手写数字识别的模型,这在思路上就错了。Mnist 是手写体,笔画粗细和形状因人而异,而水表滚轮是印刷体,字形统一,粗细固定。反过来,水表数字有手写数据集没有的干扰:数字边缘会有滚轮进位时的邻轮模糊、表盘玻璃的划痕、以及数字本身在弧形面上产生的轻微透视变形。用 MNIST 训练集训练出来的模型,在真实水表上的泛化能力并不是理想状态——它不是识别不了印刷体,而是会把玻璃划痕、半格状态当成手写笔画的随机扰动,导致置信度分配错误。
从落地成本角度,模板匹配在这个场景里优于 CNN。原因有三:第一,印刷体字形高度一致,模板库只需要 10 个数字各存一张干净样本,就能覆盖绝大多数表型;第二,模板匹配是确定性算法,结果可解释,哪种字型匹配不上时可以快速排查是模板问题还是图像问题;第三,在嵌入式设备上模板匹配不需要跑推理框架,纯 OpenCV 就能搞定。
4.2 从滚轮进位反推模板匹配的窗口设计
滚轮数字的间距很小,当一个数字在进位(比如从 9 变成 0)时,相邻数字会从滚轮缝隙里露出一部分棱边。如果窗口切得太大,把邻轮露出的部分也切进来,模板匹配必然出错。正确的做法是:以每个切分出的数字中心线为基准,截取一个比数字略窄的窗口,让邻轮露出部分不进入比对区域。
def match_digit(digit_img, templates): best_id = -1 best_score = 0 digit_img = cv2.resize(digit_img, (28, 40)) for digit, templ in templates.items(): templ = cv2.resize(templ, (28, 40)) score = cv2.matchTemplate(digit_img, templ, cv2.TM_CCOEFF_NORMED) # 取匹配矩阵的均值,避免单点过拟合 mean_score = score[0][0] if mean_score > best_score: best_score = mean_score best_id = digit return best_id, best_score窗口宽度取数字宽度的 70%,这个比例既能把数字本身的笔画完整包住,又能避开邻轮。匹配分数用 TM_CCOEFF_NORMED 而不是 TM_SQDIFF,因为它是归一化的,对光照敏感度低。实际使用中还会加一个最低分值门槛,低于 0.75 就判定为“不确定”,而不是强行给出一个数字——在抄表场景里,报一个“不确定”让人工复核,比报一个错数安全得多。
模板库的构建也有讲究。我一般从实拍图中直接裁剪样板数字保存,而不是用程序绘制字体。因为实拍模板和识别图像来自同一类表盘,能天然适配表盘的玻璃模糊、视角等特征。模板库里的数字通常只有 0-9 十个文件,但同一个数字如果笔画粗细有明显差异,也可以存两个变体,匹配时取最高分。
4.3 与 CNN 方案的取舍:什么情况下才值得换模型
如果你识别的水表不止一种,而是几十种品牌,模板库的维护量会逐渐变大,因为每种表的字体形状有差异,模板需要按品牌分组。这种情况下,换一个轻量卷积模型是合理的。但这种模型的训练数据不能用 MNIST,要自己采集真实水表图像做标注,每个数字至少收集几千个样本,否则训练出来的模型还不如模板匹配稳定。
判断要不要换模型,一个简单标准是:在纯模板匹配下,如果每种表型的准确率都能到 98% 以上,就没有换的必要;如果低于这个值,先排查前几步的定位和切分问题——通常切分不准导致的错误比识别器本身的错误更多。真要上 CNN,也别用大模型,MobileNet 或 LeNet5 在这个任务上足够,输入分辨率 28×28 就行,重点在训练数据里加半格状态和反光样本。
5. 水表识别避坑:5 个最容易翻车的环节
5.1 滚轮半格状态:数字卡在两个数中间,怎么判都像错的
现象:识别结果显示数字“4”,但人工复核时发现滚轮在 4 和 5 之间,机械上还没完全进位,这个位置怎么读都有争议。
原因:滚轮进位是机械过程,摄像头抓拍时捕获的是中间状态,数字本身只显示了一半。
解决:生产环境里用连续多帧识别做投票。抓拍 3 帧,分别识别,取出现次数最多的数字;如果三帧各不同,检查是否落在进位区间——通常两帧低值一帧高值,或反过来,按机械表的进位移位方向是“低位向高位递增”,应取低值加一,或按业务规则让系统标记为需人工复核,不要硬判。
5.2 玻璃反光把数字腰斩成两截
现象:二值化后一个数字断成上下两段,模板匹配完全失效。
原因:表盘玻璃表面的平行光反射形成高光带,高光区域的亮度超过二值化阈值,数字被“漂白”掉一段。
解决:先做 3.2 节的光照补偿,再把二值化阈值从固定值改为自适应。实际操作中我会把补偿后的图用 opencv 的 adaptiveThreshold 再跑一遍,blockSize 取 15,C 取 5,牺牲一点边缘连续性换来数字整体的连通性。断开的数字如果仍然存在,再做一次闭运算把缺口填上,核用 3×3,不要大——大了会把相邻数字连到一起。
5.3 远处管道边缘被误当成表盘圆
现象:HoughCircles 找出的圆比表盘大一圈,圈进去半个管道,后面的 ROI 全部切偏。
原因:表盘和管道的边缘都是圆形弧线,管道接口在图像里可能比表盘边缘更清晰。
解决:加一个约束,圆心必须落在图像中心区域且圆面积占图像总面积的 40%-60%,可以参照 2.2 节的写法。如果管道边缘和表盘接近同心圆,直接取最大半径,再用 2.3 节的椭圆拟合 + 透视校正,把圆拉成正方形,边缘就准了。
5.4 表盘玻璃脏污导致数字区域亮度不均匀
现象:同一批表里,有的识别准确率高,有的低,低的是不是拍摄角度问题,而是表盘用久了有一层白雾,数字在雾下面变模糊。
原因:白色水垢散射光线,降低了数字边缘的对比度,梯度投影找滚轮区域时峰值不明显。
解决:在找滚轮区域前,先对灰度图做 CLAHE,clipLimit 设 2.0,tileGridSize 设 8×8。这能把局部对比度拉起来,边缘重新变清晰。别用全局直方图均衡化,那种做法在亮区会过曝,把数字的反光部分全部变成白色。
5.5 小数位红色指针被误判成滚轮数字
现象:切分后多出一个疑似数字的块,模板匹配给它硬分了一个“7”,导致读数错误一位。
原因:红色指针或小数位的小数字,和滚轮数字垂直投影在同一个位置。
解决:用尺寸约束过滤,滚轮数字的高度约占表盘高度的 15% 到 25%,在这个比例之外的块直接丢弃。另一个常见做法是:只保留最右侧连续的 6 到 8 个等宽数字块,因为水表整数位通常有固定的位数,从右往左切,舍去最左的残缺块,能明显减少干扰。
6. 最后一公里:用“跳变窗口”根治边界翻转错位
整个链路走通后,还有一个高频问题:某个数字在进位瞬间被识别成边界两边的值,来回跳。比如从 9 进位到 0 的过程中,算法在 9 和 0 之间反复横跳,触发数据上报错误。这个问题我在实际项目中见过太多次,最后是给识别结果加一个“跳变迟滞窗口”解决的。
具体思路是:对同一个数字位,记录历史识别的结果,只有当新识别值连续 N 帧与旧值不同,才确认切换。N 取 3 或 5 都可以,机械表的进位过程通常持续几十毫秒,3 帧确认能滤掉偶然误判,又不会让真实进位被滞后太多。代码如下:
class DigitHysteresis: def __init__(self, n=3): self.n = n self.counter = 0 self.current = None def update(self, value): if value == self.current: self.counter = 0 return self.current self.counter += 1 if self.counter >= self.n: self.current = value self.counter = 0 return self.current这套逻辑可以在每个数字位上单独跑,避免某个位上持续跳变影响其他位。配合 5.1 节的三帧投票,实际工程里能覆盖绝大多数读数错误场景。
我个人的习惯是,每次上线前,拿着这套方案去现场拍 200 张不同光线下的表盘照片,把识别结果逐张和人工复核对比,只看两个指标:稳定读数的比例、以及误判为错数的比例。前者做到 95%以上才值得继续,后者必须做到零——因为错数比“读不出”可怕得多。希望这套 Circle 定位 + 滚轮区域提取 + 模板匹配的链路,能帮你少踩几个坑。
本文还有配套的精品资源,点击获取