ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

两阶段OCR实战:EAST文本检测与CRNN+CTC识别的Python实现

两阶段OCR实战:EAST文本检测与CRNN+CTC识别的Python实现 简介一套基于Python与Keras/TensorFlow实现的自然场景图像文字检测与识别方案使用EAST/AdvancedEAST完成任意角度文字检测CRNNCTC实现不定长文字识别适合深度学习初学者、毕业设计及工程实训。压缩包共32个文件含19个Python脚本、8张JPG测试图、3个Markdown文档和2个TXT环境配置整体仅957KB。检测端支持90度至负90度范围内中英文、数字及符号的文本框定位识别端采用CTC算法输出文字序列需Python3.6、TensorFlow1.14与Keras2.1.6环境。资源附有预测脚本和测试样例结构清晰便于对照调试和二次开发。目前已有164人学习下载。1. 从一张带字图片到可编辑文本两阶段 OCR 方案在解决什么问题拍一张营业执照、扫描一份合同、对着黑板拍张照想要里面那几行字变成能复制粘贴的文本落地通常不是靠一个大模型一把梭而是“检测 识别”两段接力先用 EAST / AdvancedEAST 圈出哪里有文字再用 CRNN CTC 读出文字本身。这套组合在 Python 里跑起来不依赖云端接口数据能留在本地适合票据、卡证、横幅等排版相对固定的离线 OCR 场景。下面会先讲两个模型各自的原理和参数再给一条能直接改参数的推理脚本最后把检测框太紧、CTC 解码重复这类必经的坑逐个拆开。适合用 OpenCV 和 PyTorch 搭过基础图像程序的读者新手按步骤也能跑通。2. 文字检测层选型EAST 与 AdvancedEAST 的差异和最小推理实现先明确一个边界检测层解决的是“字在哪”不是“字是什么”。它输出的是若干文本框的坐标可能是水平的也可能是带旋转角度的四边形。下面按模型原理、最小推理实现和选型三个小节来写。环境方面先装好 Python 3.8 以上版本、PyTorch 和 Opencv-python很多新手卡在 numpy 装不上其实直接pip install numpy就能解决折腾环境变量比装库本身更费时间这个放到后面排查章节再提。2.1 为什么检测层先于识别层EAST 的模型结构和输出约定EAST 全称 Efficient and Accurate Scene Text Detector核心是一套全卷积网络FCN。和两阶段检测器先提候选框再分类不同它把文本检测当成逐像素的回归任务输入一张图输出两个分支——score map 和 geometry。score map 的每个像素表示该点处于文本区域内部的置信度geometry 在 RBOX 模式下是五个值分别是该像素到文本区域上、右、下、左边界的距离 d1~d4以及文本框相对水平方向的旋转角度 angle。QUAD 模式下则直接回归四边形四个顶点的坐标。这种设计的收益是不需要预设锚框检测形状更贴合自由排版的场景文字速度也快在 CPU 上也能做到接近实时的推理。缺点是当文本区域很长、倾斜角度很大的时候d1~d4 的回归值方差变大模型容易把一条长文本拆成几段。AdvancedEAST 的主要改动就在这一层它把角度的回归改造成 sin/cos 两个分量并在损失上对短边和长边做了不同权重的处理长文本和倾斜文本的稳定性明显更好。如果你的图片以水平印刷体为主EAST 完全够用一旦出现大量倾斜、竖排、透视变形的文本我会优先选 AdvancedEAST。2.2 跑通检测的最小 Python 代码加载、前向与后处理这一节给出一个可直接改路径运行的检测脚本。常见做法是图片先按高度等比缩放避免模型输入尺寸跨度太大导致 score map 上的文本区域比例失真前向得到 score 和 geometry 之后先按阈值筛出候选点再由这些点还原旋转矩形最后做 NMS 合并。import cv2 import numpy as np import torch from shapely.geometry import Polygon def east_inference(img_path, model, input_h320, score_thres0.4, nms_thres0.3): # 读取图像统一转成 RGB避免 OpenCV 的 BGR 顺序影响可视化 img cv2.imread(img_path) img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h0, w0 img_rgb.shape[:2] scale input_h / h0 resized cv2.resize(img_rgb, (int(w0 * scale), input_h)) # 归一化到 [0,1]按 NCHW 排布送入模型 tensor torch.from_numpy(resized).permute(2, 0, 1).unsqueeze(0).float() / 255.0 model.eval() with torch.no_grad(): score, geometry model(tensor) # score 经过 sigmoid 变成置信度geometry 里有 5 个通道 score score.sigmoid().squeeze().numpy() # (input_h, W) geo geometry.squeeze().numpy() # (5, input_h, W) boxes, scores [], [] h, w score.shape for y in range(h): for x in range(w): if score[y, x] score_thres: continue d1, d2, d3, d4 geo[0, y, x], geo[1, y, x], geo[2, y, x], geo[3, y, x] angle geo[4, y, x] cos_a, sin_a np.cos(angle), np.sin(angle) # 先构造相对中心点的四个角点再做旋转和平移 pts np.array([[-d4, -d1], [d2, -d1], [d2, d3], [-d4, d3]]) rot np.array([[cos_a, -sin_a], [sin_a, cos_a]]) pts pts rot.T np.array([x, y]) boxes.append(pts) scores.append(score[y, x]) if not boxes: return [] boxes np.array(boxes, dtypenp.float32) / scale # 缩放回原图坐标 scores np.array(scores) # 用四边形 IoU 做 NMS先按得分排序再合并重叠框 keep [] idx np.argsort(-scores) while len(idx) 0: cur idx[0] keep.append(cur) poly_cur Polygon(boxes[cur]) ious [] for j in idx[1:]: poly_j Polygon(boxes[j]) inter poly_cur.intersection(poly_j).area union poly_cur.union(poly_j).area ious.append(inter / max(union, 1e-6)) idx np.array([j for j, iou in zip(idx[1:], ious) if iou nms_thres]) return boxes[keep].tolist()代码里几个参数要解释一下。input_h 控制输入图像的缩放高度我一般设 320模型小的可以降到 224速度更快但小字召回率会下降。score_thres 决定置信度高于多少的像素才被采纳0.4 是相对稳的起点文字模糊时调到 0.3。nms_thres 是 NMS 的 IoU 阈值0.3 表示两个框重叠面积超过 30% 就只保留得分高的那个这个值调大框会更多更细碎调小则可能把相邻两行文本误合并。PyTorch 前向时关掉梯度计算torch.no_grad()不只是省显存对推理提速也很明显。这里旋转矩阵恢复文本框的数学原理是d1~d4 给的是当前像素相对文本区域四条边界的距离先把它们组合成一个未旋转的矩形再乘上由 angle 构造的旋转矩阵最后加上中心坐标。注意 angle 的单位是弧度模型输出一般也是弧度。恢复后的坐标要除以 scale 还原到原图尺寸否则画框位置会整体偏移。2.3 EAST 与 AdvancedEAST 的选型对照旋转文本和长文本怎么选直接给一张选型表按实际数据分布对照着选即可。场景推荐模型关键原因印刷体、水平排版扫描件、PDF 截图EAST结构简单、后处理快CPU 推理友好手机拍摄、略微倾斜发票、工牌EAST轻微倾斜下 EAST 足够调整输入尺寸即可大幅倾斜、竖排文字门店招牌AdvancedEAST角度回归用 sin/cos 表示旋转框更稳定超长文本行一整段话、长标语AdvancedEAST长边回归的损失权重处理更好断框率低密集小字名片、表格单元格EAST 更大输入尺寸此类问题更多受分辨率限制两模型差距不大EAST 的公开权重和实现相对常见跑通成本低AdvancedEAST 的实现稍复杂但对倾斜和长文本更稳适合业务图片来源复杂的场景。我的习惯是先拿同一批真实样本在两个模型上各跑一遍统计“完整框出文本行”的比例用数据选型而不是凭印象。如果新场景里文本基本水平直接选 EAST省下的调试时间足够做别的。3. CRNNCTC 识别模型把检测框变成字符串的路径检测层把文字区域框住之后第二步是把裁剪出来的小块图像识别成字符串。CRNN 负责把图像编码成序列特征CTC 负责在序列和字符之间做对齐两者组合在训练和推理上都有成熟套路。这一章讲结构和参数再给完整解码代码。3.1 CRNN 的“卷积提特征 双向 LSTM 做序列”结构CRNN 全称 Convolutional Recurrent Neural Network处理顺序是先把整张图喂给一个卷积栈常由 VGG 或 ResNet 变体构成浅层提取边缘、纹理深层提取笔画和字符部件的语义。关键一步在最后把特征图的高度压缩成 1只保留宽度方向上的维度然后按列切分每一列看作一个时间步的输入送进双向 LSTM。为什么这样做有效文字本身就是一维序列从左到右排列CNN 的输出天然携带局部视觉信息但缺少上下文BiLSTM 恰好能建模字符间的依赖关系比如“花”后面出现“生”的概率更高。实践中还要注意输入到 CRNN 的图像高度会先归一化到一个固定值常见 32宽度保持原比例这样输入进来的图片不会因为被拉伸而破坏字形。这一点和后面流水线的矫正环节是配套的。训练时 CRNN 的损失用 CTC loss而不是普通的交叉熵。原因是图像中每个字符的宽度不是等分的模型不知道“这一列对应哪几个字符”CTC 通过在输出序列中插入 blank 来对齐字符让模型自己学习分配关系。模型的输出是 T 个时刻上的字符概率分布T 是特征图在宽度方向的列数类别数由字典决定。3.2 CTC 解码不是简单 argmaxblank 机制与贪心解码推理时常见入坑是直接把模型的输出 argmax 然后认为每个时刻都对应一个字符。实际上 CTC 输出里每个时刻的 argmax 结果是带有 blank 的序列。CTC 中 blank 表示“当前时刻没有字符”贪心解码的规则是先取每个时刻概率最大的类别得到一串索引然后从左到右扫描先把相同的相邻索引合并成一个再把 blank 索引删掉。注意顺序不能反——先删 blank 再合并重复会把原本连续的两个相同字符错误地合并成一个。这一点在后面的排查章节里我会写一个具体翻车记录。贪心解码只是 CTC 解码里最朴素的一种速度快但在某些排列下不是全局最优。要求更高的场景会用 beam search保留前 K 个概率最大的候选路径最后从候选里挑出最大概率的一个。Beam size 越大结果越稳耗时也越高。在小批量离线识别场景我通常先跑贪心解码看整体准确率准确率不够再换 beam search。OCR 领域的识别评估不只看字准不准还要看编辑距离Levenshtein Distance和执行耗时。一堆文字里只错一个字的识别结果和错一半的结果在业务体感上是天壤之别所以建议评估时按整句算字错误率CER而不要按整图算是否完全正确。3.3 识别模型的推理代码与字典映射参数这里给出识别侧的最小推理脚本包含一个 CTC 标签转换器。模型结构部分用占位类代替你可以换成自己复现的 CRNN 网络。import cv2 import numpy as np import torch class CTCLabelConverter: CTC 解码器负责把模型输出的类别索引还原成可读文本 def __init__(self, chars, blank_index0): # 注意 blank 的索引必须是训练时约定的索引常见约定是 0 self.blank_index blank_index self.int2char {i: c for i, c in enumerate(chars, start1)} # 0 留给 blank def decode(self, pred_ids): out_chars [] prev self.blank_index for p in pred_ids: if p self.blank_index: prev p continue if p ! prev: out_chars.append(self.int2char[p]) prev p return .join(out_chars) def crnn_inference(crop_img, model, converter, input_h32): # 统一转灰度CRNN 常见输入是单通道 gray cv2.cvtColor(crop_img, cv2.COLOR_BGR2GRAY) h, w gray.shape[:2] scale input_h / h resized cv2.resize(gray, (int(w * scale), input_h)) # 归一化到 [0,1]增加 batch 维度和通道维度 tensor torch.from_numpy(resized).unsqueeze(0).unsqueeze(0).float() / 255.0 model.eval() with torch.no_grad(): logits model(tensor) # (1, T, num_classes) pred_ids logits.squeeze(0).argmax(dim1).tolist() return converter.decode(pred_ids)代码里的 converter 建议在模型加载后立即打印前 20 个映射关系当作一次“握手验证”。如果模型训练时用的字典是0123456789abcdef而你推理时传入的字典多了一个字符那么从那个位置开始的映射就会错位输出的文字会变成乱码这是后面排查章节的一条重点。参数方面input_h 必须和训练时一致常见值是 32有些中文模型用 48 或 64不一致会让 LSTM 拿到的序列长度和训练分布差异过大。模型输出的 T 是特征图宽度W 越大 T 越大所以输入图像宽度不能被压得太窄——单字符宽度最好不低于 8 像素否则小字会被识别成缺笔画。4. 检测与识别串联从检测框裁剪到完整 OCR 的流水线检测和识别两个部分各自跑通只能算完成一半。真把两者接起来最常见的翻车是直接在原图上按检测框的矩形范围裁剪然后丢给识别模型。如果文本框带旋转角度这种裁剪会把周围无关像素也切进去同时文字方向没有拉正识别模型的输入和它见过的训练分布完全不一致准确率掉得厉害。所以串联的关键是先讲坐标变换再讲矫正。4.1 检测框的坐标转换与透视矫正参数EAST 系列输出的框是任意四边形的四个顺时针角点但 CRNN 期望的输入是水平方向的图像。常见做法是使用透视变换把检测到的四边形映射到一个水平矩形上长边对应目标宽短边对应目标高。这样处理后文字是水平方向背景是白色或近似色识别模型的输入分布接近训练时的样本。目标尺寸很有讲究CRNN 对高度有强约束所以目标矩形的高度应该和识别模型的 input_h 保持一致宽度按检测框的宽高比计算。这样既不会拉伸变形又满足固定的输入高度。目标宽度如果超过模型可接受的范围通常的做法是拆分成多段比如每段控制在 512 像素以内分别识别后再拼接。这种切分方案在长文本场景中比强行压缩整行更可靠因为压缩过小会导致字符模糊模型无法分辨相近字符。接下来给一个透视矫正函数输入检测框四点输出矫正后的裁剪图def order_points(pts): 把四边形四点整理成 左上、右上、右下、左下 的顺序 pts np.array(pts, dtypenp.float32) s pts.sum(axis1) # 左上角 xy 最小右下角 xy 最大 diff np.diff(pts, axis1).reshape(-1) # np.diff 得到 y - x右上角 y-x 最小 return np.array([ pts[np.argmin(s)], pts[np.argmin(diff)], pts[np.argmax(s)], pts[np.argmax(diff)] ], dtypenp.float32) def rectify_crop(img, box, input_h32, max_width512): tl, tr, br, bl order_points(box) width_top np.linalg.norm(tr - tl) width_bottom np.linalg.norm(br - bl) height_left np.linalg.norm(bl - tl) height_right np.linalg.norm(br - tr) # 取平均避免单侧噪声 W int(max(width_top, width_bottom)) H int((height_left height_right) / 2) # 如果宽高比过大就限制最长宽度并等比缩高 scale 1.0 if W max_width: scale max_width / W dst np.array([[0, 0], [int(W * scale) - 1, 0], [int(W * scale) - 1, int(H * scale) - 1], [0, int(H * scale) - 1]], dtypenp.float32) M cv2.getPerspectiveTransform(np.array([tl, tr, br, bl]), dst) warped cv2.warpPerspective(img, M, (int(W * scale), int(H * scale))) return warped这段代码有几个容易被忽略的参数。max_width 是防止超长文本行被压缩到一屏内512 是一个比较保守的上限如果你的数据里长文本多在识别模型支持的情况下调到 768。scale 是等比缩小时的倍数不能单边暴力压缩否则字形横向被压扁字符间粘连识别模型很容易把m识别成n这类形状敏感的字。透视变换的映射关系是dst M * src传入的 pts 顺序必须一一对应排序函数一旦出错整个图会被左右颠倒或上下颠倒。4.2 一条龙推理脚本检测 → 矫正 → 识别把前两章的代码拼起来加上对单张图上多个文本框的循环处理就是一条完整推理链路。我一般会设定一个最小可运行脚本能对一张图输出所有检测框坐标和对应文本先拿到基线效果再逐步调参。下面这个脚本把检测、矫正、识别串起来了def ocr_pipeline(img_path, det_model, rec_model, converter): img cv2.imread(img_path) raw img.copy() boxes east_inference(img_path, det_model) # 复用第 2 章的函数 texts [] for i, pts in enumerate(boxes): # 先做透视矫正再做识别 crop rectify_crop(raw, pts, input_h32) text crnn_inference(crop, rec_model, converter) texts.append(text) # 在原图上画出四边形框方便肉眼判断框位置 cv2.polylines(raw, [pts.astype(np.int32)], True, (0, 255, 0), 2) return boxes, texts, raw脚本核心就三步检测拿框、矫正、识别。注意把 raw 原图单独存一份避免 cv2 的裁剪操作影响原图坐标。输出时把绿色框画回原图对排查“检测框位置不对但识别结果还行”这类错误很有效因为很多时候模型没看错是框偏了或框小了直接看可视化比看坐标数字直观得多。实际项目里我会把east_inference改造成接收图片数组省掉一次重复读图。4.3 用 PIL / OpenCV 组织输入的细节通道顺序、缩放、归一化这一节处理的是“怎么把图像喂给模型才不出偏差”。OpenCV 读进来的是 BGR而 PyTorch 训练时通常按 RGB 做归一化所以cv2.imread后要对通道做转换。更隐蔽的是缩放方式检测模型建议用cv2.INTER_LINEAR或INTER_AREA识别模型由于字符边缘对形变敏感我一般用INTER_CUBIC它在放大时对边缘的还原更好。归一化的均值方差要和训练时保持一致。很多公开训练代码用的是mean[0.485,0.456,0.406]、std[0.229,0.224,0.225]如果推理代码直接用除 255会把亮度和对比度改变推理效果会明显变差。一个简单的检查技巧在模型加载后随机找一张训练图做一次前向对比训练时的损失或者干脆在推理前把输入图像用cv2.imwrite导出一份肉眼确认预处理后的样子是否正常。这招虽然土但能快速排除很多黑匣子问题。5. 常见问题排查这 5 个坑我踩了一遍又一遍两阶段 OCR 的好处是每个阶段都能单独测试坏处是问题经常发生在两个阶段的连接处。下面按检测、识别、串联三个环节列几条高频踩坑记录每一条都是实际项目里反复遇到过的每条按“现象 → 原因 → 解决”展开。5.1 检测端检测框贴着文字边缘识别反而丢字现象检测框肉眼看起来很准紧贴文字边缘但把裁剪图送进 CRNN 后句子两端的字总是识别错或直接丢字。原因很反直觉识别模型在训练时样本区域四周通常会留白模型实际上学到了字符周围的上下文检测框太过贴合等于把这个上下文裁掉了CRNN 看到的字符排列和训练分布不一致两端字符尤其容易受影响。解决在矫正后的 crop 四周填充白边或者对检测框坐标做膨胀常见是向外扩 2~4 像素。具体做法是在后处理循环里把框坐标按中心放大后再矫正注意扩展别超过 8 像素否则相邻文本行会被带进来反而引入噪声。这个“把框故意放宽一点”的操作是检测和识别之间最值得先试的调参点。5.2 识别端CTC 解码输出连续重复字符现象识别结果出现“好好好好好”这类连续重复或者“你好”变成“你好好好”。原因基本在解码逻辑写错最常见的是先删 blank 再合并相邻重复或者干脆忘了合并重复只做了 argmax 加删 blank。CTC 的约束是同一个字符连续出现时中间必须插入一个 blank 才能区分“两个相同字符”和“一个字符重复两次”所以合并重复和删除 blank 的顺序非常关键。解决严格按照第 3.3 节的解码顺序用一个人为构造的序列验证解码器。比如给pred_ids[0, 5, 5, 0, 6, 6, 0]期望输出是[5, 6]对应的两个字符如果输出变成了[5, 6]之外的组合说明合并逻辑有 bug。先验证解码器再怀疑模型这是定位问题的最快路径。5.3 识别端字典映射错位导致识别乱码现象识别结果里前几个字符是对的后面全错或者所有字符都变成另一个相近字的重复。例如数字识别结果里前几位对从某一位开始全乱。原因几乎可以肯定是推理时的字典和训练时的字典不一致模型训练时类别索引从 1 排到 N推理时你在chars列表里多传或少传了一个字符导致从那个位置开始全部错位。解决打印converter.int2char的前 20 项用训练时导出的 charset.txt 逐项比对。我在一个项目里遇到过把英文字符集abcdefghijklmnopqrstuvwxyz误写成少了i结果第 9 个字符之后全部错位打印映射表后一分钟就定位了。这个验证动作应该写进加载模型的公共函数里每次加载权重都自动打印一次花不了几毫秒但效果极好。5.4 串联端透视矫正把文字转成了九十度现象检测框已经框对位置但矫正出来的 crop 图被旋转了 90 度或左右镜像识别结果变成一堆无意义字符。原因基本都在角点排序函数上。第 4.1 节写的order_points依赖“文本框是凸四边形”的假设但实际检测框可能严重倾斜甚至呈梯形简单的 sum/diff 排序在部分几何形状下会把左上和右上搞混。解决先对四点计算凸包再按边的长度比值判断长边方向最后确定左右顺序。最直观的验证方法是把矫正后的 crop 保存下来人眼看一轮比任何打印坐标都快。我现在的习惯是每次矫正后都顺手cv2.imwrite(debug_crop.jpg, crop)在调试阶段这种“土办法”远比日志可靠。5.5 串联端BGR 与 RGB 混用导致的颜色和识别双重翻车现象可视化时框的位置很准但矫正后的裁剪图颜色偏怪识别结果也不堪入目。原因通常是 OpenCV 读进来是 BGR检测模型或者识别模型在训练时用的是 RGB通道没有转就送进去了。对于检测模型这会影响 score map 的质量对于三通道输入的识别模型颜色通道错乱等于把训练分布彻底打乱。解决在入口统一转 RGB后面所有环节都用 RGB避免在串联链路里出现颜色双重转换。如果识别模型是单通道灰度输入则只转灰度不受 BGR/RGB 影响。我的习惯是写一个read_image(path, modergb)的公共函数把通道转换收口到一个地方后续所有模块都从这个函数取图从根本上杜绝混用。6. 进阶用自己的数据微调检测和识别模型时先动这几个参数到这一步你已经能用现成权重跑通整条 OCR 流水线。接下来如果要投入实际业务比如识别自家票据上的印刷体、门店招牌上的繁体字几乎都要做微调。下面不展开完整训练流程只讲最值得先动的几个参数和验证技巧。先放结论检测模型微调时不要一上来就全量训练。常见做法是冻结 backbone 前几层只训练特征融合层和输出层学习率从 1e-4 起步。冻结前几层可以让模型保留通用的边缘、纹理特征避免因为新的数据分布导致底层特征被冲掉。如果新数据和公开数据集差异很大比如全是二值化的黑白文档图再逐步解冻更多层每次解冻后学习率减半。AdvancedEAST 的损失里长短边权重在长文本多的业务里要把长边项的权重从默认的 1.0 提到 1.5~2.0否则断框问题会伴随整个训练周期。识别模型微调的关键在“字典”和“输入高度”这两个参数。新业务里如果有生僻字或特殊符号先把这些字符加进字典重新生成映射文件字典一变模型输出层的类别数就变了原来预训练权重里最后一层不能加载需要随机初始化再训练。此时学习率要降得更低在 1e-5 到 5e-5 之间否则输出层还没收敛就震荡。输入高度建议与预训练保持一致不要从 32 改成 48除非你同时做了足够多的重采样增强否则特征提取结构需要调整工作量和收益不成比例。验证阶段最该做的不是只看准确率而是拉一张“失败图集”出来。把推理失败的样本分类是检测框偏了、矫正后文字倾斜还是识别字典外字符。这项分类工作能很快定位到瓶颈在检测还是识别。比如误识别的字集中在相近字形王/玉、己/已里说明识别精度不够回去调解码或数据增强如果整块区域根本没被框出来改检测阈值比调整识别网络有效得多。这也是我反复和同事说的一句话两阶段流水线的优化先分模块评估再做整体调参你才能拿到确定的收益。这套方案能不能投入生产最终取决于三个指标单张耗时、字错误率、失败样本占比。用你自己的一批真实图去测这三项比任何公开数据集上的数字都可信。我早期的教训是只盯着准确率不放结果耗时和失败样本拖垮了上线计划现在的习惯是先定好评估脚本再动模型参数。希望帮到你。本文还有配套的精品资源点击获取
返回列表