☰
水表识别实战:定位网络与CRNN识别网络的两段式深度学习方案
2026/10/6 3:14:34 网站建设 项目流程

简介:本资源面向深度学习入门与计算机视觉实践者,提供一套完整的水表识别项目源码,采用“定位网络+识别网络”两阶段方案:先由定位网络框出表盘读数区域,再由识别网络完成数字识别,适合作为课程设计、毕业设计或工业表计识别的练手案例。压缩包共55个文件,约144KB,以18个py脚本为核心,涵盖数据预处理、模型定义、训练与测试流程;另有13个pyc编译文件、14张jpg样本图、3个xml配置及1个md说明文档,目录按base、utils、summaries等模块划分,结构清晰便于二次开发。目前已有128人学习下载。读者可从中获得两阶段检测识别的完整实现思路、数据提供与图像预处理脚本、模型训练与推理代码,以及可复用的工程目录组织方式,便于快速理解并迁移到其他仪表识别任务。

1. 水表识别为什么要拆成定位网络和识别网络两段来做

水表识别这个需求,最早大多来自水务公司的抄表环节。传统做法是抄表员拿手机拍一张表盘照片,回传后由后台人工读数,一天几百张下来眼睛都花了。后来大家想用深度学习自动读数,第一反应往往是“上一个 OCR 模型不就行了”。但真上手就会发现,水表照片里表盘只占画面一小块,背景有管道、墙面、反光、水渍,直接整图送进识别网络,准确率会掉得很难看。这就是为什么工业界常见做法是把任务拆成两段:先用一个定位网络把表盘区域框出来,再用一个识别网络只对框出来的区域读数字。定位网络负责“表盘在哪”,识别网络负责“数字是几”,两段各司其职,整体精度比端到端硬训高出一截。这套思路适合有图像基础、想做一个能落地的深度学习实战项目案例的工程师,也适合刚学完深度学习cnn、想找一个完整项目练手的人。下面我把这套方案从数据、模型、训练到部署的路径拆开讲清楚。

2. 定位网络:把表盘从整张图里框出来

2.1 为什么定位网络选轻量检测而不是分割

定位网络的目标很简单:输入一张水表照片,输出表盘区域的矩形框。常见做法有两类,一类是语义分割,把表盘像素逐点标出来再取外接矩形;另一类是目标检测,直接回归框的坐标。分割精度高但标注成本大,一张图要标几百个像素点,而且推理慢。检测网络只需要标一个矩形框,标注快、推理快,对水表这种“只有一个主要目标”的场景足够用。我一般会选轻量检测网络,比如以 MobileNet 或 ShuffleNet 为骨干的 SSD、YOLO 小模型,参数量控制在几兆以内,方便后面部署到边缘设备。选型时重点看三个指标:框的召回率要接近 100%,因为漏掉表盘后面识别网络就没输入了;框的 IoU 阈值可以放宽到 0.5 左右,因为识别网络对框的轻微偏移有一定容忍度;推理速度要能到实时,单张图控制在几十毫秒。

2.2 定位网络的数据标注与训练脚本

定位网络的数据集就是“原图 + 表盘框坐标”。标注格式常见用 VOC 的 XML 或 YOLO 的 txt。下面这段脚本把 VOC 格式转成 YOLO 需要的归一化坐标,方便直接喂给检测框架。

