Python+OpenCV+MySQL实现智能停车管理系统:车牌识别与计费实战
2026/9/17 1:17:52 网站建设 项目流程

简介:这是一份面向Python初学者及K12学生的期末项目源码,实现带GUI的智能停车管理系统,模拟车辆入场、出场全流程。项目以车牌识别为主线,结合OpenCV进行图像处理识别车牌,用MySQL存储入场、出场与计费信息,并通过pygame搭建交互窗体。代码遵循软件工程流程,从需求分析、概要设计到实现与测试均有覆盖,配合介绍文档可快速理解整体架构。压缩包内含12个文件,其中8个Python脚本分别对应主程序、数据库、计费规则、车牌识别等模块,另有3个keep占位文件与1个说明文档,整体仅17KB,结构精炼便于阅读。适合作为课程设计参考,能学习如何将目标检测、OCR识别与MySQL业务逻辑整合到实际系统中。已有760人学习下载,对想用Python完成停车场景模拟的入门者有借鉴价值。

1. 为什么我用Python重写了一个智能停车管理系统

期末作业要交一个能演示的“智能停车管理系统”,但网上大部分源码要么只做了界面,要么车牌识别是写死的字符串。我直接用Python + OpenCV + MySQL + pygame把这套模拟出入场流程完整跑通:摄像头(或图片)拍到车牌,识别出车牌号,入场时写入数据库,出场时自动算费并更新车位。这套东西不算大,但把图像识别、数据库事务、GUI事件循环三个方向全串起来了,对想在简历上写一个完整项目的读者很有参考价值。本文从选型到实现细节都给出可复现的代码和参数,重点说清楚为什么这么设计,以及实际调试会踩到什么坑。

2. 系统拆解:OpenCV车牌识别 + MySQL存储 + pygame窗体的选型逻辑

2.1 技术栈选型:为什么要用OpenCV而不是直接调云API

车牌识别的主流路径有两条:一条是直接调云平台的车牌识别API,识别率高但需要联网和付费;另一条是本地用OpenCV做图像处理,配合YOLO检测车牌区域、OCR识别字符。我选择后者,原因很直接:期末作业要求展示“技术准备”,本地方案能讲清楚每个环节,而且不受网络限制。OpenCV在图像预处理阶段不可替代——灰度化、边缘检测、轮廓提取这些操作是YOLO输入的基础。YOLO负责从复杂背景中框出车牌位置,OCR再对框内字符做序列识别。三者组合,即使模拟场景下光照不均、车牌倾斜,也能保证一定的鲁棒性。

需要说明的是,完整训练一个车牌检测模型需要大量标注数据,对学生项目不现实。常见做法是使用预训练权重(如YOLOv5s)微调,或者退一步用OpenCV的形态学操作定位车牌颜色和轮廓。我在这个项目里采用了后者作为兜底,YOLO作为增强模块——先尝试OpenCV定位,若置信度不足再走YOLO。这样既保留了深度学习的技术点,又不至于让训练成本拖垮进度。

2.2 数据库表结构设计:入场信息、计费信息、车位信息

MySQL在这里不只是存数据,更重要的是通过事务保证出入场记录的一致性。我设计了四张核心表,下面给出建表SQL。注意字符集必须是utf8mb4,否则车牌的汉字(如“京”“沪”)会乱码。

CREATE DATABASE parking_db DEFAULT CHARACTER SET utf8mb4; CREATE TABLE vehicle ( id INT AUTO_INCREMENT PRIMARY KEY, plate VARCHAR(10) NOT NULL UNIQUE, owner_name VARCHAR(50), phone VARCHAR(20) ); CREATE TABLE entry_record ( id INT AUTO_INCREMENT PRIMARY KEY, plate VARCHAR(10) NOT NULL, entry_time DATETIME NOT NULL, entry_image_path VARCHAR(255), status TINYINT DEFAULT 1 COMMENT '1=在场, 0=已出场' ); CREATE TABLE billing ( id INT AUTO_INCREMENT PRIMARY KEY, entry_record_id INT NOT NULL, exit_time DATETIME NOT NULL, duration_minutes INT NOT NULL, amount DECIMAL(6,2) NOT NULL, FOREIGN KEY (entry_record_id) REFERENCES entry_record(id) ); CREATE TABLE parking_space ( space_id INT PRIMARY KEY, is_occupied TINYINT DEFAULT 0 COMMENT '0=空闲, 1=占用', current_plate VARCHAR(10) );

