☰
EAST文字检测与CRNN+CTC识别:从模型原理到TensorFlow Lite边缘部署实战
2026/10/20 20:05:24 网站建设 项目流程

简介:这份资源面向计算机、电子信息等专业学生及深度学习入门者,提供一套基于Keras与TensorFlow实现的图像文字检测与识别完整源码,可用于课程设计、期末大作业、毕设项目或初期项目立项演示。项目将EAST/AdvancedEAST文字检测模型与CRNN+CTC文字识别模型串联,覆盖从文本区域定位到字符序列识别的完整流程,代码按检测、识别、数据、网络、模型、测试等模块组织,便于理解两阶段OCR的工程实现思路。压缩包共32个文件,以19个Python脚本为核心,辅以8张jpg示例图片、3个md说明文档和2个txt环境配置文本,整体约937KB,体积轻量、结构清晰。目前已有100人学习关注。读者可据此掌握检测与识别模型的搭建、训练与推理调用方式,参考示例图片验证效果,并借助说明文档快速跑通环境,为后续二次开发与调试提供可复用的代码基础。

1. 从一张发票照片说起:EAST 检测加 CRNN 识别到底在解决什么

你手里有一张拍歪了的发票或者快递单,想让程序自动把上面的文字读出来。这件事拆开看只有两步:先找到文字在哪,再把找到的文字认出来。前者叫文字检测,后者叫文字识别。标题里的 EAST 和 CRNN+CTC,就是这两步里被工业界反复验证过的经典组合。EAST 负责在整张图上框出文字区域,输出的是一个个带角度的四边形;CRNN+CTC 负责把框里的内容转成字符串,不需要你对每个字符做切分标注。这套方案适合谁?适合手头有几千张标注图、想自己训一个能跑在普通显卡上的 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 还是 ResNet50

EAST 原文用的是 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_channels=128): """ features: list of 4 tensors, 从骨干网络不同阶段取出 尺度依次为 1/4, 1/8, 1/16, 1/32 """ # 从最小尺度开始,逐级上采样并与上一级拼接 x = features[3] # 1/32 x = layers.Conv2D(fusion_channels, 1, padding='same')(x) x = layers.UpSampling2D(size=(2, 2))(x) # 到 1/16 x = layers.Concatenate()([x, features[2]]) x = layers.Conv2D(fusion_channels, 3, padding='same', activation='relu')(x) x = layers.UpSampling2D(size=(2, 2))(x) # 到 1/8 x = layers.Concatenate()([x, features[1]]) x = layers.Conv2D(fusion_channels, 3, padding='same', activation='relu')(x) x = layers.UpSampling2D(size=(2, 2))(x) # 到 1/4 x = layers.Concatenate()([x, features[0]]) x = layers.Conv2D(fusion_channels, 3, padding='same', activation='relu')(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_ratio=3.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_loss

neg_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. CRNN+CTC 识别模型:不定长文字序列怎么端到端训练

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_height=32, target_width=256): """ 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 resized

target_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( labels=y_true, logits=y_pred, label_length=label_length, logit_length=logit_length, logits_time_major=True, blank_index=-1 ) return tf.reduce_mean(loss)

logits_time_major=True表示输入形状是[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里不包含 blank,blank 的索引等于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 降到 192,LSTM 时间步减少;推理时用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 毫秒以内,足够应付大多数离线场景。最后说个血泪经验:量化前先把模型在验证集上的精度调到满意,量化后再调就来不及了,因为量化误差和训练误差会叠加,你分不清是哪个的问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询