import xml.etree.ElementTree as ET import os # 把 VOC 的 xml 标注转成 YOLO 的 txt 格式 # 输入:voc_dir 存放 xml,img_dir 存放原图 # 输出:每张图对应一个 txt,每行 class x_center y_center w h(归一化到 0-1) def voc_to_yolo(voc_dir, img_dir, out_dir, class_map): os.makedirs(out_dir, exist_ok=True) for xml_file in os.listdir(voc_dir): if not xml_file.endswith('.xml'): continue tree = ET.parse(os.path.join(voc_dir, xml_file)) root = tree.getroot() # 图片宽高从 size 节点取,用于归一化 size = root.find('size') img_w = int(size.find('width').text) img_h = int(size.find('height').text) lines = [] for obj in root.findall('object'): cls_name = obj.find('name').text if cls_name not in class_map: continue cls_id = class_map[cls_name] bbox = obj.find('bndbox') xmin = float(bbox.find('xmin').text) ymin = float(bbox.find('ymin').text) xmax = float(bbox.find('xmax').text) ymax = float(bbox.find('ymax').text) # YOLO 用中心点加宽高,且都要除以图片尺寸归一化 x_center = (xmin + xmax) / 2.0 / img_w y_center = (ymin + ymax) / 2.0 / img_h w = (xmax - xmin) / img_w h = (ymax - ymin) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") out_name = os.path.splitext(xml_file)[0] + '.txt' with open(os.path.join(out_dir, out_name), 'w') as f: f.write('\n'.join(lines)) if __name__ == '__main__': voc_to_yolo('annotations/xml', 'images', 'labels', {'meter_dial': 0})

这段脚本的逻辑是:遍历每个 XML,读出图片宽高,再把每个目标框的左上右下坐标转成中心点加宽高,并除以图片尺寸做归一化。参数上,class_map里只保留meter_dial一个类别,因为定位网络只关心表盘,不需要区分数字。归一化后的坐标范围在 0 到 1 之间,训练时检测框架会再乘回特征图尺寸。跑完脚本后,检查一下生成的 txt 行数是否和原图数量一致,如果某张图没有对应 txt,说明标注漏了,需要补标。

2.3 定位网络的训练参数与验证指标

训练定位网络时,我一般用迁移学习,加载在 COCO 或 ImageNet 上预训练的骨干权重,只训练检测头。学习率初始设 0.001,用余弦退火降到 0.0001,batch size 根据显存调到 16 或 32。数据增强重点做随机缩放、随机裁剪和亮度抖动,因为水表照片的拍摄距离和光照差异很大。验证时看两个指标:mAP@0.5 和召回率。mAP 到 0.9 以上、召回率到 0.98 以上,定位网络就算合格。如果召回率上不去,优先检查标注框是不是把表盘边缘漏掉了,或者增强里随机裁剪把表盘裁掉了一半。训练完成后,把定位网络导出成推理格式,后面和识别网络串起来用。

3. 识别网络:只对表盘区域读数字

3.1 识别网络为什么用序列识别而不是分类

识别网络的任务是把表盘区域里的数字读出来。水表读数一般是一串数字,长度不固定,可能是 4 位、5 位或 6 位,而且数字之间有间隔。如果按分类做,需要先切分每个数字再逐位分类,切分误差会累积。常见做法是用序列识别网络,比如 CRNN 加 CTC 损失,输入是表盘区域图像,输出是数字序列,不需要预先切分。CRNN 的结构是卷积层提特征、循环层建模序列、CTC 层做对齐,对不定长数字串很合适。选型时注意,水表数字通常是印刷体,字体规整,所以卷积层不用太深,五六层就够,循环层用双向 LSTM 两层即可。如果数字有旋转或倾斜,可以在识别前加一个空间变换网络做矫正。

3.2 识别网络的数据构造与训练脚本

识别网络的数据来自定位网络框出来的表盘区域。训练时可以直接用标注好的表盘框裁剪,也可以让定位网络先跑一遍生成裁剪图。下面这段代码用 PyTorch 定义一个简单的 CRNN 模型,并给出训练循环的关键部分。