entry_record中的status字段是关键。查询“最近一次入场信息”时,直接WHERE plate = ? AND status = 1 ORDER BY entry_time DESC LIMIT 1,避免车辆多次进出后取错记录。parking_space表需要提前初始化若干车位编号,每次入场时找一个is_occupied = 0的位子分配出去,出场时再释放。

2.3 项目目录结构与模块职责

原始压缩包里的结构已经给出了模块划分:main.py是入口,gui.py放pygame窗体,train.py用于模型训练(或加载权重),license_plate.py封装车牌识别,billing.py是计费逻辑,database.py负责MySQL连接。我的建议是保持这个划分,然后增加一个config.py统一存放摄像头索引、MySQL连接参数、费用规则等常量。

src/ ├── main.py # 程序入口,启动GUI ├── gui.py # pygame窗体,按钮与事件 ├── database.py # MySQL连接与CRUD ├── license_plate.py # OpenCV + YOLO + OCR车牌识别 ├── billing.py # 计费金额计算 ├── train.py # 训练/加载YOLO权重 ├── config.py # 全局配置参数 └── test_image/ # 测试图片,模拟摄像头抓拍

模块之间单向依赖:main.py调用gui.pygui.py在按钮回调里调用license_plate.pydatabase.pybilling.py纯计算不依赖UI。这样后期替换识别引擎或数据库都只动单点。我在重构时把数据库操作全部收敛到database.py,避免在GUI逻辑里直接拼SQL。

3. 车牌识别与出入场状态机:从摄像头到数据库记录

3.1 车牌定位与字符识别:YOLO + OCR的流程

车牌识别不是单一算法,而是一条流水线。我实现的LicensePlateRecognizer类包含三个阶段:先用OpenCV做候选区域检测,再用YOLO验证并矫正,最后用OCR输出文本。第一阶段代码如下。

import cv2 import numpy as np def locate_plate_candidates(image): # 转灰度,去噪,突出边缘 gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) gray = cv2.bilateralFilter(gray, 11, 17, 17) edged = cv2.Canny(gray, 30, 200) # 轮廓检测,按面积和宽高比筛选车牌区域 contours, _ = cv2.findContours(edged, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) aspect_ratio = w / h # 标准车牌宽高比约为 3.0~4.5,面积需大于给定阈值 if 3.0 < aspect_ratio < 4.5 and w > 60 and h > 20: candidates.append((x, y, w, h)) return candidates

bilateralFilter在去噪的同时保留边缘,比高斯模糊更适合车牌这种边缘密集区域。Canny阈值(30, 200)是我试出来的经验值——背景简单时可以用更低的低阈值(20)以召回更多候选框,背景杂乱时则提高低阈值避免误检。宽高比筛选是OpenCV方案的精髓,因为车牌本身比例固定,能一次过滤掉大量非车牌轮廓。

拿到候选区域后,把每个候选框裁剪出来,送入YOLO模型确认是否是车牌,并输出四角坐标做透视矫正。OCR部分我使用PaddleOCR,它内置了车牌字符模型,识别中文和蓝牌效果比Tesseract好得多。如果环境不允许安装深度学习框架,可以退化为模板匹配,但效果会明显下降。

3.2 入场流程:识别-写入-更新车位

入场流程的状态机只有三步,但每一步都有事务性要求。来看核心代码。

