ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

CTPN文本检测算法:从片段提议到序列拼接的完整解析

CTPN文本检测算法:从片段提议到序列拼接的完整解析

那阵子在给某票据识别项目做初版方案,遇到一个挺尴尬的情况:通用目标检测模型检测车牌、印章这类紧凑目标时效果还行,一旦面对发票上密密麻麻的印刷体、街景里长短不一的招牌文字,检测框就开始乱跳,要么一个框框住半行字,要么把几个词硬并到一起。后来查资料发现,文本检测模型CTPN(Connectionist 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就是锚点数量,这里k=10;
  • 2k个垂直方向坐标回归量,包括中心y坐标和高度h;
  • k个水平边界偏移量,用于端点的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_dim=512, k=10): super().__init__() self.k = k self.conv1 = nn.Conv2d(feat_dim, 512, 3, padding=1) self.lstm = nn.LSTM(512, 128, bidirectional=True, batch_first=True) 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, width=16): # 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

说句实话,新项目如果从零起步且文本形态复杂,我不会首选CTPN,DBNet这一类的分割方案在灵活性上优势太明显。但有几个场景我依然觉得CTPN是更合适的答案。

一是业务数据以水平文本为主,比如票据、证件、车牌、固定视角的货架标签。这类场景里CTPN的检测稳定性和调参成熟度都很高,踩坑经验丰富,部署方案到处都是。二是老项目里已经有完整的CTPN检测管线,OCR识别模型也都是基于轴对齐或轻微旋转的检测框训练的,这时候为了一个偶尔出现的弯曲招牌去重构整个检测模块,成本高收益低,不如把弯曲样本单独立一个旁路处理。三是对推理延迟极其敏感的CPU部署场景,CTPN这种“检测头轻、主干重”的结构,优化手段比较成熟,量化、剪枝、算子融合都有现成经验可参考。

6.3 一个提高倾斜文本检测精度的小技巧

最后分享一个我自己在项目中反复使用的后处理技巧。CTPN合并出来的文本框是轴对齐矩形,遇到拍摄角度倾斜的文字,直接送识别模型经常会把下端的内容裁掉一点。我的做法是:把所有组成某个文本链的proposal中心点收集起来,用最小二乘法拟合一条直线,让这条直线方向作为文本行主轴,然后以主轴为基准生成旋转框。这样不修改CTPN的任何结构,就能在不稳定边缘预测的情况下,把倾斜检测的精度显著拉起来。实测下来,对30度以内的倾斜文字,这个技巧比直接回归旋转框稳定得多,也基本不影响推理速度。

文本检测模型的选型和调优,说到底是对“文本是什么”的理解。CTPN用一维序列的观点看文本,今天看依然有很强的启发性。如果你的任务恰好和它的假设匹配,这套老方案依然能打。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进