ARTICLE DETAIL

资讯详情

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

Python搭建OCR流水线:EAST文本检测与CRNN+CTC识别

Python搭建OCR流水线:EAST文本检测与CRNN+CTC识别 简介基于Python与Keras/TensorFlow实现自然场景图像文字检测与识别涵盖EAST/AdvancedEAST文字检测模型与CRNNCTC文字识别模型适合作为毕业设计、课程设计或入门进阶学习项目。资源压缩包共32个文件主要包括19个Python源码文件、8张示例图片、3份说明文档和2个环境配置文件整体体积约957KB。目前已有164人学习下载便于快速上手。EAST模型支持90°至-90°任意角度文字检测可定位中英文、数字及符号并返回文本框坐标CRNN模型结合CTC算法实现不定长文字识别。通过内置预测脚本可直接体验检测识别流程也可参考模块划分进行二次开发与功能扩展。1. 这个标题拆开来是一个完整的 OCR 流水线很多人搜这个标题是想把图片里的文字“抠”出来批量读发票、截图识别、商品图描述提取、文档电子化。这里有个必须一开始就说清的反直觉结论——检测和识别是两个模型不是一个。标题前半句的 EAST/AdvancedEAST 干的是“文字在哪”输出四个角点围成的四边形后半句 CRNNCTC 干的是“这行字是什么”把裁剪出来的文本行翻译成字符串。python 的价值在于你不用碰底层 CUDA 和 C 编译pip 装完就能跑通这条路。新手如果嫌麻烦直接用 RapidOCR 之类的封装又会觉得 CPU 占用高得离谱因为每个环节都被黑匣子包住了。自己用 python 把检测和识别分别搭起来等于把每一步的输入输出都捏在自己手里。这套组合适合做自动化录入、影像归档、OCR 平台预研的工程师也适合想真正搞清楚 OCR 内部流程、而不是只调接口的 python 使用者。2. 先把实验环境钉死python 版本、TensorFlow 与 numpy 的兼容组合做这个方向环境配置踩坑的概率比模型训练本身还高。问题多数出在 TensorFlow 和 numpy 的版本锁死以及 OpenCV 在 python 里的安装方式。我建议直接用 conda 建一个独立环境避免污染你平时做数据分析、写爬虫用的 python。2.1 为什么我锁 python 3.8 和 TensorFlow 2.10EAST 和 AdvancedEAST 的开源实现大多是两三年前写出来的很多依赖 TensorFlow 2.x 的中间 API。TensorFlow 从 2.11 开始Windows 分支就不再提供官方 GPU 支持而更早的 2.5~2.10 对 numpy 又有硬性约束——装错 numpy 版本import tensorflow 直接报错。我的习惯组合是python 3.8 tensorflow 2.10.0 numpy 1.23.5。python 3.10 以上不是不行但 CRNN 训练时常用的某些旧版数据加载代码会踩collections的坑没必要在环境上给自己加难度。# 创建独立环境避免污染系统 python conda create -n ocr_env python3.8 -y conda activate ocr_env # 先装 numpy再装 TensorFlow顺序不要反 pip install numpy1.23.5 pip install tensorflow2.10.0 # 图像处理与几何计算依赖 pip install opencv-python4.6.0.66 pip install shapely imutils scipy代码是三条命令串起来的一条链路。先固定 numpy 是因为 TensorFlow 2.10 在 pip 解析时经常会把 numpy 升到 1.24 以上而那个版本在导入时就会触发np.object不存在之类的兼容报错。shapely是用来算检测框 IoU 和几何变换的imutils提供一些图像缩放和旋转的工具函数scipy在 AdvancedEAST 的标签生成里也会用到。装完后用python -c import tensorflow as tf; print(tf.__version__)验证能打印出版本号再继续。2.2 为什么不是 CPU 就能跑训练矩阵和显存估算很多人误以为 OCR 检测模型很小CPU 随便跑。实际上 EAST 的 backbone 用了类 ResNet 结构一张 512x512 的图batch size 设为 8显存占用大约 6~8 GB。AdvancedEAST 因为多了一层逐行递推的特征计算训练时显存只会更高。我的建议是训练用 GPU哪怕 GTX 1080Ti 都行推理阶段再迁到 CPU。推理时 OpenCV 的图像缩放和 nms 都不吃显存CRNN 前向一次也就几十毫秒只是别在推理机上装 TensorFlow-GPU 版本CPU 版反而省心。2.3 pip 换源与 opencv 安装的一个细节# 如果你的网络环境访问国外源较慢换国内镜像源 pip config set global.index-url https://pypi.tuna.tsinghua.edu.cn/simple # 安装 opencv 不带 extra 版避免 GUI 依赖 pip install opencv-python --no-binary opencv-pythonopencv-python默认的 wheel 只包含核心模块够用。这里要注意一个容易翻车的地方很多人图省事装opencv-contrib-python这个包体积大一倍而且会和opencv-python冲突导致cv2.imread读图时出现奇怪的通道顺序问题。我一般只装opencv-python需要createTrackbar这类扩展功能时再单独装opencv-contrib-python且要先卸载另一个。--no-binary那个参数不是必选项只有当你发现 opencv 的 wheel 在当前平台不兼容时才用它会触发本地编译耗时较长但能解决一些 ARM 平台的玄学问题。3. 图像文字检测EAST 和 AdvancedEAST 差在哪以及怎么准备数据训练检测模型的任务是把图片里的文字区域框出来但框不是正矩形而是带角度的四边形。EAST 在 2017 年提出时打出的招牌是“端到端、无需候选框”把文字检测简化成两个任务像素级分类这个点是不是文字中心区域和像素级回归这个点到上下左右四个边界距离。AdvancedEAST 是后来为缓解它两个明显短板做的改进小文字容易漏检、长文本容易被切断。3.1 EAST 的 pipeline 和 AdvancedEAST 的改动点EAST 的网络骨架是一个特征提取网络常见实现里用 PVANet 或 ResNet50从四个不同尺度的 feature map 做上采样拼接得到一个融合特征层。它输出两个头一个头是 score map每个像素的得分表示“该像素属于文字区域中心”的概率另一个头是 geo map每个像素回归出四个距离从而推算出四边形角点。后处理用一个叫 locality-aware NMS 的办法合并重叠框。这个设计的优点是干净缺点是回归目标直接定死为“像素到四边的距离”遇到特别长的文本行小感受野撑不住模型会把一行字切成一截一截。AdvancedEAST 的关键改动是把“回归距离”换成了“相对下一行特征点的偏移”。具体做法是对每一行文字由一个特征点开始逐点向外递推计算相对坐标生成一个层次化结构。好处是两个第一长文本行只要首点定位准后续点按顺序推出来不会因为感受野不够而断掉第二小号文字的召回明显提高因为回归目标从“绝对距离”变成“相对偏移”对尺度变化更钝感。如果你的场景是自然场景图片、小字密集、或者文字行偏长我建议直接上 AdvancedEAST如果只是扫描文档、印刷体截图原始 EAST 反而更好训模型更小后处理也更简单。3.2 把 ICDAR 标注转成训练格式解析脚本与四个边界坑公开数据集里最常见的标注格式是 ICDAR 2015 的x1,y1,x2,y2,x3,y3,x4,y4,text每行一个四边形。几乎所有人第一个标注解析脚本都是在这里翻车的因为它有三个陷阱。第一文本框坐标不是按左上、右上、右下、左下顺序给出的有的标注是顺时针有的是从任意顶点开始第二文本内容里可能包含逗号直接按逗号 split 会把文本截断第三标注为###的表示“忽略区域”训练时必须跳过否则模型会学着预测这一坨东西。下面是我常用的解析函数。import os def parse_icdar15_gt(gt_path): boxes, texts [], [] with open(gt_path, r, encodingutf-8-sig) as f: for line in f: parts line.strip().split(,) if len(parts) 9: continue # 空行、损坏行直接跳过 try: # 前 8 个值是四个点的 x,y 坐标 pts [float(x) for x in parts[:8]] except ValueError: continue # 文本内容可能含逗号用 join 恢复完整文本 text ,.join(parts[8:]).strip() if text ###: continue # ### 表示该区域忽略 if len(pts) ! 8: continue boxes.append(pts) texts.append(text) return boxes, texts # 用法解析一张图对应的标注文件 boxes, texts parse_icdar15_gt(gt_img_1.txt)这段代码的逻辑分三层。第一层是防御性解析标注文件常伴随空行或换行符问题encodeingutf-8-sig是为了吃掉 BOM 头否则第一行的第一点坐标会带\ufeff前缀强转 float 会报错。第二层是坐标提取第 8 个元素之后的部分是文本不能 split。第三层是忽略区过滤###区域在 ICDAR 评测里非但不加分还会干扰训练丢弃最安全。说完这个解析函数还没到能训练的程度——训练还需要一个数据生成器把四边形的多边形 mask 画到 score map 上并生成 geo map 回归目标。不同开源实现的生成器写法差异很大但解析标注这一步是通用的。3.3 训练参数学习率、输入尺寸和 loss 的推荐组合训练 EAST 时输入图不是直接喂原图而是先做长边缩放到 512 或 640再随机裁剪成固定尺寸。常用训练参数我整理在下面这张表里这是我在两套开源代码上调过的比较稳的组合。参数FAST 推荐值AdvancedEAST 推荐值说明输入尺寸512x512640x640尺寸越大小字召回越好显存也越高batch size84~6显存不够就减 batch别减输入尺寸初始学习率1e-31e-3Adam 优化器训练中段降到 1e-4loss 权重score: 1.0, geo: 1.0score: 1.0, geo: 1.0AdvancedEAST 多了行递推 loss权重约 0.1训练轮次30~4040~50每 5 轮跑一次验证集看 fscoreEAST 的 loss 是两部分相加score map 用 dice loss比交叉熵更抗正负样本不平衡geo map 用 IoU loss。AdvancedEAST 在此基础上多了一个逐行结构的损失项占总 loss 的比重保持在 0.1 左右太大会让模型只顾着学行的相对位置而忽略单字结构。训练时监控 fscore 比监控 loss 值更有意义——loss 降到 0.01 不代表检测框准fscore 是 precision 和 recall 的调和平均能直观反映“模型有没有把文字区域完整框出来”。训练结束后导出的模型推理阶段会发现它输出的是一堆候选四边形和对应 score还需要 NMS 把重叠的框合并。这个合并的阈值EAST 系列一般设在 0.2~0.5 之间阈值调低会漏框调高会叠框后面组装 pipeline 时还要再试。4. 文字识别CRNN 加上 CTC让网络自己学会对对齐检测完拿到四边形第二步就是把四边形里的内容读出来。这个任务听起来简单——图像分类而已——但难点在于一行字的长度不固定每个字在图像里的宽度也不固定你怎么告诉模型“这个特征对应的是哪个字”CRNNCTC 这套组合的聪明之处是不强迫模型做逐字切分而是让模型输出一个“按时间步排列的字符概率序列”再用 CTC 解码在概率序列里找到最可能的文本行。4.1 CTC 到底在解决什么问题把“对齐”变成“排列”如果用普通的分类网络做文字识别你得先把文字切成单字图片再逐个分类切字的错误会像雪崩一样传导。CRNN 的做法是把图像缩放成高度固定 32、宽度自适应的一张长条图送入卷积网络提取特征然后把特征序列展平成时间步接一层双向 LSTM 后每个时间步输出一个字符概率分布。问题来了模型并不知道每个时间步对应的是哪个字符也没有逐字标注来训练。CTC 就是解决这里它允许模型在任意位置输出“空白”blank并在训练时穷举所有可能的时间步到标签序列的对齐路径把所有正确路径的概率求和作为损失。推理时CTC 解码会把重复字符折叠比如“hello”预测成“hheelllloo”greedy 解码时把连续重复的折叠再删掉 blank就得到“hello”。4.2 字符表、图像预处理和训练数据生成器CRNN 训练前要定义一份字符表也就是所有可能出现的字符集合。中文识别常见做法是收集常用汉字加上标点3000 到 6000 字都很常见字符表大小直接影响最后一个全连接层的参数量。字符表里必须预留两个特殊位置blank 和 unknown。blank 放在索引 0 在 CTC 里有特殊意义它会参与对齐路径的计算不能随意丢弃。import numpy as np import cv2 class CRNNDataset: def __init__(self, image_paths, labels, char_list, max_width320): self.image_paths image_paths self.labels labels # char_list 的第一个元素强制为 blank字符从索引 1 开始 self.char2idx {c: i 1 for i, c in enumerate(char_list)} self.max_width max_width def __getitem__(self, idx): img cv2.imread(self.image_paths[idx], cv2.IMREAD_GRAYSCALE) # 统一高度为 32宽度按比例缩放但不超过 max_width h, w img.shape[:2] scale 32 / h new_w min(int(w * scale), self.max_width) img cv2.resize(img, (new_w, 32), interpolationcv2.INTER_LINEAR) # 宽度不足 max_width 的右侧补白边注意是 255 不是 0 padded np.full((32, self.max_width), 255, dtypenp.float32) / 255.0 padded[:, :new_w] img / 255.0 # label 转成索引序列 label_idx [self.char2idx.get(c, self.char2idx[unk]) for c in self.labels[idx]] return padded, np.array(label_idx, dtypenp.int32), len(label_idx) def __len__(self): return len(self.image_paths)这里有一个非常容易踩的坑padded初始值用的是 255 再除以 255也就是白色。识别模型输入的图像文字是黑、背景通常是白如果补边用 0黑色模型会学到“右边界有一道黑条”遇到真正文字靠右时就会误判。这个细节我在第一次训练时没注意导致验证集上凡是有右边白边的图识别结果末尾总多一个“假字符”。另一个容易忽视的是宽度CRNN 的 BiLSTM 对宽高比敏感超过 320 像素宽的文本行会被压得很扁字与字粘连在一起解码全是乱码后面推理组装时我会再讲怎么切长行。max_width320是个经验值它对应大约 8~12 个中文或 20 个英文。如果你的业务里长文本行很多可以把max_width提到 480但相应也要把 LSTM 的隐藏层层数或单元数增大一些否则记忆容量不够长序列的后半段会丢失。4.3 训练参数学习率、batch 和 eval 的正确姿势CRNN 的优化器我通常用 Adadelta初始学习率 1.0跑起来非常稳基本不需要学习率衰减。也可以用 Adam初始学习率 1e-3配合每 10 个 epoch 衰减 0.1。batch size 受限于输入宽度如果max_width320一张图就是 32x320 的矩阵batch 设 16 时显存大约 4~6 GB。训练轮次建议 30 左右。要注意的是CRNN 的 lossCTC loss值本身并不直观它随字符表大小变化很大6000 字的表 loss 初始在 60 左右降到 0.5 以下基本就能用了。验证得不看 loss要看解码之后的字符串准确率也就是逐字符比对编辑距离。greedy 解码可以满足大多数场景beam search 能把准确率再提一到两个百分点代价是推理耗时翻一倍。我先用 greedy 验证模型是否收敛最后上线前再决定要不要切 beam。5. 把检测和识别串成一条 OCR 管线裁剪对齐与坐标排序的细节两个模型单独训练好之后真正让它们工作起来的是组装这一层。检测模型输出的是一个四边形四个角点而识别模型期望的输入是一张高度 32、背景干净的文本行图。中间要做两件事把四边形里的内容抠出来再矫正成水平方向。5.1 四边形角点排序这一步错了识别全是镜像EAST 输出的四个点顺序不是固定的有时顺时针、有时逆时针、有时起点在右下角。如果你不排序直接做透视变换裁剪出来的图可能是旋转 90 度甚至镜像的。我常用下面这个基于轴对称的排序函数。import numpy as np def order_points(pts): # 输入shape (4, 2) 的四边形坐标 # 输出按左上、右上、右下、左下排列的坐标 rect np.zeros((4, 2), dtypefloat32) # 求和最小的点是左上角最大的点是右下角 s pts.sum(axis1) rect[0] pts[np.argmin(s)] rect[2] pts[np.argmax(s)] # 求差最小的x-y 最小是右上角最大的x-y 最大是左下角 diff np.diff(pts, axis1).reshape(-1) rect[1] pts[np.argmin(diff)] rect[3] pts[np.argmax(diff)] return rect这个函数的依据是在标准的“左上、右上、右下、左下”四边形里四个顶点的 xy 值中左上最小、右下最大x-y 值中右上最小、左下最大。它假设文本框是个凸四边形且不会发生严重畸变。实际场景中如果检测框特别弯曲比如弧形文字这个函数会失效输出明显错乱的排列表现为识别结果是一串毫无规律的乱码。到时候不用怀疑模型先去检查四边形排序结果可视化画一遍基本一眼就能看出来。注意以上顺序假设你读到的检测框顺序是随机的。但如果你的检测模型后处理里已经做了“按角度归一化”那输出可能已经是固定顺序这时再排序反而会打乱顺序。经验是跑一两个样本可视化确认。5.2 裁剪文本行并送入识别模型完整推理代码import cv2 import numpy as np def crop_text_line(img, quad, height32): # quad: (4, 2) 已排序的角点 # 透视变换到高度固定 height 的矩形 (tl, tr, br, bl) quad width_top np.linalg.norm(tr - tl) width_bottom np.linalg.norm(br - bl) width max(int(width_top), int(width_bottom)) dst np.array([ [0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypefloat32) M cv2.getPerspectiveTransform(quad, dst) warped cv2.warpPerspective(img, M, (width, height)) return warped # 推理主流程 def ocr_pipeline(img, det_model, rec_model, char_list): # 1. 检测输入原图输出若干四边形和置信度 quads, scores det_model.predict(img) results [] for pts, score in zip(quads, scores): if score 0.6: # 置信度过滤去低质量检测 continue pts order_points(pts) # 2. 裁剪四边形矫正成水平文本行 text_img crop_text_line(img, pts, height32) # 3. 识别宽度同步缩放到 32x-再喂 CRNN text_img cv2.resize(text_img, (min(text_img.shape[1], 320), 32)) text_img text_img.astype(np.float32) / 255.0 # 4. CTC greedy 解码argmax 每个时间步再折叠重复和 blank pred rec_model.predict(text_img[np.newaxis, ...]) pred_idx np.argmax(pred, axis-1)[0] # (T,) # 折叠连续重复字符 collapsed [] prev -1 for idx in pred_idx: if idx 0: # 0 是 blank prev -1 continue if idx prev: continue collapsed.append(idx) prev idx text .join([char_list[i - 1] for i in collapsed]) results.append((pts.tolist(), text, float(score))) return results这段代码的每一块都有对应作用。检测部分先过滤置信度低于 0.6 的框能有效减少把阴影、桌面纹理误判成文字导致的虚警注意阈值别调太高0.8 以上会把模糊的小字全部丢掉。order_points确保裁剪前角点顺序正确。透视变换的目标矩形宽度取了上下边的最大值避免因透视导致的文字宽度变形。识别部分缩放到 320 以内防止长行被压缩。CTC greedy 解码时先对每个时间步取最大概率索引再折叠连续重复、跳 blank。这里的char_list[i - 1]是因为训练时字符索引从 1 开始0 留给 blank所以解码出的索引要减一才能真正映射回字符表。5.3 CPU 推理的耗时敏感点在 CPU 上跑这条管线最耗时的反而不是 CRNN而是 EAST 的前向和图像缩放。TensorFlow 在 CPU 上跑 640x640 的推理一次大约 300 到 500 毫秒CRNN 一次前向大约 30 到 80 毫秒。如果你用 RapidOCR 觉得 CPU 被吃满多半是因为它默认开启多线程。自己搭管线时可以显式设置tf.config.threading.set_intra_op_parallelism_threads(4)限制 TensorFlow 占用的线程数。也可以在检测前先等比缩到短边 480能显著降耗时代价是漏掉一些小字——这个平衡只能根据你的实际图片分辨率和文字密度来定没有统一答案。6. 踩坑与验证训练到部署路上 5 个必排查的问题和一条验证路径把检测和识别分别训练好、再组装起来这个过程中我经历过的问题基本上集中在五个地方。下面按“现象 - 原因 - 解决”的方式列出来每一条都值得在你自己的项目里提前排查。坑 1import tensorflow 报np.object不存在现象环境装好后import tensorflow 直接抛 AttributeError。原因TensorFlow 2.10 的部分导出代码引用了np.object而 numpy 1.24 开始移除了这个别名。解决固定 numpy 1.23.5不要让它被 pip 顺手升级。检查方法是pip list | grep numpy。坑 2cv2.imread 读不了中文路径的图片现象训练数据里有中文目录或中文文件名时图片读出来是 None不报错。原因OpenCV 的 C 底层在 Windows 上不支持非 ANSI 路径。解决不要改文件名改用cv2.imdecode(np.fromfile(img_path, dtypenp.uint8), cv2.IMREAD_COLOR)读取这是最省事的绕过方案。坑 3裁剪出来的文本行是颠倒或侧转的现象识别结果整体是字母或汉字翻转或者变成竖排。原因四边形排序函数在检测框极细长或接近垂直时失效。解决排序前先计算所有边向量以最长边方向为主轴决定旋转角度或者在上一步可视化每个检测框的角点把顺序错误的样本收集起来看规律。自己写排序函数时一定要先画图验证十张。坑 4CTC 训练 loss 不降现象训练了十几个 epochloss 不降反升解码结果全是空字符串。原因一字符表里没有把 blank 放在索引 0导致 CTC 对齐路径全部无效原因二输入图像预处理把 255 和 0 弄反模型始终看到的是黑底白字的“反色图”。解决对照训练集中的图像是否一切正常地显示为白底黑字然后检查 char2idx 映射再把 one-hot 标签的维度对齐字符表长度。坑 5识别准确率很高但输出总是多一个空格或引号现象单字准确率 95%但字符串级准确率只有 80%。原因长文本行、以及中文引号和英文引号在字符表里被合并成了同一个索引。解决字符表保留中文和英文左右引号的不同字符不要粗暴去重如果业务场景里这类符号多字符表宁可大一些也别节省这几百个索引。验证路径这部分我习惯的做法是从测试集里随机挑 20 张图先用检测模型画出所有四边形和置信度人工确认“框有没有打对位置”再把每个裁剪图保存成文件和识别结果并列排成一张对比图。这一步能快速区分错误来自检测还是识别。如果用 20 张图人工看没问题再上自动指标检测端看 fscore识别端看编辑距离准确率。两个模型如果单独指标都好合起来却不行问题往往在中间裁剪那一步回到坑 3 去查。最后说个习惯我每次改完数据预处理或后处理代码都会重新跑一遍这 20 张图的对比而不是只看 loss 曲线。OCR 这条线的很多 bug 都不在模型参数里而在图像坐标和格式的转换里——这些地方出了问题文档上什么都不显示。希望帮到你。本文还有配套的精品资源点击获取
返回列表