多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

自然场景OCR三件套:YOLOv3、CTPN与CRNN的工程实践

自然场景OCR三件套:YOLOv3、CTPN与CRNN的工程实践 简介这是一份自然场景OCR检测与识别完整方案整合YOLOv3、CTPN与CRNN三个深度学习模型面向有一定神经网络基础的开发者与研究人员可应用于车牌识别、文档电子化、广告牌文字提取等实际场景。压缩包共366个文件大小约51.38MB主要包含129张jpg图像与100个xml标注为模型训练与验证提供了数据基础43个py源码和8个ipynb笔记完整展示了从模型构建到推理的流程另有md说明文档、txt配置及辅助脚本便于快速复现。目前已有4669人学习下载。通过这份资源可以深入理解YOLOv3如何定位文字区域、CTPN如何抽取文本行、CRNN如何将视觉特征转化为字符序列代码注释细致并附带数据集样例与训练记录能够为后续在自有项目中集成自然场景OCR能力提供直接参考。1. 自然场景OCR为什么绕不开YOLOv3CTPNCRNN这条链路街拍一段路牌、对着货架拍一张价签、翻拍一页票据——只要背景不是白纸黑字的扫描件OCR就属于自然场景OCR。这类图片光照复杂、背景纹路多、文字倾斜模糊直接丢给文档级OCR引擎识别率往往不到一半。我做这类识别时最常走、也最能兜底的方案就是标题里这条三段链路YOLOv3把画面里可能含文字的区域先框出来CTPN在框内把文字行切准CRNN再把文字行翻译成字符串。它看似多跑了两个模型实际换来的是每个环节都能单独调优、单独换模型。这条链路适合做票据关键字段提取、车牌识别、街景文字理解而且完全可以在本地离线跑不依赖云端接口。下面我把原理、参数和踩坑一起展开看完你能直接搭一条可复现的管道。2. 用YOLOv3定位文字候选区anchor聚类与NMS参数怎么调整张场景图里文字可能出现在任何位置招牌上方、瓶身侧面、小票底角、栏杆表面。第一步不是识别而是先把“哪里可能有文字”捞出来。我选YOLOv3做这个粗定位理由很直接它把整图划分成网格一次前向推理就能批量输出候选框速度和召回之间的平衡在目标检测模型里最适合作前置筛选器。2.1 检测头结构三个尺度特征图为什么适合捞文字候选区自然场景文字的大小跨度极大店招上两米的大字和价签上几毫米的小字共存于一张图。YOLOv3用特征金字塔把13×13、26×26、52×52三层特征图都接上检测头结构小文字从大尺寸特征图里找大文字从低分辨率特征图里找正好覆盖这个跨度。每个网格预测3个anchor全图三层共输出上万组候选这个密度对于“先兜住再精修”的思路是够用的。候选区阶段不需要把每个字符都框准只需要把整块文字区域包围住所以YOLOv3这类单阶段视觉检测模型比Faster RCNN更合适后者两阶段推理慢在候选生成上属于浪费算力。对比专门做场景文字检测的模型比如RRPN、DBNet它们虽然检测精度更高但和后面CTPN的职能重叠工程上我宁可让YOLOv3只做粗筛把“切文本行”的精细活留给CTPN这样每个模块的职责单一出了问题也好定位。2.2 给文字目标重聚类anchor宽高比是通用模型的2到5倍通用检测的anchor和文字目标的形状差别很大。COCO预训练那组九宫格anchor高宽接近1:1而文字框天然是扁的宽高比从1:1.5到1:8都有可能。直接沿用通用anchorYOLOv3对文字目标的定位会很勉强召回全靠运气。我一般会对自己数据集的标注框重新做k-means聚类距离用1减IoU这样聚类出来的anchor形状直接贴合文字框分布。import numpy as np def wh_iou(w1, h1, w2, h2): iw, ih min(w1, w2), min(h1, h2) inter iw * ih union w1 * h1 w2 * h2 - inter return inter / union if union 0 else 0 def kmeans_anchor(boxes_wh, k9, max_iter300, seed7): 对文字标注框做 anchor 聚类距离度量用 1 - IoU wh boxes_wh.astype(np.float32) rng np.random.default_rng(seed) anchors wh[rng.choice(len(wh), k, replaceFalse)] for _ in range(max_iter): dist np.zeros((len(wh), k)) for i, (w, h) in enumerate(wh): for j, (aw, ah) in enumerate(anchors): dist[i, j] 1.0 - wh_iou(w, h, aw, ah) labels dist.argmin(axis1) new_anchors [] for j in range(k): group wh[labels j] new_anchors.append(group.mean(axis0) if len(group) 0 else anchors[j]) anchors np.array(new_anchors, dtypenp.float32) if np.allclose(anchors, np.array(new_anchors)) is False: pass return np.sort(anchors, axis0)这段代码输入是标注文件里所有框的宽高输出是9组anchor。把聚类距离设计成IoU而非欧氏距离是为了让anchor和YOLOv3训练时的匹配逻辑一致训练时用IoU挑最大匹配聚类时用IoU定义距离才不会出现“类聚得挺像训练时匹配不上”的尴尬。k固定为9对应三个尺度各3组anchor聚类完成后按面积从大到小分给13×13、26×26、52×52层。常见的一个坑是直接拿通用检测模型训练好的一组anchor硬用。我之前做过一套价签识别的数据聚类出来面积最大的三组anchor宽高比接近6:1通用anchor里最扁的也只有2:1左右差距摆在那里后面CTPN补得再多也追不回来。2.3 候选区推理后处理conf_thres宁可低NMS按IoU过滤YOLOv3输出的原始预测还不能直接用。每个grid cell预测的是中心坐标偏移、宽高缩放和objectness需要先解码成真实坐标再做NMS去重。我在候选区阶段会把置信度阈值放得很低这是和后面模块联动最关键的一个选择。import numpy as np def yolov3_postprocess(pred, conf_thres0.25, iou_thres0.45): pred: 三个尺度检测头输出合并后的数组shape [N, 7] 每行 [center_x, center_y, w, h, objectness, cls_score, cls_id] obj pred[:, 4] mask obj conf_thres pred pred[mask] if pred.shape[0] 0: return np.empty((0, 4)), np.empty((0,)) x1 pred[:, 0] - pred[:, 2] / 2 y1 pred[:, 1] - pred[:, 3] / 2 x2 pred[:, 0] pred[:, 2] / 2 y2 pred[:, 1] pred[:, 3] / 2 conf pred[:, 4] order np.argsort(conf)[::-1] keep [] while order.size 0: i order[0] keep.append(i) if order.size 1: break xx1 np.maximum(x1[i], x1[order[1:]]) yy1 np.maximum(y1[i], y1[order[1:]]) xx2 np.minimum(x2[i], x2[order[1:]]) yy2 np.minimum(y2[i], y2[order[1:]]) inter np.clip(xx2 - xx1, 0, None) * np.clip(yy2 - yy1, 0, None) area_i (x2[i] - x1[i]) * (y2[i] - y1[i]) area_j (x2[order[1:]] - x1[order[1:]]) * (y2[order[1:]] - y1[order[1:]]) union area_i area_j - inter iou inter / np.clip(union, 1e-7, None) order order[1:][iou iou_thres] boxes np.stack([x1[keep], y1[keep], x2[keep], y2[keep]], axis1) return boxes, conf[keep]这段是标准的贪心NMS。先按objectness降序排列每次保留得分最高的框然后把它和剩余框的IoU算出来把重叠度超过iou_thres的框全部丢掉循环往复。候选阶段的conf_thres我一般给0.25数据质量差时会直接降到0.1。原因很简单YOLOv3这里漏掉一个文字区域下游两层模型不会自己去找漏检是整条链路里不可恢复的错误。NMS的iou_thres给0.45。如果同一段文字被YOLO框切成两三块说明anchor形状还没完全贴合这时候i保留的框之间重叠大可以放宽到0.6宁可多出几个候选也不要让NMS把同属一行文字的相邻框误删。3. CTPN精修文本行从候选框到带倾斜角度的文本框YOLOv3给出的候选框是轴对齐矩形但自然场景文字经常倾斜或轻微弯曲直接按矩形裁剪CRNN会看到大量背景和错位笔画。CTPN在这里的职责是把矩形候选区域重新切成一行一行、带真实角度和边界的文本行。3.1 CTPN的原理固定宽度anchor加BiLSTM本质是序列预测CTPN的核心思路和YOLO完全不同它不预测“一个完整的框”而是只预测“一列一列宽度固定为16像素的条状anchor”。先用VGG16前几个卷积层提取特征得到一张宽W高H的特征图每个位置用3×3窗口滑一遍再接一个双向LSTM横向扫过整行。为什么接LSTM因为文字天然是序列一个字符周围的上下文能帮模型判断这里到底是文字还是背景纹理。网络最终输出三个分支2k个垂直坐标参数预测每个anchor的y中心和高k个side-refinement偏移修正文本行左右边缘位置2k个text/no-text分数判断这列anchor里有没有文字。k就是anchor种类的数量宽度固定16高度从11到283分10档。因为anchor宽度窄CTPN理论上能拼接出任意长度的文本行也能通过多段anchor组合还原出倾斜角度这是矩形框检测做不到的。3.2 CTPN的anchor与阈值默认配置怎么改才不翻车CTPN的anchor高度按论文默认给了一组11、16、23、33、48、68、97、139、198、283。这组数覆盖了从价签小字到路牌大字的常见范围大多数场景可以直接沿用。真正需要调的是推理时的两个阈值score_threshold决定哪些anchor算“有文字”nms_iou_threshold决定重叠的anchor怎么去重。ctpn_config { anchor_heights: [11, 16, 23, 33, 48, 68, 97, 139, 198, 283], anchor_width: 16, score_threshold: 0.7, nms_iou_threshold: 0.5, min_height: 16, max_width: 1024, }score_threshold默认0.7是按公开模型的标准setup来的。做复杂背景识别比如招牌上光照不均、有反光我把这个值降到0.55到0.6否则很多被光照压暗的anchor会被过滤掉。反过来做干净票据识别这类图像文字对比度高可以抬到0.85减少背景纹理误判。nms_iou_threshold保持0.5一般够用anchor之间本身重叠率就低这个参数影响不大。训练阶段还会涉及正负样本的界定正样本要求anchor与标注框的IoU大于0.7负样本小于0.3介于两者之间的忽略。锚框匹配规则和YOLOv3不一样CTPN不是全图匹配而是只在标注文本行附近采样背景负样本占比通常控制在3:1以内不然模型会倾向把所有anchor都判负。3.3 文本线构造把散落的anchor合并成一条文本框CTPN推理出来的是几百个带分数的细条anchor还需要把它们横向串联成整行文本。常见做法是按x坐标排序把相邻且垂直方向重叠度高的anchor归到同一条线最后用合并函数输出文本框的四个顶点。import numpy as np def merge_line(anchors): 把同一行的一组anchor合并成一个文本行框 arr np.array(anchors) # 每行 [x_center, y_center, height, width, score, x_side] left arr[:, 0].min() right arr[:, 0].max() total arr[:, 4].sum() y_center (arr[:, 1] * arr[:, 4]).sum() / total h_avg (arr[:, 2] * arr[:, 4]).sum() / total return [left, y_center, h_avg, right - left, arr[:, 4].max()] def build_text_lines(proposals, score_th0.7, overlap_th0.2, max_gap64): 把离散anchor按x轴顺序和垂直重叠度串成文本行 proposals [p for p in proposals if p[4] score_th] if len(proposals) 0: return [] proposals.sort(keylambda p: p[0]) lines [] cur [proposals[0]] for p in proposals[1:]: last cur[-1] gap p[0] - last[0] y1_top last[1] - last[2] / 2 y1_bot last[1] last[2] / 2 y2_top p[1] - p[2] / 2 y2_bot p[1] p[2] / 2 inter_y max(0, min(y1_bot, y2_bot) - max(y1_top, y2_top)) overlap_y inter_y / min(last[2], p[2]) if gap max_gap and overlap_y overlap_th: cur.append(p) else: lines.append(merge_line(cur)) cur [p] lines.append(merge_line(cur)) return lines这里的关键参数是max_gap和overlap_thanchor宽度固定16同一行文字里相邻anchor的x间隔一般不超过4个anchor宽度所以max_gap取64overlap_th取0.2表示两个anchor在垂直方向上至少要重叠20%才算同一行不然会把上下两行文字串成一条。merge_line里用score做加权平均算y中心比简单平均更稳得分高的anchor定位更可靠。合并完成后每个文本行最终输出为[x_center, y_center, height, width, score]格式再根据四个角点坐标还原成平行四边形交给下一层。到这里YOLOv3的坐标是原图的CTPN是在YOLO裁剪区域里做的坐标一定要加回偏移量这一步漏了后面全部错位。4. CRNN把文本行转成字符串高度固定、宽度可变文本行已经从背景里干净地切出来了形状是一块被拉正的条状图。CRNN就是处理这种条状图的标配网络不管文字行多长输入高度固定为32宽度跟着文字行自由变化正好匹配场景文字长短不一的特点。4.1 CRNN结构CNN特征序列加BiLSTM加CTCCRNN由三段组成卷积骨干负责提视觉特征把图像转成一列一列的特征序列双向LSTM在特征序列上建模文字上下文最后的CTC层把序列解码成字符串。输入图像会先被压缩到高度32保持宽高比经过7层卷积加4个池化后宽度方向被压缩到原来的约四分之一变成一组时间步序列。每个时间步对应原图里一小段水平区域BiLSTM在那基础上预测该位置最可能的字符类别。CTC的存在是为了绕开“字符位置对齐”这个难题。文字行的字符数不固定每个字符宽窄不同强行让CNN每个输出位置对齐到一个字符很难标注。CTC允许网络输出序列比字符长度长解码时通过blank符号把同一字符的重复输出合并掉比如识别“helllo”时连续l被折叠成一个。工程上用CTC比attention更稳的地方在于CTC收敛快、显存占用低、推理时不需要自回归逐步生成离线批量识别吞吐更高。4.2 字典与训练参数中文场景怎么选字符集字典设计直接决定识别上限。纯英文数字场景字典里几十个字符就够了中文场景至少得覆盖任务出现的常用字我一般用GB2312一级字表的3755个字加上数字英文符号够覆盖大多数路牌和票据。字符集一旦定下来训练后要扩充字符就得重训输出层所以起步时宁可多备几百个字也不要先小后大频繁返工。crnn_config { img_height: 32, batch_size: 128, max_width: 480, char_set: [blank] list(0123456789) list(abcdefghijklmnopqrstuvwxyz) list(ABCDEFGHIJKLMNOPQRSTUVWXYZ) [兑, 换, 券, 优, 惠, 活, 动, 已, 结, 束], hidden_units: 256, num_layers: 2, learning_rate: 1e-4, lr_decay: 0.9, beam_width: 5, }参数里几个值得留意的点img_height取32是一般设置如果场景文字笔画粗壮比如广告黑体用48能让字符在卷积后保留更多细节但推理时间会涨。learning_rate从1e-4起步每两个epoch乘一次0.9比固定学习率稳。beam_width是推理阶段beam search的宽度取5够了取大并不会线性提升精度只会让CPU推理时间成倍涨。训练数据里最有效的增强是随机透视变换模拟倾斜、高斯模糊模拟失焦、随机调亮度模拟光照变化。场景文本识别和文档OCR最大的不同就在这里模型不是在学“写字”而是在学“照片里的字”增强不够项目就废。4.3 用Python把三段模型串成一条OCR推理链路把前面三个模块接起来最小可跑通流程是这样。模型接口我用封装后的对象调用实际工程里把各自的权重载入和预处理包在detect与recognize方法里即可。import cv2 import numpy as np def four_point_transform(img, box): 把CTPN输出的四点文本框拉正成水平矩形 pts np.array(box, dtypenp.float32) s pts.sum(axis1) tl, br pts[np.argmin(s)], pts[np.argmax(s)] diff np.diff(pts, axis1).ravel() tr, bl pts[np.argmin(diff)], pts[np.argmax(diff)] width int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl))) height int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl))) dst np.array( [[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]], dtypenp.float32, ) M cv2.getPerspectiveTransform( np.array([tl, tr, br, bl], dtypenp.float32), dst ) return cv2.warpPerspective(img, M, (width, height)) def ocr_natural_scene(image, yolo_model, ctpn_model, crnn_model): results [] # 第一步YOLOv3 全图粗定位输出原图坐标 cand_boxes yolo_model.detect(image, conf_thres0.25, iou_thres0.45) for x1, y1, x2, y2 in cand_boxes: pad int(0.08 * min(x2 - x1, y2 - y1)) 5 x1p, y1p max(0, x1 - pad), max(0, y1 - pad) x2p min(image.shape[1], x2 pad) y2p min(image.shape[0], y2 pad) crop image[y1p:y2p, x1p:x2p] # 第二步CTPN 在候选区域内精检文本行 text_lines ctpn_model.detect(crop, score_threshold0.7) # 第三步文本框拉正后送 CRNN 识别 for box in text_lines: # 注意box是候选区域内的坐标需要换算回原图 box_abs [(bx x1p, by y1p) for bx, by in box] line_img four_point_transform(image, box_abs) text, conf crnn_model.recognize(line_img) if conf 0.6: results.append((box_abs, text, conf)) return resultspipeline的顺序是严格串行的YOLOv3负责召回CTPN负责精定位CRNN负责最终识别。pad外扩是为了避免YOLO框边缘正好卡在文字上导致CTPN看不到完整的字符笔画。CTPN返回的坐标是裁剪图内的绝对不能在调用four_point_transform时直接用这一点上面代码里已经做了偏移换算。CRNN的conf低于0.6的识别结果一般建议丢弃那是把背景纹理当成文字识别出来的典型表现。5. 自然场景OCR避坑指南五个最容易翻车的环节这条链路最麻烦的地方不在单个模型而在三级模块层层传递错误。下面五条是我实际调过的踩坑记录每一条都是先看到现象、再定位原因、最后才改对的。5.1 现象YOLO框把文字从中间截断CRNN输出全是乱码候选框只截到半个词时CRNN会识别出一串没有意义的字符而且置信度还不低。原因通常有两个一是标注时文字边界标得太紧框贴着笔画二是聚类的anchor尺寸偏小NMS保留的框不够宽。解决方法是标注时统一外扩2到4像素聚类完把anchor宽高乘1.1到1.2的安全系数让YOLO“宁可多框背景也不截断文字”。推理阶段也可以在裁剪时尽量多扩一点我的经验是至少扩5%到8%。5.2 现象招牌上整片文字YOLOv3检出来了CTPN却只零散吐出几个小框CTPN对垂直纹理梯度敏感玻璃反光、招牌阴影、笔画粗细不匀都会让部分anchor得分过低导致一整行文字被拆成几段甚至只剩零碎两三列。这种问题的定位最快的方法是画图把score_threshold从0.7降到0.55再跑一遍可视化能看到漏检的anchor大多是被反光压暗了。解决手段是裁出候选图后先做一次自适应直方图均衡化提高局部对比度再把阈值从0.7降一档。这类问题是整个链路里最玄学的地方没有固定参数只能靠可视化看图和阈值下探并行。5.3 现象竖排文字识别全错数字英文勉强对中文一个不对竖排文字纵向阅读顺序是CRNN的天然盲区因为模型的时间步方向是横向的把竖排文字直接裁下来每个字符的笔画顺序被打乱CNN特征序列根本无法建模出正确的字符关系。常见的工程解法是一层方向分类在YOLO候选框阶段额外接一个0度、90度、180度、270度的旋转分类器把竖排区域旋转成横排后再走CTPN和CRNN。现在已经有一些OCR库把“竖排/纵向阅读顺序”做成开关本质上就是先判断方向再旋转预处理。如果你直接拿CRNN硬训竖排训练量要翻好几倍效果还不一定稳。5.4 现象价签上的数字和字母相互认错0和O、8和B混成一团自然场景里字体变形严重、分辨率低相似字形之间的区分度本来就差。CTC训练时会把“0”和“O”这类字符的预测概率在blank折叠过程中互相拉扯如果数据里这对字符出现次数不均衡模型会偏向高频一方。解决分三层第一层单独扩相似字符对的样本量让每个易混字符至少出现上千次第二层如果业务场景固定比如只识别货架价签把字典收缩成数字、小数点和有限符号模型不用在无关类别上浪费参数第三层加词典约束的beam search让“B0”、“0O”这类组合被上下文规则纠正。5.5 现象离线OCR在本地跑太慢CPU上单张图要一秒多三个模型串行每层都有预处理、推理、后处理候选框一多CTPN和CRNN的功耗直接起飞。我踩过最大的坑是CRNN的beam search宽度给了20CPU推理时间翻了三倍识别率几乎没涨调回5立刻恢复理性。整体提速有几个立竿见影的做法YOLOv3输入分辨率从608降到416把CTPN的多个候选图拼成一个batch推理而不是for循环单张送网络CRNN推理用ONNX Runtime做fp16量化精度损失在小场景下基本无感。这条链路本来就适合离线本地部署但优化重点要放在减少模块间的重复计算上而不是盲目压缩单个网络结构。6. 让链路更稳的两个技巧角度校正与三级置信度联动6.1 用CTPN文本框的四个顶点求倾斜角先把字拉直再识别CTPN输出的文本行四点已经隐含了倾斜角只是很多人下一步就直接切图。先用左上和右上两个点算斜率几十行代码就能得到角度import math def estimate_angle(box): dx box[1][0] - box[0][0] dy box[1][1] - box[0][1] return math.degrees(math.atan2(dy, dx))拿到角度后用cv2.getRotationMatrix2D做旋转校正或者直接走four_point_transform的仿射变换把文本行拉成水平矩形。倾斜超过15度的文本行这一步带来的识别率提升往往比换更大模型还明显因为它消除了CRNN特征序列里的几何歧义。6.2 三级置信度阈值是怎么联动的三级模型各自的阈值不是孤立的我常用这组联动配置链路阶段关键阈值建议值调错的代价YOLOv3conf_thres0.25漏检后下游无法补救CTPNscore_threshold0.7调高丢字调低混背景CRNNoutput conf0.6展示 / 0.8入库调高误拒调低误报整体原则是前松后紧。YOLOv3漏掉的候选框后面两层再准也看不到CTPN多吐几个框不可怕CRNN的置信度会兜底CRNN作为最后一道闸入库数据一定要把置信度抬到0.8以上展示用途再放宽到0.6这样误报不会污染下游业务。6.3 验证与调优的收尾做法我在验证这条链路时会把测试样本按干净票据、复杂路牌、手机随手拍分成三组分别统计端到端整图匹配率也就是把识别文本拼接后与标注逐字比对。模块间错误传递是这条链路最讨厌的问题所以我额外保留了一批“必测图”每张图都预先标注好失败环节每次改完任意模块都全部回归一遍。这些年做下来最深的教训是这条链路里任何一级“差不多就行”最后都会在真实样本上以奇怪的方式翻车。与其追求单个模型刷分不如把每一级的可视化输出存下来快速定位是哪一环丢的。希望帮到你。本文还有配套的精品资源点击获取
返回列表