news 2026/9/12 3:03:16

中文车牌识别实战:从YOLO检测到LPRNet识别与系统部署

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
中文车牌识别实战:从YOLO检测到LPRNet识别与系统部署

简介:面向计算机相关专业毕业设计及深度学习初学者的中文车牌识别与管理系统项目包。基于深度学习实现车牌检测、字符分割与识别,并配有简洁美观的图形管理界面。压缩包共16个文件,包含9个Python脚本(模型训练、核心识别、界面及视频处理)、2个H5模型权重文件、3个GIF效果展示、1个演示视频及1份使用说明,整体大小约26.69MB。模型与代码已调试完成,下载解压后按说明即可运行,适合快速搭建完整毕设Demo或学习CNN/UNet在CV任务中的应用。目前已有274人学习使用,可作为高分毕业设计参考,也可在此基础上扩展夜间识别、新能源车牌等方向。文件按功能拆分清晰,便于对照阅读和二次开发。

1. 中文车牌识别,真正的门槛不在模型在数据

中文车牌识别和通用 OCR 看起来都是"图里找字",上手做一次就知道差别很大。车牌只有 7 个字符,却涵盖 31 个省份汉字、24 个英文字母和 10 个数字,加上蓝绿黄几种底色,以及倾斜、反光和运动模糊,传统模板匹配到了真实场景基本撑不住。深度学习把任务拆成检测和识别两步,是目前车牌识别项目里最主流的技术路线。

这篇从一个典型课题切入——基于深度学习的中文车牌识别与管理系统。别把注意力全押在"完整代码"上,这类项目真正要花时间的是模型选型、训练数据来源,以及识别结果如何落进管理系统。下文按常规工程顺序,从选型讲到部署验证。

2. 检测与识别分开做:中文车牌识别的主干选型理由

2.1 为什么检测选 YOLO 系,而不是把识别做成端到端

车牌识别在结构上有两条路线。第一条是端到端:输入一张全景图,模型直接输出车牌字符串,中间不分检测和识别。论文里这条路线的指标往往很好看,因为训练数据和推理图是同一分布,端到端梯度回传也省掉了中间环节。但实际做管理系统时我一般不碰它——真实场景里车牌在画面中的尺度变化很大,出入口照片里车牌可能只占整个画面的 2% 到 5%,端到端模型要么在整图上漏检小目标,要么放大特征图后引入大量背景干扰,调起来非常被动。

第二条路线是检测加识别两阶段。检测网络先用目标检测模型把车牌区域框出来,再把框内图像裁剪后送给识别网络。两个模型各自独立训练、独立换版本,比如检测从 YOLOv5s 换成 YOLOv8n,识别网络完全不用动;反过来识别网络从 LPRNet 换成 CRNN,检测侧也不用重训。这个解耦特性在毕业设计和中小型系统里非常实用,因为车牌检测的数据集相对好找,识别数据集才是真正的瓶颈。

检测模型的选择,我一般推荐 YOLOv5s 或 YOLOv8n。v5s 生态成熟,部署资料多;v8n 在同样输入尺寸下推理略快,但对 PyTorch 版本有要求。车牌这类小目标检测,输入尺寸不要盲目用 640,条件允许的话用 960 或 1280 训练,对小车牌召回率提升明显,代价是显存占用变大。锚框方面,车牌的长宽比大约是 3:1 到 4:1,默认锚框对这个比例覆盖不好,训练前用 k-means 重算锚框,能少调很多 epoch。

提示:两阶段方案不是对所有场景都最优。如果只识别固定的相机画面、车牌占据画面比例稳定,端到端模型反而更省事。选型先看输入分布,再谈模型结构。

2.2 识别网络:LPRNet 用 CTC,省掉字符分割

车牌区域裁剪出来后,第二步是从一张宽高比 3:1 左右的小图里读出 7 个字符。传统做法是先把字符一个个切出来,再做单字符分类,这要求定位算法对字符间距、边缘粘连非常敏感,稍微有点倾斜或反光就切错。深度学习领域里更省事的做法是序列识别加 CTC,也就是不做显式字符分割,直接对整张车牌图预测按时间步展开的字符概率序列,再用 CTC 解码对齐。

LPRNet 就是这类方案的代表。主干是一个轻量卷积网络,后面不接 RNN,直接输出按时间步展开的字符概率,通过 CTC 损失训练。因为车牌字符是等宽的印刷体,卷积特征本身就带有位置信息,RNN 不是必须的。实际训练中 LPRNet 字符级准确率可以做到 99% 以上,整牌准确率在干净数据上到 95% 左右,真实抓拍数据上会掉到 85% 到 92%,这个区间完全够管理系统用。

