简介:一份面向深度学习初学者、毕业设计及课程设计学生的光学字符识别系统完整工程资料。系统融合卷积神经网络和循环神经网络进行特征提取与序列建模,使用Python语言开发,后端以Django框架构建识别服务,前端基于Vue移动端模板搭建界面,并覆盖图像去噪、二值化、倾斜校正等预处理流程,可用于车牌识别、文档扫描、自动归档等场景。资源包共两千零一十一个文件,压缩后约五十四点六一兆字节,主要类型包括一百七十五个Python脚本、一千三百六十六个Markdown文档、三百五十一个JavaScript文件,以及三十九个JSON、九个C++源码和配置文件;其中Python脚本负责模型训练、推理与后端逻辑,C++文件对应检测、识别、分类等后处理工具,Markdown文档说明项目结构与算法原理。目前已有五十四人学习,适合需要完整项目参考、代码研读或答辩展示的开发者。借助该包可以快速理清从数据预处理、模型训练到系统集成的完整流程,直接复用前后端代码和预处理脚本,节省课题开发时间。
1. 基于深度学习的文字识别系统:为什么传统 OCR 在真实场景频频翻车
做过票据截图提取的人都有过这种体验:图片一倾斜、光线一变,Tesseract 输出的文字就开始乱拼。基于深度学习的文字识别系统解决的就是这个场景——把图片里的文字当成一个序列预测问题,用 CNN 提特征、序列模型输出文本,而不是逐字分割。这套方案我现在用来处理卡证、票据、截图和车牌识别,准确率能明显压过传统 OCR。适合的人群也比较具体:手里有一批图片要批量文字化、想把 OCR 能力接入业务系统、或者正在看深度学习实战项目案例的工程师。这个 zip 交付的不是论文演示,而是一套从训练到推理都能跑起来的最小工程。
2. 文字识别模型选型:CRNN+CTC、Attention 还是 TrOCR,先想清楚再动手
2.1 先给结论:CRNN+CTC 为什么是默认答案
文字识别在深度学习里有两条路线。一条是先把文字行检出来,再对每一行做识别;另一条是端到端同时做检测和识别。工程上最常见、最可控的是前者,识别部分的主流框架是 CRNN+CTC。CRNN 的结构很好理解:卷积网络把图片转成按宽度展开的特征序列,双向 LSTM 在序列上建模,最后每个时间步输出一个字符概率分布。CTC 在这里解决的是对齐问题,它允许你只用整行文本做标签,而不需要标注每个字符的精确位置。
这个省掉逐字标注的特性,是 CRNN+CTC 能在工业界普及的关键。早期方案要先做字符分割,一旦字符粘连、倾斜,分割错误就会一路放大到识别结果里。CTC 把对齐当作隐变量处理,模型自己学每个时间步对应哪个字符,省掉的不仅仅是标注成本,还有大量因分割产生的拍错。我一般会拿它做第一版模型,不是因为它准确率绝对最高,而是因为稳定、快、网上能查到的踩坑资料多。
如果你对这套机制还不熟,可以把 CTC 理解成一个黑匣子对齐器:预测序列和真实标签长度不一致没关系,它会穷举所有可能的对齐方式求和,训练时只需要计算最后的总损失。所以中文、英文、数字混排的场景,用 CRNN+CTC 都能直接上手,不需要为每种语言单独造轮子。配合检测模型(比如 DBNet)先粗定位文字行、再逐行识别,两阶段配合在印刷体场景有很高的上限,这也是很多开源 OCR 项目的默认结构。
2.2 三种文字识别架构的适用边界:按场景选,不按名气选
也不是什么时候都该无脑 CRNN。这几年 Attention 和 Transformer 路线也成熟了,选型逻辑主要看三件事:数据量、推理时延、文本长度。Attention 类的识别模型(比如经典 RARE、ASTER,以及 TrOCR 这类 Transformer 方案)能更好地建模长距离依赖,对弯曲、倾斜、复杂版面有更强的容错,但训练时需要更多数据和显存,推理速度也慢一截。
| 架构 | 准确率上限 | 训练成本 | CPU 推理速度 | 适合场景 |
|---|---|---|---|---|
| CRNN + CTC | 中上,稳定 | 低,单卡可训 | 快 | 票据、卡证、车牌、截图,工业默认 |
| Attention(ASTER/RARE) | 高,长文本有优势 | 中,需更多数据 | 中 | 弯曲文本、不规则版式 |
| TrOCR(Transformer) | 很高,依赖预训练 | 高,显存要求高 | 慢 | 文档级识别、手写体,算力充足 |
我的习惯是:第一版固定用 CRNN+CTC 把全流程跑通,等真实场景的坏例攒够一批,再判断要不要升级到 Attention。因为 Attention 在推理时是逐时间步解码,长度一长,速度下降比 CRNN 明显;如果只是普通印刷体,CRNN 的准确率差距并不大,但工程代价小很多。反过来,如果业务里全是复杂版面的文档,一开始就用 TrOCR 这类预训练模型反而省事,前提是你接受它的大显存和慢推理。
2.3 用 PyTorch 搭建一个可训练的 CRNN 网络骨架
模型定义不用自己发明,但三个细节影响训练成败。一是卷积输出的高度必须压到 1,才能把特征图转成「时间步序列」;二是 LSTM 要用双向,否则只看左边信息,识别长词容易丢尾巴;三是输出层要做 log_softmax,配合 PyTorch 的 CTCLoss 才能直接算。下面这个骨架按这三条写:
import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, height=32, hidden_size=256): super().__init__() # CNN backbone: 把 (B,3,H,W) 缩成 (B,512,H',W') self.cnn = nn.Sequential( nn.Conv2d(3, 64, 3, 1, 1), nn.BatchNorm2d(64), nn.ReLU(True), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, 1, 1), nn.BatchNorm2d(128), nn.ReLU(True), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True), nn.Conv2d(256, 256, 3, 1, 1), nn.BatchNorm2d(256), nn.ReLU(True), nn.MaxPool2d(2, (2, 1)), nn.Conv2d(256, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.ReLU(True), nn.Conv2d(512, 512, 3, 1, 1), nn.BatchNorm2d(512), nn.ReLU(True), nn.MaxPool2d(2, (2, 1)), ) # 把高度统一压到 1,宽度时间步保留 self.pool = nn.AdaptiveAvgPool2d((1, None)) # 双向序列建模 self.lstm = nn.LSTM(512, hidden_size, bidirectional=True, num_layers=2, batch_first=True) self.fc = nn.Linear(hidden_size * 2, num_classes) # num_classes=字符集大小+1(blank) def forward(self, x): x = self.cnn(x) # (B,512,H*,W*) x = self.pool(x) # (B,512,1,W*) x = x.squeeze(2).permute(0, 2, 1) # (B,W*,512) 时间步=宽 x, _ = self.lstm(x) # (B,W*,2*hidden) x = self.fc(x) # (B,W*,num_classes) return torch.log_softmax(x, dim=-1)这段代码的 num_classes 必须是你的字符集大小加 1,因为 CTC 需要一个额外的 blank 符号表示「当前时间步没有输出」。输入图高度统一为 32,宽度任意,经过卷积和池化后时间步数约为输入宽度的四分之一,比如 320 宽的图会得到约 80 个时间步。hidden_size 取 256 是常用值,显存紧张可以降到 128,但中文场景我建议保持 256。
代码里的 adaptive pool 是关键:它保证不管你把输入高度设成 32 还是 48,最后都能压到高度 1,不会像固定池化那样在非 32 高度时直接报错。这个细节是我在深度学习环境配置时踩过的坑,不少人在 ubuntu20.04 上把 PyTorch 配好,结果模型一 forward 就维度对不上,多半就是这里写死了。BiLSTM 的 batch_first=True 是为了让序列维在前面,和后面的 permute 对齐。
这个骨架当前不包含字符映射、检测模型和训练循环,先把 forward 跑通、能输出 (B, T, C) 的 log 概率,下一步才谈得上喂数据和训练。
3. 数据工程:合成数据、LMDB 与增强,OCR 的 80% 工作量在这里
3.1 合成数据是第一步:用 TextRecognitionDataGenerator 批量造训练图
很多 OCR 项目刚开始都卡在「没有数据」上。真实标注图片要一张张框、一行行标,成本高得吓人。常见的做法是先用合成数据把模型训练起来,再用少量真实数据微调。合成数据生成工具里,TextRecognitionDataGenerator 用得最多,它能把指定词表的文本渲染成各种字体、背景、位置的图片,一分钟能出几千张。
以中文识别为例,我会准备三样东西:一套覆盖常用字的中文字典文件、几款不同字体的 ttf 文件、一个纯色和渐变背景池。然后跑批量生成命令:
python3 run.py \ --output_dir ./synthdata \ --language zh \ --count 100000 \ --format jpg \ --background 2 \ --fonts ./fonts/simhei.ttf,./fonts/simsun.ttf \ --dict ./dicts/chinese_common.txt \ --width 320 --height 48 \ --random_kerning --random_blur这里--language zh指定中文,--dict决定生成哪些字,--fonts可以传多个字体让模型不至于只认一种字形。--width和--height控制输出图片尺寸,我建议宽度别小于 256,高度和训练输入一致设为 48,这样下游不用反复 resize。--random_kerning和--random_blur是便宜的增强,能让合成图没那么「假」。生成的文件名默认是「内容_随机数.jpg」,后面写 LMDB 时直接解析文件名前段当标签,非常方便。
有一点要注意:合成数据的目标是覆盖字符和词汇,不是模拟真实拍摄。字体覆盖越全,模型对字形的泛化越好;背景和模糊的作用有限,真实拍摄里的光照渐变、透视变形它模拟不了,这部分留给后面的在线增强和真实数据去补。
3.2 把图片和标签装进 LMDB:训练时不再被 IO 拖死
图片一多,千万个小文件散在磁盘上,PyTorch 的 DataLoader 每次随机读取都要和文件系统打交道,训练一大半时间花在等 IO 上。工程里常见的做法是把所有样本打进一个 LMDB 文件,按 key 直接取字节,内存映射读取,速度稳定得多。下面这段脚本把目录里的图片按「文件名前缀=标签」的规则写入 LMDB:
import lmdb import io import os from PIL import Image def build_lmdb(src_dir, output_path, map_size=10**11): env = lmdb.open(output_path, map_size=map_size) idx = 0 with env.begin(write=True) as txn: for root, _, files in os.walk(src_dir): for f in files: if not f.lower().endswith(('.jpg', '.png')): continue label = f.split('_')[0] # 文件名规则: 标签_随机串.jpg img = Image.open(os.path.join(root, f)).convert('L').resize((320, 48)) buf = io.BytesIO() img.save(buf, format='JPEG') txn.put(('image-%09d' % idx).encode(), buf.getvalue()) txn.put(('label-%09d' % idx).encode(), label.encode()) txn.put(('length-%09d' % idx).encode(), str(len(label)).encode()) idx += 1 print('total samples:', idx)这里有个固定约定:image-、label-、length- 三个 key 用同一个序号关联,训练时 Dataset 按序号读取,length 字段用来还原被 padding 的标签。map_size 必须给足,默认的 10MB 会在写入中途直接抛 MapFullError,我一般先按样本数估算,10 万张图给 100GB 的映射空间,实际磁盘占用不会立刻涨满,但映射要预留。
图片转灰度并 resize 到固定宽度,是为了让训练批处理更简单;如果要在训练时做宽度动态 batching,这一步就不要 resize 宽度,只统一高度,留给 DataLoader 的 collate_fn 去 padding。
3.3 数据增强:真实拍摄场景的保命手段
合成数据干净得不像话,真实场景里却是另一回事:手抖会糊、环境光偏色、纸张扭曲、还有桌面阴影。如果只拿合成图训练,模型在验证集上可能 95%,一到线上就掉到 60% 以下。在线增强是成本最低的补丁,我常用 albumentations 在读取图片后即时做变换:
import albumentations as A train_aug = A.Compose([ A.Affine(rotate=(-3, 3), scale=(0.9, 1.1), translate_percent=(0, 0.02), p=0.7), A.GaussianBlur(blur_limit=(3, 5), p=0.3), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), A.GaussNoise(var_limit=(10, 50), p=0.3), ]) def load_and_augment(img_path): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) return train_aug(image=img)['image']增强参数要克制:旋转超过 ±5 度,文字行就跟着转出画面,标签不再对应真实内容;对比度和亮度拉太狠,模型会把正常图片当成噪声学会乱猜。我一般把增强后的效果图每隔几百步存一张,肉眼看一圈再调参,不靠感觉。
这里顺序也有讲究:模糊和噪声放在 Affine 之后,让模型看到的是「先变形再加噪」的真实过程;同时增强只在训练时做,验证集和测试集保持原始图像,否则指标会被增强干扰,看不出模型真实水平。真实数据哪怕只有几千张,也一定要混进训练集,合成数据负责打底,真实数据负责纠正分布。
4. 训练与调参:把识别准确率从 60% 拉到 90% 的关键参数
4.1 训练主循环:CTCLoss、Adam 与梯度裁剪
模型和数据都就位后,训练主循环比一般分类任务多三个坑:CTC 的输入要转成 (T, B, C) 顺序,标签要按 batch 内最长文本 padding 并记录每个样本的真实长度,输入长度要显式给出。下面这段是一个能直接跑的最小循环:
import torch from torch.nn.utils import clip_grad_norm_ def train_one_epoch(model, loader, opt, device): model.train() total_loss = 0.0 ctc = torch.nn.CTCLoss(blank=0, zero_infinity=True) for imgs, labels, label_lens in loader: imgs = imgs.to(device) labels = labels.to(device) label_lens = label_lens.to(device) logits = model(imgs) # (B, T, C) log_probs = logits.permute(1, 0, 2) # CTC 要 (T, B, C) seq_len = torch.full((imgs.size(0),), logits.size(1), dtype=torch.long) loss = ctc(log_probs, labels, seq_len, label_lens) opt.zero_grad() loss.backward() clip_grad_norm_(model.parameters(), 5.0) # 防 RNN 梯度爆炸 opt.step() total_loss += loss.item() return total_loss / len(loader) model = CRNN(num_classes=len(charset) + 1).to(device) opt = torch.optim.Adam(model.parameters(), lr=1e-3)这里的 CTCLoss(blank=0) 和模型里 num_classes 的 +1 必须对应,blank 默认索引 0,所以字符映射要从 1 开始编号。zero_infinity=True 是为了防止 log(0) 产生 inf,早期训练概率极端时能避免 loss 变成 NaN。labels 是经过 collate_fn padding 后的长整型序列,label_lens 是每个样本的真实长度,CTC 只对有效长度计算。
优化器我固定用 Adam,初始学习率 1e-3。训练前可以先用十几张图过拟合一遍,如果 loss 不能降到接近 0,说明模型或数据管道有 bug,这时候不要盲目调参。
4.2 三个必调参数:输入高度、batch size、学习率
参数里最容易被忽略的是输入高度。CRNN 的默认高度是 32,英文数字够用,但中文笔画多,32 像素高时横竖笔画挤在一起,模型容易把「己」「已」「巳」这类字认错。我处理中文时把高度提到 48,条件允许就 64,代价是 CNN 计算量增加。注意高度变了,前面 AdaptiveAvgPool2d 依然有效,模型定义不用改。
batch size 的直觉是「越大越稳」,但文字识别里图片宽度不一致,实际 batch 大小还要乘上宽度成本。显存不够时,常见做法是降 batch 到 64 或 32,同时把学习率按比例下调到 3e-4,避免收敛变慢。宽度 padding 是显存浪费的大头,一个 320 宽的 batch 里混进几张 640 宽的图,所有人都被拉齐到最大宽度,所以 collate_fn 里我会按宽度排序分组,而不是简单洗牌。
学习率策略上,Adam 加 StepLR 就够用。前 2000 步保持 1e-3 让模型快速收敛,之后每 5 个 epoch 乘 0.8,微调阶段降到 1e-4。有些项目直接上余弦退火,效果不差,但中期 loss 平台期容易被误解为不收敛,反而不利于判断。
4.3 用编辑距离验证模型,别只看整行准确率
训练跑起来后,最怕的就是只看 loss 或者只看整行完全匹配的准确率。文字识别有个特点:一行 10 个字,错 1 个,整行准确率就是 0,但业务上这个结果可能完全能用。所以我验证时同时看两个指标:整行精确匹配率和归一化编辑距离。
def evaluate(model, loader, device, idx_to_char): model.eval() exact_hit, total_dist, total_len, n = 0, 0, 0, 0 with torch.no_grad(): for imgs, labels, label_lens in loader: logits = model(imgs.to(device)) pred_ids = logits.argmax(dim=-1) # 每步取最大概率 for b in range(logits.size(0)): pred = decode_greedy(pred_ids[b]) # 去 blank 和连续重复 gt = labels[b][:label_lens[b]] pred_str = ''.join(idx_to_char[i] for i in pred) gt_str = ''.join(idx_to_char[i] for i in gt) exact_hit += (pred_str == gt_str) total_dist += edit_distance(pred_str, gt_str) total_len += len(gt_str) n += 1 return exact_hit / n, 1 - total_dist / total_len def decode_greedy(ids, blank=0): prev = blank res = [] for i in ids.tolist(): if i != blank and i != prev: # blank 跳过,重复字符合并 res.append(i) prev = i return resdecode_greedy 是 CTC 推理最简单的解码方式,把 blank 时间步去掉,再把相邻重复字符合并。它比 beam search 略差一点,但第一版够用。编辑距离建议用字符级而不是单词级,中文场景做单词分割本身就是麻烦事,字符级编辑距离能精确告诉你模型在一行里错了几处。
验证集一定要留独立的一批真实图片,不能用合成数据做最终判断。我见过太多项目合成集 95%、真实集 55%,原因是合成分布太单一,模型在「看起来像合成图」的图片上过拟合了。真实验证集不用大,几百张有代表性的就够,关键是覆盖:倾斜、模糊、强光、反光,各放几十张进去。
5. 常见问题排查:训练不收敛、中文乱码、真实场景掉点的四个节点
5.1 loss 一直不降,或者降到某个值卡住不动
现象:训练跑了上千步,loss 在 2.5 附近一动不动;或者前几步正常下降,某一步直接变 NaN,后面全部崩掉。
原因排查时先去掉「病急乱投医」。loss 不降最常见的三个来源:学习率不合适导致梯度震荡;字符集和标签不一致,比如标签里出现了 vocab 之外的字符,模型永远学不对;训练样本里图片和标签对不上,文件名里的标签带了下划线或特殊符号,被解析错位。NaN 则通常是梯度爆炸,RNN 在长序列上叠加,梯度值很容易冲高。
解决流程我一般是倒着查:先取 20 张图过拟合,loss 能降到接近 0 说明管道没问题,转去调学习率;过拟合不了,逐张打印 img 和 label,确认标签字符全部在 charset 里;最后给训练循环加 clip_grad_norm_,把梯度范数限制在 5 以内。还有一个容易被忽略的点:PyTorch CTCLoss 要求输入是 log 概率,如果你在模型里输出了原始 logits,loss 会在前几步直接崩掉。
5.2 英文数字都没问题,中文全乱
现象:英文、数字识别都在 95% 以上,一到中文就错一半,而且错的都是笔画相近的字,比如「未」和「末」、「日」和「曰」。
原因有两个层面。第一是数据层面:中文常用字 3000 到 5000 个,如果合成时字典只覆盖了高频词,低频字样本不足,模型天然学不好;字体覆盖也至关重要,只用一款黑体训练,换到楷体、隶书基本全挂。第二是模型层面:输入高度 32 对中文太矮,复杂字形在池化后细节丢失,模型只能靠上下文猜字。
解决上,我一般分三步:把输入高度提高到 48 或 64,这一步通常能带来五到十个百分点的提升;字典按字频采样,保证每个字至少出现几百次,而不是按词频自然分布;字体扩充到宋体、黑体、仿宋、楷体外加几个开源的免费字体,合成时随机选择。最后检查一下字符映射是否覆盖了训练标签全集,漏一个字符就是一块短板。
5.3 合成数据验证 95%,换真实场景只剩 50%
现象:测试集是合成图时准确率很高,模型一上线,手机拍的票据、扫描件、监控截图,识别结果惨不忍睹。
原因:合成数据的分布太干净了,真实场景里透视畸变、摩尔纹、反光、印章遮挡、手写涂改这些干扰,合成工具完全没模拟。模型学到的只是「印刷字体在纯色背景上的识别」,遇到真实成像链路就失效。这是 OCR 项目里最常见的翻车现场,不是模型不行,是训练分布和推理分布不一致。
解决思路是分层补数据:真实数据一定要混进训练集,哪怕只有几千张,按 1:4 和合成图混合;复盘 bad case,把线上识别错的图按错误类型归类,模糊的一批、倾斜的一批、光照的一批,分别增强后加入训练;合成层面加大仿射变换力度,把旋转角度从 3 度加到 8 度,加椒盐噪声、运动模糊,让合成图更「脏」。真实数据的标注用半自动方式:先拿当前模型预识别,人工改错,比从零标快很多。
5.4 CPU 部署慢到没法用,GPU 显存又不够
现象:模型训练时是在 GPU 上跑的,实际部署环境只有 CPU,推理一张 320x48 的图要 200 到 400 毫秒,业务方完全不能接受。想上 GPU,预算又只给得起小显存的卡。
原因:CRNN 的 BiLSTM 在 CPU 上很吃亏,两层双向 LSTM 串行计算,没法像卷积那样轻松并行;输入图片宽度越大,时间步越长,LSTM 计算量线性上涨。另外如果没做任何优化直接导出,模型还是 FP32,CPU 跑不动的量很大。
解决上,我先看宽度:文字行图片做等比例缩放,把宽度限制到 320 以内,或者长文本分两段识别,时间步减少一半,速度能提升一倍。然后是推理引擎:ONNX Runtime 的 CPU 线程数设为物理核数,不要默认全核抢占;如果还是慢,做 INT8 静态量化,准确率通常掉一到两个点,速度能快两三倍。小显存场景下,batch 设 1、输入动态形状别固定死,实测一张卡能跑起来就行。最后提醒一句:量化前先在验证集上对比 FP32 和 INT8 的输出,出现过某些字符集在量化后批量识别错的情况,上线前必须有这步对比。
6. 模型导出与封装:把识别系统变成一个解压即跑的 zip
做到这里,你已经有一个能训练的模型和一套验证流程,最后一步是把整个工程变成别人(或未来的自己)解压就能跑的状态。我习惯的收尾动作是:导出 ONNX、封装一个命令行推理入口、锁定依赖、压缩交付。
6.1 用 ONNX 导出并验证输出一致
PyTorch 模型直接给业务方不现实,对方不一定有 PyTorch 环境。导出成 ONNX 后,任意推理框架都能加载,还能顺手做量化。导出和验证放一起做:
model.eval() dummy = torch.randn(1, 3, 48, 320) torch.onnx.export(model, dummy, "crnn.onnx", input_names=["input"], output_names=["logits"], dynamic_axes={"input": {0: "batch"}}, opset_version=13) import onnxruntime as ort import numpy as np sess = ort.InferenceSession("crnn.onnx", providers=["CPUExecutionProvider"]) out_t = model(dummy).detach().numpy() out_o = sess.run(None, {"input": dummy.numpy()})[0] print(np.abs(out_t - out_o).max()) # 小于 1e-5 说明导出一致动态轴只放开 batch 维,宽度保持固定,能省去不少模型部署环节的踩坑。两路输出的最大绝对误差在 1e-5 以内,就是导出成功的信号。
6.2 锁定依赖,压缩交付
命令行推理入口保持精简,用 argparse 读入图片路径和模型路径,输出文本和耗时。requirements.txt 里锁死版本,run.sh 里写好创建虚拟环境、安装依赖、跑 demo 的三行命令。zip 里放好这些文件后,解压、运行,就能在任意一台带 Python 的机器上复现。这个 zip 就是整个深度学习文字识别系统的可交付快照。
我的习惯是压缩前再跑一遍全流程测试,从解压到推理 demo 一路走通才敢发出去;吃过亏——之前交付过一版缺了字体文件的 zip,对方解压后一堆乱码,最后发现是合成阶段字体没随包带上。现在我会把字体、charset、README 一起锁进包里,确保解压即用。希望这个流程能帮你少走一次弯路。
本文还有配套的精品资源,点击获取