简介:一份面向深度学习车牌识别实战的完整工程资源,聚焦LPRNet与MTCNN的协同工作流程,适合计算机视觉初学者、算法工程师及智能交通项目开发者。资源包共870个文件、约543.9MB,其中包含大量ckpt模型权重文件(覆盖LPRNet不同迭代次数)、Python源码、PyTorch模型文件(pth)以及图片与标注文件,并附带UI设计、文档和演示视频,便于从训练到部署全流程参考学习。已有406人浏览学习。内容在检测端使用MTCNN进行车牌定位,识别端采用LPRNet完成字符序列输出,并给出了预处理、裁剪、识别到后处理的完整实现思路。通过这一资源,读者可以快速搭建车牌识别实验环境,获取现有训练权重和推理代码,研究不同训练轮次下的模型表现,同时也能参考工程目录结构与数据处理脚本,为自定义数据集训练和模型优化提供直接基础。
1. 为什么在停车场项目里选MTCNN+LPRNet这条路
先说说我遇到的实际场景。当时接了一个园区停车场的车牌识别需求,要对着出入口相机抓拍画面做实时识别,要求既要能跑在普通服务器上,又要保持较高的准确率。最早团队里有人提议直接用YOLO v2或者YOLO v4做端到端检测,再配一个分类网络去读字符。说实话这个方案本身没什么问题,但在真实项目里会遇到几个很现实的难点。
第一个痛点是标注成本。YOLO系目标检测要画的是“车牌整体位置框”,这还好办,跟着矩形框拉就行。但如果你走的是先检测再分割字符的路线,就必须额外标注字符级的位置框,一个车牌七到八个字符,一辆车一个框变成七八个框,标注工作量直接翻倍。第二个痛点是字符切分在真实场景里非常脆弱,车牌上有螺丝钉、有污渍、有倾斜、有反光,字符粘连和断裂是常态,一旦分割错一位,识别结果基本就废了。
所以我最终选的是MTCNN做车牌区域检测、LPRNet做车牌字符序列识别。这两个模型组合起来,最大的好处是不需要做字符级分割。MTCNN负责把车牌位置找出来,LPRNet用CTC对齐的方式直接输出一串字符,整条pipeline干净利落。当时LPRNet在精度上虽然不一定比得上某些重型模型,但胜在轻量、推理快、部署简单,对于停车场这种追求“够用且稳定”的场景非常合适。
适合参考这条路线的人有两类:一类是正在做车牌识别相关课题的学生,想了解一套能跑通的完整方案;另一类是工程落地人员,想找一个不依赖商业SDK、自己可控的识别模块。我下面写的所有内容,都会围绕这套方案的原理、代码、工程坑和调优经验展开。
2. MTCNN做车牌检测:改改模型就上,不用从零训练
2.1 MTCNN原本是用来干嘛的
MTCNN全称是Multi-task Cascaded Convolutional Networks,最早是2016年提出的人脸检测算法。它由三个级联网络组成:P-Net、R-Net、O-Net。P-Net在图像金字塔上快速生成大量候选人脸框,R-Net做进一步筛选和精修,O-Net输出最终的人脸框以及 landmarks 关键点坐标。
关键点在于,MTCNN的三个网络内部都有多条任务分支,除了框回归分支之外,还带了分类分支。而结构上的这种多任务设计,天然决定了它可以被迁移到其他检测任务上。车牌检测和人脸检测在视觉形态上有不少相似之处——都是一个矩形目标、包含内部纹理、相对独立地出现在画面中。所以我们不需要把模型结构推倒重来,只需要把训练数据换成车牌图片,把输出类别从“人脸/非人脸”改成“车牌/非车牌”,就能得到一个相当能用的车牌检测器。
2.2 数据准备与标注格式转换
MTCNN的训练数据需要特定格式的标注,核心字段包括:目标框左上角和右下角坐标、类别标签、以及关键点坐标(人脸场景下是双眼鼻子嘴角,车牌场景下可以留空或者标注车牌四角)。我当时从停车场相机里抓了大概两万多张图,然后通过人工+半自动方式标了框。
半自动的方式很简单:先用一个已经训练好的YOLO模型对图片做预标注,然后再人工修正那些偏移比较大的框。这样两万张图,两个人花三天时间能标完。如果项目预算少、时间紧,也可以用开源车牌数据集做预训练,然后用自己场景的数据做微调,这样需要的标注量可以降到几千张。
数据标注完成后,需要转成MTCNN训练用的TFRecord或者自定义DataLoader格式。我这里用的是PyTorch版本,所以直接写了一个继承Dataset的类。核心处理包括:随机裁剪、颜色抖动、仿射变换、水平翻转。注意车牌上的字符顺序不能搞反,水平翻转这种增强方式要谨慎——翻转之后“京A12345”会变成“54321A京”,虽然检测框是对的,但后续识别模型会有问题。所以我训练检测器的时候,翻转增强的概率设得很低,只在竖直方向做轻微平移扰动。
2.3 关键训练参数与结构微调
用MTCNN训练车牌检测,和原始论文里的训练策略保持一致就好。损失函数用的是多任务加权和:
L_total = alpha * L_cls + beta * L_box + gamma * L_landmark
其中分类损失用交叉熵,框回归损失用欧氏距离。车牌场景没有关键点标注,我就把gamma设成0,alpha设为1,beta设为0.5,让网络把重心放在分类和框回归上。这里有个细节:原始MTCNN的框回归用的是坐标差值的归一化,即 dx = (gt_x1 - pred_x1) / size,训练时标签也要做同样的归一化处理,否则loss会震荡得很厉害。
我在微调过程中把O-Net最后一层的卷积核数量稍微减少了一点,从原始256减到128,因为车牌检测相比人脸检测,类别内变化更小,不需要那么强的表示能力,减下来之后模型体积缩小,推理速度提升,精度没有明显下降。
2.4 检测结果的后处理细节
MTCNN输出的是一个矩形框加置信度。到了实际使用阶段,检测框往往不是特别紧贴车牌边缘,所以要做一步外扩。对外扩的比例我一般取10%到15%,具体看相机安装角度。如果车牌在画面里比较小,外扩比例要更大一些,给后续识别模型留出足够的上下文,避免字符被切断。
另外要处理一个现实问题:MTCNN会输出大量重叠框。原始代码用NMS做抑制,但NMS阈值对结果影响很大。停车场场景里一辆车通常只有一个车牌,但反光、阴影可能导致多个高响应区域。我实测NMS的IoU阈值设在0.4比较合适,太低会把同一个车牌的多个候选框全部保留,太高又会把贴近车牌的误检框也算进来。还要加一个置信度阈值过滤,这个值设在0.85以上,因为停车场的相机角度相对固定,检测难度低于通用场景,宁可漏检一点也不能让误检冲垮后面识别模块。
3. LPRNet车牌识别:无分割序列识别的核心逻辑
3.1 为什么车牌识别要抛弃字符分割
传统车牌识别算法一般分三步:定位车牌、分割字符、逐个字符分类。字符分割看着简单,实际上在真实环境里是最大的坑。车牌中间有小圆点分隔符,不同省份的汉字宽度不一样,新能源车牌比普通车牌多一位字符,还有双层黄牌这种特殊类型。分割算法稍微调不好,整个识别结果就崩了。
LPRNet走的是另一条路。它检测完车牌之后,把整张车牌图片输入网络,通过CNN提取视觉特征,再经过RNN建模序列关系,最后用CTC loss对齐预测序列和真实车牌文本。整个过程完全没有字符分割,网络自己学习字符之间的边界在哪里。这种做法对字符粘连、遮挡、模糊都有更好的鲁棒性。
3.2 LPRNet的网络结构拆解
LPRNet的骨干网络是一个轻量CNN,由多个卷积块堆叠而成。每个卷积块包含卷积、BatchNorm、ReLU,部分块还加了MaxPooling。输入图片的典型尺寸是 94x24,宽高比接近4:1,正好匹配普通车牌的形状。
CNN部分提取的特征图会送进一个双向LSTM,用于捕捉字符序列的上下文依赖。LSTM输出的特征经过全连接层映射到字符类别空间,最终得到形状为 [T, num_classes] 的序列预测。其中T是时间步数,等同于特征图在宽度方向的序列长度;num_classes是字符集大小加一个CTC空白符号。
我自己复现的时候,CNN部分主要参考了原始LPRNet论文的结构,但把LSTM的隐藏层大小从原来的128调成了64。因为中文车牌字符集不大,常见省份汉字加上字母数字也就是七十多个类别,序列长度本身也不长,隐藏层太大容易过拟合,而且推理速度会受影响。
下面是核心模型结构的简化代码,方便理解整体流程:
import torch import torch.nn as nn class SmallBasicBlock(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1) self.bn = nn.BatchNorm2d(out_channels) self.relu = nn.ReLU(inplace=True) def forward(self, x): return self.relu(self.bn(self.conv(x))) class LPRNet(nn.Module): def __init__(self, num_classes): super().__init__() self.backbone = nn.Sequential( SmallBasicBlock(3, 64), nn.MaxPool2d(kernel_size=3, stride=1, padding=1), SmallBasicBlock(64, 128), nn.MaxPool2d(kernel_size=3, stride=2, padding=1), SmallBasicBlock(128, 256), SmallBasicBlock(256, 256), nn.MaxPool2d(kernel_size=3, stride=2, padding=1), SmallBasicBlock(256, 512), SmallBasicBlock(512, 512), ) self.adaptor = nn.Conv2d(512, 128, kernel_size=1) self.rnn = nn.LSTM(128, 128, num_layers=2, bidirectional=True, batch_first=True) self.linear = nn.Linear(256, num_classes) def forward(self, x): x = self.backbone(x) # [N, C, H, W] x = self.adaptor(x) # 降维 x = x.mean(dim=2) # 高度方向压缩,得到序列特征 x = x.permute(0, 2, 1) # [N, W, C] x, _ = self.rnn(x) x = self.linear(x) # [N, W, num_classes] return x代码里有个值得注意的地方:高度方向不是用全连接层压扁,而是直接做全局平均池化。这么做的好处是让网络对车牌字符在竖直方向上的位置变化不那么敏感,车牌偏上偏下一点都能识别。
3.3 字符集设计、训练细节与损失函数
字符集我定义为:省份汉字(京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云陕甘青宁新藏)、大写字母(排除I和O,防止和数字1、0混淆)、数字0到9、新能源车牌的特殊字符“挂”“学”“警”等。常见场景下全部加起来约70个类别,再加一个CTC空白符。
训练数据方面,我收集了差不多二十万张车牌图片,包含正常角度、倾斜、模糊、夜间、雨天等不同情况。这里面大概十五万张来自停车场相机,五万张来自网络公开数据集。数据增强非常关键,尤其是光照扰动和透视变换。车牌识别最容易翻车的就是强反光和夜间低照度,所以我把随机亮度调整范围设到了正负40%,随机对比度调整也在正负30%以上。透视变换的幅度不能太大,水平方向偏移控制在±15度以内,超过这个角度人眼识别都困难。
训练用的损失函数是PyTorch内置的CTCLoss,要传三个关键参数:预测序列logits、目标序列标签、以及每条样本的序列长度和目标长度。CTC的beam search解码参数在我这里用的是beam_width=10,再结合一个简单的词典过滤,把不可能出现的连续重复字符合并,效果比纯贪心解码好不少。
OCR模型训练还有个容易被忽视的点:学习率策略。CTCLoss的收敛曲线通常会在训练初期下降很快,然后陷入长尾。我在训练LPRNet时先用1e-3的学习率跑前5个epoch做warmup,再切换到余弦退火。总共训练了差不多20个epoch就能达到一个比较稳定的精度,继续增加epoch收益不大,反而可能在结构化噪声上过拟合。
3.4 训练数据与真实场景的差异
前面提到数据增强,但还是要强调一个我踩过的坑:训练数据里的“干净车牌”太多了。很多公开数据集里的车牌都是正对着相机拍的正视图,车牌表面干净、光照均匀。而真实停车场里,车牌经常是脏的、歪的、装在前保险杠弧面上的,还有大量加了边框和底托的车牌。如果只用公开数据集训练,模型在本地测试集上可能看起来有99%的准确率,一上现场就掉到七八成。
我的经验是,从现场相机抓取足够多的真实图片加入训练集,至少要占30%以上。哪怕现场图片标注质量差一些,只要框的位置基本准确,对模型提升的效果也远好于再增加几万张标准图。另一个做法是合成数据,用程序把真实的省份汉字、字母数字随机组合,叠加到真实背景图上,再做随机的变形、加噪、调色。这个方法在小样本场景下特别有用,我后来在项目里就用合成数据把识别准确率又往上拉了两三个百分点。
4. 从单张图片到停车场相机流:工程落地的几个大坑
4.1 相机取流与车辆触发
模型调好之后,真正往停车场项目里部署,才发现难点根本不在模型本身,而在工程链路。停车场出入口一般是海康、大华这类IPC相机,常见两种取流方式:一种是直接用RTSP拉视频流,另一种是通过相机SDK或者ONVIF协议获取抓拍图片。RTSP拉流简单,但容易卡顿和丢帧,适合做调试,不适合做正式计费。SDK取图比较稳定,但每个厂商的SDK风格差异大,接口文档也写得参差不齐。
还有一种更主流的做法是用硬件触发。出入口相机自带地感线圈或者雷达触发,相机拍下车辆照片后,通过HTTP或者MQTT把图片推送到服务端。现场项目里很多是大华和海康对接,如果走的相机私有协议,需要去查厂商SDK里抓拍回调字段的说明。MQTT是另一种常见的对接方式,很多停车平台会把相机抓拍图片作为消息内容推送到MQTT broker,服务端订阅主题拿到图片之后,再跑检测识别。这种方式解耦性最好,前端相机和后端AI服务各管各的,出了问题也方便排查。我们当时就是订阅了以设备编号为标识的主题,图片以base64编码放在JSON消息体里,服务端收到后解码再进识别流程。
4.2 低照度与逆光场景的图像预处理
停车场出入口最容易翻车的两类图像:夜间低照度、白天强逆光。夜间画面整体很暗,车牌区域可能曝光不足,字符模糊;逆光场景则相反,车牌区域过曝,字符消失在一片白色里。
针对这两种情况,图像预处理比换更大的模型更有效。我实测下来一套组合拳很管用:先做灰度化,再做自适应直方图均衡化(CLAHE),最后做轻度锐化。CLAHE的clipLimit参数我一般设为2.0,网格大小设为8x8,这个组合在大多数停车场场景下都能把车牌字符的对比度拉起来。还有一个技巧是相机本身的ISP参数调整,比如开启宽动态模式(WDR),让车牌局部曝光更均匀。宽动态对逆光场景的提升比任何算法后处理都明显。
顺带说一下补光灯。很多停车场装了白光补光灯或红外补光灯来辅助抓拍。红外光下车牌上的反光涂层会让字符变成白色、背景变成黑色,这种“反色”情况模型是不认识的。如果现场装了红外补光灯,一定要把红外模式下的反色图片也加入训练集,或者做反色增强。
4.3 车牌颜色、双层车牌与新能源车牌
模型训练的时候只考虑了蓝底白字、黄底黑字、绿底黑字、白底黑字等常见类型,但实际跑起来还会遇到很多边缘情况。新能源车牌是绿底黑字,字符比普通车牌多一位,LPRNet这种序列识别模型天然能处理变长输入,比固定长度分类的方案要友好很多。双层黄牌车牌的宽高比和普通车牌完全不同,直接resize到94x24会严重变形。我针对双层车牌单独训练了一个模型,或者更简单的方式:检测出车牌框之后,先根据框的宽高比判断是单层还是双层,再决定resize到哪个尺寸。
另外,大货车车牌经常挂得很低,相机角度大,拍出来是严重的透视图。MTCNN检测出来的框是矩形,如果不做矫正,直接把透视变形的车牌送进LPRNet,识别率会明显下降。我后来在检测和识别之间加了一个透视矫正模块,用车牌角点估计一个单应矩阵,把车牌矫正成正面视角。这个步骤对倾斜较大的车牌提升非常明显,准确率能从85%左右拉到95%以上。
4.4 并发与响应时间优化
停车场项目有一个硬性指标:从车辆进入识别区域到道闸抬杆,整个耗时必须在几百毫秒到一秒以内。识别服务如果还要排大队,现场体验会非常差。我当时的做法是把检测和识别拆成两个服务,中间用消息队列衔接。检测服务负责从相机图片里找车牌框,找到之后把裁剪好的车牌图片发到识别服务。两个服务都可以独立水平扩展,识别服务瓶颈明显时就多加几个实例。
推理层面,MTCNN和LPRNet都是轻量模型,在GPU服务器上单张图片的推理时间加起来不超过20毫秒;即使只用CPU,也能跑到100毫秒以内。真正的耗时大头反而在图像解码和网络传输上。如果你用的是Python,尽量用OpenCV的imdecode而不是PIL去解码,解码速度差距在2倍以上。服务端收到MQTT消息时也不要直接就在回调线程里跑推理,先把图片数据丢进队列,用独立的工作线程池去消费,避免回调阻塞导致消息积压。
5. 实测效果与调优经验:让模型在恶劣环境下稳住
5.1 一个真实场景的精度数据
最后说一下我在某个园区停车场项目里的实测数据。这个项目是双车道出入口,一个方向进一个方向出,相机安装高度约1.5米,抓拍距离3到5米。用了大概一个月的抓拍图做测试集,总共约一万张,覆盖白天、夜晚、雨天、逆光、跟车等场景。
测试结果比较理想:车牌检测率(正确检出车牌且不存在框错位)在99%左右,车牌识别准确率(在检测正确的前提下,字符全部识别正确)约96.5%,整条流水线端到端准确率约95.5%。这里的准确率定义为“一个字符都不错”,如果允许一位字符错误而触发二次识别或者人工审核,那可用率可以到98%以上。
其实单纯看96%的准确率,好像没有比商用SDK高,但我更在意的是在可控成本下实现了自主可控。商业SDK是按年授权的,停车场这种场景一年license费用不低,而且断网情况下没法用。自研方案模型文件几十MB,离线也能跑,后续针对特殊场景调优也不受制于人。
5.2 提升准确率的三个有效手段
第一是二次识别。现场车辆不是静止的,抓拍瞬时角度差一点,识别结果就可能出问题。我实现的方案是:车辆触发抓拍后连拍三张,分别识别,然后用投票或置信度加权的方式决定最终结果。这个方法直接把端到端准确率提升了将近2个百分点,而且实现成本极低。
第二是字符级别的置信度分析。LPRNet的CTC解码结果可以拿到每个字符位置的置信度,如果某个字符的置信度低于阈值,比如0.7,就标记为低置信度。在停车场后台逻辑里,遇到低置信度字符时优先走人工审核或者再触发一次识别,而不是直接把错误结果输出到计费系统。
第三是针对特定省份做地域化微调。如果你负责的项目集中在某个省,可以额外收集该省的车牌数据做微调,尤其是省名汉字的识别。车牌省份汉字本身是一个固定集合,但手写风格的汉字印刷体在不同字体下还是有差异的,微调之后省份识别准确率能明显提升。比如“渝”和“湘”在某些字体和光照下容易互相混淆,加数据微调后这类问题大幅减少。
5.3 模型部署的几个坑
部署阶段我遇到过不少问题,挑两个有代表性的说说。
第一个是ONNX导出时的动态形状问题。PyTorch导ONNX默认用固定尺寸输入,但LPRNet输入的宽度在检测框外扩之后可能有小的波动,写死尺寸会导致运行时resize不准。解决方式是把输入尺寸所有维度都设为动态轴,导出命令里指定dynamic_axes。当时这个问题排查了好几个小时,表面上是推理报错,实际上是因为动态维度没开。
第二个是TensorRT加速下的精度抖动。在FP16精度下,LPRNet的CTC输出分布会发生细微变化,导致个别字符识别错误。尤其是汉字类别之间的差异本来就小,量化误差可能会把置信度排名打乱。如果追求稳定,建议TensorRT用FP32;如果需要FP16性能,可以在导出前做QAT量化感知训练,直接PTQ的话效果不太可控。
5.4 一个容易被忽略的现场问题:相机视角一致性
模型在一个停车场调得再好,换一个场地可能效果就打折。原因往往不是模型泛化能力不行,而是相机的安装高度、俯仰角、抓拍距离变了,车牌在画面里的大小、角度、宽高比都和训练数据有差异。
我刚接手第二个停车场项目时就吃了这个亏。第一个项目相机装在1.5米高度,画面里车牌大约占80到100像素宽;第二个项目相机装在2.5米高度,车牌在画面里只有40像素宽,识别率直接掉到90%。后来把训练数据里加入小目标样本,同时调高测试集里小尺寸车牌的权重,才重新拉回95%以上。如果你在多个场地部署,最好在每个场地上线后先跑一周数据收集,用新场地数据做增量微调。
6. 后续还能往哪扩展:从识别到停车平台
LPRNet加MTCNN这套组合本身已经解决了“车牌是什么”的问题,但在停车场项目里,真正要做的其实是“车牌对应哪个车、该怎么计费”。我后来在这个项目基础上做了三个扩展方向。
第一个方向是车辆特征融合。只认车牌有一个天然弱点:套牌车。后来我增加了车辆颜色、车型、车标这些辅助信息,用另外一个轻量分类模型做多任务输出,和车牌信息一并上传到平台。这样即使车牌的某一位字符识别不确定,也能用车辆特征做交叉验证,降低误判概率。
第二个方向是云边协同。直接把AI推理放在摄像头端做不现实,因为大部分相机没有GPU,但可以在边缘小主机上部署模型,只把识别结果上传云端。这样网络抖动不影响本地落杆,云端故障也不影响现场的兜底运行。我这边实际用的方案是NVIDIA Jetson系列边缘盒子,跑LPRNet加MTCNN绰绰有余。
第三个方向是异常事件处理。比如无牌车、车牌遮挡、跟车闯闸,这些情况模型是没法解决的,需要在平台上做业务逻辑的兜底。我当时加了一个事件回调模块,识别失败时抓拍现场图和全景图推送给管理人员,辅助人工远程放行。AI识别不是万能的,但是配合好的业务流程,能覆盖掉绝大多数长尾场景。
再分享一个关于数据流的小技巧:不管你是用MQTT对接相机还是用SDK拉图,都建议保留一个本地图片落盘目录。出问题的时候翻日志不如看图快,保留一份原始抓拍图和一份识别中间结果图,能让你在一个月后排查问题的时候少掉很多头发。我最后说句实在话,这套方案放到今天不算最新最前沿,但它胜在结构简单、每个环节都可解释、可替换。如果你正在基于LPRNet和MTCNN做类似的车牌识别项目,完全可以把我的经历当成一份参考。从模型选型到部署运维,每一步都有比我想象中更多的细节,这些细节才是真正确保项目稳定运行的关键。
本文还有配套的精品资源,点击获取