CRNN 是另一条常见路线,结构是 CNN 特征提取加双向 LSTM 再加 CTC。它比 LPRNet 多一个序列建模阶段,对模糊字符的容错稍好,代价是模型体积和推理时间增加。车牌识别这种字符长度固定、字符间距均匀的任务,LPRNet 性价比更高;如果识别对象还包括其他变长文字,CRNN 更通用。选型参考:

对比项LPRNetCRNN端到端单模型
字符分割不需要不需要不需要
序列建模无,靠卷积位置信息双向 LSTM视结构而定
模型体积小,几 MB中等
车牌场景准确率略高依赖数据分布
调试难度

2.3 字符集与标注规范:65 分类是怎么来的

中文车牌识别的字符集是个硬约束。车牌第一位是省份简称,全国 31 个省份对应 31 个汉字;第二位是发牌机关代号,用英文字母表示;后面是序号,由字母和数字组成。实际类别集合是 31 个汉字、24 个英文字母(I 和 O 不使用,避免和数字 1、0 混淆)、10 个数字,一共 65 类。

这是 65 类,不是常见的 10 类,标注时要特别注意类别 id 的一致性。检测模型标注用 YOLO 格式,类别只有 1 类(plate),标注框是车牌的四个顶点坐标转成中心点加宽高。识别模型标注是一串字符串,比如"京A12345",训练时映射成类别 id 序列,再用 CTC 的机制处理序列对齐。另外新能源绿牌是 8 个字符,比蓝牌多一位,如果系统要同时处理两类车牌,识别网络输出不要写死长度,靠 CTC 的合并机制兼容 7 位和 8 位。

拿到一个所谓"完整代码"的 zip 包,先别看训练脚本,先看数据集的目录结构。多数项目会提供原始图片和标注文件,标注格式如果不统一,后面所有脚本都要返工。我一般要求数据组织成下面这种格式,一眼能检查标注对不对,也方便做数据清洗:

dataset/ ├── 京A12345/ │ ├── 001.jpg │ ├── 002.jpg │ └── ... ├── 沪B88888/ │ └── ... └── ...

目录名就是标签字符串,目录下是裁剪好的车牌图。这个格式的好处是标注检查不需要打开任何标注工具,文件管理器里扫一眼就知道字符有没有标错、图片有没有混入非车牌图。

3. 训练可用的识别模型:从数据增强到收敛判断

3.1 最小可跑的 LPRNet 训练脚本

先给一个能跑的 PyTorch 训练框架。数据按上一章说的方式组织,dataset目录下每个子目录名就是车牌字符串,子目录里是该车牌对应的裁剪图。这是车牌识别数据集最常见的组织方式,也是对初学者最友好的一种。

