【免费】基于YOLO26的车辆车牌识别系统(深度学习+LPRNet+CCPD+PyQt6) 人工智能 机器视觉 计算机视觉 锋哥原创出品,必属精品
大家好,我是Java1234_小锋老师,分享一套锋哥原创的基于YOLO26的车辆车牌识别系统(深度学习+LPRNet+CCPD+PyQt6) 人工智能 机器视觉 计算机视觉。

项目介绍
随着机动车保有量持续增长,停车场管理、卡口稽查和道路监控等场景对车牌自动识别提出了更高要求。传统依赖人工判读或浅层特征的方法在复杂光照、倾斜拍摄和新能源绿牌并存的条件下稳定性不足。本文设计并实现了一套基于YOLO26、LPRNet与CCPD的车辆车牌识别桌面系统,在CPU环境下完成从车牌定位到字符识别的完整流程。
系统采用两阶段技术路线:首先使用在CCPD中文车牌数据上训练的YOLO26-Pose模型检测车牌区域并回归四个角点,再通过透视变换将倾斜车牌拉正;对双层车牌执行上下区域拆分与横向拼接;随后将矫正图像缩放为94×24像素,送入LPRNet进行端到端字符识别,并以CTC贪心解码得到车牌号码。颜色判定与号牌类型推断作为后处理,用于区分普通蓝牌、新能源绿牌和黄牌。
软件层面以Python 3.11为开发语言,使用PyQt6构建侧边导航式图形界面,覆盖图片识别、视频与摄像头识别、识别历史、统计分析与系统设置五类功能。识别任务放入工作线程,避免界面卡顿。数据层采用MySQL 8,库名为db_plate_recognition,包含识别记录、模型信息和系统参数三张表,原图、车牌小图和标注图统一保存到本地目录。测试表明,系统能够完成蓝牌、绿牌、黄牌的检测与识别,并支持批量图片与抽帧视频处理,满足本科毕业设计的功能完整性与可演示性要求。
源码下载
链接: https://pan.baidu.com/s/1Rjp2PTRWZnIplgYjF9mB1w?pwd=1234
提取码: 1234
系统展示





