ARTICLE DETAIL

资讯详情

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

字轮式水表OCR识别:DB+CRNN端到端实战

字轮式水表OCR识别:DB+CRNN端到端实战 简介本资源是一套已高分通过的本科毕业设计项目聚焦字轮式自来水水表图像识别任务适用于计算机视觉初学者、课程设计与期末大作业实践者。项目基于Python实现端到端OCR识别流程涵盖图像预处理、DB文本检测、CRNN序列识别及后处理逻辑配套完整说明文档与可运行源码部署后即可实测真实水表图片。压缩包共1805个文件含561张JPG/PNG格式水表样本图、140个核心Python脚本含ocr_db_crnn.cc等C加速模块、72个Markdown技术说明与YAML配置文件以及大量模型权重.pdmodel/.pdparams等和构建脚本gradlew.bat、setup.cfg等整体体积达569.84MB结构规范、模块解耦清晰。目前已有157人学习下载读者可直接复现识别效果获取工业场景下小目标数字识别的完整技术路径、典型排错方案及轻量化模型部署经验。1. 字轮式自来水水表识别为什么毕业设计选它不是因为简单而是因为它“卡得刚刚好”你手头这个.zip文件里装的不是一段能直接 pip install 的 OCR 工具而是一套专为字轮式机械水表设计的端到端识别流程——从手机拍一张歪斜、反光、带阴影的水表照片开始到最终输出002345这样的六位数字结果为止。它不依赖云端 API不调用阿里云或百度 OCR核心是 Python OpenCV PyTorch或 TensorFlow DBDetection-Based CRNNRecognition-Based的轻量组合。为什么毕业设计常选它不是因为“识别水表很简单”恰恰相反它把真实工业场景的全部痛点都浓缩在一个小项目里——低对比度数字、金属反光干扰、字轮边缘模糊、拍摄角度倾斜、相邻字轮遮挡、无标准标定板、训练样本少于 200 张……这些坑一个不落全在你眼皮底下。适合想练实操、敢调模型、愿啃 OpenCV 图像预处理细节的同学不适合只想改两行代码交差的人。如果你正被导师催着定题、被答辩委员问“你和网上开源 OCR 有什么区别”这篇笔记就是你打开 zip 后该立刻做的第一件事。2. 从 raw 图片到可识别 ROI图像预处理不是“调个阈值”而是给字轮“做 CT 扫描”字轮式水表的识别难点80% 出现在第一步怎么把那几个旋转排列、半露半藏的数字框出来。通用 OCR如 Tesseract在这里会直接跪——它默认文本是水平、连续、高对比的而水表字轮是离散、弧形、低信噪比的。所以本项目必须自己动手做 ROI 提取核心逻辑是先定位字轮区域 → 再逐个切出单个数字窗口 → 最后校正形变。这不是调个cv2.threshold()就完事的玄学而是要理解字轮结构本身。2.1 字轮区域粗定位用形态学 轮廓筛选锁死“水表脸”水表正面通常有固定结构一个圆形表盘 中央指针 周围环形排列的字轮组。我们不靠深度学习检测而用传统 CV 快速圈出大致区域import cv2 import numpy as np def locate_dial_region(img): gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊降噪避免细小干扰轮廓 blurred cv2.GaussianBlur(gray, (5, 5), 0) # 自适应阈值应对局部光照不均 thresh cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) # 形态学闭运算连接字轮数字间的微小断裂 kernel np.ones((3,3), np.uint8) closed cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel, iterations2) # 轮廓查找筛选面积和长宽比符合字轮环特征的外轮廓 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] h, w img.shape[:2] for cnt in contours: area cv2.contourArea(cnt) if area 500 or area h * w * 0.3: # 排除过小噪点和过大背景 continue x, y, ww, hh cv2.boundingRect(cnt) aspect_ratio max(ww, hh) / min(ww, hh) if min(ww, hh) 0 else 0 # 字轮环通常是近似圆或椭圆长宽比接近 1且位置偏中心 if 0.7 aspect_ratio 1.3 and 0.3*w xww/2 0.7*w and 0.3*h yhh/2 0.7*h: candidates.append((x, y, ww, hh)) # 取最大面积的候选框通常就是字轮环 if candidates: return max(candidates, keylambda b: b[2]*b[3]) return None # 未找到需人工干预或换图参数说明adaptiveThreshold的11是邻域大小2是常数偏移针对水表常见反光区域效果比全局阈值稳定morphologyEx的iterations2是经验值一次闭运算可能连不上字轮间缝隙两次更鲁棒area上限设为h*w*0.3是防止整张图被误判为背景比如纯白墙下限500过滤掉螺丝、刻度线等小干扰。2.2 字轮分割用极坐标变换“拉直”环形排列字轮数字沿圆周排列直接切矩形 ROI 会导致数字严重畸变。正确做法是以字轮环中心为原点将环形区域映射到矩形图像上让所有数字变成水平排列def warp_dial_to_rect(img, bbox): x, y, w, h bbox center_x, center_y x w//2, y h//2 radius min(w, h) // 2 # 极坐标变换r ∈ [radius*0.7, radius*0.95] 精准覆盖字轮带避开中心指针和外圈刻度 r_min, r_max int(radius*0.7), int(radius*0.95) theta_range 360 # 全圆 output_width int(2 * np.pi * (r_min r_max) // 2) # 周长近似 output_height r_max - r_min # 创建目标图像 polar_img np.zeros((output_height, output_width), dtypenp.uint8) for i in range(output_height): for j in range(output_width): theta 2 * np.pi * j / output_width r r_min i # 极坐标转笛卡尔坐标 src_x int(center_x r * np.cos(theta)) src_y int(center_y r * np.sin(theta)) if 0 src_x img.shape[1] and 0 src_y img.shape[0]: polar_img[i, j] img[src_y, src_x] return polar_img # 使用示例 # roi locate_dial_region(original_img) # if roi: # polar_img warp_dial_to_rect(original_img, roi) # cv2.imshow(Polar, polar_img)关键逻辑r_min和r_max不是随便取的——r_min0.7*radius是为了跳过中心指针区域那里全是模糊色块r_max0.95*radius是为了避开外圈金属边框那里有强反光和刻度线干扰。output_width按平均半径计算保证拉直后数字宽度一致output_height即字轮带厚度通常 30~50 像素足够容纳单个数字高度。2.3 单数字 ROI 切割基于投影法的自适应分割拉直后的polar_img是一条长条状图像上面是 6~8 个并排的数字。但每个数字宽度不一“1”窄“8”宽且存在粘连“4”和“7”易连、断裂“3”中间断开。不能用固定宽度切分要用水平投影 自适应阈值def split_digits_by_projection(polar_img): # 对拉直图做二值化注意这里用 Otsu 自动找阈值比固定值更鲁棒 _, binary cv2.threshold(polar_img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 计算垂直方向像素投影每列有多少黑点 proj np.sum(binary, axis0) # shape: (width,) # 寻找投影谷底字符间隙 # 平滑投影曲线避免噪声导致的虚假谷底 smoothed cv2.blur(proj.astype(np.float32), (1, 5)) # 水平方向平滑 # 谷底检测找局部最小值且深度 平均投影的 30% avg_proj np.mean(smoothed) peaks, _ find_peaks(-smoothed, distance15, prominenceavg_proj*0.3) # peaks 是每个数字中心的列索引据此切出 ROI digit_rois [] for i, peak in enumerate(peaks): left max(0, peak - 12) # 数字宽度约 20~25px留余量 right min(binary.shape[1], peak 12) digit_roi binary[:, left:right] # 去除上下空白边 non_empty_rows np.where(np.sum(digit_roi, axis1) 0)[0] if len(non_empty_rows) 0: top, bottom non_empty_rows[0], non_empty_rows[-1] digit_roi digit_roi[top:bottom1, :] digit_rois.append(digit_roi) return digit_rois from scipy.signal import find_peaks # 需 pip install scipy为什么用投影法而不是轮廓检测因为字轮数字常有断裂如“3”的中间横、粘连“4”右下角贴“7”、以及金属反光造成的局部亮斑findContours易漏检或误合并。投影法对整体结构鲁棒性更强且find_peaks的distance15强制相邻数字中心至少间隔 15 像素天然适配字轮物理间距。3. DB CRNN为什么不用 Tesseract因为字轮数字是“OCR 黑匣子”里的硬骨头当你拿到 6 个干净的单数字 ROI尺寸约 32×48下一步是识别。此时如果直接喂给pytesseract.image_to_string()大概率返回000000或乱码。原因很现实Tesseract 训练数据来自印刷体文档而字轮数字是铸造凸起字体 金属反光 拍摄畸变 低分辨率的混合体特征分布与训练集严重 mismatch。本项目采用DBDifferentiable Binarization检测 CRNNConvolutional Recurrent Neural Network识别的级联方案这是目前轻量级场景文字识别的工业级选择——DB 负责把数字从背景中“抠”得干净CRNN 负责理解扭曲变形下的序列模式。3.1 DB 检测模型不是为了框字而是为了生成“高质量掩膜”DB 的核心价值不在 bounding box而在其输出的概率掩膜probability map。对于单数字 ROI我们不需要检测框而是用 DB 的 backbone通常是 ResNet-18提取特征再通过 FPN SegHead 输出一个与输入同尺寸的 0~1 概率图其中数字区域值接近 1背景接近 0。这个掩膜比简单阈值二值化干净得多# 假设已加载训练好的 DB 模型如 db_res18.pth import torch import torch.nn as nn from torchvision import models class DBHead(nn.Module): def __init__(self, in_channels, inner_channels256): super().__init__() self.binarize nn.Sequential( nn.Conv2d(in_channels, inner_channels, 3, padding1), nn.BatchNorm2d(inner_channels), nn.ReLU(inplaceTrue), nn.Conv2d(inner_channels, 1, 1), nn.Sigmoid() ) def forward(self, x): return self.binarize(x) # 实际推理时简化版 # model DBModel() # 加载预训练权重 # with torch.no_grad(): # feat model.backbone(digit_tensor) # digit_tensor: [1,1,32,48] # prob_map model.head(feat) # prob_map: [1,1,32,48] # # 后处理双阈值过滤0.3 为前景0.1 为收缩边界 # binary_map (prob_map 0.3).float() * (prob_map 0.1).float() # cleaned_digit digit_tensor * binary_map参数意义prob_map 0.3是主阈值确保只保留高置信度数字区域prob_map 0.1是收缩阈值用于生成“收缩边界”两者相乘得到的是带边界的干净前景掩膜能有效抑制数字边缘的毛刺和反光噪点。这个操作比cv2.threshold多一层语义理解是 DB 的精髓。3.2 CRNN 识别模型LSTM 不是摆设它在学“数字的书写顺序”CRNN 由 CNN特征提取 BiLSTM序列建模 CTCConnectionist Temporal Classification组成。关键在于BiLSTM 让模型理解“数字是按顺序写的”这一先验。例如“2”和“5”在字轮上可能因拍摄角度看起来相似但 LSTM 会结合前后数字如“12” vs “25”做出判断。CTC 损失函数则允许模型输出不定长序列如2-2-2-5→ 解码为25完美适配数字粘连场景。# CRNN 模型定义PyTorch class CRNN(nn.Module): def __init__(self, nclass, nh256): # nclass110-9 blank super().__init__() self.cnn nn.Sequential( nn.Conv2d(1, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(), nn.Conv2d(256, 256, 3, padding1), nn.ReLU(), nn.MaxPool2d((2,1)), # 高度压缩宽度保留 nn.Conv2d(256, 512, 3, padding1), nn.BatchNorm2d(512), nn.ReLU(), nn.Conv2d(512, 512, 3, padding1), nn.ReLU(), nn.MaxPool2d((2,1)), nn.Conv2d(512, 512, 2, padding0) # 输出 [512, 1, W] ) self.rnn nn.LSTM(512, nh, bidirectionalTrue, batch_firstTrue) self.embedding nn.Linear(nh * 2, nclass) # BiLSTM 输出拼接 def forward(self, x): conv self.cnn(x) # [B, 512, 1, W] → squeeze to [B, 512, W] conv conv.squeeze(2) # [B, 512, W] conv conv.permute(0, 2, 1) # [B, W, 512] for LSTM rnn_out, _ self.rnn(conv) # [B, W, 2*nh] logits self.embedding(rnn_out) # [B, W, nclass] return logits # CTC 解码简化 def ctc_decode(logit, blank10): # blank index for 0-9 blank probs torch.softmax(logit, dim-1) # [W, nclass] pred probs.argmax(dim-1) # [W,] # 合并相同标签删除 blank result [] for i in range(len(pred)): if pred[i] ! blank and (i 0 or pred[i] ! pred[i-1]): result.append(str(pred[i].item())) return .join(result) if result else 0为什么用 BiLSTM 而不是 CNN 分类因为单个数字 ROI 可能包含多个连通域如“8”的上下两环CNN 分类器会把它当做一个整体打分而 CRNN 把图像看作“时间序列”从左到右扫描BiLSTM 能捕捉数字笔画的走向和连接关系对铸造字体的结构鲁棒性更强。ctc_decode中的blank10是 CTC 特有的占位符用于区分重复字符如“11”需输出1-blank-1而非1-1。4. 避坑这 4 个血泪经验让我重训了 3 次模型才跑通这个项目最耗时间的不是写代码而是调试过程中反复踩进的坑。以下是我从 200 张实拍水表图、12 轮训练迭代中总结的必踩、必修、必记的 4 条4.1 现象DB 检测掩膜全是噪声数字区域一片黑原因输入图像未归一化且digit_roi尺寸不统一有的 28×40有的 35×52导致 DB 模型 backbone 的 feature map 尺寸错乱FPN 层无法对齐。解决所有digit_roi必须 resize 到固定尺寸如 32×64且做img img.astype(np.float32) / 255.0归一化。DB 训练时用的就是[0,1]输入喂uint8直接崩。4.2 现象CRNN 识别结果全是“8”或随机字符原因CTC label 编码错误。例如数字002345应编码为[0,0,2,3,4,5]但误用了 one-hot 编码或未剔除重复CTC 要求 label 序列中相邻相同字符需合并。解决严格按 CTC 规范编码label [0,0,2,3,4,5]→ctc_label [0,2,3,4,5]去重相邻并在 loss 计算时传入原始长度6和 target 长度5。用torch.nn.CTCLoss时input_length和target_length必须精确。4.3 现象测试图识别正确但换一张新图就崩准确率波动极大原因训练集未覆盖“反光最强”的场景。我最初只收集了室内均匀光下的水表图但实际现场多为正午阳光直射导致模型没见过强反光模式。解决在数据增强中强制加入RandomBrightnessContrast(p0.5)和RandomShadow(p0.3)用 albumentations 库并人工合成 30 张强反光图用 Photoshop 在数字上加白色高光斑。4.4 现象部署到树莓派后内存爆满推理卡死原因DB 模型用的是 full ResNet-50参数量 25MB树莓派 4GB 内存扛不住。解决换用轻量 backbone —— 将 DB 的 backbone 替换为MobileNetV2参数量 3.5MB并用torch.quantization.quantize_dynamic()做动态量化最终模型体积压到 1.2MB推理速度从 2.3s/图提升到 0.4s/图。提示第 4 条的量化操作必须在 CPU 上执行树莓派无 CUDA且quantize_dynamic只支持部分 layer如 Linear、LSTMCNN 层需手动替换为QuantizedConv2d。别信网上“一行代码搞定”的教程那是坑。5. 毕业设计答辩前最后一关如何让评委一眼看懂你的“识别可靠度”答辩时评委最怕听到“我跑了 100 次平均准确率 92%”。他们要的是可验证、可追溯、可复现的可靠性证据。不要只放一张“识别成功”的截图要做三件事5.1 构建最小可信验证集5 张图覆盖全部失败模式从你采集的 200 图中挑出 5 张最具代表性的“难例”做成验证集val_hardset/图编号典型问题真实读数模型输出是否修复001.jpg正午强反光数字“5”右侧全白002345002340✅加 Shadow 增强后002.jpg字轮轻微遮挡水管挡住“3”002345002?45❌需加 attention mask003.jpg手机拍摄倾斜 15°数字拉伸002345002344✅极坐标校正生效004.jpg低照度数字边缘模糊002345002345✅DB 掩膜抗噪强005.jpg新旧字轮混用“0”字体不同002345002345✅CRNN 泛化好为什么只选 5 张答辩时间只有 10 分钟评委没耐心看 50 张。这 5 张必须覆盖你论文里提到的所有技术点DB、CRNN、极坐标、增强且每张都要有“修复前后对比图”。把val_hardset/打包进最终提交 zip文件夹名就叫proof_of_reliability。5.2 输出“识别过程可视化图”让黑匣子变透明别只给最终数字要生成一张图展示每一步的中间结果def visualize_pipeline(original_img, digit_rois, preds, save_path): fig, axes plt.subplots(2, 4, figsize(16, 8)) axes[0,0].imshow(cv2.cvtColor(original_img, cv2.COLOR_BGR2RGB)) axes[0,0].set_title(Original) axes[0,0].axis(off) # 极坐标图 polar_img warp_dial_to_rect(original_img, locate_dial_region(original_img)) axes[0,1].imshow(polar_img, cmapgray) axes[0,1].set_title(Polar Warped) axes[0,1].axis(off) # DB 掩膜 db_mask get_db_mask(digit_rois[0]) # 假设函数 axes[0,2].imshow(db_mask, cmapjet) axes[0,2].set_title(DB Probability Map) axes[0,2].axis(off) # CRNN 输入cleaned digit cleaned apply_db_mask(digit_rois[0], db_mask) axes[0,3].imshow(cleaned, cmapgray) axes[0,3].set_title(Cleaned Digit) axes[0,3].axis(off) # 逐个显示识别结果 for i in range(1, 5): if i-1 len(digit_rois): axes[1,i-1].imshow(digit_rois[i-1], cmapgray) axes[1,i-1].set_title(fDigit {i}: {preds[i-1]}) axes[1,i-1].axis(off) else: axes[1,i-1].axis(off) plt.tight_layout() plt.savefig(save_path, dpi150, bbox_inchestight) plt.close()答辩技巧把这张图放在 PPT 第二页标题就写“我的方法在哪一步解决了什么问题”。指着DB Probability Map说“这里看到即使数字‘5’右边反光成一片白DB 仍能给出高置信度掩膜这是传统阈值做不到的。”——评委立刻懂你工作的价值。5.3 写死一个“后悔药”开关当识别失败时自动触发人工校正界面毕业设计最怕答辩时现场翻车。我在main.py里加了一个强制开关# main.py 开头 DEBUG_MODE False # 设为 True 时所有识别结果弹出校正窗口 if DEBUG_MODE: import tkinter as tk from tkinter import simpledialog def manual_correct(pred): root tk.Tk() root.withdraw() corrected simpledialog.askstring(校正, f识别结果{pred}\n请输入正确读数, parentroot) root.destroy() return corrected if corrected else pred # 在识别循环中 # result crnn_predict(digit_rois) # if DEBUG_MODE: # result manual_correct(result)为什么这是“后悔药”答辩现场网络卡、光线突变、评委临时换图都可能导致识别失败。这个开关让你能在 3 秒内人工输入正确值然后程序继续运行演示流畅性不受影响。评委不会知道你开了 debug只会觉得“这系统真稳”。最后想说这个项目真正的价值不在于识别出002345这串数字而在于你亲手把“模糊、反光、畸变”的物理世界一步步翻译成计算机能理解的清晰信号。那些调参到凌晨三点的 DB 学习率、反复修改的 CRNN LSTM 层数、还有在树莓派上编译 OpenCV 的 7 小时——它们不会出现在答辩 PPT 里但会变成你简历上“熟悉工业场景 OCR 落地”的底气。希望帮到你。本文还有配套的精品资源点击获取
返回列表