简介:这份智能停车场车牌识别计费系统源码包面向Python初学者、课程设计与毕业设计开发者,基于PyCharm运行并接入百度AI图片识别服务,可完成车牌识别、车辆进出场判断、收入统计柱状图展示和车位满预警。压缩包共2003个文件、约189.59MB,以1777个py源码为主体,辅以pyc、txt说明、xml/json配置、html/css/js页面资源及pdf/doc文档,便于阅读代码、配置环境和二次开发。已有247人学习下载,适合练习图像识别接口调用、停车场计费逻辑、数据统计可视化和预警规则设计。随包的使用说明可帮助读者替换百度AI Key、跑通主界面与核心流程,并参考目录结构与实现思路进行功能扩展或项目复现。
1. 从一辆车进场说起:这套 Python 智能停车场车牌识别计费系统到底能干什么
晚高峰的小区门口,保安一手扶栏杆一手拿登记本,弯腰抄车牌,后面排了七八辆车。这类场景用一个摄像头加一套软件就能拿掉,而这个 Python 智能停车场车牌识别计费系统做的就是这件事:摄像头抓一帧画面,OpenCV 在画面里找出车牌、切出字符、识别成字符串,命中后写一条入场记录;出场再识别一次,拿两个时间戳套费率表算钱,落库、开闸、打印或推送账单。
它适合三类人:想完整走一遍“图像处理 → 业务状态机 → 数据落库”链路的开发者,要做门禁或小型车场项目的同学,以及手里有几个车位、几十个车位想把手写登记换掉的技术型物业。单出入口、几百条日流水的规模下,PyQt5 桌面端 + SQLite 这套组合足够,不用上服务器集群。
2. 技术栈与目录结构:从摄像头帧到账单落库的分层
2.1 技术选型:为什么是 OpenCV + PyQt5 + SQLite 这套组合
先把整套东西的边界讲清楚,后面看代码才不迷路。这个项目的输入有两个:一路视频(USB 摄像头、RTSP 网络流或者本地视频文件),一份费率配置。输出有两个:一条带进出时间和金额的停车记录,一张可打印的小票或界面上的结算弹窗。
选型上,图像这一层绕不开 OpenCV——cv2.VideoCapture读流,cv2.cvtColor转色彩空间,形态学、轮廓、仿射变换这些算子都是现成的,没必要自己造。识别这一层有两种常见做法:一种是纯自研,自己定位、自己分割、自己训一个小 CNN 做字符分类;另一种是拿一个端到端的开源识别库当黑盒,前面只做画面裁剪和预处理。我一般会先跑通端到端方案把业务逻辑验证完,再回头替换定位和识别部分,这样不会在“识别率上不去”的玄学里卡住整个项目进度。
界面层用 PyQt5:一个主窗口左侧是视频画面,右侧是在场车辆列表,底部是费率设置和手动放行按钮。为什么不 Web?因为这套东西的部署环境大概率是一台放在岗亭里的 Windows 机器,装个 Python 环境双击运行最省事,不用折腾端口和浏览器。数据库用 SQLite,单文件、零配置、支持事务和索引,几百条日流水完全够,而且拷给别人时只需要带走一个.db文件——这一点对课程设计和答辩演示特别友好。
依赖清单大致是这样,写进requirements.txt:
opencv-python==4.9.0.80 numpy==1.26.4 PyQt5==5.15.10 Pillow==10.2.0 pyttsx3==2.90 # 可选:播报"XX车入场,请通行" python-dateutil==2.9.0版本号不用死磕,但opencv-python和numpy的搭配要注意,某些老版本组合在cv2.Sobel传ksize时会抛类型错误,这是很典型的环境坑,后面排查章节会细说。
2.2 目录结构:八个文件撑起整个系统
这份源码的目录组织是典型的“小而完整”,没有过度分层,但每个文件职责很明确:
smart_parking/ ├── main.py # 程序入口,初始化数据库 + 拉起主窗口 ├── config.py # 全局配置:摄像头地址、阈值参数、费率默认值 ├── core/ │ ├── camera.py # 视频源封装,统一本地/USB/RTSP 三种输入 │ ├── plate_locator.py # 车牌粗定位 + 精定位 + 倾斜校正 │ ├── char_segment.py # 字符分割:二值化 + 垂直投影 │ ├── recognizer.py # 字符识别,加载模型并输出车牌字符串 │ └── validator.py # 识别结果校验:省份、长度、字符集 ├── business/ │ ├── billing.py # 计费核心:时长计算、费率套用、封顶 │ └── parking_service.py # 入场/出场业务编排,防重复入场 ├── data/ │ ├── db.py # 连接池、建表、增删改查 │ └── schema.sql # 三张表的建表语句 ├── ui/ │ ├── main_window.py # 主界面,视频刷新 + 在场列表 │ └── widgets.py # 结算弹窗、费率设置对话框 └── resources/ ├── model/ # 字符分类模型(.onnx 或 .h5) └── images/ # 图标、测试图片这套拆法的好处是,core目录完全不依赖 PyQt5 和数据库,你可以单独写个脚本python -c "from core.plate_locator import locate"把一张图片丢进去调参,不用每次都启动整个界面。我强烈建议保留这个边界——很多人图快,把识别代码直接写进界面按钮的回调里,调一次参数重启一次窗口,效率低到怀疑人生。
config.py里集中放这些可调项:
# config.py CAMERA_SOURCE = 0 # 0 表示本机默认摄像头,也可填 rtsp 地址字符串 FRAME_WIDTH, FRAME_HEIGHT = 1280, 720 DETECT_INTERVAL_MS = 300 # 界面刷新间隔,太快会吃满 CPU PLATE_MIN_AREA = 2000 # 候选框最小面积,按 1280x720 调的 PLATE_ASPECT_RANGE = (2.2, 5.5) # 中国车牌宽高比大致在这个区间 CONFIDENCE_THRESHOLD = 0.85 # 低于这个分数不自动放行,转人工 DB_PATH = "data/parking.db"DETECT_INTERVAL_MS这个参数值得单独说一句。很多人第一次跑会把定时器设成 30ms,想着“越快越灵敏”,结果单帧识别要 80~150ms,定时器排队把界面直接卡死,看起来像程序无响应。300ms 是个体面的折中:一秒三次,车开到栏杆前足够抓好几帧。
2.3 数据表与配置:三张表把车牌、时间和钱串起来
数据库这块设计得越简单越好,别一上来就上多表关联。核心其实就两张表:一张停车记录,一张费率规则,再加一张可选的白名单(月租车、内部车)。
-- data/schema.sql CREATE TABLE IF NOT EXISTS parking_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, -- 车牌号,如 京A12345 entry_time TEXT NOT NULL, -- ISO 格式:2024-05-20 08:31:12 entry_image TEXT, -- 入场抓拍图的相对路径 exit_time TEXT, exit_image TEXT, duration_minutes INTEGER DEFAULT 0, fee REAL DEFAULT 0, status TEXT NOT NULL DEFAULT 'IN', -- IN / OUT / EXCEPTION lane TEXT -- 车道号,多出入口时用 ); -- 按车牌 + 状态查询是最高频的操作,必须有索引 CREATE INDEX IF NOT EXISTS idx_plate_status ON parking_record (plate_no, status); CREATE TABLE IF NOT EXISTS rate_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, rule_name TEXT NOT NULL, free_minutes INTEGER DEFAULT 15, -- 免费时长 first_hour_fee REAL DEFAULT 5.0, -- 首小时 per_hour_fee REAL DEFAULT 3.0, -- 之后每小时 daily_cap REAL DEFAULT 30.0, -- 24 小时封顶 is_active INTEGER DEFAULT 1 ); CREATE TABLE IF NOT EXISTS whitelist ( plate_no TEXT PRIMARY KEY, owner TEXT, expire_at TEXT -- 月租到期时间,过期后按临时车计费 );几个设计取舍说明一下。entry_time和exit_time存的是 TEXT 而不是时间戳整数,好处是直接打开数据库文件肉眼可读,排查问题时不用换算;代价是范围查询要做字符串比较,但因为格式固定为YYYY-MM-DD HH:MM:SS,字典序和时间序一致,BETWEEN照样能用。status用IN/OUT/EXCEPTION三态,第三种是给识别失败或人工抬杆留的口子——现场一定会出现“车牌被泥糊住”“临时车牌贴纸”这类情况,没有异常状态兜底,账就对不平。
费率我做成表而不是写死在代码里,是为了后面能改。真实现场经常出现“白天 3 元/小时、夜间 1 元/小时”“前 30 分钟免费”这种调整,改代码要重启,改表只需要在界面上点一下。
3. 车牌识别核心链路:从一帧画面到一张车牌字符串
3.1 预处理与粗定位:颜色通道和边缘通道双保险
车牌定位是整条链路的第一道关,也是决定上限的一步——框歪了、框大了,后面分割和识别再调也救不回来。中国车牌有几个先验特征可以直接用:蓝底白字、黄底黑字、绿底黑字(新能源)、白底黑字(警用),尺寸比例大致 440:140,也就是宽高比 3.14 左右。
单一特征都不够稳,所以常见做法是双通道并行再合并:颜色通道负责“大概率在哪”,边缘通道负责“纹理密度够不够”。我一般写成这样:
# core/plate_locator.py import cv2 import numpy as np def coarse_locate(bgr: np.ndarray) -> list: """粗定位:返回候选车牌框列表 [(x, y, w, h), ...]""" # 通道一:HSV 蓝色掩膜,覆盖蓝底车牌 hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) lower_blue = np.array([100, 90, 90]) # OpenCV 的 H 取值 0~180 upper_blue = np.array([140, 255, 255]) # 蓝色集中在 100~140 blue_mask = cv2.inRange(hsv, lower_blue, upper_blue) # 通道二:Sobel 横向梯度,字符区垂直边缘密集 gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (5, 5), 0) # 先降噪,否则噪点全被当成边缘 sobel = cv2.Sobel(gray, cv2.CV_8U, 1, 0, ksize=3) # dx=1, dy=0 取横向梯度 _, edge_mask = cv2.threshold(sobel, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) # 形态学闭运算把碎裂的字符块连成一整块车牌 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) blue_mask = cv2.morphologyEx(blue_mask, cv2.MORPH_CLOSE, kernel) edge_mask = cv2.morphologyEx(edge_mask, cv2.MORPH_CLOSE, kernel) mask = cv2.bitwise_or(blue_mask, edge_mask) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if h == 0 or w == 0: continue aspect = w / h if w * h < 2000: # 面积太小,多半是噪点 continue if not (2.2 <= aspect <= 5.5): # 宽高比不合,直接丢 continue candidates.append((x, y, w, h)) return candidates参数逐个解释。lower_blue和upper_blue是 HSV 阈值,H在 OpenCV 里是 0~180 而不是 0~360,这是新手最容易踩的坑——按 240 的蓝色去设阈值,掩膜永远是全黑。S给到 90 的下限是为了避开灰白色的天空和墙面,V同理。kernel的(17, 5)是横向拉长、纵向压扁的矩形,符合车牌横长条的形状;如果车牌在画面里很小,这个核要相应调小,不然闭运算会把相邻的两辆车糊成一块。
面积阈值2000是配 1280×720 分辨率调的,换成 640×480 就该降到 800 左右。这种“硬编码数字跟随分辨率变化”的问题,最省事的做法就是统一先cv2.resize到标准尺寸再处理,别让输入尺寸飘。
3.2 倾斜校正与精定位:把歪车牌扶正
粗定位拿到的框可能是歪的,尤其车没停正、摄像头有俯仰角的时候。字符分割对倾斜极其敏感,歪个五六度,垂直投影的峰就分不开,七个字符切出八个碎片。所以中间要插一步:用cv2.minAreaRect求最小外接旋转矩形,拿角度做仿射变换,把车牌掰正。
def deskew_and_crop(bgr: np.ndarray, box: tuple) -> np.ndarray: """倾斜校正 + 裁剪,输出统一尺寸的车牌灰度图""" x, y, w, h = box # 先给一点外扩,避免把车牌边缘切掉 pad = int(h * 0.15) x0, y0 = max(0, x - pad), max(0, y - pad) x1, y1 = min(bgr.shape[1], x + w + pad), min(bgr.shape[0], y + h + pad) roi = bgr[y0:y1, x0:x1] gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) gray = cv2.GaussianBlur(gray, (3, 3), 0) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return cv2.resize(roi, (440, 140)) # 兜底:直接拉伸成标准尺寸 rect = cv2.minAreaRect(max(contours, key=cv2.contourArea)) box_pts = cv2.boxPoints(rect) angle = rect[2] if angle < -45: # 统一角度约定,避免-90~0摆动 angle += 90 center = (roi.shape[1] / 2, roi.shape[0] / 2) m = cv2.getRotationMatrix2D(center, angle, 1.0) rotated = cv2.warpAffine(roi, m, (roi.shape[1], roi.shape[0]), flags=cv2.INTER_CUBIC, borderValue=(0, 0, 0)) # 校正后再框一次,这次用正矩形裁 gray2 = cv2.cvtColor(rotated, cv2.COLOR_BGR2GRAY) _, bin2 = cv2.threshold(gray2, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) cnts2, _ = cv2.findContours(bin2, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not cnts2: return cv2.resize(rotated, (440, 140)) bx, by, bw, bh = cv2.boundingRect(max(cnts2, key=cv2.contourArea)) plate = rotated[by:by + bh, bx:bx + bw] return cv2.resize(plate, (440, 140), interpolation=cv2.INTER_CUBIC)resize到 440×140 这一步很关键,它不是随手写的数字,而是中国车牌的标准物理比例。统一到固定尺寸之后,后面字符分割的阈值、卷积核大小、模型输入尺寸全都不用再随分辨率改,代码一下子就稳了。angle那个<-45的处理是minAreaRect的老毛病,它返回的角度在[-90, 0)之间,不归一化的话旋转方向会时正时反,表现出来就是“有时候校正对了,有时候扭了 90 度”,属于典型的玄学 bug,加两行就解决。
INTER_CUBIC而不是默认的INTER_LINEAR,是因为放大插值时三次插值保留的边缘更锐利,对后面 Otsu 二值化更友好;代价是慢一点点,在这个尺度下可以忽略。
3.3 字符分割:垂直投影切出七个字符
到这一步手里是一张 440×140 的端正车牌图,接下来要把它切成一个个单独的字符。中国车牌有两类:普通车 7 位(省份简称 + 字母 + 5 位字母数字,新能源是 8 位,字母位多一点)。切分的方法经典但有效——垂直投影。
先把车牌二值化成白字黑底(注意蓝底白字的车牌要反色),然后按列统计白色像素个数,字符所在列的值高,字符之间的间隔列接近 0:
# core/char_segment.py import cv2 import numpy as np def segment_chars(plate_bgr: np.ndarray, expect: int = 7) -> list: """垂直投影分割字符,返回字符小图列表""" gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) h, w = binary.shape # 抹掉上下边框和铆钉:车牌字符基本落在中间 60% 的高度带里 binary[: int(h * 0.12), :] = 0 binary[int(h * 0.90):, :] = 0 # 垂直投影:每一列的白像素数量 col_sum = (binary > 0).sum(axis=0) # 用列均值的 15% 做动态阈值,比写死数字稳 thr = max(2, int(col_sum.mean() * 0.15)) in_char, start = False, 0 spans = [] for i, v in enumerate(col_sum): if v > thr and not in_char: in_char, start = True, i elif v <= thr and in_char: in_char = False if i - start >= 8: # 宽度太小的是噪点或铆钉 spans.append((start, i)) if in_char: spans.append((start, w)) # 字符数对不上时按等宽兜底,保证下游拿到固定数量 if len(spans) != expect: step = w // expect spans = [(i * step, (i + 1) * step) for i in range(expect)] chars = [] for x0, x1 in spans: sub = binary[:, x0:x1] rows = np.where((sub > 0).sum(axis=1) > 0)[0] if len(rows) == 0: continue chars.append(cv2.resize(sub[rows[0]:rows[-1] + 1, :], (20, 40))) return chars几个细节值得抠一下。第一,THRESH_BINARY_INV是把白字变前景;如果换成黄底黑字车牌,这里就不该反色,工程里通常是先判断底色再决定。第二,上下各砍掉 12% 和 10%,是为了避开边框线和固定螺丝,不砍的话投影图两头各有一个大峰,会被误当成字符。第三,阈值用col_sum.mean() * 0.15动态算,比写死一个 5 更抗光照变化——逆光下整体像素值偏低,写死阈值会把所有列都判成“无字符”。
最后的兜底逻辑是血泪经验:投影法在车牌有污渍、反光、字粘连时会切多或切少,一旦切错,后面的识别模型输入维度就对不上,直接抛异常整个流程挂掉。所以我宁可等宽切一刀,让识别去“猜”,也不要让程序崩。识别错最多是人工核对一次,崩了就是现场堵车。
3.4 识别与结果校验:省份简称是最便宜的一道过滤
字符小图有了,接下来是分类。工程上有两条路:一是自己训练一个轻量 CNN(输入 20×40 灰度,几层卷积加全连接,输出 65 类);二是调用一个端到端的车牌识别库,直接吃整张车牌图输出字符串。前者的好处是可控、可自己加数据微调,坏处是标注成本;后者上手快,但遇到本地的特殊车牌格式(比如带“使”“领”字)容易失灵。
不管走哪条,输出都必须过一道校验,这一步很多人省掉,然后被误识别坑得很惨:
# core/validator.py PROVINCES = set("京津沪渝冀豫云辽黑湘皖鲁新苏浙赣鄂桂甘晋蒙陕吉闽贵粤青藏川宁琼") LETTERS = set("ABCDEFGHJKLMNPQRSTUVWXYZ") # 车牌不用 I 和 O,避免和 1、0 混淆 DIGITS = set("0123456789") def validate(plate: str) -> tuple: """返回 (是否可信, 原因)""" plate = plate.strip().upper().replace(" ", "") if len(plate) not in (7, 8): return False, f"长度异常: {len(plate)}" if plate[0] not in PROVINCES: return False, f"首字不是省份简称: {plate[0]}" if plate[1] not in LETTERS: return False, f"第二位不是合法字母: {plate[1]}" rest = plate[2:] if not all(c in LETTERS | DIGITS for c in rest): return False, "含非法字符" return True, "ok"PROVINCES和LETTERS这两张表看着朴素,实际拦截率相当高。识别模型最常见的错误是把“京”认成“凉”、把“0”认成“O”、把“8”认成“B”,而车牌字符集本身就把I和O排除掉了,所以任何出现I/O的结果一定是错的,直接判废重识别,比降低置信度阈值靠谱得多。
校验通过之后还要过一道业务层的置信度门槛。CONFIDENCE_THRESHOLD = 0.85意味着低于这个分数不自动开闸,弹窗让值班人员看一眼确认。这个设计的用意是:自动化的收益在于“大多数情况不用管”,而不是“所有情况都不管”。真出了错,一次误放行的成本远高于多看一眼的成本。校验失败时把原始图和候选字符串一起落到EXCEPTION记录里,后面可以拿这些样本回来重新训练模型,形成闭环。
4. 计费与进出场业务:时间戳、费率表和状态机
4.1 入场:先查在场记录再放行
识别出车牌只是拿到了一个字符串,真正让它变成“一辆车在场”的,是业务层的一次状态迁移。入场流程按顺序做四件事:识别 → 校验 → 查重 → 落库。
# business/parking_service.py from datetime import datetime from data.db import get_conn def check_in(plate_no: str, image_path: str = "", lane: str = "A") -> dict: """车辆入场,返回 {ok, msg, record_id}""" conn = get_conn() cur = conn.cursor() # 一、查这辆车有没有未结算的在場记录 cur.execute( "SELECT id, entry_time FROM parking_record " "WHERE plate_no = ? AND status = 'IN' ORDER BY id DESC LIMIT 1", (plate_no,) ) row = cur.fetchone() if row: # 已经在场:可能是识别抖动导致的重复触发,直接忽略,不重复记账 return {"ok": False, "msg": f"该车已于 {row[1]} 入场,忽略重复识别", "record_id": row[0]} # 二、查月租白名单,标记类型(计费时用得上) cur.execute("SELECT expire_at FROM whitelist WHERE plate_no = ?", (plate_no,)) wl = cur.fetchone() is_monthly = bool(wl and wl[0] and wl[0] >= datetime.now().strftime("%Y-%m-%d")) # 三、写入场记录 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur.execute( "INSERT INTO parking_record (plate_no, entry_time, entry_image, status, lane) " "VALUES (?, ?, ?, 'IN', ?)", (plate_no, now, image_path, lane) ) conn.commit() return {"ok": True, "msg": f"{plate_no} 入场成功" + ("(月租车)" if is_monthly else ""), "record_id": cur.lastrowid}查重这一步是必须的。摄像头连续抓帧,一辆车在栏杆前停三秒,可能被识别出五六次,每次都插一条记录的话,出场时会查到一堆IN状态的记录,账单直接乱套。用status='IN'做去重键,逻辑上等价于“同一辆车同时只能有一次在场”,简单且够用。
is_monthly那个判断其实还有更严谨的写法——expire_at应该转成date对象比较,字符串比较在跨年时会出问题(比如2024-12-31和2025-01-01字典序是对的,但2024-1-5和2024-12-01就错了)。所以写入时务必统一成补零格式,或者干脆用date.fromisoformat解析后再比。
4.2 计费函数:Decimal、免费时长和封顶价
计费是整个系统里最容易扯皮的部分,一分钱算错车主就会来找。核心就三件事:算时长、套费率、做封顶。写这段代码有个铁律——金额一律用Decimal,不要用float。
# business/billing.py from datetime import datetime from decimal import Decimal, ROUND_HALF_UP def parse_time(s: str) -> datetime: return datetime.strptime(s, "%Y-%m-%d %H:%M:%S") def calc_fee(entry: str, exit_: str, rule: dict) -> dict: """按费率规则计算停车费,返回明细""" t_in, t_out = parse_time(entry), parse_time(exit_) minutes = int((t_out - t_in).total_seconds() // 60) if minutes < 0: # 时钟回拨或人为改时间,按 0 处理 minutes = 0 free = int(rule.get("free_minutes", 15)) if minutes <= free: return {"minutes": minutes, "hours": 0, "fee": Decimal("0.00"), "desc": f"未超过免费时长 {free} 分钟"} billable = minutes - free # 不足一小时按一小时:向上取整,别用 round(),round 是四舍五入会少收钱 hours = -(-billable // 60) first = Decimal(str(rule["first_hour_fee"])) per = Decimal(str(rule["per_hour_fee"])) if hours <= 1: fee = first else: fee = first + per * (hours - 1) # 按天封顶:跨了几个 24 小时就乘几 cap = Decimal(str(rule.get("daily_cap", 30))) days = max(1, -(-minutes // 1440)) if fee > cap * days: fee = cap * days return { "minutes": minutes, "hours": hours, "fee": fee.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP), "desc": f"计费 {hours} 小时,首小时 {first} 元,后续 {per} 元/小时", }-(-billable // 60)这个写法第一次看会愣一下,其实是整数向上取整的惯用技巧:-(-x // y)等价于ceil(x / y)。用round()会出大事,比如停了 1 小时 20 分,round(1.33)得 1,少收一小时的费,长期下来账目对不上还没人发现。用浮点算金额同理,0.1 + 0.2 != 0.3这种问题在报表汇总时会累积成肉眼可见的偏差,Decimal(str(x))先转字符串再构造是必须的,直接Decimal(0.1)会把浮点误差原样带进去。
封顶那里days = max(1, -(-minutes // 1440)),1440 是一天的分钟数。停 25 小时算 2 个计费日,封顶就是cap * 2,这是停车场行业比较通行的算法,具体怎么封顶各地说法不一样,改这一行就行。真实项目里还有“夜间包段”“首 15 分钟免费但跨段要收”这类复杂规则,那就得把rate_rule扩成带时间段的多行配置,用循环套,别硬塞进一个函数。
4.3 出场结算与异常兜底
出场和入场是对称的,但多了一步“找不到入场记录怎么办”。现实里这个情况一定会出现:摄像头漏识别、车牌脏了、系统刚重启数据库没同步。处理方式决定了这套东西能不能在真实场景活下来。
def check_out(plate_no: str, image_path: str = "") -> dict: """车辆出场结算""" conn = get_conn() cur = conn.cursor() cur.execute( "SELECT id, entry_time FROM parking_record " "WHERE plate_no = ? AND status = 'IN' ORDER BY id DESC LIMIT 1", (plate_no,) ) row = cur.fetchone() if not row: # 找不到入场记录:登记为异常,人工处理;不要瞎猜一个入场时间 now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur.execute( "INSERT INTO parking_record (plate_no, entry_time, exit_time, status, exit_image) " "VALUES (?, ?, ?, 'EXCEPTION', ?)", (plate_no, now, now, image_path) ) conn.commit() return {"ok": False, "msg": f"{plate_no} 无入场记录,已转人工处理", "fee": Decimal("0.00")} record_id, entry_time = row now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") rule = load_active_rule() # 从 rate_rule 表读当前生效规则 result = calc_fee(entry_time, now, rule) cur.execute( "UPDATE parking_record SET exit_time = ?, exit_image = ?, " "duration_minutes = ?, fee = ?, status = 'OUT' WHERE id = ?", (now, image_path, result["minutes"], float(result["fee"]), record_id) ) conn.commit() return {"ok": True, "msg": f"{plate_no} 停车 {result['minutes']} 分钟", "fee": result["fee"], "detail": result["desc"]}注意status='EXCEPTION'这条分支,它的价值不在于“处理了异常”,而在于“留下了证据”。很多人图省事,找不到入场记录就直接按 0 元放行,结果一个月后盘点发现少了几千块,还查不出是哪几辆车。写一条异常记录,值班人员看到告警手动补录,账才对得上。
fee存进 SQLite 时转了float,因为 SQLite 没有原生 decimal 类型,用 REAL 存储。这里会有精度损失,但展示和汇总时再格式化成两位小数完全够用;真要精确到分做财务对账,就改用整数存“分”。
5. 排查手册:环境、摄像头与识别率的六类翻车现场
5.1 先把环境和摄像头跑通
在调识别率之前,得先确认“画面能进来、界面能显示”。整个跑通流程是四步:
# 1. 建虚拟环境,别在系统 Python 里装 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 2. 装依赖 pip install -r requirements.txt # 3. 初始化数据库(首次运行会自动建表,也可以手动执行) python -c "from data.db import init_db; init_db()" # 4. 启动 python main.py手机摄像头、USB 摄像头、网络摄像头这三类源,在config.py里改CAMERA_SOURCE即可:0是本机默认摄像头,填"rtsp://..."是网络流。用网络流时要注意,cv2.VideoCapture打开 RTSP 是阻塞的,如果地址不通会卡好几秒,所以我一般会加一个超时判断,或者干脆在启动时用独立线程去连,连上了再通知界面。
先放一张本地测试图片进去验证识别链路,再开摄像头。python -c "import cv2; from core.plate_locator import coarse_locate; img=cv2.imread('test.jpg'); print(coarse_locate(img))"这种一行命令能省掉大量“到底是摄像头问题还是识别问题”的排查时间。
5.2 六类高频翻车现场:现象、原因、解决
下面这六条是我和身边人踩过的,按出现频率排。
第一条:程序一启动就闪退,报ImportError: DLL load failed。现象是双击运行,窗口都没出来就没了;命令行跑能看到cv2导入失败。 原因基本是opencv-python和numpy版本不匹配,或者装的是精简版 Python 缺少 VC 运行库。 解决办法是先pip uninstall opencv-python numpy -y再按 requirements 里的版本重装;还不行就装一遍微软的 VC++ 运行库,或者换用opencv-python-headless(不需要图形界面时更省事)。
第二条:界面卡成幻灯片,鼠标点不动。现象是视频画面一顿一顿,切换标签页没反应。 原因是在主线程里同步跑识别,一帧要 100ms 以上,把 Qt 的事件循环堵死了。 解决办法是把识别放进QThread或者concurrent.futures.ThreadPoolExecutor,界面线程只负责取最新结果画图。我一般用一个“最新帧覆盖”的队列:识别线程处理完就把结果丢进变量,界面定时器读变量,读不到就画上一帧,绝不让界面等识别。
第三条:明明车牌很清楚,就是框不到。现象是人眼看着车牌,程序返回空列表,一帧都识别不出来。 原因多半是 HSV 阈值不匹配当前光照。夜间路灯是暖黄色,蓝色车牌的 H 值会往低处漂,[100, 140]这个区间就覆盖不住了。 解决办法是写个小脚本,用滑块调lower_blue/upper_blue的六个值,实时看掩膜效果;同时确认代码里的 H 是 0~180 还是 0~360,混了必然全黑。边缘通道此时就是兜底,如果两个通道都失效,检查PLATE_MIN_AREA是不是设太大了。
第四条:字符切出来数量不对,7 位切成 6 位或 9 位。现象是识别结果长度异常,或者模型输入维度报错。 原因是二值化后字符粘连或断裂,投影峰没分开;或者车牌本身是新能源 8 位,代码按 7 位切。 解决办法是先看二值化图存下来肉眼检查,粘连严重就调形态学的核,断裂就做一次横向膨胀再接投影;同时把expect参数做成根据车牌颜色自适应——绿牌按 8 位,其余按 7 位。
第五条:同一辆车入场刷出好几条记录。现象是在场列表里同一车牌出现多次,出场时算出一堆账单。 原因是没做在场去重,或者去重查询没走索引导致并发下读到旧数据。 解决办法就是 4.1 节那段WHERE plate_no = ? AND status = 'IN'的逻辑,配合idx_plate_status索引;如果跑多线程,写库时加threading.Lock,别指望 SQLite 自己处理并发写。
第六条:金额差一分钱,或者停了 61 分钟按 2 小时收。现象是车主拿着小票来问,一核对发现多了或少了几毛。 原因是用float累加导致精度漂移,或者用了round()做小时取整。 解决办法是所有金额走Decimal,小时数用-(-x // 60)向上取整,最后统一quantize(Decimal("0.01"))。这条没什么捷径,改一遍代码比后期对账省事一百倍。
注意:第六条这个坑,我在两个项目里各踩过一次,第二次还是同一个地方。后来养成习惯,凡是金额相关的函数,先搜一遍有没有
float和round,再往下写。
5.3 日志与自检:让问题可复现
一套要放到现场跑的系统,日志比代码重要。我一般会在三个关键节点打日志:识别出结果时(车牌号 + 置信度 + 耗时毫秒)、业务状态迁移时(入场/出场/异常)、计费完成时(时长 + 金额 + 命中的规则 id)。
日志文件按天切,别写成一个几百 MB 的大文件。调试的时候,最有用的其实是把每次识别的原图、定位框、校正后的车牌图、二值化图各存一份到debug/日期/车牌号/下,出问题时直接翻图看是哪一步坏的。这个开关平时关着,现场投诉时打开跑一天,基本能定位到具体环节。
还有一个小技巧:加一个“离线视频回放”模式,把CAMERA_SOURCE填成一个 mp4 路径,用视频代替摄像头。好处是可以反复跑同一段素材,改一次参数看一次效果,比在现场等车来快得多。这个功能实现起来只是在camera.py里判断源是不是文件,循环播放而已,但调试效率提升明显。
6. 二次开发:从能跑的 Demo 到能交付的小系统
源码跑通之后,多数人下一步是把它改成自己能用的东西。这里给三条具体的改造路径,按投入产出比排。
第一条是把识别引擎做成可替换的接口。现在recognizer.py里是一个具体实现,我一般会抽成一个基类:
# core/recognizer.py from abc import ABC, abstractmethod class BaseRecognizer(ABC): @abstractmethod def recognize(self, plate_bgr) -> tuple: """返回 (车牌字符串, 置信度)""" ... class CnnRecognizer(BaseRecognizer): """自训练的小模型,输入 20x40 灰度字符图""" def recognize(self, plate_bgr): # 内部先调 char_segment 切字符,再逐个过模型 ... class EndToEndRecognizer(BaseRecognizer): """端到端识别库,直接吃整张车牌图""" def recognize(self, plate_bgr): ...抽出来之后,config.py里加一行RECOGNIZER = "cnn",业务层完全不用动。这么做的好处是,你可以用端到端方案先上线,等攒够了现场样本,再换成自训练模型提升特定场景的准确率,切换成本几乎为零。
第二条是把费率规则做成可配置的多时段。现在的rate_rule是一行,扩展成按时间段多行:
| 字段 | 说明 | 示例 |
|---|---|---|
start_hour | 该时段起始小时 | 8 |
end_hour | 该时段结束小时 | 20 |
fee_per_hour | 时段内每小时单价 | 3.0 |
first_hour_fee | 跨时段时首小时价 | 5.0 |
max_daily | 全天封顶 | 30.0 |
计费时按时段切片累加,跨段的边界那一小时按“取高”或“取低”由配置决定。这块逻辑不难,但边界条件特别多,写完之后一定拿十几个手算的用例跑一遍单元测试,别靠肉眼验。
第三条是补上出入口的硬件联动。真实车场要有道闸控制和地感触发,软件侧一般是串口或网络继电器,收到“允许通行”指令就抬杆。改造的时候,把开闸动作从业务函数里解耦成一个回调,parking_service只负责返回{"ok": True, "fee": ...},开不开闸由外部决定。这样即使硬件没接好,软件也能先跑起来演示,不会因为少一个继电器就卡住整个进度。
验证方法上,我建议搭一个小型的回归集:准备 50 张不同光照、不同角度、有污损的车牌图,放在tests/fixtures/下,写一个脚本跑一遍,输出识别正确率和平均耗时。每次改定位参数或换模型,先跑这个集子,正确率掉了多少一目了然。没有这个集子,改参数就是纯凭感觉,改好一处坏一处,来回折腾。
最后说个我自己的习惯。凡是要交给别人部署的项目,我一定会在第一次跑通之后,把“从零到能跑”的完整步骤在一个干净的虚拟机里再走一遍,把每一步的报错和解决办法记下来,塞进 README。因为我踩过太多次——在自己机器上什么都对,到了别人机器上因为一个编码问题或者路径分隔符,卡一整天。这套车牌识别计费系统本身不复杂,卡人的从来都是环境。希望帮到你。
本文还有配套的精品资源,点击获取