看到"进阶版、零基础入门CV、街景字符识别、学习赛、得分0.915"这几个词,我第一反应就是:这哥们儿/这姑娘走的是一条非常标准且正确入门的路线。街景字符识别这个任务,几乎是我见过的对新人最友好的CV实战入口,比一上来就啃目标检测、实例分割要温和得多。它的数据直观、评价指标简单、模型套路成熟,而0.915这个分数,说实话,放在这类入门赛里已经是大概率能进前列的水平了。
这一篇我想把这个项目的完整链路拆给你看。我不会只告诉你"用ResNet18加5个分类头",而是会把每一步选择背后的为什么讲清楚,把从0.83慢慢磨到0.915的过程中那些文档里不写、比赛群里才会聊的经验交代完。无论你是刚看完神经网络基础的小白,还是想带竞赛的学长学姐,这篇应该都能让你少走几段冤枉路。
1. 项目与任务本质:街景字符识别到底在解什么题
1.1 赛题设定与数据长什么样
这个赛题的核心任务一句话就能说清:给一张包含门牌号的街景图片,让模型把里面的字符串识别出来。字符是0到9的数字,长度不固定,短的可能只有1位,长的能到5位甚至更多。图片本身来自真实的街景拍摄环境,所以不会像MNIST那样又干净又规矩,常见的情况包括:太阳直射产生的过曝高光、门牌号旁边贴着的杂七杂八的招贴、字符被树枝或路灯遮挡一部分、拍摄角度导致数字有轻微倾斜变形,甚至同一张图里存在好几个数字区域需要按顺序读出来。
第一次接触这个任务的人,很容易把它简单理解成"就是OCR识数字嘛",然后直接套一个手写数字识别的模型上去,最后发现效果很尴尬。原因在于,传统手写数字识别是"单字符分类",而街景字符识别本质是"定长/变长序列识别 + 定位",需要模型至少掌握两件事:知道数字在哪里,以及知道这些数字按什么顺序排列。这两件事绑在一起,难度就不是简单的90%分类准确率能覆盖的了。
我记得赛题的训练集是几万张标注好的图片,每张图会配一个文本标注,告诉你有几位数字、每一位是什么。为了统一处理,平台通常会提供一个"定长极限设定",比如最长不超过5位,那么训练样本的标签就统一按5位长度对齐,不足5位的位置填上某个占位符。这个占位符的设计就属于典型的"细节决定成败",后面我会单独展开。
1.2 从"图像分类"到"多标签分类"的思路转变
所有零基础选手的第一版模型,几乎都是拿一个成熟的分类网络直接改。这么说吧,图像分类的经典思路是:输入一张图,经过卷积网络提取特征,最后接一个全连接层输出每个类别的概率,取最大的那个作为预测结果。而街景字符识别需要输出的是一个字符串,不是一个类别,所以直接套用单个分类头必然失败。
第二版常见的思路是"定位+分类"分步走,先用一个检测模型把每个字符框出来,再对框内字符做分类。这条路效果好,但明显不是零基础阶段该碰的——Faster R-CNN或者YOLO那一整套训练流程,前处理、锚框、NMS、正负样本均衡,每一个概念都能劝退不少新人。
真正适合入门且能冲高分的手段,是多标签分类的思路。我把这个任务换个角度理解:既然图片里最长不过5位字符,那我就让模型输出5个预测槽位,每个槽位独立做11分类(0-9外加一个空字符)。模型不再需要先框出数字再分类,而是在整个图片上一次性判断"这5个位置分别是什么字符"。这种思路把一个序列识别问题简化成了多个并行分类问题,完美契合了ResNet这类卷积网络的输出结构,代码量小,训练稳定,而且在大部分样本不超过5位的前提下,精度并不会比检测路线差很多。
1.3 为什么0.915在这个赛道算"进阶版"水平
说起这个分数,我得分清楚一个概念:这比赛的评估标准通常是对比"整串预测正确率",即一张图片里的5位字符全对才算对,任何一位错误都算一个错例。因此预测难度随位数增加是呈指数级上升的。
如果只是跑通流程,用ResNet裸训10来个epoch,准确率一般落在0.8到0.85之间,看起来已经不错了,但你会发现模型在2位、3位、4位长度的样本上错得不少,尤其是对一个字符串的中间部分经常判断失误。上0.9之后,每提升0.01都要费很大力气,需要把数据分布、增强策略、模型结构细节、后处理逻辑全部打磨过一轮。所以0.915这个分数,已经说明使用者不只是"跑完了个baseline",而是真正花心思去分析了失败样本,做了针对性的优化。这也是我把这个标题下的项目称为进阶版的原因:它用入门任务承载了进阶的思路。
2. 数据先行:预处理与分析决定分数上限
2.1 样本分布:字符长度的"长尾"陷阱
拿到数据后的第一件事,永远不是写模型,而是统计标签分布。我当时的统计结果很有代表性:训练集里1位数字的样本占比最多,接近三分之一;2位其次;3位、4位依次递减;5位以上很少。这个分布就带来了一个很隐蔽的问题:如果模型训练时随机抽取样本,它在小样本长度上的学习就会被大样本长度严重稀释,导致长位数预测精度较差,虽然整体准确率看起来还行。
我来打个比方吧:你突击复习考试,题库里1位数的题有3000道,5位数的题只有300道,如果你每次随机抽题来练,练到最后5位数题型依然做不熟。考试时碰到5位数题目,基本只能靠猜。
解决的方案有三层。第一层是统计长度分布,我手动画了一下训练集样本长度下的准确率曲线,一眼就能看出模型在哪类样本上最薄弱。第二层是按长度做重采样,每次batch采样的时侯,用一定概率从"低样本量长度"分组里多抽一些图,让模型在每个长度上都接触到足够的样本。第三层是直接把样本数量最少的长度做复制翻倍,类似简单的过采样。实测下来,重采样对末尾涨分的贡献非常明显,尤其从0.89提升到0.91这一段,靠的不是网络结构,而是数据分布调整。
2.2 统一尺寸与归一化这类"小事"别马虎
很多零基础选手会在这里犯低级错误,包括我自己最开始。数据集的图片尺寸并不统一,长宽比和分辨率都不一样,而CNN又要求输入尺寸固定。最简单粗暴的做法是把所有图片resize到固定尺寸,比如64乘64或者128乘64。我在实践中强烈推荐把"长边"作为基准,做一个不变形的resize,再对不足的边做灰色填充,而不是直接拉扯变形。因为字符的长宽比例本身就是重要特征,你非要把一张宽幅的门牌号截图硬压成一个正方形,数字会变扁,模型学到的特征就会走偏。
归一化也很关键。CV的训练一般会做两个操作:像素值从0到255缩放到0到1或-1到1;然后减均值、除方差。用预训练模型时,必须用对应预训练权重的统计均值和方差,用ImageNet预训练模型就配合ImageNet的归一化参数,不然迁移学习的效果会打折扣。说穿了,预训练模型内部对颜色的敏感度已经在ImageNet分布上固化了,你输入分布和人家的不一致,前面几层卷积的输出就全是漂移的。
还有一个小众但常被忽视的点:图片的"留白"。门牌号图片里字符周围通常有大量背景区域,如果不做中心裁剪直接resize,字符区域会被压缩得很小,模型容易被背景纹理带偏。我当时的做法是统计标注框位置,做一个"按标注框外扩裁剪"的预处理,确保喂进网络的图片里,字符主体至少占到画面的一半。这一步能显著改善低分辨率下的识别效果。
2.3 数据增强:别把数字增没了
数据增强是深度学习里最经典的防止过拟合手段,但这任务的增强不能乱用。我见过有人随手把角度旋转参数调到30度,结果模型把6识别成9,把2识别成5,分数不升反降。为什么?数字字符是有强语义方向的,旋转超过一定角度就改变了数字本身的语义,等同于产生了错误标签。
我实际验证下来比较安全的增强组合是这样的:随机水平或垂直平移少量像素,范围控制在图片宽度的5%以内;轻微缩放,到0.9到1.1倍;亮度、对比度、饱和度的随机扰动,模拟不同天气光照;小范围内的随机遮罩,模拟门牌号被局部遮挡。旋转我基本保持在正负5度以内,最多不超过10度。色相扰动别开,或者数值极低,因为门牌号本身没有固定色调规律,开了容易让模型过度依赖颜色,而街景图片里天空、墙面颜色变化极大,这会催生不该有的偏见。
另一个容易忽略的增强是"裁剪无关区域"。实际场景中门牌号经常只占图的一半,另一半是墙或树叶。我干脆在训练时做了一个随机裁剪,把图片的一部分裁掉再resize回固定尺寸,强制模型学会从局部辨认数字。但这个裁剪的尺度要控制好,别裁到把主字符切掉一半。反复试下来,裁剪比例在0.7到1.0之间时效果最好,超过这个范围就产出大量困难样本,模型会在这类样本上崩溃。
3. 模型选型与网络结构设计
3.1 三条经典路线:定长多标签、检测加识别、CRNN序列识别
如果去翻各类相关比赛的开源方案,会发现大家主要走三条路,我分开说下各自的适配场景。
第一条路线是定长多标签分类。假设已知最长字符数为N,把输出层改成N个分类头,每个头做11类分类即10个数字加1个空字符。优点是实现简单、训练稳定、速度飞快,对入门选手极度友好。缺点是遇到超过N位的样本只能放弃识别或截断,不过街景门牌号绝大多数不超过5位,所以这个缺点在实际场景中不算致命。
第二条路线是检测加识别级联。先用目标检测模型把单个字符框出来,再用一个分类器识别每个框内的数字。这种方式理论上最接近真实场景,能处理任意长度,精度上限也高。但训练过程要把标注数据转换成检测框格式,调参复杂,推理速度慢,新手用起来心态很容易崩。
第三条路线是序列识别,典型的如CRNN加CTC损失。把图片当成一个序列输入循环神经网络,CTC负责对齐变长序列。这条路对不定长识别效果最好。但训练周期更长,梯度回传路径更复杂,可视化Debug极其麻烦,一旦精度不对,基本没法分清是特征提取问题还是CTC解码问题。
三条路线横评后,我的结论很明确:入门阶段请务必先做定长多标签分类。不是说它最好,而是它是成功率最高、最容易建立信心的方案。等你把数据、训练、评估这套流程完全吃透了,再往检测或CRNN方向升级,难度的坡度就会平缓很多。
3.2 定长方案细节:ResNet主干加N个分类头
我最终的网络结构长这样:主干是ResNet18,把最后的GlobalAvgPool保住,然后从后接出5个分支,每个分支是一个由全连接层组成的小型分类头,各自输出11维的概率向量,对应10个数字类加1个空白类。代码上只需要在构造模型的时侯把最后一层替换成一个ModuleList,循环里包5个线性层就行,完全不用手写卷积。
这里有个设计细节值得说:每个分类头之间要不要共享参数?理论上有三种做法。第一种是五个头完全独立,各学各的,参数量大,但每个位置都能学到不同的特征侧重点。第二种是五个头共享同一个全连接层,参数量小,但模型无法表达"第2位的特征和第4位的特征不同"这类位置信息。我实测下来,独立头的效果好于共享头,因为门牌号的第1位和最后1位在图像空间和特征位置上确实存在系统性差异。位置敏感是这类任务的重要先验,强调它没有坏处。
主干网络选择上,我从ResNet18换到ResNet34试过,分数提升大约0.003到0.005,但训练时间增加了快一半,性价比不高。有过想试ResNet50的冲动,但单卡显存不够,如果压缩batch size反而会拖累BN稳定性。所以最终停留在ResNet34,准确率在0.91左右,再配合两折模型融合才稳定跨过0.915。
3.3 损失函数与标签设计:空白类不白给
这个设计的核心问题是如何处理"不足5位"的样本。假设一个样本真实值是123,那模型标签怎么生成?常规做法是把标签补成1、2、3、空、空,空位置用一个额外的类别表示,比如类别10。分类头输出11个维度,其中第10维表示"这是空白"。
损失函数直接对五个位置的交叉熵求和。但这里有个关键问题:空字符在训练样本中的出现频率远高于数字类别。训练集里占位符比例可能超过40%,如果不加权,模型会倾向于把大量位置预测为空,导致非空数字的预测精度下降。我采用的补救措施是对空字符类别的损失给予一个相对较低权重,比如0.3,让数字类别在梯度中拥有更大的话语权。这个权重我调过0.1、0.2、0.5三档,0.3到0.4之间最稳定。
还有一个后处理技巧必须提前讲:推理的时候,模型对每个位置输出11维概率,正常人会直接取每个位置ar股max做预测,最终得到[3,空,空,空,空]这样的结果,然后去除空位置补丁。但实际更稳的做法是先判断"终止位置"。因为正常门牌号里,不可能出现第1位是空但第3位是数字的情况。所以解码时要从左到右扫描,一旦某一位被判为空白,后面所有位都强制设为空白。这个约束可以大大降低低置信度误判,尤其是模型在长后缀位置容易产生幻觉。
4. 训练全流程与调参实录
4.1 优化器与学习率策略:warmup加cosine
训练过程我用的优化器是AdamW,这个选择不是随便拍的。Adam类优化器自带一阶动量和二阶动量自适应,入门选手不用手动调太多参数,对学习率不那么敏感。SGD加动量虽然有时上限更高,但需要配合精细的learning rate schedule,新手一不留神就会发散。
学习率策略是warmup加cosine退火。warmup阶段让学习率从极小值慢慢升到设定峰值,比如5个epoch内从1e-5升到3e-4,目的是避免在训练初期用大学习率把预训练权重毁掉。cosine退火阶段学习率按余弦曲线降到极小值,训练后期让模型在低学习率下持续挖掘参数空间的局部最优。整套策略下来,前5个epoch和最后5个epoch的行为差异非常大,前期快跑大跳,后期微调精修。这种学习率节奏用在预训练分类网络上几乎百试百灵。
batch size和输入分辨率,我最终使用的是batch size 64加输入尺寸64乘128。刚开始我用过128乘256,效果确实好一点,但显存占用高,训练速度慢,对入门选手的本地机器不友好。把输入调小到64乘128,在训练速度和精度之间取得了不错的平衡。这里有个我经常强调的点:输入分辨率会影响数据增强的强度上限,低分辨率图片再做大幅度裁剪会丢失关键像素,所以分辨率定下来后再回头调增强参数,顺序别搞反。
4.2 训练过程:从0.85到0.915我做了什么
大概梳理一下我训练的四个阶段,这样读者能直观感受涨分节奏。
第一阶段,裸ResNet18加AdamW,学习率固定3e-4,不做任何花哨操作,训练30个epoch。验证集准确率停在0.84到0.85。这个时候观察失败样本,发现错误集中在光照强烈的图、字符拥挤的图和长位数图。
第二阶段,引入重采样和标签平滑。重采样我前文已说过;标签平滑就是把真实标签概率从1改成0.9,把其中0.1均分给其余10类。这个小改动看起来微小,但对字符分类任务的泛化帮助很大,因为真实数据里存在标注噪声,某些图片可能肉眼看起来就分不清是1还是7,模型不该为这种模糊样本输出过于自信的概率。这个阶段验证集涨到0.88左右。
第三阶段,我把基线增强升级为随机裁剪、轻微旋转加颜色扰动,并引入5分折交叉验证的思路。单模型在验证集上到了0.90附近,多个fold融合后稳定到0.91附近。
第四阶段,也就是冲0.915的关键,核心动作是两个:TTA测试时增强,以及置信度感知的后处理。TTA简单来讲就是推理时把同一张图做几次轻微增强变形,分别预测,再把概率平均作为最终输出。这等于在推理阶段用了一次模型集成,涨2到3个千分点。后处理就是前面提到的终止位规则和空位填充规则,又掰回来一个千分点。到这一步,单模型加TTA基本能稳定在0.913到0.917之间。
4.3 后处理与TTA:不训练也能涨分的手段
先展开TTA,因为它几乎零成本。推理阶段我把输入图片做5次变换:原图、水平翻转、平移几个像素、轻微缩放、轻微旋转。模型分别输出5个预测概率,按位置取平均后得到最终概率。为了让翻转语义一致,我用的平移和旋转都控制在很小幅度内,确保字符完整性。我做过统计,TTA大约能让模型在验证集上稳定涨0.002到0.004,而且在0.91这个区间显得非常值钱。
再说后处理的细节。模型输出的5个分类头各自对应一个位置,但真实字符串可能只有3位。模型把第4和第5位识别成空字符的概率应很高。但偶尔模型会对空字符产生高置信度误判。我的做法是先在整个序列里搜索"连续空段",一旦某个位置被判定为空白,那它前面以及后面的空白段都必须连续存在。比如预测结果是[1,空,2,空,空],那我会手动修正为[1,空,空,空,空],理由是真实门牌号不可能出现"第1位数字、第2位空白、第3位又出现数字"的断裂情况。虽然这个规则会牺牲掉极少数中间带空格的真实样本,但整体收益大于损失。
最后还有一个压箱底的小技巧:输出概率过大时做温度缩放。模型在训练充分后,概率分布会非常尖锐,几乎都以接近1的置信度输出某个类。用温度系数把这些概率稍微拉平均一点,再做argmax或阈值判断,有时候也能把模糊样本的预测结果往回救一点。温度参数我用1.5到2.0时效果是最稳的,也比较好解释:模型知道答案时概率依然高,不知道时不会硬去装自信。
5. 常见问题排查与避坑手册
5.1 卡在0.85不动怎么办
我见过很多人在这个阶段卡几天,原因是模型已经在"平均样本"上尽力了,但对困难样本无能为力。这时候别去开新的网络结构,先做错误分析。我的标准操作是随机抽50张预测错的图,分门别类贴出来,看它们的共性是啥。结果通常有两个:一个是图片本身光照或模糊程度极端,另一个是标注框把多个数字挤在一起,两个甚至三个字符几乎粘连。这种情况下,提升模型容错能力比增加复杂度更有效。手段就是两点:数据增强加强以及重采样让模型多看见难例。还有一个容易被忽略的点是:检查验证集里是否存在错标注问题。训练集来源于平台采集,偶尔会有标注错误或者标注框偏移的情况,这些脏数据严重时会拖低分数。我会手动清洗掉验证集里的安全疑点样本,避免模型被误导。
5.2 精度震荡与不收敛排查清单
训练过程中loss下降曲线不光滑、验证集准确率剧烈起伏,这个问题很常见。先用下面的清单排查一遍,基本能覆盖90%的病因。
第一,检查学习率是否过大。标准信号是训练集loss在初期直接飙升或者完全不动,这时把学习率降五倍即可。第二,检查BN层的统计状态。如果用了预训练模型,但又直接冻结了所有BN层,模型在分辨率变化时会表现极不稳定,解决办法是让BN层参与更新。第三,检查数据增强是否过度。比如随机裁剪的比例设置得太离谱,导致同一张图的增强版本之间语义完全不同,模型就会无所适从。第四,不要让batch size过小,否则BN的均值和方差受到当前batch内少量样本主导,训练进程忽上忽下。我在batch size 8的环境中跑过同样的模型,验证集波动幅度比batch size 64时大一整倍。
还有一个很多人都会踩的坑是:把测试集数据混进训练集里做交叉验证,虽然比赛平台上不算作弊,但会导致提交分数虚高、泛化能力堪忧。正确做法是严格按官方划分走,或者自己按分层抽样划分训练与验证集,且确保同一图片的不同增强版本不会被同时分到训练集和验证集。
5.3 硬件受限时的训练策略
不少读者可能只有一张普通显卡甚至只用CPU在跑。这种情况下,首先把输入分辨率从64乘128下降到48乘96;把ResNet18换成更小的骨干,比如ResNet10或者MobileNetV2。模型参数变少了,训练周期缩短很多,但精度并不会大幅下降,MobileNetV2在这个任务上大概在0.89左右。
显存不够时,使用梯度累积模拟更大的batch size,把每次前向的batch设为16,梯度累积4次再更新一次参数,等效batch size为64。梯度累积不会让BN自动工作正常,因为BN统计的是当前子batch的均值和方差,所以累积只对优化器更新有等效效果,需要同步降低BN的momentum。更省显存的做法是开启混合精度训练,许多框架提供了现成的AMP支持,简单几行配置就能把显存占用降下来40%,训练速度还更快。不过使用AMP一定要留意loss scaling的数值,防止梯度下溢。
6. 进阶扩展:从0.915继续往上走
6.1 检测加识别级联:处理不定长字符
如果你已经在定长方案里把这个分数吃透了,想进一步挑战真正的街区级场景,就可以上检测加识别。做法不再是把整图送分类头,而是先训练一个轻量目标检测模型,输出每个字符的边界框和类别。框出一个字符后,再做单个字符识别。
这个方案的优势是能处理任意长度,而且对字符之间距离较远、分布不规则的样本特别有效。训练门槛在于要把原始数据集的字符串标注转换到检测框标注,需要自己跑数据标注的转换脚本。我第一次转换的时候就被坐标偏移坑过,标注框在训练数据里用的是归一化坐标,自己写脚本时把高度和宽度弄反了,导致训练集视觉正常但模型效果奇差。务必先用画框可视化工具检查至少100张转换后的样本再喂给模型。
6.2 CRNN加CTC:真正的序列识别路线
如果连检测框都不想标注,直接走CRNN加CTC的序列识别路线也很值得一试。CRNN的本质是让模型把图片当成一个时间序列,顶部接循环神经网络输出每个时间步的概率,再用CTC把不定长序列对齐到标签。
这条路的优势是不需要预知字符长度,天然适配长字符串。代价是训练收敛慢,调参难度高。我的建议是,如果0.915之后你想保持定长方案的优势,同时想探索更强泛化,可以在同一条数据流里先训练一个CRNN作为辅助模型,最后用它与定长模型做结果融合,很多时候能再拉回0.5到1个百分点。
CRNN训练里最需要注意的是序列数据的预处理。图片要在高度方向上保持固定,宽度可以随意变化,一般会做一个固定高度的重缩放,再把宽度等比压缩到某个上限。CTC的blank机制是专门用来处理字符间空隙的,但初始学习率不宜过高,否则容易在训练初期就崩溃成全部空白。
6.3 这个项目沉淀的CV基本功
写到这里,我想认真说一句:这个项目让我收获最大的不是那个0.915的分数,而是训练出了一整套"先看数据再看模型、先找问题再调参数"的判断力。这类街景字符识别里培养起来的几个习惯,后来被我迁移到了目标检测、图像分割项目上,依然完全适用。比如遇到模型效果差先做失败样本分析而不是换结构;比如数据增强策略跟随数据分布调整,而不是用一套固定模板走天下;比如结果提升到一定阶段后,用后处理和概率平均去争取那最后两三个千分点,而不是盲目堆参数。
这些能力比任何单次比赛的名次都重要。如果正在读这篇的你也是刚接触CV不久,我的建议很简单:别急着把代码复制下来跑个结果就完事,而是多改一改训练配置,多画几次错误样本的图,多看看那些失败预测到底长什么样。你从0.9往0.915走的过程,才是这个项目真正的进阶内容。
顺便再分享一个我一直沿用的个人守则:每次涨分之后,立刻把改动记录下来,包括改动的原因、训练时长、验证集分数和改动前对比。写竞赛日志这件事很多人不屑于做,但等你遇到分数突然跌落时,日志就是你找回状态的唯一索引。0.915不是终点,它只是一个信号,说明你已经掌握了这个方向的基础方法论,可以自信地走向更复杂的任务了。