简介这是一篇研究基于CTPN神经网络实现营业执照文字检测的PDF论文面向机器学习、深度学习和OCR文字检测方向的研究者与开发者。内容围绕复杂背景下的水平文字定位问题展开对比传统RPN网络在营业执照场景中的不足并给出基于TensorFlow与OpenCV框架的CTPN模型训练、测试实现。作者以2000张营业执照图像为实验数据经过10000次迭代训练验证了该模型在水平文字检测正确率上的提升同时讨论了项目落地时仍存在的精度挑战。资源为单份PDF文档共1个文件压缩包大小约1.2MB适合用于了解CTPN原理、文字检测数据构建及深度学习模型训练流程也可为相关毕设或工程项目提供参考思路。该文档在CSDN已有127人学习下载具备一定的实践参考价值。1. 营业执照文字检测CTPN 不是万能的但却是落地的及格线在营业执照的 OCR 流水线里最消耗人力的从来不是字符识别而是“先找到文字在哪”。执照照片的拍摄角度、红章压字、浅底白字、金属防伪纹路任何一样都能让通用检测模型原地翻车。我在这类票据、证照项目里试过一圈方案最后往往回到 CTPN 神经网络这个老模型上它只做水平文本行检测结构清楚、显存占用小、单张推理在 CPU 上也能压到百毫秒级。它解决的是“一段文字到底框在哪”而不是“这段文字是什么”适合证书、执照、发票这类文本排布相对规整的文档型图像也适合作为后续 OCR 识别模块的前置定位器。这篇笔记我按自己做过的落地路径来写从模型输出结构、数据标注、训练参数到避坑尽量给你可以直接抄走的经验。2. CTPN 的锚点与 LSTM先看懂模型输出再谈训练2.1 VGG16 主干和双向 LSTM 在拟合文本行时各自承担什么CTPN 的核心结构是“卷积神经网络 循环神经网络”的混合。主干网络是一个 VGG16去掉全连接层只保留到 conv5_3 的卷积特征这一层特征图相对原图做了 16 倍下采样也就是说原图里 16 个像素对应特征图上的 1 个像素。如果输入图是 1024×1024那特征图就是 64×64。在这个特征图上CTPN 不是直接去回归一个完整的文本框而是把文本行看成“一串等宽的小片”。每一步滑动窗口只负责一个宽 16 像素的竖条预测这个竖条内有没有文字、文字的顶边和底边在哪、以及跟下一个竖条的侧向偏移量。这里就轮到 LSTM 神经网络登场每个竖条单独判断很容易被背景干扰但文本有上下文连续性一句“统一社会信用代码”里的每个字符之间在高度、左右间距上是强相关的。双向 LSTM 从左右两个方向把卷积特征串起来相当于在做序列建模让第 i 个位置能参考前后若干位置的特征从而把断开的字符片段重新连贯成完整文本行。这个过程也是为什么 CTPN 常被归到“RNN 循环神经网络系”的检测器里。注意 CTPN 和 Faster R-CNN 的差别Faster R-CNN 直接回归任意宽高比的候选框而 CTPN 的锚点把宽度写死为 16 像素只预测高度和偏移。这个设计假设文本是水平或接近水平的。营业执照正拍、稍微旋转 510 度没问题但如果是横拍竖排的执照CTPN 会失效后面详细说。2.2 锚点生成与真值编码一张 1024 图实际上要预测多少次CTPN 论文里给出了一组固定高度档位常见配置是 10 个11、16、23、33、48、68、97、139、198、283 像素。注意这里的单位是原图尺寸不是特征图尺寸。每个特征图位置生成一个高度档位所以 64×64 特征图上一共要评估 64×64×10 40960 个候选锚点。import numpy as np def generate_ctpn_anchors(feat_h, feat_w, stride16, base_w16): # CTPN 传统配置高度取 10 档宽度固定为 16 heights [11, 16, 23, 33, 48, 68, 97, 139, 198, 283] anchors [] for y in range(feat_h): for x in range(feat_w): # 特征图坐标映射回原图中心 cx (x 0.5) * stride cy (y 0.5) * stride for h in heights: x0 cx - base_w / 2 y0 cy - h / 2 x1 cx base_w / 2 y1 cy h / 2 anchors.append([x0, y0, x1, y1]) return np.array(anchors, dtypenp.float32)这段代码用于生成全部候选锚点。核心参数是stride16和base_w16对应 VGG16 的下采样倍数和 CTPN 的固定宽度。如果你换了主干网络stride 必须跟着变否则锚点在原图上的位置全是错的。接下来是真值编码。对每个锚点如果它与某个真实文本行框的 IoU 大于 0.7就标记为正样本小于 0.3 标记为负介于中间的忽略。正样本要回归三个量锚点本身的顶边和底边到真实文本行顶边和底边的距离以及一个侧向偏移量。侧向偏移的含义是当前锚点中心到真实文本行中心线的水平距离它用来在推理时决定哪些相邻锚点属于同一个文本行。def encode_anchor(anchor, gt_box): # anchor: [x0, y0, x1, y1] # gt_box 是同一 x 区间内的文本行真实框 ah anchor[3] - anchor[1] # 垂直坐标归一化用锚点高度做分母 v_top (gt_box[1] - anchor[1]) / ah v_bottom (gt_box[3] - anchor[3]) / ah # 侧向偏移文本行中心与锚点中心的水平差除以锚点宽度 side ((gt_box[0] gt_box[2]) / 2 - (anchor[0] anchor[2]) / 2) / 16.0 return np.array([v_top, v_bottom, side], dtypenp.float32)这里有个容易忽略的点encode_anchor里的 gt_box 并不是整个文本行而是和当前锚点在水平方向上重合的那一小段。文本行有多长就要拆成多少个这种局部真值。所以你在准备训练数据时不能只给一个全局的矩形框而要把每个文本行按 16 像素宽度切碎后逐个匹配锚点。这也是 CTPN 数据预处理比普通检测模型繁琐的地方。3. 数据准备与增强把营业执照图片变成 CTPN 能收敛的监督信号3.1 从通用检测标注转成 CTPN 真值需先做三点归一化营业执照的标注一般有两种来源一种是标注平台导出的四点框左上、右上、右下、左下一种是 OCR 厂商给出的中心点加宽高格式。四点框最稳定因为执照上的文字会被红章遮挡标注员画矩形框时往往把印章面积也框进去四点框可以手动只标文字可见的部分。拿到四点框后先转成水平矩形再做前面说的切碎处理。import json import numpy as np from shapely.geometry import Polygon def quad_to_hrect(quad): # quad: 4 个点顺序按左上、右上、右下、左下 xs [p[0] for p in quad] ys [p[1] for p in quad] # 对接近水平的文本行直接取外接矩形 return [min(xs), min(ys), max(xs), max(ys)] def parse_label_file(json_path): with open(json_path, r, encodingutf-8) as f: data json.load(f) gt_boxes [] for item in data[shapes]: label item[label].strip() if label not in (text, 企业名称, 统一社会信用代码, 地址): continue # 四点框转水平外接矩形 hrect quad_to_hrect([tuple(p) for p in item[points]]) gt_boxes.append({box: hrect, label: label}) return gt_boxes这段代码把标注文件里的四点框归一化成水平矩形同时过滤掉不需要的类别。我一般保留“企业名称”“统一社会信用代码”“地址”“法定代表人”这几类关键字段作为正样本其他装饰性文字不参与训练。原因很简单CTPN 是检测模型不是分类模型它不关心框里是什么字但如果训练数据里包含大量“联系电话”“邮政编码”这类次要字段模型会浪费容量去学习它们的位置先验反而降低关键字段的召回。切碎策略上我通常把每个真实文本行按 16 像素宽度切段然后计算每一段与锚点的 IoU。一个 500 像素宽的文本行在 stride 16 下会产生约 31 个有效的正锚点序列。如果某段文本被印章完全盖住标注时应该把这段删掉或者标记为 ignore否则模型会努力去拟合一个看不见的文本行导致推理时同样位置出现幻觉框。3.2 针对执照场景的增强组合低对比、倾斜和印章噪声营业执照图像有一个显著特点背景不是纯白而是淡黄色或浅粉色渐变加上红章、钢印、底纹。如果只用常规的随机裁剪和水平翻转模型训练出来后在真实场景上的误检率会很难看。我常用的增强组合是三件套对比度扰动、小角度旋转、颜色通道抑制。import cv2 import numpy as np def aug_gamma(img, gamma_range(0.6, 1.4)): 随机亮度/对比度扰动模拟不同灯光下的照片 gamma np.random.uniform(*gamma_range) table np.array([(i / 255.0) ** (1.0 / gamma) * 255 for i in range(256)]).astype(uint8) return cv2.LUT(img, table) def aug_red_suppress(img): 模拟印章区域随机把红色通道压暗强迫模型不依赖颜色 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask (hsv[:, :, 0] 10) | (hsv[:, :, 0] 170) img_copy img.copy() img_copy[mask] np.clip(img_copy[mask] * 0.5, 0, 255).astype(np.uint8) return img_copy def aug_rotate(img, angle_range(-8, 8)): 小角度旋转执照翻拍最常见的偏移 angle np.random.uniform(*angle_range) h, w img.shape[:2] m cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) return cv2.warpAffine(img, m, (w, h), flagscv2.INTER_LINEAR, borderValue(255, 255, 255))注意aug_rotate虽然做了旋转但 CTPN 本身只输出水平框所以旋转后的标注框也要做同样的仿射变换再重新取外接矩形。我一般把旋转角度限制在正负 8 度以内超过这个范围模型学到的“旋转补偿”会明显扭曲锚点回归真实业务中如果发现大角度照片占比较高应该在上游加一个方向矫正模块而不是硬让 CTPN 学。印章抑制增强这步很容易做过头如果你把红色通道全部清零模型会学习到“看到红色就没文字”但真实执照上很多关键字段恰恰盖了红章。所以我只做区域随机压暗不整图抹红让模型同时见过“有字的红章”和“无字的红章”两种样本。颜色增强的本质是降低模型对颜色的依赖而不是教会它忽略红色。4. 训练与调参损失权重、锚点档位和收敛性检查4.1 用最小可运行训练脚本跑通 CTPN训练脚本的核心是把模型输出拆成三个头分类分数、垂直回归、侧向偏移。分类用交叉熵垂直回归用 smooth L1侧向偏移也用 smooth L1。我习惯把三个损失写成字典方便调权重。import torch import torch.nn.functional as F def ctpn_loss(out, cls_label, v_label, side_label, cfg): # out[cls]: [B, 2, H, W, 10] # out[v]: [B, 2, H, W, 10] # out[side]: [B, 1, H, W, 10] cls_loss F.cross_entropy(out[cls], cls_label, reductionmean) pos_mask cls_label 1 if pos_mask.any(): v_pred out[v][pos_mask] v_gt v_label[pos_mask] v_loss F.smooth_l1_loss(v_pred, v_gt, reductionmean) side_pred out[side][pos_mask] side_gt side_label[pos_mask] side_loss F.smooth_l1_loss(side_pred, side_gt, reductionmean) else: v_loss torch.tensor(0.0, deviceout[v].device) side_loss torch.tensor(0.0, deviceout[side].device) total (cfg[loss_weights][cls] * cls_loss cfg[loss_weights][v] * v_loss cfg[loss_weights][side] * side_loss) return total, {cls: cls_loss.item(), v: v_loss.item(), side: side_loss.item()}这段代码里有一处关键处理pos_mask过滤出正样本后再算回归损失。如果不做这个过滤背景锚点会把垂直回归的损失推向零模型只会输出一个“无脑居中”的高度预测检测框全部塌缩成水平短线。训练时的典型配置我放在下面train_cfg { img_size: (640, 640), batch_size: 8, lr: 1e-4, weight_decay: 5e-4, anchor_heights: [11, 16, 23, 33, 48, 68, 97, 139, 198, 283], max_epoch: 30, loss_weights: {cls: 1.0, v: 1.0, side: 2.0}, iou_pos_thresh: 0.7, iou_neg_thresh: 0.3, }输入尺寸我不用 1024而是压到 640。营业执照的文本行高度一般在 2045 像素之间640×640 已经足够覆盖压到 640 能显著提高训练吞吐而且对小文本行的检测没有负面影响。真正需要 1024 的场景是那些手机拍摄、整张执照充满画面且字号很小的图那种情况模型提升有限但训练时间至少翻倍性价比不高。4.2 三个必调参数正负样本比例、侧偏移权重、特征图分辨率第一个必调是正负样本比例。CTPN 在 640×640 输入下会生成大约 16000 个锚点其中正样本往往不到 1%。如果你直接用全量交叉熵模型会陷入“全部预测为背景”的局部最优。我一般用在线难例挖掘每张图每次迭代取 64 个正锚点和 128 个负锚点负锚点的选择标准是分类损失最大的那部分这样模型被迫去关注那些长得像文字的表格线、印章边缘。第二个必调是侧偏移权重。CTPN 损失里side的权重我设为cls的两倍因为它直接决定文本行能不能连起来。权重太低时相邻锚点之间的连接置信度很弱长文本行会被拆成好几段权重太高时模型会把原本不相邻的锚点强行拉在一起把两行字并成一行。如果你在验证集上发现“文本行数量明显多于真实数量”先调大这个权重如果发现“检测框跨行合并”立刻降下来。第三个必调是特征图分辨率。VGG16 的 conv5_3 输出 stride 是 16对 640×640 输入就是 40×40 特征图。每新增一个池化层stride 翻倍小字号的检测能力就下降一级。在营业执照场景里如果小字比如“住所”栏下面的详细地址检测特别差可以让输入尺寸升到 768 或 896而不是更换更大的主干网络。换 ResNet 或 EfficientNet 往往意味着锚点尺寸要全部重调成本远大于收益。4.3 指标怎么定别只盯 mAP要看字段级召回我想明确一点CTPN 输出的检测框本身不关心文字内容所以通用目标检测的 AP 曲线在文本检测场景里只能反映“框得准不准”不能反映“关键字段有没有被框到”。做营业执照文本检测我更推荐字段级召回率。def compute_field_recall(pred_boxes, gt_boxes, iou_thresh0.7): matched set() for p in pred_boxes: for gi, g in enumerate(gt_boxes): iou compute_iou(p, g[box]) if iou iou_thresh and gi not in matched: matched.add(gi) break recall len(matched) / len(gt_boxes) return recall这里的gt_boxes是导入的营业执照关键字段标注不是全部文本行。一家企业名称漏检了比地址栏漏检半行字严重得多。我通常在训练过程中每个 epoch 结束后在固定的 200 张验证图上跑一次字段级召回一旦某个字段的召回连续 3 个 epoch 不涨就停下来检查是不是这个字段的训练样本太少或标注框质量有问题而不是闷头把 epoch 跑完。5. 避坑清单CTPN 在执照场景的五个翻车现场与排查在真实项目里 CTPN 的表现并不总是在论文数据上那么光鲜。以下五类问题我都实际遇到过按“现象、原因、解决”三行展开。5.1 旋转营业执照全部检测散边现象输入一张旋转了 15 度的执照照片输出的检测框全是短横线明显不在文本行上甚至整张图一个像样的长框都出不来。原因CTPN 的锚点宽度固定为 16 像素且假设文本行是水平的。旋转后每个锚点覆盖到的不是同一行文字的连续片段而是斜着跨过两行字侧向偏移回归被斜向梯度干扰文本行构建器无法把锚点连成线。解决在 CTPN 之前加一个方向分类或者直线检测模块。我常用做法是先用霍夫变换检测执照边框的长直线估算旋转角然后做一次仿射矫正把执照拉正后再送进 CTPN。对于旋转超过 30 度的图与其硬调 CTPN不如直接放弃返回“请正对拍摄”的提示给用户。5.2 红色印章区域被当成文本行现象检测框大量落在红色印章上特别是圆章外圈的国名文字、放射状线条处。原因印章的纹理与文字边缘在垂直方向上有相似的高频梯度CTPN 的分类分支只看局部 16×h 的特征很容易把印章的放射线当成文字。解决推理时用 HSV 颜色空间把高饱和度的红色区域压暗后再做检测。这会牺牲一部分盖在章下的真实文字召回所以我会在压暗之前先用一份“是否采光是红色印章”的图像级判断如果整图红色占比超过 5%才启用压暗。训练阶段的aug_red_suppress只是降低模型对颜色的依赖并不能完全消除这个问题。5.3 小字号行被切碎文本行数量翻倍现象营业执照的“住所”一栏地址字数多、字号小推理结果里这行字被切成三到四段每一段都很短。原因CTPN 锚点最小高度是 11 像素。如果文本行在检测图中的实际高度只有 14 像素能匹配上的锚点档位只有 11 这一档侧向连接本来就稀疏一旦卷积特征经过 stride 16 下采样后丢失局部细节小字号的响应就变得更弱。解决先在推理阶段做一次 2 倍上采样让文本行高度落到 28 像素左右。实测上采样带来的召回提升非常明显缺点是 CPU 上单张图推理时间会从 80ms 增加到 130ms。如果你对延迟敏感可以把上采样改成只在图像金字塔里试一次检测不到整行长框时再上采样重跑。5.4 表格线被检出为文本行现象执照里的分隔横线、表格边框被检测成长条形矩形框数量和文本行一样多。原因细长直线在垂直方向上的梯度变化非常稳定CTPN 的垂直回归可以准确拟合出它们的上下边界分类分支又认为它们和文字行的形状高度相似。解决训练数据里显式添加“表格线”作为负样本。具体做法是把这些线的标注从ignore改成non-text参与分类损失的负样本计算。推理侧再加一个后置规则检测框高度小于 10 像素且长度大于 3 倍高度时直接丢弃。这个规则在执照场景里几乎不误伤真实文本。5.5 中文与数字混排行被横向切断现象“统一社会信用代码 91110108MA01XXXXX”这一行中文部分和数字部分经常被拆成两个框。原因中文字符宽度大于数字同一行内锚点之间的侧向偏移在中文段和数字段之间存在跳变。文本行构建器如果采用固定的侧向偏移阈值中文段能连上数字段的偏移突变会把连接断开。解决把侧向偏移的 loss 权重从 1.0 提到 2.0同时推理时把文本行构建的max_gap阈值从 32 放宽到 64。注意这个阈值不能无限放宽否则真会把上下两行字连成一行。我最终选 64是因为它在 640×640 输入下刚好能覆盖“一个中文字宽加一个半角数字宽”的间隙。6. 落地优化检测结果按字段语义排序与验收技巧6.1 把检测框排序成可读文本行而不是一堆散框CTPN 输出是若干矩形框OCR 识别前必须把它们按阅读顺序组织。我的习惯是先用 y 坐标聚类再按 x 排序。执照的版式相对规整一行字的 y 中心差不超过框高的 30%。def sort_ctpn_boxes(boxes, y_ratio0.3): # box: [x0, y0, x1, y1]按 y0 排序 boxes sorted(boxes, keylambda b: b[1]) lines [] for b in boxes: h b[3] - b[1] if not lines: lines.append([b]) continue last lines[-1][-1] # 与上一行最后一个框比较 y 中心差同行的判断条件 last_cy (last[1] last[3]) / 2 cur_cy (b[1] b[3]) / 2 if abs(cur_cy - last_cy) / h y_ratio: lines[-1].append(b) else: lines.append([b]) # 对每一行按 x0 排序 result [] for line in lines: line sorted(line, keylambda b: b[0]) result.append(line) return result注意y_ratio这个参数。营业执照的固定版式里相邻两行字间隔一般大于 0.5 倍行高所以 0.3 是一个安全值。如果遇到套打的执照喷墨打印时纸没放平这个值要放宽到 0.45但放宽后上下两行紧挨的场景会误合并。更稳健的做法是拿到检测框的垂直中心分布直方图用找峰的方式确定行间距而不是硬编码比例。6.2 验收时用“字段命中数”而不是像素 IoU落地验收时我发现像素级 IoU 掩盖太多问题。一个检测框如果只框住了“统一社会信用代码”的三分之二IoU 可能只有 0.5被判为漏检但它足够让下游 OCR 识别出大部分字符。反过来一个框完全覆盖了整行IoU 很高却把上下的边框线也包进来OCR 会把边框线识别成乱码。所以我手工标注 50 张执照统计每个字段的“检测框覆盖了该字段文本区域的百分之多少”超过 80% 就记为命中。之后每一步参数调整都对比这个命中数是否有提升。另一个让我踩过多次的坑是对比实验不固定输入尺寸。你用 640 训练、1024 推理得到的指标提升可能是尺寸带来的而不是模型调整带来的。我把训练和验证的输入分辨率都锁死在同一个值最后上线前才单独测一遍不同分辨率下的延迟再做权衡。希望这些细节能让你少走一圈弯路祝你的执照识别早日跑通。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?