
简介这是一份面向水表自动读数的OpenCV/Python图像识别项目定位在“刻度定位—指针判读—数字OCR识别”全流程适合计算机视觉初学者、智能计量与远程抄表开发者参考学习。项目代码完整覆盖图像预处理灰度化、二值化、滤波、Canny边缘检测与Hough直线检测、数字区域分割、Tesseract/自定义OCR识别以及基于上下文读数的校验修正能帮助读者理解真实表盘图像在光照不均和背景噪声下的处理思路。整个资源以zip压缩包形式提供共13个文件、约1.32MB以1个Python主程序、7张水表JPG测试图、若干XML配置文件及IntelliJ工程文件iml为主可直接用IDE打开并运行测试。目前已有593人学习/下载该资源适合需要快速上手OpenCV表盘识别的开发者作为入门模板。借助源码和样例图片可以边运行边调参复现预处理、表盘定位、指针角度计算和数字识别的每个环节对后续开展智能水表数据采集、自动抄表或智慧城市计量系统建设也有直接参考价值。1. 水表识别没你想的那么玄一个 OpenCV 项目拆开看做图像处理这几年我拆过的所谓「识别项目」不少但水表识别一直是个挺特别的存在——它不像车牌识别有那么多现成方案也不像 mnist 手写数字识别那样数据集随便下。水表这东西表盘圆形、指针多、数字还可能带着轮转的进位关系光照一差反光一片搞不好连数字都看不清。这个 Circle_水表识别_数字识别 项目就是典型场景下的一个落地尝试用 OpenCV 完成从读图、预处理、刻度定位到数字识别的完整链路核心代码集中在 Circle.py配套几张现场拍的水表样本图。适合谁刚入门 OpenCV、想找一个完整项目练手的人或者是做智能抄表预研、想先验证「OpenCV 能不能搞定我手上这批表」的工程师。下面我把这个项目的代码逻辑、参数选择和实际跑图时会遇到的坑一层层拆开讲。2. 预处理链路灰度、二值化与去噪的选型逻辑2.1 为什么第一步一定是灰度而不是直接上彩色图OpenCV 读进来默认是 BGR 三通道但水表识别里颜色信息对定位指针和数字几乎没有贡献反而会放大光照不均和表盘底色干扰。我第一次跑这个项目时偷懒直接拿彩色图做边缘检测结果 Canny 出来的边缘密密麻麻全是表盘纹理的响应指针和刻度反而淹没在里面。转灰度之后计算量降为原来的三分之一后续的阈值操作也稳定得多。常见的做法是cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)这一步没什么可调的但要注意一个问题如果输入图片本身是灰度图比如某些摄像头直接输出 Y800 格式再转一次灰度会报错或者得到全黑图最好先判断通道数。这个项目里的样本图是 JPG 彩色图直接转没问题。2.2 二值化选全局阈值还是自适应阈值水表照片最大的变量就是光照——有的图是正上方打光表盘均匀亮有的是斜射光表盘一半亮一半暗。全局阈值cv2.threshold在这种场景下表现很不稳定同一张图换个阈值结果就差很多。这个项目里用的就是全局二值化所以选阈值的时候要格外小心。我一般会先用大津法cv2.THRESH_OTSU自动算一个初始阈值再人工看一眼二值图效果。Otsu 的核心思想是让前景和背景的类间方差最大对双峰明显的灰度直方图效果好。水表表盘通常是白底黑字黑指针直方图双峰还算明显Otsu 基本能在一个合理范围内定住阈值。import cv2 # 读图后直接转灰度 gray cv2.imread(Circle test (1).jpg, cv2.IMREAD_GRAYSCALE) print(图像尺寸:, gray.shape) # 先用高斯模糊压掉传感器噪声再做大津二值化 blurred cv2.GaussianBlur(gray, (5, 5), 0) thresh_val, binary cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) print(Otsu 自动计算出的阈值:, thresh_val) cv2.imwrite(binary_output.jpg, binary)这里GaussianBlur的核大小(5, 5)是水表这类小尺寸图像里比较稳妥的选择。核太小压不掉 CMOS 传感器的颗粒噪声核太大比如(15, 15)会把指针边缘和数字笔画的细节磨掉导致后续轮廓检测把两个字连成一个。thresh_val是 Otsu 返回的实际阈值打印出来方便你人工确认——如果这个值低于 100 或者高于 200说明原图光照本身就有问题二值化救不回来得回头优化打光或拍照角度。2.3 滤波到底选高斯还是中值高斯滤波是线性滤波对高斯噪声效果好但也会平滑边缘中值滤波是非线性滤波对椒盐噪声和表盘上的细小划痕效果更好而且能在一定程度上保边。水表照片里最常出现的其实是反光造成的局部高亮斑块以及表盘玻璃上的灰尘点这两类更像椒盐噪声。实际测试下来如果表盘干净、玻璃没有明显划痕高斯滤波效果更好因为它的计算速度快对数字笔画这类低频信息保留得更好如果表盘上有肉眼可见的划痕或者污点中值滤波的核半径取 3 到 5 就能把孤立噪点摁掉。这个项目里默认高斯是没问题的但你要是拿它去跑现场拍的表建议先看一眼直方图再做决定。3. 刻度盘与指针定位从 Hough 变换到轮廓筛选3.1 Hough 直线检测找刻度线的参数坑圆形的表盘上刻度线本质上是从圆心向外辐射的短直线。用 Hough 变换检测直线时常见做法是cv2.HoughLinesP也就是概率霍夫变换。它比标准 Hough 慢一点但返回的是线段端点后续计算角度和距离都方便。这个环节有一组参数要反复调threshold累加器阈值越小越容易检测出线段也越容易出噪声、minLineLength线段最小长度太短会把刻度上的噪点连成线、maxLineGap同一线段上允许的最大间距太大容易把断开的指针和刻度线拼起来。import cv2 import numpy as np # 在二值图上做边缘检测再用 Hough 找直线 edges cv2.Canny(binary, 50, 150, apertureSize3) lines cv2.HoughLinesP( edges, rho1, # 距离分辨率1 像素足够 thetanp.pi / 180, # 角度分辨率1 度 threshold80, # 累加器阈值越小检出越多噪声 minLineLength30, # 最短线段长度过滤小噪点 maxLineGap10 # 允许断裂间距 ) if lines is not None: for line in lines: x1, y1, x2, y2 line[0] cv2.line(edges, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imwrite(hough_lines.jpg, edges)minLineLength是最值得调的参数。水表刻度线在 500 像素宽的表盘图里通常短于 40 像素如果你把minLineLength设成 50刻度线会被整体过滤掉设成 15又会把表盘数字的笔画边缘也当成刻度线。我实际跑这个项目样本图时minLineLength30是个能同时看到刻度线和指针的折中值。maxLineGap则要看你的图里刻度线有没有被二值化切断——反光导致刻度线中间断一段很常见设成 10 到 15 可以容忍这种断裂。3.2 用轮廓而不是 Hough 来定位指针Hough 找刻度线没问题但找指针就不太可靠了。指针是细长的三角形或多边形在二值图上并不总能形成一条干净的直线尤其是指针和刻度线重叠时Hough 返回的线段会五花八门。更稳的办法是先做轮廓检测再按形状特征筛。cv2.findContours返回的轮廓列表顺序和层级关系在不同 OpenCV 版本里不一样这点特别容易翻车。在 OpenCV 3.x 之后它返回两个值contours和hierarchy到了 OpenCV 4.xcv2.findContours的返回值数量没变但要注意RETR_EXTERNAL和RETR_CCOMP的区别——前者只取最外层轮廓后者保留层级关系。水表识别里如果你只想找表盘主体用RETR_EXTERNAL就够了它会跳过表盘内部所有数字和刻度的轮廓计算量小很多。contours, hierarchy cv2.findContours( binary, cv2.RETR_EXTERNAL, # 只取最外层轮廓跳过内部数字与刻度 cv2.CHAIN_APPROX_SIMPLE # 压缩水平、垂直、对角线段端点 ) # 按面积过滤去掉小噪点保留表盘区域 table_dish None for cnt in contours: area cv2.contourArea(cnt) if area 5000: # 阈值按图像尺寸调整 table_dish cnt if table_dish is not None: x, y, w, h cv2.boundingRect(table_dish) cv2.rectangle(binary, (x, y), (x w, y h), (128, 128, 128), 3)这个area 5000的阈值不是拍脑袋定的水表样本图一般都在 500 到 1000 像素见方表盘占画面 60% 以上时面积轻松过万。如果你换了一批图画面里表盘变小了这个阈值就得往下调。CHAIN_APPROX_SIMPLE是推荐的轮廓压缩方式它只保留轮廓拐点减少后续多边形逼近的计算量对纯坐标记录没影响。3.3 圆心定位为什么直接用 HoughCircles 不稳定既然表盘是圆的很多人第一反应是用cv2.HoughCircles直接找圆心。这个函数的参数非常敏感param1Canny 高阈值和param2圆心累加器阈值稍微变一点结果就从「找到圆心」变成「满图乱画圆」。而且它要求输入的是灰度图内部会自己做 Canny你前面的二值化结果它根本不看。更稳定的做法是走轮廓那一路找到表盘轮廓后算它的最小外接圆圆心和半径就都有了。(x, y), radius cv2.minEnclosingCircle(table_dish) center (int(x), int(y)) radius int(radius) cv2.circle(binary, center, radius, (200, 200, 200), 2) print(圆心坐标:, center, 半径:, radius)minEnclosingCircle返回的是浮点坐标画图前必须转成整数不然 OpenCV 会报类型错误。这个方法的优势在于它完全复用前面的轮廓结果不需要额外调参稳定性比HoughCircles高一个量级。唯一的风险是表盘轮廓不完整——比如照片只拍到了表盘的一角外层轮廓不是完整的圆这时候minEnclosingCircle会算出一个偏大的圆。实际项目中倒是很少遇到这种情况因为水表照片基本都是正对着拍的。4. 数字识别从连通域切分到 OCR 落地4.1 连通域分析切出每个数字数字识别的第一步是把二值图上每个数字单独抠出来这一步用连通域分析比用轮廓检测更直接。cv2.connectedComponentsWithStats返回每个连通域的左上角坐标、宽高和面积一步到位。注意这一步要处理的是表盘内部区域所以不能再用RETR_EXTERNAL得用RETR_CCOMP或者直接对表盘 ROI 重新做一次二值化否则你拿到的轮廓只有整个表盘的外圈数字全在里面出不来。num_labels, labels, stats, centroids cv2.connectedComponentsWithStats( binary_roi, # 表盘 ROI 的二值图 connectivity8 ) # 遍历每个连通域按宽高比和面积筛出数字 digits [] for i in range(1, num_labels): x, y, w, h, area stats[i] # 数字的宽高比通常在 0.3~0.8 之间排除指针和刻度 if 0.3 (w / h) 0.8 and 200 area 5000: digit_img binary_roi[y:y h, x:x w] digits.append((digit_img, (x, y, w, h)))connectivity8意味着上下左右和对角都算连通这对手写体或者笔画断裂的数字更友好但也会让靠得很近的两个数字粘连成一个区域。如果发现两个数字被连在一起可以把connectivity改成 4代价是笔画内有断开时一个数字被拆成多个块。宽高比0.3~0.8是印刷体数字的经验区间如果你识别的是多位计数器的数字这个范围基本够用面积下限 200 是为了滤掉噪点但如果你拍的是小尺寸水表数字实际面积可能不到 200这个阈值要按实际图像尺寸缩放。4.2 Tesseract 识别数字时的语言与模式选择切出来的数字块可以直接丢给 Tesseract但很多人栽在语言包和--psm模式上。Tesseract 默认的语言是英文识别数字没问题但如果你不加--psm它默认按整行文本处理单个数字反而容易识别错。常见做法是逐一切割出的数字块用--psm 10单个字符模式去识别这比整个表盘直接丢给 Tesseract 准确率高很多。import pytesseract from PIL import Image for idx, (digit_img, box) in enumerate(digits): # 把 OpenCV 的 BGR 图先编码成 PNG 字节流再让 PIL 读取 pil_img Image.fromarray(digit_img) text pytesseract.image_to_string( pil_img, config--psm 10 -c tessedit_char_whitelist0123456789 ).strip() print(f数字 {idx}: 识别结果 {text}, 位置 {box})--psm 10是 Tesseract 4.x 里的单字符模式专门处理 ROI 里只有一个字符的情况。tessedit_char_whitelist0123456789把候选字符限定在 0 到 9避免把 6 识别成 b、0 识别成 O这块是纯数字识别的保底配置。如果你在 Ubuntu 上装 Tesseract注意apt install tesseract-ocr之后还要确认tesseract --list-langs里有eng否则会抛tessdata相关错误。4.3 识别结果合理性校验用进位关系兜底OCR 不是 100% 准的水表数字尤其容易把 8 认成 3 或者 6。常见的补救办法是加一个规则校验——水表读数只能单调递增连续两次识别的结果不能倒退。这个项目虽然没有内置完整的校验模块但你在自己工程化时一定要加上这一层我一般会维护一个最近 N 次读数列表低于当前最大值的识别结果直接标为可疑等下一帧再确认。5. 常见问题排查我跑这个项目时遇到的五个坑5.1 现象一二值化后表盘变成一团黑原因原图光照过暗Otsu 算出来的阈值偏低把表盘背景也划进了前景。解决方式是把cv2.threshold的THRESH_BINARY改成THRESH_BINARY_INV并反转前景背景或者先用直方图均衡化cv2.equalizeHist打散灰度分布再做二值化。我跑Circle test (4).jpg时遇到过这个情况均衡化之后 Otsu 阈值从 90 升到了 140背景和数字立刻分开了。5.2 现象二Hough 检测出的直线横七竖八没有一条是刻度线原因threshold参数设得太低表盘上的玻璃反光边缘也被当成线段。解决方式是先把threshold从 80 提到 120如果还不行检查edges图——反光区域在 Canny 输出里通常是一大片密集边缘而不是一条细线这种情况光调 Hough 参数没用得回二值化环节把反光压掉。5.3 现象三指针和刻度线粘连轮廓检测分不开原因二值化时指针和刻度线的灰度差异不够在阈值两侧被归到了同一类。解决方式是改用cv2.adaptiveThreshold做局部自适应二值化用局部均值做基准能有效把灰度差异小的区域分开。adaptive cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, blockSize31, # 局部邻域大小必须为奇数 C10 # 从局部均值中减去的常数 )blockSize31表示每个像素的阈值由周围 31x31 邻域的加权均值减去C10得到。C越大二值化结果越保守前景区域越小。如果C设成 10 还是粘连就往上加到 15如果数字开始出现断裂就往下减。这个参数是水表识别里最值得反复试的没有之一。5.4 现象四只识别出一部分数字单个的数字总是漏掉原因连通域分析时面积上下限设得太死数字 1 的面积经常比其他数字小很多被area 200过滤掉了。解决方式是不用固定面积阈值改为按图像总面积的比例算动态下限比如area 0.001 * binary_roi.size这样换图之后不用重新调参。5.5 现象五Tesseract 把 0 识别成空字符串原因--psm 10模式下Tesseract 对某些瘦长字体的 0 置信度极低直接返回空。解决方式是不把 OCR 结果当唯一依据——先统计连通域里的像素数量0 的像素密度通常高于 1 但低于 8用这个作为兜底和 OCR 结果做一次投票两个都一致才采纳不一致就打标人工复核。6. 进阶技巧用模板匹配兜底 OCR 的识别短板如果 Tesseract 在你手上这批表上的准确率始终上不去还有一个不用训练模型的办法——模板匹配。水表数字是固定的印刷字体同一个表型字体的粗细、衬线风格完全一致直接拿一张裁好的数字模板去做cv2.matchTemplate在多数场景下比 OCR 更稳。import cv2 import numpy as np # 假设你有一张干净的 0~9 模板图按 10 等分切出每个数字 template_path digits_template.png template_img cv2.imread(template_path, cv2.IMREAD_GRAYSCALE) _, template_bin cv2.threshold(template_img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 把模板图横向切成 10 个数字块 digit_w template_bin.shape[1] // 10 templates [] for i in range(10): t template_bin[:, i * digit_w:(i 1) * digit_w] templates.append(t) def match_digit(roi_bin): best_score -1 best_digit -1 for i, t in enumerate(templates): # 统一缩放到 ROI 尺寸 t_resized cv2.resize(t, (roi_bin.shape[1], roi_bin.shape[0])) res cv2.matchTemplate(roi_bin, t_resized, cv2.TM_CCOEFF_NORMED) _, score, _, _ cv2.minMaxLoc(res) if score best_score: best_score score best_digit i return best_digit, best_score模板匹配的关键在于TM_CCOEFF_NORMED归一化相关系数对光照不敏感适合水表这种灰度差异明显的场景。但有个前提ROI 和模板的宽高比必须一致否则cv2.resize会把数字压变形匹配分直接崩。我一般会先把所有 ROI 统一高度再按原始宽高比缩放宽度不足的部分补白边。模板匹配不是万能药它要求模板和实际数字是同一字体否则效果反而不如 Tesseract。实际操作时我把两种方案都跑一遍取置信度高的一路Tesseract 置信度高于 90 就信 OCR否则采信模板匹配结果。从那以后我每次跑水表识别项目都强制走一遍「二值化可视化 → 轮廓面积检查 → 识别结果投票」这三步宁可多写三行打印也不把识别结果直接当最终读数输出。这套流程帮我挡掉了不少翻车现场希望帮到你。本文还有配套的精品资源点击获取