Python车牌识别计费系统:从OpenCV定位到SQLite计费全流程
简介这是一套面向Python初学者与计算机视觉爱好者的智能停车场车牌识别计费系统完整源码基于百度AI开放平台图片识别接口实现车牌自动识别、车辆出入场判断、收入统计与车位满预警等核心功能适合作为课程设计、毕业设计或Python项目实战的参考案例。资源包共2003个文件以1777个py源码文件为主体辅以pyc编译文件、txt配置说明、h头文件、xml布局、pdf文档及少量js、html、css等前端资源压缩包约189.59MB目录结构完整便于按模块查阅与二次开发。使用前需联网并在key.txt中配置百度AI的Key项目根目录附有详细程序使用说明文档。目前已有247人学习下载。通过本资源可掌握车牌识别接口调用、车辆进出场逻辑设计、柱状图收入统计以及基于历史数据的车位预警算法等实用技能是学习Python图像识别与业务系统整合的优质实践素材。1. 从一张出场照片到一笔停车费Python 车牌识别计费系统到底在算什么地下车库出口车压到地感线圈相机抓一张车头照道闸要在两秒内决定抬不抬杆、屏幕上跳多少钱。这背后是一条很短的链路车牌定位、字符识别、入场记录匹配、按时长算费、抬杆放行。用 Python 把这条链路串起来就是标题里说的智能停车场车牌识别计费系统。它适合谁做小区、园区、商超停车场改造的集成商想用一套能自己改的源码替代黑盒板卡也适合刚学完 OpenCV、想找一个能跑通全流程项目的开发者。源码加使用说明的价值不在代码多长而在于你能看清每一步的输入输出出了问题知道去哪一行打日志而不是对着一个只会报「识别失败」的盒子干瞪眼。2. 车牌识别这一环从 OpenCV 预处理到字符切分2.1 为什么不用深度学习一把梭很多人第一反应是上 YOLO 加 CRNN端到端识别。方向没错但落地到停车场出口这种场景你要考虑三件事一是硬件出口工控机往往没有独显跑轻量模型还行跑大模型帧率掉到个位数二是数据你手里可能只有几百张现场图标注成本高模型容易过拟合到某个角度三是可解释性识别错了你要能定位是定位框偏了还是字符切错了。所以常见做法是先用传统图像处理做定位和切分把车牌区域抠出来再对单个字符做模板匹配或轻量分类。这套方案在光照均匀、角度正对的车场出口足够用而且每一步都能可视化调试。我一般会这样拆灰度化、高斯模糊、Sobel 边缘检测、形态学闭运算连成块、按长宽比和面积筛出候选车牌区域、透视校正、二值化、垂直投影切字符。下面这段代码是定位部分的核心跑通它你就能看到候选框画在原图上。import cv2 import numpy as np def locate_plate(image_path): # 读图并转灰度减少计算量 img cv2.imread(image_path) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊压掉噪点核大小按分辨率调1080P 用 5 够 blur cv2.GaussianBlur(gray, (5, 5), 0) # Sobel 取水平边缘车牌字符垂直边缘丰富 sobel cv2.Sobel(blur, cv2.CV_8U, 1, 0, ksize3) # 二值化阈值 0 配合 OTSU 自动找 _, binary cv2.threshold(sobel, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 闭运算把断开的字符连成整块 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 找轮廓 contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) ratio w / float(h) # 国内蓝牌长宽比约 3.14放宽到 2.5 到 4.5 if 2.5 ratio 4.5 and w 80: candidates.append((x, y, w, h)) return img, candidates逻辑说明Sobel 只取水平方向是因为车牌字符的垂直笔画在水平梯度上响应强竖直边缘会被弱化这样背景里的横线干扰少。闭运算的核宽度 17 是经验值太小连不上「京A」这种字间距太大容易把相邻的车标也吞进来。筛选条件里w 80是防止把远处的小广告牌误判实际部署时按相机分辨率和安装距离调1080P 下出口相机一般车牌宽度在 120 到 200 像素之间。参数怎么改高斯核从 5 加到 7 适合噪点大的老旧相机闭运算核宽度按车牌字符间距的 1.5 倍估长宽比范围如果现场有新能源绿牌绿牌比例接近 3.0可以统一用 2.5 到 4.5 覆盖。2.2 字符切分与模板匹配的实操细节定位到车牌后透视校正把倾斜的车牌拉正再二值化。二值化这里有个坑全局阈值在车牌有反光时会把字符吃掉我一般用局部自适应阈值块大小取 15 到 25 之间。切分用垂直投影统计每一列白色像素个数字符之间的谷底就是切分点。def split_chars(plate_binary): # 垂直投影每列白点数量 projection np.sum(plate_binary, axis0) / 255 # 找投影小于阈值的列作为切分点 threshold plate_binary.shape[0] * 0.15 splits [] in_char False start 0 for i, val in enumerate(projection): if val threshold and not in_char: in_char True start i elif val threshold and in_char: in_char False # 宽度太小的当噪点丢掉 if i - start 5: splits.append((start, i)) return splits逻辑说明threshold取行高的 15%是因为字符笔画在投影上至少占这个比例低于它的列基本是字符间隙。i - start 5过滤掉铆钉或污渍造成的窄条。切完一般得到 7 个字符第一个是汉字后面是字母数字。汉字识别用模板匹配容易翻车因为汉字笔画多、字体差异大我一般把汉字单独用一个小型 CNN 分类或者直接调一个轻量 OCR 只认汉字位。字母数字用模板匹配就够模板从现场图里裁几十张做平均比网上找的通用字体准。提示模板匹配前把字符统一缩放到 20x40归一化后再算相似度否则大小不一致会让匹配分数失真。3. 计费逻辑从入场记录到出场结算的数据结构3.1 用 SQLite 存进出场记录字段怎么设计识别出车牌只是第一步计费靠的是入场和出场两条记录的时间差。数据库表设计得糙后面查费率、查逃费都难受。我一般用 SQLite单机够用不用装服务。核心表两张vehicle_record存进出场fee_rule存费率。CREATE TABLE vehicle_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, entry_time DATETIME NOT NULL, exit_time DATETIME, entry_photo TEXT, exit_photo TEXT, fee REAL DEFAULT 0, status INTEGER DEFAULT 0 -- 0 在场 1 已出场 ); CREATE INDEX idx_plate_status ON vehicle_record(plate_no, status); CREATE TABLE fee_rule ( id INTEGER PRIMARY KEY, free_minutes INTEGER DEFAULT 15, hourly_rate REAL DEFAULT 5.0, daily_cap REAL DEFAULT 30.0 );逻辑说明status字段是关键查车是否在场时用plate_no status0走索引比按时间范围扫快得多。entry_photo和exit_photo存文件路径不存二进制数据库体积可控。费率表单独拆出来改价格不用动代码运营人员自己就能改。参数怎么改free_minutes免费时长小区一般 15 到 30 分钟商超 30 到 60 分钟hourly_rate按小时单价daily_cap封顶价防止停一天算出天价。这三个值直接决定用户投诉率上线前拿真实场景跑一周再定。3.2 计费函数不足一小时怎么算、跨天怎么处理计费函数看着简单边界条件一堆。不足一小时按一小时还是按半小时跨天要不要重新计免费时长出场时找不到入场记录怎么办。下面这个实现覆盖了常见规则。from datetime import datetime def calc_fee(entry_time, exit_time, rule): # 解析时间格式按实际存储调整 fmt %Y-%m-%d %H:%M:%S t_in datetime.strptime(entry_time, fmt) t_out datetime.strptime(exit_time, fmt) minutes (t_out - t_in).total_seconds() / 60 # 免费时长内不收费 if minutes rule[free_minutes]: return 0.0 # 计费时长扣除免费部分 billable minutes - rule[free_minutes] # 不足一小时按一小时向上取整 hours int(billable // 60) (1 if billable % 60 0 else 0) fee hours * rule[hourly_rate] # 封顶 if fee rule[daily_cap]: fee rule[daily_cap] return round(fee, 2)逻辑说明billable % 60 0判断是否有零头有就加一小时这是停车场行业最常见的「不足一小时按一小时」。跨天场景这里没展开如果要做在算 hours 之前先判断t_out.date() ! t_in.date()按天数乘封顶价再加当天零头具体规则看运营要求。参数怎么改把向上取整改成按半小时把hours的计算换成billable // 30再乘半小时单价免费时长如果只针对当天首次入场需要在查询时加日期条件。注意时间字符串的格式必须和数据库里存的一致SQLite 的 DATETIME 默认是YYYY-MM-DD HH:MM:SS如果你用 Python 的datetime.now()直接存记得strftime格式化否则解析报错。4. 把识别和计费串起来一个能跑的最小闭环4.1 主流程代码从图片到费用输出前面两块单独跑通后串起来就是一个函数传入出场图片识别车牌查入场记录算费更新出场记录。下面是最小闭环。import sqlite3 from datetime import datetime def process_exit(image_path, db_path): conn sqlite3.connect(db_path) cursor conn.cursor() # 1. 识别车牌这里假设 recognize_plate 返回字符串 plate_no recognize_plate(image_path) if not plate_no: return {code: 1, msg: 识别失败} # 2. 查该车牌最近一条未出场记录 cursor.execute( SELECT id, entry_time FROM vehicle_record WHERE plate_no? AND status0 ORDER BY entry_time DESC LIMIT 1, (plate_no,) ) row cursor.fetchone() if not row: # 没入场记录按临时车处理或放行 return {code: 2, msg: 无入场记录, plate: plate_no} record_id, entry_time row # 3. 读费率 cursor.execute(SELECT free_minutes, hourly_rate, daily_cap FROM fee_rule WHERE id1) rule_row cursor.fetchone() rule {free_minutes: rule_row[0], hourly_rate: rule_row[1], daily_cap: rule_row[2]} # 4. 算费 exit_time datetime.now().strftime(%Y-%m-%d %H:%M:%S) fee calc_fee(entry_time, exit_time, rule) # 5. 更新记录 cursor.execute( UPDATE vehicle_record SET exit_time?, exit_photo?, fee?, status1 WHERE id?, (exit_time, image_path, fee, record_id) ) conn.commit() conn.close() return {code: 0, plate: plate_no, fee: fee, entry: entry_time, exit: exit_time}逻辑说明查询用ORDER BY entry_time DESC LIMIT 1取最近一条防止同一车牌有多条未出场记录时算错。status0是过滤条件配合前面的索引。返回结构里带code方便上层判断0 成功、1 识别失败、2 无记录道闸控制逻辑根据 code 决定抬杆还是转人工。参数怎么改recognize_plate是你自己封装的识别函数返回空字符串表示失败数据库路径按部署环境改如果要做临时车收费在无入场记录分支里加一条插入记录再算费。4.2 用 Flask 暴露一个 HTTP 接口给道闸调用道闸控制器一般通过 HTTP 或串口通信用 Flask 包一层最省事。下面这个接口接收图片路径或 base64返回 JSON。from flask import Flask, request, jsonify app Flask(__name__) app.route(/exit, methods[POST]) def exit_api(): data request.get_json() image_path data.get(image_path) if not image_path: return jsonify({code: 1, msg: 缺少图片路径}) result process_exit(image_path, parking.db) return jsonify(result) if __name__ __main__: app.run(host0.0.0.0, port5000)逻辑说明接口只做参数校验和转发业务逻辑在process_exit里方便单独测试。host0.0.0.0让同网段设备能访问生产环境前面加 Nginx 做转发和限流。参数怎么改端口 5000 如果被占改 5001如果道闸传的是 base64 图片在接口里先解码存临时文件再传路径给识别函数。提示识别函数耗时通常在 200 到 500 毫秒接口超时设 3 秒以上别用默认的 1 秒否则道闸那边会频繁超时重试。5. 避坑与排查上线后最容易翻车的五个地方5.1 识别率白天正常晚上掉到一半现象白天识别率 95% 以上晚上车灯一开识别率掉到 50% 以下。原因车灯直射造成局部过曝Sobel 边缘检测在过曝区域全是噪声候选框筛选把真车牌过滤掉了。解决在定位前加一步自适应直方图均衡化CLAHE限制对比度防止噪声放大同时把相机曝光调成手动固定快门别用自动曝光。clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray clahe.apply(gray)clipLimit从 2.0 起调太高会把噪点也拉出来tileGridSize按图像分辨率1080P 用 8x8 合适。5.2 同一辆车出场算出两笔费用现象车主出场后道闸没抬杆倒车再进一次系统又生成一条入场记录下次出场匹配到两条费用翻倍。原因出场更新记录失败或道闸通信超时status没改成 1。解决出场更新用事务更新后检查rowcount为 0 说明没更新成功回滚并重试同时在道闸侧加防倒车逻辑出场失败时保持杆不抬等人工。5.3 车牌带「·」或新能源绿牌识别成乱码现象新能源车牌比蓝牌多一位字符切分按 7 位切最后一位被丢掉。原因切分逻辑写死了 7 个字符。解决切分后判断字符数8 位按新能源处理汉字位单独识别模板匹配的字符集里加上新能源专用的字母组合。5.4 数据库锁死接口全部超时现象高峰期多个道闸同时请求SQLite 报database is locked。原因SQLite 写操作会锁库并发写超过一个就排队。解决把连接超时设长sqlite3.connect(db_path, timeout10)写操作集中到一个队列里串行处理或者直接换 MySQL。停车场出口并发不高但入口和出口同时写就会撞。5.5 时间格式不一致导致计费为负现象算出来的费用是负数。原因入场时间存的是 UTC出场用本地时间时差导致t_out t_in。解决统一用本地时间存或者存时间戳整数展示时再格式化。数据库里加个检查exit_time entry_time才更新。6. 进阶技巧用日志和回放把识别率从 90% 推到 98%上线只是开始真正拉开差距的是出问题后你能不能快速定位。我习惯在识别函数里把每一步的中间图存下来按日期分目录文件名带时间戳和车牌。这样当车主投诉识别错了你翻出那张图看候选框画在哪、二值化后字符清不清楚五分钟就能判断是相机问题还是算法问题。import os from datetime import datetime def save_debug(img, candidates, plate_binary, plate_no): # 按日期建目录避免单目录文件过多 day datetime.now().strftime(%Y%m%d) debug_dir os.path.join(debug, day) os.makedirs(debug_dir, exist_okTrue) ts datetime.now().strftime(%H%M%S) # 原图加候选框 for (x, y, w, h) in candidates: cv2.rectangle(img, (x, y), (x w, y h), (0, 255, 0), 2) cv2.imwrite(os.path.join(debug_dir, f{ts}_{plate_no}_cand.jpg), img) # 二值化图 cv2.imwrite(os.path.join(debug_dir, f{ts}_{plate_no}_bin.jpg), plate_binary)逻辑说明candidates画框能看出定位有没有偏plate_binary能看出二值化有没有把字符吃掉。文件名带车牌方便按车牌搜。这个习惯帮我省了无数次现场跑腿很多问题看一张图就清楚了。回放测试是另一个技巧。把历史出场图片按日期整理成一个列表写个脚本批量跑识别统计识别率和耗时。每次改完参数跑一遍回放看识别率是涨了还是跌了别凭感觉调参。import glob def replay_test(image_dir, ground_truth): # ground_truth 是 {文件名: 真实车牌} 的字典 correct 0 total 0 for path in glob.glob(os.path.join(image_dir, *.jpg)): name os.path.basename(path) if name not in ground_truth: continue total 1 pred recognize_plate(path) if pred ground_truth[name]: correct 1 print(f识别率: {correct}/{total} {correct/total*100:.2f}%)ground_truth需要你人工标几十张标 100 张就够看趋势。每次调完 CLAHE 的clipLimit或闭运算核大小跑一遍回放识别率涨了再上线跌了就回滚。这个流程比在出口蹲着看车靠谱得多。我自己的习惯是任何参数改动先回放测试再灰度上线一个出口观察一天没问题再推全部。停车场这行稳定比先进重要一个出口堵十分钟投诉电话就来了。希望帮到你。本文还有配套的精品资源点击获取