# database.py import pymysql from datetime import datetime def insert_entry_record(plate, entry_time=None): conn = pymysql.connect(host='localhost', user='root', password='123456', database='parking_db', charset='utf8mb4') cursor = conn.cursor() if entry_time is None: entry_time = datetime.now() try: # 查找空闲车位 cursor.execute( "SELECT space_id FROM parking_space WHERE is_occupied=0 LIMIT 1") space = cursor.fetchone() if not space: return {"error": "车位已满"} # 写入入场记录 cursor.execute( "INSERT INTO entry_record (plate, entry_time, entry_image_path) " "VALUES (%s, %s, %s)", (plate, entry_time, f"images/{plate}_{entry_time:%H%M%S}.jpg")) record_id = cursor.lastrowid # 更新车位状态 cursor.execute( "UPDATE parking_space SET is_occupied=1, current_plate=%s " "WHERE space_id=%s", (plate, space[0])) conn.commit() return {"record_id": record_id, "space_id": space[0]} except Exception as e: conn.rollback() return {"error": str(e)} finally: cursor.close() conn.close()

这里我刻意把三步操作放在同一个事务里:插入入场记录、查找空闲车位、更新车位状态。如果不使用事务,可能会出现“入场记录写入了,但车位没更新”这种脏数据。lastrowid用来获取刚插入记录的ID,后续出场时直接关联。入场图片路径我按车牌_时间戳.jpg命名,方便事后核对。

3.3 出场流程:识别-查最近记录-计费-更新状态

出场逻辑更复杂,因为要先查出有效入场记录,再计算费用,最后同步更新入场记录状态和车位状态。下面这段代码是出场处理的核心。

def process_exit(plate, exit_time=None): conn = pymysql.connect(host='localhost', user='root', password='123456', database='parking_db', charset='utf8mb4') cursor = conn.cursor() if exit_time is None: exit_time = datetime.now() try: # 查最近一条未出场的记录 cursor.execute( "SELECT id, entry_time FROM entry_record " "WHERE plate=%s AND status=1 " "ORDER BY entry_time DESC LIMIT 1", (plate,)) rec = cursor.fetchone() if not rec: return {"error": "未找到入场记录"} record_id, entry_time = rec duration = (exit_time - entry_time).total_seconds() / 60 amount = compute_fee(duration) # 计费逻辑见第4章 cursor.execute( "INSERT INTO billing (entry_record_id, exit_time, " "duration_minutes, amount) VALUES (%s, %s, %s, %s)", (record_id, exit_time, int(duration), amount)) cursor.execute( "UPDATE entry_record SET status=0 WHERE id=%s", (record_id,)) # 释放车位:找到当前占用该车牌的车位 cursor.execute( "UPDATE parking_space SET is_occupied=0, current_plate=NULL " "WHERE current_plate=%s", (plate,)) conn.commit() return {"duration_minutes": int(duration), "amount": amount} except Exception as e: conn.rollback() return {"error": str(e)} finally: cursor.close() conn.close()

查询最近入场记录时用了status=1条件,这是关键。如果车辆之前在系统里有过多次入场,但没有出场标记,ORDER BY entry_time DESC能确保取到的是最近一次。如果一辆车重复入场(前一次未出场又入场),需要业务上做拦截——我一般在入场时也检查status=1的记录是否存在,存在则拒绝入场,提示“车辆已在场内”。

4. 计费规则与GUI事件循环:让模拟出入场真正跑起来

4.1 计费算法:按时段还是按次?我实现的阶梯定价

计费规则是业务逻辑里最容易被低估的部分。很多课设只做“首小时10元,每小时5元”的简单线性计算,但这会导致超时边界溢出问题。我实现的是阶梯定价:前30分钟免费,超过30分钟不满2小时收费5元,之后每小时加收3元,24小时封顶30元。