import torch import torch.nn as nn # 简单的 CRNN:卷积提特征,双向 LSTM 建模序列,全连接输出字符概率 class CRNN(nn.Module): def __init__(self, num_classes, hidden_size=256): super().__init__() # 输入假设是 1 通道灰度图,高 32,宽 128 self.cnn = nn.Sequential( nn.Conv2d(1, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding=1), nn.ReLU(), # 高度方向池化到 1,宽度保留,方便按列读序列 nn.MaxPool2d((2, 1)), nn.Conv2d(256, 256, 3, padding=1), nn.ReLU(), nn.MaxPool2d((2, 1)), ) self.rnn = nn.LSTM(256, hidden_size, bidirectional=True, batch_first=True) self.fc = nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: (B, 1, H, W) feat = self.cnn(x) # (B, C, H', W') b, c, h, w = feat.shape # 把宽度方向当作时间步,通道和高度合并成特征维度 feat = feat.permute(0, 3, 1, 2).reshape(b, w, c * h) out, _ = self.rnn(feat) # (B, W', 2*hidden) logits = self.fc(out) # (B, W', num_classes) return logits # 训练时用 CTC 损失,blank 设为 0,数字字符从 1 开始编号 ctc_loss = nn.CTCLoss(blank=0, zero_infinity=True) model = CRNN(num_classes=11) # 0 是 blank,1-10 对应数字 0-9 optimizer = torch.optim.Adam(model.parameters(), lr=0.001) # 一个 batch 的训练步骤示意 def train_step(images, targets, target_lengths): model.train() logits = model(images) # (B, T, C) log_probs = logits.log_softmax(2) # CTC 需要 log 概率 input_lengths = torch.full((images.size(0),), logits.size(1), dtype=torch.long) loss = ctc_loss(log_probs.permute(1, 0, 2), targets, input_lengths, target_lengths) optimizer.zero_grad() loss.backward() optimizer.step() return loss.item()

这段代码里,卷积部分把输入图像的高度逐步池化到 1,宽度保留,这样每个时间步对应图像的一列,符合从左到右读数字的顺序。num_classes=11是因为 CTC 需要一个 blank 类别,数字 0 到 9 共 10 类,加上 blank 共 11 类。CTCLoss的blank=0要和类别编号对应。训练时targets是拼接后的数字标签,target_lengths是每个样本的数字个数。参数上,学习率 0.001 是常用起点,如果损失震荡就降到 0.0005。batch size 受限于序列长度,一般设 32 或 64。跑几个 epoch 后看 CTC 损失是否稳定下降,如果一直不降,检查标签编码是不是把 blank 和数字 0 搞混了。

3.3 识别网络的解码与准确率评估

训练完识别网络后,推理时用 CTC 贪心解码或束搜索解码。贪心解码就是每个时间步取概率最大的类别,然后合并重复字符并去掉 blank。束搜索解码精度略高但慢一些,对水表这种短序列,贪心解码通常够用。评估准确率时,要区分“整串全对”和“字符级准确率”。整串全对率到 95% 以上,字符级准确率到 99% 以上,识别网络就算可用。如果整串全对率低但字符级高,说明错误集中在某几位数字上,可能是那几位数字在训练集里样本太少,需要补充对应样本。另外,水表数字里 6 和 8、3 和 8 容易混,可以在训练时对这几类数字做重点增强。

4. 两段网络串起来:从拍照到读数的完整链路

4.1 定位与识别的串联方式和坐标映射

两段网络串起来时,定位网络输出表盘框的坐标,识别网络需要的是裁剪后的表盘图像。这里有一个容易翻车的点:定位网络输出的框可能带一点旋转,如果直接按水平框裁剪,表盘数字会倾斜,识别准确率下降。常见做法是定位网络输出水平框,然后在裁剪时按框的外接矩形稍微外扩 5% 到 10%,保证表盘完整。如果表盘倾斜严重,可以在定位网络里加一个角度回归分支,或者在裁剪后加一个矫正步骤。坐标映射时注意,定位网络输入的是缩放后的图,输出坐标要映射回原图尺寸再裁剪,否则裁剪区域会偏。下面这段代码演示串联推理的流程。

