
1. 为什么拿车牌识别练手以及两条技术路线的取舍先说一下这个项目的来龙去脉。我大概用两周多时间用 Pytorch 从零实现了一个车牌识别系统含训练、验证、推理全流程。整体效果是在测试集上字符级准确率能到 97% 左右整牌准确率在 93% 上下。这个成绩不算惊艳但对于一个学习项目来说已经能说明很多问题——模型架构、训练技巧、数据策略这些核心环节我都摸过一遍了。为什么选车牌识别因为它是 OCR 细分场景里麻雀虽小、五脏俱全的典型代表。车牌字符集合很小省份简称约 30 个汉字 24 个英文字母 10 个数字但目标尺度变化大、光照环境复杂、运动模糊常见而且还有一个汉字识别问题——汉字笔画密集特征提取难度比字母数字高一个量级。做完这个小项目你对 CNN 特征提取、序列建模、CTC 损失函数、推理解码这一整条链路都会有切身体会而不是停留在调 API 的层面。动手之前第一个绕不开的决策是技术路线。主流做法大致分两类两阶段方案先用目标检测模型定位车牌区域再把裁切出的车牌图丢给字符分类模型或序列识别模型。检测可以用通用的目标检测框架识别部分则退化成纯分类问题逻辑直观且每阶段都能单独优化。端到端方案输入一张车辆图像网络直接输出车牌字符串。这类方案通常把车牌区域定位和字符识别压缩在一个模型里或者退一步——先用传统方法粗定位区域再用端到端的序列识别网络做字符解码无需逐字符标注。我先试的是两阶段。检测部分用现成权重识别部分自己训练一个 ResNet 分类器按固定位置切字符。结果发现一个麻烦车牌字符位置并非完全均匀分布尤其是汉字和字母之间有空隙不同车牌的字符间距还会因为拍摄角度发生变化。固定切分的方法在正对车牌的图片上勉强能用一旦有倾角就崩。后来我才明白固定切字符本质上是在赌拍摄条件而序列识别才是应对自然场景的正解。端到端方案里业界已有较成熟的思路用轻量 CNN 主干提取特征图按时间步展开成特征序列送入双向循环网络建模上下文最后用 CTC 损失函数对齐变长车牌字符串和变长特征序列。这套设计不需要逐字符的坐标标注只需要整张车牌的文本标签数据标注成本低很多。而且模型天然能学习字符间的组合关系比如京 A后面大概率跟数字这种语义信息是固定切分方案完全不具备的。我最终选了后者但不是一上来就照搬完整结构。我的做法是把整套任务拆成粗定位 序列识别两个环节。先用一个简单的规则加形态学操作把车牌大致区域从图中切出来再用序列识别网络处理区域图。这样既不需要训练检测模型又能避开字符级标注整个项目复杂度降到一个人能 hold 住的范围。这个决策直接影响后面所有步骤我认为是整条开发链路里最重要的一步。2. 数据集这一关公开资源、合成车牌与标签对齐细节选完路线就该喂数据了。很多新手在这一步会低估工作量随手拿几百张图片就开始训练结果模型欠拟合严重还以为是网络结构的问题。我一开始也踩了类似坑后来老老实实把数据环节补齐训练效果立刻上了一个台阶。2.1 数据来源和数量规划公开的车牌数据集在网上能搜到一些主要包含国内各省车牌的车辆图或已裁切车牌图。不过直接拿来用有几个问题有些数据集的标注格式不统一有的是整车图带车牌框坐标有的是纯车牌图带字符串标签还有的包含大量特殊车牌使馆、警用、新能源需要过滤。另一点是分布不均某些省份的简称出现频率特别低模型很容易对冷门省份汉字产生盲区。我最后定的数据构成是公开数据约 1.2 万张纯车牌图作为基线再用合成数据补足长尾。合成数据这一步在这个项目里不是锦上添花而是雪中送炭——它解决了两个实际问题一是冷门省份汉字样本不足二是复杂光照和倾斜角度的覆盖不够。2.2 合成车牌的生成思路合成车牌听起来简单做起来有不少细节。我写了一个生成器核心逻辑分为三层背景层真实车牌底色蓝底白字、绿底黑字、黄底黑字等叠加渐变和噪声来模拟自然光照而不是纯色块。字符层按真实车牌排版规则把字符绘制上去——省份简称 发牌机关代号 字母数字组合。字体选择很关键必须用接近车管所字库的等宽字体否则合成图与真实图差异过大会影响迁移效果。退化层随机旋转、透视变换、高斯模糊、运动模糊、亮度抖动、对比度调整、JPEG 压缩噪声。这三层加在一起每生成一张图都是不同的拍摄场景。我大概生成了 3 万张与公开数据混在一起训练比例大约 2.5:1。实测下来加入了合成数据后冷门汉字的召回率提升非常明显说明合成数据确实弥补了长尾分布空缺。2.3 标签编码与序列对齐序列识别模型的标签不能直接存字符串需要编码成整数序列。我维护了一个字符表包含31 个省份简称汉字、24 个英文字母排除 I 和 O避免与数字 1 和 0 混淆、10 个数字加一个 CTC 空白符共 66 类。每个车牌字符串按字符表映射成一个长度不等的整数序列作为训练标签。这里有一个特别容易出错的细节CTC 损失要求输入序列长度必须大于或等于标签序列长度。我的网络在时间步维度上输出 18 个特征向量对应输入图片被压缩后的宽度方向而车牌字符串最长 7 个字符新能源车牌 8 位。18 对 7留了充足冗余。如果你把特征图压得太狠时间步小于字符数CTC 会直接报错或者训练不收敛这是最典型的新手坑。2.4 数据增强的取舍增强策略上我比较克制。基础增强包括随机亮度、对比度、饱和度扰动随机仿射变换范围控制在正负 10 度内随机水平和垂直方向微小平移。更激进的增强比如随机遮挡、Cutout我试过但效果不大因为车牌字符本身密集遮挡容易把关键字符遮掉反而产生难以学习的样本。另外增强操作必须在标签不变的前提下进行比如旋转角度过大导致车牌字符串本身不可读这种样本实际上是噪声而不是增强需要控制变换幅度。3. 网络骨干与序列建模的核心原理拆解模型结构这块我参考了业界常见的端到端车牌识别网络设计整体可以拆成三段卷积骨干提取视觉特征、循环层建模序列依赖、CTC 解码输出字符概率。每一段都有可以展开讲的细节这里按我的理解逐个说清楚。3.1 卷积骨干轻量化与小感受野的平衡骨干网络用了类似 VGG 风格的小卷积堆叠没有直接套 ResNet 那种重型结构因为车牌字符是密集排列的小目标过于激进的降采样会丢失细节。我的配置是三层卷积 池化通道数从 32 逐步升到 128。每层卷积都是 3x3 小卷积核步长 1padding 1激活用 ReLU。池化只在宽高两个方向同时降采样但宽度方向的压缩要克制。输入车牌图统一 resize 到 96x32宽 x 高经过骨干后特征图尺寸约 24x8宽 x 高宽度方向保留了 24 个空间位置。这个设计是有意为之的。特征图宽度方向的每个位置可以理解为原始图片上一个垂直条形区域的语义抽象。24 个位置对应 24 个时间步每个时间步包含图像局部区域的信息供循环网络按顺序读取。高度方向压缩到 8 是有讲究的——车牌字符高度方向的信息需要保留一部分完全压成 1 会丢失字形结构比如鲁和晋这类笔画密集的汉字需要垂直方向的空间信息才能区分。3.2 双向循环层为什么单向 LSTM 不够用卷积输出的特征序列需要送入循环网络建模上下文。我用的是双向 LSTM隐藏单元 128。双向的含义是两个独立的 LSTM 分别按正序和逆序读取特征序列最后把两个方向的隐状态拼接输出。为什么必须双向车牌字符有强上下文依赖省份简称后必然接发牌机关代号代号后通常接数字或字母某些字母如 Q后面极少出现特定字符。正向 LSTM 能捕捉前面字符限制后面字符的规律但有些依赖是反向的比如后面是数字时前面的模糊字符更可能被推断为字母而不是汉字。双向结构让每个位置的输出同时看到左右两侧的信息大幅提升解码准确率。3.3 CTC 损失让网络自己学会对齐模型输出的原始结果是 24 个时间步上、每个时间步 66 个类别的概率分布。问题来了24 个时间步对应 7 个字符怎么把序列压缩成字符串这就是 CTC 要解决的核心问题。CTC 引入了一个特殊的空白符号blank标记为索引 0。它允许网络在每个时间步输出 blank 或某个真实字符。训练时路径是每个时间步输出一个符号的完整序列比如 24 个时间步输出 24 个符号其中包含大量 blank 和重复字符。CTC 要计算的是所有能折叠成目标标签的路径的概率之和。折叠规则有两条先合并相邻的重复字符再删除 blank。这就解决了两个问题一是字符重复——如果车牌里有连续相同字符比如京 A·A123直接按 argmax 解码会得到一个 A 还是两个 ACTC 通过 blank 插入让网络产生一个 A、一个 blank、再一个 A的路径折叠后得到两个 A。二是字符间无显式边界——网络不需要知道每个字符的精确起止位置只需要整体概率最高对齐这件事被 CTC 隐藏起来了。用 Pytorch 实现特别简单核心代码只有几行import torch.nn.functional as F # log_probs: (T, N, C)T时间步NbatchC类别数含blank # targets: (N, S)NbatchS每个样本的标签长度需按长度排序 # input_lengths: (N,)每个样本的时间步数通常是同一个值 # target_lengths: (N,)每个样本的标签长度 loss F.ctc_loss(log_probs, targets, input_lengths, target_lengths, blank0)但要记住一个细节F.ctc_loss要求输入按target_lengths降序排列这是为了在 batch 内部做闪存优化。训练时如果忘了按长度排序会报错或得到错误结果。3.4 解码策略贪心解码与 Beam Search 的取舍推理阶段需要把概率序列变成字符串。最简单的做法是贪心解码每个时间步取概率最大的类别得到符号序列后按 CTC 折叠规则压缩。我实测贪心解码在测试集上整牌准确率大约 91%对学习项目来说已经不错。如果想进一步提升可以换 Beam Search。它维护多条候选路径每步扩展时保留概率最高的 Top-K 条最后再选整体概率最高的路径。Beam Search 比贪心多考虑全局一致性比如京 A后面按概率分布可能有多个候选Beam Search 会把它们都保留下来让后续时间步的信息参与决策。代价是推理耗时增加我测试过 Beam width10 时整牌准确率提升约 2 个百分点但单张推理时间从 2ms 涨到 15msCPU移动端部署时需要权衡。贪心解码实现很简单核心逻辑# preds: (T, C)模型输出的概率分布 pred_ids preds.argmax(dim1).tolist() # 每个时间步取最大概率类别 # CTC 折叠: 合并相邻重复 删除 blank(0) collapsed [] prev -1 for idx in pred_ids: if idx ! prev and idx ! 0: collapsed.append(idx) prev idx # collapsed 中的索引映射回字符 chars [char_table[i] for i in collapsed] plate_text .join(chars)4. 训练全过程与踩坑记录模型结构定下来之后训练过程反而是整个项目里耗时最长的部分。神经网络训练像调乐器结构是乐器本身超参数是调音旋钮数据是演奏曲目三者匹配才能出好声音。下面记录几个我踩得最深的坑。4.1 第一个坑学习率设置不合理导致不收敛我最初用固定的学习率 1e-3Adam 优化器。跑了 10 个 epoch训练 loss 从 20 左右降到 8 左右就不再下降验证集准确率只有 40%。当时以为是模型容量不够后来排查才意识到是学习率偏大导致 loss 在高位震荡。调整方案是改用余弦退火调度器初始学习率 1e-3最小学习率 1e-5一轮训练 50 个 epoch。前 5 个 epoch 预热warmup从 1e-5 线性升到 1e-3然后按余弦曲线衰减。改动之后训练 loss 顺利降到 1.5 以下验证集准确率突破 85%。关键经验如果你的训练 loss 下降一段后停滞在一个较高位置且震荡明显优先怀疑学习率调度不要急着改模型结构。4.2 第二个坑字符类别不均衡导致冷门汉字欠拟合训练初期发现皖赣鄂这类省份简称错误率特别高而京苏粤这些常见省份几乎不会错。原因很明确数据分布不均衡。公开数据集里常见省份占比太高冷门省份样本不足。我做了两个修正。一是合成生成器按均匀分布采样省份简称确保每个汉字在训练集中出现频率接近。二是对公开数据做按省份重采样把冷门省份的图片复制增强直到各类别数量达到一个较均衡的水平。这两个操作之后冷门汉字的 Top-1 准确率从 70% 提升到 95% 以上。4.3 第三个坑batch size 越小训练越不稳定尝试过 batch size 32 和 128对比很明显。batch size 32 时loss 曲线噪声大收敛速度慢batch size 128 时loss 曲线光滑很多最终收敛效果也更好。原因是序列数据的长度差异较大batch 大小影响梯度估计的稳定性。不过加大 batch 的同时要同步调学习率。我用的是线性缩放经验法则batch 翻倍学习率也翻倍。比如 batch 64 配 5e-4batch 128 配 1e-3。只加 batch 不调学习率效果反而可能更差。4.4 训练超参数速查表超参数我使用的值备注优化器Adam默认 betas(0.9, 0.999)初始学习率1e-3配合 warmup 余弦退火Batch size128显存 8G 可跑Epoch50早停 patience8图像分辨率96x32宽 x 高时间步24骨干网络输出特征宽度字符类别数66含 CTC blank梯度裁剪max_norm5防止 LSTM 梯度爆炸4.5 验证集评估整牌准确率 vs 字符级准确率训练过程中的评估指标我同时跟踪两个字符级准确率和整牌准确率。字符级准确率是所有预测字符中正确字符的比例整牌准确率是整张车牌字符串完全正确的比例。前者更细粒度后者更贴近实际使用。我最后 50 个 epoch 的收敛结果是字符级准确率 97.2%整牌准确率 93.1%。差距主要来自多字符联合错误——单个字符准确率再高乘起来整牌准确率就会打折。三个字符各 99% 准确率整牌准确率大约是 0.99^3 97% 左右如果七个字符都 99%整牌就只有 93%。所以如果追求整牌准确率字符级准确率需要到 99.5% 以上才行。这个数学关系一直提醒我错一个字符的代价和错七个字符是一样的精度是工程的天花板。5. 推理链路工程化模型导出、解码与性能优化训练跑通只是第一步。真正要落地到实际场景还得解决推理链路里的几个实际问题。这一段我把自己在工程化过程中踩过的问题和最终方案写出来。5.1 模型导出与动态形状处理训练好的模型要部署到实际环境常见做法是导出成通用格式再加载推理。我用 TorchScript 做中间转换核心考量是摆脱对训练代码的依赖。但这里有一个大坑我的模型输入尺寸固定为 96x32但实际推理时检测模型裁切出的车牌区域长宽比不是固定的。直接 resize 到 96x32 会拉伸变形影响识别效果。更好的做法是等比例缩放短边补零到固定尺寸。比如裁切区域是 120x36按比例缩放到 96x28上下各 pad 2 个像素的零值得到 96x32再送入模型。这样字符没有被拉伸识别准确率稳定性明显上升。TorchScript 导出代码大致如下model.eval() example_input torch.randn(1, 3, 32, 96) # (batch, channel, height, width) traced_script_module torch.jit.trace(model, example_input) traced_script_module.save(plate_recognition.pt)5.2 推理时的批处理策略实际业务场景经常面对的不是单张图片而是一段视频或一批车辆图片。逐张推理效率太低我采用批处理方式把多张车牌区域图叠成一个 batch一次性前向计算出所有结果。Pytorch 的 batch 推理比单张推理平均快 3 到 5 倍因为矩阵乘法的并行利用率更高。batch 大小需要根据实际计算资源调节。CPU 推理时我一般用 8 到 16如果跑 GPU可以到 64。还有一个经验推理时不需要梯度用torch.no_grad()或torch.inference_mode()包裹前向计算能额外节省显存和少量计算时间。5.3 后处理置信度过滤与多帧稳定模型输出的每个时间步概率分布可以换算成整体置信度。我的做法是把每个时间步最大概率的均值作为整张车牌的置信度低于 0.8 的结果视为低置信度交给下游逻辑决定是否重新抓取或人工介入。这一步能有效拦截模板字符、模糊车牌等难例。在视频场景下同一个车牌会在多帧中出现。简单地对多帧结果做投票能进一步提升准确率。我实测对连续 5 帧的识别结果做字符级投票整牌准确率从 93% 提升到 96.8%代价是延迟增加 5 帧左右但如果业务对实时性要求不高这个策略性价比极高。5.4 性能基线参考推理环境单张耗时备注CPU4 核约 30ms含预处理和后处理CPU 批处理batch16约 6ms/张平均到单张GPU入门级显卡约 2ms/张含预处理和后处理性能调优到这里基本够用了。如果想进一步压榨可以考虑模型量化INT8或剪枝但这个项目作为学习记录我暂时没做太深。后续扩展方向可以参考文章最后的个人思考。6. 从结果反推问题哪些错误最难修以及我学到的三件事项目做到这个程度已经可以归档了。但复盘这个过程有几件事是比能跑更值得沉淀下来的。6.1 最常见的错误类型与根因我统计了测试集中约 500 张错误样本发现错误集中在几类模糊车牌运动模糊导致字符粘连、极端光照强逆光或夜间、以及相似字符混淆B和8、0和D、皖和晚。前两类属于图像质量退化需要更好的数据增强或超分辨率前处理来缓解第三类属于特征混淆需要更强的判别性特征——比如字符级注意力机制或更大容量模型。特别想说一下B和8这一对。人眼能轻松区分是因为我们综合了上下文和几何细节B 的右侧是竖直笔画8 的右侧是弧形。模型有时在小分辨率图上会忽略这种局部细节。我试过把车牌图高度从 32 提到 48字符级准确率提升约 1.5%说明更高的分辨率确实能缓解相似字符混淆但训练和推理成本都会增加。6.2 端到端方案与两阶段方案的再思考做完这个项目我对两种路线的理解比动手前清晰得多。端到端方案在数据量充足时上限更高、部署更简洁但排查问题时是端到端黑盒字符错误很难定位是视觉特征的问题还是序列建模的问题。两阶段方案调试直观但中间产物检测框、字符位置会引入累积误差。对学习项目来说我的建议是**从端到端序列识别入手把 CTC 的原理吃透再回头补检测模块。**CTC 这个损失函数是深度学习序列任务的通用工具——语音识别、手写识别、场景文字识别全都在用掌握它带来的复利远超项目本身。6.3 项目可扩展的三个方向如果后续要继续深入我会优先考虑三个方向。一是把粗定位环节替换成可学习的目标检测模块让整个流程实现输入端到端适应更复杂的场景。二是引入字符级注意力机制让模型在解码时显式关注每个字符对应的高价值图像区域。三是把模型量化部署的链路补全包括 INT8 量化、ONNX 导出以及前向推理框架的适配因为从学习到落地的距离往往比从模型到精度的距离更远。最后说一点个人体会。做这个项目之前我觉得车牌识别无非是图像分类的延伸做完之后才发现序列数据建模和定长分类在思维方式上是两回事。CTC 让我第一次真正理解了概率对齐这个概念也让我对 Pytorch 的损失函数体系有了更深的认识。如果你也准备拿这个项目练手希望这份记录能帮你少走几步弯路——尤其是数据部分值得多花时间打磨它会以你意想不到的方式回馈你。