import os import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from PIL import Image import torchvision.transforms as T CHARS = "京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼" \ "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" char_to_idx = {c: i for i, c in enumerate(CHARS)} class PlateDataset(Dataset): def __init__(self, root, height=48, width=168): self.paths, self.labels = [], [] for label in os.listdir(root): for name in os.listdir(os.path.join(root, label)): self.paths.append(os.path.join(root, label, name)) self.labels.append(label) self.tf = T.Compose([ T.Resize((height, width)), T.ToTensor(), T.Normalize([0.5], [0.5]) ]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img = Image.open(self.paths[idx]).convert("RGB") label = [char_to_idx[c] for c in self.labels[idx]] return self.tf(img), torch.tensor(label, dtype=torch.long) class LPRNet(nn.Module): def __init__(self, num_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(128, 256, 3, padding=1), nn.BatchNorm2d(256), nn.ReLU(), nn.MaxPool2d(2), ) self.fc = nn.Conv2d(256, num_classes, 1) def forward(self, x): x = self.features(x) # (B, C, H/8, W/8) x = self.fc(x) # (B, C, H/8, W/8) x = x.mean(dim=2, keepdim=True) # 合并高度方向 x = x.squeeze(2).permute(2, 0, 1) # (T, B, C) return x def ctc_decode(preds): # preds: (T, B, C),每个时间步取最大概率类别,再合并重复 preds = preds.argmax(dim=2) # (T, B) batch_size = preds.size(1) results = [] for b in range(batch_size): seq, prev, out = preds[:, b].tolist(), -1, [] for t in seq: if t != prev and t != len(CHARS): # len(CHARS) 是 blank out.append(t) prev = t results.append("".join(CHARS[i] for i in out)) return results

这段代码有两个关键点。第一,输入统一缩放到 168 乘 48,这个 3.5:1 的比例接近真实车牌,是识别效果和推理速度的折中,不要随便改成正方形。第二,ctc_decode里把类别总数len(CHARS)单独留出来作为 blank 的索引,PyTorch 的 CTCLoss 默认blank=0,如果你的空白位放在索引 0,字符映射就要整体后移一位,否则解码全错。这个细节是初学者最容易踩的坑,我习惯把 blank 放最后,显式传给损失函数。

训练循环本身不长,但 CTC 的几个参数必须对齐:

def collate_fn(batch): imgs, labels = zip(*batch) return torch.stack(imgs), list(labels) model = LPRNet(num_classes=len(CHARS) + 1) # 加 1 给 blank optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CTCLoss(blank=len(CHARS), zero_infinity=True) dataloader = DataLoader(PlateDataset("dataset"), batch_size=64, shuffle=True, collate_fn=collate_fn) for epoch in range(60): model.train() total_loss = 0 for imgs, labels in dataloader: batch_size = imgs.size(0) input_lengths = torch.full((batch_size,), imgs.size(3) // 8, dtype=torch.long) target_lengths = torch.tensor([len(l) for l in labels], dtype=torch.long) targets = torch.cat(labels) preds = model(imgs) # (T, B, C) loss = criterion(preds, targets, input_lengths, target_lengths) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch}: loss {total_loss / len(dataloader):.4f}")

input_lengths是特征序列长度,168 宽经过三次池化后是 21 个时间步,所以用imgs.size(3) // 8计算;target_lengths是每个样本的真实字符数;targets要把 batch 内的标签拼成一个一维张量,这就是 CTC 需要的扁平化目标。collate_fn是必须的,因为 batch 内每个车牌字符串长度一样但组合不同,默认的 collate 会把变长张量直接报错。优化器用 Adam、初始学习率 1e-3、批次 64,显存不够就减到 32。训练 50 到 80 个 epoch 后,字符准确率一般能达到 98% 以上。

3.2 数据增强参数:别把车牌增强没了

车牌识别数据增强比通用分类任务更容易做过头。车牌纹理就是字符和底色,增强过于激进,字符边缘被模糊掉,模型学到的是色块而不是字符。我常用的增强分三类:几何、颜色和噪声。几何增强里,随机旋转不要超过 15 度,水平透视变换偏移量控制在宽度 10% 以内,超过这个范围车牌形变就不像真实相机拍出来的。颜色增强里,HSV 的 H 通道抖动控制在正负 5 度以内,S 和 V 可以放宽到正负 20%,因为真实场景色温和亮度变化很大。噪声增强里,高斯噪声标准差建议 0.01 到 0.03,再大字符边缘会被噪声淹没。

import imgaug.augmenters as iaa aug = iaa.Sequential([ iaa.Affine(rotate=(-12, 12), shear=(-8, 8)), iaa.AdditiveGaussianNoise(scale=(0, 0.02 * 255)), iaa.MultiplyHueAndSaturation((0.95, 1.05), (0.8, 1.2)), iaa.LinearContrast((0.8, 1.2)), iaa.GaussianBlur(sigma=(0.0, 0.8)) ])

imgaug 的GaussianBlursigma 上限 0.8 是经验值,超过这个值字符笔画被磨平,模型会对模糊图像过拟合,测试集反而掉点。shear控制在正负 8 度模拟车辆转弯时的车牌形变,对蓝牌够用,对大车双行车牌要再调小。如果数据集中没有雨雾样本,可以叠加iaa.Rain或对比度降低,但这类增强只建议在最后 20% 的 epoch 开启,前期全开模型收敛会很慢。参数范围参考下面的表:

增强类型参数范围说明
旋转±12 度超过后字符形变失真
水平剪切±8 度模拟转弯形变
高斯噪声σ=0~0.02过大淹没字符边缘
高斯模糊σ=0~0.8经验上限
色相偏移±5%车牌底色有约束

另一个容易被忽略的点是不要对整张训练图做随机裁剪。识别网络的输入应该是完整车牌区域,裁剪会把边缘字符切掉,CTC 对字符缺失的容错很差。真实场景的车牌遮挡应该在检测模块用遮挡样本训练,而不是在识别模块用裁剪模拟。

3.3 收敛标准:字符准确率和整牌准确率要分开算

训练时只看 loss 容易误判。CTC loss 降到 0.1 左右,视觉上可能还有 5% 的字符是错的。建议每个 epoch 后在验证集上同时算两个指标:字符准确率(识别对的字符数除以总字符数)和整牌准确率(7 个字符全部正确的车牌占比)。字符准确率反映模型鲁棒性,整牌准确率反映业务可用性,管理系统做查询和统计时用的是后者。

实际项目中这两个指标差距通常在 8 到 12 个百分点。字符准确率 98% 时,单牌 7 个字符全部正确的概率约是 0.98 的 7 次方,约 87%,也就是每 100 辆车会识别错 13 辆左右。如果管理系统要求失配率低于 5%,字符准确率要到 99.3% 以上,这时候单纯堆数据不太够,要考虑置信度过滤或多次识别投票,这个在识别服务部分展开。

4. 识别服务与管理系统的对接:数据流和接口设计

4.1 用 FastAPI 把识别能力封装成独立服务

训练好的模型不该和业务代码耦合在一起。常见做法是把识别封装成独立 HTTP 服务,管理系统通过接口调用,模型更新不需要重新部署业务,多个入口也能共用同一个识别服务。用 FastAPI 写一个最小实现:

from fastapi import FastAPI, UploadFile import torch from PIL import Image app = FastAPI() def load_models(): detect = torch.hub.load("ultralytics/yolov5", "custom", path="weights/plate_det.pt") recog = torch.load("weights/lprnet.pt", map_location="cpu") return detect, recog detect_model, recog_model = load_models() @app.post("/recognize") async def recognize(file: UploadFile): img = Image.open(file.file).convert("RGB") dets = detect_model(img, size=960) results = [] for box in dets.xyxy[0].cpu().numpy(): x1, y1, x2, y2 = map(int, box[:4]) crop = img.crop((x1, y1, x2, y2)) plate, conf = recognize_plate(crop, recog_model) # 内部做 LPRNet 推理 results.append({"plate": plate, "conf": conf, "bbox": [x1, y1, x2, y2]}) return {"code": 0, "data": results}

接口设计有两点要注意。第一,size=960让检测模型在大图上推理,对小目标召回率很重要,但单张推理时间会从 20 毫秒涨到 60 毫秒左右,车牌识别系统没有实时视频流要求的话完全可以接受。第二,接口把置信度和检测框一并返回,业务端统一做置信度过滤,而不是每个业务系统各写一套判断逻辑。部署时用 gunicorn 加 uvicorn worker 启动,4 个 worker 可以支撑每秒 20 次左右的识别请求。

4.2 管理系统的表结构与查询设计

管理系统常见需求是车辆进出记录、车牌查询、统计报表。表结构上最少要三张表:车辆档案表、通行记录表和识别日志表。车辆档案表保存白名单和车主信息,通行记录表保存每次识别事件,识别日志表保存原始图片路径和模型输出,用于事后排查和算法迭代。

CREATE TABLE vehicle_registry ( id INT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(10) NOT NULL UNIQUE, owner_name VARCHAR(50), phone VARCHAR(20), vehicle_type TINYINT DEFAULT 0 COMMENT '0蓝牌 1绿牌 2黄牌', status TINYINT DEFAULT 1 COMMENT '1白名单 0黑名单' ); CREATE TABLE passage_record ( id INT PRIMARY KEY AUTO_INCREMENT, plate_no VARCHAR(10), direction TINYINT COMMENT '0入场 1出场', confidence DECIMAL(5, 4), image_path VARCHAR(255), happen_time DATETIME NOT NULL, INDEX idx_time (happen_time), INDEX idx_plate (plate_no) ); CREATE TABLE recog_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, raw_plate VARCHAR(10), corrected_plate VARCHAR(10), raw_conf DECIMAL(5, 4), final_conf DECIMAL(5, 4), log_time DATETIME );