# billing.py import math def compute_fee(duration_minutes: float) -> float: FREE_MINUTES = 30 BASE_FEE = 5.0 BASE_HOURS = 2 HOURLY_RATE = 3.0 DAILY_CAP = 30.0 if duration_minutes <= FREE_MINUTES: return 0.0 # 向上取整到小时,避免时间跳表 billable_hours = math.ceil((duration_minutes - FREE_MINUTES) / 60) if duration_minutes <= BASE_HOURS * 60: fee = BASE_FEE else: extra_hours = billable_hours - BASE_HOURS fee = BASE_FEE + max(0, extra_hours) * HOURLY_RATE return min(fee, DAILY_CAP)

注意billable_hours向上取整。如果停留90分钟,duration - 30 = 60,取整为1小时,超过2小时边界吗?没有,所以收费5元。如果停留121分钟,duration - 30 = 91,向上取整为2小时,超过2小时边界,收费5 + 2*3 = 11元?不对——实际上只有1分钟是超过2小时的,但向上取整让这1分钟按整小时计费,这是停车行业常见做法,但要在注释里写明。另一种更公平的做法是精确到秒,用math.floor配合每分钟费率,但这里从业务演示角度选择了整小时阶梯。

4.2 pygame窗体与业务逻辑解耦

pygame在这里不是做游戏,而是提供一个带按钮和反馈区域的窗体。我实现了一个简单的Tkinter替代方案?不,题目要求pygame,那就用pygame。GUI模块的核心是一个主循环,监听鼠标点击事件,按按钮位置分发到不同的业务函数。

# gui.py import pygame from license_plate import recognize_from_camera from database import insert_entry_record, process_exit class ParkingApp: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((800, 600)) self.font = pygame.font.SysFont("microsoftyahei", 24) self.result_text = "" def draw_buttons(self): # 简单按钮区域:入场(左半屏),出场(右半屏) pygame.draw.rect(self.screen, (100, 200, 100), (50, 300, 300, 80)) pygame.draw.rect(self.screen, (200, 100, 100), (450, 300, 300, 80)) self.screen.blit(self.font.render("入场识别", True, (255,255,255)), (150, 330)) self.screen.blit(self.font.render("出场识别", True, (255,255,255)), (550, 330)) pygame.display.update() def handle_click(self, x, y): # 模拟摄像头抓拍,改为读测试图片 if 50 <= x <= 350 and 300 <= y <= 380: plate = recognize_from_camera("test_image/car_in.jpg") result = insert_entry_record(plate) self.result_text = f"入场成功: {plate}" if "record_id" in result else result["error"] elif 450 <= x <= 750 and 300 <= y <= 380: plate = recognize_from_camera("test_image/car_out.jpg") result = process_exit(plate) if "amount" in result: self.result_text = f"出场: {plate} 费用 {result['amount']}元" else: self.result_text = result["error"] def run(self): while True: for event in pygame.event.get(): if event.type == pygame.QUIT: pygame.quit() return if event.type == pygame.MOUSEBUTTONDOWN: self.handle_click(*event.pos) self.screen.fill((240, 240, 240)) self.draw_buttons() # 显示结果文本 label = self.font.render(self.result_text, True, (0, 0, 0)) self.screen.blit(label, (50, 500)) pygame.display.flip()

这个例子把业务函数直接写在回调里,虽然简化了代码,但存在一个隐患:recognize_from_camera是耗时操作,如果摄像头识别的等待时间太长,pygame主循环会被阻塞,窗口显示“未响应”。我在后面的排错章节会专门讲怎么解决这个问题。按钮区域用硬编码坐标并不优雅,但演示项目里完全可以接受,调试时我用一个字典维护按钮矩形,方便调整布局。

4.3 联调:按钮触发识别,模拟摄像头输入

实际摄像头调用在部分电脑上经常找不到设备或权限不足。我在开发时做了一个开关:配置项里把camera_index设为-1,这时从test_image目录读取图片代替实时视频流。

# license_plate.py import cv2 def recognize_from_camera(source): if isinstance(source, int): cap = cv2.VideoCapture(source) ret, frame = cap.read() cap.release() else: frame = cv2.imread(source) # frame 送入车牌识别流水线,见3.1 plate_text = plate_recognition_pipeline(frame) return plate_text

