简介:基于YOLOv8的车牌识别算法包覆盖12种中文车牌类型,从单行蓝牌、黄牌到新能源绿牌,再到警用、教练、武警、使馆、港澳粤Z牌、双层车牌及民航牌,兼顾颜色、结构及专用场景差异,覆盖面较广。压缩包内共224个文件,总大小38.55MB,包括149个Python源码、48个YAML配置、PyTorch模型权重(pt/pth)、测试图片及Shell工具脚本等,结构清晰,便于定位训练、推理或部署相关模块。已有348人浏览学习,适合作为智能交通、停车场管理相关课程设计或毕业设计的基础项目。直接运行detect_plate.py即可体验端到端识别过程,附带的多样车牌样张可验证不同拍摄条件下的效果,同时YAML与权重文件也为进一步微调或迁移学习提供了良好起点。
1. 从检测到分类:yolov8 车牌识别真正难的不是框
停车场闸机前,绿牌新能源车停了三秒还没抬杆,后台日志显示检测框一直在跳,置信度在 0.45 上下徘徊——这种场景做车牌识别的人都不陌生。基于 yolov8 的车牌识别算法,核心工作分两段:先用目标检测把车牌从画面里找出来,再对框内的内容做分类或字符识别。标题里的「12 种中文车牌类型」才是真正的分水岭,因为中国车牌的类型区分不只在底色,还在字符排列、位数和边框特征:蓝牌是 7 位,绿牌是 8 位且第二位是字母 D 或 F,挂车黄牌是 8 位,港澳入出境车牌是黑底白字且带「港」「澳」字样。yolov8 负责的是前一段——把各种底色、各种角度、各种光照下的车牌稳定检出并分类出类型,为后面的字符识别(OCR)提供干净的输入。这篇文章把模型选型、数据标注、训练参数、部署推理和类型分类的坑一次讲透,适合正在做毕业设计、停车场项目或安防系统选型的人直接参考。
2. yolov8 网络结构与 12 类车牌分类的映射逻辑
2.1 为什么用 yolov8 而不是 yolo5 或传统图像处理
车牌识别领域之前的主流方案是「颜色分割 + 形态学 + 模板匹配」或者 yolo5 + LPRNet。传统方案对光照和倾斜极度敏感,yolo5 的 anchor-based 机制在检测小目标和细长目标时召回率不够稳。yolov8 是 anchor-free 的,直接预测目标中心点到边框四边的距离,省掉了聚类 anchor 这一步。
这个特性对车牌识别很重要:车牌在画面中经常是细长矩形,尤其是侧方停车场景下宽高比能到 3:1 以上。anchor-based 模型需要在训练前对数据集做 K-means 聚类得到合适的 anchor 尺寸,一旦拍摄距离变化大,聚类结果就不够用了。yolov8 的解耦头(Decoupled Head)把分类和回归分支分开,每个分支有自己的损失权重,训练时互不干扰。C2f 模块(Cross Stage Partial with 2 convolutions and n bottlenecks)在保证梯度流通的同时减少了参数量,GTX 1660 Ti 这种 6GB 显存的卡也能跑起来。
12 种车牌类型分类这件事,可以在 yolov8 的检测头里直接做——类别数设为 12;也可以拆成「检测 + 分类」两段——检测只输出 1 类(车牌),另外用 ResNet 之类的分类器判断类型。两种方案各有场景,但多数项目我会建议直接在检测头里做多类,理由在 2.3 里展开。
2.2 12 种车牌类别的标注定义与类别 ID 设计
先把 12 类定义清楚,这一步决定了后续所有工作的准确性。不同省份、不同时期的地方牌照写法有差异,但作为通用车牌识别算法,一般按「底色 + 字符结构 + 使用场景」三个维度划分:
| 类别ID | 类型名称 | 典型特征 | 字符位数 | 常见场景 |
|---|---|---|---|---|
| 0 | 蓝牌 | 蓝底白字 | 7位 | 小型汽车(最常见) |
| 1 | 黄牌 | 黄底黑字 | 7位(大型车)/8位(挂车) | 大型汽车、挂车 |
| 2 | 绿牌 | 绿底黑字 | 8位,第二位为D或F | 新能源车(D纯电/F混动) |
| 3 | 黑牌 | 黑底白字 | 7位 | 涉外车辆、港澳入出境 |
| 4 | 白牌 | 白底黑字 | 6~7位 | 警车、军车 |
| 5 | 教练车 | 黄底黑字,带「学」字 | 7位 | 驾校车辆 |
| 6 | 农用车 | 绿底白字或黄底黑字 | 7位 | 农机、低速载货汽车 |
| 7 | 使馆车 | 黑底白字,带「使」字 | 7位 | 外国驻华使馆 |
| 8 | 临时牌 | 纸质,白底黑字或棕底白字 | 7位 | 临时上路车辆 |
| 9 | 挂车 | 黄底黑字,最后一位为「挂」 | 8位 | 半挂车、全挂车 |
| 10 | 摩托车 | 黄底黑字或蓝底白字 | 7位 | 二轮摩托车 |
| 11 | 其他 | 特殊用途车牌 | 不定 | 拖拉机、轮式机械等 |
注意:这里的 12 类划分不是国标原文,而是工程实现中为了训练收敛和实际场景覆盖而做的「合并同类项」。比如军牌里还分白底红字和绿底白字,但样本量太少时不必单独开类,归入「白牌」或「其他」即可。
类别 ID 的设计有学问——训练时不建议把「蓝牌」和「绿牌」的 ID 设得差距过大或过近。yolov8 默认用交叉熵损失做分类,类别间的独立性假设意味着它不考虑「蓝牌和绿牌长得有点像」这种语义关系。如果样本量不均衡,模型会倾向把难分样本硬归到样本量大的类。因此标注阶段要设一个规则:同一张图里的多块车牌都要标,不能只标最清晰的那块。很多项目训出来精确率看着不错,一上真实场景就崩,就是因为训练集里把模糊车牌全跳过了。
2.3 检测头直接分类 vs 检测 + 分类器两段式
直接在 yolov8 检测头里输出 12 类,好处是单模型端到端,部署简单、推理时间短(单帧一次前向搞定检测和类型判断)。坏处是:类型判断的精度受检测框质量影响——如果车牌边缘没框全,分类特征(特别是底色和边框)就看不全。
两段式方案(yolov8 只检测车牌,后面挂一个轻量分类器)的好处是检测框稍微偏一点也能通过分类器修正;坏处是推理链路变长,而且多一个模型就要多做一次前向、多一份显存或内存开销。
我的选择标准是看部署平台:如果是 RK3588、Jetson Orin Nano 这类边缘盒子,单模型 12 类直接出,省事省算力;如果是服务器 GPU 部署且精度要求极高,再把类型分类拆出去优化。后面第 4 章的代码按单模型方案给出。
3. 训练 yolov8 车牌识别模型:从数据集到损失函数曲线
3.1 数据来源、整理与 CCPD 之外的补充思路
公开数据集里最常用的是中科大的 CCPD 数据集(Chinese City Parking Dataset),包含 30 万张以上自然场景车牌图,覆盖蓝牌、绿牌、黄牌,带详细的标注信息。但 CCPD 有一个明显短板:场景集中在安徽合肥的停车场,角度、光照、背景多样性不够。而且 CCPD 的标注格式是 JSON,字段含义比较绕,需要转成 YOLO 格式。
更稳妥的做法是「开源数据 + 自采数据」混合:
- 开源部分:CCPD 按需抽 5~10 万张,并做去重(同一辆车连续帧不要重复训)。
- 自采部分:在停车场出入口、路侧、加油站等位置采集视频,按帧抽图。
- 合成数据:用 3D 渲染或图像合成工具生成不同角度、不同光照、不同模糊程度的车牌,特别是绿牌的 D/F 第二位特征要多做合成增强。
标注工具用 LabelImg 或 X-AnyLabeling 都行,导出 YOLO 格式。标注规范要提前写清楚:检测框紧贴车牌边缘,不要包含车牌边框以外的车身部分;倾斜车牌按最小外接矩形标注,但 yolov8 是水平框检测器,倾斜角过大的车牌对检测来说天然困难,这就引出 3.2 的角度增强。
3.2 数据增强与倾斜车牌处理的三个关键参数
from ultralytics import YOLO model = YOLO("yolov8s.pt") model.train( data="plate.yaml", epochs=200, imgsz=640, batch=16, optimizer="SGD", lr0=0.01, lrf=0.01, momentum=0.937, weight_decay=0.0005, warmup_epochs=3, warmup_momentum=0.8, cos_lr=True, hsv_h=0.03, hsv_s=0.6, hsv_v=0.5, degrees=15, translate=0.1, scale=0.4, shear=5.0, perspective=0.0002, flipud=0.0, fliplr=0.5, mosaic=1.0, mixup=0.3, )这段代码里对车牌场景最有影响的参数是degrees(旋转)、perspective(透视)、fliplr(水平翻转)这三个。
degrees=15:允许训练时把图片随机旋转正负 15 度。超过这个值会导致车牌长宽比变形太严重,模型学到的是「斜矩形」而不是「车牌」。侧方停车场景下真实倾斜角通常在 10~30 度,训练时用 15 度、推理时靠检测框的泛化能力去覆盖,实测够用。如果你想让模型更抗斜,可以逐步加到 25,但一定要配合shear(错切)一起调,单独加大degrees会让损失函数震荡。
perspective=0.0002:模拟拍摄视角变化带来的近大远小。车牌识别最典型的失败场景是车头正对摄像头时车牌是一个正矩形,但车从侧面经过时变成梯形。透视增强让模型见过各种形变的车牌,代价是训练时间变长。
fliplr=0.5:水平翻转增强,这个对车牌识别有特殊的双刃剑效应。翻转后「京A12345」会变成「54321A京」,字符位置语义被破坏了。但对类型分类影响不大——蓝牌翻过来还是蓝牌。所以如果 12 类分类精度优先,fliplr建议降到 0.3;如果检测召回优先,保持 0.5 不变。
hsv_h=0.03要特别注意,不能开大。车牌的底色是分类的核心特征,蓝牌的 HSV 色相范围集中在 200~230 度之间,hsv_h超过 0.05 会把蓝牌增强成紫色甚至红色,模型分类就直接学歪了。
3.3 模型结构选择:n/s/m/l 怎么选与 GPU 显存估算
yolov8 官方提供 n(nano)、s(small)、m(medium)、l(large)、x(xlarge)五个尺寸。车牌识别不是小目标检测(车牌在画面中通常占 100x30 像素以上),不需要为了小目标能力上 l 或 x。参考显存和帧率数据:
| 模型 | 参数量 | 输入尺寸 640x640 的 GFLOPs | 最低显存建议 | GTX 1660 Ti 实测帧率(batch=1) |
|---|---|---|---|---|
| yolov8n | 3.2M | 8.7 | 2GB | 60~80 FPS |
| yolov8s | 11.2M | 28.6 | 4GB | 40~55 FPS |
| yolov8m | 25.9M | 78.9 | 6GB | 20~30 FPS |
| yolov8l | 43.7M | 165.2 | 10GB | 10~15 FPS |
GTX 1660 Ti(6GB)跑yolov8s是甜点选择:精度比 nano 高一个档次,显存刚好放得下 batch=16 的训练,推理帧率能到 40 以上。Jetson Orin Nano 部署建议用yolov8n或把yolov8s转 INT8 量化。RK3588 的 NPU 对 INT8 支持好,但前提是模型结构里不要有算子不兼容的模块,第 5 章会展开说。
yolov8 的 C2f 模块相比 yolo5 的 C3 模块,多了一次梯度分流(split 后分别经过 Bottleneck 再 concat),理论上梯度流更丰富、特征表达能力更强。但代价是模型文件大了 10%~15%,加载速度和前向速度会略降。对车牌场景来说,C2f 带来的精度提升是实打实的,尤其对绿牌上 D/F 字母这种细节特征的提取有帮助。
3.4 损失函数曲线怎么看:判断模型是否欠拟合或过拟合
训练时打开results.csv或直接用tensorboard看损失曲线。关键看三张图:train/box_loss、train/cls_loss、val/cls_loss。
# 训练结束后快速画出损失曲线 import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv("runs/detect/train/results.csv") # 实际列名可能带空格,先打印列名确认 df.columns = [c.strip() for c in df.columns] plt.figure(figsize=(12, 4)) plt.subplot(1, 3, 1) plt.plot(df["epoch"], df["train/box_loss"], label="train box loss") plt.plot(df["epoch"], df["val/box_loss"], label="val box loss") plt.xlabel("epoch") plt.ylabel("box loss") plt.legend() plt.subplot(1, 3, 2) plt.plot(df["epoch"], df["train/cls_loss"], label="train cls loss") plt.plot(df["epoch"], df["val/cls_loss"], label="val cls loss") plt.xlabel("epoch") plt.ylabel("cls loss") plt.legend() plt.subplot(1, 3, 3) plt.plot(df["epoch"], df["metrics/precision(B)"], label="precision") plt.plot(df["epoch"], df["metrics/recall(B)"], label="recall") plt.xlabel("epoch") plt.ylabel("score") plt.legend() plt.tight_layout() plt.savefig("loss_curves.png", dpi=150)画完曲线后按这三个标准判断:
- train loss 持续下降但 val loss 拐点后上升:过拟合。车牌数据集样本量足够大时不太常见,但如果自采数据只有几千张就容易出现。对策:加大
mixup到 0.5,提前epochs到 150 左右开启早停。 - train 和 val 的 cls loss 都居高不下(>0.1):类型分类学不动。大概率是类别样本不均衡,黄牌样本是蓝牌的 1/50,模型对黄牌的梯度贡献微乎其微。对策:计算每个类别的样本数,按倒数加权设置
cls_loss的类别权重,或者用过采样把少数类复制几轮。 - box loss 收敛但 cls loss 波动剧烈:检测框已经稳定了,但类型判断不稳定。重点检查标注——有没有把「教练车黄牌」和「普通黄牌」混标了,这两个外观几乎一样,区别只在有没有「学」字,人的肉眼都容易看错。
推荐的训练策略是先用yolov8s加上 mosaic=1.0 跑 200 轮,观察损失曲线;如果 150 轮后 cls loss 还在平稳下降,说明数据量不够,可以加载 best.pt 继续训 100 轮,把mosaic关掉(mosaic=0.0),只用小角度旋转和 HSV 微调做最后精修。这个「先强增强、后弱增强」的两段式策略在车牌场景下比单阶段从头训效果稳定 3~5 个百分点。
4. 推理部署与 12 类车牌的前后处理链路
4.1 ONNX 导出与 CPU/GPU 推理基准
训练完的best.pt是 PyTorch 权重,不能直接上生产。最常见的落地路径是导出 ONNX,再根据部署平台转成 TensorRT(NVIDIA GPU)、OpenVINO(Intel CPU)、RKNN(瑞芯微 NPU)或 TensorFlow Lite(手机端)。
# 导出 ONNX,并同时输出 NMS 后的结果(end2end 模式在 yolov8 中可用) from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") model.export( format="onnx", imgsz=640, opset=12, dynamic=False, simplify=True, half=True, )导出后建议用onnxruntime或onnxsim验证一遍输出张量的形状。yolov8 的 ONNX 输出是一个 1x84x8400 的张量(以 COCO 80 类为例),其中 8400 是三个尺度特征图(80x80 + 40x40 + 20x20)展平后的候选框总数,84 = 4(边框)+ 80(类别概率)。车牌 12 类场景下输出变成了 1x16x8400。这个形状和 yolo5 的端到端输出一样,但注意 8400 个候选中绝大多数是背景,需要 NMS 过滤。
import onnxruntime as ort import numpy as np import cv2 session = ort.InferenceSession("best.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) input_name = session.get_inputs()[0].name def preprocess(img, size=640): h, w = img.shape[:2] r = min(size / h, size / w) resize_w, resize_h = int(round(w * r)), int(round(h * r)) resized = cv2.resize(img, (resize_w, resize_h), interpolation=cv2.INTER_LINEAR) canvas = np.full((size, size, 3), 114, dtype=np.uint8) x_offset = (size - resize_w) // 2 y_offset = (size - resize_h) // 2 canvas[y_offset:y_offset + resize_h, x_offset:x_offset + resize_w] = resized blob = canvas[:, :, ::-1].transpose(2, 0, 1) blob = blob.astype(np.float32) / 255.0 return blob[None], r, x_offset, y_offset img = cv2.imread("car.jpg") blob, r, x_off, y_off = preprocess(img) outputs = session.run(None, {input_name: blob})[0] # shape: (1, 16, 8400) # 后续要对 outputs 做 NMS,并把坐标按 r 和 offset 还原回原图preprocess 里做了 letterbox(保持宽高比缩放 + 填充),推理后必须把归一化坐标映射回原图。r是缩放比例,x_off/y_off是填充偏移,映射公式为:
ori_x = (pred_x - x_off) / r ori_y = (pred_y - y_off) / r这一步忘了做的话,检测框会整体偏移——实际项目里很多「模型不准」的反馈最后都查出来是坐标没还原。
4.2 车牌定位成功后的二次精细化:为什么需要矫正
yolov8 输出的水平检测框已经把车牌定位住了,但 12 类分类可以用「框内图像」再做一次校验。真实场景下,摄像头安装角度和车牌本身倾斜会导致检测框内的车牌是平行四边形或梯形,直接拿去 OCR 会失败。
常见做法是在类型分类之后、字符识别之前,加一个四角点回归网络或传统边缘检测做透视矫正。如果不用额外模型,可以用 OpenCV 的minAreaRect找车牌区域的旋转外接矩形,再按角度做仿射变换:
import cv2 import numpy as np # 假设 yolo 输出检测框后的结果: box = [x1, y1, x2, y2, conf, cls] # 在框内找绿色/蓝色的轮廓 def align_plate_crop(img, box): x1, y1, x2, y2 = [int(v) for v in box[:4]] crop = img[y1:y2, x1:x2] hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) # 蓝牌色相范围 mask = cv2.inRange(hsv, (100, 80, 80), (130, 255, 255)) # 绿牌色相范围(新能源是绿底黑字) mask_green = cv2.inRange(hsv, (35, 80, 80), (85, 255, 255)) mask = cv2.bitwise_or(mask, mask_green) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return crop contour = max(contours, key=cv2.contourArea) rect = cv2.minAreaRect(contour) angle = rect[2] if angle > 45: angle = angle - 90 matrix = cv2.getRotationMatrix2D((crop.shape[1] / 2, crop.shape[0] / 2), angle, 1.0) rotated = cv2.warpAffine(crop, matrix, (crop.shape[1], crop.shape[0])) return rotated这个方法的局限是依赖 HSV 找色,对光照剧烈变化(逆光、夜间)会失效。更稳的替代方案是把四角点回归任务直接并入 yolov8——用 yolo 的检测头输出 4 个关键点坐标(仿照 yolov8-pose 的结构),但训练数据需要额外标注四角点。这是进阶优化,第一次做车牌项目建议先用 HSV 矫正,把管线跑通再考虑关键点方案。
4.3 端到端部署时的 NMS 参数和置信度阈值
推理时conf_thres建议设 0.35~0.45,iou_thres设 0.5~0.6。车牌目标大、特征明显,阈值太保守会漏检——特别是绿牌在阴暗环境下对比度低,置信度普遍比蓝牌低 5~10 个百分点。如果你发现绿牌老是检不出来,试着把conf_thres降到 0.3,然后靠 NMS 后的面积过滤去掉误检。
类别置信度还有一个后处理技巧:12 类分类结果里,如果「蓝牌」和「绿牌」两个类别的置信度都超过 0.4 且差值小于 0.1,就标记为「待人工复核」,不直接做决定。这比强行设定一个分类阈值要可靠得多。
5. 识别结果的时序稳定性:让算法对同一辆车只识别一次的工程手段
5.1 单帧识别的问题:闪烁、跳变和重复计数
停车场场景中最影响体验的问题是「一帧识别到、下一帧没识别到、再下一帧又识别到」——原因是车辆运动模糊、车牌反光、摄像头自动曝光切换都会让某几帧的检测置信度跌破阈值。如果直接按帧触发后续逻辑(比如抬杆、计费、入库),就会产生重复记录或误触发。
yolov8 本身没有任何时序记忆能力,单帧输入输出结构决定了它「每帧都是第一次见这张图」。要做时序稳定,必须在检测外面包一层「轨迹管理」逻辑,常见做法是 IOU 匹配 + 状态机。
5.2 基于 IOU 的跨帧匹配与车牌类型投票机制
设计一个简单的PlateTracker类,维护一个字典保存「当前正在跟踪的车牌」的状态:检测框坐标、类别置信度、连续命中帧数、丢失帧数、累计类别投票结果。每帧检测完后,把当前帧的检测框和已有轨迹做 IOU 匹配,匹配成功就更新轨迹,匹配失败就新建轨迹。超过连续 3 帧没匹配上(丢失帧数 > 3)就删除轨迹。
class PlateTracker: def __init__(self, iou_threshold=0.4, max_miss_frames=3, vote_threshold=5): self.iou_threshold = iou_threshold self.max_miss_frames = max_miss_frames self.vote_threshold = vote_threshold self.tracks = {} # track_id -> {"box": [...], "votes": {...}, "miss": int, "hit": int} self.next_id = 0 def iou(self, a, b): x1 = max(a[0], b[0]); y1 = max(a[1], b[1]) x2 = min(a[2], b[2]); y2 = min(a[3], b[3]) inter = max(0, x2 - x1) * max(0, y2 - y1) area_a = (a[2] - a[0]) * (a[3] - a[1]) area_b = (b[2] - b[0]) * (b[3] - b[1]) return inter / (area_a + area_b - inter + 1e-6) def update(self, detections): # detections: list of [x1, y1, x2, y2, conf, cls] assigned = [False] * len(detections) for track_id, track in list(self.tracks.items()): best_iou, best_idx = 0, -1 for i, det in enumerate(detections): if assigned[i]: continue score = self.iou(track["box"], det[:4]) if score > best_iou: best_iou, best_idx = score, i if best_iou >= self.iou_threshold and best_idx != -1: det = detections[best_idx] track["box"] = det[:4] track["hit"] += 1 track["miss"] = 0 # 类别投票:累积每帧的分类结果 cls_id = int(det[5]) track["votes"][cls_id] = track["votes"].get(cls_id, 0) + 1 assigned[best_idx] = True else: track["miss"] += 1 # 新建轨迹 for i, det in enumerate(detections): if not assigned[i]: self.tracks[self.next_id] = { "box": det[:4], "votes": {int(det[5]): 1}, "hit": 1, "miss": 0, } self.next_id += 1 # 清理长期丢失的轨迹 for track_id in list(self.tracks.keys()): if self.tracks[track_id]["miss"] > self.max_miss_frames: del self.tracks[track_id] # 返回稳定结果:命中帧数达到阈值且类别票数最高 results = [] for track_id, track in self.tracks.items(): if track["hit"] >= self.vote_threshold: best_cls = max(track["votes"], key=track["votes"].get) best_votes = track["votes"][best_cls] total_votes = sum(track["votes"].values()) if best_votes / total_votes >= 0.7: results.append({ "box": track["box"], "cls": best_cls, "hit": track["hit"], "votes": total_votes, }) return resultsvote_threshold=5的含义是「同一辆车至少连续 5 帧被识别到才认为是有效结果」。这看起来增加了延迟(以 30 FPS 的输入算,约 200 毫秒),但换来的是把单帧误检几乎完全过滤掉。miss_frames=3允许短暂遮挡(比如行人走过)后轨迹不丢。
提示:投票机制解决的是「类型分类不稳定」的场景。如果 12 类里蓝牌和绿牌总是被投出不同结果,说明检测框里包含了太多车身背景,HSV 特征被污染了。回到第 3 章检查标注质量,别在投票上硬堆阈值。
5.3 边缘设备部署:RK3588 和 Jetson Orin Nano 的适配差异
RK3588 的 NPU(6 TOPS INT8)跑yolov8n转换后的 RKNN 模型,实测单帧 640 输入能做到 25~40ms。转换链路是 PyTorch → ONNX → RKNN,关键操作是rknn.config里把target_platform设为rk3588,quantized_dtype设为w8a8。要注意节制:如果模型里用了SiLU之外的激活函数或者自定义算子,RKNN-Toolkit2 可能报错,优先选择官方已经支持的结构,C2f 模块在最新的 RKNN-Toolkit2 版本中是支持的。
Jetson Orin Nano 的路线是 ONNX → TensorRT 的trtexec转 engine,支持 FP16 和 INT8(需要校准数据集)。TensorRT 的 batch 固定后性能提升明显,但动态尺寸(--minShapes/--maxShapes)会引入额外延迟,固定 640 输入最稳。运行yolov8nFP16 的推理时间约 8~12ms,INT8 可以到 5ms 以内。
两种边缘设备上都建议部署时开启硬件解码(RK3588 的 MPP、Jetson 的 V4L2 + NVDEC),把 JPEG 解码从 CPU 挪到硬件单元。否则摄像头输入 1080p 时,解码开销可能比模型推理还高。IPC 的 RTSP 流拉取用 FFmpeg 子进程 + 队列,别在推理线程里直接做网络 I/O,否则一丢包整套流程就卡住了。
5.4 一个可直接落地的完整推理脚本骨架
import cv2 import numpy as np import onnxruntime as ort from collections import deque class PlateRecognizer: def __init__(self, onnx_path, conf_thres=0.35, iou_thres=0.5): self.session = ort.InferenceSession(onnx_path, providers=["CPUExecutionProvider"]) self.conf_thres = conf_thres self.iou_thres = iou_thres self.input_name = self.session.get_inputs()[0].name self.input_size = self.session.get_inputs()[0].shape[2] # 640 def detect(self, frame): blob, r, x_off, y_off = preprocess(frame, self.input_size) preds = self.session.run(None, {self.input_name: blob})[0][0] # preds shape: (16, 8400),转置后按置信度过滤 preds = preds.T boxes, scores, cls_ids = [], [], [] for p in preds: conf = p[4:].max() if conf < self.conf_thres: continue cls_id = int(p[4:].argmax()) bx1, by1, bx2, by2 = p[:4] # yolov8 直接输出 xyxy 坐标,无需再解算 boxes.append([bx1, by1, bx2, by2]) scores.append(float(conf)) cls_ids.append(cls_id) indices = cv2.dnn.NMSBoxes(boxes, scores, self.conf_thres, self.iou_thres) results = [] for i in indices.flatten(): x1, y1, x2, y2 = boxes[i] # 映射回原图 x1, x2 = (x1 - x_off) / r, (x2 - x_off) / r y1, y2 = (y1 - y_off) / r, (y2 - y_off) / r results.append([x1, y1, x2, y2, scores[i], cls_ids[i]]) return results def recognize_video(self, video_source): cap = cv2.VideoCapture(video_source) tracker = PlateTracker() while cap.isOpened(): ret, frame = cap.read() if not ret: break dets = self.detect(frame) stable = tracker.update(dets) for res in stable: plate_type = TYPE_NAMES[res["cls"]] x1, y1, x2, y2 = [int(v) for v in res["box"]] cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText(frame, plate_type, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.imshow("plate", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()这个骨架把「单帧检测」和「时序稳定」拆成两个独立模块:PlateRecognizer.detect()只做图像推理,PlateTracker.update()只做跨帧决策。这两个模块可以分别优化——比如在 GPU 服务器上把detect()换成 TensorRT engine,而PlateTracker不用改一行代码。注意NMSBoxes需要 OpenCV 4.1+,否则换成onnxruntime里的自定义 NMS 或轻量实现。
识别到稳定车牌后,下一步是把车牌区域裁剪下来送进 OCR 引擎做字符识别。yolov8 只负责「找到车牌和判断类型」,字符识别可以用 PaddleOCR 的 PP-OCRv4(直接喂整张图)或者轻量 CNN + LSTM + CTC 的 LPRNet(只需要车牌区域图,更适合边缘设备)。类型分类结果可以作为 OCR 的前置知识——比如类型是「绿牌」,OCR 的字符集就锁定为「数字 + D/F 字母 + 省份简称」,能显著提升识别置信度和速度。
时序稳定这块还有一个更彻底的做法:把 5~10 帧的检测结果做加权融合,不只是投票,而是把多帧的检测框坐标做平滑(EMA),类型概率做平均。这能进一步消除抖动,但对实时性要求高的场景(比如车速 60km/h 通过卡口),帧间车牌位移可能超过 1/3 帧宽,IOU 匹配会失败,这时候要引入卡尔曼滤波预测下一帧位置。车牌识别项目做 100 个,有 90 个的真正难点不在模型选型而在这种工程边界——模型结构用 yolo 系的现成方案就行,时间和踩坑都花在数据质量、阈值适配和时序处理的取舍上。
本文还有配套的精品资源,点击获取