简介:本资源面向深度学习入门与计算机视觉实践者,提供一套完整的水表读数识别项目源码,采用“定位网络+识别网络”两阶段方案:先由检测模型框出表盘数字区域,再由识别模型完成字符读取,适合作为课程设计、毕业设计或工业仪表智能识别的参考案例。压缩包共55个文件,约144KB,包含18个Python脚本、13个pyc编译文件、14张jpg样本图及少量xml、md等配置说明,覆盖数据预处理、模型定义、训练与测试等模块,目录按base、summaries、seg、WM等分层组织,便于按流程阅读与二次开发。目前已有128人学习下载。读者可从中获取两阶段网络的设计思路、数据提供与图像预处理脚本、训练入口及测试代码,并借助README快速理解整体结构,为水表识别或类似仪表读数任务提供可复用的工程骨架与排错参考。
1. 水表识别为什么要拆成定位网络和识别网络两段来做
去年帮一个做智慧水务的团队看现场,他们最初想用一个端到端模型直接从整张表盘图里读出读数,结果在实验室里准确率能到 95%,一拉到小区楼道里就掉到 60% 出头。问题不在模型不够深,而在于一张水表图里,真正有用的只有中间那块读数窗口,四周全是金属外壳、管道、反光、水渍和贴纸。你让一个网络同时干「找窗口」和「读数字」两件事,它两件都干不好。
这就是水表识别项目里最核心的工程判断:把任务拆成定位网络加识别网络两段。定位网络负责从整图里框出读数区域,识别网络只在这个小框里做字符识别。拆开之后,定位网络可以专门对付复杂背景和角度,识别网络可以专门对付字轮和指针的字符形态,两边各自调参、各自换模型,互不拖累。这套思路在深度学习实战项目案例里非常典型,也是我一般会推荐给刚接触深度学习项目的人的第一个练手方向,因为它把检测和识别两条主线都串起来了。
这篇文章面向的是想真正把水表识别跑起来的人:你可能在做智慧水务、远程抄表、老旧小区改造,或者只是想找一个深度学习实战项目案例练手。我会按「定位网络怎么选、识别网络怎么搭、两段怎么接、坑在哪」的顺序讲,参数和命令都给到能直接抄的程度。深度学习环境配置、深度学习pytorch 这些前置话题我只在必要处点一下,不展开。
2. 定位网络:从整图里把读数窗口抠出来
2.1 为什么定位用检测而不是分割
定位网络的目标很明确:输入一张任意尺寸的水表图,输出读数窗口的边界框。常见做法有两类,一类是语义分割,把读数区域逐像素标出来再取外接矩形;另一类是目标检测,直接回归边界框。我一般会选检测,原因有三个。
第一,水表读数窗口基本是一个规整的矩形,检测框天然贴合,不需要分割那种像素级精度。第二,检测模型的推理速度更快,单张图在普通 GPU 上能压到 20ms 以内,分割往往要慢一倍以上。第三,检测框架成熟,数据标注成本低,标一个框比标一张掩码快得多。分割唯一的优势是能处理极端倾斜和遮挡,但水表场景里这种极端情况占比很低,不值得为它牺牲整体速度。
检测模型的选择上,如果追求精度且算力够,用 YOLO 系列或者 RT-DETR;如果要在边缘设备上跑,用轻量化的 YOLO-nano 或者 MobileNet 做 backbone 的 SSD。我下面用 YOLO 格式讲,因为它的数据格式和训练脚本最通用,换成别的检测框架,数据准备和评估逻辑是一样的。
2.2 标注规范:一个框决定后面所有事
定位网络的精度上限由标注决定。水表读数窗口的标注有几个容易翻车的地方,我按血泪经验列一下。
框要贴紧读数窗口的内边缘,不要把外壳边框框进去。外壳边框在不同表型上宽度差异很大,框进去会让网络学到无关特征。如果读数窗口里有分隔线或者装饰性边框,框到分隔线内侧即可。对于字轮式水表,读数窗口通常是那排数字所在的矩形;对于指针式水表,是包含所有指针和刻度的那块圆形或方形区域。两种表型最好分开训练,或者至少在标注时用同一个类别名,让网络自己学。
标注文件用 YOLO 的 txt 格式,每行是class_id x_center y_center width height,坐标都归一化到 0 到 1。下面这段脚本把常见的 VOC 格式 xml 转成 YOLO txt,我把它放在tools/voc2yolo.py。
import os import xml.etree.ElementTree as ET # 类别映射,水表项目里通常只有一类:reading_window CLASS_MAP = {"reading_window": 0} def convert(xml_dir, out_dir, img_w, img_h): os.makedirs(out_dir, exist_ok=True) for name in os.listdir(xml_dir): if not name.endswith(".xml"): continue tree = ET.parse(os.path.join(xml_dir, name)) root = tree.getroot() # 图片宽高优先从 xml 里读,读不到就用传入的默认值 size = root.find("size") w = int(size.find("width").text) if size is not None else img_w h = int(size.find("height").text) if size is not None else img_h lines = [] for obj in root.findall("object"): cls_name = obj.find("name").text if cls_name not in CLASS_MAP: continue 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) # 归一化并转成中心点加宽高 xc = (xmin + xmax) / 2.0 / w yc = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{CLASS_MAP[cls_name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") if lines: out_name = os.path.splitext(name)[0] + ".txt" with open(os.path.join(out_dir, out_name), "w") as f: f.write("\n".join(lines)) if __name__ == "__main__": convert("data/annotations", "data/labels", 1920, 1080)这段脚本的逻辑很直白:遍历 xml,读图片尺寸,把每个目标框的左上右下坐标转成归一化的中心点加宽高。参数上,CLASS_MAP决定哪些类别会被保留,水表项目里通常只留读数窗口一类;img_w和img_h是兜底尺寸,只在 xml 里没有 size 节点时生效。转换完一定要抽查几张,用可视化脚本把框画回原图,确认框的位置和大小对得上,这一步省不得。
2.3 训练定位网络的命令与必调参数
数据准备好之后,按 YOLO 的标准目录结构放:images/train、images/val、labels/train、labels/val,再写一个data.yaml指向这些路径和类别名。训练命令我一般这样写:
yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=150 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=30 \ project=runs/detect \ name=water_meter_locate参数里最值得说的是三个。imgsz=640是输入分辨率,水表读数窗口在整图里占比不大,分辨率太低会让窗口缩成几十个像素,定位框抖动明显,我一般不低于 640,图特别大就上 960。lr0=0.01是初始学习率,检测任务这个值比较稳,如果 loss 前几个 epoch 就炸,降到 0.001。patience=30是早停耐心值,验证集 30 轮不涨就停,避免过拟合。训练完看runs/detect/water_meter_locate下的混淆矩阵和 PR 曲线,重点看读数窗口这一类有没有漏检。
2.4 定位输出的后处理:裁剪与扩边
定位网络输出的是归一化坐标,要还原到原图并裁剪出读数窗口,送给识别网络。这里有个细节:裁剪时不要严格按框裁,要往外扩 5% 到 10% 的边。原因是定位框偶尔会偏内,把数字边缘切掉,识别网络看到残缺字符直接读错。扩边之后即使框偏一点,字符也完整。下面这段是裁剪逻辑。
def crop_reading_window(img, box, expand=0.08): # box 是归一化的 (xc, yc, w, h) h, w = img.shape[:2] xc, yc, bw, bh = box # 转成像素坐标 x1 = (xc - bw / 2) * w y1 = (yc - bh / 2) * h x2 = (xc + bw / 2) * w y2 = (yc + bh / 2) * h # 按比例扩边 pad_w = (x2 - x1) * expand pad_h = (y2 - y1) * expand x1 = max(0, int(x1 - pad_w)) y1 = max(0, int(y1 - pad_h)) x2 = min(w, int(x2 + pad_w)) y2 = min(h, int(y2 + pad_h)) return img[y1:y2, x1:x2]expand这个参数我一般设 0.08,表型复杂、定位抖动大的场景可以加到 0.12。扩边之后裁剪区域会包含一点外壳,没关系,识别网络对这点背景不敏感,但字符残缺是致命的。
3. 识别网络:在裁剪窗口里读出水表读数
3.1 识别任务到底在识别什么
裁剪出来的读数窗口里,内容分两种。字轮式水表是一排数字,通常是 4 到 8 位,每位是一个 0 到 9 的字轮,有的位是红色表示小数。指针式水表是若干指针加刻度盘,每个指针指向一个数字。这两种的识别难度完全不同。
字轮式本质是定长字符识别,可以用 CRNN 加 CTC,也可以用简单的 CNN 分类器逐位切分后分类。指针式本质是回归任务,要检测每个指针的旋转角度,再换算成读数。我建议新手先做字轮式,因为它是标准的 OCR 流程,资料多、坑少;指针式留到字轮跑通之后再啃。
识别网络的输入是定位裁剪出的小图,输出是读数字符串。这里有个关键判断:识别网络不需要知道整图长什么样,它只在这个小窗口里工作,所以输入分辨率可以统一缩放到固定尺寸,比如 32 乘 320,这样 batch 训练效率高,推理也快。
3.2 用 CRNN 搭一个字轮识别网络
CRNN 的结构是 CNN 提特征加 RNN 做序列建模加 CTC 解码,适合不定长字符识别。水表字轮虽然位数固定,但用 CRNN 可以省掉字符切分这一步,端到端出结果。下面是一个精简版实现。
import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes=11): super().__init__() # CNN 部分:把输入图压成高度为 1 的特征序列 self.cnn = nn.Sequential( nn.Conv2d(1, 64, 3, 1, 1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, 1, 1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(), nn.Conv2d(256, 256, 3, 1, 1), nn.ReLU(), nn.MaxPool2d((2, 1)), nn.Conv2d(256, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.ReLU(), nn.Conv2d(512, 512, 3, 1, 1), nn.ReLU(), nn.MaxPool2d((2, 1)), ) # RNN 部分:双向 LSTM 建模字符间上下文 self.rnn = nn.LSTM(512, 256, bidirectional=True, batch_first=True) self.fc = nn.Linear(512, num_classes) def forward(self, x): # x: (B, 1, 32, 320) feat = self.cnn(x) # (B, 512, 1, W') feat = feat.squeeze(2) # (B, 512, W') feat = feat.permute(0, 2, 1) # (B, W', 512) out, _ = self.rnn(feat) # (B, W', 512) logits = self.fc(out) # (B, W', num_classes) return logitsnum_classes=11是 10 个数字加 1 个 CTC 空白符。CNN 部分把高度压到 1,宽度保留,形成从左到右的序列;RNN 用双向 LSTM 捕捉相邻字符的关系,比如「1」后面跟「2」的概率;最后全连接输出每个位置的字符概率。训练时用 CTC loss,解码用贪心或束搜索。输入图统一缩放到 32 乘 320,灰度单通道,因为水表读数窗口基本是黑白灰,彩色信息没用。
3.3 训练识别网络的参数与数据增强
识别网络的训练数据来自定位裁剪,所以要先跑一遍定位网络,把训练集和验证集的读数窗口都裁出来,再人工核对标签。标签就是读数字符串,比如01234。训练命令我一般这样组织:
python train_rec.py \ --train_list data/rec/train.txt \ --val_list data/rec/val.txt \ --img_h 32 \ --img_w 320 \ --batch_size 64 \ --lr 0.001 \ --epochs 200 \ --num_classes 11参数上,img_h=32和img_w=320是 CRNN 的经典输入尺寸,水表读数位数不多,320 宽度够用。batch_size=64在单卡 8G 显存上比较稳。lr=0.001配 Adam 优化器,CTC loss 对学习率比较敏感,太大容易不收敛。数据增强方面,我一般加随机亮度对比度扰动、轻微旋转正负 3 度、随机遮挡一小块,模拟水渍和反光。不要加水平翻转,数字翻转后语义就变了。
3.4 两段网络怎么串成一条推理流水线
定位和识别分别训练好之后,推理时按顺序串起来:整图送定位网络,拿到读数窗口框,裁剪扩边,缩放送识别网络,CTC 解码出字符串。下面是一个完整的推理函数。
def infer(img, loc_model, rec_model, loc_conf=0.5): # 第一步:定位 results = loc_model(img)[0] boxes = results.boxes if len(boxes) == 0: return None # 取置信度最高的框 idx = boxes.conf.argmax() if boxes.conf[idx] < loc_conf: return None box = boxes.xywhn[idx].cpu().numpy() # 第二步:裁剪扩边 crop = crop_reading_window(img, box, expand=0.08) # 第三步:识别 gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) resized = cv2.resize(gray, (320, 32)) tensor = torch.from_numpy(resized).float().unsqueeze(0).unsqueeze(0) / 255.0 with torch.no_grad(): logits = rec_model(tensor) text = ctc_greedy_decode(logits) return textloc_conf=0.5是定位置信度阈值,低于这个值说明网络没找到可信的读数窗口,直接返回空比返回错读要好。实际部署时这个阈值要根据现场误检率调,误检多就调高,漏检多就调低。整条流水线的延迟主要在定位网络,识别网络因为输入小,耗时可以忽略。
4. 避坑与排查:水表识别项目里最容易翻车的五件事
4.1 定位框抖动导致识别结果跳变
现象是同一块水表连续拍几张,读数偶尔对偶尔错,错的时候往往差一位。原因是定位框在相邻帧之间抖动,裁剪区域偏移,识别网络看到的字符位置变了。解决方法是推理时对定位框做时序平滑,如果输入是视频流,用前几帧的框做加权平均;如果是单图,把扩边比例从 0.08 提到 0.12,给识别网络更多容错空间。另外检查定位网络的输入分辨率,太低会导致框回归不稳。
4.2 字轮半字状态被识别成相邻数字
现象是水表字轮处于两个数字中间时,识别结果在两者之间跳。原因是字轮式水表在进位过程中会露出半个数字,标注时如果只标完整数字,网络没见过半字状态。解决方法是在数据里专门收集进位状态的样本,标注时按字轮实际显示的主导数字标,同时让识别网络输出每一位的置信度,置信度低的位置标记为待人工复核。CTC 解码时也可以用束搜索,比贪心解码更稳。
4.3 反光和阴影让定位网络漏检
现象是某些角度下水表玻璃反光,定位网络直接找不到读数窗口。原因是训练集里缺少反光样本,网络把反光当成了背景。解决方法是在数据增强里加随机高光模拟,用椭圆遮罩加高斯模糊生成反光效果;同时收集现场反光图补充训练。如果漏检集中在某个表型,检查这个表型的标注框是不是偏小,框太小网络学不到完整特征。
4.4 识别网络把外壳纹理读成数字
现象是读数窗口边缘的外壳纹理被识别成多余字符,比如读数0123被读成01231。原因是裁剪扩边扩太多,把外壳带进来了,而训练时识别网络的输入都是干净窗口。解决方法是控制扩边比例,同时训练识别网络时故意在窗口边缘加一些外壳纹理作为负样本,让网络学会忽略边缘噪声。CTC 解码后可以加一个后处理,按读数位数截断或过滤非法字符。
4.5 训练集和现场分布不一致导致上线掉点
现象是实验室验证集准确率 98%,现场只有 70%。原因是训练集的水表型号、拍摄角度、光照条件和现场差异大。解决方法是上线前用现场图做一次小样本测试,统计定位漏检率和识别错误率,针对性补数据。补数据优先补定位漏检的样本,因为定位错了识别再准也没用。另外现场部署时把定位置信度阈值调低一点,宁可多裁几个窗口让识别网络判断,也不要漏掉。
5. 进阶:用识别置信度反哺定位,把两段网络真正联动起来
前面讲的都是定位和识别各干各的,串行推理。实际项目跑到后面,你会发现两段网络可以互相帮忙,这是把水表识别从「能跑」做到「好用」的关键一步。
具体做法是:识别网络对每个字符位置输出置信度,把这些置信度聚合成一个窗口质量分。如果质量分低于阈值,说明这个窗口要么定位偏了,要么图像质量差,这时候触发两个动作。第一个动作是回退到定位网络,取置信度第二高的框重新裁剪识别,相当于给定位一次后悔药。第二个动作是把这张图标记为低质量样本,存下来做后续人工复核和增量训练。这个机制在指针式水表上尤其有用,因为指针角度回归的置信度天然比字轮分类低,靠单一阈值判断容易误杀。
下面这段是带质量分和回退逻辑的推理改造。
def infer_with_fallback(img, loc_model, rec_model, loc_conf=0.5, quality_th=0.85): results = loc_model(img)[0] boxes = results.boxes if len(boxes) == 0: return None, 0.0 # 按置信度排序,保留前三个候选框 order = boxes.conf.argsort(descending=True)[:3] best_text, best_score = None, 0.0 for idx in order: if boxes.conf[idx] < loc_conf: continue box = boxes.xywhn[idx].cpu().numpy() crop = crop_reading_window(img, box, expand=0.08) gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) resized = cv2.resize(gray, (320, 32)) tensor = torch.from_numpy(resized).float().unsqueeze(0).unsqueeze(0) / 255.0 with torch.no_grad(): logits = rec_model(tensor) text, score = ctc_decode_with_score(logits) if score > best_score: best_text, best_score = text, score # 质量分达标就直接返回,不继续试后面的框 if score >= quality_th: break return best_text, best_scorequality_th=0.85是质量分阈值,这个值要在验证集上标定:把验证集样本按质量分排序,看多少分以上识别结果基本正确,取那个分位点。ctc_decode_with_score在贪心解码的同时把每个字符的最大概率连乘或取平均,作为整串的置信度。回退逻辑最多试三个候选框,避免一张图反复推理拖慢速度。
这套联动机制的价值在于,它把定位和识别从「流水线」变成了「闭环」。定位网络不再是一次性输出,识别网络也不再是被动接收,两边通过置信度互相校验。我自己的经验是,加上这个机制之后,现场误读率能降一半以上,尤其是那些定位框在正确位置附近抖动的样本,回退一次基本都能救回来。
还有一个技巧是增量训练的数据回流。把质量分低于阈值的样本自动存到一个待标注目录,每周人工复核一批,把正确标签补进去,下个训练周期把定位和识别一起微调。注意微调时学习率要调小,一般是初始学习率的十分之一,否则会把之前学好的特征冲掉。定位网络微调时重点补漏检样本,识别网络微调时重点补半字和反光样本,两边分开补,不要混在一起。
最后说个我自己的习惯:每次改完定位或识别的任何参数,我都会固定跑一遍那 50 张最难的现场图,记录定位漏检数、识别错字数、端到端准确率三个指标。这三个数不涨,参数改得再花哨也不上线。水表识别这个方向不难,难的是把现场那些玄学情况一个个收进训练集里,靠的就是这种笨办法。希望帮到你。
本文还有配套的精品资源,点击获取