YOLOv8车牌识别实战:两段式架构、复现流程与踩坑全解析
简介一份基于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.txtPython 依赖清单创建虚拟环境后先装它注意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 python3.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 argumenttorch 的 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, conf0.25, imgsz640) print(results[0].boxes) 这段代码的逻辑是加载训练好的检测权重对 dog.jpeg 做一次推理然后打印检测框信息。conf0.25是置信度阈值低于这个值的框会被丢弃车牌属于小目标阈值调太高会把真车牌滤掉imgsz640是输入尺寸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), swapRBTrue) rec_net.setInput(blob) chars rec_net.forward() # 按字符类别映射表解码输出车牌字符串 return decode_chars(chars) frame cv2.imread(demo.jpg) results det_model.predict(frame, conf0.25, iou0.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是字符映射表的解码函数把网络输出的类别索引映射回汉字、字母和数字项目源码里应该有对应实现你不需要自己写。参数层面有两个点值得留意。iou0.45是 NMS 的交并比阈值值越小抑制越狠相邻的重复框会被去掉但如果两个车牌靠得太近阈值太小可能误杀swapRBTrue是因为 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 \ modelyolov8n.pt \ dataplate.yaml \ epochs100 \ imgsz640 \ batch16 \ devicecpumodelyolov8n.pt表示用官方预训练权重做迁移学习不要从零开始训练收敛快得多epochs100对车牌这种目标相对单一的任务通常够用训练集很小的话可以减到 50反而能避免过拟合batch16在 CPU 上可能偏大显存不够或内存报错就降到 8 或 4devicecpu是 CPU 环境的写法有 NVIDIA 显卡就改成device0。训练完成后去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直接指向视频文件并开启streamTrueUltralytics 会按视频帧流式返回结果。我自己常用的写法是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(sourcedemo.mp4, conf0.25, imgsz640, streamTrue): 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, axis1) 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, axis0) 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_classes65): super().__init__() self.features nn.Sequential( nn.Conv2d(1, 32, 3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(inplaceTrue), 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_conf0.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 modelruns/detect/train/weights/best.pt formatonnx imgsz640 opset12转出来的 onnx 文件可以用 onnxruntime 在 CPU 上跑性能比 PyTorch 推理快不少如果目标设备是 rk3588 这类边缘盒子再走一步 RKNN 工具链转成板端格式。导出的常见问题是动态尺寸被锁死imgsz640意味着输入必须缩放到 640批量预测时记得做同样的预处理。第三也是我吃过亏的地方——换场景先测光照别直接上生产。项目里的权重在演示视频的光照条件下效果不错一旦换到逆光、夜间、雨雾天气识别率会断崖式下跌。我现在拿到任何一套新识别资源都会先收集目标场景的 50 张照片过一遍单独统计检测率和识别率确认掉点在哪一段再针对性地补数据或调参数。这套 YOLOv8 车牌识别资源也不例外做任何改动前先跑一轮场景摸底。从那以后我每次拿到新的车牌识别项目都强制自己先跑一遍 demo_target.mp4 的视频回放再做训练为什么呢因为静态图片识别正常根本不能说明问题动态视频里框稳不稳、识别连不连贯才是这个模型真实水平的试金石。希望这套流程和踩坑记录能帮你在车牌识别这条路上少走几个弯路。本文还有配套的精品资源点击获取