EAST文字检测与CRNN+CTC识别:从模型原理到TensorFlow Lite边缘部署实战

发布时间:2026/10/20 20:05:38
EAST文字检测与CRNN+CTC识别:从模型原理到TensorFlow Lite边缘部署实战
简介这份资源面向计算机、电子信息等专业学生及深度学习入门者提供一套基于Keras与TensorFlow实现的图像文字检测与识别完整源码可用于课程设计、期末大作业、毕设项目或初期项目立项演示。项目将EAST/AdvancedEAST文字检测模型与CRNNCTC文字识别模型串联覆盖从文本区域定位到字符序列识别的完整流程代码按检测、识别、数据、网络、模型、测试等模块组织便于理解两阶段OCR的工程实现思路。压缩包共32个文件以19个Python脚本为核心辅以8张jpg示例图片、3个md说明文档和2个txt环境配置文本整体约937KB体积轻量、结构清晰。目前已有100人学习关注。读者可据此掌握检测与识别模型的搭建、训练与推理调用方式参考示例图片验证效果并借助说明文档快速跑通环境为后续二次开发与调试提供可复用的代码基础。1. 从一张发票照片说起EAST 检测加 CRNN 识别到底在解决什么你手里有一张拍歪了的发票或者快递单想让程序自动把上面的文字读出来。这件事拆开看只有两步先找到文字在哪再把找到的文字认出来。前者叫文字检测后者叫文字识别。标题里的 EAST 和 CRNNCTC就是这两步里被工业界反复验证过的经典组合。EAST 负责在整张图上框出文字区域输出的是一个个带角度的四边形CRNNCTC 负责把框里的内容转成字符串不需要你对每个字符做切分标注。这套方案适合谁适合手头有几千张标注图、想自己训一个能跑在普通显卡上的 OCR 模型的开发者也适合已经用过现成 OCR 接口、但受限于网络延迟或数据不能出内网、想自己搭一套的团队。它不追求 SOTA但胜在结构清晰、复现成本低、调参空间大。2. EAST 检测模型从特征图到四边形框的完整链路2.1 为什么选 EAST 而不是 DB 或 CTPN文字检测模型这几年迭代很快DB 在弯曲文本上表现更好CTPN 对水平长文本友好但 EAST 的优势在于结构简单、后处理轻、对四边形文本的回归直接。它的核心思路是用全卷积网络输出两张图一张是每个像素属于文字的概率score map另一张是每个像素到它所属文字框四个顶点的距离geometry map。推理时先对 score map 做阈值化再对每个连通区域取几何图里的距离反推出四边形顶点。整个过程没有 RNN没有复杂的锚框机制一张 512×512 的图在普通显卡上能跑到几十毫秒。对于票据、证件、街景招牌这类文本方向相对规整的场景EAST 的性价比很高。我一般会在项目初期用 EAST 快速搭出基线如果发现弯曲文本漏检严重再考虑换 DB。2.2 骨干网络与特征融合PVANet 还是 ResNet50EAST 原文用的是 PVANet 做骨干但实际复现时更多人换成了 ResNet50 或 MobileNetV2。原因很直接PVANet 的预训练权重不好找而 ResNet50 在 Keras 里有现成的 ImageNet 权重迁移学习收敛快。特征融合部分EAST 把骨干网络不同阶段的特征图做上采样后拼接再经过几层卷积压缩通道。这里有个关键参数特征融合后的通道数。原文是 128但如果你检测的是小文字建议加到 256否则小目标的几何回归会不准。下面是一个用 Keras 搭 EAST 特征融合部分的代码片段假设骨干输出是四个不同尺度的特征图。import tensorflow as tf from tensorflow.keras import layers, Model def east_feature_fusion(features, fusion_channels128): features: list of 4 tensors, 从骨干网络不同阶段取出 尺度依次为 1/4, 1/8, 1/16, 1/32 # 从最小尺度开始逐级上采样并与上一级拼接 x features[3] # 1/32 x layers.Conv2D(fusion_channels, 1, paddingsame)(x) x layers.UpSampling2D(size(2, 2))(x) # 到 1/16 x layers.Concatenate()([x, features[2]]) x layers.Conv2D(fusion_channels, 3, paddingsame, activationrelu)(x) x layers.UpSampling2D(size(2, 2))(x) # 到 1/8 x layers.Concatenate()([x, features[1]]) x layers.Conv2D(fusion_channels, 3, paddingsame, activationrelu)(x) x layers.UpSampling2D(size(2, 2))(x) # 到 1/4 x layers.Concatenate()([x, features[0]]) x layers.Conv2D(fusion_channels, 3, paddingsame, activationrelu)(x) return x这段代码的逻辑是把深层特征上采样到浅层尺度再和浅层特征拼接让网络同时看到语义信息和细节。fusion_channels控制融合后的通道数设小了细节丢失设大了显存吃紧。我一般从 128 起步如果验证集上小文字召回率低于 0.7就调到 256 再训一轮。注意拼接前要对深层特征做 1×1 卷积降通道否则拼接后通道数爆炸显存直接翻车。2.3 损失函数分类损失和回归损失怎么配比EAST 的损失由两部分组成score map 的二分类交叉熵和 geometry map 的 IoU 损失或平滑 L1 损失。分类损失里有个细节正负样本极度不均衡文字像素通常只占整图的 5% 到 15%。所以要在分类损失里加一个平衡系数原文用的是类别平衡交叉熵正样本权重设为负样本的 3 到 5 倍。回归损失只对正样本计算负样本不参与。下面是一个简化版的损失实现。def east_loss(y_true_cls, y_pred_cls, y_true_geo, y_pred_geo, neg_pos_ratio3.0): y_true_cls: 真实 score map, 形状 [B, H, W, 1] y_pred_cls: 预测 score map y_true_geo: 真实 geometry map, 形状 [B, H, W, 4] y_pred_geo: 预测 geometry map # 分类损失带权重的二分类交叉熵 pos_mask tf.cast(tf.equal(y_true_cls, 1.0), tf.float32) neg_mask 1.0 - pos_mask pos_loss -tf.reduce_sum(pos_mask * tf.math.log(y_pred_cls 1e-8)) neg_loss -tf.reduce_sum(neg_mask * tf.math.log(1.0 - y_pred_cls 1e-8)) cls_loss (pos_loss neg_loss * neg_pos_ratio) / (tf.reduce_sum(pos_mask) 1e-6) # 回归损失只对正样本计算平滑 L1 geo_diff tf.abs(y_true_geo - y_pred_geo) smooth_l1 tf.where(geo_diff 1.0, 0.5 * geo_diff ** 2, geo_diff - 0.5) geo_loss tf.reduce_sum(pos_mask * smooth_l1) / (tf.reduce_sum(pos_mask) * 4 1e-6) return cls_loss geo_lossneg_pos_ratio控制负样本的惩罚力度设太小模型会把所有像素都预测成背景设太大又容易误检。我一般从 3 开始试如果验证集上误检框特别多就降到 2如果漏检多就升到 5。回归损失里的平滑 L1 阈值 1.0 是个经验值对归一化后的坐标来说够用。2.4 从 score map 到四边形后处理里的三个阈值模型输出的是像素级预测要变成可用的四边形框还得做后处理。步骤是先对 score map 做二值化得到文字区域掩码再对掩码做连通域分析每个连通域对应一个文字实例最后对每个连通域里的像素取 geometry map 预测的四个距离加权平均后反推出四边形顶点。这里有三个阈值直接影响结果score 阈值、连通域最小面积、四边形最小边长。score 阈值一般设 0.8 到 0.9低于 0.8 会引入大量噪声连通域最小面积按输入尺寸的千分之一到千分之五来设比如 512×512 的图最小面积设 200 到 500 像素四边形最小边长设 5 到 10 像素过滤掉细碎噪点。这三个参数没有万能值得在验证集上网格搜一下。3. CRNNCTC 识别模型不定长文字序列怎么端到端训练3.1 CRNN 的三段式结构卷积、循环、转录CRNN 的名字已经说明了结构卷积层提取图像特征循环层建模序列依赖转录层把序列预测转成最终字符串。卷积部分通常用 VGG 或 ResNet 的前几层把输入图像变成高度为 1、宽度为 W 的特征序列。循环部分用双向 LSTM每个时间步输出一个字符概率分布。转录层就是 CTC它解决了一个核心问题图像里的文字没有字符级对齐标注你不知道哪个像素对应哪个字符。CTC 通过引入空白符和动态规划让网络自己学会对齐。这套结构对不定长文字特别友好训练时只需要整行文字的标注不需要切分每个字符。3.2 输入图像预处理高度归一化和宽度填充CRNN 对输入有个硬性要求图像高度必须归一化到固定值通常是 32 或 48 像素。宽度可以变但一个 batch 内的图像宽度要一致所以要么按最大宽度填充要么按宽度分桶。我一般把高度设成 32宽度按比例缩放后填充到 256。填充用灰色像素值 128这样不会引入额外的边缘响应。下面是一个预处理函数。import cv2 import numpy as np def preprocess_for_crnn(img, target_height32, target_width256): img: BGR 图像来自 EAST 检测框裁剪 返回: 归一化后的图像形状 [target_height, target_width, 3] h, w img.shape[:2] # 按高度缩放保持宽高比 scale target_height / h new_w int(w * scale) resized cv2.resize(img, (new_w, target_height)) # 如果缩放后宽度超过目标宽度直接截断否则填充 if new_w target_width: resized resized[:, :target_width] else: pad_width target_width - new_w # 用灰色填充避免黑色边缘干扰 resized cv2.copyMakeBorder( resized, 0, 0, 0, pad_width, cv2.BORDER_CONSTANT, value(128, 128, 128) ) # 归一化到 [-1, 1] resized resized.astype(np.float32) / 127.5 - 1.0 return resizedtarget_height设 32 是 CRNN 原文的默认值如果你的文字特别小可以设 48但计算量会翻倍。target_width设 256 能覆盖大部分票据行如果一行文字特别长可以设 512但 LSTM 的时间步会变多训练变慢。填充值用 128 而不是 0是因为 0 在归一化后是 -1和文字边缘的对比度太强容易让卷积层学到填充边界。3.3 CTC 损失blank 标签和动态规划CTC 的核心是引入一个 blank 标签通常放在字符集最后。网络在每个时间步输出所有字符加 blank 的概率CTC 通过前向-后向算法把所有可能对齐路径的概率求和得到目标字符串的概率。Keras 里可以直接用tf.nn.ctc_loss但要注意输入格式logits 的形状是[T, B, num_classes]label 是稀疏的整数序列label_length和logit_length都要提供。下面是一个训练步的示例。def ctc_loss_fn(y_true, y_pred, label_length, logit_length): y_true: 稀疏标签形状 [B, max_label_len] y_pred: 网络输出 logits形状 [T, B, num_classes] label_length: 每个样本的真实标签长度形状 [B] logit_length: 每个样本的有效时间步形状 [B] # 确保 logits 是 float32 y_pred tf.cast(y_pred, tf.float32) # CTC 要求 blank 在最后一个类别 loss tf.nn.ctc_loss( labelsy_true, logitsy_pred, label_lengthlabel_length, logit_lengthlogit_length, logits_time_majorTrue, blank_index-1 ) return tf.reduce_mean(loss)logits_time_majorTrue表示输入形状是[T, B, C]这是 TensorFlow CTC 的默认要求。blank_index-1表示 blank 是最后一个类别。label_length和logit_length必须准确否则损失计算会出错。我见过有人把logit_length全设成 T结果填充部分也参与损失模型学出来全是 blank。3.4 解码贪心解码和束搜索怎么选训练完模型推理时要把 CTC 输出转成字符串。最简单的是贪心解码每个时间步取概率最大的类别然后合并重复字符、去掉 blank。贪心解码快但对模糊字符容易出错。束搜索保留多个候选路径效果更好但慢。实际部署时如果对延迟敏感用贪心如果追求准确率用束搜索束宽设 5 到 10 就够了。下面是一个贪心解码的实现。def greedy_decode(logits, charset): logits: 形状 [T, num_classes] charset: 字符列表blank 在最后 # 取每个时间步概率最大的类别 indices tf.argmax(logits, axis-1).numpy() result [] prev -1 for idx in indices: # 跳过 blank 和重复字符 if idx ! prev and idx ! len(charset): result.append(charset[idx]) prev idx return .join(result)charset里不包含 blankblank 的索引等于len(charset)。合并重复字符时要注意CTC 的规则是相同字符之间必须有 blank 才算是两个字符所以这里直接跳过连续重复是正确做法。4. 把 EAST 和 CRNN 串起来训练流程与数据准备4.1 数据标注格式四边形和文本内容训练 EAST 需要每张图对应一个标注文件每行是一个文字实例格式通常是x1,y1,x2,y2,x3,y3,x4,y4,文本内容。四个点按顺时针排列从左上角开始。训练 CRNN 时从原图里按四边形裁剪出文字区域做透视变换矫正再和文本内容配对。如果标注里没有文本内容CRNN 就没法训。常见做法是先用 EAST 训一版检测模型在验证集上跑出检测框人工修正后作为 CRNN 的训练数据。这个过程叫半自动标注能省不少人力。4.2 分阶段训练先训检测再训识别我一般分两阶段训。第一阶段只训 EAST输入是整图损失是分类加回归。第二阶段冻结 EAST 的权重用它生成检测框裁剪后训 CRNN。为什么不联合训练因为两个模型的输入尺度差异太大EAST 看整图CRNN 看文字条联合训练时梯度会互相干扰收敛很慢。分阶段训虽然多花点时间但每阶段的目标明确调参也容易。EAST 训到验证集 F1 在 0.85 以上再开始训 CRNN。CRNN 训到验证集字符准确率 0.95 以上就可以串起来做端到端测试了。4.3 数据增强对检测和识别要分开做数据增强对两个模型的要求不一样。EAST 需要几何变换比如随机旋转、缩放、裁剪、透视变换让模型适应不同角度和尺度的文字。CRNN 需要颜色和噪声增强比如亮度对比度抖动、高斯噪声、模糊让模型对拍摄质量鲁棒。注意不要对 CRNN 做大幅几何变换因为文字条已经被矫正过了再旋转会让字符变形。下面是一个针对 EAST 的增强示例。import imgaug.augmenters as iaa east_aug iaa.Sequential([ iaa.OneOf([ iaa.Affine(rotate(-15, 15)), # 小角度旋转 iaa.PerspectiveTransform(scale(0.02, 0.08)), # 透视变换 ]), iaa.Resize({height: (0.8, 1.2), width: (0.8, 1.2)}), iaa.Fliplr(0.5), # 水平翻转 ])旋转角度不要超过 15 度否则四边形标注会超出图像边界需要额外处理。透视变换的 scale 控制形变程度0.02 到 0.08 比较温和。水平翻转对文字检测通常没问题但如果你的场景里文字有方向性比如竖排就不要翻转。5. 避坑与排查训练和推理中最容易翻车的五个点5.1 损失不下降score map 全黑或全白现象训练几个 epoch 后EAST 的 score map 输出全是 0 或全是 1损失卡在某个值不动。原因通常是正负样本极度不均衡或者学习率太大导致模型直接崩溃。解决先检查标注文件里正样本像素占比如果低于 3%把neg_pos_ratio降到 2 甚至 1.5再把学习率从 1e-3 降到 1e-4加 warmup。如果还不行检查数据里有没有空标注的图空标注会让模型学到全背景。5.2 检测框粘连多个文字实例被合并成一个现象后处理出来的四边形把相邻的两行文字框在了一起。原因一般是 score 阈值太低或者连通域分析时没有做形态学腐蚀。解决把 score 阈值从 0.8 提到 0.9再在二值化后做一次 3×3 的腐蚀断开细小的连接。如果文字本身挨得很近腐蚀会伤到正常框那就改用连通域面积过滤把面积超过阈值的大连通域按投影切分。5.3 CRNN 训练时 CTC 损失为 NaN现象训练 CRNN 时CTC 损失突然变成 NaN梯度爆炸。原因通常是logit_length设错了或者输入图像里有全灰的填充条导致 LSTM 输出全零log 里出现负无穷。解决检查logit_length是否等于实际有效时间步不要用固定值在 CTC 损失前对 logits 做 clip限制在 [-10, 10]如果填充太多改用按宽度分桶的 batch减少填充比例。5.4 识别结果重复字符或漏字符现象CRNN 解码出来的字符串有连续重复字符比如“你好好好”或者漏掉某个字符。原因通常是 CTC 的 blank 标签没对齐或者训练数据里字符集不完整。解决检查字符集是否覆盖了所有标注里的字符包括标点和数字如果重复字符多说明 blank 学得不好增加 blank 的样本权重或者在解码时用束搜索代替贪心。漏字符一般是logit_length太长把填充部分也算进去了缩短到实际宽度。5.5 端到端推理速度慢显存不够现象EAST 和 CRNN 串起来跑单张图要几百毫秒显存占用超过 4G。原因通常是输入分辨率太高或者 CRNN 的宽度设得太大。解决EAST 输入从 512 降到 384 或 320精度损失不大但速度翻倍CRNN 的target_width从 256 降到 192LSTM 时间步减少推理时用tf.function包装开启 GPU 内存增长避免一次性占满。6. 进阶技巧用 TensorFlow Lite 把模型塞进边缘设备训练完的模型如果只在服务器上跑那这套方案的价值就少了一半。很多票据识别场景要求离线、低延迟比如手持终端或者嵌入式设备。这时候可以把 EAST 和 CRNN 分别转成 TensorFlow Lite 格式量化到 int8模型体积能压到原来的四分之一推理速度提升两到三倍。转换时要注意EAST 的后处理里有非极大值抑制和连通域分析这些操作 TFLite 不支持得用自定义算子或者挪到 CPU 上用 OpenCV 做。CRNN 的 LSTM 在 TFLite 里支持得不错但 CTC 解码要自己实现。我一般会把 EAST 的输出 score map 和 geometry map 直接导出在 CPU 上做后处理CRNN 的 logits 导出后用 C 写一个贪心解码。这样整个流程不依赖 TensorFlow 运行时部署包能控制在 20M 以内。下面是一个转换 CRNN 的示例。import tensorflow as tf # 假设 model 是训练好的 CRNN converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] # 提供代表性数据集用于 int8 量化 def representative_dataset(): for _ in range(100): # 从验证集里取真实样本形状要和模型输入一致 yield [np.random.rand(1, 32, 256, 3).astype(np.float32)] converter.representative_dataset representative_dataset converter.target_spec.supported_ops [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type tf.int8 converter.inference_output_type tf.int8 tflite_model converter.convert() with open(crnn_int8.tflite, wb) as f: f.write(tflite_model)representative_dataset必须用真实数据随机数据量化出来的模型精度会掉得很厉害。inference_input_type设成 int8 后输入图像也要做对应的量化通常是(pixel / 255 - 0.5) / 0.5 * 127再转 int8。量化后一定要在验证集上重新测一遍准确率如果掉超过 2 个点就改用 float16 量化体积大一点但精度稳。这套流程我在几个边缘设备上跑过EAST 加 CRNN 全流程在四核 ARM 上能跑到 150 毫秒以内足够应付大多数离线场景。最后说个血泪经验量化前先把模型在验证集上的精度调到满意量化后再调就来不及了因为量化误差和训练误差会叠加你分不清是哪个的问题。希望帮到你。本文还有配套的精品资源点击获取