☰
Python车牌识别计费系统:识别、状态机与道闸闭环实战
2026/11/6 2:13:53 网站建设 项目流程

简介:这是一套基于 Python 与百度 AI 开放平台车牌识别接口实现的智能停车场计费系统源码及配套使用说明,面向具备 Python 基础、希望完成计算机视觉落地项目或课程设计的高校学生与开发者。系统围绕车辆入场出场识别、收入统计柱状图展示与车位满预警三大功能展开,需在 PyCharm 中运行并配置 key.txt 中的接口密钥,可直接作为实训范例或二次开发基础。资源包共 2003 个文件,以 1777 个 py 源码为主体,辅以 pyc 编译文件、txt 配置与说明、h 与 c 等底层依赖文件,以及少量 xml、json、js、html、css 前端与配置资源和 pdf、doc 文档,压缩包约 189.59MB,含完整运行环境与依赖,目录结构清晰便于检索。目前已有 248 人浏览学习,适合按说明复现识别流程、理解计费与统计逻辑,并在此基础上扩展车位调度等功能。

1. 晚高峰出口那半米倒车:这套 Python 车牌识别计费系统到底解决了什么

晚高峰出口排队十几辆车,前车往后倒了半米才被认出来——这种场面在小型停车场不算罕见,问题往往不在摄像头像素,而在识别链路和计费状态机没对齐:车牌是读出来了,却没跟上一笔入场记录挂上钩,道闸该抬的时候不抬,该收的钱收不上来。

这份 Python 实现的车牌识别计费系统(源码加使用说明),把视频取帧、车牌定位、字符识别、出入场判定、费用计算、道闸抬杆串成了一条能跑通的完整闭环,而不是输出一串车牌号就结束的演示脚本。它适合三类人:想拿一个完整业务闭环练手的视觉方向初学者,需要交一份能演示的停车场原型的课程设计,以及手上有小型车场、想先跑通业务逻辑再谈硬件的开发者。代码量不大,但该有的分层、该写的表结构、该留的调试开关都在,改起来有地方下手。

2. 拆源码:识别、计费、道闸三条链路是怎么串成一个闭环的

拿到一套陌生源码,我习惯先看目录,再看入口,最后才看算法实现。顺序反了容易一头扎进某个函数里,出来时已经忘了它在整个流程里处在什么位置。这套项目的分层比较老实,没有为了显得"工程化"而硬堆抽象,几个目录基本对应了数据流的几个阶段。

2.1 目录结构与模块职责

parking_lpr/ ├── app.py # 启动入口,解析参数、拉起主循环 ├── config.yaml # 摄像头地址、模型路径、费率开关、道闸模式 ├── core/ │ ├── capture.py # 取帧、断流重连、有界帧队列 │ ├── locate.py # 车牌定位:灰度、边缘、形态学、候选筛选 │ ├── segment.py # 字符分割与尺寸归一化 │ ├── recognize.py # 字符识别与结果纠错 │ └── billing.py # 计费状态机与费率计算 ├── storage/ │ ├── schema.sql # 建表与索引 │ └── dao.py # 增删改查封装 ├── gate/ │ └── barrier.py # 道闸控制,含纯软件模拟模式 ├── ui/ │ └── console.py # 命令行面板,实时打印识别结果与状态 └── data/ ├── models/ # 定位模型文件 └── snapshots/ # 抬杆瞬间的抓拍图

capture.py和billing.py是最值得先读的两个文件。前者决定系统稳不稳,后者决定账算得对不对。locate.py、segment.py、recognize.py三段是识别流水线,改动它们影响的是准确率;改动前两个影响的是能不能连续跑三天。

提示:先跑通app.py的默认参数,再动core/里的任何一个文件。默认参数至少保证在光线正常的室内场景能出结果,改坏之后还有个对照基准。

2.2 识别链路:一帧图像要过几道工序

