简介:自然场景OCR项目整合了YOLOv3、CTPN与CRNN三个模型,是一套面向目标检测与文字识别学习者的完整可运行方案,解决图片中不规则文本的定位与识别问题,可迁移至车牌识别、文档数字化、广告牌文字提取等实际场景。压缩包共366个文件,容量约51.38MB,内含129个jpg图像样本、100个xml标注文件、43个py源码、8个ipynb分析笔记,以及配置文件、脚本等辅助材料,便于直接开展训练与二次开发。目前已有4669人学习下载,具备较好的社区参考价值。代码注释详尽,既说明YOLOv3如何快速锁定文字区域、CTPN如何切分完整文本行、CRNN如何识别字符序列,也展示三者串联的工程实现细节。开发者通过阅读和运行,可深入理解自然场景OCR全流程,掌握数据集构造、模型部署与调参的实操经验,适合课程设计、项目实战或算法研究使用。
1. 自然场景OCR:YOLOv3+CTPN+CRNN这套组合,为什么能打
自然场景OCR和拍照扫描件识别是两码事。扫描件背景干净、字体规整,传统OCR引擎就能应付;但路边招牌、收据小票、快递单上的字,背景是水泥墙、塑料包装、褶皱纸面,光照忽明忽暗,字体歪歪扭扭,有时候还有透视变形。这套项目把目标检测、文本定位、序列识别三段管线串起来,用YOLOv3做候选区域粗筛,CTPN把粗筛区域精确切成文本行,最后CRNN把文本行序列识别成文字。做过实际项目的人会告诉你,检测文本行位置比识别文字本身更能决定OCR的最终效果——文本行切歪了,后面识别率断崖式下跌。这套方案正是围绕这条主线设计的,适合想用pytorch在真实场景里做OCR落地的工程师,也适合拿开源模型做二次开发的研究者。下面按管线顺序讲清楚原理、参数和坑。
2. 模型选型:为什么是YOLOv3、CTPN、CRNN三件套,而不是一个端到端模型
2.1 三段式架构的设计逻辑
自然场景OCR最大的难点是“不知道字在哪”。传统OCR先做二值化再找连通域,遇到复杂背景直接失效。端到端OCR模型把检测和识别揉在一个网络里,训练成本高,且检测和识别的损失函数互相干扰,实际项目里很难调稳。用YOLOv3做第一级粗筛,是因为它的召回率高,速度快,能快速把包含文字的区域从整张图里框出来。CTPN做第二级细定位,它专门为文本行设计,通过等宽锚框逐步滑过文本区域,把一个个小框横向连接成完整的文本行。CRNN做最后一级识别,它接受固定高度的文本行图像,输出变长字符串。
三段式的好处是每一级可以独立训练、独立替换。YOLOv3检测不准就加数据或换backbone;CTPN漏检就调锚框参数;CRNN识别错字就扩充字典或加训练数据。生产环境里这样的结构便于排障——识别结果错了,能明确知道是哪一级出的问题。
2.2 各模型输入输出与衔接参数
| 模型 | 输入 | 输出 | 关键衔接参数 |
|---|---|---|---|
| YOLOv3 | 416×416整图 | 检测框坐标+类别置信度 | conf_thres=0.25,iou_thres=0.45 |
| CTPN | 裁剪后的候选区域 | 文本行坐标(带高度) | 锚框高度数组[11,16,23,33,48,68,97,139,198,283] |
| CRNN | 文本行图片(高度统一为32) | 字符串序列 | 字典路径、CTC解码参数 |
实际管线中,YOLOv3输出的检测框通常比文字区域大一圈,直接送进CTPN没问题,但框太大会引入背景噪声。我一般会把YOLOv3输出的框做一次膨胀处理,宽高各扩大10%到15%,给CTPN留出锚框滑动的余量,然后按坐标裁剪原图,缩放后送进CTPN。
CTPN输出的文本行坐标是精细的,但多个相邻文本行之间可能重叠或首尾错位。常见做法是用NMS合并重叠框,再按y坐标和倾斜角度做一次聚合。最后把文本行图片统一缩放到高度32的灰度图,送进CRNN识别。
3. YOLOv3文本区域检测:从标注到训练,复现一份能用的检测器
3.1 数据集转换与标注格式准备
YOLOv3训练需要VOC格式或YOLO格式的标注。网上能找到的公开数据集大多是ICDAR格式,每个图像对应一个gt.txt文本文件,每行四个坐标点。先把ICDAR格式转成VOC XML,再转成YOLO的txt格式。下面是一个转换脚本,按左上、右上、右下、左下的坐标顺序解析。
import os import cv2 import numpy as np from xml.etree import ElementTree as ET def icdar_to_voc(gt_path, img_path, xml_save_path): img = cv2.imread(img_path) h, w = img.shape[:2] annotation = ET.Element('annotation') size = ET.SubElement(annotation, 'size') ET.SubElement(size, 'width').text = str(w) ET.SubElement(size, 'height').text = str(h) ET.SubElement(size, 'depth').text = str(3) with open(gt_path, 'r', encoding='utf-8') as f: for line in f.readlines(): parts = line.strip().split(',') if len(parts) < 9: continue x1, y1, x2, y2, x3, y3, x4, y4 = map(float, parts[:8]) xmin = max(0, min(x1, x2, x3, x4)) ymin = max(0, min(y1, y2, y3, y4)) xmax = min(w, max(x1, x2, x3, x4)) ymax = min(h, max(y1, y2, y3, y4)) obj = ET.SubElement(annotation, 'object') ET.SubElement(obj, 'name').text = 'text' ET.SubElement(obj, 'difficult').text = '0' bndbox = ET.SubElement(obj, 'bndbox') ET.SubElement(bndbox, 'xmin').text = str(int(xmin)) ET.SubElement(bndbox, 'ymin').text = str(int(ymin)) ET.SubElement(bndbox, 'xmax').text = str(int(xmax)) ET.SubElement(bndbox, 'ymax').text = str(int(ymax)) tree = ET.ElementTree(annotation) tree.write(xml_save_path, encoding='utf-8', xml_declaration=True)注意gt.txt里每行前八个数字是四边形四个顶点坐标,第九个是文本内容或标签。转换时取外接矩形作为YOLOv3的标注框,对水平文字足够用,倾斜角度大的文本会有冗余,这个冗余交给CTPN去精修。
3.2 训练参数与数据增强
完成标注转换后,训练配置文件里的核心参数如下:
# yolo3.cfg 关键参数修改 batch=16 subdivisions=4 width=416 height=416 classes=1 # 每个anchor预测的bbox数,单类场景保持默认3训练过程用ImageNet预训练权重做迁移学习,冻结前layer后先跑50轮,再解冻全部层微调40轮。初始学习率0.001,每20轮衰减到十分之一。数据增强用random_perspective、random_hsv、mosaic,尤其是mosaic增强,能在小目标检测上明显提升召回率。文本目标在整图里通常占比偏小,mosaic把四张图拼成一张,变相增加了小目标的数量。
YOLOv3在自然场景文本检测中的定位是快速召回,不需要它输出精细边界。一张图里十几个文本区域,YOLOv3只要能框出来七八成,剩下的交给CTPN也不迟。如果召回率偏低,优先检查标注框是否覆盖了小字区域,其次是调低置信度阈值到0.2看看效果。
4. CTPN文本行检测:锚框参数、LSTM时序建模和文本行合并
4.1 锚框设计与垂直回归
CTPN的核心思想是检测文本行中的一个个小片段,再把小片段连成线。由于文本行在水平方向上长度不定,但在垂直方向上高度相对稳定,CTPN预设了十种高度的锚框,每个锚框只预测垂直偏移和中心偏移,不预测水平偏移。锚框高度设置为[11,16,23,33,48,68,97,139,198,283],对应不同字号的中文和英文字符,覆盖从正文小字到标题大字。
为了让新手直观理解CTPN的锚框机制,可以看下面的关键代码片段。
# 基于anchor_target_layer.py的锚框生成逻辑 def generate_anchors(base_size=16, ratios=[0.5, 1.0, 2.0], scales=2**np.arange(3, 9)): heights = [11, 16, 23, 33, 48, 68, 97, 139, 198, 283] widths = [16, 16, 16, 16, 16, 16, 16, 16, 16, 16] anchors = [] for h, w in zip(heights, widths): x_ctr = w / 2 y_ctr = h / 2 anchors.append([-x_ctr, -y_ctr, x_ctr, y_ctr]) return np.array(anchors) def bbox_transpose_inv(anchors, deltas): # deltas是网络输出的垂直回归偏移 heights = anchors[:, 3] - anchors[:, 1] cy = (anchors[:, 1] + anchors[:, 3]) / 2 pred_cy = deltas[:, 0] * heights + cy return pred_cy这里每条anchor只输出一个垂直偏移量,配合文本/非文本的置信度分数,宽度固定为16像素。这样设计的合理性在于:文本行在水平方向上是连续的,网络只需要判断每个16像素宽的窗口里是否包含文字,以及文字在垂直方向上的精确位置。水平方向的连接交给后处理。
4.2 BLSTM与文本行连接
CTPN在VGG16的conv5特征图上做3×3滑动窗口,每个窗口位置生成一组锚框,然后再接一个双向LSTM。BLSTM的作用是捕捉水平方向上的上下文依赖——判断第i个锚框是不是文本,不能只看它自己,还要看它左右邻居是不是文本,文本行的连续性赋予了这个判断很强的序列特性。
BLSTM输出后接两个分支:一个分支做文本/非文本二分类,一个分支做垂直坐标回归。后处理阶段用文本序列概率连接算法,把置信度超过阈值且首尾重叠的锚框连接成完整的文本行。
连接伪代码如下:
def merge_boxes(boxes, overlap_threshold=0.5): sorted_boxes = sorted(boxes, key=lambda b: b['x1']) merged = [] current = sorted_boxes[0] for box in sorted_boxes[1:]: overlap = min(current['x2'], box['x2']) - max(current['x1'], box['x1']) if overlap > overlap_threshold * min(current['x2'] - current['x1'], box['x2'] - box['x1']): current['x2'] = max(current['x2'], box['x2']) current['y1'] = min(current['y1'], box['y1']) current['y2'] = max(current['y2'], box['y2']) else: merged.append(current) current = box merged.append(current) return mergedoverlap_threshold设0.5比较保守,两个相邻锚框必须有50%以上重合才合并。阈值太高会切碎文本行,阈值太低会把无关区域连在一起,实际调参时先按0.5跑一遍,再根据漏检和错检方向微调。
4.3 一行字里的不同字号怎么处理
CTPN十种锚框高度在同一条文本行内可能出现多个匹配结果。比如一行字里既有大字标题又有小字注释,垂直方向上文字基线对齐但高度不同,多个锚框可能落在同一水平区间。合并时按最大重合度优先的策略,先合并水平方向重叠的锚框,再在垂直方向做一次聚类。代码里如果发现并出来的框高度异常大,多半是混入了背景框,此时要检查分类置信度阈值,把它从0.7适当提到0.85,背景锚框置信度一般偏低,能过滤掉一部分。
5. CRNN文本识别:CTC解码、字典设计、和全流程串联
5.1 CTC损失函数为什么适合变长文本识别
传统识别模型在训练时要求输入和输出长度一一对应,文本行图片宽度不同,对应的字符数就不同,直接无法训练。CTC损失允许模型在每个时间步输出一个字符概率分布,最终的字符序列通过对齐和去重得到。CRNN用卷积网络提取特征,LSTM对特征序列建模,CTC对输出序列解码,这套组合在文本识别上效果稳定。
推理阶段的关键代码如下:
import torch import numpy as np def ctc_decode(preds, beam_size=1): # preds形状: [T, batch, num_classes] preds = preds.permute(1, 0, 2).cpu().numpy() # [batch, T, num_classes] batch_size = preds.shape[0] results = [] for b in range(batch_size): probs = preds[b] if beam_size == 1: # 贪婪解码:每个时间步取最大概率的字符 indices = np.argmax(probs, axis=1) # 合并重复字符 decoded = [] prev = -1 for idx in indices: if idx != prev and idx != 0: # 0是blank decoded.append(idx) prev = idx results.append(decoded) else: # 束搜索解码,beam_size控制搜索宽度 results.append(beam_search_decode(probs, beam_size)) return resultsbeam_size为1时就是常见的贪心解码,速度最快;beam_size设为5或10能提升1%到2%的准确率,但耗时明显增加。生产环境里一般先用贪心解码跑一遍,只在识别置信度低时对单张图重跑一次束搜索。
5.2 中文字典与字符集设计
CRNN的softmax输出维度等于字典大小加1(多一个CTC blank),字典设计直接影响模型体积和识别范围。全量GB2312常用汉字有3755个,加上数字、标点和英文字母,大约3800个字符;按这个规模训练,模型输出层参数约为3800×256,增加的内存可以接受。但如果目标场景就是街道招牌,高频字就那几百个,不需要全部收录,否则生僻字会拉低常见字的识别率。
写作过程中要通过具体案例验证字典的实际覆盖情况。常见做法是准备一套中国车牌省份简称加数字字母的组合,标注几百张小图验证字典覆盖情况。
5.3 端到端串联与参数协调
整条管线的串联脚本如下:
# run_ocr_pipeline.sh python yolo_detect.py --input test.jpg --weights weights/yolov3_text.pt --conf 0.25 python ctpn_detect.py --input crop_results/ --weights weights/ctpn-text.pth --cuda python crnn_recognize.py --input text_lines/ --weights weights/crnn_lstm.pt --dict dict.txtYOLOv3的conf阈值不好直接一刀切,推荐用0.2跑候选,再进CTPN精筛。CTPN内部还有自己的阈值,两级过滤叠加会导致真正文本区域被过滤掉,因此重点调整CTPN的输出阈值而不是YOLOv3的输入阈值。
识别阶段输入CTPN的裁剪图宽度不固定,CRNN对宽度没有硬性约束,只要高度固定为32就行。宽度过大会超出LSTM时间步上限,这批图要按宽度与高度比值缩放,宽度超过300时做一次降采样。
6. 避坑与常见问题:安装依赖版本冲突、漏检与误检定位
6.1 现象:torch版本导致CTPN推理报维度错误
原因:CTPN代码基于torch 0.4.1编写,使用Variable.concat等旧API,新版torch移除这些接口。
解决:推荐用torch 1.7.0搭配python 3.8环境,CTPN原版代码基本可以无修改运行。如果必须用新版torch,按报错逐一替换Variable为tensor,concat改为torch.cat,gather函数参数对齐新版签名。
6.2 现象:YOLOv3检测框把文字区域框得太小,CTPN入检漏字
原因:YOLOv3用外接矩形标注训练,检测框紧贴文字,如果文字是倾斜形状,边缘的字可能被截断。
解决:在YOLOv3输出阶段对框做膨胀扩展,宽高各扩大15%,并确保扩展后不超出原图边界。对固定模板票据场景,这一步能显著提高后续CTPN的完整文本行检出率。
6.3 现象:CTPN对长文本行只识别出一半
原因:CTPN的BLSTM时序长度有限,当文本行文字超过80个字符时,序列特征在长距离传播中衰减。也可能锚框合并阈值过高导致后半段没有连接上。
解决:先在文本行级做切分,超过40个字符的按50%重合度划分为前段和后段,分别识别后合并。合并时按文本行x坐标排序,两段结果直接拼接。
6.4 现象:竖排文字识别率极低
原因:整条管线按水平文本设计,CTPN的锚框宽度固定为16像素、高度可变,竖排文字在水平方向上宽度窄,宽度方向信息天然丢失。
解决:将原图旋转90度后重新过一遍管线,识别结果按旋转前的坐标关系映射回图。日常处理账单、证书、包装上的竖排字时,在配置文件里开启“竖排/纵向阅读顺序”开关即可,本质就是物理旋转后再识别。
6.5 现象:识别结果出现大量中文乱码
原因:字典文件和模型词汇表不一致,或裁剪图包含太多背景噪声干扰了LSTM建模。
解决:核对字典txt是否和训练时完全一致,必须精确到字符顺序——重新生成字典时按字符的Unicode编码排序,训练脚本中的字典路径不能配置错。背景噪声问题可以在CTPN输出后加一步裁剪边界收缩,把文本行上下界向内压缩到原来的80%。
7. 进阶技巧:倾斜文本矫正和透视变换,把最后一截识别率提上来
自然场景里完全水平的文本行是少数,招牌上的字往往带一定旋转角度。CTPN输出的文本行坐标是水平矩形,倾斜文字被包在矩形里,带进来的背景直接干扰CRNN识别。实际项目中我习惯先对CTPN输出的文本行做倾斜矫正,再用透视变换裁正,最后才送CRNN。这一步能把类似路牌、包装袋、标牌上的文字识别率提高5到8个点。
import cv2 import numpy as np def deskew_and_warp(text_line_img): # 用图像矩计算倾斜角度 gray = cv2.cvtColor(text_line_img, cv2.COLOR_BGR2GRAY) thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1] coords = np.column_stack(np.where(thresh > 0)) angle = cv2.minAreaRect(coords)[-1] if angle < -45: angle = 90 + angle h, w = text_line_img.shape[:2] center = (w // 2, h // 2) M = cv2.getRotationMatrix2D(center, angle, 1.0) rotated = cv2.warpAffine(text_line_img, M, (w, h), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) # 透视矫正只处理明显的透视形变,通常先旋转就够了 return rotated rotated_line = deskew_and_warp(ctpn_crop_img)这段代码的思路是先做二值化,再把文字区域的所有非零像素坐标收集起来,用minAreaRect算出包围矩形的最小外接矩形的角度,然后按这个角度反向旋转。BORDER_REPLICATE的边界填充很关键,旋转后图像四角会出现黑色区域,复制边缘像素能避免给CRNN引入无关噪声。
置信度阈值调优时,我在每个阶段记录日志。管线的核心瓶颈往往不是识别模型,而是预处理。批量跑测试集时,我在每张图上画出YOLOv3框、CTPN框和CRNN识别结果,一眼能看出哪一级漏了。曾经有个客户要识别饮料瓶身上的生产日期,特点是被圆柱弯曲和反光干扰,YOLOv3检测正常但CTPN合并出来的文本行带明显弧度——直接识别日期错字严重。后来在CTPN输出阶段按列切分,每列独立矫正再拼接识别结果,准确率从62%提到91%。从那以后,我每次处理弯曲表面、透视变形的文本,都会强制在矫正后做一次目检,确定文字是否水平,再跑识别。这套项目在自然场景里能少走很多冤枉路,希望帮到你。
本文还有配套的精品资源,点击获取