import cv2 import torch def pipeline(image_path, detector, recognizer, img_size=640): # 读原图并记录原始尺寸 img = cv2.imread(image_path) orig_h, orig_w = img.shape[:2] # 定位网络输入需要缩放和归一化 inp = cv2.resize(img, (img_size, img_size)) inp = inp[:, :, ::-1].transpose(2, 0, 1) # BGR 转 RGB 再转 CHW inp = torch.from_numpy(inp).float().unsqueeze(0) / 255.0 with torch.no_grad(): boxes = detector(inp) # 假设输出归一化坐标 (x1,y1,x2,y2) # 把归一化坐标映射回原图 x1, y1, x2, y2 = boxes[0] x1, x2 = int(x1 * orig_w), int(x2 * orig_w) y1, y2 = int(y1 * orig_h), int(y2 * orig_h) # 外扩 8%,防止边缘数字被裁掉 pad_w = int((x2 - x1) * 0.08) pad_h = int((y2 - y1) * 0.08) x1 = max(0, x1 - pad_w) y1 = max(0, y1 - pad_h) x2 = min(orig_w, x2 + pad_w) y2 = min(orig_h, y2 + pad_h) crop = img[y1:y2, x1:x2] # 识别网络输入:灰度、缩放到固定高宽 gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (128, 32)) rec_inp = torch.from_numpy(gray).float().unsqueeze(0).unsqueeze(0) / 255.0 with torch.no_grad(): logits = recognizer(rec_inp) # 贪心解码 pred = logits.argmax(2)[0].tolist() chars = [] prev = -1 for p in pred: if p != 0 and p != prev: chars.append(str(p - 1)) # 1-10 映射回 0-9 prev = p return ''.join(chars)

这段代码的关键点是坐标映射和外扩。定位网络在 640 尺寸上推理,输出坐标要乘回原图宽高。外扩 8% 是为了防止表盘边缘的数字被裁掉,这个比例可以根据实际框的紧致程度调整,框越紧外扩越大。识别网络输入统一缩放到 128×32,和训练时保持一致,否则卷积层看到的特征分布会变。贪心解码时,p != 0去掉 blank,p != prev合并重复字符,最后把类别编号减 1 还原成数字。

4.2 端到端联调的三个检查点

串联之后不要直接上大批量测试,先做三个检查。第一,拿几张训练集里的图,把定位框画出来看是否框住了表盘,如果框偏了,先修定位网络。第二,把裁剪图单独送识别网络,看读数是否正确,如果裁剪图对但读数错,问题在识别网络。第三,拿没见过的图跑完整链路,统计整串全对率。联调时常见的问题是定位框抖动,同一张图两次推理框的位置差几个像素,导致裁剪区域变化,识别结果也跟着变。解决办法是定位网络推理时加非极大值抑制,或者对多尺度结果做融合。另外,如果部署在移动端,两段网络可以共享部分卷积特征,减少计算量,但共享后要重新微调,不能直接拼接。

5. 避坑与排查:水表识别项目里最容易翻车的五件事

5.1 表盘反光导致定位框漂移

现象:定位网络在反光强烈的照片上框出的表盘区域偏移,甚至框到旁边的管道。原因:反光区域和表盘玻璃的纹理相似,检测网络把反光当成了表盘特征。解决:训练集里补充反光样本,标注时仍然只框表盘;推理时对输入做直方图均衡化,减弱反光影响;如果反光固定出现在某个角度,可以在定位网络前加一个简单的图像预处理,比如限制对比度自适应直方图均衡。

5.2 数字粘连导致识别串位

现象:识别网络把两个相邻数字读成一个,或者多读出一位。原因:表盘数字之间间隔小,CTC 在时间步上对齐时把两个数字合并了。解决:训练时增加数字间距小的样本;识别网络输入宽度适当加大,让每个数字占更多时间步;解码时用束搜索代替贪心,束宽设 5 到 10,能缓解粘连。

5.3 训练集和推理输入尺寸不一致

现象:训练时识别准确率很高,部署后准确率骤降。原因:训练时图像缩放到 128×32,推理时忘了缩放或者用了不同的插值方式。解决:把预处理封装成一个函数,训练和推理共用;检查插值方式,训练用双线性,推理也用双线性,不要一个用最近邻一个用双线性。