核心代码
# -*- coding: utf-8 -*-
"""
YOLO26-Pose 车牌检测器。
加载 `yolo26s-plate-detect.pt`(或用户指定的其它 YOLO 权重),
在 CPU 上推理,输出检测框、置信度、类别(单层/双层)以及四个角点。
若加载的是普通 detect 模型(无关键点),则仅返回检测框。
"""
from pathlib import Path
from typing import List, Optional
import numpy as np
from config.settings import Settings
from utils.logger import logger
class PlateDetector:
"""车牌检测器,封装 Ultralytics YOLO 的加载与推理。"""
def __init__(
self,
weights_path: Optional[str] = None,
conf: float = None,
iou: float = None,
imgsz: int = None,
device: str = None,
):
"""
初始化检测器(延迟加载权重,避免导入阶段就报错)。
:param weights_path: 权重文件路径
:param conf: 置信度阈值
:param iou: NMS IOU 阈值
:param imgsz: 检测输入尺寸
:param device: 推理设备,本系统固定 cpu
"""
self.weights_path = str(weights_path or Settings.DETECT_MODEL_PATH)
self.conf = Settings.DETECT_CONF if conf is None else conf
self.iou = Settings.DETECT_IOU if iou is None else iou
self.imgsz = Settings.DETECT_IMGSZ if imgsz is None else imgsz
self.device = device or Settings.DEVICE
self.model = None
self.ready = False
self.error_msg = ""
def load(self) -> bool:
"""
加载 YOLO 权重到 CPU。
:return: 是否加载成功
"""
path = Path(self.weights_path)
if not path.exists():
self.error_msg = f"检测权重不存在:{path},请先在系统设置页下载模型"
logger.warning(self.error_msg)
self.ready = False
return False
try:
from ultralytics import YOLO
self.model = YOLO(str(path))
self.ready = True
self.error_msg = ""
logger.info("检测模型加载成功:%s", path)
return True
except Exception as exc:
self.error_msg = f"检测模型加载失败:{exc}"
logger.exception(self.error_msg)
self.ready = False
return False
def reload(self, weights_path: str) -> bool:
"""
切换并重新加载检测权重。
:param weights_path: 新的权重路径
:return: 是否加载成功
"""
self.weights_path = str(weights_path)
self.model = None
return self.load()
def detect(self, image: np.ndarray) -> List[dict]:
"""
对单张图像执行车牌检测。
:param image: BGR 图像
:return: 检测结果列表,每项包含 rect/conf/cls/landmarks
"""
if image is None:
return []
if not self.ready or self.model is None:
if not self.load():
return []
try:
results = self.model.predict(
source=image,
conf=self.conf,
iou=self.iou,
imgsz=self.imgsz,
device=self.device,
verbose=False,
)
except Exception as exc:
logger.exception("YOLO 推理失败:%s", exc)
return []
detections = []
for result in results:
boxes = getattr(result, "boxes", None)
if boxes is None or len(boxes) == 0:
continue
keypoints = getattr(result, "keypoints", None)
kpts_xy = None
if keypoints is not None and getattr(keypoints, "xy", None) is not None:
kpts_xy = keypoints.xy
num = len(boxes)
for idx in range(num):
xyxy = boxes.xyxy[idx].cpu().numpy().tolist()
conf = float(boxes.conf[idx])
cls_id = int(boxes.cls[idx]) if boxes.cls is not None else 0
landmarks = []
if kpts_xy is not None and idx < len(kpts_xy):
landmarks = kpts_xy[idx].cpu().numpy().tolist()
detections.append(
{
"rect": [int(v) for v in xyxy],
"conf": conf,
"cls": cls_id,
"landmarks": landmarks,
}
)
return detections
# -*- coding: utf-8 -*-
"""
识别历史页面。
提供按车牌号、来源类型、日期区间筛选的分页表格,支持查看原图、删除和导出。
表格列宽按内容分配:记录编号、识别时间给固定宽度,来源文件列随窗口拉伸。
"""
import csv
from math import ceil
from PyQt6.QtCore import QDate, Qt
from PyQt6.QtGui import QPixmap
from PyQt6.QtWidgets import (
QComboBox,
QDateEdit,
QDialog,
QFileDialog,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
QWidget,
)
from config.settings import Settings
from db.dao import PlateRecordDao
from ui.widgets.image_viewer import ImageViewer
from utils.image_utils import bgr_to_qimage, imread_unicode
from utils.plate_utils import source_type_text
class HistoryPage(QWidget):
"""识别历史查询页。"""
def __init__(self, parent=None):
"""初始化历史页。"""
super().__init__(parent)
self.page = 1
self.page_size = Settings.PAGE_SIZE
self.total = 0
self._build_ui()
def _build_ui(self):
"""构建筛选栏与表格。"""
root = QVBoxLayout(self)
root.setContentsMargins(16, 16, 16, 16)
filt = QHBoxLayout()
filt.addWidget(QLabel("车牌号"))
self.edt_plate = QLineEdit()
self.edt_plate.setPlaceholderText("支持模糊查询")
self.edt_plate.setFixedWidth(160)
filt.addWidget(self.edt_plate)
filt.addWidget(QLabel("来源"))
self.cmb_source = QComboBox()
self.cmb_source.addItem("全部", "")
self.cmb_source.addItem("图片识别", "image")
self.cmb_source.addItem("批量识别", "batch")
self.cmb_source.addItem("视频识别", "video")
self.cmb_source.addItem("摄像头", "camera")
filt.addWidget(self.cmb_source)
filt.addWidget(QLabel("开始日期"))
self.dt_start = self._make_date_edit(QDate.currentDate().addDays(-30))
filt.addWidget(self.dt_start)
filt.addWidget(QLabel("结束日期"))
self.dt_end = self._make_date_edit(QDate.currentDate())
# 默认不限制日期,需点击「启用日期筛选」后才生效
filt.addWidget(self.dt_end)
self.chk_date = QPushButton("启用日期筛选")
self.chk_date.setCheckable(True)
self.chk_date.setStyleSheet("background:#eef3f9;color:#2c3e50;")
filt.addWidget(self.chk_date)
self.btn_query = QPushButton("查询")
self.btn_reset = QPushButton("重置")
self.btn_reset.setStyleSheet("background:#eef3f9;color:#2c3e50;")
self.btn_export_xlsx = QPushButton("导出Excel")
self.btn_export_xlsx.setStyleSheet("background:#1abc9c;")
self.btn_export_csv = QPushButton("导出CSV")
self.btn_export_csv.setStyleSheet("background:#eef3f9;color:#2c3e50;")
filt.addWidget(self.btn_query)
filt.addWidget(self.btn_reset)
filt.addStretch()
filt.addWidget(self.btn_export_xlsx)
filt.addWidget(self.btn_export_csv)
root.addLayout(filt)
headers = [
"ID", "记录编号", "车牌号码", "颜色", "类型", "检测置信度", "识别置信度",
"来源", "来源文件", "耗时(ms)", "识别时间", "操作",
]
self.table = QTableWidget(0, len(headers))
self.table.setHorizontalHeaderLabels(headers)
self.table.verticalHeader().setVisible(False)
self.table.setAlternatingRowColors(True)
self.table.setSelectionBehavior(self.table.SelectionBehavior.SelectRows)
self.table.setEditTriggers(self.table.EditTrigger.NoEditTriggers)
self.table.setWordWrap(False)
# 行高加高,保证「查看 / 删除」按钮完整显示
vheader = self.table.verticalHeader()
vheader.setDefaultSectionSize(50)
vheader.setMinimumSectionSize(50)
vheader.setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
self._apply_column_widths()
root.addWidget(self.table, 1)
page_bar = QHBoxLayout()
self.btn_prev = QPushButton("上一页")
self.btn_next = QPushButton("下一页")
self.lbl_page = QLabel("第 1 页")
page_bar.addWidget(self.btn_prev)
page_bar.addWidget(self.lbl_page)
page_bar.addWidget(self.btn_next)
page_bar.addStretch()
root.addLayout(page_bar)
self.btn_query.clicked.connect(self.reload)
self.btn_reset.clicked.connect(self._reset)
self.btn_prev.clicked.connect(self._prev)
self.btn_next.clicked.connect(self._next)
self.btn_export_xlsx.clicked.connect(lambda: self._export("xlsx"))
self.btn_export_csv.clicked.connect(lambda: self._export("csv"))
def _make_date_edit(self, date: QDate) -> QDateEdit:
"""
创建带日历弹窗的日期选择框。
:param date: 初始日期
:return: 配置完成的日期控件
"""
editor = QDateEdit()
editor.setCalendarPopup(True)
editor.setDisplayFormat("yyyy-MM-dd")
editor.setDate(date)
# 预留右侧日历图标空间,避免日期文字把箭头挤没
editor.setFixedWidth(168)
editor.setMinimumHeight(34)
return editor
def _apply_column_widths(self):
"""设置表格列宽:编号和时间给够宽度,来源文件占剩余空间。"""
header = self.table.horizontalHeader()
header.setMinimumSectionSize(56)
header.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
header.setStretchLastSection(False)
# 来源文件列随窗口拉伸,其余列固定以免文字被省略号截断
header.setSectionResizeMode(8, QHeaderView.ResizeMode.Stretch)
header.setSectionResizeMode(11, QHeaderView.ResizeMode.Fixed)
widths = {
0: 56, # ID
1: 210, # 记录编号 PR20260818172517001
2: 110, # 车牌号码
3: 64, # 颜色
4: 80, # 类型
5: 92, # 检测置信度
6: 92, # 识别置信度
7: 80, # 来源
9: 80, # 耗时(ms)
10: 176, # 识别时间 2026-08-18 17:25:17
11: 168, # 操作
}
for col, width in widths.items():
self.table.setColumnWidth(col, width)
def showEvent(self, event):
"""每次进入页面自动刷新。"""
super().showEvent(event)
self.reload()
def _filters(self):
"""读取当前筛选条件。"""
start = self.dt_start.date().toString("yyyy-MM-dd") if self.chk_date.isChecked() else ""
end = self.dt_end.date().toString("yyyy-MM-dd") if self.chk_date.isChecked() else ""
return {
"plate_no": self.edt_plate.text().strip(),
"source_type": self.cmb_source.currentData() or "",
"start_date": start,
"end_date": end,
}
def reload(self):
"""按条件重新查询当前页。"""
try:
rows, total = PlateRecordDao.page_query(
page=self.page, page_size=self.page_size, **self._filters()
)
except Exception as exc:
QMessageBox.warning(self, "查询失败", str(exc))
return
self.total = total
pages = max(1, ceil(total / self.page_size)) if total else 1
if self.page > pages:
self.page = pages
self.lbl_page.setText(f"第 {self.page}/{pages} 页,共 {total} 条")
self.table.setRowCount(0)
for row in rows:
r = self.table.rowCount()
self.table.insertRow(r)
values = [
str(row.get("id", "")),
row.get("record_no", ""),
row.get("plate_no", ""),
row.get("plate_color", ""),
row.get("plate_type", ""),
f"{float(row.get('detect_conf') or 0):.2%}",
f"{float(row.get('rec_conf') or 0):.2%}",
source_type_text(row.get("source_type", "")),
row.get("source_name", ""),
str(row.get("cost_ms", "")),
row.get("create_time", ""),
]
for c, val in enumerate(values):
item = QTableWidgetItem(val)
item.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
item.setToolTip(val)
self.table.setItem(r, c, item)
self.table.setRowHeight(r, 50)
op = QWidget()
lay = QHBoxLayout(op)
lay.setContentsMargins(8, 8, 8, 8)
lay.setSpacing(8)
lay.setAlignment(Qt.AlignmentFlag.AlignCenter)
btn_view = QPushButton("查看")
btn_view.setFixedSize(58, 28)
btn_view.setStyleSheet("background:#1a73e8;padding:0px 8px;min-height:28px;max-height:28px;")
btn_del = QPushButton("删除")
btn_del.setFixedSize(58, 28)
btn_del.setStyleSheet("background:#e74c3c;padding:0px 8px;min-height:28px;max-height:28px;")
rid = int(row.get("id"))
btn_view.clicked.connect(lambda _, i=rid: self._view(i))
btn_del.clicked.connect(lambda _, i=rid: self._delete(i))
lay.addWidget(btn_view)
lay.addWidget(btn_del)
self.table.setCellWidget(r, len(values), op)
self._apply_column_widths()
def _reset(self):
"""重置筛选条件。"""
self.edt_plate.clear()
self.cmb_source.setCurrentIndex(0)
self.chk_date.setChecked(False)
self.page = 1
self.reload()
def _prev(self):
"""上一页。"""
if self.page > 1:
self.page -= 1
self.reload()
def _next(self):
"""下一页。"""
pages = max(1, ceil(self.total / self.page_size)) if self.total else 1
if self.page < pages:
self.page += 1
self.reload()
def _view(self, record_id: int):
"""弹出对话框查看原图与车牌小图。"""
row = PlateRecordDao.get_by_id(record_id)
if not row:
return
dlg = QDialog(self)
dlg.setWindowTitle(f"识别详情 - {row.get('plate_no')}")
dlg.resize(900, 560)
lay = QVBoxLayout(dlg)
info = QLabel(
f"编号 {row.get('record_no')} 号码 {row.get('plate_no')} "
f"颜色 {row.get('plate_color')} 类型 {row.get('plate_type')} "
f"时间 {row.get('create_time')}"
)
lay.addWidget(info)
imgs = QHBoxLayout()
left = ImageViewer("无标注图")
left.show_path(row.get("annotated_path") or row.get("original_path") or "")
imgs.addWidget(left, 3)
plate_box = QVBoxLayout()
plate_box.addWidget(QLabel("车牌小图"))
thumb = QLabel()
thumb.setFixedSize(280, 90)
thumb.setAlignment(Qt.AlignmentFlag.AlignCenter)
thumb.setStyleSheet("background:#0d213f;border-radius:6px;color:#fff;")
plate_img = imread_unicode(row.get("plate_path") or "")
if plate_img is not None:
qimg = bgr_to_qimage(plate_img)
thumb.setPixmap(
QPixmap.fromImage(qimg).scaled(
thumb.size(),
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
)
else:
thumb.setText("暂无小图")
plate_box.addWidget(thumb)
plate_box.addStretch()
imgs.addLayout(plate_box, 1)
lay.addLayout(imgs, 1)
dlg.exec()
def _delete(self, record_id: int):
"""删除一条记录。"""
if QMessageBox.question(self, "确认", "确定删除该识别记录?") != QMessageBox.StandardButton.Yes:
return
PlateRecordDao.delete_by_id(record_id)
self.reload()
def _export(self, kind: str):
"""
导出当前筛选条件下的全部记录。
:param kind: xlsx 或 csv
"""
try:
rows = PlateRecordDao.list_export(**self._filters())
except Exception as exc:
QMessageBox.warning(self, "导出失败", str(exc))
return
if not rows:
QMessageBox.information(self, "提示", "没有可导出的数据")
return
suffix = "xlsx" if kind == "xlsx" else "csv"
path, _ = QFileDialog.getSaveFileName(
self, "保存文件", f"识别记录.{suffix}", f"*.{suffix}"
)
if not path:
return
headers = [
"ID", "记录编号", "车牌号码", "颜色", "类型", "检测置信度", "识别置信度",
"来源", "来源文件", "原图路径", "车牌图路径", "耗时ms", "识别时间",
]
keys = [
"id", "record_no", "plate_no", "plate_color", "plate_type",
"detect_conf", "rec_conf", "source_type", "source_name",
"original_path", "plate_path", "cost_ms", "create_time",
]
try:
if kind == "csv":
with open(path, "w", newline="", encoding="utf-8-sig") as fp:
writer = csv.writer(fp)
writer.writerow(headers)
for row in rows:
writer.writerow([row.get(k, "") for k in keys])
else:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "识别记录"
ws.append(headers)
for row in rows:
values = []
for k in keys:
val = row.get(k, "")
if k == "source_type":
val = source_type_text(val)
values.append(val)
ws.append(values)
wb.save(path)
QMessageBox.information(self, "成功", f"已导出 {len(rows)} 条到\n{path}")
except Exception as exc:
QMessageBox.warning(self, "导出失败", str(exc))
更多推荐


所有评论(0)