ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

水表识别实战:定位网络与CRNN识别网络的两段式深度学习方案

水表识别实战:定位网络与CRNN识别网络的两段式深度学习方案 简介本资源面向深度学习入门与计算机视觉实践者提供一套完整的水表识别项目源码采用“定位网络识别网络”两阶段方案先由定位网络框出表盘读数区域再由识别网络完成数字识别适合作为课程设计、毕业设计或工业表计识别的练手案例。压缩包共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 存放 xmlimg_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_okTrue) 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.0001batch size 根据显存调到 16 或 32。数据增强重点做随机缩放、随机裁剪和亮度抖动因为水表照片的拍摄距离和光照差异很大。验证时看两个指标mAP0.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_size256): super().__init__() # 输入假设是 1 通道灰度图高 32宽 128 self.cnn nn.Sequential( nn.Conv2d(1, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding1), nn.ReLU(), # 高度方向池化到 1宽度保留方便按列读序列 nn.MaxPool2d((2, 1)), nn.Conv2d(256, 256, 3, padding1), nn.ReLU(), nn.MaxPool2d((2, 1)), ) self.rnn nn.LSTM(256, hidden_size, bidirectionalTrue, batch_firstTrue) 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(blank0, zero_infinityTrue) model CRNN(num_classes11) # 0 是 blank1-10 对应数字 0-9 optimizer torch.optim.Adam(model.parameters(), lr0.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), dtypetorch.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_classes11是因为 CTC 需要一个 blank 类别数字 0 到 9 共 10 类加上 blank 共 11 类。CTCLoss的blank0要和类别编号对应。训练时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_size640): # 读原图并记录原始尺寸 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去掉 blankp ! 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就只跑一次识别省一半计算。这套方案我从头搭过几次最大的体会是定位网络的召回率比精度重要识别网络的训练数据比模型结构重要。定位漏了表盘后面全白搭识别训练集里没有足够多的反光和倾斜样本模型上线后就会在各种玄学场景下翻车。每次新拿到一批水表照片我都会先抽几十张看看有没有没见过的表盘样式如果有先补标注再训练不要指望模型自己泛化。希望帮到你。本文还有配套的精品资源点击获取
返回列表