我最早接触CTPN是因为一个看起来特别简单的需求在图片里找一行数字。当时试着用通用目标检测模型去框文本结果令人头大——数字与数字之间间距不均、长宽比极其夸张、背景还特别花哨。后来翻到CTPNConnectionist Text Proposal Network这篇论文才有一种“原来文本检测该这么做”的感觉。CTPN是深度学习文本检测里的经典方法它把文本行拆成宽度固定的细粒度候选框再用序列模型把这些候选框串成整行。这篇文章我会从原理、核心机制、训练细节到落地踩坑尽量把CTPN讲透给正在做OCR或者刚入门文本检测的开发者一份可以直接参考的笔记。1. CTPN在文本检测江湖中的定位为什么我至今还在用它1.1 场景文本和普通目标检测的差别做过目标检测的朋友都知道常规检测器擅长找“方块状”的物体。一个行人、一辆车、一只猫宽高比通常在一个合理范围内。但文本不是这样的一行文字可能宽度很大、高度很小长宽比能达到十几甚至几十字符和字符又挤在一起没有清晰的独立目标边界。把文本当普通目标直接检测会遇到一个很现实的问题——锚点Anchor不知道怎么设计才能覆盖所有形状。我早期直接用Faster R-CNN试过效果相当一般。模型经常把半行文字框成一个框或者把几个字符和背景粘在一起。后来明白一个道理文本的视觉特征是“线性”的一行字可以理解成由很多宽度近似相等的小块沿水平方向拼接而成。CTPN正是抓住了这一点它不直接预测整行文本框而是先生成一系列宽16像素的细粒度候选框再靠后续的文本行构造算法把它们拼起来。这个思路看起来“偷懒”却非常贴合文本的天然结构。就像拼拼图先拼出每一小片再连成大块比一上来就画一个完整方框要稳妥得多。1.2 CTPN在技术演进中的位置这些年文本检测方法已经更新了很多代比如EAST直接回归旋转四边形PSENet和DBNet用分割思路处理任意形状文本。但如果拿CTPN和它们放在一起看它依旧有自己清晰的位置。方法核心思路优势劣势CTPN细粒度候选框 序列建模 连接对水平长文本召回高模型小部署成熟不擅长任意方向、弯曲文本EAST直接回归旋转框支持多方向速度快对长文本和密集文本容易漏DBNet可微二值化的分割方法支持任意形状鲁棒性好依赖分割精度模型较重CTPN最大的价值在于它对“水平或近似水平的文本行”有着极强的建模能力。很多生产项目根本不关心街景广告牌里那种任意方向的文字只关心身份证、文档扫描件、车牌这些相对规整的场景。在这些场景下CTPN依然是性价比很高的选择。它不需要很深的模型用VGG16的前几层加一个双向LSTM就够了推理时间可控对CPU部署也相对友好。1.3 什么样的项目适合选CTPN根据我的使用经验下面这几种场景可以优先考虑CTPN卡证类OCR身份证、银行卡、驾驶证文字方向基本水平。文档扫描件文本行之间存在明显行间距CTPN能很好切行。车牌识别车牌上字符排列规整CTPN定位非常稳。表格识别的前置模块把单元格内的文本行准确检测出来再交给后续识别。不太适合的场景是街景广告牌、艺术字体、弯曲文本或随机方向文本。这类需求建议直接上分割类方法。如果你已经选了CTPN又遇到任意方向文本可以增加一个前置的角度矫正模块先把图片大致摆正再把结果送进CTPN。这个方法不优雅但非常实用我在项目里用过不止一次。2. 核心机制拆解16像素宽的细粒度候选框和文本行构造2.1 为什么固定宽度候选框是聪明的“偷懒”方式CTPN里每个候选框的宽度是固定的论文里定为16像素。很多人第一次看到这个设计会觉得奇怪文本行的宽度明明是千变万化的固定成16像素岂不是自断一臂实际上文本行虽然整体宽度不定但局部结构是有规律的。一行文字从左到右可以看成由一系列垂直方向的“小片”组成每个小片大约覆盖一个字符的一部分宽度大致相同高度随字符大小变化。固定宽度可以让网络把注意力集中到“这个区域是不是文本”和“这个区域高度多少”这两件事上不需要再为各种各样的宽度做复杂的锚点设计。这种处理方式还有一个额外好处特征图上的每个点天然对应一个原图16像素宽的局部区域候选框和特征位置是一一对应的不需要做复杂的滑动窗口或多次缩放。检测框宽度、文本行整体宽度都交由后续的合并过程解决极大简化了学习目标。从工程角度看这简直是“又偷懒又有效”的典型。2.2 垂直锚点与回归目标每个特征点会生成一组垂直方向的锚点论文中使用的是一组高度递增序列常见设置为11、16、23、33、48、68、97、139、198、283共10个锚点宽度固定为16像素覆盖从很小的字符到很大的标题文字。训练时用文本行级别的标注框来分配正负样本。判断标准是锚点与真值框的IoU大于0.7算正样本小于0.3算负样本中间区域不参与训练。需要特别注意CTPN的正负样本统计是按“细粒度候选框”算的不是按整个文本行算的。每个锚点只负责预测自己那一小段垂直范围内是不是文本。回归目标包括三个维度中心y坐标的偏移量、高度取对数后的偏移量以及左右边界细化的偏移量。中心坐标和高度用下面的方式归一化垂直中心偏移v_y (y_c^* - y_c^a) / h^a高度偏移v_h log(h^* / h^a)其中带星号的是真值带a的是锚点。这个归一化方式和Faster R-CNN非常类似。之所以把高度偏移取对数是因为文本高度跨度大直接用绝对像素值训练会让大文本的loss主导取对数后不同规模的文本可以被均衡对待。2.3 文本行构造算法把一串小框拼成一个大框模型输出的每个候选框都带一个“是文本”的置信度。要得到最终的文本行边界框还需要把散落的小框粘连起来。论文给出的构造算法核心是一个贪心策略先把候选框按x坐标排序然后从左边第一个框开始寻找它的“后继框”。满足两个条件才能连上水平方向距离足够近后一个框的左边缘与前一个框的右边缘距离小于一定阈值。垂直方向重叠足够高两个框在y轴上的重叠比例大于阈值。如果多个候选框同时满足就取置信度最高的那个。这样一直往后串直到无法找到后继框为止一整条链就是一个文本行。我用一个简单伪代码说明思路def build_text_lines(proposals, score_thresh0.7, overlap_thresh0.7, dist_thresh50): keep [p for p in proposals if p.score score_thresh] keep.sort(keylambda p: p.x1) groups [] for p in keep: if not groups: groups.append([p]) continue last groups[-1][-1] if (p.x1 - last.x2) dist_thresh and vertical_overlap(p, last) overlap_thresh: groups[-1].append(p) else: groups.append([p]) boxes [] for g in groups: x1 min([p.x1 for p in g]) y1 min([p.y1 for p in g]) x2 max([p.x2 for p in g]) y2 max([p.y2 for p in g]) score sum([p.score for p in g]) / len(g) boxes.append([x1, y1, x2, y2, score]) return boxes这段逻辑看着简单但实际中很多细节会影响效果。比如垂直重叠阈值设得太严格遇到有轻微倾斜的文本行就容易断链设得太松又可能把两行挨得近的文字串成一行。我的习惯是先统计一下项目里文本行的倾斜程度和行距再决定阈值。通常重叠阈值在0.6到0.8之间水平距离阈值在30到80像素之间需要根据图片分辨率调整。3. 模型结构细节VGG特征、BLSTM序列建模和全连接层3.1 特征提取为什么要选VGG16的conv5CTPN的特征提取部分直接用了VGG16的卷积层截取到conv5输出特征图相对原图缩放了16倍。这个选择看起来不“时髦”却非常务实。VGG16的预训练模型很容易获得迁移学习效果好conv5的感受野足够看到一个词级别的局部上下文又不会因为下采样太多丢掉细节信息特征图的每个像素对应原图16x16区域和候选框的16像素宽度形成天然对应关系。实际使用时输入图片通常会被缩放到固定高度宽度可以保持原比例也可以随机缩放。这样做的好处是保证候选框的最小宽度有意义避免图片被压得过扁导致文本信息损失。我在复现时习惯把图片高度统一成320像素宽度保持在几百到几千像素之间。太长的话可以切成几个patch分别跑前向再合并结果。3.2 双向LSTM让每个小块学会看上下文如果只用CNN特征每个位置的信息是独立的。一个字符局部可能像“横线”也可能像“竖线”很难单凭局部判断它是不是文本的一部分。但放在整个句子序列里看字符之间天然存在强约束前一个位置像字符、后一个位置也像字符那么中间大概率也是文本区域。CTPN在这个位置引入了双向LSTM。具体做法是把特征图reshape成序列以特征图的x方向作为时间步每个时间步的输入是当前x位置垂直方向上所有通道的拼接向量。换句话说每个“小块”被放进了水平方向的上下文中。双向LSTM分两个方向分别编码信息一个从左往右看一个从右往左看。这样可以同时利用左右两边的内容来增强当前小块的特征。比如识别到“号”“码”两个字就能推断中间的空格区域很可能是文本的一部分即使那个区域的局部视觉特征并不明显。这一点在长文本行尤其关键也是CTPN对比纯CNN方法的重要优势。3.3 输出分支设计在双向LSTM之后网络会接一个1x1卷积或全连接层把每个位置的特征映射成5k个输出值这里的k是锚点数量。默认k为10所以每个位置输出50个值分成三组2k个值每个锚点是文本还是非文本的二分类分数。2k个值每个锚点对应的中心y坐标和高度回归量。k个值每个锚点的左右边界偏移量也就是side refinement。结构上可以这样理解输入任意尺寸图片缩放后送入网络。VGG16 conv1到conv5得到512通道的特征图尺寸是原图的1/16。3x3卷积对特征做进一步融合保持空间分辨率不变。Reshape准备进BLSTM把特征图按x方向展开为序列。双向LSTM编码上下文信息。1x1卷积输出5k维的预测结果。我在实际实现中会把BLSTM的输入维度算清楚因为这里特别容易出错。如果特征图高度是H那么每个时间步的输入向量维度就是512 * H。所以LSTM的input_size不是随便取的它跟特征图高度绑定。如果输入图片高度固定为320经过5次池化后H是20input_size就是10240。问题在于不同高度的图片输入同一模型时特征图高度不同LSTM的输入维度可能会变化。这也是CTPN落地时的一个麻烦点。4. 训练要点锚点划分、损失函数和样本不均衡4.1 训练数据准备和标签生成CTPN训练需要的是文本行级别的矩形标注不是字符级标注。我第一次做数据时以为需要精确标到每个字后来发现文本行框就够了。过程大致是加载一张图片和对应的文本行框对每个文本行框按16像素宽度切成多个小段然后让这些小段落到对应的锚点上。如果一个文本行总宽度不足16像素该怎么办这样的文本在检测阶段很难遵守CTPN的固定宽度假设通常直接忽略或者当作训练时的正样本锚点覆盖区域视项目需求而定。对场景文本检测来说过小的文本行就算检测出来后续OCR识别也没什么意义所以过滤掉是合理的。4.2 损失函数三件套CTPN的损失函数由三部分组成形式比较清晰分类损失每个锚点的文本/非文本二分类用交叉熵。垂直坐标回归损失对正样本计算中心y坐标和高度偏移使用smooth L1。边界细化损失对正样本计算左右偏移同样使用smooth L1。整体损失可以写为L L_cls λ1 * L_ver λ2 * L_side实践中λ1和λ2都取1就够了不需要太复杂的调参。要注意的是回归损失只对正样本生效负样本不参与回归计算否则大量背景锚点会把回归分支拖向“什么都预测成背景”的方向。为什么回归用smooth L1而不是L2因为文本高度差异很大小字可能20像素大字可能200像素L2对差异大的回归目标非常敏感容易让模型过于关注高文本、忽视矮文本。smooth L1在误差较小时是二次的误差较大时是一次函数能有效抑制异常值梯度训练更稳定。4.3 我踩过的坑正负样本不均衡和难例挖掘CTPN训练里最让人头疼的是正负样本不均衡。一张自然图片里文本区域通常只占很小一部分大量锚点都是背景。如果所有负样本都参与loss计算模型很快学会“全部判为背景”这种懒惰行为。我第一次训练时就遇到这个情况训练两轮后分类loss很低但检测效果完全稀烂所有文本都没框出来。后来做了三件事才救回来增加在线难例挖掘。每个batch按照分类loss从高到低排序只保留正样本和少量难负样本参与反向传播正负比例控制在1比3左右。数据增强时做“文本区域放大”。随机裁剪时尽量保证裁剪区域内包含文本单纯全局缩放对自然场景的帮助有限。对短文本行放宽正样本IoU阈值。比如文本行高度很小、锚点很难完全对齐时IoU高于0.5也可以算正样本减少正样本缺失问题。这些办法后来发现并不只适用于CTPN很多基于锚点的检测器都吃这套。关键是要理解模型不是靠一堆简单负样本学会检测的它需要看到有挑战性的负样本和充足的正样本才能真正把决策边界学会。5. 从论文到落地我在真实场景里遇到的三类问题与改进5.1 长文本行断裂问题真实场景中最常见的问题就是长文本行中间断掉。可能是文本中间有空格可能是几个字符对比度太低也可能是采集图像模糊导致某些区域卷积特征不清晰网络把它们判成了非文本。遇到断行我一般分两步处理。第一步是降低后处理里的分数阈值比如从0.7降到0.5让更多低置信度的候选框能进入文本行构造。第二步是在文本行构造算法里允许一定程度的“跳跃连接”也就是当两个候选框之间有少量空隙时如果两者的垂直中心线偏差很小、高度近似就依然把它们视为同一文本行。还有一个更粗暴但有效的办法在构建文本行时不要求每个候选框都必须超过阈值只要一个连续区域内超过阈值的框占比足够高就把整段视作文本行。这有点像做断点续传牺牲一点点准确率换取召回率。对我们这种识别类项目来说漏检比误检更致命所以宁可多框一点也不让文本断掉。5.2 倾斜文本的局限与我的替代做法CTPN对倾斜文本比较敏感这算天生的短板。如果文本行整体倾斜超过30度简单的水平候选框就很难覆盖字符区域导致检测框歪歪扭扭甚至漏检。我的做法是分层次应对。如果项目里的图片倾斜角度可控比如扫描件偏移很小可以直接靠数据增强加上随机旋转正负10到15度让模型适应轻微倾斜。如果图片倾斜明显比如手机随手拍的身份证就必须加前置矫正。常用的方案是用一个轻量级的文本框检测或者边缘检测找到文本区域的主方向做一次仿射变换把文本拉直再送进CTPN。有一次做表格识别发现某些单元格的文字带了约15度倾斜直接把CTPN勉强跑出来的框拿去做透视矫正后面OCR识别率惨不忍睹。后来在流程里多加了一个快速的主方向估计用OpenCV的minAreaRect求文本区域的旋转角度矫正后再检测稳定很多。要记得CTPN的定位是“水平文本检测器”别指望它单枪匹马处理所有方向。5.3 部署性能优化从Python到端侧CTPN的VGG部分占了大部分FLOPsBLSTM参数不算多但内存访问频繁如果没有优化好速度反而会被它拖慢。我在部署时走过不少弯路简单分享几个有效手段。转ONNX是常规操作但LSTM在ONNX里的表现有时不稳定特别是动态时间步推进。最稳的办法是固定输入宽度把LSTM展开成固定步数例如256步超过部分截断。TensorRT加速卷积组合算子和长条LSTM效果都还可以。我实测过一张512x512的输入在入门级GPU上大概能在30到60毫秒内完成前向足够做实时OCR流。如果部署平台对LSTM支持不好可以考虑用一个1D卷积替代BLSTM的前后向过程。精度会掉一点但速度提升非常明显。文本检测本身不一定要那么强的序列建模能力很多轻量场景完全够用。CPU上建议先用INT8量化VGG结构对量化很友好BLSTM稍有精度损失但整体可控。优化完以后CTPN最占时间的地方往往变成图像的缩放和候选框的后处理。记得别把时间都耗在模型加速上忘记后处理脚本那部分用纯Python写传输数据也可能吃掉几十毫秒。6. 手把手复现一个轻量版CTPN思路伪代码与关键参数6.1 搭建网络骨架的PyTorch思路下面是一个极简的PyTorch风格网络结构主要看思路不是完整可直接运行的代码。import torch.nn as nn class CTPN(nn.Module): def __init__(self, k10): super().__init__() # 使用VGG16的conv1到conv5作为特征提取器 self.features build_vgg_conv5() # 输出通道512 self.rpn_conv nn.Conv2d(512, 512, 3, padding1) # BLSTM: input_size 512 * feat_h # feat_h取决于输入图片高度例如输入高度320下采样16倍后是20 self.lstm nn.LSTM(input_size512 * 20, hidden_size256, bidirectionalTrue) self.fc nn.Conv2d(512, 5 * k, 1)这段代码最大的提醒是BLSTM的input_size。很多人直接硬编码一个固定数字一旦输入高度变了模型就跑不起来。我的做法是让输入图片高度保持固定比如统一resize到320这样就绕开了动态维度问题。宽度可以变化因为x方向是时间步方向LSTM对序列长度本身不敏感。6.2 Anchor生成与匹配Anchor生成是最容易出错的地方之一。基础参数记住这几点宽度固定为16像素。高度集合设为[11, 16, 23, 33, 48, 68, 97, 139, 198, 283]。在特征图上每个位置生成10个anchor中心点位于该特征位置对应的原图中心。匹配标签时要记得先计算每个anchor与所有文本行框的IoU取最大值作为匹配结果。IoU大于0.7的设为正样本小于0.3设为负样本中间区域忽略。对正样本还要计算回归偏移量也就是前面提到的中心y、高度对数和左右偏移。有一个细节anchor的中心x坐标不需要回归因为固定宽度条件下候选框的中心x天然由特征图位置决定不需要模型预测。这个“偷懒”设计让问题又简单了一层。6.3 后处理文本行合并的简单实现前面已经给过合并的伪代码。真正写实现时需要注意边界情况比如某些图片里只有单个孤立字符候选框数量很少此时要决定是否仍然输出一个单独的框再比如候选框列表为空时直接返回空列表。这些边界处理不复杂但漏掉任何一个都可能在后续环节报错。我在生产代码里还会额外记录每个文本行的得分作为后续OCR识别的置信度参考。如果一个文本行得分偏低说明检测结果可能来自边缘区域或模糊区域识别阶段可以跳过或做二次确认。6.4 跑通Demo的最小训练步骤想快速验证自己写的CTPN有没有问题可以按这个顺序来先准备一个合成数据集。用字体渲染生成纯色背景的文本图片文本行都是水平排列先不管复杂背景。这一步只验证网络能不能正常收敛。固定输入高度为320batch设为8到16学习率1e-4用Adam优化器。训练两三个epoch观察分类loss和回归loss是否同步下降。如果分类loss降到很低但回归loss纹丝不动大概率是正样本匹配出了问题。推理时跑同样的图片先看候选框是否正确输出再看文本行合并是否得到整行。确认链路走通后再换成真实数据在背景多样化、存在光照变化的数据上微调。我复现时踩过最大的坑是锚点坐标映射错误。特征图上的每个点要乘以16的缩放系数才能回到原图坐标但VGG的卷积和池化会导致坐标偏移半个像素。虽然影响不大但调试时总感觉框偏了几个像素。后来统一在锚点生成时做了0.5的偏移修正顺眼多了。最后聊点个人感受。CTPN其实已经不算新了但它的设计思想放在今天依然很值得学习对文本“序列化”的理解、固定宽度候选框的取舍、双向LSTM带来的上下文建模这些都是理解了就受用很久的东西。现在不少更先进的检测器里都能看到CTPN的影子比如把候选框换成更灵活的表示、在分割图里加序列约束等等。如果你正准备入门OCR与其一上来就啃那些复杂的分割模型不如先把CTPN彻底跑通一遍很多概念会清晰很多。后来我在几次项目里甚至在纯文档场景中用“去掉BLSTM的CTPN”跑出了接近原版的效果速度还快了一倍。这说明模型设计服务于场景论文里的模块并不一定每个都必须留。真正值钱的是你能看清哪些机制对当前问题起到关键作用哪些可以大胆删掉。这就是CTPN给我的最大启发。