三个表的关键都在索引上。passage_recordhappen_time建索引,因为日常查询基本都带时间范围,比如统计一天的进出车辆数;plate_no建索引是为了按车牌查历史记录。recog_log表是个人习惯,也是答辩时最值得展示的一张表,被问到"识别错了怎么办",可以直接指出误识别样本的修正链路。管理端前端框架用 Vue3 或 React 都行,关键是把查询条件和结果表格分开,这里不展开。

4.3 置信度三档策略和二次识别

识别服务返回的置信度在真实场景里分布很宽。干净蓝牌照片通常 0.95 以上,反光或污损的车牌掉到 0.7 到 0.9。管理系统一般分三档处理:大于 0.95 直接采用;0.85 到 0.95 进人工复核队列;低于 0.85 判定识别失败,走重拍或人工录入。阈值不要拍脑袋定,建议用 500 张现场照片做阈值扫描,画准确率随阈值变化的曲线,取准确率开始掉头的点。

二次识别是提升整牌准确率的低成本手段。第一次结果置信度不高时,把车牌图分别做一次轻微旋转和一次对比度增强,再送识别模型,三次结果投票,多数一致的就是最终输出。这个操作只多花 2 到 3 次推理时间,误识别率能降 20% 到 40%。更重一点的做法是保留 CTC 解码时每个时间步的 top-2 概率,对易混淆的字符对(0/O、1/I、8/B)做规则替换。