一帧原始画面进来,先降采样到工作分辨率(常见做法是宽 640,再大对 CPU 推理没有收益,只是白烧算力),转灰度,做高斯模糊压掉传感器噪点,然后走 Sobel 或 Canny 拿边缘。车牌区域的纹理特征非常集中——大量短促的垂直边缘密集排列,所以形态学闭运算(先膨胀后腐蚀)能把字符之间的缝隙填成一整块连通区域,这一步的参数比边缘检测阈值更影响召回。

连通区域出来后进入候选筛选,也是整个定位环节最容易调崩的地方。常用三个判据:面积区间、宽高比区间、矩形度。国内蓝牌标准尺寸 440×140,宽高比约 3.14,实际拍摄因为透视和倾斜会浮动,所以区间给到 2.2 到 4.2 比较稳。筛出来的候选按面积从大到小排序,取前若干个逐个送进字符分割,分割出的字符数量不在 6 到 8 之间的直接丢弃。

字符识别这一段,源码里常见两种做法:模板匹配和轻量卷积网络。模板匹配胜在零训练成本、可解释,缺点是字体稍变、污损、倾斜就掉点;轻量网络准确率高一截,代价是要带一份权重文件。判断这套项目走哪条路很简单,看data/models/下有没有几十 KB 以上的二进制文件就知道了。两条路都留了替换接口,recognize.py里通常只暴露一个recognize_chars(char_imgs) -> str的函数签名。

最后一步是结果校验,很多人会跳过,但它是把"识别率 85%"提到"可用"的关键。校验包含三层:省份简称是否在白名单内、字符总数是否合法、第二位字母是否落在允许集合里。这三层能挡掉相当一部分因为分割错误产生的乱码,挡不掉的部分交给业务层的复核队列。

2.3 计费状态机:入场、在场、出场三态怎么存

识别只是输入,真正决定这个系统能不能拿去用的是状态怎么存。这套项目用的是单表加状态字段的做法,一条记录代表一次完整通行,入场时写status='IN',出场时补齐leave_ts、fee_cents并改成OUT。表结构大致如下:

字段类型作用
idINTEGER自增主键
plateTEXT车牌号,统一大写去空格
enter_tsINTEGER入场时间,Unix 秒
leave_tsINTEGER出场时间,未出场为 NULL
fee_centsINTEGER费用,单位分,避开浮点误差
confidenceREAL入场识别置信度
snapshotTEXT抓拍图相对路径
statusTEXTIN / OUT / REVIEW

状态判定的核心逻辑很短,但每个分支都得想清楚:

# core/billing.py 片段:一条识别结果进来后怎么定状态 DEDUP_WINDOW = 6.0 # 去重窗口,秒。同一车牌在这个窗口内重复出现视为同一次通行 def handle_plate(self, plate: str, ts: float, conf: float): last = self.dao.last_record(plate) # 该车牌最近一条记录 if last and ts - last["enter_ts"] < DEDUP_WINDOW: return "DUP" # 连续帧重复触发,直接丢弃 if last is None or last["status"] == "OUT": # 无未结算记录 -> 判定为入场 self.dao.insert_entry(plate, ts, conf) self.barrier.open("in") return "IN" fee = self.calc_fee(last["enter_ts"], ts) # 有未结算记录 -> 出场结算 self.dao.close_record(last["id"], ts, fee) self.barrier.open("out") return "OUT"

DEDUP_WINDOW是这段代码里唯一一个拍脑袋但必须有的参数。摄像头一秒二十五帧,一辆车从入画到出画可能被识别十几次,没有这个窗口,一次通行会写进十几条入场记录。六秒是个经验值,车速快、镜头远可以压到四秒,慢速匝道可以放到八秒。窗口太长会吃掉"出场后立刻又进场"的掉头车,窗口太短则去重不干净,两边都要在真实车道上验。

3. 从零跑通:依赖、模型路径、数据库三处改完才算能启动

源码能读和能跑是两件事。我见过太多项目卡在"依赖装完了但 import 报错"这一步,最后不了了之。这套项目的启动路径比较短,只要改对三个地方:Python 环境、模型路径、数据库连接。

