
简介这份资源面向希望入门或进阶自然场景文字检测与识别的学习者可作为课程设计、毕业设计、大作业或工程实训的参考项目。其核心是用Keras配合TensorFlow后端实现EAST文字检测与CRNNCTC文字识别EAST以目标检测方式回归文本框四角坐标支持90°至-90°任意方向覆盖中英文、数字与符号CRNN负责不定长文本序列识别输出端接入CTC算法模块整体结构便于生产环境部署与二次维护。压缩包共32个文件以19个py源码为主体辅以8张jpg效果图、3个md说明文档和2个txt环境配置整体约957KB目录按east、crnn、data、net、model、test等模块划分并附predict_east.py、predict_crnn.py等预测入口。目前已有164人学习适合具备一定Python基础、能自行调试报错并扩展功能的读者参考。1. 从一张发票照片说起EAST 检测加 CRNN 识别到底能干什么手里有一批报销单据、合同扫描件或者快递面单想把上面的文字自动抠出来存进表格这是很多人搜「python 图像文字检测」时最真实的诉求。单靠 OCR 引擎直接识别整图遇到倾斜、密集、多语种混排的版面就会大面积翻车因为识别模型默认输入的是「一行摆正的文字条」它不负责找文字在哪。所以工业界常见做法是拆成两段先用检测模型把文字区域框出来并矫正再送进识别模型逐行读取。EAST 就是检测这一段里性价比很高的选择全卷积结构、单次前向就能输出旋转框速度比两阶段检测器快不少AdvancedEAST 在它基础上加了多尺度融合和更细的边界回归对小字和长文本行更友好。识别这一段用 CRNN 加 CTC 是经典组合卷积提特征、循环网络建模序列、CTC 解决对齐问题不需要逐字符标注位置。这套「EAST/AdvancedEAST CRNN CTC」的流水线适合做票据、证件、工业铭牌、自然场景招牌这类有明确文字区域的场景也适合想自己动手复现一套完整 OCR 的开发者。下面按「检测怎么搭、识别怎么接、坑在哪、怎么调」的顺序讲透。2. EAST 检测头怎么搭从特征图到旋转框的完整链路2.1 为什么选 EAST 而不是通用目标检测通用检测器像 Faster R-CNN、YOLO 系列是为「水平矩形框」设计的文字行一旦倾斜水平框里会塞进大量背景识别阶段裁剪出来的图就废了。EAST 的核心设计是直接回归旋转矩形或者任意四边形输出通道里除了每个像素的得分图 score map还有四组几何量点到上、下、左、右边界的距离或者四个顶点相对当前像素的偏移。这样即使文字行斜着排也能框得贴合。另一个理由是速度。EAST 用 PVANet 或 ResNet 做骨干后面接特征合并分支把不同尺度的特征图上采样后拼接再经过几层卷积输出最终结果。整个网络是全卷积的输入任意尺寸都能跑不需要 RPN 那种候选框机制单次前向就出结果。实测在 720P 图上轻量骨干能跑到几十毫秒级别比两阶段方案省一大截。AdvancedEAST 的改动主要在特征融合和边界回归上。它把多个尺度的特征更充分地合并并且对长文本行的端点回归做了加权减少长行被截断的情况。如果你的场景里文字行普遍较长比如整行的地址、商品名称AdvancedEAST 的召回会明显好一些。2.2 用 Python 搭出检测网络的最小骨架下面这段代码给出 EAST 检测头部分的核心结构骨干网络可以用 ResNet 或 PVANet这里用简化的特征合并分支说明数据流。重点看输出通道数和几何量的组织方式。import torch import torch.nn as nn import torch.nn.functional as F class EASTHead(nn.Module): def __init__(self, in_channels128, inner_channels128): super().__init__() # 特征合并后的卷积压缩 self.conv1 nn.Conv2d(in_channels, inner_channels, 3, padding1) self.bn1 nn.BatchNorm2d(inner_channels) # 得分图每个像素是否属于文字区域 self.score_map nn.Conv2d(inner_channels, 1, 1) # 几何量4 个边界距离 1 个旋转角共 5 通道 self.geo_map nn.Conv2d(inner_channels, 5, 1) def forward(self, x): x F.relu(self.bn1(self.conv1(x))) score torch.sigmoid(self.score_map(x)) # 归一化到 0~1 geo self.geo_map(x) * 512.0 # 距离量放大便于回归 return score, geo逻辑说明score_map 用 1 通道加 sigmoid输出每个像素是文字的概率训练时用二值交叉熵。geo_map 用 5 通道前 4 个是像素到框四条边的距离第 5 个是旋转角。这里乘 512 是个常见技巧因为距离值域大直接回归梯度不稳定放大后配合损失函数里的尺度归一化更稳。参数说明in_channels 要和骨干输出对齐ResNet50 的 stage4 输出是 1024stage3 是 512特征合并后一般压到 128。inner_channels 设 128 是速度和精度的折中显存紧张可以降到 64。geo_map 的 5 通道顺序要和后处理解析代码严格一致顺序写反是新手最常见的血泪坑。2.3 特征合并分支的尺度拼接EAST 的精度很大程度靠特征合并。骨干网络不同 stage 的输出分辨率不同浅层分辨率高、语义弱深层语义强、分辨率低。合并的做法是把深层特征上采样到和浅层一样大然后沿通道拼接。class FeatureMerge(nn.Module): def __init__(self): super().__init__() self.up1 nn.ConvTranspose2d(1024, 128, 2, stride2) # 1/32 - 1/16 self.up2 nn.ConvTranspose2d(512, 128, 2, stride2) # 1/16 - 1/8 self.up3 nn.ConvTranspose2d(256, 128, 2, stride2) # 1/8 - 1/4 self.merge nn.Conv2d(128 * 4, 128, 3, padding1) def forward(self, c2, c3, c4, c5): # c5 是 1/32逐级上采样并与对应 stage 拼接 p5 self.up1(c5) p4 torch.cat([p5, c4], dim1) p3 torch.cat([self.up2(p4), c3], dim1) p2 torch.cat([self.up3(p3), c2], dim1) return self.merge(p2)逻辑说明从最深层 c5 开始每次转置卷积放大两倍和同分辨率的浅层特征在通道维拼接。最后 merge 卷积把 4 组 128 通道压回 128输出给检测头。这样浅层的高分辨率细节和深层的语义信息都用上了。参数说明转置卷积的 stride 设 2、kernel 设 2 是最省事的整数倍上采样也可以用最近邻插值加普通卷积效果接近但更省参数。拼接顺序 c2 到 c5 要和 forward 里的顺序对应通道数对不上会直接报错。如果显存不够可以把 c2 这一路去掉只保留三层合并精度掉一点但能跑起来。2.4 训练标签怎么生成EAST 训练需要把标注的四边形转成 score map 和 geo map。常见做法是把四边形缩小一圈生成 score map 的正样本区域缩小比例一般取 0.3 到 0.5这样能缓解文字行紧挨时正样本粘连的问题。geo map 则对每个正样本像素计算它到四条边的距离和角度。import numpy as np def shrink_polygon(poly, ratio0.3): # poly: (4, 2) 四边形顶点 center poly.mean(axis0) shrunk center (poly - center) * (1 - ratio) return shrunk def make_geo_map(poly, score_size): # 简化示意计算每个像素到四边的距离 h, w score_size geo np.zeros((5, h, w), dtypenp.float32) # 实际实现需遍历正样本像素计算点到边距离与角度 # 这里只给出结构完整实现见训练脚本 return geo逻辑说明shrink_polygon 把标注框往内缩缩后的区域作为 score map 的正样本边界附近作为难例。geo map 的生成是逐像素计算工程上会用向量化操作加速否则一张图几万个像素用 Python 循环会慢到没法训练。参数说明ratio 取 0.3 是经验值文字行密集时调到 0.5稀疏时可以降到 0.2。score map 的损失用 dice loss 或者 balanced cross entropy因为正负样本极度不均衡普通交叉熵会被背景淹没。3. CRNN 加 CTC把检测框里的文字读出来3.1 CRNN 的三段式结构和 CTC 的作用检测阶段给出的是一个个文字行区域识别阶段要把这些区域转成字符串。CRNN 的结构分三段卷积层提特征把输入图变成特征序列循环层用双向 LSTM 建模序列上下文转录层用 CTC 把序列输出转成最终文本。为什么用 CTC 而不是逐字符分类因为文字行里字符宽度不一逐字符标注每个字符的位置成本极高。CTC 引入空白符 blank允许网络输出重复字符和空白通过动态规划把输出序列对齐到目标文本训练时只需要整行的文本标签不需要字符级位置。这是 CRNN 能大规模落地的关键。3.2 识别网络的 Python 实现import torch import torch.nn as nn class CRNN(nn.Module): def __init__(self, num_classes, hidden256): super().__init__() # 卷积段输入 32x100 的灰度图输出序列长度约 25 self.cnn nn.Sequential( nn.Conv2d(1, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2, 2), nn.Conv2d(128, 256, 3, padding1), nn.ReLU(), nn.Conv2d(256, 256, 3, padding1), nn.ReLU(), nn.MaxPool2d((2, 1)), nn.Conv2d(256, 512, 3, padding1), nn.ReLU(), nn.Conv2d(512, 512, 3, padding1), nn.ReLU(), nn.MaxPool2d((2, 1)), ) # 循环段双向 LSTM self.rnn nn.LSTM(512, hidden, bidirectionalTrue, batch_firstTrue) # 转录段输出每个时间步的字符概率 self.fc nn.Linear(hidden * 2, num_classes) def forward(self, x): feat self.cnn(x) # (B, 512, H, W) b, c, h, w feat.shape feat feat.view(b, c * h, w) # 把高度维压进通道 feat feat.permute(0, 2, 1) # (B, W, C*H) seq, _ self.rnn(feat) logits self.fc(seq) # (B, T, num_classes) return logits逻辑说明卷积段最后两个池化用 (2,1)只在高度方向下采样宽度方向保留这样序列长度不会太短。特征图高度压成 1 后宽度维就是时间步。双向 LSTM 每个时间步看到前后文对字符粘连和模糊有更好的鲁棒性。fc 输出每个时间步的类别概率训练时接 CTC loss。参数说明num_classes 要包含 blank通常设成「字符集大小 1」。hidden 设 256 是常见值显存够可以到 512。输入图高度统一到 32宽度按比例缩放太长的行要切分否则序列过长 LSTM 会慢。3.3 CTC 解码的两种方式和参数训练完模型推理时要把 logits 转成文本。CTC 解码有贪心解码和束搜索两种。贪心解码每个时间步取最大概率的类别然后合并重复、去掉 blank速度快但精度略低。束搜索保留多个候选路径精度高但慢。def greedy_decode(logits, idx2char): # logits: (T, num_classes) preds logits.argmax(dim-1) # 每个时间步取最大 result [] prev None for p in preds: p p.item() if p ! prev and p ! 0: # 0 是 blank去重去 blank result.append(idx2char[p]) prev p return .join(result)逻辑说明贪心解码的核心是「去重 去 blank」。连续相同的字符只保留一个blank 直接跳过。这个规则来自 CTC 的对齐定义理解这一点能避免很多解码 bug。参数说明blank 的索引要和训练时一致通常设 0。idx2char 字典要和训练用的字符集完全对应顺序错一位结果全乱。束搜索的 beam width 一般取 5 到 10再大收益递减。3.4 检测和识别怎么串起来两段模型各自跑通后串接逻辑是检测输出旋转框用透视变换把每个框矫正成水平矩形缩放到高度 32送进 CRNN 识别最后按框的位置排序输出。import cv2 import numpy as np def crop_and_rectify(img, box): # box: (4, 2) 四边形顶点顺序为左上、右上、右下、左下 w int(max(np.linalg.norm(box[0] - box[1]), np.linalg.norm(box[2] - box[3]))) h int(max(np.linalg.norm(box[0] - box[3]), np.linalg.norm(box[1] - box[2]))) dst np.array([[0, 0], [w, 0], [w, h], [0, h]], dtypenp.float32) M cv2.getPerspectiveTransform(box.astype(np.float32), dst) warped cv2.warpPerspective(img, M, (w, h)) # 统一高度到 32宽度按比例 scale 32.0 / h return cv2.resize(warped, (int(w * scale), 32))逻辑说明透视变换把倾斜的文字行拉正这是识别准确率的关键一步。顶点顺序必须固定顺序乱了变换矩阵就错裁出来的图是扭曲的。参数说明目标高度 32 要和 CRNN 训练时的输入高度一致不一致识别率会掉。宽度按比例缩放太宽的行可以按最大宽度截断或切分。warpPerspective 的插值默认线性即可。4. 避坑与排查这套流水线最容易翻车的五个地方4.1 检测框顶点顺序错乱导致识别全错现象检测看起来框对了但识别结果全是乱码或者完全不对。原因geo map 解析出的四边形顶点顺序不固定透视变换时把右下当成了右上裁出来的图旋转了 90 度或镜像。解决在后处理里强制按「左上、右上、右下、左下」排序排序规则是先按 y 坐标分上下两组组内按 x 排序。这一步不做后面全白搭。4.2 score map 阈值设太高导致漏检现象明明有文字的区域没框出来尤其是浅色或者小字。原因score map 阈值默认 0.9 太高模型对模糊文字的置信度上不去。解决把阈值降到 0.5 到 0.7 之间试同时配合 NMS 的 IoU 阈值调整。注意阈值降低会引入更多误检要一起看 precision 和 recall 的曲线。4.3 CTC 训练 loss 不下降现象训练几个 epochCTC loss 卡在一个值不动。原因常见有三种一是字符集和标签对不上标签里有字符不在字典里二是输入图高度和网络不匹配特征序列长度算错三是学习率太大梯度爆炸。解决先打印一个 batch 的标签和字典比对再检查输入尺寸最后把学习率降到 1e-4 以下试。4.4 长文本行被截断现象整行的地址或商品名只识别出前半段。原因EAST 对长行的端点回归不准框短了一截或者 CRNN 输入宽度超过训练时的最大宽度被强行缩放导致字符糊在一起。解决检测端用 AdvancedEAST 的长行加权识别端把长行按固定宽度切分后分别识别再拼接。4.5 显存不够训练中断现象训练到一半报 CUDA out of memory。原因EAST 特征合并分支通道数大加上 CRNN 的 LSTM显存占用高。解决把 batch size 降到 4 或 2用梯度累积模拟大 batch或者把骨干换成轻量网络特征合并通道从 128 降到 64。混合精度训练也能省不少显存。5. 把识别率再往上推一档几个我常用的调优习惯模型跑通只是起点真正决定能不能用的是识别率。我一般会从数据侧先动手因为改数据的收益往往比调模型大。第一件事是检查训练集的字符覆盖如果测试集里出现的字符训练集里没有模型再强也认不出来这种情况直接补数据比调参快。第二件事是把检测和识别分开评估检测看 IoU 和召回识别看字符准确率和行准确率哪一段拖后腿就修哪一段不要一起调。识别端的调优我常用两个手段。一是把 CRNN 的输入高度从 32 提到 48对中文和小字场景提升明显代价是速度慢一些。二是给 CTC 解码换成束搜索beam width 取 5行准确率通常能涨一到两个点实时性要求不高时值得开。下面这个表格是我在不同场景下的参数起点可以直接拿去试。场景输入高度检测阈值解码方式备注票据扫描件320.8贪心字清晰速度快自然场景招牌480.6束搜索光照复杂需高召回工业铭牌320.7束搜索字符集小精度优先手写体480.5束搜索需专门手写数据微调还有一个容易被忽略的点是后处理。识别结果里经常混入标点误判和空格我一般会按业务规则做一层清洗比如金额字段只保留数字和小数点日期字段做格式校验。这层规则不复杂但能把最终可用率拉高不少。验证方法上我习惯留一个固定的小测试集每次改动都跑一遍记录检测召回、识别行准确率和端到端准确率三个数。端到端准确率才是最终指标因为检测漏了或者识别错了都会体现在这里。改动一次只动一个变量不然出了问题不知道是哪一步引起的。最后说个教训。我早期做这套流水线时花了两周调模型结构识别率就是上不去后来发现是训练标签里有一批框的顶点顺序是反的模型学到的几何回归一直是错的。从那以后我养成了一个习惯任何新数据集进来先可视化一批检测标签和识别裁剪图肉眼确认没问题再开训。这个习惯帮我省下的时间比任何调参技巧都多。希望帮到你。本文还有配套的精品资源点击获取