简介:一份基于YOLOv8的车牌检测与识别项目源码,面向智能交通、电子收费等场景的开发者与研究者,解决从零搭建车牌识别系统的核心难题。资源共55个文件、32.93MB,涵盖JPEG/PNG训练与测试图片、YOLOv8模型配置与权重(.pt/.pb)、Jupyter Notebook分步演示、训练评估代码及真实道路视频,可快速复现全流程。随附的详细流程教程配合 README 说明与 requirements 依赖配置,可逐模块理解车牌检测、字符分割与识别各环节,并完成从数据准备到模型训练、性能评估的完整实战路径。另有演示视频直观呈现实际部署效果。目前已有 298 人学习下载,适合希望掌握 YOLOv8 落地方案并快速交付车牌识别系统的入门与进阶人员。
1. YOLOv8 车牌识别实战包:从检测到识别的一条龙源码可以省掉你一周调研时间
做车牌识别项目的人,十有八九会卡在同一个认知误区上:以为一个模型就能把「车牌在哪里」和「车牌号是什么」同时搞定。真实工程里,YOLOv8 在这条链路中只负责检测这一半——把车牌从画面里准确定位出来,字符识别是另一套模型、另一套预处理和另一套后处理的事。这份基于 YOLOv8 的车牌检测与识别源码包,恰好把两段都补齐了:有训练好的检测权重、序列化识别模型产物、可直接运行的 Notebook、演示视频和完整流程教程。不管你是想快速验证 YOLOv8 在真实车牌场景下的表现,还是打算以它为底座做二次开发,这套资源都值得花一个下午完整过一遍。
2. 先看清技术链路:检测与识别为什么拆成两段,YOLOv8 到底负责哪一半
2.1 车牌识别是两段式任务:检测负责定位,识别负责读字,混在一个模型里会互相拖累
YOLO 系列本质上是回归任务,网络输出的是边界框坐标、置信度和类别概率。它擅长回答「画面里有没有车牌、车牌在哪」,但不擅长回答「这一串字符具体是京A12345 还是京A12346」。字符识别的本质是细粒度分类,同一个字形的笔画差异可能只有一两个像素,把这种任务塞进检测头里,训练时梯度会互相干扰,最终检测框漂移、字符分类精度上不去,两头都做不好。
两段式架构的另一个理由是数据成本和更新频率。检测模型只需要框的标注,一张图三十秒标完;字符识别模型需要把每个字符单独抠出来标类别,还涉及分割质量,标注成本高一个量级。工程上两者的更新频率也不同:换一个省份的新能源车牌样式,只需要补充字符识别模型的样本,检测模型可以完全不动。如果你打算二次开发,分开维护这两套模型,比将来拆一个耦合的大模型省力得多。
2.2 项目文件拆解:runs、fingerprint.pb、两个 Notebook 各管哪一段
拿到压缩包先别急着跑,按文件职责过一遍,后面排查问题会快很多。这套资源的目录结构在工程上很典型,我按自己的理解拆了一张表:
| 文件/目录 | 职责推断 | 复现时怎么用 |
|---|---|---|
| runs/ | YOLOv8 训练输出目录,含检测权重(weights/best.pt 等)和训练指标曲线 | 推理时加载这里的权重文件 |
| detect License and Car Detection.ipynb | 主 Notebook,跑通「检测车牌+检测车辆」完整链路 | 按顺序执行可看到检测结果 |
| License Detection.ipynb | 纯车牌检测 Notebook,专注检测环节 | 单独验证检测效果时用 |
| Formats.ipynb | 数据集格式转换 | 用自己的标注数据训练前,先过一遍它 |
| model/fingerprint.pb + variables/ | 识别阶段的序列化模型产物,典型的 TensorFlow 冻结图 + 变量目录结构 | 检测出车牌后,把裁剪区域送入这里做字符识别 |
| demo.mp4 / demo_target.mp4 | 两个演示视频,大概率一个是原始画面,一个是叠加检测框的输出画面 | 快速看效果用,不用自己找测试视频 |
| dog.jpeg | 测试图片 | 验证环境是否装通 |
| requirements.txt | Python 依赖清单 | 创建虚拟环境后先装它 |
注意model/目录里同时存在 fingerprint.pb 和 variables,这是 SavedModel 导出结构的常见形态。如果把 fingerprint.pb 单独拷走而不带 variables 目录,加载大概率报错,这个坑后面细说。
2.3 requirements.txt 之外的版本基线:CPU 环境完全能跑,但 Python 和 torch 版本要锁住
这套资源不带环境,需要自己搭。我自己复现时的基线是 Python 3.10 + Ultralytics 8.x 系列,CPU 版本 torch 就能完成推理和小规模训练,显卡不是必需项。如果你用的是 Ubuntu 20.04 这类常见系统,直接按下面的命令建环境即可:
conda create -n plate python=3.10 -y conda activate plate pip install -r requirements.txtrequirements.txt 里大概率包含 ultralytics、torch、opencv-python、numpy、notebook 这类核心包。装完建议手工确认三个关键版本:
python -c "import ultralytics; print(ultralytics.__version__)" python -c "import torch; print(torch.__version__)" python -c "import cv2; print(cv2.__version__)"版本检查是有原因的。Ultralytics 8.0 和 8.2 的预测接口有细微差异,老版本跑新代码可能报 unexpected keyword argument;torch 的 CPU 版本大小写敏感,装错了 import 直接崩;opencv 版本过低会导致部分视频编码格式读不出来,demo.mp4 是黑屏不是模型的问题。这几个版本坑,我见过太多人踩了。
提示:纯 CPU 环境跑训练不是不行,但 100 个 epoch 可能要跑几个小时。如果你只是想验证效果,先直接用项目里训练好的权重跑推理,训练环节放到后面再说。
3. 完整复现流程:环境准备、预训练推理、用自己的数据训练三步走
3.1 环境安装与目录结构核对:先让 demo 跑起来,再谈别的
复现的第一步不是看代码,是确认目录完整。解压后先看一下顶层结构有没有缺东西,重点确认runs/下有 weights 目录、model/下有 fingerprint.pb 和 variables、demo.mp4能正常打开。如果缺了这几样,后面大概率跑不通。
目录确认无误后,用 conda 建好环境并安装依赖。装完依赖后别急着跑 Notebook,先用 dog.jpeg 做一次最小验证:
python -c " from ultralytics import YOLO model = YOLO('runs/detect/train/weights/best.pt') results = model.predict('dog.jpeg', conf=0.25, imgsz=640) print(results[0].boxes) "这段代码的逻辑是:加载训练好的检测权重,对 dog.jpeg 做一次推理,然后打印检测框信息。conf=0.25是置信度阈值,低于这个值的框会被丢弃,车牌属于小目标,阈值调太高会把真车牌滤掉;imgsz=640是输入尺寸,YOLOv8 会把原图缩放成 640×640 再送进网络,这是速度与精度比较均衡的默认值。如果你看到打印出若干 box 坐标,说明环境是通的,可以进 Notebook 跑完整流程了。
3.2 主 Notebook 推理链路解读:检测框裁剪后送入识别模型,两段是串起来的
detect License and Car Detection.ipynb的核心逻辑不复杂:用 YOLOv8 检测出车牌框和车辆框,然后把车牌框对应的区域从原图裁剪出来,送入识别模型读出车牌号。我在本地按这个思路还原过推理主流程,核心代码长这样:
from ultralytics import YOLO import cv2 # 1. 加载检测模型,定位车牌和车辆 det_model = YOLO("runs/detect/train/weights/best.pt") # 2. 识别模型用 OpenCV DNN 加载冻结图 rec_net = cv2.dnn.readNetFromTensorflow("model/fingerprint.pb") def recognize_plate_crop(crop): # 裁剪区域缩放到识别网络的输入尺寸,常用 224x224 resized = cv2.resize(crop, (224, 224)) # 归一化后构造 blob,送入识别网络 blob = cv2.dnn.blobFromImage(resized, 1.0 / 255.0, (224, 224), (0, 0, 0), swapRB=True) rec_net.setInput(blob) chars = rec_net.forward() # 按字符类别映射表解码,输出车牌字符串 return decode_chars(chars) frame = cv2.imread("demo.jpg") results = det_model.predict(frame, conf=0.25, iou=0.45)[0] for box in results.boxes: cls = int(box.cls[0]) if cls == 0: # 假设类别 0 是车牌 x1, y1, x2, y2 = map(int, box.xyxy[0]) crop = frame[y1:y2, x1:x2] plate_text = recognize_plate_crop(crop) print("识别结果:", plate_text)逻辑说明:检测模型先跑一遍,拿到所有目标的类别和坐标,类别 id 为 0 的当成车牌(具体 id 以项目里 data.yaml 的类别顺序为准),按坐标裁剪出车牌小图,再做缩放、归一化、blob 转换,最后送入 TensorFlow 冻结图识别输出字符序列。decode_chars是字符映射表的解码函数,把网络输出的类别索引映射回汉字、字母和数字,项目源码里应该有对应实现,你不需要自己写。
参数层面有两个点值得留意。iou=0.45是 NMS 的交并比阈值,值越小抑制越狠,相邻的重复框会被去掉,但如果两个车牌靠得太近,阈值太小可能误杀;swapRB=True是因为 OpenCV 读图默认是 BGR 通道顺序,而识别网络大概率按 RGB 训练,不交换通道的话识别结果会偏移。这种隐蔽的通道顺序问题,是识别准确率莫名其妙低的常见原因。
3.3 训练自己的检测模型:Labelme 标注转 YOLO 格式,关键参数一次说清
项目自带的权重只能覆盖它训练时的场景。你换了一个停车场、换了一种摄像头角度,检测效果可能明显下降,这时候就需要用自己的数据微调。YOLOv8 训练自定义数据的标准流程是:标注 → 转格式 → 写 yaml → 训练。
标注工具推荐 Labelme,它导出的 JSON 格式不是 YOLO 直接能用的,好在项目里的Formats.ipynb就是干这个转换的。转换逻辑是把 JSON 里的多边形坐标转成归一化的 x_center、y_center、width、height,同时把类别 id 从 0 开始编号。转换后的数据集目录结构长这样:
plate-dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── plate.yamlplate.yaml 的内容:
path: ./plate-dataset train: images/train val: images/val nc: 2 names: 0: license_plate 1: car注意 yaml 里的类别顺序必须和标注转换时一致,顺序错了模型会拿车牌框去匹配车的标签,训练出来的权重完全没法用。nc是类别总数,这里标了车牌和车两类。
数据准备好后,训练命令用 Ultralytics CLI 一行搞定:
yolo detect train \ model=yolov8n.pt \ data=plate.yaml \ epochs=100 \ imgsz=640 \ batch=16 \ device=cpumodel=yolov8n.pt表示用官方预训练权重做迁移学习,不要从零开始训练,收敛快得多;epochs=100对车牌这种目标相对单一的任务通常够用,训练集很小的话可以减到 50,反而能避免过拟合;batch=16在 CPU 上可能偏大,显存不够或内存报错就降到 8 或 4;device=cpu是 CPU 环境的写法,有 NVIDIA 显卡就改成device=0。
训练完成后去runs/detect/train/目录看results.png,这张图画了 loss 曲线和 mAP 曲线。正常情况下 loss 应该一路下降后趋平,mAP50 稳定在 0.9 以上算是合格。如果 loss 曲线震荡剧烈不收敛,先检查标注数据有没有负坐标或越界框,再检查 yaml 类别顺序,这两个问题占了训练失败原因的一大半。
4. 避坑排查:复现这套资源的 5 个典型翻车现场,踩中任何一个都够你折腾半天
4.1 检测框时好时坏,车检出来了但车牌框不住
现象:车辆检测框很正常,但车牌框偶尔有、偶尔无,或者框的位置偏移明显,尤其在车辆较小、车牌在画面里只占几十个像素的镜头里。
原因:车牌在整幅图像里属于典型小目标。imgsz设置过小(例如 320)时,原图缩放后车牌区域只剩十几个像素,特征在下采样过程中直接丢失;conf阈值太高也会把置信度本就不高的小目标框全部滤掉。
解决:把推理参数里的imgsz提到 640,条件允许就上 1280,让车牌区域保留更多像素;conf从默认的 0.25 降到 0.2 甚至 0.15,先让框出来,再用后处理去过滤。另外检查训练样本里是否包含小尺寸车牌,如果全是近景大车牌,模型天然对小目标不敏感,这时候要补充远距离样本重新微调。
4.2 识别结果里省份汉字全是错的,但数字和字母基本对
现象:车牌号后五位基本识别正确,第一位省份汉字要么错得离谱,要么频繁与相邻字符混淆。
原因:字符识别模型里,省份汉字的训练样本量通常远少于数字和字母,因为一个数据集的车辆可能集中在某一个省份;另外分割环节没把汉字的左右结构处理好,例如「川」字被从中间切开分成了两部分,后面的分类器拿到残缺字符,输出自然不对。
解决:先看分割结果,把实际切出来的字符图可视化,确认汉字是否完整。如果分割把汉字切碎了,在投影分割前先做一次形态学膨胀,把汉字内部的笔画间隙连起来再投影;如果分割没问题但分类还是错,那就走数据路线,补充该省份汉字的多角度、多光照样本,单独重训识别模型。
4.3 CPU 上跑视频推理只有一两帧,卡得没法看
现象:单张图片推理正常,但用 demo.mp4 做视频推理时,每帧要处理一两秒,GPU 又不存在,程序跑起来画面肉眼可见地卡。
原因:很多人图省事,在循环里一帧一帧调用model.predict(frame),每帧都重新做一次完整的预处理、推理、后处理流水线,Python 侧的开销被放大到不可接受。视频推理的正确姿势是让模型吃帧流,而不是反复重建计算图。
解决:把source直接指向视频文件,并开启stream=True,Ultralytics 会按视频帧流式返回结果。我自己常用的写法是:
from ultralytics import YOLO import cv2 model = YOLO("runs/detect/train/weights/best.pt") cap = cv2.VideoCapture("demo.mp4") writer = None for result in model.predict(source="demo.mp4", conf=0.25, imgsz=640, stream=True): frame = result.orig_img for box in result.boxes: x1, y1, x2, y2 = map(int, box.xyxy[0]) cls = int(box.cls[0]) if cls == 0: cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) if writer is None: h, w = frame.shape[:2] writer = cv2.VideoWriter("out.mp4", cv2.VideoWriter_fourcc(*"mp4v"), 25, (w, h)) writer.write(frame) writer.release()这样视频解码和推理在底层 pipeline 内完成,CPU 上至少能跑到可用帧率。如果还是慢,把imgsz降到 480,检测精度损失有限,速度能再快一截。
4.4 fingerprint.pb 加载失败或报错,识别环节直接中断
现象:推理脚本运行到识别模型加载时抛出异常,常见报错有No graph found、NotFoundError或Op type not registered之类,检测部分正常但识别走不到。
原因:most 情况下是文件依赖问题。fingerprint.pb 是冻结图模型文件,它依赖同目录下的 variables 文件保存的权重变量值,单独拷走 pb 而没带文件夹,图结构有了但权重是空的;另一种可能是用cv2.dnn.readNet加载 pb 时遇到不支持的 op,它对新版 TensorFlow 导出的部分算子兼容性有限。
解决:第一步确认 pb 和 variables 保持原始相对路径,不要单独拷贝 pb 到别处;加载方式优先用cv2.dnn.readNetFromTensorflow("model/fingerprint.pb"),如果报 op 相关错误,改成 TensorFlow 原生接口tf.saved_model.load("model")加载整个 SavedModel 目录,再从中取推理函数。这里没有通用银弹接口,我的建议是两种加载方式都在代码里留一个开关,哪个能跑用哪个。
4.5 Labelme 标注转完格式后训练 loss 不降,甚至越训越高
现象:数据转换完成后训练能启动,但 loss 在前几十个 epoch 没有下降趋势,或者直接飙升,mAP 曲线一直贴着零。
原因:这类问题的根源几乎都在数据侧。转换脚本输出的类别 id 和 yaml 顺序不一致,网络输出的梯度方向从一开始就是错的;另一种常见情况是标了背景类,YOLO 格式里背景不需要标注,多出来的一类直接污染损失计算。
解决:转换完成后做一次抽查验证,写几行代码检查 labels 目录下的 txt 文件:
head -5 plate-dataset/labels/train/xxx.txt每一行应该是一个类别 id 加四个归一化坐标值,id 范围在 0 到 nc-1 之间,坐标值在 0 到 1 之间。发现负数或大于 1 的坐标,说明转换脚本有 bug,回到 Formats.ipynb 检查归一化分母是否用了正确的图片宽高。把这几行确认干净再训练,能省出大半天的排错时间。
5. 字符识别与后处理:从车牌裁剪图到最终车牌号的工程细节
5.1 字符分割:投影法为主、连通域分析兜底,别让汉字结构毁掉整个识别
检测模型输出的车牌裁剪图直接喂进识别网络是不行的,字符识别网络通常接收单字符图,中间隔着分割这一步。工程上最经典的做法是投影法:把车牌图二值化,水平投影找出字符行的上下边界,垂直投影把每个字符单独切出来。我用的分割代码长这样:
import cv2 import numpy as np def split_chars(plate_bgr): gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 蓝底白字车牌需要反色,让字符成为白底黑字中的白色前景 _, bin_img = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 水平投影:统计每行白点数,确定字符行的上下范围 h_proj = np.sum(bin_img // 255, axis=1) rows = np.where(h_proj > 0)[0] top, bottom = rows[0], rows[-1] plate_row = bin_img[top:bottom, :] # 垂直投影:统计每列白点数,按连续区间切分字符 v_proj = np.sum(plate_row // 255, axis=0) cols = np.where(v_proj > 0)[0] char_boxes = [] start = cols[0] for i in range(1, len(cols)): if cols[i] - cols[i-1] > 1: char_boxes.append((start, cols[i-1])) start = cols[i] char_boxes.append((start, cols[-1])) # 过滤过窄区间,去掉噪声残留 chars = [] for x1, x2 in char_boxes: if x2 - x1 >= 5: chars.append(plate_row[:, x1:x2]) return chars逻辑说明:OTSU 自动阈值做二值化,蓝色底区域被置为黑色、白色字符保留;水平投影按行统计白色像素数量,非零区域就是字符所在的垂直范围;垂直投影同理,把字符之间的空隙找出来切成独立小图。最后一步按宽度过滤,小于 5 像素的区间大概率是噪点。
参数层面的坑在汉字上。汉字笔画多且有左右结构,例如「川」「渝」,垂直投影时内部笔画间隙可能超过字符间距阈值,一个汉字被切成两三块,后续分类必错。我通常会在投影前先做一个横向膨胀操作,让同一个字内部的笔画粘连起来,然后再投影分割。这一步是玄学参数重灾区,kernel 大小建议从 2×2 开始试,切碎了就加大,切粘连了就减小。
5.2 字符分类模型:车牌字符类别不多,轻量 CNN 足够,重点是样本分布
分割完成后,每个字符小图送入分类器。车牌字符的类别集合是固定的:31 个省份汉字(有些城市字号不同也算一类)、24 个字母(去掉 I 和 O,防止和数字 1、0 混淆)、10 个数字,合计约 65 类。这个规模不需要大模型,ResNet18 以下的轻量 CNN 完全能胜任。
如果你要自己训练字符分类器,一个小型 CNN 结构就够用,输入统一为 32×32 灰度图:
import torch.nn as nn class CharClassifier(nn.Module): def __init__(self, num_classes=65): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Linear(64 * 8 * 8, num_classes) def forward(self, x): x = self.features(x) x = x.flatten(1) return self.classifier(x)输入 32×32、灰度单通道,两层卷积加池化后特征图缩到 8×8、64 个通道,展平后接全连接分类。网络很小,CPU 上跑毫秒级。
真正决定识别效果的不是网络结构,是样本分布。省份汉字如果不平衡,模型会倾向于把所有汉字都猜成样本量最多的那个省,这是分类器最常见的坏习惯。训练前按类别统计样本数,把低于平均数的类别用旋转、平移、亮度扰动做数据增强补齐,比换大网络管用得多。
5.3 后处理校验:正则表达式 + 置信度过滤,把误识别率压到业务可接受范围
模型输出的字符序列直接当结果用是危险的,交替出现的「0/O」「1/I」在真实车牌上经常被读反。工程上的最后一道防线是后处理校验,核心是两件事:格式校验和置信度过滤。
中国大陆车牌的基本格式是省份汉字 + 发牌机关字母 + 5 或 6 位字符,新能源车牌多一位。用正则约束格式,能挡掉一半以上的离谱输出:
import re PLATE_PATTERN = re.compile( r"^[京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领]" r"[A-Z]" r"[A-HJ-NP-Z0-9]{5,6}$" ) def is_valid_plate(text): return bool(PLATE_PATTERN.match(text)) def post_process(raw_chars, confidences, min_conf=0.7): # 先按置信度过滤低质量字符位 chars = [c for c, s in zip(raw_chars, confidences) if s >= min_conf] text = "".join(chars) # 格式不符则标记为待人工复核 if not is_valid_plate(text): return None, "recheck" return text, "ok"校验逻辑分两层:置信度低于阈值的字符位不参与拼接,宁可少一位进入人工复核,也不要硬凑一个错误车牌;正则负责整体格式把关,省份位必须是合法汉字集合里的一个,第二位必须是字母,剩余位限定为字母(去掉 I/O)和数字。两层都过才认为识别结果是可信的。
这块很容易被忽略,但它恰恰是车牌识别项目验收时拉开差距的地方。通用场景检测识别全做对不难,难的是把误识别率压下去,你的客户不会关心模型 mAP 是多少,只在意「识别出来的 100 个车牌里有几个是错的」。后处理这层代码成本不高,收益却立竿见影。
6. 进阶验证:视频批量测试、ONNX 导出、换场景前必做的三件事
这套资源跑通只是起点,真正要落地到具体业务场景,我建议按顺序做三件事。
第一,用 demo_target.mp4 做基准回放。这个视频大概率是带检测框叠加的输出版本,把它当成「标准答案」反复对照自己模型的输出,重点关注框的稳定性——目标在运动时车牌框是否抖动、是否漏检。我一般会把检测结果按帧率抽出来做逐帧比对,框的中心点偏移超过 10 个像素就说明模型在该场景下不够稳。注意动态画面下漏检率通常比静态图高一个量级,先确认检测器不飘,再谈字符识别精度。
第二,导 ONNX 做跨平台部署验证。Ultralytics 自带导出命令,一条命令把 PyTorch 权重转成 ONNX 格式:
yolo export model=runs/detect/train/weights/best.pt format=onnx imgsz=640 opset=12转出来的 onnx 文件可以用 onnxruntime 在 CPU 上跑,性能比 PyTorch 推理快不少;如果目标设备是 rk3588 这类边缘盒子,再走一步 RKNN 工具链转成板端格式。导出的常见问题是动态尺寸被锁死,imgsz=640意味着输入必须缩放到 640,批量预测时记得做同样的预处理。
第三,也是我吃过亏的地方——换场景先测光照,别直接上生产。项目里的权重在演示视频的光照条件下效果不错,一旦换到逆光、夜间、雨雾天气,识别率会断崖式下跌。我现在拿到任何一套新识别资源,都会先收集目标场景的 50 张照片过一遍,单独统计检测率和识别率,确认掉点在哪一段再针对性地补数据或调参数。这套 YOLOv8 车牌识别资源也不例外,做任何改动前先跑一轮场景摸底。
从那以后,我每次拿到新的车牌识别项目,都强制自己先跑一遍 demo_target.mp4 的视频回放再做训练,为什么呢?因为静态图片识别正常根本不能说明问题,动态视频里框稳不稳、识别连不连贯,才是这个模型真实水平的试金石。希望这套流程和踩坑记录能帮你在车牌识别这条路上少走几个弯路。
本文还有配套的精品资源,点击获取