CTPN文本检测算法:从片段提议到序列拼接的完整解析
那阵子在给某票据识别项目做初版方案遇到一个挺尴尬的情况通用目标检测模型检测车牌、印章这类紧凑目标时效果还行一旦面对发票上密密麻麻的印刷体、街景里长短不一的招牌文字检测框就开始乱跳要么一个框框住半行字要么把几个词硬并到一起。后来查资料发现文本检测模型CTPNConnectionist Text Proposal Network几乎就是为这类问题设计的。它的思路很朴素但很有效把文本当作一串有顺序的“小片段”来检测再用网络学习文本的上下文关系最终拼成完整的文本行。如果你正在做OCR前置检测、票据结构化、车牌识别这类任务又想知道这个老牌模型为什么至今仍有人用这篇就把CTPN的检测原理、训练细节和实操坑位一次讲清楚。1. 为什么自然场景下文本检测不能套用通用目标检测1.1 文本与普通物体的结构性差异拿通用目标检测的思路去处理文本问题出在文本根本不是“一个物体”而是一条结构化的序列。普通目标检测器内部普遍依赖固定尺度的锚点、二维框回归和NMS这套逻辑对“一辆车”“一个人”这种紧凑目标很合适但文本行的特性完全反着来。文本行的长宽比可以极端到一个夸张的程度。一行广告词可能是1:8甚至1:12而字符高度可能只有二三十个像素。锚点设计里即使覆盖了多种长宽比为了同时兼顾“一个字”和“一行字”检测器要在同一个特征图上铺大量长条锚点计算量涨上去召回率还未必跟上。再加上文本行内部是高度重复的密集字符字符之间、标点和背景之间经常没有清晰的边界一个文本行如果被强行框成一个目标标注本身就存在歧义到底是一个完整句子、一个词组还是一个字这种语义层面的模糊是通用框检测很难直接学习的。还有个更现实的问题自然场景下的文本背景极其杂乱。玻璃反光、招牌透视、树叶遮挡都会让一个完整文本行以碎片形式出现在画面上。通用目标检测先生成框再分类的范式很难把一堆碎片重新拼成一整行。这里我习惯用一个糖葫芦的类比。一个文本行像一串糖葫芦通用检测试图用一个盒子把整串包住结果经常把旁边的糖纸、手指、背景树影也包进去而文本检测真正该做的是先找到每个山楂球再按竹签方向把它们串起来。CTPN就是把这条路走通了。1.2 CTPN的核心思路先找片段再按序列拼接CTPN的做法可以浓缩成三个关键词细粒度文本提议、序列上下文、侧边修正。它先把一个完整的文本行沿水平方向切成若干个固定宽度的小段每个小段大约对应原始图片上16像素宽的窄条。网络要做的是判断每个窄条里是不是有文字并回归出这个窄条内文字在垂直方向上的完整高度。这一步叫fine-scale text proposal本质上把二维的整行框定位拆成了“一列一列找字”的一维问题。但光有一列一列的独立预测肯定不行字符之间会有空隙标点会造成信号中断一个窄条单独看可能预测得不准。CTPN在特征图水平方向上接了一个双向LSTM让每个位置的信息可以沿文本行方向流动相当于让模型理解“我这一列前后还有文字所以我也应该是文字的一部分”。这个序列上下文机制是CTPN区别于早期文本检测算法最核心的一点。最后后处理阶段把所有高置信度的小片段按位置排序、合并得到一个完整的文本行。合并时还会用网络额外预测的水平偏移对文本行的左右边界做一次精修。这样设计的直接收益有两个。第一不需要设计各种极端长宽比的锚点因为模型根本不试图一次框住整行文字第二对密集、长距离的文本行片段级的预测天然更稳漏检一小段至少还能靠前后上下文拉回来。理解了这一点后面看网络结构和训练细节就顺了。2. 网络主干与前向计算流程2.1 从VGG16到特征图16像素宽度的来历CTPN的主干用的是公开实现里最常见的VGG16特征提取器但不使用后面的全连接分类层只取到conv5这一层特征图。VGG16的conv5特征图相对于原始输入下采样倍率是16。换句话说特征图上的一个像素对应原始图片上一个16x16像素的感受野中心区域这就是CTPN里小片段宽度固定为16像素的直接来源。不是拍脑袋定的而是由骨干网络的stride决定的。这个设计有个很顺手的性质特征图每一列天然对应原图16像素宽的窄条。模型在空间位置上做一次预测就相当于给原图某一段16像素宽的列打标签。这样就不需要额外滑窗也不需要在水平方向生成一堆不同宽度的锚点。还有一点可以顺带解释虽然每个特征点嘴上说的是“感受野中心”但它实际看到的区域远大于16像素。VGG16到conv5的累积感受野大约是228像素左右。这个感受野足以覆盖一个特征点所在窄条的上下、左右若干像素模型判断“这一列是否属于文字”时能看到足够多的局部形状和周围环境。前向流程大致是这样一张任意尺寸的图片输入VGG16得到Bx512x(H/16)x(W/16)的特征图然后过一个3x3卷积做特征压缩再进入双向LSTM编码水平方向上下文最后接三个并行的预测头。整体结构清爽不算复杂。2.2 十个垂直锚点与RPN头的设计目标文本的高度差异很大一行小五号字体可能只有20像素高一条横幅标题可能有200像素高。如果只用固定高度锚点垂直方向的回归压力会非常大。CTPN借鉴了RPN的锚点思想但只针对垂直方向设计了10个不同高度的锚点覆盖从十几像素到接近300像素的文本高度。这个覆盖范围是根据真实应用里文本行高度的先验分布定的如果你处理的场景字号分布不一样比如全是超大号数字这组锚点高度要重新统计设计。每个特征点位置需要输出几组信息2k个文本/非文本分类得分k就是锚点数量这里k102k个垂直方向坐标回归量包括中心y坐标和高度hk个水平边界偏移量用于端点的side refinement。注意这里刻意没有做水平方向的偏移回归。原因是水平方向的位置关系最终靠文本线构造合并如果每个小框都独立回归水平边界很容易在字符间隙处产生裂缝反而不利于拼接。只回归垂直方向等于让网络专心管“这段里文字有多高”水平方向让序列信息去决定。用追求极致卷积框检测的思维看这种设计像是“降级”了但对文本确实合理。文本的本质是水平方向的序列垂直方向才是字符高度的关键维度二维框回归在这里反而是多余且有害的。2.3 双向LSTM在特征图上做什么把特征图按空间位置看每个特征点都携带一个512维的特征向量。CTPN把这些特征向量沿着水平方向按顺序排列形成一个序列序列长度就是特征图的宽度W/16。然后把这个序列送进一个双向LSTM输出的每个位置特征同时包含左侧和右侧的上下文信息。为什么要双向因为一个16像素窄条本身提供的信息太少了。举个例子两个汉字之间有个标点标点占的窄条单独看起来“不太像文字”但如果它前后都判定为文字它大概率就是文本的一部分。反过来如果前后都是空白它更可能是噪声。字符间隙、遮挡、反光造成的低级误判都可以靠上下文修正。双向的意义在于修正信息既能来自左侧也能来自右侧扇形的传播只有一个方向是不够的。工程实现时有几个容易出问题的地方。第一LSTM处理的序列长度是特征图宽度不同图片宽度不一样所以训练时要么把宽度固定成某个值要么做长度相同的batch并在padding位置加mask否则padding位置的双向状态会污染真实序列的特征。第二别把LSTM输出和特征图的空间位置搞混需要先把LSTM输出按batch、高、宽重新reshape回特征图布局再接预测头。下面给一个简化版PyTorch实现辅助理解前向结构import torch import torch.nn as nn class CTPNHead(nn.Module): def __init__(self, feat_dim512, k10): super().__init__() self.k k self.conv1 nn.Conv2d(feat_dim, 512, 3, padding1) self.lstm nn.LSTM(512, 128, bidirectionalTrue, batch_firstTrue) self.fc nn.Linear(256, 256) # 三个输出头 self.cls_head nn.Conv2d(256, k * 2, 1) # text / non-text self.vertical_head nn.Conv2d(256, k * 2, 1) # y, h self.side_head nn.Conv2d(256, k, 1) # side offset def forward(self, x): b, c, h, w x.shape x self.conv1(x) # b,512,h,w # 把特征图展开成序列每个位置取一列特征 seq x.view(b, c, h * w).permute(0, 2, 1) # b, h*w, 512 seq, _ self.lstm(seq) feat self.fc(seq).view(b, h, w, 256).permute(0, 3, 1, 2) cls self.cls_head(feat) vertical self.vertical_head(feat) side self.side_head(feat) return cls, vertical, side真实项目里还要考虑一行文字对应多行特征图的情况比如特征图高度大于1时LSTM实际上要按行分别处理。很多公开实现是把特征图按行拆开循环送入LSTM性能会差一些。优化做法是批量并行处理所有行但要注意保持每行序列独立不能让行与行之间串信息。3. 真实文本框如何变成训练标签坐标编码与正负样本分配3.1 从四边形标注到一组16像素小框CTPN的训练标签和一般目标检测不太一样。公开文本数据集通常按四边形顶点给出文本区域格式是四点坐标(x1,y1,x2,y2,x3,y3,x4,y4)。训练前需要把这些四边形转成一组小框标签。做法是沿着文本的长边方向按固定宽度16像素把整行文字切成若干段每个切段生成一个ground truth小框。小框的高度要完整覆盖该切段内的文字区域也就是说垂直方向要延伸到文本行的上下边缘水平方向就是切段的左右边界。这样一个长文本行可能被切成几十个小框每个小框对应特征图上的一个位置。这里有个容易迷糊的点锚点本身的宽度就是16像素所以切分宽度和锚点宽度天然一致。每个特征图位置负责原图16个像素宽度的列如果一个文本行占了100个像素宽度就会切出大约6到7个小框对应特征图上6到7个横向相邻的位置。切分时还要注意文本行的四边形可能带有倾斜。严格的做法是沿着倾斜方向做投影切分而不是简单按轴对齐矩形切。工业项目里如果倾斜角度不大很多人直接按外接矩形切也能训练出可用模型但检测精度会受一点影响特别是倾斜明显的场景。下面这段代码演示了轴对齐切分的核心逻辑可以作为理解参考def split_box_to_proposals(box, width16): # box: [x1, y1, x2, y2, x3, y3, x4, y4] 按四角点顺序 xs [box[0], box[2], box[4], box[6]] ys [box[1], box[3], box[5], box[7]] x_min, x_max min(xs), max(xs) y_min, y_max min(ys), max(ys) proposals [] cur x_min while cur x_max: left cur right min(cur width, x_max) proposals.append([left, y_min, right, y_max]) cur right return proposals真实工程里还需要考虑文本行太短的情况比如一个只有10像素宽的单字。这种情况下切分成一个宽度不足16像素的小框匹配锚点时仍然可以参与训练但样本量会很少通常需要靠数据增强补足。3.2 锚点匹配与正负样本规则有了小框标签后接下来要做锚点匹配。每个特征图位置有10个不同高度的锚点每个锚点都要在垂直方向上和对应的小框计算重叠程度。主流实现里通常用IoU或者垂直方向的交叠比例来判定样本类别。常见规则是锚点与某个ground truth小框的IoU大于0.7标记为正样本IoU小于0.3标记为负样本0.3到0.7之间的锚点不参与损失计算。这个阈值不是绝对的有些实现里把正样本IoU放宽到0.5因为文本小框本身高度变化大过严会损失大量正样本。背景锚点数量远多于文本锚点一个特征图可能同时覆盖几十万个位置负样本数量很容易把比例冲到100:1以上。训练时要对负样本做随机采样让正负样本比例接近1:3否则模型会倾向把所有位置都预测为非文本召回率惨不忍睹。3.3 损失函数组成与训练参数CTPN的损失函数由三块组成文本分类损失、垂直坐标回归损失、侧边偏移回归损失。分类损失就是对每个锚点预测的前景背景做softmax交叉熵。垂直坐标回归用的是Smooth L1损失和Faster R-CNN里的边界框回归是同一套思想参数量包括归一化后的中心y坐标和高度。侧边偏移回归同样用Smooth L1但只对正样本锚点计算因为只有正样本才存在真实的水平边界偏移。整体损失大致是L L_cls λ1 * L_vertical_reg λ2 * L_side_offset_reg权重λ1和λ2在原始设计里都取1实测下来这个配比在大多数场景下都能work。如果你发现文本行上下边界抖动厉害可以把θ1调高一些如果发现左右边界总偏就把θ2调大一点点。训练参数上几个项目跑下来比较“稳”的配置是这样的先用公开通用检测数据集上预训练过的VGG16权重初始化主干然后在自己的文本数据上微调。输入图像的长边一般控制在2000像素以内短边不要低于32像素否则小字的高度直接小于锚点下限找不到对应特征。数据增强这里特别提醒一句平移、小幅度的旋转15度以内、色彩抖动都可以用但不要做大角度旋转增强。CTPN的整个结构建立在“文本是水平序列”的假设上训练时引入90度旋转样本等于让模型同时学横排和竖排两种完全不同的连接模式很容易两头不讨好。4. 把几百个小框串成一行文本线构造逻辑4.1 过滤、排序与建立连接推理阶段网络输出的是每个位置上的多个锚点预测。第一步要按文本得分过滤把score低于阈值的锚点全部丢弃。这个阈值在0.7左右时精度比较干净但后续文本线构造会更严格我建议推理时用0.5到0.6先放进来靠后处理再筛一遍比一开始就卡太紧更不容易漏检。保留下来的proposal按左边界x坐标从小到大排序。排序之后需要判断哪些proposal应该被串进同一个文本行。判断规则通常包含三个条件两个proposal在水平方向上不能间隔太远垂直中心线的距离要足够近垂直方向的重叠比例要够大。水平方向不能间隔太远是因为同一个文本行里前后文字通常是连续的字符间隙不会特别夸张。垂直中心线距离近是因为同一行文字的中心线基本在一条直线上。垂直方向有足够重叠是为了排除同一个画面里上下两行文字被误连。满足条件的一对proposal会被连成一条边所有proposal和边组成一个图结构。接下来的任务是找到图中“最长的一条链”本质上是把相邻能连的proposal首尾衔接成一个文本行的候选。这个算法实现起来不难但场景稍微复杂一点就会露出坑来比如两行文字离得特别近高度又差不多连接判定就很容易把相邻两行串起来。所以实际项目中我对垂直中心距离的阈值会卡得比理论值更严宁可不合并也不能错行。4.2 合并、边界修正、NMS与旋转框输出找到一条文本链后把链上所有proposal合并成一个完整文本框。合并时水平左边界取链上所有proposal的x最小值水平右边界取x最大值垂直方向和高度则按照每个proposal的分数做加权平均。如果链内包含的proposal数量太少比如只有两个小框通常可以丢弃否则会在图片里留下大量碎片框。合并出来的矩形边界其实还不够准。模型在每个proposal上额外预测了水平偏移量也就是side refinement值。用这些偏移量把文本行的左右边界再修正一次能让检测框和真实文本的边缘贴合度明显提升。这一步虽然叫“精修”但实现成本很低效果却很值。再往后是NMS。注意CTPN的NMS阈值和通用目标检测不一样通用检测常用0.5到0.6文本检测建议取0.4左右。因为同一个文本行如果因为中间有空隙被切成了两段这两段的IoU可能很高阈值太松会把本来属于同一行但恰好多段输出的框全部消掉反而造成漏检。如果业务上需要输出旋转框一种做法是对合并出的轴对齐矩形用最简单的minAreaRect包装成旋转矩形。但我的经验是对于拍摄倾斜的文本直接对文本行内所有proposal的中心点做直线拟合再根据拟合直线方向旋转坐标得到的框往往比minAreaRect更贴合真实文字走向也更稳定。整个后处理流程虽然原始论文是早期产物但放到今天看思路仍然清晰候选生成、序列组合、几何修正、冲突消除每个环节都有明确目标调试起来也知道往哪个方向调。5. 复现过程中的踩坑记录与调优建议5.1 长文本被切成多段召回率被拉低第一次复现CTPN时最容易遇到的问题是一行很长的文字被输出成两三个互相重叠的框。检查loss发现训练很正常问题基本都出在后处理上。一个常见原因是score阈值设太高。长文本里如果某个片段因为笔画稀疏、光照反光分类得分只有0.6阈值设在0.7时它就被滤掉了原本连续的文本链从中间断掉后面自然合并不到一起。把阈值降到0.5再用更严格的连接判定补上来召回率会明显改善。另一个原因是文本线构造里的水平间隔阈值太严格。中文数字里经常有空格空格宽度稍大一点字符片段和片段之间的水平距离就会超过阈值。解决方法是把水平间隔阈值放宽但同时对垂直中心线距离做更严限制防止不同行的文本被连起来。训练侧也有办法。如果数据里长文本占比太少模型对“长链”的感知本来就弱可以对已有长文本做水平拼接增强把两行长文本拼成一行超长文本强行增加长序列样本。5.2 竖排文本和弯曲文本是硬伤CTPN的设计里明确假设文本行是水平方向的序列。遇到中文竖排文本特征图按水平方向跑LSTM竖排文字在水平方向上几乎没有连续性结果就是检测框大面积漏检或错叠。最省事的办法是两阶段处理把输入图像旋转90度再检测一遍把两次检测结果合并竖排文字在旋转后变成了横排CTPN就能正常处理。代价是推理时间翻倍但对竖排占比不高的业务来说足够划算。弯曲文本则基本是CTPN的盲区。文本线构造只能在水平方向上把proposal串起来弧形文字串到中间就会断开。如果你的场景大量是招牌、灯箱上的弧形文字不要试图在CTPN上做修补直接考虑分割类文本检测算法比如后起之秀中的DBNet路线它们的核心机制天然支持任意方向文本。5.3 推理性能与部署优化CTPN的骨干是VGG16这个结构放到今天看计算量不算低但优点是部署成熟。我在CPU上跑一套包含检测和识别的OCR流程CTPN检测部分耗时几百毫秒在GPU上输入800x600左右的图耗时能压到几十毫秒级别做小流量业务完全够用。部署优化有几个方向一个是把VGG16的后几层卷积替换成可分离卷积或直接剪枝另一个是把双向LSTM的序列长度做限制只保留有效文本区域的特征列减少无用计算。还有一个常见的做法是转成量化模型检测头的浮点计算在量化后精度损失很小但推理速度能提升不少。另外提醒一句如果检测结果要送进后续OCR识别模型CTPN输出的文本框建议统一转成四角点格式并且按顺时针排序。很多识别模型对输入框的顶点顺序有要求顺序乱了识别准确率会雪崩。6. CTPN与新一代文本检测算法的对比现在怎么选6.1 核心思路与能力横向对比这些年文本检测算法迭代很快网上讨论比较多的还有EAST、DBNet、SAST等路线。它们和CTPN的核心差异在于检测建模方式。算法思路核心机制水平长文本弯曲/竖排文本工程成熟度CTPN片段提议序列连接比较好不擅长高参考资料多EAST直接回归旋转框中等一般较高DBNet分割可微二值化好擅长较高SAST分割中心线拟合好弯曲文本好中等从表格能看出一条清晰的分水岭CTPN是“序列派”文本行在它眼里是一维序列所以对水平长文本有天然优势特别是密集的、一行几十个字的中文文本片段级建模比“一次回归整框”更稳。而分割派的算法把文本检测当成像素级分割问题天然不关心文本行的方向所以弯曲、竖排、任意角度文本都更适合分割派。6.2 什么情况下我仍然选CTPN说句实话新项目如果从零起步且文本形态复杂我不会首选CTPNDBNet这一类的分割方案在灵活性上优势太明显。但有几个场景我依然觉得CTPN是更合适的答案。一是业务数据以水平文本为主比如票据、证件、车牌、固定视角的货架标签。这类场景里CTPN的检测稳定性和调参成熟度都很高踩坑经验丰富部署方案到处都是。二是老项目里已经有完整的CTPN检测管线OCR识别模型也都是基于轴对齐或轻微旋转的检测框训练的这时候为了一个偶尔出现的弯曲招牌去重构整个检测模块成本高收益低不如把弯曲样本单独立一个旁路处理。三是对推理延迟极其敏感的CPU部署场景CTPN这种“检测头轻、主干重”的结构优化手段比较成熟量化、剪枝、算子融合都有现成经验可参考。6.3 一个提高倾斜文本检测精度的小技巧最后分享一个我自己在项目中反复使用的后处理技巧。CTPN合并出来的文本框是轴对齐矩形遇到拍摄角度倾斜的文字直接送识别模型经常会把下端的内容裁掉一点。我的做法是把所有组成某个文本链的proposal中心点收集起来用最小二乘法拟合一条直线让这条直线方向作为文本行主轴然后以主轴为基准生成旋转框。这样不修改CTPN的任何结构就能在不稳定边缘预测的情况下把倾斜检测的精度显著拉起来。实测下来对30度以内的倾斜文字这个技巧比直接回归旋转框稳定得多也基本不影响推理速度。文本检测模型的选型和调优说到底是对“文本是什么”的理解。CTPN用一维序列的观点看文本今天看依然有很强的启发性。如果你的任务恰好和它的假设匹配这套老方案依然能打。