尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

用MLP+OpenCV实现轻量级OCR:从字符切分到端到端识别

用MLP+OpenCV实现轻量级OCR:从字符切分到端到端识别 前面一篇文章把OpenCV做图像预处理和字符切分的流水线讲完了今天继续把最后一环补上怎么把这一个个切出来的字符图片交给MLP去识别再拼成完整的文字结果。这也是“用MLP解决OCR问题”这个系列的最终篇整条链路从读图、预处理、切分、识别到最后输出一段可用的文本全部都会跑通。如果你还没有看过上一篇也不用回头翻核心结论一句话就能说清楚OpenCV负责把一张包含文字的图片切成若干小图每一个小图就是一个待识别的字符MLP负责对这些小图做分类。这篇文章适合两类朋友看一类是自己手上有带文字的图片想不依赖Tesseract、PaddleOCR这类通用引擎从零搭一套轻量级OCR出来另一类是刚接触机器学习想弄明白一个最简单的神经网络到底是怎么端到端解决真实问题的。整个项目只依赖OpenCV、NumPy和一个深度学习框架跑起来不需要GPUCPU完全够用。1. 内容整体设计与思路拆解1.1 OCR问题在MLP视角下到底是什么很多人一听到OCR就觉得很高深其实一旦你把问题缩小到“识别单个字符”它就是一个标准的图像分类问题。给定一张字符小图比如28×28像素模型要做的事情就是判断它是数字几、是哪个字母。MLP是全连接神经网络输入是一个一维向量所以字符小图要展平成784个数值每一个数值表示一个像素点的灰度。MLP要学到的就是这784个数值和最终类别之间的非线性映射关系。这里有一个关键点必须搞清楚MLP没有卷积那种平移不变性。字符稍微往左偏一点、变大一点MLP看到的就是完全不同的输入向量。所以预处理阶段必须尽量把字符统一到同一个位置、同一个大小这也是为什么OpenCV在前面那个阶段如此重要。整条链路不是随便拼起来的每一阶段的输出质量都会直接影响下一阶段的效果。很多新手在字符识别阶段反复调模型参数识别率还是上不去最后发现根因是前面预处理没做干净这个顺序问题希望大家一开始就重视。1.2 为什么是MLP而不是CNN或现成OCR我经常被问到这类问题都用MLP了是不是过时了直接接PaddleOCR或者Tesseract不是更好吗我的回答是分场景来看。如果你的目标是做一个上线产品要处理复杂版面、多语言混排、艺术字体那我也劝你直接上PaddleOCR或者商用SDK它们背后是检测模型加识别模型加语言模型的整套体系不是我们这篇博客能替代的。但如果你的目标是解决一个非常固定的场景比如识别一个设备屏幕上显示的七段数字、识别某个仪器上的状态码、识别预印好的序列号那MLP这套方案完全够用而且能做到极致的轻量。模型文件可能只有几百KB纯CPU推理单张字符图耗时在几毫秒内不需要GPU不需要云服务这是很多嵌入式场景非常看重的点。再说一个我个人的看法MLP是理解更复杂网络的地基。预处理的思路、特征归一化的思路、过拟合的判断和处理这些东西在CNN、Transformer时代一样成立。把MLP这条路完整走一遍之后再换CNN只是替换中间网络结构的问题整体思路不用重学。用MLP解决OCR问题最大的价值不在于“生产可部署”而在于用最少的代码量把“用机器学习解决实际问题”的完整逻辑讲透。理解了这套逻辑你再看那些复杂框架就不会只停留在调包阶段。1.3 整体管线拆解与模块边界我习惯把整个OCR系统拆成五个阶段图像读取与灰度化把彩色图变成灰度图降低后续计算量。二值化与去噪把前景和背景分开把噪点清掉。字符切分按行投影或轮廓分析把单个字符切出来。尺寸归一化与向量化把每个字符小图统一缩放到固定大小转成一维向量。字符分类MLP模型对每个向量给出类别概率取最大值作为输出。前面四个阶段全部归OpenCV处理第五个阶段归MLP。这样拆分最大的好处是边界清晰后期不管哪个环节出了问题都能快速定位到具体模块。我在调试实际项目时最怕的就是“整体识别率低”这种笼统的现象因为它可能来自任意一个阶段。把模块边界划清楚之后排查思路就变成了先看切分结果是否干净再看归一化后的图像是否标准最后才轮到怀疑模型。这个排查顺序我后面还会再详细展开。2. 环境准备与依赖安装2.1 OpenCV安装与版本选择前提是你的Python环境已经是3.8以上推荐3.10或者3.11太老的版本容易出现兼容性问题。安装OpenCV主包的命令很简单pip install opencv-python如果你还需要用到OpenCV里一些带开源协议争议的算法模块比如SIFT、SFM这类就需要装增强版pip install opencv-contrib-python这里有一个我踩过的坑必须提醒一下不要把opencv-python和opencv-contrib-python同时装进同一个环境。两个包会互相覆盖底层库文件最后表现出来的就是一些莫名其妙的AttributeError比如明明应该有的函数却提示不存在实际上就是包冲突。一个环境里只保留一个OpenCV版本这是基本操作。2.2 深度学习框架和NumPyOpenCV本身依赖NumPy所以装了OpenCV之后NumPy一般已经在环境里了可以再确认一下版本python -c import numpy; print(numpy.__version__)深度学习框架我用的是PyTorch因为训练代码写起来直观报错信息也相对友好。CPU版本直接安装pip install torch如果你是刚开始学千万不要一上来就折腾CUDA和GPU版本。先用CPU把整个流程跑通确认所有代码都没问题之后再考虑显卡加速。GPU环境配不好很容易把人劝退而OCR单字符识别这种任务用CPU训练一个几千张样本的小模型也就是几分钟的事情完全不需要一开始就上GPU。2.3 验证环境是否正常安装完成后在Python交互环境里执行下面这几行import cv2 import numpy as np import torch print(cv2.__version__) print(np.__version__) print(torch.__version__)能正常打印出版本号说明环境就绪。我在这个项目里用的版本组合是OpenCV 4.8.1、NumPy 1.26.4、PyTorch 2.3.0如果你用的版本比我新后面代码基本不会有兼容性问题。如果import阶段就报错先检查是不是有多个Python环境混用了pip装到了A环境但你在B环境里运行代码这是新手最常见的问题。3. 训练数据准备没有现成样本就自己造OCR这个任务和别的图像分类任务不太一样你很难在网络上直接找到适合自己特定场景的字符样本集。比如你要识别LCD屏幕上的数字公开数据集里的字体形态和你实际碰到的基本对不上。所以准备数据这一步我建议认真对待宁可多花半小时也不要偷懒。3.1 先跑通流程用现成数据集验证为了验证整个流程没有bug我推荐先用公开数据集MNIST把流程跑通。MNIST每张图是28×28的灰度字符类别是0到9总共有6万张训练图和1万张测试图。PyTorch里可以用torchvision直接下载from torchvision import datasets, transforms train_dataset datasets.MNIST( root./data, trainTrue, downloadTrue, transformtransforms.ToTensor() ) test_dataset datasets.MNIST( root./data, trainFalse, downloadTrue, transformtransforms.ToTensor() )用MNIST先跑一遍的好处是数据质量高、来源固定如果模型在这里都识别不好那问题一定出在模型而不是数据。把流程跑通之后再切换成自己准备的真实数据这样排查起来就有参照物。我每次搭新的OCR方案都会先跑一遍标准数据集确认框架和代码没问题再换到实际业务数据上。3.2 用OpenCV和PIL生成自定义字符样本如果你的目标字符集不是单纯的数字比如要加26个大写字母或者要识别特定字体的文字那我建议用合成数据的方式自己造训练集。思路很简单用PIL的ImageDraw在画布上把字符画出来然后用OpenCV做和真实数据一致的预处理最后保存成训练样本。我写过一个小脚本核心逻辑大概长这样from PIL import Image, ImageDraw, ImageFont import cv2 import numpy as np import os def generate_char_samples(char_list, font_path, save_dir, img_size48): font ImageFont.truetype(font_path, 42) os.makedirs(save_dir, exist_okTrue) for idx, ch in enumerate(char_list): img Image.new(L, (img_size, img_size), 255) draw ImageDraw.Draw(img) draw.text((4, 2), ch, fontfont, fill0) arr np.array(img) # 用OpenCV找字符的实际外接矩形 contours, _ cv2.findContours( cv2.bitwise_not(arr), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) x, y, w, h cv2.boundingRect(contours[0]) char arr[y:yh, x:xw] cv2.imwrite(os.path.join(save_dir, f{idx}_{ch}.png), char)这里有一个非常关键的细节画完字符后不能直接保存整张画布那样会把大量空白也当成特征。我用findContours找到字符的实际外接矩形裁剪掉四周空白再存下来。如果你跳过这一步模型会把字符周围的空白位置也学进去真实图片里的字符位置稍有变化识别就崩了。这个裁剪习惯我在后面预处理函数里也在用本质上是为了去除非字形本身的干扰信息。3.3 数据增强让模型见过更多变化训练数据如果只有白底黑字的合成图遇到真实图片里有点倾斜、有点噪声的情况识别率会掉得很快。我当时第一次做这个项目的时候就踩了这个坑合成数据上准确率96%换到手机拍的图片直接掉到70%多。后来补了几个增强手段才好起来随机平移把字符在画布内上下左右挪动几个像素。随机缩放把字符尺寸缩放0.9到1.1倍。随机加噪声用cv2.randn叠加高斯噪声。随机膨胀腐蚀模拟打印不清晰或笔画变粗变细的情况。这些操作全部可以用OpenCV完成不依赖其他库。增强的目的是让模型不要过度依赖字符的绝对位置和绝对粗细学到的特征更有泛化性。实测下来只做平移和缩放就能让真实场景的识别准确率提升一到两个百分点尤其是对相机拍摄的图片效果很明显。注意增强的幅度不要太大比如旋转角度控制在正负5度以内太夸张了反而会让模型学歪。4. 从字符图像到MLP特征向量4.1 尺寸归一化与按比例缩放MLP要求输入维度固定所以在喂给模型之前所有字符小图都必须缩放成同一个尺寸。我习惯用28×28和MNIST保持一致这样后续做对比实验也方便。直接调cv2.resize就能实现char_resized cv2.resize(char, (28, 28), interpolationcv2.INTER_AREA)这里有一个细节需要注意缩小时建议用INTER_AREA它会对像素做区域平均缩放结果不容易出现锯齿。如果是放大场景用INTER_CUBIC或INTER_LINEAR效果更好边缘不会过于生硬。但直接拉伸有个问题字符的比例会失真。比如一个数字“1”天然就是窄的直接拉伸成28×28之后它会被拉成一张“胖胖的1”训练出来的模型对窄字符的识别率通常偏低。解决办法是先按比例缩放再贴到一张28×28的空白画布中间。具体做法是计算字符的宽高比把长边缩放到20像素然后计算偏移量居中放置到28×28画布上。这样字符保持了原始长宽比边缘的空白区域也足够模型做“定位”参考。4.2 二值化与前景背景统一OpenCV处理字符图像时二值化几乎是必做的一步。因为灰度图里不同区域的亮度差异可能很大如果直接拿灰度值当特征训练时模型会花大量精力去学习亮度变化而不是字形本身。二值化之后每个像素只有0和255两种值特征更稳定。最省事的做法就是Otsu自适应阈值_, binary cv2.threshold(char_gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU)这里要特别提醒前景色统一的问题。有的数据源是黑字白底有的数据源是白字黑底如果不统一同一个字符的像素值会正好相反模型会被彻底搞糊涂。我在预处理函数里固定了一个约定前景字符为白色255背景为黑色0。如果图像不符合这个约定就做一次cv2.bitwise_not翻转。这个统一动作一定要放在训练和推理两端都做否则训练时数据是一套规则推理时输入是另一套规则最终结果一定很糟。4.3 特征向量化和标签编码把28×28的图像转成一行784维向量最简单的方式就是flatten()feature binary.flatten().astype(np.float32) / 255.0除以255是把像素值归一化到0到1区间。这个过程不是可选项直接喂0到255的原始值网络训练初期loss会非常大收敛也慢。原因是输入尺度差异大会让梯度更新不稳定相当于在同一个网络里同时处理“很暗”和“很亮”的特征权重更新幅度很难控制。标签处理上多分类任务有两种常见方式一种是直接给类别ID比如A映射成10配合交叉熵损失另一种做成one-hot编码比如类别3对应[0, 0, 0, 1, 0, ...]。用PyTorch的话CrossEntropyLoss直接接受类别ID作为目标不需要手动做one-hot这个细节在准备数据集时注意一下少写很多冗余代码。5. MLP模型搭建、训练与评估5.1 网络结构设计与隐藏层选择MLP解决OCR问题的网络结构我给出一个经过调参验证的默认配置适合数字加大写字母共36类的场景import torch import torch.nn as nn class CharMLP(nn.Module): def __init__(self, input_dim784, hidden_dim128, num_classes36): super().__init__() self.net nn.Sequential( nn.Linear(input_dim, hidden_dim), nn.ReLU(), nn.Linear(hidden_dim, hidden_dim // 2), nn.ReLU(), nn.Linear(hidden_dim // 2, num_classes), ) def forward(self, x): return self.net(x)为什么隐藏层用128和64这是我试过512、256之后折中出来的方案。隐藏单元太多在字符数据量不大几千张的情况下很容易过拟合训练集准确率接近99%验证集只有80%。隐藏单元太少比如32个表达力又不足识别率明显下降。128加64这个组合在我遇到的各种字符识别任务里表现都比较稳既有足够的非线性表达能力又不会因为参数过多而难以收敛。5.2 训练超参数设置与完整训练循环训练部分的代码我习惯写成这样方便直接复用import torch.optim as optim from torch.utils.data import DataLoader, TensorDataset def train_model(model, X_train, y_train, X_val, y_val, epochs30, batch_size64, lr0.001): train_ds TensorDataset(torch.tensor(X_train), torch.tensor(y_train, dtypetorch.long)) val_ds TensorDataset(torch.tensor(X_val), torch.tensor(y_val, dtypetorch.long)) train_loader DataLoader(train_ds, batch_sizebatch_size, shuffleTrue) val_loader DataLoader(val_ds, batch_sizebatch_size, shuffleFalse) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lrlr) for epoch in range(epochs): model.train() train_loss, correct, total 0.0, 0, 0 for xb, yb in train_loader: optimizer.zero_grad() out model(xb) loss criterion(out, yb) loss.backward() optimizer.step() train_loss loss.item() * xb.size(0) _, preds torch.max(out, 1) correct (preds yb).sum().item() total yb.size(0) model.eval() val_correct, val_total 0, 0 with torch.no_grad(): for xb, yb in val_loader: out model(xb) _, preds torch.max(out, 1) val_correct (preds yb).sum().item() val_total yb.size(0) acc correct / total val_acc val_correct / val_total print(fepoch {epoch1:03d} | loss {train_loss/total:.4f} | acc {acc:.4f} | val_acc {val_acc:.4f})学习率lr0.001配合Adam是默认选择我建议不要随便改成0.1这种大学习率否则训练曲线会上下乱跳很难收敛。batch_size设64在大多数机器上都很稳定如果内存紧张改成32也行。epochs设30对这个任务规模来说已经足够因为MNIST或自造数据都不大训练30轮之后验证集准确率提升幅度已经很小再训练只会浪费时间。5.3 曲线分析和过拟合判断训练过程中我最关心的不是训练集准确率而是训练集和验证集准确率的差距。理想状态下两个数值一起上升最后都稳定在一个不错的水位。如果训练集到了99%但验证集只有80%基本可以判断过拟合了模型把训练数据里的细节噪声都背下来了没有学会泛化规律。这时候有几个处理方向增加数据增强、减小模型规模、加Dropout。我自己的经验是在隐藏层之间加nn.Dropout(0.3)是最立竿见影的加了之后验证集准确率通常能回来好几个百分点。当然Dropout也不能加过头模型本身就弱的情况下还加Dropout等于雪上加霜验证集反而会掉下去。判断模型是强是弱就看训练集准确率有没有足够高只有训练集本身学得好的模型Dropout才有效果。6. 端到端跑通OCR推理流水线实现6.1 预处理和推理函数整合模型训练完之后把OpenCV预处理部分和MLP推理部分组合成一个完整的函数输入一张图片路径输出识别文本def ocr_pipeline(img_path, model, class_map): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) _, binary cv2.threshold(img, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 切分字符返回按x坐标排序的字符小图列表 chars split_chars(binary) result [] for char in chars: # 统一前景色为白色缩放并居中到28x28 char_norm preprocess_char(char) feature char_norm.flatten().astype(np.float32) / 255.0 with torch.no_grad(): out model(torch.tensor(feature).unsqueeze(0)) pred torch.argmax(out, dim1).item() result.append(class_map[pred]) return .join(result)这里split_chars就是上一篇里用OpenCV实现的投影切分或者轮廓切分方法。preprocess_char就是把字符统一缩放并居中到28×28。class_map是类别ID到实际字符的映射表比如{0: 0, 1: 1, ..., 10: A, ...}。整个函数没有多余的复杂逻辑就是按顺序执行五个阶段。6.2 一个完整的测试例子我真实测试过这样一张场景一张白纸上打印着一串“A2C9”的字符用手机拍下来轻度倾斜光照还有点不均匀。整个管线的输出过程大概是这样的灰度化之后因为背景和前景差异明显Otsu二值化效果很好噪点不多。字符之间间距较大列投影切分能顺利把4个字符分出来。每个字符归一化到28×28后模型输出的置信度分布里最大概率项都已经超过0.9。最终识别结果和真实文本一致。这里要说一个判断标准如果单字符置信度低于0.7我建议不要直接把argmax结果当成最终答案而是在业务层面标记成“低置信度”让人工介入确认。很多实际项目里错字比不识别更麻烦宁可不识别也不要给一个错误的可靠结果。6.3 单字符预测和多字符组合的顺序问题如果你是做验证码识别或者序列号识别最后的“预测结果”一定要按字符出现顺序拼接因为MLP本身不感知顺序顺序关系完全靠前面切分阶段的输出顺序来保证。这也是为什么切分阶段不能用findContours的默认返回顺序因为轮廓排序默认是按扫描顺序不一定等于从左到右。我通常会把切出来的字符统一根据外接矩形的x坐标排序再进入识别阶段。这个顺序问题在最开始设计流程时就要考虑到否则后面识别结果看起来就是一团乱码。7. 常见问题与排查技巧实录7.1 识别准确率一直上不去这是最常被问到的问题。我建议的排查顺序是这样先从训练集里随机抽几张图走一遍完整预处理人工看这些预处理之后的图是否干净再检查验证集和训练集是否来自同一分布最后看训练曲线的loss有没有正常下降。很多时候问题根本不在模型而在训练数据太单一或者预处理不一致。举个例子如果你的训练数据全是合成字体验证数据全是手机实拍那准确率低是必然的换任何模型都没用。先解决数据分布问题再谈调参。7.2 字符切分错位导致识别失败字符粘连、间距不均匀都会导致切分错误。一个字符被切成两半模型看到的就不是完整字形两个字符粘在一起切成一块模型同样无法识别。我在实际测试中发现对打印体文字先做一次5像素左右的膨胀操作再做列投影分析粘连问题能缓解很多。但如果目标文字是手写体这个方案就不太可行了手写体建议绕开MLP方案换用RNN加CTC那套模型或者在数据端做更精细的手写体切分。7.3 训练数据和真实场景不一致我用合成字体训练了一个版本拿去识别真实打印件准确率从95%直接掉到70%左右。后来一排查主要原因是真实照片里字符有轻微旋转和模糊。后来我在合成数据里加入旋转正负5度、高斯模糊等增强才把准确率拉回来。数据分布永远比模型结构重要这句话在OCR场景体现得淋漓尽致。你花很多时间调网络结构可能还不如把训练数据做得更接近真实场景来得有效。7.4 常见问题排查速查表现象可能原因排查方向训练loss不降学习率过大或输入未归一化检查数据范围是否在0~1之间训练集高但验证集低过拟合加Dropout或数据增强验证集不稳定波动大batch_size太小或数据比例失衡增大batch_size检查类别分布明亮区域识别错误前景背景不统一检查二值化后字符是否固定为白色字符粘连切不开形态学处理不足尝试膨胀操作或改用轮廓法切分单个字符置信度偏低字符未居中或尺寸缩放异常检查预处理函数可视化待输入图像这个表里的排查方向都是我在实际操作中验证过的。它的核心逻辑是从数据端到模型端从前端到后端一层一层往下查。不要一上来就怀疑模型结构结构在绝大多数情况下是没有问题的真正有问题的往往是数据。这一篇代码量不大但它把从图像到文字的完整逻辑串起来了。我手里这个MLP加OpenCV的字符识别方案后来在一个工控屏幕上跑了好几个月模型文件不到200KB识别单个数字的平均耗时在2毫秒左右效果非常稳定。最后再分享一个小技巧模型训练完之后不要只看准确率把一些预测失误的图片单独保存下来定期看一眼。你会发现很多问题直观到不需要任何机器学习理论就能判断出来OCR这类任务的样本差异永远集中在“输入图像长什么样”这件事上。先把图像端做到极致模型端的压力会小很多。
返回列表