5.4 定位网络漏检小表盘

现象:远景照片里表盘只占几十个像素,定位网络直接漏掉。原因:检测网络的下采样倍数太大,小目标在特征图上只剩一两个像素。解决:用更高分辨率的输入,比如把 640 提到 1024;或者在检测网络里加特征金字塔,把浅层高分辨率特征和深层特征融合;训练时对包含小表盘的图做过采样。

5.5 读数结果后处理缺失

现象:识别网络输出一串数字,但实际水表读数有固定位数,比如 5 位,输出 4 位或 6 位。原因:CTC 解码没有约束输出长度。解决:在解码后加一个后处理,根据水表型号把读数补齐或截断;如果知道表盘数字位数,可以在 CTC 解码时加长度惩罚,让输出长度接近真实位数。另外,水表最后一位通常是红色小数位,如果业务只关心整数位,后处理时直接去掉最后一位。

6. 把两段网络压到一块板子上:量化与部署的实操技巧

训练完的两段网络,参数量加起来可能几十兆,直接放到边缘设备上推理速度不够。我一般会做两步压缩。第一步是量化,把浮点权重转成 int8,模型体积缩小到四分之一,推理速度提升两三倍。PyTorch 里可以用动态量化或静态量化,识别网络这种以卷积和 LSTM 为主的模型,静态量化效果更好。第二步是算子融合,把卷积、批归一化和激活函数合并成一个算子,减少内存访问。部署时注意,定位网络和识别网络可以串行跑,也可以并行跑,如果设备有两个计算核心,并行能省一点时间。下面是一个静态量化的示例。

import torch.quantization # 识别网络静态量化:先准备模型,再校准,最后转换 model.eval() model.qconfig = torch.quantization.get_default_qconfig('fbgemm') # 融合卷积、BN 和 ReLU,减少推理时的算子数量 model_fused = torch.quantization.fuse_modules(model, [['cnn.0', 'cnn.1']]) model_prepared = torch.quantization.prepare(model_fused) # 用一批校准数据跑一遍,统计激活值的分布 calib_data = [torch.randn(1, 1, 32, 128) for _ in range(100)] with torch.no_grad(): for data in calib_data: model_prepared(data) model_quantized = torch.quantization.convert(model_prepared) # 保存量化后的模型 torch.jit.save(torch.jit.script(model_quantized), 'crnn_quantized.pt')

这段代码里,fbgemm是 x86 平台的量化后端,如果是 ARM 设备要换成qnnpack。融合时只融合了第一层卷积和 ReLU,实际项目里要把所有卷积、BN、ReLU 的组合都列出来。校准数据要用真实表盘裁剪图,不能用随机噪声,否则激活值分布统计不准,量化后精度掉得厉害。量化后一定要在验证集上重新测一遍整串全对率,如果掉超过两个百分点,就要考虑混合量化,只量化部分层。

部署到设备上之后,还有一个容易忽略的点是图像预处理的时间。拍照得到的图可能是几兆的 JPEG,解码和缩放本身就要几十毫秒,如果预处理用 CPU 单线程做,可能比网络推理还慢。我一般会把预处理也放到 GPU 或 DSP 上,或者用硬件解码器直接输出缩放后的图。另外,连续抄表时,相邻两张图可能拍的是同一个表,可以在定位网络前加一个简单的去重,如果两张图的表盘框 IoU 超过 0.9,就只跑一次识别,省一半计算。

这套方案我从头搭过几次,最大的体会是:定位网络的召回率比精度重要,识别网络的训练数据比模型结构重要。定位漏了表盘,后面全白搭;识别训练集里没有足够多的反光和倾斜样本,模型上线后就会在各种玄学场景下翻车。每次新拿到一批水表照片,我都会先抽几十张看看有没有没见过的表盘样式,如果有,先补标注再训练,不要指望模型自己泛化。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询