
做车牌识别这个项目很多人的第一反应是“先搞目标检测把车牌框出来再对每个字符做分类”。这个思路没错但如果你用的是 Pytorch并且想让项目在有限的数据和时间里快速出效果我强烈建议换一条路直接用卷积神经网络提取特征再接循环神经网络做序列建模最后用 CTC 损失函数完成端到端的车牌字符识别。第P10周这个项目我选的就是这条端到端路线。输入是一张已经裁好的车牌图或者经过简单透视校正的车牌区域输出是“京A12345”这样的字符串。整个项目不依赖额外的大模型也不需要在 CPU 上跑推理时卡到怀疑人生。这篇文章把我从数据准备、模型搭建、训练调参到推理部署的完整过程写下来尤其是那些网上教程里不会明说的坑我会一条条讲清楚。适合刚学完 Pytorch 基础、想拿一个完整小项目练手的同学也适合已经在做车牌识别但准确率卡住、想排查问题的人。1. 车牌识别任务的正确打开方式1.1 车牌识别的两种主流路线车牌识别在工业界其实已经非常成熟但落地方式分两种。一种是组合方案先用 YOLO 这类目标检测网络定位车牌位置然后把车牌区域裁出来再用一个分类网络对每个字符逐一识别最后拼成字符串。这个方案的优点是每一步都能单独优化比如车牌检测不准就单独调检测模型缺点是流程长字符分割一旦出问题比如铆钉遮挡、字符粘连、倾斜后面全崩。另一种就是我这次用的端到端序列识别方案。它把“识别车牌字符”当成一个序列问题来处理类似 OCR 里的 CRNN 思路。网络直接输入车牌图像输出一个字符序列。这样做的好处是不需要精确标注每个字符的坐标框只需要整张车牌的字符串标签就可以训练模型隐式学习字符之间的时序关系对字符分割错误天然不敏感推理时也能处理长度不固定的车牌比如新能源车牌有 8 位。从工程角度看这套方案代码更少、训练流程更简洁非常适合入门实战也适合快速验证效果。1.2 为什么选择 Pytorch 搭建这套方案Pytorch 在这类项目里的优势非常明显。它的动态图机制让模型结构调试特别顺手你可以在 forward 里随意打印中间张量的 shape不需要像静态图那样先构图再编译。车牌识别这种任务输入尺寸相对固定但中间层涉及 CNN 特征图转序列的操作动态图能让你一眼看明白张量是怎么变化的。另外一个现实原因是生态。Pytorch 的 torchvision 里有很多预训练卷积模型可以直接拿来当 backbone虽然车牌字符比较特殊但用预训练权重做初始化仍然能加快收敛。再加上 Pytorch 的 DataLoader、分布式训练、ONNX 导出这些配套工具都很成熟从 lab 原型到线上部署的路径很短。如果你还在纠结框架选型我的建议是直接 Pytorch社区资料多、踩坑经验也多遇到问题搜索一下基本都有答案。1.3 模型整体流程拆解这个项目的核心流程可以拆成四段输入车牌图像经过 CNN 提取视觉特征再把特征图按宽度方向展开成序列送入 BiLSTM 建模字符之间的上下文关系最后通过一个全连接层输出每个时间步在字符集上的概率分布用 CTC 损失函数对齐标签序列。这里最关键的一步是“图像特征如何转成序列”。概念上不复杂CNN 输出的特征图是 (C, H, W)我们把高度方向的维度压缩成 1宽度方向的每个位置就看成一个时间步。每个时间步对应一条竖条图像区域的特征向量模型要做的就是根据这些竖条特征推断出这一列大概是什么字符。因为这个原因输入图像的宽度实际上决定了序列长度高度则影响特征提取的充分程度。我用的输入尺寸是 48 像素高、160 像素宽经过多次卷积和池化之后特征图变成 (512, 1, 40)也就是 40 个时间步每个时间步是一个 512 维的特征向量。这 40 个时间步足够编码 7 到 8 个车牌字符了。2. 数据准备字符集、标签与数据加载2.1 数据集选择与字符集定义车牌识别的公开数据集国内最常用的是 CCPDChinese City Parking Dataset它包含几十万张真实场景下的车辆图像并且每张图都标注了车牌的四角坐标和车牌字符串。项目前期不需要把整张车辆图拿去识别先用它的标注把车牌区域裁出来作为模型的训练输入。如果你手头有停车场出入口的抓拍数据其实更好因为真实场景里的光照、倾斜、遮挡会更贴近最终部署环境。字符集这块要提前规划好。中国民用车牌通常由省份简称1 个汉字、发牌机关代号1 个字母和序号5 位或 6 位字母数字组成。省份简称我保留了常见省份的汉字比如京、津、冀、晋、蒙、辽、吉、黑、沪、苏、浙、皖、闽、赣、鲁、豫、鄂、湘、粤、桂、琼、渝、川、贵、云、藏、陕、甘、青、宁、新等。字母和数字方面注意车牌上不会出现字母 I 和 O为了避免和数字 1、0 混淆这两个字母要排除掉。这样算下来字符集大约是 31 个汉字加 24 个字母加 10 个数字一共 65 个字符。字符集定义直接决定了模型的输出维度所以第一步就得敲定。我习惯把它保存成一个文本文件比如chars.txt每一行一个字符顺序固定。训练时读取这个文件生成char_to_index和index_to_char两张映射表。这里有个容易忽略的点CTC 的 blank 字符通常占用索引 0所以实际类别数是len(chars) 1代码里所有标签都要加上这个偏移量。2.2 标签编码思路有了字符集接下来要把“京A12345”这种字符串转成模型能吃的标签。CTC 训练时不需要严格对齐每一个字符到某一列但标签本身仍然编码成整数序列例如“京A12345”对应[9, 28, 3, 4, 5, 6, 7]索引是举例。因为车牌长度固定为 7 或 8批量训练时要用同一个 batch 内的最长标签做 paddingCTC 损失函数需要传入每个样本的真实标签长度所以除了标签序列本身我还要记录target_lengths。这里推荐把标签统一 pad 到最大长度 8不足的部分用 0 填充。注意 0 是 blank 的索引但 CTC 在计算时会根据target_lengths只取前几位真实标签所以 padding 不会影响损失计算只是方便我们用一个固定 shape 的 tensor 来组织 batch。数据加载器部分我写了一个标准的Dataset类读取图片路径和对应的标签文件在__getitem__里完成图像解码、缩放、归一化和标签编码。这里面有一个很不起眼但很要命的细节Pytorch 的 DataLoader 默认会把 batch 里的 tensor 按维度 stack如果标签已经是固定长度 8 的 tensor那么 stack 出来就是 (batch, 8)没问题。但图像 tensor 每个样本都是 (3, 48, 160)也 OK。关键是要保证同一个 batch 里图像尺寸完全一致否则 DataLoader 会直接报错。2.3 数据增强适度最好过度容易翻车车牌图像和一般分类图像不太一样字符结构本身很精细增强太狠会把笔画搞断。我试过非常激进的 RandomErasing 和大幅度旋转结果训练损失降得很慢验证集准确率反而更差。最后稳定下来的增强组合是轻度透视变换模拟车辆角度带来的四边形畸变、小角度旋转正负 5 度以内、亮度对比度微调、高斯噪声很小的方差以及随机遮挡小矩形模拟铆钉或污泥。透视变换是这里面最有效的增强因为实际停车场抓拍的车牌多多少少都有倾斜模型见过各种透视形变之后推理时面对倾斜车牌会稳很多。实现时可以直接用 OpenCV 的cv2.getPerspectiveTransform配合cv2.warpPerspective在__getitem__里随机生成四个角点的偏移量偏移幅度控制在原图宽高的 5% 以内。随机遮挡这个技巧我是在调试中偶然验证的。一开始我完全不加遮挡训练集准确率已经很高但换到另外一批真实抓拍图上准确率掉了不少。后来检查发现现场很多车牌上有泥点或者螺丝钉遮挡于是我加了随机小矩形遮挡效果立竿见影。遮挡矩形宽度大概占车牌宽度的 3% 到 8%高度可以稍微大一点但千万不要动不动遮掉 20% 以上那会把字符信息彻底抹掉模型学不到有用的东西。3. 模型搭建CNN BiLSTM CTC 的完整实现3.1 卷积骨干网络设计模型结构我参考了 CRNN 的设计思想但针对车牌识别的特点做了一些改动。CNN 部分用了一个精简的卷积栈没有直接用 ResNet 那么深的网络因为车牌字符识别不需要太宏观的语义信息反而是中低层特征更关键。我的卷积部分结构如下第一层卷积 ReLU MaxPool第二层卷积 ReLU MaxPool第三层和第四层卷积继续保持特征图分辨率最后通过一个卷积把通道数映射到 512。这里有个重要操作第二层 MaxPool 之后特征图高度从 48 变成 12再经过一次步长为 1 的卷积操作后我用了一个自适应池化或者直接调整卷积步长最终把高度压到 1。这样特征图就从 (512, 12, 40) 变成 (512, 1, 40)然后我用squeeze(2)把高度维度去掉得到 (512, 40)再转置成 (40, 512) 送入 LSTM。为了让这个流程更清晰直接看关键代码import torch import torch.nn as nn class CNNBackbone(nn.Module): def __init__(self, in_channels3, out_channels512): super().__init__() self.conv1 nn.Sequential( nn.Conv2d(in_channels, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2) # 48x160 - 24x80 ) self.conv2 nn.Sequential( nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2) # 24x80 - 12x40 ) self.conv3 nn.Sequential( nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue) ) self.conv4 nn.Sequential( nn.Conv2d(256, out_channels, kernel_size3, padding1), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) def forward(self, x): x self.conv1(x) x self.conv2(x) x self.conv3(x) x self.conv4(x) # x shape: (batch, 512, 12, 40) x x.mean(dim2, keepdimTrue) # 高度方向全局平均池化 - (batch, 512, 1, 40) x x.squeeze(2) # (batch, 512, 40) x x.permute(2, 0, 1) # (40, batch, 512) return x代码里我用x.mean(dim2, keepdimTrue)做高度方向的全局平均池化和直接用步长卷积把高度压到 1 效果差不多但实现更简单、更稳定。这个操作相当于告诉模型把每一列的所有高度信息融合成一个特征向量。3.2 双向 LSTM 建模字符序列特征序列进入 LSTM 之前要明确一点LSTM 的输入格式是 (seq_len, batch, input_size)。上面 CNN 输出的 (40, batch, 512) 恰好直接满足要求。我用的是双向两层 LSTM隐藏单元数 128这样每个时间步能同时看到前后文。为什么用双向车牌字符串虽然短但字符和字符之间存在强依赖关系比如“京A”后面跟着的通常是数字或字母最后一个字符大概率是数字。BiLSTM 可以更好地利用这种上下文约束。代码实现很简单class PlateLSTM(nn.Module): def __init__(self, input_size512, hidden_size128, num_layers2, num_classes66): super().__init__() self.lstm nn.LSTM( input_size, hidden_size, num_layers, batch_firstFalse, bidirectionalTrue ) self.fc nn.Linear(hidden_size * 2, num_classes) def forward(self, x): # x: (seq_len, batch, input_size) out, _ self.lstm(x) # out: (seq_len, batch, hidden_size * 2) out self.fc(out) # 返回 (batch, seq_len, num_classes)方便后续 CTC Loss 使用 return out.permute(1, 0, 2)全连接层的输出维度是num_classes也就是字符集大小加 1blank。CTC Loss 在 Pytorch 里的输入格式要求是 (batch, seq_len, num_classes)所以代码里做了一次 permute。3.3 CTC Loss 到底在解决什么问题CTC 是这个项目的核心它解决的是“序列不对齐”的问题。车牌图像经过网络后输出 40 个时间步但真实标签只有 7 个字符。我们不知道每个字符对应哪几个时间步CTC 通过引入 blank 机制把所有可能的对齐方式都纳入概率计算然后最大化真实标签序列的概率之和。训练时 Pytorch 的nn.CTCLoss用起来非常简单但有几个参数容易踩坑。blank0表示索引 0 是 blank。zero_infinityTrue可以避免某个样本的对齐概率下溢成 0 导致 loss 为无穷大。另外需要注意的是CTCLoss 的输入 log_probs 需要用F.log_softmax(output, dim2)做归一化不能在模型里直接输出 softmax 结果也不能直接拿原始 logits 计算。我当时第一次写就漏了 log_softmax训练 loss 一路飙升检查半天才发现。loss_fn nn.CTCLoss(blank0, zero_infinityTrue) # 训练时 log_probs F.log_softmax(model(images), dim2) # (batch, seq_len, num_classes) log_probs log_probs.permute(1, 0, 2) # CTCLoss 需要 (seq_len, batch, num_classes) loss loss_fn(log_probs, targets, input_lengths, target_lengths)这里的input_lengths是一个长度为 batch 的 tensor每个值都是序列长度 40因为我们的输入序列长度固定。target_lengths是每个样本真实标签的长度比如[7, 7, 8, 7]。这个长度信息必须和标签 padding 之前一一对应否则 CTC 计算的路径会错位模型根本学不到东西。4. 训练配置与识别效果验证4.1 超参数选择与完整训练流程训练超参数我试过很多组合最后稳定下来的一套是优化器 Adam初始学习率 0.0005batch size 64训练 25 到 30 个 epoch。学习率采用余弦退火策略前 3 个 epoch 做 warmup让它从 0.0001 逐渐升到 0.0005。车牌识别这个任务不算特别复杂过大的学习率会导致 loss 在早期震荡过小则收敛太慢0.0005 这个量级配合 Adam 在 ResNet-18 级别的模型上表现很稳。完整的训练流程可以分成几块读取 batch、前向计算、计算 CTC loss、反向传播、梯度裁剪、更新参数。梯度裁剪这里值得多说一句LSTM 训练时很容易出现梯度爆炸尤其是序列长度较长或者学习率偏大的时候。我给 max_grad_norm 设了 5一旦梯度范数超过这个值就缩回去。不加这个操作训练到中间某个 batch 时 loss 可能突然变成 nan整个模型直接报废。每训练一个 epoch我都在验证集上算一次整牌准确率并且只保存验证集准确率最高的那个模型权重而不是最后一个 epoch 的权重。车牌识别容易出现过拟合验证集准确率通常在中间某个点达到峰值之后再训就掉了。这个问题在数据量只有几万张时尤其明显所以模型保存策略不是小事。4.2 评估指标别只看损失值训练过程中很多人只盯着 loss 下降但车牌识别真正要关心的是整牌准确率。我定义了两种指标字符准确率也就是所有预测出来的字符里有多少是对的这个指标偏向于评估模型对单个字符的辨识能力整牌准确率也就是一张车牌的 7 位或 8 位字符全部预测正确才算对这个指标更贴近真实需求。评估的时候我会把模型切到 eval 模式关闭梯度计算对验证集图片做前向推理然后用后面要讲的贪心解码算法把模型输出转成字符串再和真实标签比较。实测下来在一个大概 3 万张的训练子集上训练 25 个 epoch 之后字符准确率可以到 98.5% 左右整牌准确率在 94% 到 96% 之间。如果训练数据扩充到 10 万张以上整牌准确率可以进一步提升到 97% 以上。需要提醒的是验证集里如果包含很多模糊、强曝光或者严重倾斜的车牌整牌准确率会明显下降。这不代表模型有问题而是数据分布本身更难。如果部署场景就是停车场出入口那种相对固定的角度和光照模型表现会好很多。4.3 模型保存与加载的工程细节训练完的模型不能只存一个model.state_dict()就完事因为推理的时候还需要字符集映射表。我习惯把模型权重和字符集打包保存比如用torch.save保存一个字典里面包含state_dict、chars字符串列表、input_height和input_width这些元信息。这样推理脚本加载模型时不需要额外配置文件也能还原出完整的字符映射关系。加载模型时还有一个容易错的地方如果你保存的是state_dict而模型类定义在另一个文件里需要保证模型结构完全一致包括 LSTM 的层数、hidden_size、CNN 的通道数。我吃过这个亏模型类里一个全连接层维度写错加载时直接报 shape mismatch排查起来还很费神。所以保存的时候最好连带保存模型配置参数加载时先根据参数重建模型结构再 load_state_dict。5. 推理解码与工程化细节5.1 从模型输出到车牌文本推理时模型输出的是一个三维张量 (batch, seq_len, num_classes)每个时间步在字符集上的概率分布。要得到最终字符串最常用的方法是贪心解码每个时间步取概率最大的类别索引然后去重并删除 blank。Pytorch 代码实现如下def greedy_decode(output): # output: (batch, seq_len, num_classes) pred output.argmax(dim2) # (batch, seq_len) batch_size pred.size(0) results [] for i in range(batch_size): indices pred[i].tolist() chars [] prev None for idx in indices: if idx ! 0 and idx ! prev: chars.append(idx) prev idx results.append(chars) return results这里去重和去 blank 的顺序很容易搞错。CTC 解码的规则是先合并连续的相同索引再删除 blank。对应到代码里就是if idx ! 0 and idx ! prev。先合并再删 blank不会漏字符。如果你先把 blank 去掉再合并重复字符遇到“京A88888”这种连续相同数字的情况就会丢掉多余的 8。贝叶斯解码Beam Search我在这版项目里没有用因为字符集不大、序列长度很短贪心解码已经够用。在实际测试中Beam Search 对准确率的提升非常有限大约 0.1 到 0.3 个百分点但推理耗时增加明显。对车牌识别这种实时性要求高的场景贪心解码是更优选择。5.2 中文车牌和新能源车牌的兼容车牌识别项目逃不开新能源车牌的问题。新能源车牌是 8 位前 1 位是省份汉字第 2 位是字母第 3 位到第 8 位是字母数字混合而且它比普通蓝牌更长。这两个差异对模型来说不是大问题因为 CTC 天然支持不定长输出只要训练数据里有足够多的新能源车牌模型会自动学会输出 8 个字符。工程上需要处理的主要是输入尺寸。如果你的模型输入宽度固定为 160新能源车牌比普通车牌长暴力 resize 会压缩字符导致识别率下降。我常用的做法是先把车牌区域根据宽高比裁出来然后不改动长宽比、以高度为基准缩放短边不足的部分填充黑色。这个预处理步骤在 DataLoader 的get_item里完成训练和推理保持一致。新能源车牌的字符集和普通蓝牌完全一致不需要额外加字符。它的难点主要是样本不均衡。CCPD 数据集里新能源车牌比例不高如果不做处理模型会严重偏向输出 7 位字符。我的解决办法是单独采样新能源车牌样本在 DataLoader 里设置一个采样器让每个 batch 里新能源车牌占比不低于 30%效果立竿见影。5.3 推理加速与模型导出训练好的模型要真正落地通常不会直接跑 Pytorch 的 Python 脚本。我一般先把模型导出成 TorchScript 或者 ONNX再用 TensorRT 或者 OpenVINO 做推理加速。车牌识别场景通常运行在边缘设备或者普通 CPU 服务器上ONNX Runtime 配合 OpenVINO 可以把单张推理时间压到几十毫秒以内。导出 ONNX 时要注意模型里不要有动态维度导致的算子不兼容。我的 CNN LSTM 结构在导出时比较容易出问题的是 LSTM 部分如果你用的是 Pytorch 原生nn.LSTMONNX 导出一般能自动处理但如果你在 forward 里传了初始隐状态(h0, c0)导出时要把这两个参数固定为全零张量并且用torch.onnx.export的dummy_input完整走一遍前向。如果你不需要跨框架部署直接保存 Pytorch 模型用torch.jit.trace导出 TorchScript 会更省事。因为输入尺寸固定trace 模式足够应付而且 LSTM 在 TorchScript 里的兼容性比 ONNX 好很多。我之前用 ONNX 在部分 CPU 设备上遇到过 LSTM 算子不支持的情况换 TorchScript 就一路畅通。6. 常见问题排查与避坑指南6.1 训练阶段的高频问题训练前先检查数据加载。如果你发现训练循环卡住不动最可能的原因是 DataLoader 的num_workers在 Windows 上出了问题把num_workers设为 0 是最快的解决办法。另外所有图片必须确保能被 OpenCV 正常解码我之前有一批图片本身损坏DataLoader 直接抛异常排查了好久才发现是数据问题。loss 不下降是新手最常遇到的问题。如果 loss 一直保持在比较高的水平几乎不动第一步检查是不是忘记做log_softmax第二步检查标签索引有没有偏移。blank 索引是 0但字符映射表通常从 0 开始就是第一个汉字这样会造成 blank 和第一个汉字冲突。正确做法是字符集索引整体加 1让 0 专门留给 blank。这个错误非常隐蔽因为模型仍然能学到东西但准确率永远上不去。梯度变成 nan 的基本原因就两个学习率太大或者数据里有异常值。数据归一化其实也很关键车牌图像的像素值如果不除以 255模型输入范围太大BN 层虽然能缓解但训练初期仍然容易出现梯度爆掉。我习惯在ToTensor和归一化里直接把图像缩放到 [0, 1] 区间这个问题基本不会再出现。6.2 推理阶段的高频问题推理时输出全为空字符串先别急着怀疑模型。检查一下推理数据的预处理是不是和训练一致尤其是图像缩放方式。如果训练时用双线性插值缩放到 48x160推理时却用了最近邻插值模型看到的特征分布完全不同输出自然全错。输出字符串长度不对比如 6 位或者 9 位这是个值得琢磨的问题。如果模型在训练集上整牌准确率很高但推理时长度错误多半是输入图像里有多余的元素比如车牌的白色边框被一起裁进来了模型把边框识别成了字符。解决办法是预处理时做一次边缘裁剪只保留最外圈字符区域。最后一个很实际的问题GPU 显存不够。车牌图像尺寸不大48x160 的输入对显存压力很小但如果 batch size 设得太大照样会 OOM。先排查 batch size不要一上来就用 128。另外 LSTM 部分在反向传播时需要保存所有时间步的中间状态序列长度是 40其实是可控的所以这个项目对显存的需求不高满血 2080 都能轻松训练。6.3 环境配置与依赖项避坑这个项目依赖 Pytorch、OpenCV、Pillow、NumPy 这些常见库安装本身不复杂但版本不匹配容易出各种诡异问题。建议直接用 Anaconda 创建独立环境Python 版本 3.9 或 3.10 都可以。Pytorch 的安装要对照自己的 CUDA 版本如果只是做 CPU 推理和训练小模型CPU 版本也完全够用。下载慢的话换个国内镜像源几分钟就能装好。OpenCV 在读取中文路径图片时经常报错这个问题非常恶心。默认的cv2.imread对中文路径支持不好返回 None 还不报错你会以为图片不存在实际上只是路径编码问题。解决方案是先用np.fromfile读取文件字节再用cv2.imdecode解码或者干脆在数据预处理阶段把中文路径改成英文和数字。Pytorch 和 CUDA 版本不匹配最常见的表现是torch.cuda.is_available()返回 False但显卡明明存在。先执行nvidia-smi看驱动支持的 CUDA 版本再选择对应版本的 Pytorch。不要盲目装最新版稳定兼容比新特性重要得多。如果你用 CPU 跑这个项目训练速度会慢不少但跑通流程没问题单张图片推理时间在 100 毫秒以内完全能接受。做完这个车牌识别项目我最大的感受是端到端序列识别方案的容错能力比传统“分割 分类”强太多它不需要你去精调字符分割的阈值也不需要为每个字符单独训练分类器。真正决定准确率上限的是数据质量和预处理细节——倾斜校正、样本均衡、增强幅度这些都比网络结构本身更影响最终效果。从第 P10 周这个节点出发把这个项目吃透之后你会发现随手换到验证码识别、票据号码识别核心思路都大同小异。