Python车牌识别计费系统实战:从OCR识别到状态机与跨天计费
简介面向需要构建智能停车管理系统的 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, fillblack, fontfont) img.save(path)Windows 直接用系统字体里的simhei.ttfLinux 需要先安装中文字体包。生成后调用识别函数python -c from recognize import recognize_plate; print(recognize_plate(sample_plate.jpg))第一次运行要加载 ONNX 模型等几秒才出结果这是正常现象。输出车牌文本说明链路通了输出 None 先检查图片是不是全黑、字体有没有渲染出来、阈值会不会太高。这个最小闭环跑通后再写计费逻辑才不心虚。真实项目里摄像头通常走 RTSP 流常见做法是抽帧识别而不是每帧都跑import cv2 cap cv2.VideoCapture(rtsp://user:passcamera_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 statusactive, (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(hour23, minute59, second59) slice_end min(exit, day_end) total calc_fee(day_start, slice_end, rules) day_start slice_end timedelta(seconds1) 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 statusactive 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?, statuscompleted WHERE id?, (now.strftime(%Y-%m-%d %H:%M:%S), fee, record_id), ) conn.commit() conn.close() return fee, ok白名单车辆可以在结算前先查vip_recordsvip_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_cap3 天总时长 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?, statuscompleted WHERE id? AND statusactive, (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 字段每个计费操作都留下可追溯日志这些习惯比任何花哨的算法都更值钱。希望这篇笔记能帮你少走一段弯路把系统从「跑得动」做到「敢收钱」。本文还有配套的精品资源点击获取