3.1 依赖清单与版本对齐

python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install -r requirements.txt python -c "import cv2, numpy; print(cv2.__version__, numpy.__version__)"

虚拟环境这一步别省。视觉类项目对底层库版本极其敏感,系统 Python 里预装的 OpenCV 和老版本 NumPy 经常跟requirements.txt打架,装到一半开始报符号冲突,排查起来非常浪费时间。

版本对齐的重点在 OpenCV 主版本。3.x 和 4.x 在轮廓检测的返回值个数上就不一样——3.x 返回三个值(图像、轮廓、层级),4.x 只返回两个(轮廓、层级)。如果源码里写的是_, contours, _ = cv2.findContours(...),在 4.x 环境里会直接抛ValueError: too many values to unpack。这不是代码写错了,是版本错配,改调用方式或者降版本都行,我一般选择改调用方式,因为降版本会把 NumPy 也一起拖下水。

装完之后跑一次那个print验证,确认输出里 OpenCV 是 4.x,NumPy 在 1.20 以上。这两条对上了,后面的报错才有排查价值。

3.2 模型文件与路径配置

配置项集中在config.yaml或者config.py里,需要改的其实就五行:

# config.py 里必须改的几项 CONFIG = { "camera": 0, # 0 表示本地摄像头,网络流填完整地址 "model_path": "data/models/plate_locate.xml", # 定位模型,相对路径以项目根目录为基准 "db_url": "sqlite:///data/parking.db", # 换 MySQL 改成 mysql+pymysql://user:pwd@host/db "snapshot_dir": "data/snapshots", # 抓拍图落盘目录,要保证有写权限 "gate_mode": "simulate", # simulate 纯软件模拟,serial 走真实串口 }

camera填 0 的时候,程序会去抢本机默认摄像头。如果机器上有多个摄像头,或者是笔记本外接了一个 USB 摄像头,索引可能会漂到 1 或 2,挨个试最快。填网络流地址的话要注意超时重连,capture.py里一般会有个RECONNECT_INTERVAL,默认五秒,网络抖动频繁的场景可以压到两秒。

gate_mode保持simulate直到业务逻辑全部验证通过。这一点非常重要——真实道闸是物理设备,误抬杆的后果是要有人去现场处理的,不像日志里多打一行那样无所谓。

注意:snapshot_dir指向的目录如果不存在,有些实现会静默失败而不是报错,结果就是抬杆了但抓拍图一张没存,事后想复核都找不到证据。启动前手动确认一次目录存在并且可写。

3.3 数据库初始化与首次冷启动

CREATE TABLE IF NOT EXISTS parking_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate TEXT NOT NULL, enter_ts INTEGER NOT NULL, leave_ts INTEGER, fee_cents INTEGER DEFAULT 0, confidence REAL, snapshot TEXT, status TEXT NOT NULL DEFAULT 'IN' ); CREATE INDEX IF NOT EXISTS idx_plate_status ON parking_record(plate, status); CREATE INDEX IF NOT EXISTS idx_enter_ts ON parking_record(enter_ts);

第一个索引服务于"查这个车牌有没有未结算记录",是出场结算路径上最高频的查询;第二个索引服务于日结对账和报表,按时间范围扫表。两个索引都不建也能跑,数据量到几万行以后出场响应会明显变慢,因为每次都要全表扫描找那个status='IN'。

初始化直接用sqlite3 data/parking.db < storage/schema.sql就行,SQLite 不需要单独的服务器进程,这也是这套项目适合做原型的核心原因之一——部署时少一个组件,就少一类故障。真上生产换成 MySQL,只需要改db_url并把建表语句里的AUTOINCREMENT换成AUTO_INCREMENT。

冷启动阶段建议手工插几条假数据把出场流程跑一遍:一条status='IN'且入场时间在半小时前的记录,然后让系统识别同一个车牌号,看费用算得对不对、状态有没有正确翻成OUT。这一步比看十遍代码更能发现问题。

