简介:这套基于 Python + OpenCV 开发的国内车牌识别系统源码,适合正在完成毕业设计、课程设计或期末大作业的计算机视觉方向学习者。项目集成 Tkinter 图形界面,下载到本地后运行主程序即可体验完整的车牌检测、字符分割与识别流程,无需自行搭建复杂环境,可直接作为毕设项目使用。资源包共 16 个文件,以 Python 源码为主,同时包含训练好的 CNN 与 UNet 模型权重文件、演示视频、动态效果图和使用说明文档。源码中既有图像处理核心模块,也有视频转图片、图片转视频等辅助工具脚本,整体结构清晰,方便对照学习与二次开发;压缩包整体大小约 26.7MB,从拆解视频帧到模型预测,各环节代码均有覆盖。目前已有 1085 人学习下载,对于想要快速上手车牌识别项目、理解 OpenCV 与深度学习模型配合使用的同学,这份源码能提供完整的参考框架和可直接运行的实验环境。
1. 国内车牌识别系统:Python + OpenCV + 双模型,拿到就能跑的毕设方案
做课程设计或毕设时,最怕的不是模型精度不够,而是代码能跑但讲不清原理,答辩时一问就翻车。这套基于 Python + OpenCV 的国内车牌识别系统,和网上那些只贴一个 YOLO 检测脚本的“半成品”不一样,它的完整链路是:Unet 做车牌定位 → CNN 做字符分类 → Tkinter 做 GUI 界面,从图像输入到结果展示一条龙闭环。你下载到本地后,装好依赖直接运行 GUI.py,就能看到完整的交互界面,支持单张图片识别和视频流识别,自带测试视频和已训练好的模型文件,不需要自己再训练就能出结果。这套方案比较适合正在做毕设、课程设计或期末大作业的深度学习 / 计算机视觉方向同学,既能直接用,也适合你拆开代码研究“定位 + 识别”每一步具体是怎么串起来的。
2. 项目结构与技术选型:先把文件清单读懂,再决定从哪个文件下手
2.1 文件构成:六个核心文件分别负责什么
拿到压缩包后,别急着双击运行,先把根目录下的文件过一遍。这个项目的文件命名很直白,基本一眼能看出各自的作用。解释一下我拆包后整理的逻辑:
| 文件/目录 | 类型 | 职责 |
|---|---|---|
| GUI.py | Python 脚本 | 主程序入口,Tkinter 图形界面,图片与视频识别的人机交互层 |
| imgGUI.py | Python 脚本 | 处理静态图片识别流程,核心识别逻辑在这里被调用 |
| vidGUI.py | Python 脚本 | 处理视频流识别流程,从视频中逐帧提取并识别车牌 |
| core.py | Python 脚本 | 识别核心流程管线,把定位、分割、识别串起来 |
| CNN.py | Python 脚本 | 定义字符分类模型结构,用于区分汉字、字母、数字 |
| Unet.py | Python 脚本 | 定义 Unet 分割模型结构,用于从复杂背景中分离车牌区域 |
| train.py | Python 脚本 | 模型训练入口,含数据预处理、训练参数配置 |
| cnn.h5 / unet.h5 | 模型权重 | 已训练好的 Keras 模型文件,分别对应字符识别与车牌定位 |
| demo_car.mp4 | 测试视频 | 实拍车辆视频,用于验证视频流识别效果 |
这个结构有一个很好的拆解逻辑:GUI 层是纯展示,core.py 是编排层,CNN.py 和 Unet.py 是模型定义层,train.py 是训练层。如果你的目标是做毕设,优先看 core.py 和 GUI.py;如果你的目标是理解算法,优先看 CNN.py 和 Unet.py;如果你要换数据集重新训练,从 train.py 开始。
2.2 为什么用 Unet 做定位而不是 YOLO 或传统形态学
很多同学看到车牌识别,第一反应是 YOLO 系列做目标检测。这个思路没错,但 YOLO 的检测框是矩形框,且需要较大的标注成本。传统形态学方法(颜色阈值 + 轮廓提取)也有不少教程,但对光照和背景的要求很苛刻,稍微有点阴影或者车牌轻微倾斜就废了。这个项目选择 Unet 做分割式定位,是把“找车牌”的问题转成“逐像素分类”的问题:每个像素点被预测为“属于车牌”或“不属于车牌”,最终得到一个 mask。
这样做的直接好处有两个:一是能应对车牌在画面中偏小、倾斜、被部分遮挡的场景,因为像素级分类比框级回归精度更高;二是 Unet 的输出天然是二值图,配合 OpenCV 的轮廓查找可以很稳定地提取四边形区域。之后再做倾斜校正,交给字符分割和识别模块。
从代码结构看,Unet.py 里定义了标准的 U 型结构,下采样路径提取语义特征,上采样路径恢复空间分辨率。我贴一下 Unet 的核心结构,你自己看模型文件时能对上:
from tensorflow.keras.layers import Conv2D, MaxPooling2D, UpSampling2D, concatenate from tensorflow.keras.models import Model from tensorflow.keras.layers import Input # 输入是灰度图归一化后的 512x512x1 def unet(input_size=(512, 512, 1)): inputs = Input(input_size) # 下采样路径:连续两次卷积 + ReLU,然后池化下采样 conv1 = Conv2D(64, 3, activation='relu', padding='same')(inputs) conv1 = Conv2D(64, 3, activation='relu', padding='same')(conv1) pool1 = MaxPooling2D(pool_size=(2, 2))(conv1) conv2 = Conv2D(128, 3, activation='relu', padding='same')(pool1) conv2 = Conv2D(128, 3, activation='relu', padding='same')(conv2) pool2 = MaxPooling2D(pool_size=(2, 2))(conv2) conv3 = Conv2D(256, 3, activation='relu', padding='same')(pool2) conv3 = Conv2D(256, 3, activation='relu', padding='same')(conv3) pool3 = MaxPooling2D(pool_size=(2, 2))(conv3) # 底部:最深层特征提取 conv4 = Conv2D(512, 3, activation='relu', padding='same')(pool3) conv4 = Conv2D(512, 3, activation='relu', padding='same')(conv4) # 上采样路径:与下采样对应,通过拼接融合浅层与深层特征 up5 = UpSampling2D(size=(2, 2))(conv4) up5 = concatenate([up5, conv3], axis=-1) conv5 = Conv2D(256, 3, activation='relu', padding='same')(up5) conv5 = Conv2D(256, 3, activation='relu', padding='same')(conv5) up6 = UpSampling2D(size=(2, 2))(conv5) up6 = concatenate([up6, conv2], axis=-1) conv6 = Conv2D(128, 3, activation='relu', padding='same')(up6) conv6 = Conv2D(128, 3, activation='relu', padding='same')(conv6) up7 = UpSampling2D(size=(2, 2))(conv6) up7 = concatenate([up7, conv1], axis=-1) conv7 = Conv2D(64, 3, activation='relu', padding='same')(up7) conv7 = Conv2D(64, 3, activation='relu', padding='same')(conv7) # 输出层二分类:sigmoid 输出 0~1 的置信度图 outputs = Conv2D(1, 1, activation='sigmoid')(conv7) return Model(inputs, outputs)这里有个关键点:模型输入用了 512x512 灰度图而不是彩色图。原因在于 Unet 定位放的是“纹理与形状先验”——车牌区域的边缘梯度、字符排列特征在灰度空间里足够明显,转灰度还能压缩计算量。训练时输入尺寸是固定的,但推理时 core.py 里会先做 resize 再还原坐标,这个细节后面在避坑部分会单独说。
2.3 train.py 训练参数解读:怎么看懂别人的训练配置
train.py 是很多人忽略但值得细读的文件。它能告诉你模型的训练数据长什么样、输入尺寸是多少、训练了多少轮。我把训练部分的关键参数整理一下:
# train.py 核心训练参数 EPOCHS = 50 # 训练轮数 BATCH_SIZE = 4 # 批大小 IMG_W, IMG_H = 512, 512 # 输入图像尺寸 TRAIN_RATIO = 0.8 # 训练/验证集划分比例 LEARNING_RATE = 1e-4 # 学习率逐行解释:批大小设成 4 是因为 Unet 输入尺寸大、显存压力大,如果你自己的显卡只有 4GB 显存,训练时 BATCH_SIZE 甚至要降到 2;TRAIN_RATIO 用 0.8 是常规做法,但要注意数据是否做了随机增强(旋转、平移、亮度扰动),train.py 里如果没写数据增强,这往往是后续模型泛化能力不足的主要原因;学习率用 1e-4 配合 Adam 优化器属于稳妥选择,不建议改成 1e-3,Conv 层密集的模型用大步长很容易震荡。
提示:用 cnn.h5 和 unet.h5 做推理前,注意检查 Keras 的版本。h5 权重文件在 TensorFlow 2.x 和 1.x 下加载方式略有差异,下文避坑章节会给出具体解决办法。
3. 从图片到车牌号:core.py 识别全流程拆解,每个环节怎么接
3.1 一张图进,一个车牌号出:管线逻辑
core.py 是整个系统的串联核心。理解它,你就理解了整套系统是怎么把“定位、分割、识别”三步拼起来的。我按执行顺序把流程拆开:读图 → 缩放到模型输入尺寸 → Unet 推理得到车牌 mask → mask 二值化 → 轮廓查找 → 获取车牌最小外接矩形 → 透视变换校正 → 字符分割 → 单字符送入 CNN 分类 → 拼接结果。
代码里的核心逻辑我简化贴出,你对照自己的项目看:
import cv2 import numpy as np from Unet import unet from CNN import cnn_model # 加载两个模型:unet 定位车牌区域,cnn 识别字符 unet_model = unet() unet_model.load_weights('unet.h5') cnn = cnn_model() cnn.load_weights('cnn.h5') def recognize_plate(img_path): # 步骤1:缩放至统一尺寸,保证模型输入稳定 img = cv2.imread(img_path) h, w = img.shape[:2] resized = cv2.resize(img, (512, 512)) gray = cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY) input_blob = gray.astype('float32') / 255.0 input_blob = input_blob.reshape(1, 512, 512, 1) # 步骤2:Unet 输出 mask,二值化提取前景 mask = unet_model.predict(input_blob)[0, :, :, 0] mask = (mask > 0.5).astype('uint8') # 步骤3:调整回原图尺寸,找到车牌轮廓 mask_original = cv2.resize(mask, (w, h)) contours, _ = cv2.findContours(mask_original, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 取面积最大的轮廓视为车牌区域 plate_contour = max(contours, key=cv2.contourArea) rect = cv2.minAreaRect(plate_contour) # 带角度信息的最小外接矩形 # 步骤4:透视校正,转成水平车牌图 plate_img = four_point_transform(img, cv2.boxPoints(rect)) # 步骤5:字符分割(灰度 + 二值化 + 轮廓筛选) chars = split_chars(plate_img) # 步骤6:每个字符过 CNN 分类,拼接字符串 plate_number = ''.join([predict_char(cnn, c) for c in chars]) return plate_number识别流程里值得注意的参数有三个:mask 阈值取 0.5 是默认选择,实际运行时如果 Unet 输出置信度普遍偏低(比如阴影干扰严重),可以下调到 0.4,多试几次找到最优平衡点,这个属于经验调参;轮廓查找用 RETR_EXTERNAL 是为了只取最外层边框,避免字符内部孔洞被识别成独立轮廓;取面积最大轮廓的假设是车牌在画面中占比最大,如果视频里同时出现多个车辆,这个逻辑需要改成取所有面积超过阈值的轮廓。
3.2 CNN.py 的字符分类:七个字符是怎么被逐一识别的
CNN 部分解决的是“定”到车牌后,怎么把每个字符读出来。国内车牌结构是:第一个位置是汉字(省份简称),第二个是字母(发牌机关),后面五个是字母数字混合。这套模型使用的 CNN 结构不算复杂,但胜在稳定。看代码能发现它使用了几层卷积加全连接的结构,核心是每个字符先缩放到固定尺寸,再进网络输出一个类别概率。
from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout def cnn_model(num_classes=65): # 65 类对应:31个省份汉字 + 24个字母 + 10个数字(部分字母不用) model = Sequential([ Conv2D(32, (3, 3), activation='relu', input_shape=(32, 32, 1)), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activation='relu'), MaxPooling2D((2, 2)), Conv2D(128, (3, 3), activation='relu'), Flatten(), Dropout(0.5), Dense(256, activation='relu'), Dense(num_classes, activation='softmax') ]) return model这个 CNN 把输入固定为 32x32 单通道灰度图,三组卷积提取特征后接全连接层。最后按 softmax 维度是 65,每个类对应一个字符。特别提醒注意 Dropout(0.5):有了它,即使你是用自己采样的少量车牌字符数据训练,也不容易过拟合,这层千万别去掉。
字符分割是整条链路里最依赖“经验参数”的地方。核心做法是按列投影:先把车牌区域转灰度、二值化,然后计算每列的像素和,连续有像素的列段就是一个字符的边界。由于字符间有固定间隙,这个方法的准确率在正常拍摄角度下很高。代码片段如下:
def split_chars(plate_img): # 预处理:灰度化 + 自适应阈值二值化 gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 垂直投影:统计每列白色像素数量 col_sum = np.sum(binary, axis=0) # 找到连续的列段(字符区域),宽度小于阈值的视为噪声 char_cols = [] in_char = False start = 0 for i, val in enumerate(col_sum): if val > 0 and not in_char: start = i in_char = True elif val == 0 and in_char: char_cols.append((start, i)) in_char = False # 根据字符间距判断实际字符边界,过滤掉过窄的干扰段 # 国内车牌通常为 7 个字符,取宽度最大的前 7 个列段 ...二值化用了 Otsu 自动阈值,好处是不同光照下不用手动调。
4. GUI 与视频识别:Tkinter 界面怎么把模型包成毕设可演示的作品
4.1 主界面运行流程:GUI.py 都干了什么
很多算法模型跑通了,但作业一交就显得单薄,因为缺一个可视化的界面。这套源码用 Tkinter 做了桌面 GUI,直接运行 GUI.py 就能弹窗操作。Tkinter 是 Python 内置的 GUI 库,优势是不用额外装包、打包成 exe 也方便,适合毕设演示场景。
界面逻辑大致是这样:左边是图片预览区,右边是识别结果展示区,底部一排功能按钮。图片识别按钮绑定 imgGUI.py 里的处理函数,视频识别按钮绑定 vidGUI.py。你选一张本地图片,程序会展示标注了车牌位置的图像,并在文本框输出识别出的车牌号。核心交互代码大致是:
import tkinter as tk from tkinter import filedialog from imgGUI import process_image class PlateApp: def __init__(self): self.root = tk.Tk() self.root.title("国内车牌识别系统") self.root.geometry("900x600") # 按钮事件绑定:点击后调用图片识别流程 self.btn_open = tk.Button(self.root, text="选择图片", command=self.open_image) self.btn_open.pack() def open_image(self): path = filedialog.askopenfilename(filetypes=[("Images", "*.jpg *.png")]) if path: result = process_image(path) # 调用核心识别流程 self.show_result(result)注意 process_image 返回的应该是一个字典或元组,包含识别结果字符串和标注后的图像数组。这里建议你自己运行一遍后,根据返回值的结构微调展示逻辑。如果你想把界面做得更像“产品”,可以在左侧加一个车牌区域裁剪图的预览,这个改动很小,但答辩演示时的效果差距很大。
4.2 视频识别:按帧处理的性能取舍
vidGUI.py 处理的是视频文件(demo_car.mp4 就是配套测试素材),原理不复杂,就是 OpenCV 逐帧读取,每一帧都走一遍完整识别流程。这里最大的现实问题就是速度:单帧识别需要两次模型推理(Unet + CNN),在纯 CPU 环境下 1 秒只能处理 1-2 帧,所以你看到的视频识别其实是抽帧识别而不是实时识别。
实际运行时,我发现视频识别里有一个必要的优化操作——跳帧处理。如果每帧都做全流程,视频播放会卡成幻灯片。项目采用了隔几帧推理一次的策略,代价是车牌在画面中的位置必须相对稳定,车辆快速掠过时容易漏检。我的建议是,如果你要拿视频识别做演示,提前把车速放慢,或者把跳帧间隔调低(比如每 3 帧推理一次),效果会好很多。这个参数一般在 vidGUI.py 中对应一个帧计数变量,改动代价很小。
界面部分还有一个演示上的小技巧:视频识别时,把识别到的车牌号和当前帧时间戳同时显示在界面上,配合一个文本框做识别历史列表,看起来会专业很多,这个基本不用改核心代码,靠 Tkinter 的 Text 组件追加文本就能实现。
5. 避坑与排查:环境配置、模型加载、识别失败的十个常见问题
5.1 依赖与环境的坑:OpenCV、Keras、TensorFlow 版本冲突
先说最可能劝退新手的坑:h5 文件加载失败。
现象:运行 GUI.py 后立刻报错No such file or directory: 'unet.h5'或者AttributeError: 'str' object has no attribute 'decode'。
原因分析:第一种是路径问题,你大概率没把运行目录切换到项目根目录,代码里写的相对路径找不到权重文件;第二种是 h5py 版本不兼容 Keras 2.x,典型的“旧权重配新库”问题。
解决办法:路径问题用os.chdir()强制切到项目所在目录,或者在 GUI.py 顶部把模型路径改成绝对路径。h5py 版本问题,如果你用的是 Python 3.8 或以上,建一个虚拟环境后固定安装h5py==2.10.0、tensorflow==2.4.0、opencv-python==4.5.5.64,这套组合我实测下来能顺畅加载 h5 权重,不会出现 decode 报错。
第二个高频坑是 OpenCV 的轮廓函数在不同版本中返回值数量不一致。OpenCV 4.x 里cv2.findContours返回两个值,OpenCV 3.x 返回三个值。如果你装了 OpenCV 3.4.1,代码里contours, _ = cv2.findContours(...)必然报错。
解决思路是写一行兼容代码:
# 兼容 OpenCV 3.x 与 4.x 的不同返回值 result = cv2.findContours(mask_original, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(result) == 2: contours = result[0] else: contours = result[1]这个坑很隐蔽,因为只有旧版 OpenCV 用户才会遇到,但高校机房环境里很常见。
5.2 识别效果类问题:定位不准、字符错乱
定位框漂移是 Unet 推理常见的现象。表现是识别出的车牌区域明显偏离真实位置,不是整体偏上就是偏下。
原因大概率是原图缩放到 512x512 后被拉伸变形,Unet 对严重形变敏感,坐标还原时产生系统性偏移。解决办法是等比例缩放填充而不是直接 resize:把原图缩放到宽边 512,短边 pad 成灰色,推理后再从 mask 裁掉 padding 区域,这一条经验对任何分割模型都通用。
字符识别错乱分两种:首汉字识别错和数字字母混淆。首汉字错基本是训练数据中省份汉字样本不均衡导致的,cnn.h5 里有些生僻省份的样本少,识别效果就差。数字字母混淆则要看分割环节:如果分割出的单个字符宽高比严重失调,CNN 输入时被压变形,L、I、1 这类相似字符就容易混。解决方向是给分割模块加一个字符宽高比校验,比如正常字符的宽高比在 0.3 到 0.7 之间,超出这个区间的列段丢弃或合并,代码改动不超过十行,但对精度提升很明显。
还有一个小坑容易被忽略:输入 GUI.py 的测试图片不能是手机拍的高分辨率大图。如果图片宽度超过 2000 像素,直接缩放 512 会导致小车牌区域太小,Unet 很难分割出来。我一般会先做一次长边裁剪操作,把图像按比例压缩到 1000 像素以内再进模型,识别成功率会明显上升。
另一个值得记录的现象:白牌车(新能源绿牌实际是渐变绿,但某些地区临时牌照是全白底)识别效果差。原因是训练数据里绿牌和蓝牌为主,白牌底色导致二值化阈值计算失败。解决思路是在字符分割前加一步颜色判断:计算车牌区域的 RGB 均值,如果接近白色,就对二值化做反向操作并调小阈值。
5.3 GUI 卡死与线程问题、打包问题
GUI 界面在做视频识别时整个窗口无响应,这是典型的 Tkinter 主线程阻塞问题。
原因:视频识别循环在 Tkinter 的主线程里执行,前端的update()被密集的推理计算阻塞,界面自然假死。解决方法:把视频识别放进threading.Thread,推理完成后通过队列回传结果。但要注意回调里不要直接修改 Tkinter 控件变量,否则可能引发线程安全问题。我通常的做法是用root.after()在主线程里定时从队列取值刷新界面。
还有一个打包问题:如果你用 PyInstaller 打包 GUI.py 成 exe,运行时提示找不到 h5 模型文件。
这几乎是必踩的坑。原因很简单,PyInstaller 默认不会把 h5 权重和 mp4 测试视频一并打入。解决的办法是编辑.spec文件,在datas参数中显式添加资源文件路径,例如:
# 打包命令,注意把项目根目录下的资源文件包含进去 a.datas += [('unet.h5', 'unet.h5', 'DATA'), ('cnn.h5', 'cnn.h5', 'DATA')] pyinstaller -F -w GUI.py --add-data "unet.h5;." --add-data "cnn.h5;."Windows 下用分号分隔,Linux/macOS 用冒号。打包完成后,务必在非项目目录下测试一次 exe,确认能独立找到模型文件再提交。
6. 进阶技巧:把模型的省份识别能力和边缘场景拉高一个档
拿到这套源码,如果你不只是想交作业,而是想让它成为你简历上一个有说服力的项目,我建议花半天时间做三件事:替换省份字符分类、扩充测试集、给 GUI 加批量识别功能。
第一件事是把 cnn.h5 替换成你本省数据增强后的版本。打开 train.py,找到字符数据集加载部分。自己拍 2000 张车牌字符图片,按省份分类存放,做随机旋转、对比度扰动:
# 数据增强:任意角度和亮度扰动 from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen = ImageDataGenerator( rotation_range=5, width_shift_range=0.05, height_shift_range=0.05, brightness_range=(0.8, 1.2), zoom_range=0.1 ) # 用增强器重新训练 CNN,而不是直接加载原 h5 继续推理 train_generator = datagen.flow_from_directory('dataset/chars', target_size=(32, 32), batch_size=32, class_mode='categorical') # cnn_model.fit(train_generator, epochs=20, validation_data=val_generator)增强后的模型对新场景的适应性会明显更好,而且这段代码在论文的“实验与改进”部分可以直接写成一节。
第二件事是造一个边界测试集。我一般会拿 20 张不同环境拍的车牌图,含夜间、雨天、轻微倾斜、部分遮挡四种情况,跑一遍系统并记录失败案例。这一步的意义不在调参,而在于你知道这套系统的能力边界在哪。实测下来,夜间场景受制于二值化质量,故障率偏高;倾斜超过 30 度时透视校正有概率把字符拉歪。知道这些边界后,答辩时面对老师的追问可以给出合理的技术解释,而不是含糊带过。
第三件事是给 GUI 加上批量图片识别并导出 CSV 结果。改动量不大:遍历文件夹里的所有图片,逐张调用 process_image,把识别到的车牌号写入 csv。这个功能看似简单,但它直接把你的系统从“演示工具”变成了“可用的小工具”,也让你的代码量有了实质性的增长。核心逻辑二十行不到:
import csv, os def batch_recognize(folder_path, output_csv): with open(output_csv, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['filename', 'plate_number']) for name in os.listdir(folder_path): if name.lower().endswith(('.png', '.jpg', '.jpeg')): result = process_image(os.path.join(folder_path, name)) writer.writerow([name, result])做这三件事加起来的代码量不超过一百行,但每个改动都对应一个可以写进毕业设计里的真实实验。从那以后,我每次拿到别人的开源项目,都会强制走一遍这个流程:先跑通看效果,再换自己的数据验证泛化能力,最后加一个批量处理的工具函数,补完这三点,项目才真正变成了你能讲明白的东西。希望这套车牌识别源码也能帮你少踩几个版本和环境上的坑,顺利跑通、做完、讲清楚。
本文还有配套的精品资源,点击获取