做 OCR 项目这几年,我在文本检测这一步上花的时间,比对识别模型本身花的时间还多。原因很现实:识别模型再强,检测框给歪了、切碎了、漏掉了,后面的一切都是白搭。今天要聊的 CTPN,全称 Connectionist Text Proposal Network,是我最早系统吃透、也反复用在真实业务里的一个文本检测网络,哪怕后来各种更快的检测头层出不穷,它在水平文本场景下的稳定性和可解释性依然是很多项目默认的首选。这篇把 CTPN 从设计思路到训练部署的完整链路讲透,适合刚入门 OCR 的算法同学,也适合准备在老项目里替换或改进检测模块的工程师。
1. 为什么通用目标检测器做不好文本检测
1.1 文本不是“物体”,而是细长的序列
我见过不少团队在接到文本检测需求时,第一反应就是把 Faster R-CNN、SSD 甚至 YOLO 拉过来直接训。短文本、印章这类相对方正的场景还能凑合,一旦遇到说明书上的小字、票据里的长串号码、或者海报上占了半屏的标语,通用检测器就全线崩溃。问题出在哪?出在我们对“目标”的定义完全不同。
通用目标检测面对的是“物体”,瓶子、汽车、人、猫狗,这些目标有明确的封闭轮廓,长宽比基本都在 0.5 到 2 之间。而文本行不是这样的,一句话可能只有 4 个字符,也可能有 50 个字符,长宽比从 1 到 30 甚至更高,字符之间有大小写、笔画疏密、空格,形态极度不规律。你很难让一个为矩形物体设计的 anchor 体系去覆盖这种极端长宽比分布,也很难让一个滑动窗口感受到“这一串东西是一个语义整体”。
更麻烦的是,文本行内部没有清晰的物体边界。通用目标检测器里,目标就是目标,背景就是背景,RPN 只需要区分这两类。文本行不一样,它内部有很多空白和半字符区域,同一个文本行里并不是每个像素都属于“文字”,但检测框却要完整包住它们。这就导致模型学到的特征响应经常是离散的、碎片化的,要么只激活了每个字符所在的小块,要么把背景也一起激活了。
1.2 直接套用 Faster R-CNN 会踩到什么坑
就算你把 Faster R-CNN 的 anchor 长宽比改成 1:5、1:10、1:20,硬让它去拟合长条形文本行,还是会遇到几个绕不开的问题。
第一个是正负样本严重失衡。极端长宽比的 anchor 数量天然庞大,但真正落在文本行上的比例极低,大部分长条 anchor 跨在背景上。训练时分出的正样本太少,负样本太多,模型会偏向把一切判定为背景,召回率惨不忍睹。
第二个是回归目标不稳定。文本行的高度和宽度变化范围极大,横跨几十个像素的长文本行,其坐标回归难度远超一个标志牌或者一个水杯。小误差在短文本上可能只偏几像素,在长文本上就可能让右侧端点偏出半个框,导致下游识别直接把最后几个字符切掉。
第三个是感受野的矛盾。要判断一个像素是不是文本序列的一部分,你需要看到它左右两侧的上下文;但通用检测器里的卷积感受野往往是方形的、对称的,对水平方向的序列信息并不敏感。结果就是,模型经常把单独一个汉字当成一个有效目标,而无法确认它到底属于哪一行。
我当时在实际项目里最直观的体验是:用通用检测器跑一段 30 个字符的发票号,它会输出七八个碎框,每个框认定里面是“文字”,但没有任何一个框能把整行串起来。这不是调参能解决的问题,是设计范式的问题。
1.3 CTPN 的破局思路:把检测框拆成文本片段
CTPN 最核心的决策,是不再试图一次性预测完整的文本行框,而是把文本行拆成一连串固定宽度的“文本片段”,每个片段只负责预测自己的高度和中心位置,最后再做一次连接,把相邻的片段拼成完整的文本行。用大白话说,这就像把一句话拆成一行积木,每块积木只管自己那一小块,拼起来就是完整的意思。
这里面有两个关键转变。
第一个转变是从“矩形检测”变成“序列预测”。文本行天然是序列结构,字符从左到右依次排列,有强上下文关系。CTPN 把网络设计成了 CNN 提取特征 + Bilstm 建模序列的结构,这就比纯卷积网络更贴合文本的本质。
第二个转变是让 anchor 适应文本的局部一致性。文本行的高度在局部范围内是基本恒定的,所以 CTPN 只使用“垂直方向”的 anchor——固定宽度、变化高度,只回归每个 anchor 的中心 y 和高度。这个设计大幅压缩了回归空间,让模型更容易收敛,也让后续的文本行连接有了物理依据。
所以从思路上看,CTPN 不只是一个检测模型,它把文本检测重新定义成了“检测密集 anchor 序列 + 序列连接”的组合任务。这种定义方式,让它在水平文本场景下天然比通用检测器更稳。
2. CTPN 网络结构拆解:从骨干网络到三路输出
2.1 骨干网络:VGG16 特征提取与感受野考量
CTPN 的骨干网络用的是 VGG16,准确说,是用到 conv5_3 这一层输出的特征图。图片输入网络之前会先缩放到固定尺度,常见做法是把高度缩放到 720 左右,或者宽度限制在 960 以内,具体看显存和业务目标大小。VGG16 的 stride 到这里是 16,也就是说原始图像上 16x16 的区域,在特征图上对应一个像素点。
为什么不选更轻的骨干,也不选 ResNet?历史原因是一部分,但从效果上看,VGG16 的 512 通道特征在文本检测这种任务上非常够用。文本检测需要的是对笔画边缘、字符纹理、局部间距的精细感知,这些特征在浅层和中层卷积里响应明显,VGG16 这种“深而窄”的结构反而比较容易稳定训练。换 ResNet 做骨干也不是不行,但梯度路径更复杂,早期 CTPN 开山时大家没有太多现成的预训练权重可用,VGG16 就是最省事、最稳的选择。
我自己的经验是,骨干网络的选择要看你处理的文字尺度。如果业务里有很多小字,就需要注意 VGG16 的 stride 16 会把小字的细节磨掉很多,特征图上一个小字可能只剩一两个响应点。这种情况下要么放大输入图像,要么换更浅的骨干配合多尺度特征,不能指望 VGG 硬扛。
2.2 序列建模:Bilstm 为什么对文本有效
CTPN 真正区别于当时其他检测器的地方,是在卷积特征之上接了一个双向 LSTM。这一步在很多人看来是“玄学”,但它有非常扎实的依据。
卷积神经网络在任意一层上的输出,光看一个局部窗口,是分不清“这个像素是文字中间还是文字边缘”的。文本行的语义要靠上下文才能确定,比如你看到“地址”两个字,左侧可能是“收件人”,右侧可能是具体内容,模型如果能感知到这种顺序关系,就能更准确地把同一行的文字聚在一起。Bilstm 的作用就是提供这种跨距离的序列记忆能力。
具体实现上,CTPN 把特征图按宽度方向看成一条序列。假设特征图是 H x W x 512,每个位置沿宽度方向展开,就得到一个长度 W 的序列,每个时间步对应一个特征列向量。把这条序列送进双向 LSTM,每个方向 128 个隐藏单元,两个方向拼接后得到 256 维,再接一个全连接层映射到 512 维。
实测下来,去掉 Bilstm 直接接全连接,短文本检测差异不大,但连续长文本的召回会明显下降,尤其是行内有空格、字间距较大或者排班很密的场景,断行率能涨十几个点。原因很直观:卷积只能看到固定感受野范围内的局部信息,而 LSTM 可以在几十甚至上百个像素的范围内传递“我还在同一行”的上下文信号。这也是 CTPN 在长文本上比同期很多纯卷积方案强的原因。
2.3 三路输出:分类、垂直回归、边线细化
Bilstm 输出接全连接层之后,网络会分成三个分支,对应三种不同任务。这是理解 CTPN 的关键,很多人看论文容易把这三分支混在一起。
第一路是分类分支,输出 2k 个通道。这里 k 是每个特征点上的 anchor 数量,默认是 10 或 11 个,2k 对应每个 anchor 的“是文字 / 非文字”两个得分。这一路直接决定这个位置有没有文本片段存在。
第二路是垂直回归分支,同样输出 2k 个通道。每个 anchor 回归两个值:中心点 y 坐标的偏移量,以及高度的缩放量。因为文本片段宽度固定为 16 像素,所以不需要回归 x 和 w,只需要把每条“竖条”放到正确的纵向位置、调整到真实文字高度即可。
第三路是边线细化分支,对每个 anchor 输出其左右端点的水平偏移,用来精细化文本行的左右边界。别小看这一路,后面文本行构造时,如果只靠 16 像素宽的 anchor 去对齐边界,误差会不断累积,最后整行的右边界可能偏出几个像素甚至更多。边线细化就是用来修正这种累积误差的,我在证件类项目里实测过,开着这一路分支,下游识别对最后几个字符的切分错误率能降一半以上。
三个分支共享同一份卷积和 Bilstm 特征,只有最后的全连接层参数不同,所以推理时计算量几乎没有额外增加。这就是 CTPN 能保持不错速度的原因之一。
3. 核心机制:vertical anchor 与坐标回归
3.1 固定宽 16 像素的 anchor 设计
CTPN 里 anchor 的设计很特别,它摒弃了通用检测器里那种“各种长宽比、各种尺寸”的 anchor 组合,只保留了一种形态:宽度固定为 16 像素、高度在一定范围内变化的竖条。这个 16 是 VGG16 在特征图上的采样步长对应的原始图像尺寸,换句话说,特征图上的一个点对应原图一个 16x16 的区域,那么 anchor 宽度定为 16,正好让每个特征点对应一个文本片段位置。
那高度怎么定?CTPN 在每个特征点上同时生成 k 个不同高度的 anchor,论文里 k 取 10 或 11,高度值按照对数均匀分布在十几个像素到两三百个像素之间。这样设计的原因很直接:训练前你不知道数据里文字有多高,所以先用一组覆盖范围较广的候选高度让网络去选,再通过回归精调。真正落地时不要死抄论文里的那组高度,应该先对自己的数据集做一次文字高度统计,然后用统计分布重新设计 anchor 高度。比如电商小票里的文字普遍只有 15 到 25 像素,而门头招牌上的文字可能高达 300 像素,anchor 覆盖范围差着一个数量级。
这里有一个容易忽略的换算问题。anchor 是在特征图坐标系里定义的,但回归目标和最终输出都要映射回原图。由于 stride 是 16,特征图坐标乘 16 就是原图坐标,这个换算看起来简单,但一旦涉及多尺度输入或者裁剪推理,就特别容易出错。我在工程里吃过一次亏,把特征图坐标映射回原图时忘了乘 stride,导致所有检测框整体缩小了 16 倍,最后一句“请重新缩放输入”排查了大半天。
3.2 垂直 IoU 匹配与回归目标
CTPN 匹配 anchor 和真实框的时候,用的不是通用检测里的完整 IoU,而是只算垂直方向的 IoU。这个设计说出来你可能觉得简单,但它非常关键。
为什么只算垂直方向?因为 anchor 宽度固定为 16 像素,而文本行通常比 16 像素宽得多,一个 anchor 只覆盖文本行的一部分水平区间。在这种情况下,如果还按完整 IoU 去匹配,那几乎不可能有 anchor 和整个文本行框的 IoU 超过阈值。所以 CTPN 干脆只比较 y 方向上的交并比。假设 anchor 的 y 区间是 [ya1, ya2],真实框的 y 区间是 [yt1, yt2],垂直 IoU 就是这两个区间交集长度除以并集长度。
具体匹配规则是:垂直 IoU 大于 0.7 的 anchor 作为正样本,小于 0.5 的作为负样本。注意还有一个附加条件,anchor 的中心 y 坐标需要落在真实文本行的 y 范围内,否则即使垂直 IoU 很高也不能算正样本,这能防止 anchor 歪着匹配到文本行边缘。
回归目标是两个值:中心点 y 的偏移量 v_c,以及高度缩放量 v_h。计算公式可以理解为:
v_c = (cy_gt - cy_anchor) / h_anchor v_h = log(h_gt / h_anchor)这里 cy_gt 是真实文本行中心 y,cy_anchor 是 anchor 中心 y,h_anchor 是 anchor 高度,h_gt 是真实高度。除以 h_anchor 是为了把偏移量归一化,高度用 log 而不用直接差值,是因为高度跨度可能很大,用对数可以让小文字和大文字的回归误差处于可比量级。训练时回归分支的目标就是用 smooth-L1 损失去拟合这两个值。
我在训练初期踩过一个常见的坑:只看分类得分高就以为检测好了,其实垂直回归没收敛,导致所有预测框的高度一致偏大或偏小。文本行连接的时候又依赖 height 一致性,最后整个文本行构造成了一团乱麻。所以训练时建议单独监控垂直回归的误差项,而不是只看总 loss。
3.3 side-refinement 到底是干嘛的
side-refinement 这一路在不少讲解里被一笔带过,但它在精度敏感场景里非常重要。
当我们把若干个 16 像素宽的 anchor 拼成文本行时,行首和行尾这两个 anchor 的边界并不一定准确对齐真实文本行的左右边界。因为 anchor 的 x 坐标是按固定步长滑动的,文本行右边界可能落在 anchor 的中间,而不是正好在边界上。如果直接拿 anchor 的 x1 和 x2 当文本行边界,误差就是半个 anchor 宽度级别,累计下来可能偏出 8 到 16 像素。
side-refinement 的做法是,对行首和行尾的 anchor 分别回归一个水平偏移量,让最终输出的文本行框能精确对齐真实左右边界。这个分支的回归目标同样用 smooth-L1 损失。在车牌、身份证号、二维码下方一长串数字这类场景里,边界哪怕偏 2 个像素,识别结果的置信度都会明显下降,所以这一步属于“增加少量计算量,换回大块精度收益”的典型操作。
4. 文本行构造与后处理
4.1 从 proposal 到文本行:贪心连接算法
模型输出的是一堆“文本片段”,每个片段有自己的预测框、文字得分和边线偏移。要让这些片段变成一句话,就得做文本行构造。CTPN 的做法是贪心连接,不是全局最优,但在实践中非常有效。
第一步,先过滤掉低置信度的 anchor,通常阈值设 0.7。这一步能去掉大量背景碎片。
第二步,对所有剩余 anchor 按分类得分从高到低排序,得分最高的那一个作为新文本行的起点。
第三步,逐个检查当前文本行的最后一个 anchor 和下一个候选 anchor 是否满足连接条件。条件有两个核心指标:垂直重叠度和水平距离。垂直重叠度用两个 anchor 在 y 方向上的 IoU 衡量,要求大于 0.7;水平距离则要求两个 anchor 的 x 方向间隔不能太大,论文中的常见设定是两个 anchor 之间不允许隔开超过 2 倍 anchor 宽度。满足条件的 anchor 就拼接到当前文本行末尾,然后继续向下一个候选扩展;不满足就跳过,直到没有可连接的 anchor,再开始一个新的文本行。
第四步,每个文本行内所有 anchor 的得分取平均,作为该行的置信度;行内做一次 NMS,去掉相互重叠的重复框;最后用 side-refinement 的输出精细调整整行的左右边界。
整个后处理过程几乎不用 GPU,纯 CPU 也很快。伪代码大概长这样:
sort proposals by score desc for each proposal p: if p.score < 0.7: continue if p is already assigned: continue start new line with p while True: find unassigned proposal q that: q is the nearest right neighbor of line tail vertical IoU(p_tail, q) > 0.7 horizontal distance < 2 * anchor_width if found: append q to line else: break这个流程看起来简单,但有几个细节决定成败。第一个是“只向右扩展”还是“双向扩展”,原论文是向右扩展,因为文本行有明确的方向性。第二个是垂直 IoU 的阈值,它不是随便定的,要保证同一个文本行里字符高度一致,阈值设太低会把不同高度的文字混进同一行,设太高则会把同一个文本行拆成好几段。
4.2 阈值选择与后处理踩坑实录
我最早用自己的数据集跑 CTPN 后处理时,最重要的三个阈值来回调了两周,最后总结出一套经验。
得分阈值上,0.7 对多数场景是个不错起点。如果文本比较密集、字符笔画细、或者图片有噪声,可以适当降到 0.6 甚至 0.55;但低于 0.5 以后,背景碎片会大量混入连接过程,文本行之间容易被横穿连成一条横线,非常难清洗。如果场景里文字很大、很清晰,阈值可以拉到 0.8 以上,减少误检带来的后处理成本。
垂直 IoU 阈值上,0.7 是论文默认值。我自己的体感是:文字大小比较均匀的印刷体,0.7 很稳;手写体或者字体大小参差不齐的票据,可以降到 0.5 到 0.6,否则一行字里稍微大一点的字母会让行内连接直接断掉。
水平距离阈值是最容易踩坑的地方。它决定哪些相邻 anchor 可以连成一行。25 像素到 35 像素之间是常见区间。如果文本行内部有大段空格,比如发票号“1234 5678 9012”中间的空格,这个阈值设太小就会把一行拆成两段。但设太大又会出现跨行连接,尤其垂直方向有多个文本行的时候。我的做法是先按文本高度动态计算水平距离阈值,比如用文本行高度的一半作为参考,而不是全图用一个固定值,效果会好很多。
另一个经常被忽略的点是:文本行连接之前,最好先做一个基于分类得分的小范围 NMS。因为相邻的 anchor 可能对同一个字符响应得分的都是 0.9,如果不先压掉重复框,行内会出现一堆几乎完全重叠的检测结果,虽然不影响最终框,但会影响文本行的平均得分和边线偏移回归的稳定性。
5. 训练细节与工业落地经验
5.1 损失函数与正负样本采样
CTPN 的训练损失由三部分组成:分类的交叉熵损失、垂直回归的 smooth-L1 损失、side-refinement 的 smooth-L1 损失。总损失就是这三者的加权和,权重通常都设为 1.0,不需要太多调参。
分类损失处理的是每个 anchor 是文字还是背景,这是标准的二分类问题。回归损失只对正样本计算,因为只有正样本才对应真实的文本行,负样本的回归目标是无效的。实际操作中我建议把回归损失只在正样本掩码上计算,并且在代码里显式确认掩码和损失形状一致,不然很容易出现所有 anchor 都参与回归的情况,那损失虽然会降,但检测结果一塌糊涂。
正负样本采样是文本检测里不可忽视的一环。一张密集文字图片上,正面 anchor 可能只占所有 anchor 的 1% 甚至更低,如果全量参与训练,模型会严重偏向输出“非文字”。常见做法是在每张图的 anchor 里随机采样一批,保持正负样本比例大约 1:1。这里有一个细节:不要只采样简单的负样本,那些靠近文字边缘、含有偏旁部首或部分字符的负样本是真正难分的样本,最好保留下来。如果正负样本失衡特别严重,可以上在线难例挖掘,把损失最大的 128 个负样本挑出来专门训练,这种方式在发票、证件这种版式复杂的图片上提升明显。
我在实际项目中还发现,分类分支和回归分支的训练节奏最好错开一点点。刚开训的前几个 epoch,模型还在学“哪里可能有文字”,这时回归分支容易因为目标不稳定而震荡,建议前 2500 次迭代只训练分类分支,等分类基本收敛后再放开回归分支联合训练。这个技巧没有写进论文,但实践里能明显减少早期 loss 爆掉的情况。
5.2 数据准备与多尺度训练
CTPN 训练用的数据格式跟通用检测器不太一样。标注时通常需要给出文本行的 box 坐标,最常见的是用四个顶点坐标表示一个四边形。CTPN 内部处理时,需要把四边形转为外接矩形,或者至少提取出 y 方向的上下边界作为回归目标。
这里有一个容易犯的错:如果文本行是倾斜的,直接把四边形取外接矩形会带进来大量背景区域,导致训练时正样本 anchor 覆盖的区域不只是文字本身。我的做法是训练前先对整张图做轻度旋转矫正,把文本行尽量摆正,或者对倾斜文本单独扩展样本,让模型学会区分“倾斜文字”和“背景”。
公开数据集方面,最常用的有场景文本检测的标注数据(比如水平文本为主的数据集)、合成文本数据集(用字体渲染生成的百万级样本),以及自己标注的业务数据。合成数据对提升首轮训练的基准效果特别好,因为你可以无限生成字型、颜色、背景的排列组合。但合成数据和真实数据的分布差异很大,工业落地时一定要用真实数据做微调,不然模型在简单背景上很准,一到真实弱纹理背景上就疯狂漏检。
多尺度训练方面,我建议把输入图像高度随机缩放到 600 到 900 之间,宽度等比缩放,同时限制最长边不超过 1200。这样模型能适应不同尺寸的文字。需要特别注意的是,anchor 的原图尺寸是在模型输入时确定的,多尺度训练必须在每个 epoch 动态调整 anchor 的尺度范围,或者把 anchor 定义在特征图坐标系上从而随输入尺度自动伸缩。工程里建议用后者,代码更统一,也避免版本管理时出现边界不符。
5.3 推理加速与模型压缩
CTPN 在推理阶段其实不慢,因为整张图只需要过一次 VGG16 前向,Bilstm 虽然后处理里有依赖,但序列长度就是特征图的宽度,计算量不大。在普通桌面级显卡上,720p 的图片推理一次大约 20 到 30 毫秒,足以应对不少实时场景。
落地时比较麻烦的是模型转换。Bilstm 在转 ONNX 或 TensorRT 的时候是个难点,很多转换器不支持动态序列长度或者 LSTM 算子。我推荐的做法是:转换前先固定输入尺寸,把 Bilstm 展开成固定时间步的循环结构,或者干脆把它替换成两个方向的一维空洞卷积。一维空洞卷积在等价感受野下能近似模拟 Bilstm 的序列建模能力,推理速度更快,精度损失通常在 1 到 2 个点以内,适合对延迟敏感的场景。
另一个压缩思路是把 VGG16 换成一个轻量级分类骨干,比如 MobileNetV3 或 ShuffleNetV2,接一个相同结构的检测头。实测下来,模型体积可以从 100MB 级别降到 20MB 左右,推理速度提升 2 到 3 倍,精度损失视业务场景在 3 到 6 个点之间。如果业务不需要极高的召回率,这种精度换速度的取舍非常划算。
6. 常见问题与调优实战
6.1 小文字漏检怎么破
CTPN 处理小文字困难,核心原因在于 stride 16 让特征图上的小文字响应过于稀疏。一个 10 像素高的字,在 stride 16 的特征图上可能只覆盖一个像素点,即使 VGG 中途的卷积保留了更多信息,到了最终检测特征图上,这个字的存在感也已经被压得很弱。
我常用的方案有三条。第一,放大输入图像,把高度从 720 提到 1000 以上,小文字的像素占比上来后,检测难度直接降低,代价是显存占用和推理时间上升。第二,缩短骨干网络的 stride,比如把 VGG 的 conv5 层 stride 改为 8 或者 4,这样特征图分辨率更高,小文字保留信息更多;注意这会改变所有坐标映射关系,需要同步调整。第三,引入 FPN 结构,把高层语义特征和低层高分辨率特征融合,让检测头同时看到全局和细节,这是目前很多新检测器默认的做法。
针对小文字场景,最好的办法是三者搭配:适度放大输入、降低 stride、加入跨层融合。单纯调阈值是解决不了本质问题的,阈值只是后处理,信息损失发生在骨干网络前向过程中。
6.2 文本行断行与粘连
断行和粘连是后处理阶段最常见的两类问题,它们往往是同一个参数的两端:得分阈值、垂直 IoU 阈值、水平距离阈值。
断行最典型的表现是一个完整的文本行被拆成两段甚至多段。原因通常是水平距离阈值设太小,行内有空格时就断了;或者得分阈值设太高,行尾置信度稍低的 anchor 被过滤掉。排查方法很简单:在预测结果上把得分低于阈值的 anchor 可视化出来,如果行尾明明有连续响应却因为分数不够被掐断,降阈值;如果中间有空隙导致连接条件不成立,调大水平距离阈值。
粘连则相反,多个文本行或者文本行与背景碎片被连成了一条诡异的长横线。原因通常是垂直 IoU 阈值设太低,两个垂直方向有重叠的单行文字被混在一起;或者得分阈值太低,背景碎片也被当成候选。处理粘连时,先收紧垂直 IoU 阈值,再调高得分阈值。如果这两个参数已经调得很紧还是粘连,就该怀疑你的训练数据里文本行之间的间隔太小,导致模型对“行间距离”的敏感度不够,这时需要补充该类样本或者加大输入图像分辨率,让行间距离在特征图上更明显。
行与行间距特别小的票据、表格场景,我还会额外加一个后处理:对构造出的文本行做一次长宽比检查,把明显不正常的长条(宽度超过全图 60% 且高度浮动特别大)拆开或者丢弃。虽然这个方法不够优雅,但能快速清理一大批粘连结果。
6.3 旋转与倾斜文本怎么办
CTPN 本质上是为水平文本设计的。anchor 竖条只能感知垂直方向和水平方向的偏移,无法表达旋转角度。所以遇到倾斜超过 15 度到 20 度的文本,哪怕肉眼看起来只有轻微歪斜,检测结果也会出现框不够贴边、文本行连接错乱等问题。
面对旋转文本,最直接的方案是旋转矫正:先用一个轻量方向分类或回归模块估计整张图/文本区域的旋转角度,再对图像做逆旋转变换,让文本变成近似水平,最后送进 CTPN。这种方案在文档扫描、身份证拍照这类场景里非常成熟,而且因为旋转角度通常在正负 30 度以内,角度估计的误差不会对检测造成致命影响。
如果文本是任意方向甚至弯曲的,比如招牌上的弧形字、商品包装上的环绕文字,CTPN 就基本无能为力了。这种情况应该直接上支持四边形输出的检测方案,比如 EAST、DBNet 之类。它们通过直接预测文本框四个顶点,天然支持旋转和任意四边形,但代价是后处理逻辑更复杂、对极长文本的连接能力反而可能不如 CTPN 这种基于竖条连接的方案稳。所以“CTPN + 旋转矫正”和“四边形检测器”没有绝对优劣,完全取决于你的文本形态分布。
7. CTPN 的局限与后续演进
7.1 还有哪些坑是绕不开的
CTPN 虽然经典,但它的边界条件很明确,理解这些局限能帮你避免在错误的场景里死磕。
垂直方向倾斜过大的文本时,CTPN 的 anchor 匹配会出问题。因为真实文本行的 y 区间在倾斜时会“歪”,一个垂直竖条的 anchor 很难同时命中倾斜文本的上下边缘,导致正样本不足、回归目标不稳定。用 CTPN 检测竖排文字几乎不可行,竖排文字本质上是把“行”和“列”交换了,CTPN 的序列建模方向是水平方向的,完全不适用。
曲线文本和环绕文本同样无能为力。文本行构造算法默认行是近似直线,无法处理弧形或折线。你可以在后处理里加圆弧拟合,但这就不是 CTPN 本身能解决的问题了。
超长文本行也容易断裂。虽然 Bilstm 能提供上下文,但 anchor 之间的连接条件在超长文本上会因为字符间距、换行、中间插图等干扰而中断。一个文本行超过 80 到 100 个字符时,断行概率会明显上升。
7.2 从 CTPN 到 EAST、DBNet 的演进逻辑
理解 CTPN 之后再去看后来的文本检测方案,会发现演进脉络非常清晰。
EAST 把检测变成了直接回归文本框四边形的全卷积任务,利用特征融合和多方向框回归,绕过了 CTPN 里“先检测竖条再连接”的中间步骤,速度更快、支持任意四边形。但 EAST 在处理长文本时,回归目标包含大量坐标值,训练难度和模型对精确边界估计的要求都更高。
DBNet 则把文本检测建模成分割任务,引入可微分二值化,让模型自己学习阈值。它天然适合弯曲文本和任意形状文本,语义信息丰富。但分割任务的缺点是容易把相邻文本行粘连成一个掩码,需要精细的后处理去分离贴在一起的文字。DBNet 的工程落地比 CTPN 繁琐不少,涉及到掩码连通域分析、轮廓提取、四边形拟合等多个环节。
CTPN 的思想在这些后来的方案里并没有消失,对长文本的序列连接、对 anchor 垂直方向的约束,都演变成了各种变体里的“先验知识”。我自己在搭建一个场景文字识别系统的时候,最终还是把 CTPN 作为 baseline。它也许不是最先进、最快的方案,但它的每一部分都可以单独解释、单独调试,这种“我清楚模型在想什么”的可控感,在工业项目里非常珍贵。换任何新架构,我都知道自己优化的是特征提取、序列建模、还是后处理连接这一步,而不是在一个黑盒里反复试参数。