简介:一份基于Python的智能停车场车牌识别计费系统源码包,面向有一定Python基础、希望学习车牌识别与小型计费系统开发的学习者,也适合课程设计或毕业设计参考。系统借助百度AI开放平台图像识别能力,实现车牌识别、车辆出入场判断、收入统计柱状图展示和满员预警等核心功能,描述中已写明Key配置与运行步骤,可快速启动。压缩包共2003个文件,约189.59MB,以1777个py源码为主,辅以pyc编译文件、txt与doc说明文档、html/css/js前端文件、xml/json配置文件等,集齐程序源码、依赖与使用资料,整体组织较清晰。已有247人学习下载。通过阅读和运行源码可掌握百度API对接、界面交互设计、数据统计可视化以及基于历史记录的预警逻辑;附带说明文档方便部署调试,也适合二次开发和功能扩展。
1. 智能停车场车牌识别计费系统:一条从识别到计费的完整闭环
智能停车场车牌识别计费系统,最花时间的往往不是车牌识别模型,而是计费规则和异常订单处理。这套 Python 源码把摄像头抽帧、车牌识别、按时长计费、MySQL 订单存储串成了完整闭环,跑通之后不是只看到一张识别结果图,而是能直接生成一笔可核对金额的停车订单,这是它比单纯的“车牌识别 demo”值钱的地方。
它适合三类人。正在做课设或毕设的学生,需要演示一套完整业务链而不是孤立的算法;想快速给物业或停车场运营方搭建原型的开发者,这套代码可以直接改配置接真实摄像头;以及有真实计费需求、想少排一个人工收费员的运营者。源码本身不依赖重型框架,识别端用 OpenCV 加 HyperLPR,业务端用原生 Python,数据落在 MySQL,整体结构清楚,改起来不费劲。下面按识别、计费、存储、避坑、上线验证这个顺序拆,每个环节都给出可复现的参数和代码。
2. 车牌识别模块:HyperLPR 选型理由与置信度阈值调优
2.1 为什么没有从零训练:HyperLPR 面向国内车牌的先天优势
车牌识别这个问题,很多人上来就想训 YOLO,但实际项目里这是性价比最低的做法。停车场场景的摄像头位置固定、角度固定,车辆过闸速度也就 5 到 10 公里每小时,根本不需要一个通用目标检测器去满图找车牌。真正难的是车牌字符的结构识别:省份汉字、字母、数字混合,加上蓝牌 7 位、新能源绿牌 8 位、黄牌单双层、警车白牌这些模板差异,自己从零训练得准备几千张带标注的车牌图,分类器还得单独处理汉字,这个数据量和标注成本对个人开发者来说非常不划算。
HyperLPR 是专门针对国内车牌训练的识别库,检测和字符识别是一体的,直接用就行。我一般把它和 PaddleOCR 做对比:PaddleOCR 是通用 OCR,确实能读出图里的文字,但它不做车牌结构校验,识别出“鲁 A12345”和识别出“鲁A12345”中间有没有空格、是不是车牌格式,都要你自己再写一层规则去兜。HyperLPR 的输出天然带车牌框和置信度,省掉了这层胶水代码。当然,如果停车场是多车道、大广角、车辆斜插进画面,单靠 HyperLPR 的检测会吃力,这时常见做法是前面加一层 YOLO 专门定位车牌区域,裁出来再喂给 HyperLPR 做字符识别。这套源码针对的是单车道或双车道闸机场景,所以直接用 HyperLPR 就够了。
2.2 抽帧、定位、识别一条链:核心代码与结果清洗
识别模块的核心代码不长,但有几个顺序问题容易翻车。我拆这套源码时第一处注意到的就是 BGR 和 RGB 的转换,OpenCV 读图默认是 BGR,HyperLPR 内部按 RGB 处理,顺序不对识别率会明显下降。
import cv2 import re try: from hyperlpr3 import LPR as HyperLPR except ImportError: from hyperlpr import HyperLPR lpr = HyperLPR() def recognize_plate(frame): # OpenCV 读出来的是 BGR,HyperLPR 需要 RGB,转一下再送识别 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) results = lpr(rgb) if not results: return None # 一张图中可能有多块疑似车牌,取置信度最高的那条 best = max(results, key=lambda x: x["confidence"]) plate = clean_plate(best["plate"]) if not plate: return None return { "plate": plate, "confidence": best["confidence"], "boxes": best["boxes"], } def clean_plate(plate): # HyperLPR 偶尔会带出噪点字符,先过滤,再按国内车牌长度校验 plate = re.sub(r"[^A-Z0-9\u4e00-\u9fa5]", "", plate) return plate if len(plate) in (7, 8) else None逻辑说明:results是列表,每项包含plate、confidence、boxes三个字段,plate是识别出的车牌字符串,confidence是置信度,boxes是车牌在画面里的坐标框。用max取置信度最高的一条,防止画面里出现广告牌、反光区域被误判成车牌。clean_plate做字符白名单过滤,把标点、空格、噪点字符全部去掉,最后只放行长度为 7 或 8 的车牌,这是国内蓝牌和绿牌的标准位数,能挡掉相当一部分乱码结果。
如果你装的是hyperlpr3,导入名是LPR,老版本则是HyperLPR,代码里那个try就是兼容两个版本用的。初始化之后直接调用实例就行,不需要额外加载模型文件。
2.3 三个影响识别率的关键参数:置信度、抽帧间隔与图像预处理
识别模块里最值得调的就是下面这张表,三个参数直接决定识别率上限:
| 参数 | 默认值 | 影响 |
|---|---|---|
| confidence 阈值 | 0.7 | 低于阈值直接丢弃结果;调太高会漏识别,调太低会出现误识别 |
| 抽帧间隔 | 0.5~1 秒 | 间隔太短连续帧重复识别,间隔太长会漏掉过闸瞬间 |
| 图像预处理 | 灰度 + 直方图均衡化 | 晚上和逆光场景下,这项决定识别率能不能稳住 |
置信度阈值建议按时间段分开配置,白天光照好可以设 0.75,晚上整体置信度会下降,设 0.6 更合理,一个参数走到底在车牌识别场景里基本行不通。抽帧间隔 0.5 秒是经验值,车辆过闸机时要经过减速、停车、再起步的过程,0.5 秒一帧足够在车头进入画面的瞬间抓到车牌。
图像预处理这块,我一般会在识别前加一步,尤其针对夜间场景:
def preprocess(frame): # 转灰度再均衡化,把车牌的对比度拉起来 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) gray = cv2.equalizeHist(gray) return cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)直方图均衡化对夜间车牌的反光问题非常有效,车牌字符在过曝或偏暗时边缘模糊,均衡化能把对比度拉开,效果比调低 confidence 阈值实在得多。代价是每帧多花十几毫秒,在 0.5 秒抽一帧的频率下几乎无感。
提示:摄像头架设角度比算法参数更影响识别率。建议高度 1.2 到 1.5 米,向下倾斜 20 到 30 度,正对车头,避开大灯直射。这个角度不对,调再多参数也是白搭。
3. 计费模块:时长计算、跨天费率与防重复入场
3.1 先把费率规则立住:小时计费、分时段与单日封顶
计费模块最大的坑不是写不出函数,而是费率规则没定清楚就动手写代码。停车场的计费规则看起来简单,实际拆开至少有四个维度:按小时还是按分钟计费、是否分白天夜间时段、是否设置免费时长、是否有单日封顶。这四个维度组合起来,代码里的分支会指数级增加,如果不把规则抽象成配置,写出来的函数就是一堆 if 嵌套,改一次费率改到怀疑人生。
这套源码的做法是把费率抽成一个字典,所有业务逻辑只认这个字典,不认硬编码数字:
rules = { "free_minutes": 15, # 入场 15 分钟内免费 "day_rate": 3.0, # 白天每小时 3 元 "night_rate": 2.0, # 夜间每小时 2 元 "day_start": 8, # 白天从 8 点开始 "day_end": 22, # 白天到 22 点结束,其余算夜间 "max_daily": 30.0 # 单日封顶 30 元 }字段含义很简单,free_minutes是免费时长,day_rate和night_rate是分时段的小时费率,day_start和day_end定义白天区间,max_daily是封顶金额。把费率独立成配置的好处是,停车场要调价时只改这个字典,计费函数不用动,后面接 MySQL 的fee_rules表也只是把这个字典的字段落库而已。
3.2 分钟级结算函数:免费时长、跨天分段与封顶实现
计费函数的核心逻辑是:先判断是否在免费时长内,不在的话从进场时间开始逐分钟累加费率,直到出场时间。逐分钟循环看起来笨,但它的好处是天然处理跨天,不需要额外判断“隔了几个晚上”,因为循环里每一分钟都带着自己的日期时间。
from datetime import datetime, timedelta def calculate_fee(entry_time, exit_time, rules): if exit_time <= entry_time: raise ValueError("exit_time must be after entry_time") stay_minutes = (exit_time - entry_time).total_seconds() / 60 if stay_minutes <= rules["free_minutes"]: return 0.0 total_fee = 0.0 current = entry_time while current < exit_time: hour = current.hour is_night = not (rules["day_start"] <= hour < rules["day_end"]) rate = rules["night_rate"] if is_night else rules["day_rate"] total_fee += rate / 60 # 把小时费率换算成分钟费率 current += timedelta(minutes=1) return round(min(total_fee, rules["max_daily"]), 2)逻辑说明:stay_minutes是总停车分钟数,先和free_minutes比较,在免费时长内直接返回 0 元。逐分钟累加时,每一分钟根据current.hour判断属于白天还是夜间,把对应的小时费率除以 60 得到这一分钟的金额,累加进total_fee。循环结束后用min和max_daily做封顶,最后保留两位小数。
这里有一个规则语义要讲清楚:免费时长是整体豁免,不是抵掉前 15 分钟。也就是说停了 20 分钟,不是只付 5 分钟的钱,而是按 20 分钟全额计费。这是大多数停车场实际采用的规则,如果你接的场地规则是“超出免费时长才从头计费”,保持这个实现即可。
逐分钟循环的性能边界也说一下:一天最多 1440 次循环,单笔订单计算耗时是毫秒级,对一个车道闸机的吞吐量来说完全够用。如果以后要做高并发的集中计费服务,可以改成按小时段批量计算——先算完整跨越的小时段,再补零头分钟,逻辑会复杂很多,但目前这个场景不需要。
3.3 防重复入场与异常订单:缓存兜底加人工补录
重复入场是计费模块最容易出的隐蔽问题。摄像头按 0.5 秒一帧抽帧,车辆在闸机口从减速到停稳通常有好几秒,同一块车牌子会被识别到多次。如果不做去重,一次入场可能生成三四条入场记录,出场时按哪条记时就是一笔糊涂账。
from datetime import datetime from collections import OrderedDict class ParkingGate: def __init__(self): # plate -> (entry_time, confidence) self._active = OrderedDict() def entry(self, plate, ts=None): ts = ts or datetime.now() last = self._active.get(plate) # 同一辆车 3 秒内重复识别,只更新时间,不重复建入场记录 if last and (ts - last[0]).total_seconds() < 3: self._active[plate] = (ts, last[1]) return False self._active[plate] = (ts, 1.0) return True def exit(self, plate, ts=None): ts = ts or datetime.now() entry = self._active.pop(plate, None) if entry is None: return None # 没有入场记录,走人工补录流程 return entry, ts逻辑说明:_active字典维护的是当前在场车辆,entry方法在写入前先查一下这个车牌是否在短时间内已经入场过,3 秒内的重复识别只刷新入场时间,不生成新记录。exit方法取出并删除_active里对应的入记录,把进出时间一起返回给计费函数。
这里有个点值得注意:如果直接返回None交给人工补录,补录界面要能同时修改进场和出场时间,否则这笔订单时长永远算不对。真实部署时,如果停车场有多个岗亭、多台设备同时写数据,OrderedDict要换成 Redis 哈希表,key 用车牌号,value 存入场时间戳,跨进程共享才不会乱。
4. 数据持久化:MySQL 表结构与断网兜底日志
4.1 三张核心表:在场车辆、历史订单与费率配置
数据层设计成三张表,而不是一张大表,原因是在场车辆和历史订单的查询模式完全不一样。运营要随时看“现在场里有几辆车、各自停了多久”,这个查询如果每次都扫历史订单表,数据量大了会越来越慢。把在途车辆单独成表,出场时把记录挪到订单表,两边都清爽。
vehicles表存放当前在场车辆:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT AUTO_INCREMENT | 主键 |
| plate_no | VARCHAR(16) | 车牌号,加唯一索引 |
| entry_time | DATETIME(3) | 入场时间,毫秒精度 |
| entry_image_path | VARCHAR(255) | 入场抓拍图片路径,方便对账 |
| status | TINYINT | 0 在场,1 已出场 |
orders表存放已完结的历史订单:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INT AUTO_INCREMENT | 主键 |
| plate_no | VARCHAR(16) | 车牌号 |
| entry_time | DATETIME(3) | 入场时间 |
| exit_time | DATETIME(3) | 出场时间 |
| stay_minutes | INT | 停车时长,分钟 |
| total_fee | DECIMAL(10,2) | 实收金额 |
| created_at | TIMESTAMP | 订单创建时间 |
fee_rules表就是第 3 章那个规则字典的落库版本,字段一一对应,不重复列了。
4.2 建表 SQL 与 Python 读写封装
三张表的建表 SQL 如下,时间字段统一用DATETIME(3)而不是字符串,这是为了避免后面算时长时到处做类型转换。我见过有人用VARCHAR存时间,排序靠字典序硬撑,跨天记录一多就出幺蛾子,这个习惯要改。
CREATE TABLE vehicles ( id INT AUTO_INCREMENT PRIMARY KEY, plate_no VARCHAR(16) NOT NULL, entry_time DATETIME(3) NOT NULL, entry_image_path VARCHAR(255) DEFAULT '', status TINYINT DEFAULT 0, UNIQUE KEY uk_plate (plate_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, plate_no VARCHAR(16) NOT NULL, entry_time DATETIME(3) NOT NULL, exit_time DATETIME(3) NOT NULL, stay_minutes INT NOT NULL, total_fee DECIMAL(10,2) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_plate (plate_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE fee_rules ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(64) NOT NULL, free_minutes INT DEFAULT 15, day_rate DECIMAL(6,2) DEFAULT 3.00, night_rate DECIMAL(6,2) DEFAULT 2.00, day_start TINYINT DEFAULT 8, day_end TINYINT DEFAULT 22, max_daily DECIMAL(8,2) DEFAULT 30.00 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;vehicles表上的uk_plate唯一索引是有意为之的:它保证同一块车牌在同一时刻只有一条在场记录,从数据库层面挡住重复入场。如果出现“车辆还在场内但又被识别入场”的情况,插入会报唯一键冲突,程序里捕获异常并更新原有记录即可。
Python 读写层用pymysql封装,插入订单的写法如下:
import pymysql def create_conn(): return pymysql.connect( host="127.0.0.1", user="parking", password="your_password", database="parking_system", charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, ) def insert_order(conn, order): sql = """ INSERT INTO orders (plate_no, entry_time, exit_time, stay_minutes, total_fee) VALUES (%s, %s, %s, %s, %s) """ with conn.cursor() as cur: cur.execute(sql, ( order["plate_no"], order["entry_time"], order["exit_time"], order["stay_minutes"], order["total_fee"], )) conn.commit()注意这里一定要用%s参数占位,不要用 f-string 拼 SQL。停车场管理系统会接外部设备,属于半公开网络环境,SQL 注入这种低级问题不能犯。entry_time和exit_time直接传 Python 的datetime对象,pymysql会自动处理类型匹配。
4.3 断网兜底:本地日志缓存与恢复回放
停车场岗亭的网络环境比想象中差,弱电箱断电、光纤被挖断、交换机老化,这些事在真实运营中都会遇到。如果 MySQL 连不上时程序直接把订单丢弃,月末对账就全是窟窿。这套源码里有一个本地日志兜底方案,思路是先落本地文件,再尝试写库,写失败也不丢数据。
import json import os from datetime import datetime PENDING_FILE = "pending_orders.log" def save_order_locally(order): order["cache_time"] = datetime.now().isoformat() with open(PENDING_FILE, "a", encoding="utf-8") as f: f.write(json.dumps(order, ensure_ascii=False) + "\n") def replay_pending(conn): if not os.path.exists(PENDING_FILE): return 0 recovered = 0 with open(PENDING_FILE, "r", encoding="utf-8") as f: lines = [json.loads(line) for line in f if line.strip()] for order in lines: try: insert_order(conn, order) recovered += 1 except Exception: # 单条失败不中断,让其他订单继续回放 continue if recovered == len(lines): os.remove(PENDING_FILE) return recovered逻辑说明:save_order_locally把订单序列化成 JSON 追加到本地日志文件,replay_pending在数据库恢复后逐条回放,全部成功后删除日志文件。这个方案能兜住断网场景,但也有一点粗糙:如果回放到一半程序崩溃,已经回放成功的订单会重复插入。正式落地时建议改成“读一条、写一条、标记一条”的消费模式,或者给订单加唯一业务编号,回放前先查重。
5. 避坑指南:识别率骤降、绿牌乱码与重复计费五个典型坑
5.1 晚上识别率骤降:对比度才是真凶
现象:白天识别率能到九成,到了晚上直接腰斩,车灯一开近景车牌一片白板,HyperLPR 返回空结果或乱码。很多人第一反应是模型不行,其实模型在白天和晚上用的是同一套权重,问题出在图像预处理没跟上。
原因:夜间环境光不足,摄像头自动增益会把暗部提亮,同时车灯和反光车牌过曝,字符边缘被亮光吃掉。识别模型在过曝图上提取不到字符特征,置信度自然掉到阈值以下。
解决:进识别前强制做直方图均衡化,把对比度拉开,这一步能明显改善过曝车牌的识别。摄像头参数上开启宽动态功能,或者把曝光补偿调成负值,优先保住车牌区域不过曝。逆光导致整张图偏暗时,再做一次 Gamma 校正。最后记得把夜间 confidence 阈值单独调低一档,全局一个阈值在晚上会漏掉大量本来能救回来的识别结果。
5.2 新能源绿牌识别成 7 位乱码:模型对 8 位车牌的适配问题
现象:绿牌新能源车进场,识别出来的车牌只有 7 位,或者中间带一个多余的“·”,导致车牌号和数据库里的月卡信息对不上,计费直接失效。
原因:HyperLPR 的老版本模型主要围绕 7 位蓝牌训练,绿牌是 8 位字符,字符间距和分隔符结构和蓝牌不一样,模型在切分字符时容易丢掉一位或者把分隔符当成字符输出。
解决:在clean_plate层做针对性清洗,把“·”和噪声字符过滤掉,只保留汉字、字母、数字。对长度为 8 的结果,再校验末位是否符合新能源车牌的常见字符特征,比如数字或 D、F 等字母。如果项目允许升级,优先切到hyperlpr3的新模型,绿牌支持度好了不止一档。
5.3 计费时长出现负数或零:时间类型不统一的后果
现象:订单列表里出现stay_minutes为负数,或者刚出场就显示“停车 0 分钟、金额 0 元”,对账时一眼就能看出数据有问题。
原因:十有八九是进出场时间一个来自 Python 的datetime对象,另一个从数据库查出来变成了字符串;或者是进场用的本地时间,出场用的 UTC 时间,两个时间一减直接算错。
解决:时间字段在入库前统一用datetime对象,数据库表保持DATETIME(3)类型,不要在应用层手动拼字符串。计算时长前加一个类型断言,确保两边都是datetime,一旦发现字符串就报错而不是静默往下走。这个坑我翻过车,调了半天才发现是读库时间被转成了str。
5.4 MySQL 连接隔夜就断:wait_timeout 与自动重连
现象:程序刚启动一切正常,跑了一夜之后第二天第一笔订单就报Lost connection to MySQL server during query,重启又好了,过一天又断。
原因:MySQL 默认的wait_timeout是 8 小时,连接池里的长连接如果 8 小时内没有任何活动,服务端会主动断开。pymysql本身不会自动重连,拿到一个已经失效的连接去查询必然报错。
解决:每次执行 SQL 前先conn.ping(reconnect=True),让连接池自动重建失效连接,或者捕获OperationalError后重建连接对象重试一次。不要试图把 MySQL 的wait_timeout调大,那是治标不治本,应用层做好重连才是正解。
5.5 内存持续上涨:视频帧释放与队列限制
现象:程序跑半天内存占用涨到 1GB 以上,最后被系统 OOM 杀掉,日志里什么都没留下。
原因:抽帧循环里的frame变量处理完后没有显式释放,HyperLPR 内部对识别过的图像有缓存引用,加上抽帧间隔太短,图像数据堆积在内存里。
解决:每帧处理完以后del frame,把抽帧间隔从 0.2 秒调到 0.5 秒以上。如果用了多线程并发识别,再加一个信号量限制同时在途的图片数量,防止识别速度跟不上抽帧速度导致队列无限膨胀。
6. 上线前验证:批量识别率统计与可视化复核工具
6.1 批量识别率统计:三分钟跑出准确率
上线前最重要的一步,是用历史抓拍图批量跑一遍识别率。我习惯把测试图片按“真实车牌号_序号.jpg”的格式命名放到test_imgs/目录,然后跑这个脚本:
import glob import os import cv2 from recognition import recognize_plate images = glob.glob("test_imgs/*.jpg") total = len(images) hit = 0 errors = [] for path in images: expect = os.path.basename(path).split("_")[0] frame = cv2.imread(path) result = recognize_plate(frame) if result and result["plate"] == expect: hit += 1 else: errors.append((path, expect, result["plate"] if result else None)) print(f"total={total}, hit={hit}, acc={hit/total:.1%}") for path, expect, actual in errors[:10]: print(f"FAIL {path}: expect={expect}, actual={actual}")这个脚本的意义在于,它把识别率变成了一个可以量化的数字。如果测试集有 100 张图,准确率 85%,你就知道这个系统在正式环境大概是什么水平,而不是凭感觉说“好像还行”。跑的时候注意 confidence 阈值要用正式环境的值,不要为了测试好看调松,那是在骗自己。
6.2 可视化复核:把识别框画回原图
批量统计只能告诉你哪些图片识别错了,看不出为什么错。我的习惯是再把识别结果画框写回原图,生成一批带标注的图片,按时间排序快速翻一遍,灯光、角度、逆光的整体问题一眼就能看出来:
def draw_result(frame, result, save_path): x, y, w, h = result["boxes"] cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText( frame, result["plate"], (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 1.0, (0, 255, 0), 2 ) cv2.imwrite(save_path, frame)画完框以后,重点看三类图片:识别错误的、置信度刚好在阈值边缘的、以及夜间抓拍。如果错误集中在某个时段,基本可以断定是光线问题而不是模型问题,直接去调摄像头参数或者预处理策略,比盲目调模型权重快得多。
我从那次“上线第二天被物业打电话说计费对不上”之后养成了一个习惯:不管改动多小,部署前都强制走一遍批量识别脚本,再模拟三组进出场订单,最后人工核对库里几条计费金额,三条全过才敢接真实摄像头。这套流程看着费时间,实际上能挡住九成以上的低级问题。希望帮到你。
本文还有配套的精品资源,点击获取