简介:一套Python字轮式自来水水表识别项目源码,面向具备基本Python语法、希望深入计算机视觉与机器学习实战的开发者,解决自动读取字轮水表数字的问题。项目以OpenCV为图像处理核心,覆盖灰度化、二值化、直方图均衡化、边缘检测等预处理流程,通过模板匹配和SVM、KNN或CNN等模型完成数字定位、分割与识别,并结合Tesseract OCR将图像转为文本,完整演示了从图像采集到结果输出的经典识别链路。资源压缩包共49个文件,其中22个Python脚本构成主逻辑,辅以pyc编译文件、6个HTML页面及CSS、JS、ini配置、mako模板和readme说明,整体体积约180KB,属于轻量紧凑的项目包。源码按Flask后端和water_meter_rec识别模块分层组织,包含预处理、检测、训练、测试、主入口等独立脚本,方便逐模块阅读和调试。除了核心算法,项目还涉及数据集构建、标注、单元测试与性能优化等内容,既展现工程组织也提供排错思路。目前已有1081人学习下载,适合想通过项目完整走一遍图像识别开发流程的学习者参考和二次开发。
1. 字轮式水表读数的本质:一个被低估的 OCR 难题
字轮式水表读数的难点不在“看不清”,而在“读不准”。齿轮进位时数字会停在两个字符中间,表盘玻璃上的油污和反光会在图像里形成高光区,不同批次的表具还有不同字号和字间距。把整张表盘照片直接丢给通用 OCR 引擎,大部分输出是乱码;反而是一套按“定位 → 分割 → 分类”拆解的两阶段管线能稳定跑到 99% 以上的单字准确率。这个项目正好把两阶段拆成了 det 与 rec 两组脚本,并用 Flask 包了一层可供产线调用的 Web 服务。适合正在做仪表识别、OCR 工程化或工业视觉项目的开发者参考,也能直接拿来改造成燃气表、热量表读数。
2. 字轮定位的工程化:OpenCV 预处理与检测分支的协作方式
2.1 为什么必须先解决“字轮在哪”而不是直接识别
很多人拿到水表照片的第一步就是调用 OCR 引擎,这是最容易翻车的做法。水表照片里不止有数字轮,还有表盘刻度、品牌铭牌、红色指针、玻璃反光,这些区域在视觉上全是干扰。通用 OCR 引擎倾向于把纹理丰富的区域当作文字,结果会识别出一堆仪表盘上的无效字符。
另一个原因是字轮本身有透视和遮挡。摄像头通常斜着装在表具上方,五个数字轮并不在同一个平面上,左右两端的字符会被轮轴遮挡一部分。如果直接把整张图送进识别网络,卷积核要同时学习“找字轮”和“认数字”两类任务,互相抢容量,模型参数翻倍但精度反而下滑。
所以我更倾向于把整个识别拆成两个子问题:检测分支只负责输出字轮区域的包围框,识别分支只负责对裁剪后的单字轮做分类。项目里water_meter_rec目录下的det_train.py、rec_train.py恰好就是这个分工的产物,两套模型可以分别调参、分别重训,这在实际迭代里非常重要。检测模型漏检时,不需要动分类模型,重新标注检测数据即可,反之亦然。
2.2 预处理链路:PreProcess.py 的核心操作
PreProcess.py是检测和识别共用的前置模块。它解决的问题是把摄像头采集的彩色原图转换成适合后续处理的灰度图和二值图,同时保留字轮边缘的结构信息。
import cv2 import numpy as np def preprocess(image_path, resize_w=1024): # 读取原图,按宽度等比缩放,避免长边过大拖慢后续计算 img = cv2.imread(image_path) if img is None: raise ValueError(f"cannot read image: {image_path}") h, w = img.shape[:2] scale = resize_w / w img = cv2.resize(img, (resize_w, int(h * scale))) # 转灰度后先做高斯模糊,抑制表盘玻璃的颗粒噪声 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blur = cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值比全局阈值更稳,能应对表盘不同区域光照不均 binary = cv2.adaptiveThreshold( blur, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) return img, gray, binary这段代码有两个关键参数需要按现场情况调整。resize_w控制输入宽度,我一般设 1024,因为字轮本身是高对比度目标,没必要上 2K 原图,反而会把反光纹理放大。adaptiveThreshold的blockSize=31和C=15决定阈值跟随局部像素的程度,如果拍出来的表盘背光均匀,可以改成全局cv2.threshold加 Otsu,速度更快。
拿到二值图之后,下一步是形态学处理。字轮边缘在二值图中往往有断裂,尤其是白色数字和白色底板的边界,直接找轮廓会得到碎片。对二值图做一次闭运算,用cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)把断裂的笔画接起来。内核尺寸建议用(3, 3),太大会把相邻字轮粘连,太小补不上断裂。
2.3 检测分支:det_train.py 与 det_test.py 如何衔接
经过预处理的图像会进入检测分支。det_train.py负责训练一个目标检测模型,输出每个数字轮的包围框;det_test.py负责推理验证,并把结果可视化。这个项目在检测端可以使用 YOLO 或 SSD 这类单阶段检测器,它们对中小目标密集排列的工况相对友好。
# 训练检测模型,数据放在 dataset/det/train 下,标注为 YOLO 格式 python det_train.py \ --data dataset/det/data.yaml \ --epochs 150 \ --batch-size 16 \ --img-size 640 \ --device 0训练完成后的模型会输出每个字轮左上角和右下角坐标。我在实际使用中会额外加一道过滤逻辑:按字轮宽度归一化,把长宽比异常的输出框丢掉,比如框明显比正常字轮宽三倍的,大概率是把相邻两个轮子框在了一起。
检测结果可视化由see_det_result.py完成,它会遍历测试集图片,把预测框和真实框画在同一张图上。这一步强烈建议在训练完检测模型后立刻做一次,不要只看 mAP 指标。框偏左还是偏右、是否包含字轮外圈金属边,这些细节直接决定后续裁剪图像的质量。
| 预处理参数 | 推荐值 | 调整方向 |
|---|---|---|
| resize_w | 1024 | 图像过小看不清字轮时适当增大到 1280 |
| GaussianBlur ksize | (5, 5) | 噪声严重时加大到 (7, 7),但会损失锐度 |
| adaptiveThreshold blockSize | 31 | 光照不均明显时减小到 21 |
| adaptiveThreshold C | 15 | 二值化出现大块黑斑时调大到 25 |
3. 数字识别:模板匹配兜底与 CNN 分类兜顶
3.1 模板匹配在单一字轮上的局限与适用边界
检测模型输出单个字轮的裁剪图后,最直观的识别方式是模板匹配。创建 0 到 9 共十个模板,在裁剪图上滑动计算匹配度,取最大值对应的数字作为结果。这个思路在小规模实验里看似成立,实际部署时很快会暴露两个问题。
第一,模板匹配对光照和成像角度极度敏感。水表玻璃有弧度,不同角度拍摄同一个数字,灰度分布差异远大于数字本身的字形差异。第二,字轮滚动过程中,数字会同时露出两个字符的碎边,这时候只取最大匹配值经常在两个相邻数字间跳变。
# 单字轮模板匹配的参考实现,适合验证用,不适合直接上线 import cv2 def match_digit(cell_img, templates): best_score = -1.0 best_digit = -1 for digit, tpl in templates.items(): # TM_CCOEFF_NORMED 对线性光照变化有一定容忍度 res = cv2.matchTemplate(cell_img, tpl, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ = cv2.minMaxLoc(res) if max_val > best_score: best_score = max_val best_digit = digit return best_digit, best_score这段逻辑里有个容易忽略的参数:cell_img和tpl必须缩放到相同尺寸,否则matchTemplate会对不同面积做归一化,导致大模板天然占优势。我把所有字轮裁剪图统一缩放到(32, 32),模板也在同等尺寸下生成,再去做匹配。如果best_score低于 0.6,基本可以判定裁剪图不对,直接丢弃比硬猜一个数字更安全。
模板匹配适合作为异常帧的兜底,比如现场光照突变导致分类模型输入分布偏移,模板匹配还能靠粗匹配拦住一部分明显的误识别。但它不适合作为主识别器。
3.2 rec_train.py 中的 CNN 结构与训练参数
分类模型用 CNN 最稳妥。项目里rec_train.py构建了一个轻量级卷积网络,输入是单通道灰度图,输出是 10 个数字类加上一个异常类。增加异常类的目的是给“两个数字交界处”的模糊帧一个明确的出口,而不是让它强分到某个数字上。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPool2D, Flatten, Dense, Dropout from tensorflow.keras.optimizers import Adam def build_rec_model(num_classes=11): model = Sequential([ Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=(32, 32, 1)), MaxPool2D((2, 2)), Conv2D(64, (3, 3), activation='relu', padding='same'), MaxPool2D((2, 2)), Flatten(), Dense(128, activation='relu'), Dropout(0.3), Dense(num_classes, activation='softmax') ]) model.compile( optimizer=Adam(1e-3), loss='categorical_crossentropy', metrics=['accuracy'] ) return model网络结构刻意压得很浅,因为字轮字体简单、类间差异大,用两层卷积就足够提取有效特征。真正影响精度的是数据的覆盖度,而不是网络深度。训练时我会用ImageDataGenerator做在线增强,包括随机旋转 5 度、随机平移 2 像素、亮度扰动 0.2 倍,这能模拟表盘安装角度的微小差异。
数据集组织方式是每个数字一个目录,0/、1/一直到9/,再加一个unknown/存放交界模糊帧。rec_train.py内部用flow_from_directory读取,注意class_mode='categorical'要配上classes参数,否则目录排序变化会导致标签和类别对不上。
| 超参数 | 推荐值 | 参数含义与影响 |
|---|---|---|
| 输入尺寸 | 32 x 32 | 过小丢失笔画细节,过大增加训练成本 |
| 卷积核 | 3 x 3 | 适合捕捉数字笔画的局部边缘 |
| dropout | 0.3 | 防止小数据集过拟合,过大会欠拟合 |
| learning rate | 1e-3 | 收敛慢时先降到 5e-4 再训练 |
| batch size | 64 | 类别均衡时建议接近每类样本数 |
3.3 训练与验证命令
训练脚本的数据集结构固定后,验证阶段用rec_test.py跑一遍测试集,它会输出每个类别的精确率和召回率。这一步要重点看5和8、0和6这两组容易混淆的类别。
# 训练识别模型,数据集结构为 dataset/rec/train/{0..9,unknown} python rec_train.py \ --data dataset/rec/train \ --val-dir dataset/rec/val \ --epochs 60 \ --batch-size 64 \ --save-model checkpoints/rec_cnn.h5训练完成后,我会额外跑一个硬样本分析,把置信度低于 0.8 的预测结果和原图放到同一个目录下人工过一遍。通常会发现三类问题:裁剪框把字轮外圈金属边裁进来,导致分类器学到的特征包含金属反光;相邻数字露出一部分边缘;以及标注数据里unknown类占比太低,模型倾向于把模糊帧硬分到正常数字上。
4. Flask 工程封装:从训练脚本到可用的 Web 识别服务
4.1 工程骨架的模块划分
训练脚本和真正的识别服务之间差着一个工程化封装。项目里flaskProject目录承担的就是这件事,它把 Flask 应用按职责拆成多个模块,与常见的“单文件 app.py”形成对比。
blueprints目录存放路由蓝图,把 Web 页面接口和 API 接口分开,避免把所有路由堆在主文件里。exts.py是一个被刻意抽出来的模块,专门创建db = SQLAlchemy()实例,再让app.py和models.py分别导入同一个实例,避免循环导入。migrations目录是 Flask-Migrate 生成的迁移文件,数据库表结构变更后执行flask db upgrade就能平滑迁移。decorators.py放登录态校验类装饰器,作为接口访问的拦截层。
这种拆分方式在训练脚本转向服务化时特别实用。模型权重和数据库连接信息都写在config.py里,环境切换时只需要改配置,不需要碰业务代码,足以支撑从本机开发环境到测试服务器的部署过程。
4.2 接口设计与请求返回格式
服务端提供一个数字识别接口,前端或产线脚本往POST /api/recognize上传一张表盘图片,接口返回字轮位置、识别结果和置信度。
# blueprints/api.py 中识别接口的核心逻辑 from flask import Blueprint, request, jsonify from PIL import Image import numpy as np import io api_bp = Blueprint('api', __name__) @api_bp.route('/api/recognize', methods=['POST']) def recognize(): # 读取上传的图片文件,转换为灰度图后交给预处理模块 img = Image.open(request.files['image']).convert('L') arr = np.array(img) # detect_pipeline 内部调用 PreProcess + 检测模型 + 识别模型 result = detect_pipeline(arr) return jsonify({ 'digits': result['digits'], 'boxes': result['boxes'], 'confidence': result['confidence'] })这里有个值得注意的设计:图片用PIL读取而不是cv2.imdecode,因为 Flask 拿到的FileStorage对象本质上是文件流,用 PIL 读取可以少一次临时文件写入。img.convert('L')直接完成灰度化,后面再转 numpy 数组交给预处理模块,流程更简洁。
调用方一般用requests库发送请求,或在前端用FormData构造表单。接口返回的digits是一个字符串数组,比如['2', '8', '1', '5', '7'],前端可以直接拼接展示。
// 前端上传表盘图片并获取识别结果的示例 const fd = new FormData(); fd.append('image', fileInput.files[0]); fetch('/api/recognize', { method: 'POST', body: fd }) .then(res => res.json()) .then(data => { if (data.digits.length === 5) { meterValue.textContent = data.digits.join(''); } });前端部分要处理一个边界情况:水表字轮通常是 5 到 7 位,接口返回的digits长度必须符合预期,否则大概率是检测阶段漏框。我一般会在前端加长度校验,不足位数时直接提示重新拍摄,而不是展示一个错误读数。
4.3 模型加载与并发环境下的坑
Flask 默认的开发服务器是单进程多线程,模型加载一次后放在全局变量里,所有请求复用同一个 session 做推理。这种模式在低并发场景下没问题,但要注意detect_pipeline不能被普通函数执行期间的其他请求修改全局缓存。
# app.py 中初始化模型,避免每次请求重复加载 from tensorflow.keras.models import load_model det_model = load_model('checkpoints/det_model.h5') rec_model = load_model('checkpoints/rec_cnn.h5')这两行代码必须放在模块层,而不是放在recognize()函数里。很多人第一次写接口时会把load_model放进请求处理函数,结果每来一张图就重载一次模型,单张识别耗时从几十毫秒变成几秒,接口直接被打爆。
正式部署时用 gunicorn 或者 uwsgi 起服务,多 worker 情况下每个 worker 会各自持有一份模型副本,内存占用随 worker 数线性增长。如果机器内存有限,优先保证单 worker 多线程,用--threads 8的方式提升并发能力,而不是盲目加 worker 数。
5. 精度验证与多帧投票:让现场读数更可信
5.1 两个可视化脚本的定位
see_det_result.py展示检测阶段的预测框,它解决“字轮框准不准”的问题。see_finally_result.py展示完整识别管线的最终输出,把每个字轮的识别结果直接标注在图上。我建议先跑前者后跑后者,如果检测框本身就偏,后者的识别结果再准也没有意义。
5.2 两个核心指标
检测阶段看 IoU,即预测框与真实框的交并比。字轮检测的 IoU 阈值建议设在 0.85 以上,因为字轮是规整矩形且边界清晰,低于这个值说明边框明显偏移。识别阶段看数字错误率,把预测序列与实际读数逐位比对,统计错一位和错多位的情况,只算整体准确率会掩盖6和8这类局部混淆。
5.3 多帧投票策略
单张照片识别出错的概率难以完全消除,但现场通常能连续拍多帧。常见做法是对连续五帧的识别结果做多数投票,只有三帧以上结果一致时才作为最终读数输出,否则返回重拍请求。这个策略能把随机性的误识别挡在系统入口外。
from collections import Counter def majority_vote(results_per_frame): # results_per_frame 是每帧识别出的数字字符串,如 ['28157', '28157', '28167', '28157', '28157'] counter = Counter([tuple(r) for r in results_per_frame]) best_seq, cnt = counter.most_common(1)[0] if cnt >= 3: return ''.join(best_seq) return None # 五帧无三帧一致,返回重拍标记投票粒度不需要到整串数字一致,可以下沉到每一位单独投票再拼接,因为水表读数中间某位跳变时,高位数字通常不变,整串投票会把有效的高位信息全丢掉。最后把输出的数字序列按水表位数格式化成带右侧红色小数位或检验位的形式,方便和人工抄表底数对齐。
本文还有配套的精品资源,点击获取