ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于OpenCV与CNN的车牌识别系统:从定位分割到PyQt界面与数据库集成

基于OpenCV与CNN的车牌识别系统:从定位分割到PyQt界面与数据库集成 简介这份Python项目源码面向计算机相关专业的课程设计学习者与深度学习入门开发者提供一套基于深度学习的车牌识别系统完整实现可用于交通管理、智能停车场等场景的课程实践与二次开发。压缩包为zip格式整体约27.74MB包内文件以Python源码、深度学习模型文件及图像处理脚本为主涵盖车牌定位、字符分割、字符识别等核心模块并配套用户界面相关代码便于直接运行与调试。项目技术栈涉及Python、TensorFlow或PyTorch等深度学习框架以及OpenCV图像处理库通过高斯模糊与Sobel算子增强车牌特征同时提供模型训练与优化接口支持Windows、Linux等多平台部署。目前已有134人学习下载适合希望掌握车牌识别全流程、理解卷积神经网络在字符识别中应用的学习者参考也可作为课程设计或毕业设计的实践基础。1. 拆开这个车牌识别项目它到底能跑通什么停车场出入口的相机抓拍一张图几百毫秒内要吐出「京A·12345」这样的字符串还要把结果写进数据库、推给道闸。这套流程里最容易被低估的不是模型本身而是车牌定位和字符分割这两步——很多号称「深度学习车牌识别」的项目实际上把定位、分割、识别全塞给一个端到端网络结果换个角度、换种光照就崩。这份 Python 项目源码走的是更工程化的路线OpenCV 做预处理和定位深度学习模型做字符识别中间用高斯模糊和 Sobel 算子增强车牌边缘特征最后用 PyQt 搭一个能上传图片和视频的界面。它适合课程设计、毕业设计也适合想搞明白车牌识别完整链路而不是只调一个 API 的开发者。你拿到的是一个能本地跑、能改模型、能接数据库的完整工程不是一段演示脚本。2. 车牌定位与字符分割OpenCV 预处理链路怎么搭车牌识别的第一道坎不是识别率而是「你能不能把车牌从整张图里抠出来」。抠不准后面模型再强也是白搭。这个项目用传统图像处理做定位和分割好处是可解释、可调参、不依赖训练数据坏处是对极端光照和倾斜敏感。下面把这条链路拆开讲。2.1 高斯模糊与 Sobel 算子为什么先模糊再求梯度直接对原图做边缘检测噪点会被当成边缘车牌区域里全是碎线。常见做法是先高斯模糊压掉高频噪声再用 Sobel 算子求水平梯度——车牌字符的垂直边缘在水平方向上梯度最明显这一步能把字符区域「点亮」。import cv2 import numpy as np def preprocess_plate(img_path): # 读取原图BGR 三通道 img cv2.imread(img_path) # 转灰度后续所有操作在单通道上做减少计算量 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊核大小 5x5标准差 0 表示由核大小自动推算 # 核越大越平滑但车牌字符边缘也会被抹掉5 是经验值 blur cv2.GaussianBlur(gray, (5, 5), 0) # Sobel 水平方向求导ksize3 表示 3x3 卷积核 # 输出是 16 位有符号数负梯度会被截断所以取绝对值 sobel_x cv2.Sobel(blur, cv2.CV_16S, 1, 0, ksize3) abs_x cv2.convertScaleAbs(sobel_x) # 自适应二值化不同光照下固定阈值会翻车自适应按邻域算阈值 binary cv2.adaptiveThreshold(abs_x, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 15, -5) return img, gray, binary逻辑说明GaussianBlur的核大小是第一个要调的参数图片分辨率高就往上加但别超过 7否则字符笔画糊在一起。Sobel的ksize用 3 足够再大边缘定位会偏移。adaptiveThreshold的blockSize必须是奇数15 适合 720p 左右的图C值取负数是把阈值往下压让更多边缘保留下来。参数说明CV_16S是必须的用CV_8U会丢负梯度边缘只剩一半。2.2 形态学闭运算与轮廓筛选把碎边缘连成车牌框二值化之后车牌区域是一堆断断续续的竖线需要用形态学闭运算把字符连成块。闭运算是先膨胀后腐蚀能填小孔、连断线同时不明显改变整体尺寸。def locate_plate(binary): # 定义一个横向长条核车牌是扁的横向连接比纵向更重要 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) # 闭运算先膨胀再腐蚀把字符间的缝隙填上 closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 找外轮廓只取最外层减少嵌套干扰 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) # 宽高比筛选中国车牌大致在 2.5 到 5 之间 ratio w / float(h) # 面积筛选太小的框是噪声太大的框是车身 area w * h if 2.5 ratio 5.0 and 2000 area 50000: candidates.append((x, y, w, h)) return candidates逻辑说明getStructuringElement的核尺寸直接决定能不能把车牌连成一块。17×5 是 720p 图的经验值图片更大要按比例放大。宽高比 2.5 到 5 覆盖了蓝牌和新能源绿牌但黄牌大车的比例会偏大需要单独放宽。面积上下限是为了排除螺丝孔和整块车身。常见做法是再加一步颜色筛选——蓝牌在 HSV 空间里 H 值集中在 100 到 124绿牌在 35 到 77加上颜色判断能砍掉大量误检。2.3 字符分割垂直投影法切出单个字符定位到车牌框后把车牌区域裁出来再做一次二值化然后用垂直投影找字符间的空白列。这一步的坑在于「·」和「I」这类窄字符容易被切碎需要设最小宽度阈值。def split_characters(plate_gray): # 车牌区域二值化用 OTSU 自动找阈值 _, binary cv2.threshold(plate_gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 垂直投影统计每一列白色像素个数 projection np.sum(binary 255, axis0) # 找投影为 0 的列作为字符间的分割点 char_boxes [] in_char False start 0 for i, val in enumerate(projection): if val 0 and not in_char: in_char True start i elif val 0 and in_char: in_char False # 宽度小于 5 像素的当作噪点丢掉 if i - start 5: char_boxes.append((start, i)) return binary, char_boxes逻辑说明THRESH_OTSU适合车牌这种前景背景对比明显的场景不用手动调阈值。垂直投影的axis0是按列求和得到的是一个长度为宽度的数组。最小宽度 5 像素是防止把噪点当字符但新能源车牌字符多、间距小这个值要降到 3 左右。常见做法是分割后再做一次归一化把所有字符缩放到 20×20 或 32×32喂给 CNN 时尺寸统一。3. 深度学习字符识别CNN 模型训练与推理定位和分割解决「字符在哪」识别解决「字符是什么」。这个项目用 CNN 做单字符分类输入是分割后的字符小图输出是字符类别。相比端到端 OCR单字符分类的模型小、训练快、可解释性强适合课程设计场景。3.1 数据集组织与标签映射单字符分类的数据集按类别分文件夹每个文件夹名就是标签。常见字符集包括 31 个省份简称、26 个字母、10 个数字共 67 类。数据量每个类别至少 200 张否则模型会偏向样本多的类。import os from torch.utils.data import Dataset from PIL import Image class PlateCharDataset(Dataset): def __init__(self, root_dir, transformNone): self.samples [] self.transform transform # 遍历根目录下每个类别文件夹 for label in sorted(os.listdir(root_dir)): label_dir os.path.join(root_dir, label) if not os.path.isdir(label_dir): continue for fname in os.listdir(label_dir): # 只收图片文件过滤掉 .DS_Store 之类的杂项 if fname.lower().endswith((.png, .jpg, .jpeg)): self.samples.append( (os.path.join(label_dir, fname), label)) # 建立标签到索引的映射排序保证每次运行一致 self.classes sorted(set(s[1] for s in self.samples)) self.class_to_idx {c: i for i, c in enumerate(self.classes)} def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(L) # 转灰度减少通道数 if self.transform: img self.transform(img) return img, self.class_to_idx[label]逻辑说明sorted遍历是为了保证类别顺序稳定否则每次运行标签索引会变模型权重对不上。convert(L)转灰度是因为车牌字符识别不需要颜色信息单通道能减少参数量。class_to_idx要保存下来推理时用索引反查字符。3.2 CNN 网络结构与训练循环网络不用太深三层卷积加两层全连接足够。字符图片小感受野不需要很大池化两次就能覆盖全局。import torch import torch.nn as nn class PlateCNN(nn.Module): def __init__(self, num_classes): super().__init__() self.features nn.Sequential( # 输入 1x32x32第一层卷积输出 32 通道 nn.Conv2d(1, 32, 3, padding1), nn.BatchNorm2d(32), # 加速收敛防止梯度爆炸 nn.ReLU(), nn.MaxPool2d(2), # 32x32 - 16x16 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 16x16 - 8x8 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.AdaptiveAvgPool2d(1) # 全局平均池化输出 128x1x1 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128, 256), nn.ReLU(), nn.Dropout(0.3), # 随机丢 30% 神经元防过拟合 nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))逻辑说明BatchNorm2d放在卷积和激活之间是标准做法能显著加快训练。AdaptiveAvgPool2d(1)把任意尺寸特征图压成 1×1这样输入图片尺寸不固定也能跑。Dropout(0.3)在字符识别这种小数据集上很有必要不然训练集准确率 99%、验证集 70% 是常态。训练时用CrossEntropyLoss加Adam学习率 1e-3跑 30 个 epoch 基本收敛。3.3 推理与结果拼接推理时把分割出的每个字符图依次送进模型取 argmax 得到字符再按顺序拼成车牌号。注意要处理「·」和省份简称的映射。def recognize_plate(model, char_images, class_to_idx, devicecpu): model.eval() idx_to_class {v: k for k, v in class_to_idx.items()} result [] with torch.no_grad(): for img in char_images: # 增加 batch 维度1x1x32x32 tensor img.unsqueeze(0).unsqueeze(0).float() / 255.0 tensor tensor.to(device) logits model(tensor) pred logits.argmax(dim1).item() result.append(idx_to_class[pred]) return .join(result)逻辑说明unsqueeze(0)加 batch 维unsqueeze(0)再加通道维顺序不能反。/255.0归一化到 0 到 1训练时也要做同样的归一化否则推理结果全乱。torch.no_grad()关掉梯度计算推理速度能快一倍。拼接时如果字符集里「·」单独一类直接 join 就行如果没单独类需要在第二个字符后手动插入。4. PyQt 界面与数据库集成从脚本到可交付系统课程设计交一个命令行脚本和交一个带界面的系统评分差距很大。这个项目用 PyQt 做界面用 SQLite 存识别记录整体结构清晰改起来也方便。4.1 PyQt 主窗口布局与信号槽界面分三块图片显示区、按钮区、结果列表区。用QVBoxLayout和QHBoxLayout嵌套不要用绝对定位否则换分辨率就错位。from PyQt5.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QListWidget, QFileDialog) from PyQt5.QtGui import QPixmap class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(车牌识别系统) central QWidget() self.setCentralWidget(central) layout QVBoxLayout(central) # 图片显示标签初始显示提示文字 self.image_label QLabel(请上传车牌图片) self.image_label.setMinimumSize(640, 480) layout.addWidget(self.image_label) # 按钮行 btn_layout QHBoxLayout() self.btn_open QPushButton(选择图片) self.btn_recognize QPushButton(开始识别) btn_layout.addWidget(self.btn_open) btn_layout.addWidget(self.btn_recognize) layout.addLayout(btn_layout) # 结果列表 self.result_list QListWidget() layout.addWidget(self.result_list) # 信号槽连接按钮点击触发对应方法 self.btn_open.clicked.connect(self.open_image) self.btn_recognize.clicked.connect(self.run_recognition) def open_image(self): path, _ QFileDialog.getOpenFileName( self, 选择图片, , Images (*.png *.jpg *.jpeg)) if path: self.current_path path pixmap QPixmap(path) # 缩放时保持宽高比避免图片变形 self.image_label.setPixmap( pixmap.scaled(640, 480, aspectRatioMode1))逻辑说明clicked.connect是 PyQt 的信号槽机制按钮点击自动调方法。scaled的aspectRatioMode1对应Qt.KeepAspectRatio不加这个图片会被拉扁。current_path存当前图片路径识别时用。常见做法是把识别逻辑放单独线程否则大图处理时界面会卡死这是 PyQt 新手最容易翻车的地方。4.2 SQLite 记录识别结果识别结果要落库方便后续查历史。SQLite 不用装服务一个文件搞定适合课程设计。import sqlite3 from datetime import datetime def init_db(db_pathplate.db): conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute( CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, image_path TEXT, created_at TEXT ) ) conn.commit() return conn def save_record(conn, plate_no, image_path): cursor conn.cursor() cursor.execute( INSERT INTO records (plate_no, image_path, created_at) VALUES (?, ?, ?), (plate_no, image_path, datetime.now().strftime(%Y-%m-%d %H:%M:%S))) conn.commit()逻辑说明CREATE TABLE IF NOT EXISTS保证重复运行不报错。?占位符是防 SQL 注入的标准写法别用字符串拼接。created_at存文本格式查询时用ORDER BY created_at DESC就能按时间倒序。数据库连接建议在应用启动时建一次全局复用不要每次插入都开关连接。5. 避坑与排查那些让识别率掉一半的细节这一章全是血泪经验每条都是实际跑的时候踩过的。现象白天识别正常晚上全错。原因夜间图片整体偏暗自适应二值化的blockSize和C值是按白天调的晚上边缘全丢。 解决加一步亮度判断用gray.mean()算平均亮度低于 80 时把blockSize从 15 降到 9C从 -5 调到 -10让更多边缘保留。现象模型训练准确率 99%验证集只有 60%。原因数据集每个类别样本不均衡数字和字母样本多省份简称样本少模型偏向多数类。 解决用WeightedRandomSampler按类别频率给采样权重或者在 loss 里加weight参数让少数类样本的损失权重更大。现象PyQt 界面点「开始识别」后卡死。原因识别逻辑在主线程跑CNN 推理几百毫秒界面事件循环被阻塞。 解决用QThread把识别逻辑放子线程通过信号把结果传回主线程更新 UI。这是 PyQt 的经典坑不处理的话用户体验极差。现象分割出的字符顺序错乱。原因findContours返回的轮廓顺序不保证从左到右直接按返回顺序切会乱。 解决拿到字符框后按x坐标排序sorted(char_boxes, keylambda b: b[0])再依次识别。现象换一台电脑跑模型加载报错。原因训练时用了 GPU保存的权重带cuda设备信息推理机器没 GPU。 解决保存时用torch.save(model.state_dict(), path)只存参数加载时先model PlateCNN(num_classes)再model.load_state_dict(torch.load(path, map_locationcpu))map_location是关键。6. 进阶技巧把单字符识别换成 CRNN 端到端单字符分类的瓶颈在分割——分割错一个字符整个车牌就错了。想再往上走一步可以把「分割 单字符分类」换成 CRNNCNN RNN CTC直接输入整张车牌图输出字符序列省掉分割步骤。我一般会这么改CNN 部分复用上面的features把AdaptiveAvgPool2d去掉输出B×C×H×W的特征图然后按宽度维度展开成序列B×W×C*H送进两层双向 LSTM隐藏层 256最后接全连接映射到字符集大小加 1CTC 的 blank 标签。训练时用CTCLoss输入长度和标签长度都要传blank设为 0。对比项单字符分类CRNN 端到端是否需要分割需要不需要模型参数量约 50 万约 300 万训练数据量每类 200 张整牌 5000 张以上推理速度快但分割耗时单次前向整体更快倾斜车牌鲁棒性差好验证方法很简单拿 100 张没参与训练的整牌图分别跑两套流程统计整牌准确率而不是单字符准确率。单字符 98% 听起来高但 7 个字符全对只有 0.98^7 约 87%整牌准确率才是真实指标。从那以后我每次评估车牌识别模型都强制走一遍整牌准确率不再看单字符指标自欺欺人。希望帮到你。本文还有配套的精品资源点击获取
返回列表