1. 赛题背景与整体设计思路拆解
1.1 这个比赛到底在做什么
街景字符编码识别,说白了就是给一张街景照片,让模型自动认出图中出现的所有字符序列。这类任务在真实场景里对应的是门牌号识别、路牌识别、快递面单编号提取等需求。比赛提供的数据集是一批街景图片,每张图里包含一个或多个字符组成的编号,选手需要训练模型输出对应的字符序列。
这个任务本质上属于**场景文本识别(Scene Text Recognition)**的范畴,比传统OCR更难的地方在于:街景图片中的字符存在光照变化、角度倾斜、遮挡、模糊、背景干扰等问题,而且字符长度不固定。我第一次拿到数据的时候,随手翻了几十张图,发现有些字符被树枝挡住一半,有些因为拍摄角度导致严重透视变形,还有些图片整体曝光过度,字符几乎和背景融为一体。这些真实存在的干扰因素,决定了我们不能用简单的模板匹配或者传统图像处理方法来解决。
从任务类型来看,这是一个不定长字符序列识别问题。输入是任意尺寸的RGB图像,输出是变长的字符序列。这跟固定类别的图像分类有本质区别,也跟目标检测的输出形式不同。你需要一个能处理序列输出的模型架构。
1.2 为什么选择这个技术路线
面对字符识别任务,常见的技术路线有几条:一是先做字符检测再逐个分类,二是直接做序列识别端到端输出。我选择的是**CRNN(Convolutional Recurrent Neural Network)+ CTC(Connectionist Temporal Classification)**的方案,这也是入门CV比赛最经典、最容易复现的baseline。
选择这条路线的原因很实际:第一,CRNN结构清晰,CNN负责提取图像特征,RNN负责序列建模,CTC负责解决对齐问题,每个模块的职责明确,调试起来方便定位问题。第二,这个方案在多个公开数据集上已经验证过效果,社区里有大量可参考的实现,遇到问题容易找到解决方案。第三,训练成本可控,单卡就能跑起来,不需要多卡并行或者大规模预训练。
相比之下,基于检测的方案需要先训练一个字符检测器,再训练一个字符分类器,两个模型串联会带来误差累积的问题。而且检测框的精度直接影响后续分类效果,调试链路更长。对于入门级别的比赛,端到端的CRNN方案性价比最高。
另一个关键决策是是否使用预训练模型。我试过从零训练和加载预训练权重两种方式,实测下来,加载在通用图像数据集上预训练过的骨干网络,收敛速度明显更快,最终精度也更高。原因在于街景字符识别的数据量通常有限,从零训练容易过拟合,而预训练权重提供了良好的特征提取起点。
1.3 整体方案架构
整个方案可以拆成四个核心模块:
- 数据预处理模块:负责读取图片、统一尺寸、归一化、数据增强。这里的关键是要处理不同长宽比的图片,同时保证字符不被过度拉伸变形。
- 特征提取模块:用CNN骨干网络把输入图像转换成特征序列。我选的是ResNet系列的变体,因为它在深层网络中梯度传播更稳定。
- 序列建模模块:用双向LSTM对特征序列进行上下文建模,捕捉字符之间的依赖关系。比如“1”和“0”相邻时,模型能利用上下文信息提高识别准确率。
- 转录模块:用CTC解码器把RNN输出的概率序列转换成最终字符序列,同时处理重复字符和空白标签。
训练时用CTC损失函数,推理时用贪心解码或束搜索解码。整个流程串起来就是:图片输入→CNN提特征→RNN建序列→CTC出结果。
注意:CTC有一个关键约束——输入序列长度必须大于等于输出标签长度。这意味着如果你的图片被压缩得太厉害,特征序列太短,可能无法覆盖所有字符。我在初期就踩过这个坑,把图片统一缩到很小尺寸,结果长字符序列的样本大量报错。
2. 数据处理的细节与实操要点
2.1 数据集的读取与标注解析
比赛提供的数据通常包含两部分:训练集图片和对应的标注文件。标注文件一般是JSON或CSV格式,记录了每张图片的文件名和对应的字符编号。我拿到的标注格式是每行一个JSON对象,包含“filename”和“label”两个字段。
读取标注时需要注意几个细节。第一,标注中的字符编号可能包含数字和字母,需要统计所有出现过的字符,建立字符到索引的映射表。这个映射表决定了模型输出层的维度。第二,要检查标注中是否存在空标签或者异常字符,这些脏数据如果不处理,训练时会导致损失计算异常。第三,统计字符长度的分布,了解数据集中编号长度的范围,这直接影响后续模型设计和CTC解码的参数设置。
我实际统计下来,字符长度主要集中在4到8位之间,最短2位,最长12位。这个分布信息很重要,因为它决定了CNN输出的特征序列长度至少要到12以上,否则CTC无法正确对齐。
2.2 图片预处理的关键决策
图片预处理是影响最终效果的关键环节,我在这上面花了不少时间调试。核心要解决的问题是:如何把不同尺寸的原始图片转换成网络可以接受的统一输入。
最直接的做法是直接缩放到固定尺寸,比如256×64。但这样做有个问题:不同长宽比的图片被强行拉伸后,字符会变形。比如一张很宽的图片被压到256宽,字符会变得又扁又窄,特征提取难度增加。
我采用的方案是保持长宽比缩放+填充。具体操作是:先按比例把图片缩放到高度为32像素,宽度按原始比例计算,如果宽度超过设定最大值(比如256),就截断或进一步缩放;如果宽度不足,就在右侧填充灰色像素。这样字符不会变形,同时所有图片尺寸统一。
另一个重要决策是是否转灰度图。我试过灰度图和RGB两种输入,实测下来RGB输入的精度略高一些,因为颜色信息在某些场景下有助于区分字符和背景。但灰度图的训练速度更快,显存占用更小。如果显存紧张,灰度图是更务实的选择。
2.3 数据增强的策略与避坑
数据增强是提升模型泛化能力的有效手段,但字符识别任务的数据增强有其特殊性。不是所有图像增强方法都适用,有些甚至会起反作用。
我实际使用且验证有效的增强方法包括:
- 随机亮度对比度调整:模拟不同光照条件,幅度控制在±20%以内。幅度太大会导致字符与背景对比度失衡。
- 随机轻微旋转:角度控制在±5度以内。旋转角度过大会导致字符倾斜严重,CTC对齐困难。
- 随机高斯噪声:标准差控制在0.01到0.03之间,模拟拍摄噪声。
- 随机遮挡:在图片上随机画小矩形块,模拟遮挡场景。遮挡面积不超过图片总面积的5%。
我试过但效果不好的增强方法:
- 水平翻转:字符翻转后语义完全改变,比如“6”变成“9”,这种增强会引入错误标签。
- 大角度旋转:超过10度的旋转会让字符排列方向改变,CTC无法正确对齐。
- 颜色抖动幅度过大:导致部分字符颜色与背景接近,模型难以区分。
实操心得:数据增强的强度需要根据训练轮次动态调整。前期可以用较强的增强帮助模型学习鲁棒特征,后期减弱增强让模型精细化拟合。我通常在前30个epoch用完整增强,之后逐步降低增强概率。
2.4 数据加载器的实现要点
数据加载器负责在训练时批量读取图片和标签。这里有几个容易出问题的地方。
第一,批处理中的序列长度不一致。同一个batch里不同图片的标签长度不同,CTC损失函数需要知道每个样本的实际标签长度。解决方案是在collate函数中记录每个样本的标签长度,传给损失函数。
第二,图片尺寸统一后的padding处理。填充的像素值应该与归一化后的背景一致,通常用0或者均值。如果填充值选择不当,模型可能会把填充区域误认为字符。
第三,多进程读取的稳定性。使用PyTorch的DataLoader时,设置num_workers大于0可以加速数据读取,但在某些环境下会出现死锁或者内存泄漏。我一般设置num_workers=4,并配合pin_memory=True加速GPU传输。
# 数据加载器核心配置示例 train_loader = DataLoader( dataset=train_dataset, batch_size=64, shuffle=True, num_workers=4, pin_memory=True, collate_fn=collate_fn # 自定义collate处理变长标签 )3. 模型搭建与训练过程实现
3.1 CNN骨干网络的选择与改造
骨干网络负责从图像中提取特征。我对比了几种常见选择:VGG、ResNet、MobileNet。VGG结构简单但参数量大,ResNet梯度传播更好但需要调整输入尺寸,MobileNet轻量但精度略低。
最终我选择了一个改造版的ResNet-18作为骨干。改造点包括:第一,把第一层卷积的stride从2改为1,这样输出的特征图高度更大,保留更多空间信息。第二,去掉最后的全局平均池化层和全连接层,只保留卷积部分。第三,在最后增加一个自适应池化层,把特征图高度池化到1,宽度保持不变。
这样做的原因是:CTC需要的是序列特征,高度方向压缩到1表示每个时间步对应原图的一个水平条带,宽度方向保留表示序列的时间维度。经过计算,输入图片高度32像素,经过骨干网络后高度变为1,宽度从256变为64,也就是特征序列长度为64。这个长度足够覆盖最长12位的字符序列。
3.2 RNN序列建模的配置细节
RNN部分我用了两层双向LSTM,隐藏层维度256。双向的设计让每个时间步的输出同时包含左侧和右侧的上下文信息,这对字符识别很重要。比如识别一个模糊的“5”时,如果它左边是“1”右边是“2”,模型可以利用上下文推断出中间更可能是“5”而不是“6”。
LSTM的输入维度是CNN输出的通道数(ResNet-18是512),输出维度是字符类别数加1(加1是CTC的空白标签)。两层之间加了Dropout,概率0.3,防止过拟合。
有一个细节需要注意:RNN的序列长度。如果CNN输出的特征序列长度是64,那么RNN就在64个时间步上展开。但实际图片中字符只占一部分宽度,其余是背景。CTC的空白标签机制可以处理这种情况,模型会在背景区域输出空白标签。
3.3 CTC损失函数的计算与调试
CTC损失函数是整个训练的核心。它的作用是计算给定输入序列和输出标签之间的对齐概率,并对所有可能的对齐路径求和。
实际使用中,PyTorch提供了CTCLoss接口。调用时需要传入三个关键参数:log_probs(RNN输出的对数概率,形状为T×N×C)、targets(拼接后的标签序列)、input_lengths和target_lengths(每个样本的输入和输出长度)。
我踩过的一个坑是log_probs的维度顺序。PyTorch的CTCLoss默认输入形状是T×N×C(时间步×批次×类别),而RNN的输出通常是N×T×C。需要先做transpose和log_softmax。如果顺序搞错,损失值会异常大或者直接报错。
另一个坑是blank标签的索引。CTC要求空白标签的索引为0,实际字符从1开始编号。如果搞反了,模型永远学不会输出正确字符。
# CTC损失计算示例 log_probs = F.log_softmax(rnn_output, dim=2) # N×T×C log_probs = log_probs.transpose(0, 1) # T×N×C loss = ctc_loss(log_probs, targets, input_lengths, target_lengths)3.4 训练循环与学习率调度
训练循环的伪代码逻辑是:前向传播→计算CTC损失→反向传播→更新参数。我用的优化器是Adam,初始学习率1e-3,配合余弦退火调度。
训练过程中我监控三个指标:训练损失、验证损失、验证集上的准确率。准确率的计算方式是:用贪心解码把模型输出转成字符序列,与真实标签逐样本比较,完全一致才算正确。
实际训练时发现,前10个epoch损失下降很快,之后进入平台期。这时候降低学习率能帮助模型跳出局部最优。我设置的调度策略是:每20个epoch学习率乘以0.5,最低降到1e-5。
批次大小设为64,在单张显卡上训练约50个epoch后,验证集准确率能到0.85左右。继续训练到100个epoch,准确率提升到0.90附近,但再往后提升就很有限了。
注意事项:CTC训练初期损失值可能很大,这是正常的。因为模型一开始输出的概率分布接近均匀分布,对齐路径的概率很低。随着训练进行,损失会快速下降。如果训练几个epoch后损失仍然不降,需要检查标签编码、blank索引、输入长度设置是否正确。
4. 常见问题排查与效果优化实录
4.1 训练不收敛的排查思路
训练不收敛是新手最常遇到的问题。我整理了一套排查流程,按优先级从高到低检查:
| 排查项 | 检查方法 | 常见问题 |
|---|---|---|
| 标签编码 | 打印几个样本的标签索引 | 字符映射表错误,索引越界 |
| CTC输入长度 | 打印input_lengths和target_lengths | 输入长度小于目标长度,CTC报错 |
| 学习率 | 观察损失曲线 | 学习率过大导致震荡,过小导致下降缓慢 |
| 数据归一化 | 检查输入像素值范围 | 未归一化或归一化参数错误 |
| 模型输出 | 检查log_probs是否做了log_softmax | 忘记做softmax导致损失异常 |
我遇到过一次典型问题:损失值一直是NaN。排查后发现是某张图片的标签长度超过了CNN输出的特征序列长度,CTC计算时出现除零。解决方案是过滤掉这类异常样本,或者在数据预处理时确保所有图片的宽度足够。
4.2 验证集准确率上不去的优化方向
当训练损失正常下降但验证集准确率停滞时,可以从以下几个方向优化:
数据层面:增加数据增强的多样性,特别是针对验证集中错误样本的特点做定向增强。比如如果错误集中在模糊图片上,就增加高斯模糊增强。
模型层面:增加RNN的层数或隐藏维度,提升序列建模能力。我试过把LSTM从两层加到三层,准确率提升了约2个百分点,但训练时间增加了30%。
解码层面:把贪心解码换成束搜索解码。贪心解码每个时间步只取概率最大的字符,束搜索保留多个候选路径,最终选概率最高的完整序列。实测束搜索宽度设为5时,准确率能提升1到2个百分点。
后处理层面:对模型输出做规则修正。比如统计验证集中常见的混淆对(如“0”和“O”、“1”和“I”),在解码后根据上下文做替换。
4.3 推理速度优化的实操技巧
比赛通常对推理时间有要求,优化推理速度也是重要环节。我实际用过的加速方法包括:
- 降低输入分辨率:把图片高度从32降到24,宽度从256降到192,推理速度提升约40%,准确率下降约1.5个百分点。需要根据时间限制权衡。
- 模型量化:把模型参数从FP32转为FP16,推理速度提升约30%,显存占用减半。PyTorch用
model.half()一行代码就能实现。 - 批处理推理:测试时把多张图片打包成一个batch一起推理,比逐张推理快很多。但要注意batch大小不能超过显存限制。
- 去除冗余层:如果骨干网络最后几层对精度贡献不大,可以裁剪掉。我试过去掉ResNet-18的最后一个stage,推理速度提升15%,准确率只降了0.3个百分点。
4.4 独家避坑经验汇总
最后分享几个我在这个比赛中踩过的坑和总结的经验:
坑一:字符映射表不一致。训练时建立的字符到索引映射,推理时必须用同一个映射表。我有一次重新训练时改了映射表顺序,导致推理结果全部错乱。建议把映射表保存成JSON文件,训练和推理都从同一个文件读取。
坑二:图片读取的通道顺序。OpenCV读取的图片是BGR格式,而PyTorch预训练模型通常期望RGB格式。如果不做转换,精度会明显下降。我一般统一用PIL读取图片,或者在OpenCV读取后做cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。
坑三:CTC解码时的重复字符处理。CTC解码规则是:合并连续相同字符,然后去掉空白标签。比如输出序列“11-1-0-0”解码后是“110”,而不是“1110”。这个规则需要正确实现,否则解码结果会多出重复字符。
坑四:验证集划分的随机性。如果验证集划分不合理,比如验证集中某类字符特别多,会导致准确率评估偏差。建议用分层抽样,保证验证集中各字符的分布与训练集一致。
坑五:模型保存与加载的兼容性。保存模型时最好同时保存模型结构和参数,或者用state_dict方式保存并在加载时重建相同结构。我有一次只保存了参数,加载时模型结构改了一点,导致参数对不上。
这个比赛作为CV入门项目,覆盖了图像分类、序列建模、损失函数设计等多个核心知识点。把这一套流程跑通一遍,对理解深度学习项目的完整链路很有帮助。后续如果想进一步提升效果,可以尝试引入注意力机制替代CTC,或者用Transformer做序列建模,这些都是值得探索的方向。