详解YOLOv3+STN+LPRNet:构建端到端车牌识别系统

详解YOLOv3+STN+LPRNet:构建端到端车牌识别系统 简介面向计算机视觉与智能交通方向学习者的车牌检测识别完整项目使用YOLOv3完成车牌定位空间变换网络STN负责校正倾斜与透视形变再由LPRNet输出字符与数字识别结果形成“定位—校正—识别”一体化方案。资源主要面向高校计算机、人工智能等相关专业学生及算法从业者可作为毕业设计、课程项目或工程预研的参考基座。压缩包共77个文件约17.27MB内含Python源脚本、编译生成的pyc文件、训练好的pth模型权重、YOLO配置文件cfg、车牌类别文件names以及大量jpg示例图片和Markdown说明文档目录按STN、YOLO、LPRNet模块拆分训练、测试、转换等脚本均有覆盖便于直接运行或按需修改。已有101人学习浏览资源中附带README等说明资料能够帮助快速梳理训练流程与推理链路适合具备一定深度学习基础、希望基于现有模型做改进或迁移应用的读者。1. 车牌识别不止“识别”二字YOLOv3-STN-LPRNet 这个组合在解决什么停车场进出口的相机装得再正也挡不住车牌在画面里又偏又斜夜间补光灯一亮蓝底白字还可能过曝成一团光斑。真正拿出来用的车牌识别LPR系统难点从来不只是“认字”而是先要把千奇百怪的车牌从画面里稳定地找出来、再扶正、再去逐字符识别。这个标题给出的组合很明确YOLOv3 负责目标检测找到车牌并给出外接框空间变换网络 STN 负责对检测到的区域做透视或仿射校正把倾斜的车牌“掰正”LPRNet 再对接校正后的定宽图像用 CTC 做不定长的字符序列识别。三段式分工每一段都可以独立替换、单独调试。这篇内容就围绕这套组合讲清楚三个网络各自的定位、参数设计给出能直接改的代码骨架和跑通整条链路的推理命令。2. 为什么是 YOLOv3、STN 与 LPRNet车牌识别的检测、校正与字符识别选型2.1 先把三个模型的工作边界划清车牌识别不是单个模型能包圆的端到端任务。检测阶段要处理的是“车牌的框在哪”识别阶段要处理的是“框里的字符是什么”。如果跳过检测直接对全图做 OCR那么背景里的灯箱、广告牌上的电话号码都会变成误报如果识别网络直接吃倾斜严重的车牌图字符切分和对齐都会变得极不稳定。所以第一步是把任务拆成三块这也是这套源码在结构上最值得借鉴的地方。YOLOv3 承担检测。它把目标检测当作回归问题输出每个候选框的中心坐标、宽高、置信度以及类别概率在这个场景里类别基本只有 license_plate 一类。速度上Darknet-53 骨干网络在单张 416×416 输入下能做到几十毫秒级别的推理适合部署在对实时性有要求的闸机、卡口摄像头场景。STN 承担校正。它并不负责“认出车牌”而是学习一个空间变换参数把输入图中车牌所在的区域映射到正面视角。STN 的关键特性是它可微因此可以插在检测网络和识别网络之间以识别损失为监督信号一起反向传播不需要单独为“倾斜角度”做标注。LPRNet 承担字符序列识别。它输出的是一个变长的字符概率序列用 CTC 损失在训练时对齐“图像特征序列”和“车牌文本”不需要预先切分字符。2.2 YOLOv3 的 anchor 设计在车牌检测中有什么讲究YOLOv3 在三个不同尺度的特征图上做预测每个网格点预设三种 anchor总共 9 个 anchor。anchor 的尺寸直接影响检测召回。车牌目标有明显特点宽高比很大常见民用蓝牌约为 440×140比例在 3 比 1 左右新能源车牌更宽比例接近 4 比 1。如果直接用 COCO 预训练模型自带的那组 anchor比如 (10,13) 这种小尺寸方框车牌会被拆成多个碎片框或者检测框能框住但坐标回归不稳定。常见做法是拿自建数据集跑一次 k-means 聚类得到贴合自己数据的 anchor 值。以下是一组在 CCPD 数据集上比较常见的 anchor 配置可供参考特征图尺度anchor 宽高像素52×52小目标(24, 14)(36, 22)(48, 34)26×26中目标(68, 42)(92, 56)(120, 48)13×13大目标(160, 60)(220, 76)(300, 100)如果你自己采集的车牌在画面中占比很大比如近景抓拍那么 13×13 尺度的 anchor 权重应该更大如果是远距离多车道场景则要注意 52×52 尺度的前几个 anchor。这个参数和检测框的回归难度直接相关别拿默认值直接跑。2.3 STN 用 6 个参数修正透视变形不增加标注成本STN 的数学形式并不复杂。它由一个定位网络localization network输出 6 个参数构成一个 2×3 的仿射变换矩阵然后根据这个矩阵把输入特征图的每个坐标映射到输出特征图的坐标上。流程分三步定位网络计算变换参数网格生成器根据参数生成采样坐标采样器用双线性插值从原图中取出像素值填充到校正后的图上。用在这种车牌识别链路里一个很聪明的设计是不需要单独训练一个“车牌关键点检测”模型来求四个角点。只要在训练时把“校正后的图能提高识别准确率”作为优化目标STN 自己会学会把车牌区域拉平。对轻微倾斜、左右偏转的车牌6 参数的仿射变换足够如果遇到上下视角差很大的俯拍可以考虑用 8 参数的透视变换但在实际落地里大多数相机架设角度固定仿射变换已经能满足需求。2.4 LPRNet 用 CTC 对齐字符序列比逐字符分类好在哪早期车牌识别常用一步是“字符分割”先根据垂直投影把 7 个字符切出来再对每个字符做分类。这套方法在车牌有边框、铆钉、脏污、倾斜时很容易切错而且一旦切割出错后面全错。LPRNet 则把识别当作一个序列预测问题输入固定尺寸的车牌图像输出一个 T 步的字符概率序列再用 CTC 对齐到最终的车牌字符串。好处是不需要标注每个字符的位置只要给一句“京A12345”这样的标签。LPRNet 的网络结构通常是一个轻量 CNN 加上双向循环网络或者直接用全卷积加宽度方向的池化来压缩时间步最后接一个 CTC 头。相比 CRNN 用 LSTM 做序列建模LPRNet 更轻CPU 上也能跑很适合车牌这种字符数量固定但排列位置可能偏移的场景。3. 跑通车牌检测和识别的最小实现从数据准备到 YOLOv3 与 LPRNet 核心代码3.1 车牌数据集的三种来源与标签格式转换这个项目的训练数据有三个常见来源公开数据集 CCPD、合成车牌数据、停车场实地采集。CCPD 覆盖面广包含不同天气、角度和模糊程度但其标注是 JSON 格式的四个角点信息需要转换成 YOLOv3 训练所需的归一化 txt 标签。合成数据可以自由控制车牌字符、背景和透视变形适合作为补充数据。实地自采数据最贴近实际场景但采集成本高需要脱敏处理。YOLOv3 的标签格式是每行一个目标依次为类别索引、归一化中心 x、归一化中心 y、归一化宽 w、归一化高 h。一个典型转换示意如下python tools/xml2yolo.py --input data/annotations --output labels --classes license_plate如果原始标注是四个角点先通过 minAreaRect 求出外接旋转矩形的中心、宽高和旋转角再转成轴对齐框import cv2 import numpy as np def corners_to_yolo(corners, img_w, img_h): # corners: 4 个角点坐标 (x, y)对应车牌的四个顶点 rect cv2.minAreaRect(corners.astype(np.float32)) (cx, cy), (w, h), angle rect # 保证宽大于高避免旋转矩形把宽高记反 if w h: w, h h, w return cx / img_w, cy / img_h, w / img_w, h / img_h这段代码先把任意四边形转成最小外接矩形再归一化到 0~1 区间。需要特别注意的是YOLOv3 的目标框是轴对齐的而车牌本身是有角度的。这意味着检测框边界会包含一部分背景这没关系后续 STN 会处理框内的角度变形。如果强行要求检测框完美贴合车牌反而会让检测网络训练难度上升。3.2 YOLOv3 检测网络的训练代码骨架训练 YOLOv3 的主干可以用 PyTorch 实现。整体上包含 Backbone 提取特征FPN 做多尺度融合三个检测头分别输出不同尺度的预测结果。这里给出最核心的网络构建思路与输出张量解释class YOLOv3(nn.Module): def __init__(self, num_classes1): super().__init__() self.backbone darknet53() self.neck YOLOv3FPN() self.heads nn.ModuleList([ YOLOHead(num_classes, anchorsanchors_13), YOLOHead(num_classes, anchorsanchors_26), YOLOHead(num_classes, anchorsanchors_52), ]) def forward(self, x): feats self.neck(self.backbone(x)) outputs [head(feat) for head, feat in zip(self.heads, feats)] return outputs每个 YOLOHead 的输出形状是(batch, num_anchors * (5 num_classes), grid_h, grid_w)。5 指的是中心 x、中心 y、宽 w、高 h、置信度。训练时需要将真实框分配到对应尺度的网格上计算 Giou Loss 和置信度损失。实际训练时直接使用现成的 YOLOv3 检测框架会比从零写训练循环更省事重点推荐使用 ultralytics YOLOv5/v8 或 mmdetection 来训练检测器再导出权重或中间特征接入后续模块。读这份源码时建议先读懂dataset.py里的标签加载逻辑和model.py里的前向输出再去看训练参数。3.3 用 STN 对车牌区域做仿射校正检测网络输出的是轴对齐检测框裁剪出来的图像中车牌大概率是倾斜的。STN 网络在这个环节做一次空间变换把车牌内容“旋转”到水平方向。下面是一段可用的 PyTorch STN 定义import torch import torch.nn as nn import torch.nn.functional as F class STN(nn.Module): def __init__(self): super().__init__() # 定位网络输入单通道或三通道车牌图输出 6 个仿射参数 self.localization nn.Sequential( nn.Conv2d(3, 16, kernel_size5, padding2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, kernel_size5, padding2), nn.ReLU(), nn.MaxPool2d(2), nn.AdaptiveAvgPool2d((4, 4)), # 将特征压到固定大小 nn.Flatten(), nn.Linear(32 * 4 * 4, 64), nn.ReLU(), nn.Linear(64, 6) ) # 初始化为单位仿射变换不改变输入 self.localization[-1].weight.data.zero_() self.localization[-1].bias.data.copy_(torch.tensor([1, 0, 0, 0, 1, 0], dtypetorch.float)) def forward(self, x): theta self.localization(x).view(-1, 2, 3) # F.affine_grid 生成采样网格grid_sample 执行双线性采样 grid F.affine_grid(theta, x.size(), align_cornersFalse) return F.grid_sample(x, grid, align_cornersFalse)这段代码里值得展开讲三个地方。第一是输出 6 个参数的排列顺序依次对应仿射矩阵第一行的 a11、a12、b1 和第二行的 a21、a22、b2初始化成单位矩阵意味着网络一开始不改变输入这样训练早期识别网络不会因为矫正过度而崩溃。第二是affine_grid与grid_sample的配合前者根据 theta 生成目标图像上每个像素在原图中的采样坐标后者完成实际取值。第三是定位网络的结构很轻不要设计得太复杂因为输入本身是已经被检测网络裁剪过的车牌区域任务相对简单。训练时这个 STN 模块放在 LPRNet 前面梯度会从识别损失回传到这里的卷积层。3.4 LPRNet 字符识别的推理实现LPRNet 的输入建议固定为宽 94、高 24 的三通道图像输出一个时间步数为 24 的字符概率序列。每个时间步对应车牌图像宽度方向上的一个切片。网络结构可以简化为class LPRNet(nn.Module): def __init__(self, class_num): super().__init__() self.backbone nn.Sequential( nn.Conv2d(3, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d((2, 2)), nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d((2, 2)), nn.Conv2d(128, 256, kernel_size3, padding1), nn.BatchNorm2d(256), nn.ReLU(), ) self.fc nn.Conv2d(256, class_num, kernel_size(1, 4)) self.rnn nn.LSTM(input_sizeclass_num, hidden_size128, num_layers2, bidirectionalTrue) def forward(self, x): x self.backbone(x) # [N, 256, 6, 24] x self.fc(x) # [N, class_num, 6, 24] x x.squeeze(2) # [N, class_num, 24] x x.permute(2, 0, 1) # [24, N, class_num] x, _ self.rnn(x) # 双向 RNN增强时序特征 return x这里将宽度方向保留 24 个时间步每个时间步预测一个字符概率分布。CTC 解码时先按时间步取概率最大的字符然后合并连续重复字符再去除空白符。推理阶段不需要计算 CTC 损失直接用前缀束搜索或贪心解码即可。字符表建议按车牌规则组织省份汉字 31 个英文字母 24 个排除 I 和 O数字 10 个再加一个 CTC 空白符。值得注意的一点是LPRNet 对输入图像的宽高比不敏感但训练和推理的输入尺寸要一致。如果原始检测框裁剪后宽高比和 94:24 差距很大需要做等比例缩放再填充灰边而不是直接拉伸。直接拉伸会让字符变胖变瘦识别率掉得很快。4. 端到端车牌识别实测命令行推理、参数调优与精度指标对比4.1 端到端识别的完整流程与最小命令检测、校正、识别三个模块都训练好之后要整合成一条推理流水线。推理时先 YOLOv3 前向得到若干候选框经过 NMS 去重后把每个框从原图上裁剪下来送入 STN 校正再缩放成 94×24 的 LPRNet 标准输入最后解码输出车牌字符串。为方便排查问题可以把中间结果可视化保存。python infer.py \ --source test_images/ \ --det-weights weights/yolov3.pt \ --rec-weights weights/lprnet.pth \ --conf-thres 0.45 \ --nms-thres 0.45 \ --save-dir output/这份推理脚本的流程是读入图片经过检测网络生成候选框对每个候选框执行以下操作裁剪、STN 校正、缩放至 94×24、LPRNet 识别、CTC 解码。参数的含义分别解释conf-thres是检测置信度阈值低于该值的框直接丢弃nms-thres是非极大值抑制的 IoU 阈值用于合并重叠框save-dir保存标注了识别结果的图片便于肉眼检查。4.2 阈值、图片尺寸等关键参数怎么调参数建议范围调整方向conf-thres0.3 0.6漏检多就调低误检多把车标、广告牌当车牌就调高nms-thres0.4 0.6车牌在画面里重叠率高就调低灯箱导致的重复框多可以适当调低检测输入尺寸416 或 608小目标车牌增多时从 416 提到 608显存不够时保持 416LPRNet 输入尺寸94×24训练什么尺寸推理就用什么尺寸不要临时改输出字符置信度0.7 以上关注单字符概率单字符低于阈值时报“无法识别”而不是硬报调整 threshold 时需要同时看检测的框数分布。如果出现一张图检出了四五个框基本都是某一个框置信度接近阈值且多个 anchor 重复预测此时提高nms-thres和conf-thres都有帮助。如果某个车牌因为倾斜严重被检测出来但校正后识别结果为空优先检查 STN 是否真的被训练起来了可以把 STN 的输入输出保存成图片对比。4.3 用字符准确率评估识别效果车牌识别项目的核心指标不是“检测 mAP”而是一整条链路的端到端准确率。对识别部分常用指标是字符错误率 CER计算方式是编辑距离除以真实字符数。但业务上更关心整牌准确率——即 7 位字符全部正确的比例。这个指标对恶劣场景非常敏感如果一个车牌被雨滴挡住一位字符整牌准确率直接归零。import Levenshtein def evaluate_accuracy(preds, labels): cer_sum 0 exact_match 0 for pred, label in zip(preds, labels): cer Levenshtein.distance(pred.strip(), label.strip()) / len(label) cer_sum cer if pred.strip() label.strip(): exact_match 1 return cer_sum / len(preds), exact_match / len(preds)在评估时建议把结果按“正常角度”“倾斜角度”“夜间/模糊”三个子集分开统计否则总体准确率会掩盖 STN 失效的问题。如果普通场景准确率 98%倾斜场景只有 70%问题大概率在 STN 或检测框裁剪尺度不一致而不是 LPRNet。4.4 性能瓶颈在哪里看时序再优化在 GTX 1080 上检测部分单帧大约需要 15msSTN 加 LPRNet 大约需要 5ms整体 20ms 左右可以满足单路视频流 25FPS 的实时处理。如果把检测输入尺寸改成 608检测耗时可能涨到 30ms。在 CPU 上YOLOv3 是明显瓶颈LPRNet 反而表现不差因为网络较浅。排查耗时的方法是给每个模块打点计时import time t0 time.time() det_boxes detect(model, image) t1 time.time() plate_crops [crop(image, box) for box in det_boxes] t2 time.time() rectified [stn(crop) for crop in plate_crops] t3 time.time() texts [lprnet_decode(rec_model, img) for img in rectified] t4 time.time() print(fdetect: {t1-t0:.3f}s, crop: {t2-t1:.3f}s, stn: {t3-t2:.3f}s, rec: {t4-t3:.3f}s)如果 STN 部分耗时占比超过预期可以检查是否在逐张图初始化 CUDA 流或者存在 GPU 同步点。如果检测部分耗时过高优先压缩输入尺度或换用 YOLOv5s 这类轻量版本。5. STN 与 LPRNet 的进阶技巧把整牌准确率再往上推的做法python tools/synthesize_plate.py \ --chars 京A12345 \ --bg-dir textures/ \ --output-dir synth/ \ --num-samples 500 \ --rotate-range -15 15 \ --perspective 0.1数据合成是车牌识别项目里性价比最高的增强手段。车牌识别的大多数线上难点不是字符种类多而是拍摄角度变化大。脚本只需做四件事选一张带纹理的背景图把车牌按规格渲染上去施加随机透视变形再叠加高斯噪声和运动模糊。合成样本和真实样本按 1 比 2 混合训练STN 的定位网络能学到更稳定的变换估计LPRNet 也不会过拟合到 CCPD 的固定字形上。合成样本要避免一个常见错误只做平面旋转不做透视。真实场景中相机位于车牌左上或右上方车牌呈现的是梯形变形而不是单纯的 2D 旋转。给cv2.getPerspectiveTransform传随机偏移的四个角点时偏移量控制在车牌宽高的 5% 到 15% 之间模拟侧方安装的摄像头视角。识别阶段的另一个实用技巧是强约束解码。中国车牌第一位是省份汉字第二位是英文字母后面的位置在下牌中通常是数字和字母的混排但不存在 I 和 O。CTC 贪心解码拿到原始字符序列后可以按位做合法性校验对不合法的输出做纠错或重识别。在业务系统里这个简单的规则过滤能把误报率降低一半以上因为 LPRNet 偶尔会在空白处输出一个“京”字或在边缘处多识别一个字符。规则纠正的实现只需要一个字符白名单判断函数在解码完序列后过滤掉不在白名单中的字符。推理性能的最后一公里可以考虑网络结构裁剪。STN 的定位网络通常只用两三百万参数但 LPRNet 如果用了双向 LSTM在 CPU 部署时会有较大的串行开销。可以把 LSTM 替换成torch.nn.Conv1d加全局池化在精度几乎不变的情况下把单张识别耗时从 8ms 压到 3ms。如果是在 Jetson 或嵌入式设备上部署建议把三个模型分别导出 ONNX检测和识别分别用 TensorRT 的 FP16 精度推理STN 合并进 LPRNet 的预处理脚本中共用同一个 CUDA 上下文整体端到端耗时能控制在一张图 12ms 左右。本文还有配套的精品资源点击获取