简介:面向需要构建智能停车管理系统的 Python 开发者、计算机专业学生以及毕业设计人员,这份源码包提供了一套从车牌识别、车辆入场出场判断、收入统计到满预警的完整可运行方案,并可在 PyCharm 中直接运行,借助百度 AI 开放平台的图片识别能力完成车牌号提取。
压缩包共包含 2003 个文件,大小约 189.59MB,其中 1777 个 py 文件为系统主体代码,pyc 为编译产物,txt/json 存放密钥与配置参数,doc/pdf 提供使用说明与参考文档,html/css/js 用于管理页面展示,h/c 等涉及底层依赖调用,目录结构清晰,便于按功能模块学习和二次改造。
目前已有 247 人学习下载。资源附带详细使用说明,包含百度 AI Key 配置说明、系统主界面、车辆入场/出场、收入统计柱状图和满预警提示的效果图示,能够帮助使用者快速跑通项目并理解计费逻辑、接口对接思路和异常排查方法。整体模块划分明确,可作为课程设计、毕业设计或真实停车场管理系统的改造基础。
1. 智能停车场车牌识别计费系统:识别只占工作量三分之一,计费状态才是大头
智能停车场的车牌识别计费系统,看起来就是把「拍下车牌、算出钱」两件事串起来。实际动手才发现:真正花时间的是识别结果怎么变成一条可信的停车记录,怎么避免重复入场,跨天怎么收费,两条记录同时出场谁先谁后。这些才是项目能不能从「演示版」走到「能用」的关键。这个方向特别适合两类人:一类是做毕设、需要能演示也能讲清原理的 Python 项目的人;另一类是预算有限、想用一台普通 PC 加几路摄像头撑起一个小停车场的开发者。下面按「选型 → 最小闭环 → 计费落地 → 踩坑 → 上线验证」的顺序讲,代码部分照抄改改就能跑通。
2. 选型先行:车牌识别引擎、计费状态机和数据落地的三个边界
2.1 车牌识别引擎怎么选:模板匹配、自训 YOLO 还是开箱 OCR
做车牌识别,最常看到三条路:OpenCV 模板匹配、自训 YOLO 检测加识别、直接用开箱 OCR 引擎。模板匹配对固定角度印刷体还行,一旦遇到倾斜、逆光、模糊就全盘崩掉,现在已经很少当主方案。自训 YOLO 效果上限最高,但车牌是「省份简称 + 字母 + 数字」的封闭字符集,要覆盖全国各省简称,标注样本起步几千张,还要持续迭代,对单体停车场项目来说投入产出比很低。
我一般推荐直接用开箱 OCR 引擎,比如 RapidOCR 或 PaddleOCR 的轻量模型。它们本来就是做中英文全文本识别的,车牌这种字符串在模型能力范围内,pip 装完就能跑,CPU 也能扛住。三者的对比很直观:
| 方案 | 硬件成本 | 维护成本 | 适用场景 |
|---|---|---|---|
| 模板匹配 | 低 | 低 | 固定机位、印刷体、无遮挡 |
| 自训 YOLO | 高,需要 GPU 训练 | 高,标注与迭代 | 长期演进的团队项目 |
| 开箱 OCR | 低,CPU 可跑 | 中,后处理要自己写 | 本文这种单体计费系统 |
选型时要认清一个边界:OCR 引擎返回的是整张图里的所有文本,不是直接告诉你「这是车牌」。真正区分车牌的地方在后处理。车牌结构很严格:第一位省份简称,第二位发牌机关字母,后面 5 到 6 位字母数字。过滤逻辑按这个结构写:
PLATE_PREFIX = "京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领" def is_plate_text(text: str) -> bool: text = text.strip() if len(text) not in (7, 8): # 蓝牌 7 位,新能源绿牌 8 位 return False if text[0] not in PLATE_PREFIX: # 首位必须是省份/地区简称 return False return True这段代码看起来简单,作用很大:白天顺光时 RapidOCR 的置信度能到 0.8 以上,夜间雨天掉到 0.5 左右,但至少它能稳定筛掉「停车场入口」「扫码缴费」这类干扰文本。实际项目中,前缀集合还能帮你识别「使领馆」车牌,如果业务不需要,去掉「使领」两个字即可。
2.2 计费状态机:入场、在场、出场、缴清四态怎么切
识别只是入口,计费才是这个系统的核心。没有状态管理的计费系统会连环出错:同一次入场重复计费、出场找不到记录、缴费后开闸失败又来一次。常见做法是把一条停车记录建模成状态机:入场后进 active,出场结算后进 completed,缴费没确认时进 pending 补偿。
状态转移规则不复杂,但最容易被新人省略:入场登记时,如果该车牌已经有 active 记录,就直接返回「已在场内」,对应相机闪光两次只算一次入场;出场结算时只允许 active 状态记录结算,结算完立刻置为 completed,同一辆车就不会被连续扣两次费。这个约束要落进代码里,不是口头约定。
参数层面我习惯加一个超时兜底:入口相机识别到车牌但入场登记失败,滞留记录 60 秒后自动释放;出口结算完成但道闸抬杆失败,保留 pending 记录,等下次识别到同一车牌时优先展示「已缴费未离场」。这套状态机配合一张异常记录表,能覆盖大部分现场故障。项目初期看状态机觉得是过度设计,真到现场调试时就知道它是救命的设计。
2.3 数据落库:SQLite 的边界和 MySQL 切换时机
停车计费系统对数据库的需求是「读多写少、单点可控」。单机部署的小停车场,SQLite 完全够用,省掉数据库运维。我一般会把 SQLite 开启 WAL 模式,提升并发读写体验;数据量到几十万条后,再考虑切 MySQL。切换信号不是数据量,而是系统开始拆成多台机器部署的时候——识别服务一台、计费服务一台,SQLite 文件放在共享盘上就是给自己找麻烦。
表设计按业务拆三张:parking_records 存每次进出场,fee_rules 存费率,白名单车辆单独建表。费率放数据库而不是写死在代码里,运营调价时不用动程序。日期时间字段要特别注意:SQLite 没有原生的 timestamp 类型,我统一存YYYY-MM-DD HH:MM:SS本地时间文本,写入时用datetime('now','localtime'),避免时区把计费算错。
注意:SQLite 并发写会锁库。WAL 能缓解读写互斥,但两个线程同时做出场结算时,仍然需要事务或行锁兜底,否则就会出现费用被覆盖的问题。这个问题在第 5 章专门有踩坑记录。
3. 用 Python 搭最小闭环:环境准备、识别函数与验证图片
3.1 搭建运行环境:虚拟环境与两个关键 pip 依赖
新手最容易踩的坑是直接在全局 Python 里pip install,装完 opencv 与 onnxruntime 依赖冲突,系统里原本能跑的脚本挂一片。这也是搜「python 安装」相关内容时最常见的翻车现场。建议从第一步就用虚拟环境隔离:
python -m venv .venv source .venv/bin/activate # Windows PowerShell: .venv\Scripts\Activate.ps1 pip install rapidocr-onnxruntime pillowrapidocr-onnxruntime是独立封装的 ONNX 版本,不需要装 Paddle 全家桶;pillow用来生成模拟车牌和图像预处理。安装过程会拉下 onnxruntime 和 opencv 相关依赖,等一两分钟是正常的。装完用pip show rapidocr-onnxruntime确认版本。如果用的是 VSCode,记得把解释器指向.venv里的 python,不然终端 activate 了,编辑器却还在走全局环境。
3.2 写一个车牌识别函数:OCR 输出到车牌文本的过滤
from rapidocr_onnxruntime import RapidOCR ocr = RapidOCR() # 全局只初始化一次,模型加载很耗时 PLATE_PREFIX = "京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼使领" def recognize_plate(image_path: str, min_score: float = 0.6): result, _ = ocr(image_path) if not result: return None for box, text, score in result: if score < min_score: continue text = text.strip() if len(text) not in (7, 8): continue if text[0] not in PLATE_PREFIX: continue return text return Noneresult是元组列表,每个元素包含坐标框、识别文本、置信度三部分。min_score是置信度阈值,默认 0.6 适合白天场景;夜间或逆光我会降到 0.45,同时让摄像头连拍三帧,取占比最高的结果。OCR 模型加载一次要几百毫秒,所以ocr = RapidOCR()放在函数外。阈值调太低会有杂文本混进来,所以用前缀集合加长度双重过滤,把「停车场入口」这类场景文本挡在外面。
3.3 用模拟车牌图验证最小闭环:从 0 到 1 的第一跑
真实摄像头还没到位时,先用 Pillow 生成一张白底黑字的模拟车牌,验证整条识别链路通不通:
from PIL import Image, ImageDraw, ImageFont def make_plate(path: str, text: str = "京N3C229"): img = Image.new("RGB", (220, 70), "white") draw = ImageDraw.Draw(img) font = ImageFont.truetype("simhei.ttf", 40) draw.text((10, 10), text, fill="black", font=font) img.save(path)Windows 直接用系统字体里的simhei.ttf,Linux 需要先安装中文字体包。生成后调用识别函数:
python -c "from recognize import recognize_plate; print(recognize_plate('sample_plate.jpg'))"第一次运行要加载 ONNX 模型,等几秒才出结果,这是正常现象。输出车牌文本说明链路通了;输出 None 先检查图片是不是全黑、字体有没有渲染出来、阈值会不会太高。这个最小闭环跑通后,再写计费逻辑才不心虚。
真实项目里摄像头通常走 RTSP 流,常见做法是抽帧识别而不是每帧都跑:
import cv2 cap = cv2.VideoCapture("rtsp://user:pass@camera_ip:554/stream") interval = 10 # 每 10 帧取 1 帧 count = 0 while True: ok, frame = cap.read() if not ok: break count += 1 if count % interval != 0: continue cv2.imwrite("frame.jpg", frame) plate = recognize_plate("frame.jpg") if plate: # 连续 N 次识别到同一车牌,再触发入场登记,避免单帧误判 pass抽帧间隔直接决定 CPU 占用率。一帧一帧识别,普通 PC 很快就发热降频;10 帧抽 1 帧,车进场到闸机前的几秒内照样能抓到车牌。连续多次识别到同一车牌再登记,是防止单帧误判的基础手段。
4. 计费系统落地:SQLite 表结构、入场登记与跨天出场结算
4.1 建表与费率配置:停车记录、费率、白名单三张表
计费模块第一步是建表。完整 SQL 如下:
CREATE TABLE parking_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate TEXT NOT NULL, entry_time TEXT NOT NULL, exit_time TEXT, fee REAL DEFAULT 0, status TEXT NOT NULL DEFAULT 'active' ); CREATE INDEX idx_plate_status ON parking_records(plate, status); CREATE TABLE fee_rules ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_key TEXT UNIQUE NOT NULL, rule_value REAL NOT NULL ); CREATE TABLE vip_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate TEXT UNIQUE NOT NULL, vip_until TEXT ); INSERT INTO fee_rules(rule_key, rule_value) VALUES ('free_minutes', 15), ('first_hour_fee', 5), ('hourly_fee', 2), ('daily_cap', 20);parking_records里status区分 active 和 completed,配合(plate, status)联合索引,出场结算时能秒查「这辆车有没有在场内的记录」。fee_rules单独建表是为了让费率可调,不用改代码。日期时间统一用文本格式,Python 侧用datetime.strptime解析,规则简单不容易出错。连接的库文件就叫parking.db,代码里通过参数传入,方便测试时换临时库。
4.2 入场登记:车牌规范化、防重复入场与事务写入
import re import sqlite3 def normalize_plate(plate: str) -> str: plate = plate.replace("·", "").replace(" ", "").upper() return re.sub(r"[^A-Z0-9\u4e00-\u9fa5]", "", plate) def enter_parking(db_path: str, plate: str): plate = normalize_plate(plate) if len(plate) not in (7, 8): return False, "invalid plate" conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute( "SELECT id FROM parking_records WHERE plate=? AND status='active'", (plate,), ) if cur.fetchone(): conn.close() return False, "already inside" cur.execute( "INSERT INTO parking_records(plate, entry_time, status) " "VALUES(?, datetime('now', 'localtime'), 'active')", (plate,), ) conn.commit() conn.close() return True, platenormalize_plate处理三件事:去掉车牌中间的圆点「·」,去掉空格,统一大写。真实车牌上新能源牌经常带分隔符,OCR 可能把它识别成点或空格,不规范化就会出现「京AD12345」和「京AD·12345」匹配不上的问题。重复入场判断是状态机的关键,依靠联合索引查 active 记录,存在就直接拦截。入场时间用 SQLite 的datetime('now','localtime')写入,保证所有记录的时间都来自数据库服务器,而不是各台电脑自己的时钟。
4.3 出场结算:分钟向上取整、跨天计费与状态锁存
出场结算要把时长换算成钱,计费规则定义成字典:
import math from datetime import datetime, timedelta FEE_RULES = { "free_minutes": 15, "first_hour_fee": 5, "hourly_fee": 2, "daily_cap": 20, } def calc_fee(entry: datetime, exit: datetime, rules: dict) -> float: minutes = max(0, int((exit - entry).total_seconds() // 60)) free = rules.get("free_minutes", 0) if minutes <= free: return 0.0 hours = math.ceil(minutes / 60) # 不足 1 小时按 1 小时计 fee = rules["first_hour_fee"] + max(0, hours - 1) * rules["hourly_fee"] return min(fee, rules["daily_cap"])分钟数向上取整到小时,停车 61 分钟按 2 小时算,这是停车场常见的计费口径。daily_cap是单日封顶,但直接套一个 cap 会有一个严重 bug:停车跨天时,系统会把 3 天的总时长一次性计算,然后只收 20 元封顶费。所以实际项目要用跨天版本:
def calc_fee_multi_day(entry: datetime, exit: datetime, rules: dict) -> float: total = 0.0 day_start = entry while day_start < exit: day_end = day_start.replace(hour=23, minute=59, second=59) slice_end = min(exit, day_end) total += calc_fee(day_start, slice_end, rules) day_start = slice_end + timedelta(seconds=1) return round(total, 2)这段代码把停车时长按自然日切段,比如 6 月 1 日 23:30 入场、6 月 2 日 00:20 出场,就被拆成 6 月 1 日的 30 分钟和 6 月 2 日的 20 分钟两段,每段分别套用当天的免费时长和封顶,不会出现停两天只收一天钱的情况。出场结算完整函数:
def exit_parking(db_path: str, plate: str, rules: dict): conn = sqlite3.connect(db_path) cur = conn.cursor() cur.execute( "SELECT id, entry_time FROM parking_records " "WHERE plate=? AND status='active' ORDER BY entry_time DESC LIMIT 1", (plate,), ) row = cur.fetchone() if not row: conn.close() return None, "no active record" record_id, entry_text = row entry = datetime.strptime(entry_text, "%Y-%m-%d %H:%M:%S") now = datetime.now() fee = calc_fee_multi_day(entry, now, rules) cur.execute( "UPDATE parking_records SET exit_time=?, fee=?, status='completed' WHERE id=?", (now.strftime("%Y-%m-%d %H:%M:%S"), fee, record_id), ) conn.commit() conn.close() return fee, "ok"白名单车辆可以在结算前先查vip_records,vip_until没过期就直接把 fee 置 0,代码结构不变,只是在calc_fee_multi_day之前加一个判断。入库后的状态锁存是关键:只有在 active 状态才能完成结算,结算后立刻变 completed,重复扫码也不会二次扣费。
5. 避坑手册:车牌识别计费项目最容易翻车的五个位置
5.1 新能源车牌识别结果带「绿」字,入库后匹配不上
现象:出场时明明识别出「京AD12345」,查询却显示「无入场记录」。原因:部分 OCR 引擎会把车牌下沿的「绿」字小注也识别出来,结果变成「京AD12345绿」,入库时空格和符号被清理,但「绿」字留在了末尾,和出场时识别的结果匹配不上。解决:入场登记前先做严格的字符集校验,末尾出现「绿、蓝、黄、黑」等颜色字直接去掉;更好的做法是用前面is_plate_text的逻辑,前缀校验加长度校验双保险,宁可让系统多拍一帧,也不要让脏数据进库。
5.2 0 与 O、8 与 B 混淆,出场时找不到记录
现象:入场识别为「京N0C229」,出场识别成「京NOC229」,两辆车在系统里成了两辆。原因:字形相近,部分摄像头角度下 O 和 0 几乎无法区分。解决:千万不要在后处理里把 O 统一替换成 0,因为车牌里确实可能出现字母 O。我一般会保留识别结果,查询时做归一化匹配,例如把 0 和 O 归成一组,命中多条记录后让值班员确认;也可以用连续三帧识别投票,取占比最高的结果。调阈值在这个问题上帮不上忙,属于典型的「玄学无效」场景。
5.3 RapidOCR 太吃 CPU,视频流识别直接卡顿
现象:普通 PC 上单帧识别耗时几百毫秒,视频流一接入 CPU 直接打满,道闸响应跟着卡。原因:直接把摄像头原图送进模型,分辨率越高,ONNX 计算量增长越夸张。解决:识别前把图像宽度缩到 960 到 1280,肉眼几乎看不出车牌字符损失,速度能快好几倍;再做抽帧控制,10 帧取 1 帧。另一个容易忽略的点是 RapidOCR 实例不是线程安全的,多路摄像头并发识别时要对ocr实例加锁,或者干脆串行识别,否则会出现莫名其妙的空结果。
5.4 单日封顶在跨天停车时形同虚设
现象:一辆车停了 3 天,系统只收了 20 元。原因:直接对总时长套用daily_cap,3 天总时长 72 小时,算出费用后只做了一次min(fee, 20)。解决:按自然日拆分再逐日封顶,也就是前面写的calc_fee_multi_day。上线前一定要构造跨天测试用例,哪怕是写死时间戳的单测,也能把这个 bug 拦下来。这个问题是计费系统里最容易出大额资损的地方,也是最容易被忽视的。
5.5 SQLite 并发写导致出场费用相互覆盖
现象:两辆车同时出场,后结算的那笔费用把前一笔覆盖了。原因:出场结算的「查询 active 记录 → 计算费用 → UPDATE 状态」三步不是原子操作,两个线程交错执行时,后一个 UPDATE 把前一个的状态覆盖。解决:最稳的做法是结算前用事务抢锁,或者把 UPDATE 写成带状态条件的原子操作:
cur.execute( "UPDATE parking_records SET exit_time=?, fee=?, status='completed' " "WHERE id=? AND status='active'", (now_text, fee, record_id), ) if cur.rowcount != 1: # 记录已被其他线程结算,放弃本次操作 conn.rollback()rowcount == 1才说明这次结算真正生效。这个方案不用加锁,也不依赖线程模型,进程多了同样安全。入场登记也可以照这个思路改写,把「查重」和「插入」合并成一次带条件的 UPDATE。
6. 上线前最后一关:模拟进出场脚本与识别/计费拆分
6.1 自动模拟进出场:把计费规则跑成断言
手工举杆试车可以验证基本流程,但覆盖不到跨天、免停这些边界。我习惯在项目里保留一个test_billing.py,把所有费率和状态流转写进断言:
from datetime import datetime, timedelta from billing import calc_fee, calc_fee_multi_day, FEE_RULES free_entry = datetime(2024, 6, 1, 10, 0, 0) free_exit = datetime(2024, 6, 1, 10, 10, 0) assert calc_fee(free_entry, free_exit, FEE_RULES) == 0 entry = datetime(2024, 6, 1, 9, 0, 0) exit_ = datetime(2024, 6, 1, 10, 30, 0) assert calc_fee(entry, exit_, FEE_RULES) == 5 entry_night = datetime(2024, 6, 1, 23, 30, 0) exit_morning = datetime(2024, 6, 2, 0, 20, 0) assert calc_fee_multi_day(entry_night, exit_morning, FEE_RULES) > 0 print("billing tests passed")这套脚本不需要摄像头,直接把 datetime 参数喂给纯函数,几秒钟跑完。跨天用例一定要保留,因为这个逻辑改动频率最高,最容易在调价格时被顺手改坏。
6.2 进阶:把识别服务与计费服务拆开,留好审计日志
系统跑稳后,下一步是把识别和计费拆成两个进程。识别进程只管 OCR,结果通过消息队列交给计费进程;计费进程只管状态流转和费用计算。拆开后的好处是:识别卡顿不会拖垮计费,计费升级也不用重新部署识别服务。日志方面,每一条识别记录都要保留原图、识别文本、置信度,计费操作要记操作时间和结果,方便出纠纷时追查是谁抬的杆、为什么抬的杆。历史记录超过 60 天就归档到独立表,避免 SQLite 单表膨胀后查询变慢。
我最早做这类系统时,把识别和计费直接写在同一个循环里,现场并发一高就发现计费记录会丢、会覆盖。后来习惯变了:先写状态机,再写识别,最后接数据库。每张表留一个 created_at 字段,每个计费操作都留下可追溯日志,这些习惯比任何花哨的算法都更值钱。希望这篇笔记能帮你少走一段弯路,把系统从「跑得动」做到「敢收钱」。
本文还有配套的精品资源,点击获取