4. 参数怎么定:定位阈值、字符纠错表与费率规则的实操改法

同一套源码,参数不一样,效果能拉开一个数量级。这一章讲的三组参数,分别对应识别准不准、识别错了能不能救回来、钱算得对不对。

4.1 车牌定位的四个阈值

# core/locate.py 顶部参数区 MIN_AREA = 1200 # 候选轮廓最小面积(像素²),低于此值基本是噪点 MAX_AREA = 45000 # 最大面积,超过通常是车身反光条或广告牌 MIN_RATIO = 2.2 # 宽高比下限 MAX_RATIO = 4.2 # 宽高比上限,透视倾斜会把车牌拉宽 BLOCK_SIZE = 25 # 自适应二值化邻域大小,必须是奇数

面积区间怎么定,取决于工作分辨率。如果降采样到宽 640,一个正常距离的车牌大致占 80×26 像素,面积两千出头,所以MIN_AREA=1200留了余量,远处小一点的车牌还能捞回来。如果原图分辨率更大,这四个数都要按面积比例放大,不能照抄。

宽高比区间给得比较宽,是为了照顾侧向机位。正对车道的相机,实测宽高比集中在 3.0 到 3.4,可以把上限压到 3.8 提高精度;斜 30 度安装的机位,透视会把车牌拉得又扁又宽,比例能到 4 以上,这时候上限该放宽到 4.5。

BLOCK_SIZE是自适应二值化的邻域窗口,直接决定字符和背景能不能分开。太小会把字符内部掏空,太大则把相邻字符糊成一片。白天光照均匀用 25 就够,晚上只有局部补光的时候,窗口要跟着变大,常见做法是按画面平均亮度做一次动态调整,而不是写死。

4.2 字符纠错表与白名单

# core/recognize.py PROVINCES = "京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼" DIGITS = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ" # 车牌字符集不含 I 和 O CONFUSE = {"O": "0", "I": "1", "Z": "2", "S": "5", "B": "8", "D": "0"} def fix_plate(raw: str) -> str: s = raw.upper().replace(" ", "") s = "".join(CONFUSE.get(c, c) for c in s) # 先做易混字符映射 if len(s) not in (7, 8): # 新能源牌 8 位 return "" if s[0] not in PROVINCES: return "" return s

这张CONFUSE表是这类系统的血泪经验浓缩。车牌字符集里明确排除了字母 I 和 O,就是为了避免和数字 1、0 混淆,所以识别结果里出现 I 或 O,一定是错的,直接映射过去没有风险。Z 与 2、S 与 5、B 与 8 的混淆来自字形相似,映射的代价是"如果原本真是 Z 就会改错",但在车牌第二位(代表发牌机关代号)之后基本不会出现这些字母,所以安全。

fix_plate返回空字符串表示判定为无效识别,业务层收到空串就不写库、不抬杆、丢进复核队列。这条兜底路径一定要有,否则一个乱码车牌进去,出场时匹配不到入场记录,费用直接算成零,车就白停了。

4.3 计费规则表与改费率的正确姿势

费率不要写死在函数里。写死的后果是客户说"夜间半价",你得改代码、重新部署、还可能改错分支。正确做法是把规则做成一张配置表,函数只负责套公式。