source可以是整数(摄像头索引)或字符串(图片路径)。这个设计让我在教室演示时不用依赖USB摄像头,只需要把拍好的车牌图片放进test_image即可。如果使用实时摄像头,记得在gui.py初始化时打开摄像头,而不是每次点击按钮都打开——频繁获取摄像头句柄会导致资源冲突。

5. 深入排错与验证:当车牌识别不准、数据库乱码、窗口卡死时

5.1 车牌识别准确率提升:预处理参数调节

识别不准是这套系统最常被问的问题。我排查顺序是先看OpenCV有没有框出候选区域,再看OCR文本是否错误。OpenCV没框出,通常是因为Canny低阈值太高或宽高比过滤太严格。把低阈值从30降到15,并把宽高比范围放宽到2.55.0,会召回更多候选框,代价是多几次YOLO推理。如果框出来了但OCR乱识别,一个关键技巧是在裁剪后做二值化:

# license_plate.py 局部 plate_region = frame[y:y+h, x:x+w] gray_plate = cv2.cvtColor(plate_region, cv2.COLOR_BGR2GRAY) # 自适应阈值处理光照不均 binary_plate = cv2.adaptiveThreshold( gray_plate, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 31, 10)

adaptiveThresholdblockSize(31)必须为奇数,C值(10)控制敏感度。C越大,阈值越保守(更多像素被判为背景)。对蓝底白字车牌,C值我一般设5~15之间;对夜间反光严重的图片,还需要先做直方图均衡化。OCR前用cv2.resize把字符高度统一到60像素以上,能显著提高PaddleOCR的准确率。

5.2 MySQL中文乱码与连接池设置

乱码的根源通常是连接字符集没指定。我见过太多同学只在建库时设了utf8mb4,但连接时不写charset,导致写入正常、查询乱码。更彻底的方案是在pymysql.connect里显式加上charset='utf8mb4',并且连接对象创建后立即执行SET NAMES utf8mb4。另外,每次操作都新建连接开销很大,我一般会使用DBUtils.PooledDB维护连接池,避免频繁握手。

from dbutils.pooled_db import PooledDB pool = PooledDB(creator=pymysql, maxconnections=10, host='localhost', user='root', password='123456', database='parking_db', charset='utf8mb4') conn = pool.connection()

注意dbutils的包名从旧版的DBUtils改成了dbutils.pooled_db,新版Python环境容易踩坑。连接池设置maxconnections=10足够这个演示项目用,如果识别和GUI每次操作都申请连接,池化后性能提升明显,也避免数据库连接数被占用耗尽。

5.3 pygame窗体刷新与主循环冲突的处理

pygame主循环里直接调用车牌识别,会导致点击按钮后界面卡住,因为识别时间可能达到几百毫秒甚至数秒。解决思路是把耗时任务放到线程中执行,主循环只负责接收线程返回结果。我封装了一个简单的threading.Thread回调机制:

import threading def async_task(func, args=(), callback=None): def run(): result = func(*args) if callback: callback(result) thread = threading.Thread(target=run) thread.daemon = True thread.start()

handle_click中调用async_task(recognize_from_camera, args=(source,), callback=self.show_result)。注意回调函数里不能直接操作pygame的Surface对象,因为事件循环和线程可能同时绘制。我的做法是让回调只把结果字符串存到self.result_text,由主循环每帧统一渲染。这样即使用户连续点击按钮,也不会造成界面假死。

验证整个系统是否可用的方法很简单:准备两张不同车牌的图片,先点“入场识别”,再点“出场识别”,看数据库billing表是否多了一条记录,parking_space表车位是否从占用变为空闲。再用mysql命令行查询entry_record中该车牌的最新记录status是否为0。如果状态对不上,优先检查事务提交是否在异常时被执行了回滚。把这些调试命令写成一个test_system.sql脚本,每次改动后自动跑一遍,比在GUI里手动测试高效得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询