5. 部署验证和演示避坑:让系统在答辩现场不翻车

5.1 导出 ONNX 的三个注意点

部署到 CPU 时,把模型导出为 ONNX 再推理,速度提升明显。PyTorch 直接推理一张车牌图在 CPU 上约 15 到 30 毫秒,ONNX Runtime 可以压到 8 到 15 毫秒。导出时注意三点:固定输入尺寸,不要用动态尺寸;torch.onnx.export指定opset_version=11以上,否则部分算子不兼容;导出后的输入输出名要和加载代码保持一致。

验证导出是否正确的标准做法是拿同一张测试图分别跑 PyTorch 和 ONNX Runtime,对比 argmax 序列。浮点精度差异允许概率值略有不同,但 argmax 必须一致,不一致优先检查图像归一化参数是否在导出链路里丢失。

5.2 演示现场最容易翻车的三类输入

第一类是倾斜超过 30 度的车牌。检测模型通常能框住,但识别模型训练增强里的旋转范围只有正负 12 度,30 度直接超出分布。演示前加一道校正:根据检测框四点坐标做透视变换,把车牌扶正再送识别。第二类是暗光和逆光图,对比度极低,识别模型容易把 6 看成 8。演示测试图尽量选真实抓拍风格,别用高清图库图,否则现场换成手机实拍,效果落差很大。第三类是带边框标注的车牌素材,YOLO 可能把边框也当目标,过滤掉贴近图像边缘的检测框。

5.3 留存测试集和启动自检

系统验收不要只看训练时的验证集。单独留存 500 到 1000 张未参与训练的照片,按正常光照、夜间、雨天、倾斜四类场景分别统计整牌准确率,这组数据既是验收依据也是后续迭代基准。字符混淆矩阵值得细看,找出模型常错的字符对,针对性补样本,比无脑加数据更有效。

最后给一个具体技巧:识别服务启动时加载完模型,用一张固定测试图跑一遍自检,输出与预期不符就直接拒绝启动。模型路径错误、ONNX 算子兼容问题都会被挡在门外。真实项目里"昨天还能跑,今天启动就报错"太常见,自检是成本最低的一道保险。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 3:00:02

小波去噪在PPG信号处理中的分层降噪与心率提取

简介:本资源面向生物医学工程、信号处理方向的本科生及科研初学者,提供一套基于小波变换实现脉搏信号去噪与基波提取的完整MATLAB仿真方案。资源聚焦实际生理信号处理痛点,解决原始脉搏信号中高频噪声干扰导致特征失真、基频识别困难等问题&a…

作者头像 李华
网站建设 2026/9/12 2:57:22

OpenMontage 前端请求自动去重实战:SWR 数据获取模式详解

OpenMontage 前端请求自动去重实战:SWR 数据获取模式详解 【免费下载链接】OpenMontage Worlds first open-source, agentic video production system. 12 production pipelines, 100 tools, 700 agent skill and production-knowledge files. Turn your AI coding …

作者头像 李华
网站建设 2026/9/12 2:56:24

子域名收集全攻略:从被动发现到主动爆破的完整实践

做安全测试或者资产盘点的时候,我最怕听到一句话:“目标没几个子域名,随便测测就行。”说这话的人往往在后面的测试里被自己的信息盲区狠狠坑一把。子域名收集这件事,表面上看是跑几个工具拼字典,实际上决定了你对目标…

作者头像 李华
网站建设 2026/9/12 2:56:15

如何在 reMarkable 上安装 KOReader 并用 button-listen 服务自动启动

如何在 reMarkable 上安装 KOReader 并用 button-listen 服务自动启动 【免费下载链接】koreader An ebook reader application supporting PDF, DjVu, EPUB, FB2 and many more formats, running on Cervantes, Kindle, Kobo, PocketBook and Android devices 项目地址: htt…

作者头像 李华