字段含义示例值
free_min免费时长(分钟)15
unit_min计费单元(分钟)60
unit_price每单元价格(分)500
day_cap单日封顶(分)3000
night_discount夜间折扣系数0.5
import math def calc_fee(enter_ts: int, leave_ts: int, rule: dict) -> int: minutes = max(0, (leave_ts - enter_ts) // 60) if minutes <= rule["free_min"]: return 0 billable = minutes - rule["free_min"] units = math.ceil(billable / rule["unit_min"]) # 不足一个单元按一个算 fee = units * rule["unit_price"] return min(int(fee), rule["day_cap"])

math.ceil而不是round或者整除,是因为停车场普遍按"不足一小时按一小时"收,向上取整才对得上行业惯例。如果把unit_min改成 30 做半小时计费,函数不用动。

day_cap用来封顶,避免长时间停放算出天文数字。跨天停车的封顶计算要按自然日拆,不能把总时长直接套一次min(),否则停三天和停一天收一样的钱。这里给的是单次通行的简化版,跨天场景见第 6 章的改法。

4.4 道闸联动与无硬件调试

gate/barrier.py里通常有两个实现:一个走串口或继电器板,一个纯打印。切换靠gate_mode,接口签名一致,业务层不用感知。

无硬件阶段,模拟模式要尽量还原真实时序:抬杆后保持三秒再落杆,落杆期间不接受新的抬杆指令。这个"落杆期间不接受指令"的约束很关键,真实道闸的机械行程需要时间,识别线程如果不管这个状态连续发指令,串口缓冲区会堆积,表现就是"车都走了杆还在动"。

接真实硬件之后,串口参数里最容易被忽略的是超时。默认超时是 None(阻塞),一旦设备掉线,整个主线程会卡死在写操作上,界面完全无响应。设成 0.5 秒,超时就抛异常,由上层捕获后重连。

5. 避坑与排查:五个让演示版在真实车道上出问题的点

下面这五条,每条都是我在类似项目里真实遇到过的,或者在看别人部署时见过的。现象、原因、解决三段拆开写,方便对照排查。

5.1 识别环节的两个高频故障

坑一:白天识别率九成,晚上掉到三成

现象:同一台设备、同一个机位,白天车辆通行基本顺畅,晚上大量车辆需要倒车重试,日志里能看到识别结果为空的记录集中出现在夜间时段。

原因有两个叠加。一是补光灯直射导致车牌表面反光,字符和底板的对比度被压掉,固定阈值的二值化直接失效;二是夜间画面整体偏暗,自动曝光把增益拉高,噪点跟着放大,边缘检测里全是碎边。

解决上分两步。软件侧把二值化从固定阈值换成自适应阈值,BLOCK_SIZE跟着画面平均亮度动态放大;识别前加一次伽马校正把暗部提上来,比直接调亮度更不容易过曝。硬件侧把补光灯挪到偏离镜头光轴十五度以上的角度,避免正反射进镜头。这两条做完通常能把夜间识别率拉回到可接受范围,纯软件改是治标,光路才是根。

坑二:阴天正常,一到雨天大面积失败

现象:雨天识别率骤降,且失败集中在车牌下半部有明显水膜或泥点的情况,日志里能看到分割出的字符数量不足。

原因:车牌定位依赖垂直边缘的密集纹理,水膜反光会把这个特征抹平;同时雨滴挂在镜头上,整幅画面的高频信息都在抖动。分割阶段按固定投影切分,字符被水膜连成一片时切不出六到八个字符,直接判定为无效。

解决上,预处理阶段加一次形态学梯度增强(morphologyEx用MORPH_GRADIENT)能捞回一部分边缘;更实际的是在识别失败时不要立刻丢弃,而是把连续若干帧的结果做投票,取出现次数最多的那个车牌号。雨天单帧成功率低,但二十帧里只要有三四帧读对,投票就能收敛。这个改动不大,效果明显。

5.2 计费与状态机的三个坑

坑三:一辆车一次通行,写进两条入场记录

现象:出场时查不到匹配的入场记录,或者同一次通行在库里出现两条status='IN',费用翻倍。

原因:去重窗口没生效或者设得太短。另一种可能是去重判断用的是"距上一条记录的时间差",而上一条记录可能是三天前的,判断逻辑写反了,永远返回不重复。

解决:去重窗口按秒设死,并且判断对象必须是"该车牌的最近一条记录"而不是"最近一条记录"。补一个(plate, status)的联合索引,去重查询走索引而不是全表扫,响应时间能压到毫秒级。上线前手工回放一段连续视频,确认同一辆车只产生一条入场记录,这一步别省。

坑四:出场算出的费用是零,车白停了两小时

现象:出场时费用显示 0 元,但实际停了两个多小时,查库能看到这条记录确实存在且enter_ts正确。

原因:入场识别的置信度低,被写进了REVIEW状态而不是IN,出场时按status='IN'去查自然查不到,走到"无未结算记录"分支,被当成新车入场处理了。用户的观感就是出场免费、然后多出一条新的入场记录。

解决:出场查询要同时覆盖IN和REVIEW两种状态,REVIEW的记录在结算时标记为需要人工确认,而不是直接按入场处理。另外把置信度阈值调出来看看,如果REVIEW占比超过百分之十,说明识别本身有问题,得回头调定位参数,而不是靠业务层兜。

坑五:跨零点停车,费用算成两倍

现象:晚上十一点进场、次日凌晨一点出场,两小时被收了两个小时的钱乘以二,或者被按两个自然日封顶。

原因:计费函数里同时混用了"按自然日"和"按时长"两套逻辑,跨天时两个分支都命中了一次。另一个常见原因是时间戳用了本地时间字符串做差,夏令时或者时区切换的时候直接算歪。

解决:全程用 Unix 时间戳做差,只在最终展示时转成本地时间。跨天封顶要按区间拆:把[enter_ts, leave_ts]按自然日边界切成若干段,每段独立套calc_fee,再求和后跟总封顶取小。切段逻辑十来行代码,但省掉的是无穷无尽的对账纠纷。

6. 进阶:把识别日志做成能对账的凭证,纠纷时才有后悔药

识别和计费跑通之后,多数人就停手了。真正让这套系统从"能演示"变成"敢用"的,是审计流水这一层——每次抬杆都留一条不可篡改的记录,包含时间、车牌、置信度、抓拍图路径、触发来源。这层数据不做业务决策,只在出事的时候翻出来用,但正是它把"系统说收了多少"和"实际收了多少"这两件事对上。

我一般会单独建一张gate_log表,和业务表分开,只增不改:

CREATE TABLE IF NOT EXISTS gate_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, -- 抬杆时刻,Unix 秒 plate TEXT, -- 触发抬杆的车牌,人工开闸时为空 direction TEXT NOT NULL, -- IN / OUT confidence REAL, -- 识别置信度,人工开闸记 NULL snapshot TEXT, -- 抓拍图相对路径 operator TEXT DEFAULT 'auto' -- auto 或具体操作员标识 ); -- 日结对账:按小时统计进出场次数与总收入 SELECT strftime('%Y-%m-%d %H', ts, 'unixepoch', 'localtime') AS hour_bucket, direction, COUNT(*) AS cnt, COALESCE(SUM(fee_cents), 0) / 100.0 AS amount_yuan FROM parking_record r JOIN gate_log g ON g.plate = r.plate AND g.ts = r.leave_ts WHERE r.leave_ts >= :day_start AND r.leave_ts < :day_end GROUP BY hour_bucket, direction ORDER BY hour_bucket;

这条 SQL 的价值在于它把入场和出场两条独立的数据流在时间点上钉死了。如果某天的金额对不上,就按小时桶往下钻,找到异常的那一个小时,再拿snapshot路径去翻抓拍图,基本能定位到是识别错了、还是有人手动开闸没记费。

一个具体的技巧:gate_log里的snapshot不要存绝对路径。部署环境一变,比如从开发机的D:/project/迁到车场的工控机上,绝对路径全部失效,历史图一张都打不开。存相对路径,读取时拼上当前根目录,迁移只要改一个配置项。

另一个是我自己踩过的:抓拍图别只存抬杆那一帧。车牌的清晰帧往往出现在抬杆前两三百毫秒,也就是识别刚成功的时候。我现在的习惯是保留识别成功帧和抬杆帧两张,文件名用车牌_时间戳_序号.jpg,两张放一起看,能判断到底是识别错了还是后续状态流转错了。

从那以后我每次接类似项目,不管需求方怎么催,都会先把这张gate_log表建好再写业务代码——事后补日志的代价,比一开始就写高出十倍不止。希望帮到你。

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

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

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

立即咨询