简介光学字符识别OCR是计算机视觉中历史悠久的经典任务而中文场景下的文字行识别因其字符类别多、字形结构复杂一直是工程落地的难点。传统方法需要先进行字符级检测再分类流程繁琐且对粘连文本鲁棒性差。基于卷积循环神经网络CRNN的技术路线通过CNN提取视觉特征、BiLSTM建模序列上下文、CTC损失实现序列对齐将整行文字图像直接映射为文本序列省去了字符级标注的成本尤其适合中文这种字符多且排列紧密的行级识别任务。这一技术广泛应用于票据识别、文档数字化、屏幕截图文字提取等场景。本文分享一套基于CRNN的中文汉字识别实战项目包含预训练权重、整理好的训练数据集与完整工程源码帮助开发者快速搭建可用的端到端中文OCR系统。 做汉字识别的朋友大概率都遇到过这种尴尬拿通用OCR引擎去识别自然场景下的中文文本要么把“已”认成“己”要么在长文本上断行断得七零八落更别说处理自己采集的垂类数据时从头训练一个大模型的时间和算力成本有多劝退。这篇实战笔记想聊的是我基于CRNN卷积循环神经网络搭建的一套中文汉字识别项目核心解决的是“端到端识别中文文字行”这个刚需场景。项目附带可直接使用的预训练模型、整理好的训练数据集和完整源码定位是给那些已经跑通过基础图像分类、想在OCR方向深挖一步的开发者或者有真实业务识别需求、但不想从零造轮子的工程师一个可复现的参考方案。下面我会从设计思路、实现细节、训练调优到踩坑记录把整个过程摊开来讲。1. 项目整体设计与思路拆解1.1 汉字识别难点与CRNN的适用边界汉字识别和英文OCR不太一样。英文只有26个字母外加少量符号类别数撑死一百出头而中文常用字就有三千多如果考虑生僻字和标点字典规模轻松破万。这意味着分类头的参数量会比较大也意味着模型要把“看起来很像”的汉字区分开比如“未”和“末”、“日”和“曰”、“土”和“士”这些字在人眼看来都容易糊机器要学出判别力必须依赖足够的上下文和较强的特征提取能力。另一个难点是序列性。一行文字里的字符是有顺序的而且字符宽度不固定同一个字在不同字体、不同分辨率下宽度可能差很多。传统的目标检测思路是先框出每个字的位置再逐个分类但遇到粘连文本或者弯曲文本时字符级检测框的标注成本极高而且很容易因为检测框不准导致后续分类失败。CRNN的优势在于它把识别任务看作一个序列标注问题不需要显式定位每个字符直接把整行图像映射为标签序列天然适合中文这种“字符多、排列紧密”的行级识别场景。不过要泼一盆冷水CRNN并不是万能的。它对弯曲文本、严重倾斜的文本、艺术字体的鲁棒性比不过现在基于Transformer的端到端模型。如果项目场景是印刷体公文识别、单据识别、屏幕截图文字识别这类“规规矩矩”的文字行CRNN的性价比非常高显存占用小、推理速度快、部署容易CPU上也能跑得动。如果目标是自然街景广告牌、手写自由文本甚至古籍繁体那可能需要CTPN做文本检测或者直接上Transformer-based的OCR框架这个项目就不太合适了。1.2 为什么选CRNN而不是Transformer方案我最初也纠结过要不要直接上Vision Transformer加CTC的方案后来还是选回了CRNN主要原因有三个。第一是数据效率。Transformer类模型通常需要海量数据和较长的训练周期才能收敛而我手上的真实业务标注数据只有几万张虽然可以用合成数据扩充但合成数据带来的分布偏移依然存在。CRNN的归纳偏置卷积提升局部特征RNN建模序列依赖更加贴合文本图像的结构特点在中小规模数据上更容易训练到可用水平。第二是工程复杂度。CRNN的pipeline非常清晰CNN骨干网络提取特征图Map-to-Sequence把特征图转换成序列BiLSTM序列建模CTC解码输出文本。整个过程没有复杂的attention计算训练时的显存开销和调参难度都小得多。对于需要快速交付的项目这意味着可以更快从“训练脚本能跑”演进到“模型效果达标”。第三是部署友好。很多业务场景需要把模型跑到内网服务器甚至边缘设备上CRNN这种结构用PyTorch训练完可以比较方便地转成ONNX或者TensorRT在CPU/GPU上都能获得不错的推理速度。Transformer方案在剪枝和量化上要花更多功夫并不是说做不了而是开发周期会明显拉长。1.3 项目交付内容盘点模型、数据、源码三者缺一不可这个项目和网上很多“只有训练脚本”的开源仓库不同它把真正容易被卡脖子的三样东西都配齐了能直接拿去推理的预训练权重、整理好的训练数据集、完整可跑通的工程源码。先看模型文件。项目提供了一套在合成数据加真实数据混合训练下得到的模型权重输入是固定高度的灰度图像宽度可以动态变化输出是可变长度的中文文本串。拿到权重后不需要重新训练只要按照源码里demo脚本的方式加载就能直接对文字行图像做识别。这一点对很多只想快速验证效果的同学来说非常救命因为从零训练一个能达到实用精度的汉字识别模型按单卡GPU算通常需要一到两周的时间还不算清洗数据排错的成本。再看数据集。项目附带的数据集经过了我这边的预处理流程包括字段检查、去重、按长宽比过滤分成了训练集和验证集。每张样本都是一行文字图像对应一个文本文档标注文本包含了汉字、数字、常用标点。数据规模虽然不像工业界那种千万级那么大但配合数据增强策略足够把一个基础CRNN模型训练到“可演示、可微调”的水平。如果你有自己的业务数据这个数据集也可以作为预训练的起点用它训出来的模型再去你的小数据上finetune收敛速度和最终精度都会好很多。最后是源码。工程结构覆盖了数据读取、字典生成、网络定义、训练循环、评估脚本和推理demo没有藏私也没有故意留坑。代码风格我尽量保持简单直白关键地方都有注释哪怕是第一次接触OCR训练流程的人跟着README也能把pipeline捋顺。2. 核心细节解析与实操要点2.1 CRNN三件套CNN骨干、BiLSTM序列建模、CTC损失CRNN这个名字本身已经把结构说清楚了Convolutional Recurrent Neural Network。它由三个组件拼接而成每个组件解决一个层面的问题。CNN骨干网络的作用是“看形”。输入图像经过卷积和池化分辨率逐渐降低通道数逐渐增加最终得到一组特征图。这个阶段学到了边缘、纹理、笔画、部件等不同层级的视觉特征。需要注意CRNN对输入图像的宽度没有硬性限制但高度一般固定为32像素左右这样经过四次池化每次高度减半后特征图的高度大约是2个像素刚好可以展平成序列。如果你把输入高度改成64特征图高度变4了反而会影响后续序列建模的粒度。BiLSTM的作用是“看序”。汉字识别离不开上下文比如“成本”的“成”和“城”共享一部分字形如果只看局部特征可能分不清但如果模型“看到”后面的字是“本”就能通过语境把前一个字的概率往“成”的方向掰。双向LSTM分别从左到右和从右到左对特征序列进行建模然后把两个方向的信息拼接起来这样每个时间步的隐状态都蕴含了整行文本的上下文信息。CTC的作用是“对齐”。这是整个CRNN最精妙的部分也是新手最容易懵的地方。训练时模型输出的每个时间步都有一个概率分布维度等于字典大小加上blank但训练标注只有一串字符没有告诉模型“这个字符对应特征图的哪一段”。CTC通过动态规划的方式穷举所有可能的字符到序列的映射方式计算所有能归约到目标标签的路径概率之和作为损失。它允许重复字符也允许blank分隔因此不需要字符级对齐标注。实测下来CTC对不定长文本的支持非常优雅把“对齐”这个最麻烦的问题从训练流程里彻底拿掉了。2.2 数据集准备与预处理流程数据集是整个识别项目的地基。我在整理数据时踩过不少坑这里分享一套我目前认为比较稳妥的预处理流程。第一步是统一图像格式。所有训练样本统一转成灰度图因为颜色信息对汉字识别基本没有帮助反而会引入额外的计算量。灰度图的文件名不需要特殊规则只要保证和标注文件里的id一一对应就行。第二步是标注清洗。原始标注中经常出现空格、换行、不可见字符、全角半角混用等问题。我建议统一做一次归一化全角数字转半角、中文标点保留全角、去掉首尾空格、过滤掉包含生僻字不在字典里的样本。这样能避免模型在无关细节上浪费学习能力。第三步是几何过滤。直接把高度过大或者宽高比过小的样本从数据集中剔除。比如高度超过64像素的样本如果强行resize到32像素高度文字细节会严重丢失宽高比小于2的图像往往不是一个完整文字行可能是单词或单字训练的loss会在这些样本上产生较大波动。比较合理的做法是保留宽高比在2到30之间的图像然后把高度统一resize到32宽度按比例缩放最后再padding到固定宽度比如640或者512具体取决于你训练时BatchSize和显存的平衡。第四步是字典生成。项目里包含一个build_dict脚本它会扫描训练集标注统计出现的所有字符生成两个文件char_dict.json字符到索引的映射和index_dict.json索引到字符的映射。这里很容易忽略的坑是训练集和验证集的字符集合不一致。验证集里如果出现训练集没有的字评估时就会报keyError。稳妥的做法是在同一个脚本里合并统计所有数据的字符集但训练阶段在loss中屏蔽掉只在验证集出现的字符。当然更简单的策略是直接将验证集按训练集字典过滤一遍保证两者字符集合严格一致。2.3 模型下载与文件放置的正确姿势标题里写了“模型下载”这里我得重点说说模型文件拿到手之后应该怎么放、怎么用因为很多同学卡在这一步。项目仓库的release页面提供了已经训练好的模型权重文件名字大概是crnn_chinese_ocr.pth这种风格。下载后先做两件事第一用sha256校验一下文件完整性防止下载过程中文件损坏第二确认权重保存的格式是完整的model state dict还是包含优化器状态的完整checkpoint。我提供的权重只保存了模型参数这样文件体积更小大概几十MB加载方式也更纯粹。文件位置方面我习惯在项目根目录下建一个weights文件夹把模型放进去。如果你想放到其他路径记得修改config文件或者inference脚本里的model_path参数。这里有一个容易踩的坑模型加载时报“size mismatch”通常是因为源码里的字典字符数和模型训练时的字典字符数不一致。如果你对字典文件做过修改比如增删了字符就必须用新字典重新训练或用旧字典加载旧模型不能混用。另外虽然项目里没有直接用huggingface仓库托管模型但如果你喜欢用huggingface_hub来管理权重版本可以把模型打包上传到自己的repo然后用snapshot_download接口拉取。这里纯粹是工作流偏好不影响模型本身。至于下载慢的问题我只建议检查网络环境或更换下载时段不要轻信来路不明的第三方下载站或所谓“加速器”那些渠道一方面可能篡改权重文件另一方面也有安全风险。3. 实操过程与核心环节实现3.1 环境搭建与依赖安装这个项目基于PyTorch实现依赖项不多核心是torch、torchvision、opencv-python、numpy、tqdm、tensorboard。我用的是Python 3.9加PyTorch 1.13的组合可以说相当保守后续的新版本也基本兼容。推荐用conda建一个干净环境conda create -n crnn_ocr python3.9 conda activate crnn_ocr pip install torch1.13.1 torchvision0.14.1 --index-url https://download.pytorch.org/whl/cu117 pip install opencv-python numpy tqdm tensorboard这里有一个建议GPU版本的torch一定不要用默认pip源直接装因为默认源装的是CPU版本训练会很痛苦。如果你用的是更新的显卡自行把cu117替换成cu118或者cu121即可。装完后可以用下面这段代码验证GPU是否可用import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0))如果第二条输出False说明torch版本和显卡驱动不匹配需要先升级驱动或者重装对应CUDA版本的torch。3.2 训练流程详解与关键参数调整训练脚本的主流程分为五步加载配置、构造数据加载器、构建模型、定义损失和优化器、进入训练循环。配置文件是重中之重。我项目里的config.py里几个关键参数是这样设置的image_height: 32输入图像高度这个数值和CNN的下采样倍数强相关不建议随意改。image_width: 640padding后的宽度如果显存小可以降到512。batch_size: 64在RTX 3090上训练时显存占用大约12GB如果是8GB显存建议降到32。num_epochs: 100配合早停策略实际到60个epoch左右就能看到loss收敛平台期。lr: 0.0003Adam优化器的初始学习率用了warmup加cosine decay前5个epoch从0.0001线性升到0.0003之后逐渐衰减。dictionary_path: 指向字典文件的路径。模型构建代码如下import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, hidden_size256): super(CRNN, self).__init__() self.cnn nn.Sequential( nn.Conv2d(1, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # (64, 16, 320) nn.Conv2d(64, 128, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2, 2), # (128, 8, 160) nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), # (256, 8, 160) nn.Conv2d(256, 256, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1), (2, 1)), # (256, 4, 160) nn.Conv2d(256, 512, 3, padding1), nn.BatchNorm2d(512), nn.ReLU(inplaceTrue), # (512, 4, 160) nn.Conv2d(512, 512, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d((2, 1), (2, 1)), # (512, 2, 160) nn.Conv2d(512, 512, 2), nn.ReLU(inplaceTrue), # (512, 1, 159) ) self.rnn nn.Sequential( nn.LSTM(512, hidden_size, bidirectionalTrue, num_layers2, dropout0.3), nn.LSTM(hidden_size * 2, hidden_size, bidirectionalTrue, num_layers2, dropout0.3), ) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): x self.cnn(x) # (N, C, H, W) x x.squeeze(2) # (N, C, W) x x.permute(2, 0, 1) # (W, N, C) x self.rnn(x) x self.fc(x) return x # (W, N, num_classes)注意CNN部分最后一层卷积的kernel size是2x2它把特征图高度压到1宽度变成159。159其实是640经过一系列卷积下采样后的近似长度不是固定的你可以根据实际padding宽度反推。这里不追求宽度恰好被整除因为CTC对宽度不敏感。训练循环中loss的计算方式是重点import torch.nn.functional as F # input: (W, N, num_classes), targets: (N, S) # input_lengths: (N,), target_lengths: (N,) loss F.ctc_loss( log_probsF.log_softmax(output, dim2), targetstargets, input_lengthsinput_lengths, target_lengthstarget_lengths, blank0, reductionmean, )CTC loss要求输入是log概率而且序列长度要大于等于目标长度否则会有loss返回nan。input_lengths一般是所有样本的序列长度模型输出时间步数Wtarget_lengths是每张图标注字符的实际长度。解码阶段我使用CTC贪心解码也就是每个时间步取概率最大的字符索引然后合并相邻重复字符、删除blank得到最终文本。贪心解码速度极快精度在大多数场景下已经够用如果对精度要求更高可以换成Beam Search解码但速度会慢不少实际提升也就零点几到两个百分点。3.3 模型评估与指标解读模型训练完不能只看loss必须用真实场景的验证集做评估。我项目里的评估脚本会输出两个指标字符准确率character accuracy按字符计算和整行准确率word accuracy全行完全匹配才算对。这两个指标的含义差异很大。字符准确率只关心每个字符是否被正确识别即使一行里有一两个字错了只要还占大多数指标也会不错整行准确率则严格得多任何一个字符错误都会算整行错误。对于一个单据识别的业务场景整行准确率才是业务上真正关心的因为下游系统通常是拿整行文本去做匹配的一行错一个字整行就不能被当成正确结果。我当时用混合数据集训练后验证集上字符准确率到了98%左右整行准确率在89%上下。这个差距是正常的不必焦虑。想要提升整行准确率比较有效的方向有两个一是增加数据多样性特别是容易混淆的形近字对二是在解码阶段加一个基于语言模型的纠错后处理比如用一个简单的bigram语言模型对CTC解码结果做重排序。3.4 推理demo与模型导出训练完模型后实际部署还需要两步写一个干净的推理接口把模型导出成ONNX或者TorchScript方便在不同平台上跑。推理接口的核心逻辑很简单def infer(model, image_path, char_dict, device): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) h, w img.shape # 高度统一到32宽度等比缩放 scale_h 32.0 / h new_w int(w * scale_h) img cv2.resize(img, (new_w, 32), interpolationcv2.INTER_LINEAR) img np.expand_dims(img, axis0) # (1, 32, W) img np.expand_dims(img, axis0) # (1, 1, 32, W) img torch.from_numpy(img).float() / 255.0 img img.to(device) model.eval() with torch.no_grad(): output model(img) # (W, 1, num_classes) pred output.argmax(dim2).squeeze(1).cpu().numpy() # (W,) text decode_ctc(pred, char_dict) return text对于整图识别你还需要先做文本检测把每个文字行的box切出来再逐行送进CRNN。项目源码里留了一个demo脚本先用简单的形态学操作定位文字行然后调用上面的接口做识别。这个简易检测在扫描文档上效果还行遇到复杂背景还是要接专业的检测模型。ONNX导出也很直接import torch.onnx model.eval() dummy_input torch.randn(1, 1, 32, 640) torch.onnx.export( model, dummy_input, crnn_ocr.onnx, input_names[input], output_names[output], dynamic_axes{input: {3: width}, output: {0: seq_len}}, opset_version11, )dynamic_axes里的“3: width”表示第一维batch大小固定为1第三维宽度可以动态变化。这样导出的ONNX对任意宽度的输入都适用部署时灵活性更大。4. 常见问题与排查技巧实录4.1 问题速查表我把实际运行中高频出现的问题整理成了下面这张表每一条都是我或朋友真实踩过的坑不是网上抄来的模版。现象可能原因解决方案loss 是 nan学习率过大 / 输入包含 NaN 像素降低学习率到 1e-4 起步检查数据加载是否出现除零模型加载 size mismatch字典字符数与训练时不匹配检查字典文件是否被修改使用与原训练相同的字典训练时 GPU 显存不足batch_size 太大 / 图像padding太宽调小 batch_size 或把 image_width 从 640 降到 512字符全部识别为 blankCTC blank 索引与解码逻辑不一致确认模型输出层 blank 索引是 0否则修改解码函数识别结果出现重复字符CTC去重没处理好解码时先合并相邻重复字再删blank顺序不能反中文标点识别特别差数据集中标点样本过少做数据增强时对标点区域做随机遮挡和轻度形变CPU 推理速度太慢模型宽度过大 / 没转 ONNX尝试把图像宽度压缩到 320或导出 ONNX 用 openvino 跑CUDA out of memory at epoch end验证阶段额外占显存验证阶段使用 no_grad 且逐batch计算不一次性load全部验证集4.2 我踩过的最大的坑这个项目中让我印象最深的一个坑是CTC解码时对重复字符的处理顺序。刚开始写解码函数时我先删了blank再去重结果所有“AA”这种重叠字符全部被错误合并成了一个“A”。正确做法是先合并相邻重复字符再去掉blank这样才能保证“好”字的“好”不会被错误合并。这个坑属于逻辑细节不跑代码根本发现不了后来我把这行逻辑写成了单测防止后续改动重新引入。另外一个典型坑是数据增强的度。我最初为了提升模型鲁棒性加了很强的随机透视变换训练loss确实下降了但验证集准确率一路走低。后来发现问题是透视变换生成的图像已经不像一个正常文字行了人眼都难以辨认模型自然也学不到有效特征。数据增强要围绕“模拟真实拍摄变形”这个目标来做而不是无限叠加各种变换。最终我保留的增强方式包括轻度仿射变换旋转±3度、缩放±10%、随机亮度对比度扰动、随机模糊、随机裁剪。强度都控制在比较保守的范围。4.3 遇到“模型效果差”时先检查数据再做算法很多同学在群里问“模型准确率上不去怎么办”抛出各种模型结构修改方案。但根据我的经验80%的情况下问题出在数据侧而不是模型侧。这里分享一套排查顺序供参考随机抽100张训练样本用脚本将它们和对应的标注文本打印出来人工确认一一对应。不要相信原始标注一定是对的我之前就发现近千张图的标注末尾被批量加上了“\n”导致target_lengths全部偏大。检查字典覆盖度。统计训练集和验证集字符集合的差集如果验证集里有太多训练集没见过的新字模型对这些字的识别率一定很惨。检查图像resize后的可读性。把resize到32高后的图像存下来人工看一眼是否依然能辨认。如果高度压缩太狠导致笔画糊成一团可以考虑先把图像原图切得更宽而不是继续压高度。对比训练集和测试集的数据分布。如果你的训练数据全是印刷体截图测试时却拿着自然场景文本图效果差是必然的这种情况应该采集与场景匹配的数据做finetune而不是继续堆算力。数据问题排查完如果还是不行再回头调模型结构和超参也不迟。这个顺序能节省大量无效实验时间。最后再分享一个小技巧训练过程中可以定期把模型在验证集上识别错误的图片保存下来按类别归组隔几天翻看一次。这个习惯能帮你快速看清模型的“系统性问题”比如“带数字的长串总是识别不全”“行尾标点经常丢”这些观察比任何指标曲线都更能指导下一步优化方向。汉字识别这个方向数据质量确实就是模型的真实上限算法只是努力逼近这个上限而已。本文还有配套的精品资源点击获取