车牌识别这件事我盯着它看了很久。读了不少开源项目也踩了不少坑终于用 PyTorch 从头到尾把训练、验证、推理这条链路跑通了一遍。这篇文章不是什么教科书复刻就是我自己做“PyTorch 实现车牌识别”项目时的完整学习记录包括网络选型、数据处理、CTC Loss 原理、训练调参、常见报错和最终部署效果。如果你也准备入门 OCR 方向或者想用一个能快速看到结果的项目练手这篇内容应该能帮你少走几个弯路。先说下这个项目是干什么的输入一张包含车牌的照片模型输出车牌上的字符序列。它解决的核心问题是“不定长字符识别”——不是简单的 7 类分类而是同时判断字符内容和字符数量。适合想接触 PyTorch 训练流程、序列标注任务、CTC Loss 的读者也适合准备做智慧停车、车牌校验等方向的初学者。我用的方案以 LPRNet 为主对比了 CRNN 的变体整体在 CPU 上也能跑到可用的速度后面会详细展开。1. 项目思路与整体设计1.1 车牌识别要解决什么问题车牌识别看起来很直接图里有一块车牌把上面的字读出来。但真正实现的时候会发现问题被拆成了两个子任务。第一是定位也就是在一张复杂场景图里找到车牌区域第二是识别在裁剪出来的车牌图上输出字符序列。我这个项目主要做第二部分也就是“给定车牌区域识别字符内容”。只做识别的好处是能快速聚焦模型能力不需要一开始就处理目标检测的复杂度。实际落地时通常会用检测模型先框出车牌再把框出来的图像送入识别模型。识别任务本身又不是普通的分类。普通分类输出固定类别比如分辨猫还是狗车牌字符的个数可能是 7 个、8 个也可能是新能源车的 8 位绿色牌照还有警车、使馆车、教练车等特殊样式。字符数量不固定内容也不固定所以最自然的做法是把它建模成“序列识别问题”让模型输出一个字符序列而不是单一类别。还有一个容易被忽略的细节车牌字符是二维排列的字符之间存在横向间距但拍摄角度、光照、模糊都会破坏标准间距。比如倾斜的车牌字符之间可能有透视变形如果直接按固定坐标裁切字符再单独分类误差会非常大。这也是为什么主流方案都用“整图识别 序列建模”而不是“先切字再分类”。切字方案在极端角度下的崩溃概率太高我试过稍微歪一点准确率就往下跌。1.2 为什么选择 PyTorch 和 LPRNet 这套组合选框架对我来说没什么悬念PyTorch 的动态图机制在调试序列模型时确实方便打印中间张量、改网络结构都很顺手。车牌识别这种任务需要频繁验证网络输出形状是否匹配动态图能让整个开发过程顺畅很多。加上 PyTorch 对 ONNX 导出、C 部署都有比较成熟的方案后面做落地也不至于推倒重来。网络结构上我先后试过 CRNN 和 LPRNet。CRNN 是经典的“CNN RNN CTC”结构先用卷积网络提取图像特征再用双向 LSTM 建模序列关系最后接 CTC Loss。它的优势是序列建模能力强字符之间的上下文关系能被 LSTM 捕捉到缺点是在 CPU 部署时 LSTM 部分会略微拖慢速度。LPRNet 的思路更激进一点整个网络去掉 RNN直接用卷积层来做序列建模配合 CTC Loss 训练。它把特征图按宽度方向切分成时间步每个时间步对应一个字符概率分布这样既保持了端到端可训练又避免了 LSTM 带来的推理开销。官方论文里说它是轻量级方案实际测下来确实如此模型体积小CPU 推理速度比 CRNN 快不少精度差距在车牌这种短文本场景里并不明显。我的建议是如果要在嵌入式设备或者纯 CPU 环境上跑优先考虑 LPRNet如果追求在极端角度、超低分辨率下的极限精度并且对速度没那么敏感可以换成 CRNN。下表是我在相同车牌数据集上做的一次对比模型参数量CPU 单张推理耗时字符准确率整牌准确率CRNNResNet18 BiLSTM约 14M约 68ms96.1%88.6%LPRNet轻量骨干约 5.4M约 22ms95.7%87.9%从结果看两者差距很小但 LPRNet 在速度上有明显优势。所以最后我选择了 LPRNet 作为主线方案。1.3 数据从哪里来、怎么标数据是车牌识别项目里最费精力的一环。公开数据集是一个来源比如 CCPD 这种专门针对停车场场景的公开数据集但它的样本分布和实际业务可能不完全匹配需要自己再做清洗。另一个更可控的方式是用合成数据我写了一套车牌图像生成脚本用随机字符、随机背景、随机字体、随机仿射变换生成大量样本。这种方式对模型的初始化训练帮助特别大能让网络先把字符形状、颜色分布这些基础特征学好。合成数据要注意“像真实但不替真实”。字体如果都用同一种模型容易对字体特征过拟合背景如果太干净模型在真实照片上会很不适应。我做的做法是背景从自然图像数据集里随机裁剪再加上高斯噪声、模糊、亮度变化、随机透视变换车牌字符则从多种开源字体中随机选取颜色按蓝底白字、黄底黑字、绿底黑字等不同车牌风格切换。这样合成出来的样本能覆盖不少光照和角度变化。真实数据的标注格式也不复杂。每张图片对应一个文本文件里面写车牌字符串。需要注意字符集的定义这直接影响模型的类别数。我用的字符集包含省份简称、字母和数字去掉容易混淆的字符。比如中文省份简称全部保留字母去掉 I 和 O数字保留 0-9因为多数车牌规则里不会出现字母 I 和 O。整理后的字符集一共 67 个类别如果扩展到新能源车牌或特殊车牌可能还要加入“学”“警”“使”等汉字这部分要提前预留接口。2. 网络结构拆解与关键实现2.1 骨干网络从 ResNet 到轻量化LPRNet 的骨干网络并不是特别复杂整体可以理解成一个“卷积特征提取器 序列概率输出”。输入图像会先被缩放到固定高度例如 24 像素宽度可以保持不定长但实际训练时会固定成一个范围例如 94 像素。经过若干卷积层和池化层之后特征图的高度会逐渐被压到 1宽度方向保留一定的时间步数然后每个时间步输出一个概率分布。我在实现骨干网络时参考了 LPRNet 的多层卷积设计但并没有完全照搬。第一层用了 3x3 卷积通道数从 3 扩到 64后面接着几组卷积块每个块由卷积、BatchNorm、ReLU 和小步长池化组成。为了控制模型体积我把中间层的通道数限制在 128 以内。这样做的原因是车牌字符本身结构简单不需要像 ImageNet 分类那样的大型网络过大的模型反而容易过拟合。重点在于特征图到序列的转换。经过全卷积网络后特征图的形状是(B, C, H, W)其中 H 被压到 1。这时候可以把它当成长度等于 W 的序列每个位置有一列特征送入最后的全连接层。这一步如果用普通全连接层参数会很大因为输入特征维度是通道数乘以高度这里高度已经是 1所以全连接层只作用在每一列的特征上输出维度是字符类别数。实际上可以改用 1x1 卷积来实现它等价于对每个时间步做分类。2.2 CTC Loss 与序列对齐CTC Loss 是让这个模型真正能跑起来的关键。为什么不用普通的交叉熵因为车牌字符序列的长度和模型输出的时间步长度不一致。输入图像宽度是 94经过 4 次池化后时间步可能是 24 或者更少而车牌只有 7、8 个字符。模型输出的概率序列长度远大于字符个数普通分类 Loss 没办法直接对齐。CTC 的解决思路是引入一个“空白符”。模型在每个时间步除了输出字符类别外还可以输出空白表示这个位置没有有效字符。训练时CTC 会计算所有可能的“模型输出序列到目标字符序列”的对齐路径的概率之和。比如目标序列是“京A12345”模型输出 24 个时间步但每个字符可能被重复预测多次CTC 能通过去重和去空白操作把重复、空白合并成最终预测序列。在训练时PyTorch 内置的torch.nn.CTCLoss可以直接使用但要注意它的参数顺序。log_probs的形状必须是(T, B, C)其中 T 是时间步数B 是批次大小C 是字符类别数。targets是展平后的字符索引列表input_lengths是每个样本的模型输出长度target_lengths是每个样本的真实字符长度。我第一次用这个接口时把三个参数的顺序搞错了结果 Loss 数值一直异常后面会专门讲排查过程。解码策略也有讲究。推理时不需要计算所有路径的概率通常会使用贪心解码在每个时间步取概率最大的类别如果预测是空白符就跳过然后把重复的字符合并。对于车牌这种短序列贪心解码已经足够。如果想要更高精度可以在 CTC 基础上做 beam search但我实测在小字符集上收益不大反而增加代码复杂度。2.3 标签编码与解码字符集合和索引映射字符集合的定义是贯穿整个项目的基础工程。我设置了一个全局字符表包含各省份简称、字母和数字。省份简称里要注意“京”“津”“冀”“晋”“蒙”“辽”“吉”“黑”“沪”“苏”“浙”“皖”“闽”“赣”“鲁”“豫”“鄂”“湘”“粤”“桂”“琼”“渝”“川”“贵”“云”“藏”“陕”“甘”“青”“宁”“新”这些实际用的时候可能还要加上“使”“领”“学”“警”“港”“澳”但为了简化我只保留常见省份简称加数字字母。Python 代码里用列表定义字符集再建立索引映射CHARS [ 京, 津, 冀, 晋, 蒙, 辽, 吉, 黑, 沪, 苏, 浙, 皖, 闽, 赣, 鲁, 豫, 鄂, 湘, 粤, 桂, 琼, 渝, 川, 贵, 云, 藏, 陕, 甘, 青, 宁, 新, A, B, C, D, E, F, G, H, J, K, L, M, N, P, Q, R, S, T, U, V, W, X, Y, Z, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ] CHAR2IDX {c: i for i, c in enumerate(CHARS)} IDX2CHAR {i: c for i, c in enumerate(CHARS)}这里我刻意去掉了字母 I 和 O。原因很简单真实车牌会避免这两个字母与数字 1、0 混淆训练时如果不加约束模型在模糊图片上很容易犯这种错误。字符类别数加上 CTC 的空白符就是 68CTCLoss 要求类别索引的范围是0到C-1空白符索引在 PyTorch 中默认是C所以类别数传 68额外那个留给空白符。训练标签的编码没有直接用 one-hot而是用索引序列。比如“京A12345”会被编码成[0, 33, 50, 51, 52, 53, 54]。这个列表长度就是target_lengths。注意编码时要保证字符确实在字符表里如果车牌里有生僻字要么扩充字符表要么在数据清洗阶段过滤掉。3. 训练流程与调参实录3.1 训练管线搭建训练管线的核心逻辑并不复杂从数据加载器取一个 batch 的图像和标签前向传播得到时间步输出计算 CTC Loss反向传播更新参数。但实战中的细节比理论多很多。数据加载器里做的最重要的一件事是统一图像尺寸。LPRNet 对输入高度敏感我固定高度为 24 像素宽度在训练时随机缩放到 70 到 120 像素之间。宽高比变化能模拟不同距离、不同拍摄角度下的车辆牌照有助于增强鲁棒性。但要注意模型内部如果用了全局池化或者固定尺寸的全连接层输入宽度就不能随便变而 LPRNet 用的是 1x1 卷积加全连接作用于最后一维宽度变化只影响时间步数量不影响参数数量所以可以支持不定宽输入。优化器我用的是 Adam初始学习率 0.001配合余弦退火调度器。相比固定学习率余弦退火让训练前期快速收敛后期逐步稳定。Batch size 我设成 64在单张消费级显卡上完全没有压力。CTC Loss 对 batch 内的样本长度差异很宽容但越大的 batch 越能稳定梯度。训练主循环里只做了几件事加载数据、清零梯度、前向传播、计算 loss、反向传播、每隔一定迭代打印一次 loss 和当前学习率。这里有一个容易被忽略的问题CTCLoss在计算时要求log_probs是对数概率所以模型最后一层要接log_softmax不能直接输出 softmax 后的结果。我一开始不小心在模型里加了 softmax导致 loss 完全无法下降后来检查文档才发现问题。3.2 关键超参数与 Loss 曲线解读训练过程中最值得记录的是 Loss 曲线的形态。规范的训练曲线应该是前 20 个 epoch 内从几十快速降到个位数然后在后续 epoch 缓慢震荡下降。如果 Loss 一开始就非常小比如小于 1那大概率是模型没有收敛或者标签编码有错误如果 Loss 长时间不降则要检查数据预处理是不是有 bug。我最终用的超参数组合是Adam(lr0.001)、batch_size64、max_epochs80。前 40 个 epoch 用合成数据训练把 Loss 从初始值压到 10 以下后 40 个 epoch 混入 20% 真实数据让模型适应实际拍摄环境的噪声和字体分布。这种“先合成后混合”的做法非常有效因为合成数据量可以无限生成模型能先学会基本字符结构再用真实数据精调。Loss 数值本身只是一个参考真正要看的是验证集上的字符准确率。我每个 epoch 结束后都会跑一次验证集计算逐字符准确率和整牌准确率。逐字符准确率是把预测序列和目标序列做逐位置比较但序列长度可能不同所以我用 Levenshtein 距离来计算编辑距离准确率。整牌准确率要求预测序列与目标序列完全一致这个指标更严格。一个让我印象深刻的调参经验是当 Loss 降到 5 以下后继续训练合成数据对验证集准确率帮助不大反而会轻微过拟合。这说明合成数据的多样性已经耗尽需要加入真实数据或者更强数据增强来打破瓶颈。后期我把随机擦除、颜色抖动、高斯模糊的强度都加大了一档验证集整牌准确率提升了约 2 个百分点。3.3 数据增强与过拟合治理车牌识别的数据集增强和其他 OCR 任务类似但是有它的侧重。增强手段优先级最高的是透视变换和仿射变换因为真实拍摄的牌照几乎不可能完全正对镜头。实现时可以用 PyTorch 的torchvision.transforms中的随机仿射或者直接在 OpenCV 里构造变换矩阵。我倾向于把变换放在读取图像后、进入 DataLoader 之前因为这样可以充分利用 CPU 并行。还有一种非常有效的增强是随机裁剪和缩放。车牌边缘经常被保险杠遮挡让模型学会在字符不完整的情况下也能识别就需要在训练时随机裁掉车牌区域的一部分。但裁剪幅度不能太大否则连人眼都看不出来的损坏样本会误导模型。过拟合治理方面除了数据增强我还用了早停。模型在验证集整牌准确率连续 8 个 epoch 不提升时就保存当前最优权重并停止训练。对于车牌识别这样一个样本量可以扩充的任务dropout 反而用得不多因为数据增强已经提供了足够的正则化。具体增强参数我分享一套个人常用的组合随机旋转角度-5 度到 5 度随机水平缩放0.8 到 1.2随机亮度调整0.6 到 1.4随机高斯模糊核0 到 1.5随机透视变换强度0.05随机擦除区域最多覆盖图像面积 10%这套组合在合成数据和真实数据上都表现稳定。需要注意的是随机擦除要避免把整个字符区域全部擦掉不然训练信号会变成毫无意义的猜测。我通过限制擦除区域的最大宽高比保证每个字符至少有一部分可见。4. 推理部署与常见问题排查4.1 从模型权重到端到端推理训练完成后模型的.pth权重不能直接用于生产环境因为里面还保存了优化器状态等额外信息。我习惯在保存时只保留model.state_dict()然后写一个独立的推理脚本加载权重。推理脚本包含图像预处理、模型前向计算、CTC 解码三部分。图像预处理要和训练时保持一致。先读入 BGR 图像转成 RGB缩放到高度 24 像素宽度按比例缩放但保持至少 32 像素转成 Tensor归一化到[0, 1]。这里有一点要特别注意训练时如果用了随机宽度推理时最好把宽度固定成 94 像素这样能避免宽度变化带来的时间步数量波动。固定宽度后即使原始图像宽高比不同模型仍然能识别因为拉伸造成的形变对短文本影响很小。模型前向计算得到输出张量形状是(94, batch_size, num_classes)。把 batch_size 维度压缩后对每个时间步取最大概率索引得到预测序列。接着用 CTC 解码函数把预测索引序列转换为字符序列。核心解码逻辑是遍历所有时间步索引如果是空白符则跳过如果当前索引和上一个非空白索引相同则跳过其他情况添加到结果里。我写的简单解码如下def ctc_decode(sequence): result [] prev None for idx in sequence: if idx ! blank_idx: if idx ! prev: result.append(IDX2CHAR[idx]) prev idx else: prev None return .join(result)这段代码把重复字符合并同时通过prev记录上一个有效字符避免漏掉连续相同字符的边缘情况。比如车牌里有两个连续的“1”预测序列会出现“1, 1”中间如果没有空白符隔开解码时会合并成一个“1”这在实际场景中确实可能发生。要解决这个问题需要引入“blank”分隔的机制但简单方案里我选择接受这种误差因为现实车牌里连续重复字符的概率不高。4.2 字符误判与定位错误排查推理阶段最常见的错误不是模型完全瞎猜而是个别字符识别错误。我梳理了几类典型情况第一类“8”和“B”混淆。在模糊图像上数字 8 和字母 B 的形状非常接近。解决方法是在数据增强中加入更多模糊和降采样操作让模型见过足够多低质量样本也可以在字符集层面明确业务规则比如在蓝牌第二位到第五位之间只允许数字和字母但第一位必须是省份简称。这类规则可以在后处理阶段加入约束比如根据字符位置过滤非法类别。第二类中文省份简称出错。省份简称字形复杂相似字多比如“沪”和“湘”在低分辨率下容易混淆。中文识别对分辨率的要求比字母数字高很多所以我的输入高度从 24 像素提到 32 像素后中文准确率明显上升。如果模型输入固定为 24 像素可以考虑对车牌上半部分做额外放大预处理或者使用更高分辨率的特征提取层。第三类整体输出比真实长度多一个字符。这种问题通常出在解码阶段字符之间缺少空白符导致合并失败。理想情况下 CTC 模型在字符边界处会输出空白符但训练不充分时空白概率会偏低。可以调整解码阈值只有当某个时间步的最大概率超过一定值时才输出字符否则视为空白。但这种方法需要单独调阈值容易影响召回率。定位错误是另一个层面的问题。识别模型本身输出的是字符内容但输入图像如果来自检测模型车牌位置可能偏差较大。我遇到过一种情况检测框把车牌的左边一部分截掉了导致模型输出缺少省份简称。这种问题只能通过改进检测模型或者加一个“车牌完整度判断”模块来解决。为了排查我写了一个可视化工具把输入图像和推理结果并排显示快速定位到底是检测框问题还是识别模型问题。4.3 CPU 上的速度优化与模型量化如果只是训练着玩推理速度无所谓但真要放在停车场闸机这种设备上CPU 推理速度就很重要了。我做了一个简单的 benchmark模型在普通 CPU 上单张推理大约 22ms已经能满足实时要求。但工程上还有几个优化手段可以用。第一个是关闭梯度计算。推理时用torch.no_grad()包裹前向计算这能让内存占用降低不少速度也会略快。这不是什么高级技巧但很多人刚开始会忽略。第二个是合并 BatchNorm 到卷积层。LPRNet 里大量使用 BatchNorm推理时它的均值和方差已经固定可以提前把 BN 层的缩放、偏移折算到卷积权重里减少推理计算量。这个操作能带来百分之十左右的提速。第三个是更加彻底的 ONNX 导出和量化。把 PyTorch 模型导出为 ONNX 格式后可以使用 ONNX Runtime 进行推理同时打开 CPU 的线程优化。进一步地可以做 INT8 量化。我试过把模型量化为 INT8推理时间从 22ms 降到 9ms模型体积也缩小了接近一半。但量化对精度有一定影响车牌这种任务因为字符数量少、类别间隔大精度损失在可以接受的范围内。量化时要注意数据校准。INT8 量化需要准备一批有代表性的校准数据如果校准集分布和真实场景差异太大量化后的模型可能在某类字符上崩掉。最好的做法是从训练数据里随机抽 200 张覆盖不同光照、角度和车牌颜色用来统计激活值分布。提示导出 ONNX 和量化前务必重新跑一次模型推理确认输入输出尺寸完全固定。如果模型支持可变宽度ONNX 导出时也要用固定尺寸导出动态轴会增加部署端复杂度。5. 测试指标与更进一步的扩展方向5.1 测试集构建与评估指标训练和验证之外一个独立的测试集非常关键。我按照 7:2:1 的比例把真实数据切分成训练集、验证集、测试集。测试集不参与任何调参过程只在最终评估时使用。这样的好处是能真实反映模型的泛化能力不至于因为验证集过拟合而高估效果。评估指标我之前提过两个逐字符准确率和整牌准确率。前者计算所有字符里预测正确的比例后者要求整张车牌内容完全一致。实际项目汇报时客户更关心整牌准确率因为它直接关乎车道通过率。如果整牌准确率在 90% 以下意味着每 10 辆车至少有 1 辆需要人工干预这会严重影响用户体验。我的测试集结果显示模型整牌准确率为 87.9%逐字符准确率为 95.7%。拆开来分析错误有接近一半的错误出在中文省份简称上剩下的一半分布在相似字母数字之间。这说明后续改进方向非常明确输入分辨率提升、字符集规则约束、针对难点字符增加 Hard Mining。5.2 从识别到检测完整端到端系统这个项目做到这里严格来说还不是一个完整的车牌识别系统因为它缺少定位模块。如果想要落地下一步就是集成目标检测模型。常见选择有 YOLO 系列或更轻量的 SSD。检测模块输出车牌框识别模块读取框内图像并输出字符。两个模块串起来就是“先框后读”的标准流程。做检测部分时有一些业务细节要考虑。比如车牌尺寸在整图中的占比很小属于典型的小目标检测需要在高分辨率特征图上做预测。另外不同颜色车牌的检测难度不同蓝牌对比度高容易检测绿牌和白色背景容易混淆模型训练时要尽量均衡不同颜色的样本。另一个扩展方向是视频流识别。单张图片识别之外在视频场景里可以利用多帧信息做时序投票。比如连续 5 帧的识别结果取最常见的一帧作为最终输出能显著降低抖动场景下的误识别率。这个思路实现很简单但对最终用户体验的提升非常明显。5.3 个人踩坑记录与经验沉淀最后分享几个我实际踩过、值得记住的坑。第一个坑是字符集顺序改动后没有重新训练。中途我调整了字符表的排列顺序但忘记清空旧的模型权重导致加载权重后输出类别对应关系全部错乱。后来我养成了一个习惯把所有和模型训练相关的配置包括字符集、图像尺寸、网络参数全部写在一个配置文件里任何改动都会触发重新训练。第二个坑是数据加载线程数过高。当时为了加速数据读取把 DataLoader 的num_workers设成 12结果在 Windows 上频繁出现内存错误和子进程崩溃。后来发现车牌数据集本身不大4 个 worker 就够了盲目调多反而引入不稳定因素。第三个坑是 CTCLoss 的blank参数问题。PyTorch 的CTCLoss(blank0)和默认的blank68差别很大。如果字符类别数是 68而空白符索引也用 68那么blank必须显式设置为 68否则 Loss 计算会完全错误。这个参数在文档里不起眼但错了就是毁灭性 bug。第四个坑是关于input_lengths的单位。CTCLoss 里input_lengths是指模型的输出时间步数不是输入图像的宽度。因为经过几次池化后时间步数和图像宽度不是简单位置对应关系。我在最早训练时报数据维度不匹配检查了好半天才发现是这里写错了。按我个人经历来看PyTorch 做车牌识别项目想入门最大的障碍不是模型结构而是数据管线、标签编码和 CTC Loss 的配合。只要这几块打通后续训练、调参、部署都能比较顺利。这段学习过程也让我对“序列识别”这个问题的理解深了一层。车牌只是一个载体换成发票代码、快递单号、验证码核心思路基本是一致的。