简介:面向学习Python应用开发与计算机视觉入门实践的学生及开发者,这套智能停车场车牌识别计费系统以可运行源码和配套说明为核心,围绕车牌识别、车辆进出场判定、停车计费、收入统计与车位满预警等业务展开,适合课程设计、毕业设计原型及Python桌面项目练手。压缩包共2003个文件,约189.59MB,其中1777个py文件承载主程序与依赖模块,另有pyc、txt、pdf、xml、json及前端css/js等资源,兼顾源码运行、配置说明与界面素材。已有248人学习,配套文档可辅助理解项目结构、百度AI图片识别Key的配置方式以及各功能模块的调用关系。读者可据此获得一套完整的停车场计费系统实现参考,包括车牌识别流程、进出场记录处理、柱状图收入汇总和基于历史数据的满预警逻辑,便于在此基础上做功能扩展或二次开发。
1. 智能停车场车牌识别计费系统:先看清它解决的是哪一段账
一个停车场从摄像头到道闸之间,真正难的不是识别出一串字符,而是把“哪辆车、什么时候进场、按什么规则计费、什么时候放行”串成可对账的链路。这份 Python 实现的智能停车场车牌识别计费系统,把车牌定位、字符识别、出入场状态和计费规则打包成一个可直接跑的软件/插件式项目,适合做原型验证、课程设计二次开发,也适合需要给现有停车场加一层识别计费能力的小团队。它不追求实验室里的最高识别率,重点在把识别、计费、存储和开闸事件拆开,让每一笔金额都能回查到原图和规则;新手能照着跑通最小链路,熟手更该关注状态机和事务边界。
2. 从视频流到账单:先跑通最小可复现链路
拿到这类源码包,我一般不会先接摄像头,也不会急着装一堆深度学习全家桶。先用一段固定视频回放,把“读帧、找车牌、OCR、算一笔模拟费用”跑通,确认输入输出格式,再去改参数和接道闸。原因是摄像头现场光照、角度、曝光每次都不一样,直接上线调阈值很容易把问题搅成玄学。
2.1 依赖与运行环境:别一上来就装全家桶
这套项目的核心依赖并不复杂:OpenCV 做图像预处理和轮廓筛选,NumPy 处理数组,Tesseract 做 OCR 基线,Pillow 处理图片读写。源码包如果自带 ONNX 或轻量检测模型,再补一个 onnxruntime 即可;没有模型时,传统边缘检测加 OCR 也能先跑通。Python 建议用 3.9 到 3.11 这类主流区间,虚拟环境隔离,别把系统 Python 直接装乱。Tesseract 是外部可执行程序,不是 pip 包装完就结束,安装后还要确认中文语言包路径,否则chi_sim会报找不到。
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install opencv-python numpy pytesseract pillow # 如果源码包带 ONNX 模型,再补: pip install onnxruntime逻辑说明:先创建虚拟环境,再装最小依赖,避免不同项目的 OpenCV 版本互相覆盖。参数说明:opencv-python是常用包,不写死版本号;pytesseract只是 Python 封装,真正干活的是系统里的 Tesseract。装完后用一条命令验证 OCR 是否可用:
python -c "import pytesseract; print(pytesseract.get_tesseract_version())"如果这条命令报错,先别改代码,去检查 Tesseract 可执行文件是否在 PATH 里,或者是否需要在代码里指定pytesseract.pytesseract.tesseract_cmd。配置文件我一般单独放,不把摄像头地址、ROI、OCR 语言、费率写死在函数里:
camera: source: "demo.mp4" roi: [200, 120, 900, 520] frame_skip: 5 ocr: lang: "chi_sim+eng" psm: 7 min_confidence: 60 billing: free_minutes: 30 first_hour_fee: 5.00 per_hour_fee: 2.00 daily_cap: 30.00逻辑说明:ROI 是感兴趣区域,只在画面中大概率出现车牌的位置做检测,能显著减少误检和耗时;frame_skip控制隔几帧处理一次。参数说明:ROI 用[x, y, w, h],不是右下角坐标;psm 7表示按单行文本识别,适合车牌;min_confidence是后处理过滤线,不是 OCR 引擎自带置信度,需要自己算或根据候选框打分。
2.2 模块拆分:识别、计费、存储、接口各管一段
源码包如果没有明显分层,我会先按职责拆成五块:输入层负责视频流和抓拍图,检测层负责候选框,识别层负责字符归一化,计费层负责规则和金额,存储层负责会话与事件,接口层负责道闸和显示屏。拆完再读入口脚本,比从头到尾顺着main.py读要快得多。尤其是计费系统,识别错一位只是少收一笔,状态机写错会导致重复扣费或出场找不到入场记录,后者更难补。
| 模块 | 主要职责 | 输入 | 输出 | 常见失败表现 |
|---|---|---|---|---|
| 输入层 | 读摄像头/视频/图片 | 视频源、ROI | 帧图像 | 卡帧、分辨率不对 |
| 检测层 | 找车牌候选框 | 帧图像 | 候选框列表 | 漏检、框到车标 |
| 识别层 | OCR 与字符清洗 | 候选框裁剪图 | 车牌字符串、置信度 | 中文首字丢失 |
| 计费层 | 免费时长、首小时、封顶 | 入场时间、出场时间、费率 | 金额、计费明细 | 取整规则不一致 |
| 存储层 | 会话、事件、费率快照 | 车牌、时间、金额 | 数据库记录 | 重复入场、锁冲突 |
| 接口层 | 开闸、显示、重试 | 会话 ID、动作 | 事件状态 | 开闸成功但账单未落 |
读源码时先找数据库初始化脚本和费率配置,确认金额单位是元还是分;再看识别函数返回的是原始 OCR 文本还是已经清洗过的车牌;最后看开闸调用是在数据库事务里还是事务外。常见做法是数据库事务只包数据库写入,串口或网络开闸放在事务提交之后,失败写事件表重试,别在持锁期间等硬件响应。
2.3 最小链路代码:一帧图像走完检测、识别和模拟计费
下面这段是基线链路,不依赖项目自带模型,目的是先验证输入输出。真实源码包如果带检测模型,把find_candidates换成模型推理即可,后面的 OCR 清洗和计费逻辑不用动。
import re import cv2 import numpy as np import pytesseract from decimal import Decimal PLATE_RE = re.compile(r"^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5,6}$") def normalize_plate(text: str) -> str: # 去掉空格、标点,统一大写;中文首字保留 return re.sub(r"[^A-Z0-9\u4e00-\u9fa5]", "", text.upper()) def find_candidates(frame, roi): x, y, w, h = roi img = frame[y:y+h, x:x+w].copy() gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.bilateralFilter(gray, 7, 50, 50) sob = cv2.Sobel(gray, cv2.CV_8U, 1, 0, ksize=3) _, th = cv2.threshold(sob, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(th, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] H, W = gray.shape for c in contours: x2, y2, w2, h2 = cv2.boundingRect(c) area_ratio = (w2 * h2) / float(W * H) aspect = w2 / float(h2) if h2 else 0 if 0.008 < area_ratio < 0.12 and 2.2 < aspect < 5.5: pad = 6 boxes.append((max(0, x2-pad), max(0, y2-pad), min(W, x2+w2+pad), min(H, y2+h2+pad))) return img, boxes def ocr_plate(img, box): x1, y1, x2, y2 = box crop = img[y1:y2, x1:x2] crop = cv2.resize(crop, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC) gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) gray = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray) _, gray = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 不要全局限定字母数字白名单,否则中文首字会被过滤 raw = pytesseract.image_to_string(gray, lang="chi_sim+eng", config="--psm 7") return normalize_plate(raw) def simple_fee(minutes, free=30, first=Decimal("5"), per_hour=Decimal("2")): if minutes <= free: return Decimal("0") hours = max(1, int((minutes + 59) // 60)) return first + max(0, hours - 1) * per_hour cap = cv2.VideoCapture("demo.mp4") roi = (200, 120, 900, 520) frame_id = 0 while True: ok, frame = cap.read() if not ok: break frame_id += 1 if frame_id % 5 != 0: continue img, boxes = find_candidates(frame, roi) for box in boxes[:3]: plate = ocr_plate(img, box) if PLATE_RE.match(plate): fee = simple_fee(95) print({"plate": plate, "box": box, "fee_demo": str(fee)}) break cap.release()逻辑说明:先裁剪 ROI,再做双边滤波保留边缘,Sobel 取水平边缘,Otsu 二值化后用长矩形核闭运算把字符连成车牌块,最后按面积比和宽高比过滤候选框。OCR 前放大 3 倍并做 CLAHE,是为了让低照度下的字符笔画更清楚。参数说明:bilateralFilter(7, 50, 50)中 7 是邻域直径,两个 50 是颜色和空间 sigma,车牌小可以把直径降到 5;Sobel ksize=3是常用值,噪声大时换 Scharr;形态学核(17, 5)偏向横向连接,车牌在画面中较宽时可以加到(21, 5);面积比0.008~0.12和宽高比2.2~5.5需要按现场分辨率改,4K 画面里车牌面积占比会更小。simple_fee只是演示,正式计费要按第 4 章的分单位计算和费率快照来。
3. 车牌定位与字符识别:阈值不是玄学,得按场景分组
车牌识别最容易翻车的地方,是拿白天的阈值去跑晚上的画面。蓝色车牌在 HSV 空间有相对稳定的区间,但夜间补光、逆光、雨雪、脏污都会让颜色掩膜漂移;只靠颜色会框到蓝色车身,只靠边缘会框到栅栏和广告牌。我的习惯是颜色和边缘各出一路候选,再按宽高比、面积、字符密度做二次校验,宁可多留候选给 OCR 打分,也不要把真正车牌提前过滤掉。
3.1 颜色掩膜与边缘检测怎么选
蓝色车牌、绿色新能源牌、黄色车牌可以分三组 HSV 区间,但不要把区间写死成唯一真理。下面给的是调试起点,现场要用抓拍图回放微调。颜色掩膜适合画面干净、车牌颜色明显的场景;边缘检测适合光照变化大但车牌轮廓清楚的场景。两路结果取并集,再统一做轮廓筛选。
import cv2 import numpy as np def build_plate_mask(bgr, mode="blue"): hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) if mode == "blue": lower, upper = np.array([100, 80, 70]), np.array([130, 255, 255]) elif mode == "green": lower, upper = np.array([45, 60, 60]), np.array([90, 255, 255]) else: lower, upper = np.array([15, 80, 80]), np.array([40, 255, 255]) color = cv2.inRange(hsv, lower, upper) color = cv2.morphologyEx(color, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_RECT, (9, 3))) gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) edge = cv2.Canny(cv2.GaussianBlur(gray, (5, 5), 0), 80, 200) edge = cv2.dilate(edge, np.ones((3, 3), np.uint8), iterations=1) return cv2.bitwise_or(color, edge) def filter_boxes(mask, min_area_ratio=0.006, max_area_ratio=0.15): cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) H, W = mask.shape[:2] out = [] for c in cnts: x, y, w, h = cv2.boundingRect(c) area_ratio = (w * h) / float(W * H) aspect = w / float(h) if h else 0 if min_area_ratio < area_ratio < max_area_ratio and 2.0 < aspect < 6.0: out.append((x, y, w, h)) return out逻辑说明:build_plate_mask把颜色掩膜和 Canny 边缘做并集,闭运算把车牌字符之间的空隙连起来,膨胀让边缘更连续。filter_boxes只做粗筛,不在这里判断是不是车牌,后面用 OCR 结果和置信度再筛。参数说明:蓝色区间H 100~130是常见起点,偏紫或偏青要调整;绿色新能源牌区间和蓝色有重叠,现场最好按颜色分组跑两套;Canny 80/200是通用起点,夜间噪声大时提高低阈值;面积比下限0.006别设太大,远处车牌会被漏掉。表格里列出常见分组:
| 车牌类型 | HSV 调试起点 | 适用场景 | 注意 |
|---|---|---|---|
| 蓝底白字 | H 100~130, S 80~255, V 70~255 | 常规小型车 | 蓝色车身会误入,需宽高比过滤 |
| 绿底黑字 | H 45~90, S 60~255, V 60~255 | 新能源牌 | 与蓝色区间有重叠,按场景二选一 |
| 黄底黑字 | H 15~40, S 80~255, V 80~255 | 大型车、工程车 | 黄昏画面容易和路灯混淆 |
3.2 透视矫正与二值化:OCR 前的两次归一化
检测框往往是斜的,直接把矩形裁剪图丢给 OCR,字符会倾斜、压缩。常见做法是先找车牌四边形的四个角点,做透视变换拉正,再统一到固定尺寸。尺寸不要盲目拉得很大,字符高度保持在 20 到 40 像素之间通常够 OCR 用;拉得太大只会增加耗时和噪声。二值化优先用 Otsu,光照不均时改用自适应阈值,但自适应阈值容易把字符笔画打断,中文字符尤其明显。
import cv2 import numpy as np def order_points(pts): pts = np.array(pts, dtype="float32") rect = np.zeros((4, 2), dtype="float32") s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] # 左上 rect[2] = pts[np.argmax(s)] # 右下 d = np.diff(pts, axis=1) rect[1] = pts[np.argmin(d)] # 右上 rect[3] = pts[np.argmax(d)] # 左下 return rect def warp_plate(crop, out_size=(240, 80)): gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(cv2.GaussianBlur(gray, (5, 5), 0), 80, 200) cnts, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for c in sorted(cnts, key=cv2.contourArea, reverse=True)[:5]: peri = cv2.arcLength(c, True) approx = cv2.approxPolyDP(c, 0.02 * peri, True) if len(approx) == 4: rect = order_points(approx.reshape(4, 2)) dst = np.array([[0, 0], [out_size[0], 0], [out_size[0], out_size[1]], [0, out_size[1]]], dtype="float32") M = cv2.getPerspectiveTransform(rect, dst) return cv2.warpPerspective(crop, M, out_size) return cv2.resize(crop, out_size) def preprocess_for_ocr(plate_bgr): gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) gray = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)).apply(gray) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) return binary逻辑说明:order_points用坐标和与差值给四个角点排序,warp_plate在裁剪图里再找一次四边形,找到就拉正,找不到就退回普通缩放,避免因为角点检测失败整条链路断掉。preprocess_for_ocr先 CLAHE 增强局部对比度,再 Otsu 二值化。参数说明:approxPolyDP的0.02 * peri是轮廓逼近精度,车牌边缘毛刺多时可以调到0.03;输出240x80是便于 OCR 的常用比例,实际按字符高度调;clipLimit=2.0别开太高,否则噪点会被放大。现场如果反光严重,先做cv2.inpaint或局部 gamma 校正,比硬调二值化阈值有效。
3.3 识别后处理:把 O/0、I/1 这类混淆压下去
OCR 原始输出不能直接入库。常见脏数据是空格、换行、标点、大小写混用,还有把数字 0 认成字母 O、数字 1 认成字母 I、数字 8 认成 B。我的做法是先做字符清洗,再按位置修正:车牌第二位通常是字母,后面的字符按字母数字混合处理,别全局替换,否则真车牌里的字母会被改错。多帧投票比单帧置信度更稳:同一车牌在连续 3 到 5 帧里出现 2 次以上一致,才触发业务动作。
import re PLATE_RE = re.compile(r"^[\u4e00-\u9fa5][A-Z][A-Z0-9]{5,6}$") CONFUSION = {"O": "0", "I": "1", "Z": "2", "S": "5", "B": "8"} def clean_and_fix(raw: str) -> str: s = re.sub(r"[^A-Z0-9\u4e00-\u9fa5]", "", raw.upper()) if len(s) < 2: return s head, body = s[0], s[1:] # 只在后段数字位做混淆修正,第二位字母位保持原样 fixed = [] for idx, ch in enumerate(body): if idx >= 1 and ch in CONFUSION: fixed.append(CONFUSION[ch]) else: fixed.append(ch) return head + "".join(fixed) def vote_plate(candidates, min_votes=2): counts = {} for p in candidates: if PLATE_RE.match(p): counts[p] = counts.get(p, 0) + 1 if not counts: return None plate, n = max(counts.items(), key=lambda kv: kv[1]) return plate if n >= min_votes else None逻辑说明:clean_and_fix先去掉非车牌字符,再只对后段做混淆修正,避免把第二位字母改坏。vote_plate用多帧计票,低于min_votes不返回,交给下一轮或人工。参数说明:min_votes=2适合 5 帧窗口,车流快时降到 2,慢时可以升到 3;PLATE_RE里{5,6}覆盖普通牌和新能源牌的尾部长度,首字用汉字范围,不写死具体省份简称。常见误识别处理如下:
| 现象 | 常见原因 | 处理 |
|---|---|---|
| 首字汉字丢失 | OCR 白名单限制成全字母数字 | 去掉全局白名单,保留中文语言包 |
| 0 和 O 混 | 字体、二值化断裂 | 按位置修正,保留原图人工复核 |
| 车牌被截断 | 检测框贴边、pad 太小 | 候选框外扩 8~15 像素再裁剪 |
| 多帧结果抖动 | 单帧 OCR 噪声 | 3~5 帧投票,业务层去抖 |
识别结果一定要保留裁剪图和原始 OCR 文本。计费系统不是实验室评测,遇到争议车牌要能回查“当时看到的是什么”,否则只剩一个字符串,排错就是黑匣子。
4. 计费状态机与数据库:把重复扣费挡在事务外
车牌识别计费系统出问题,金额错只是表面,根子通常在状态。入场记录写两次、出场时找不到入场、费率改了历史账单跟着变、开闸失败后重试又扣一笔,这些都是状态和事务没设计好。我的原则是:车牌只是业务键之一,会话才是核心;金额必须存快照,不能只存一个当前费率;开闸是事件,不是数据库事务的一部分。
4.1 表结构:入口记录、出场记录、费率快照
最小可用表结构包括:过车记录、停车会话、费率规则、道闸事件。过车记录保留每次识别结果和图片路径,停车会话只保留一次进场到出场的账,费率规则单独存,会话里再存一份规则快照。这样即使后面改了费率,历史账单也不会被“追溯调整”。时间统一存 epoch 秒,展示时再转本地时间,跨时区和夏令时少很多麻烦。
CREATE TABLE IF NOT EXISTS vehicle_pass ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, pass_type TEXT NOT NULL CHECK(pass_type IN ('IN','OUT')), occurred_at INTEGER NOT NULL, gate_id TEXT, confidence REAL, image_path TEXT, raw_text TEXT, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS parking_session ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, entry_ts INTEGER NOT NULL, exit_ts INTEGER, amount_cents INTEGER NOT NULL DEFAULT 0, rule_snapshot TEXT, status TEXT NOT NULL DEFAULT 'OPEN' CHECK(status IN ('OPEN','CLOSED','MANUAL')), created_at INTEGER NOT NULL ); CREATE UNIQUE INDEX IF NOT EXISTS ux_open_plate ON parking_session(plate_no) WHERE status = 'OPEN'; CREATE TABLE IF NOT EXISTS gate_event ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id INTEGER NOT NULL, action TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'PENDING', retry_count INTEGER NOT NULL DEFAULT 0, last_error TEXT, created_at INTEGER NOT NULL, UNIQUE(session_id, action) );逻辑说明:vehicle_pass是流水,允许同一车牌多次出现;parking_session是在场账本,ux_open_plate部分唯一索引保证同一车牌只能有一个 OPEN 会话,重复入场会直接被数据库挡住。gate_event的UNIQUE(session_id, action)防止同一会话重复开闸。参数说明:amount_cents用分存整数,避免浮点误差;rule_snapshot存 JSON,包含免费时长、首小时、每小时、封顶和计算明细;status='MANUAL'用于无牌车、污损车牌人工放行。临牌或月卡车如果车牌可能重复,不要只靠车牌做唯一键,要加credential_no或卡号字段。
4.2 计费函数:免费时长、向上取整和跨天封顶
计费规则先定清楚再写代码:免费时长内收 0;超过免费时长后,按总停车时长计算首小时和后续小时;不足一个计费单位向上取整;跨自然日按天封顶。最容易扯皮的是“免费时长后首小时怎么算”,有的场子把免费时长从总时长里扣掉,有的不扣。代码里必须把规则写进快照,账单展示时把minutes、bill_hours、first_cents、per_cents都列出来,别只给一个总价。
from decimal import Decimal def calculate_fee_cents(entry_ts: int, exit_ts: int, rule: dict): minutes = max(0, (exit_ts - entry_ts) // 60) if minutes <= rule["free_minutes"]: return 0, {"minutes": minutes, "reason": "free"} bill_hours = max(1, (minutes + 59) // 60) first_cents = int(Decimal(str(rule["first_hour_fee"])) * 100) per_cents = int(Decimal(str(rule["per_hour_fee"])) * 100) amount = first_cents + max(0, bill_hours - 1) * per_cents daily_cap = rule.get("daily_cap") if daily_cap: days = max(1, (minutes + 24 * 60 - 1) // (24 * 60)) cap_cents = int(Decimal(str(daily_cap)) * 100) * days amount = min(amount, cap_cents) detail = { "minutes": minutes, "bill_hours": bill_hours, "first_cents": first_cents, "per_cents": per_cents, "amount_cents": amount, } return amount, detail逻辑说明:先把秒级时间差转成分钟,免费时长内直接返回 0;超过后按 60 分钟向上取整得到bill_hours,首小时收first_hour_fee,后续每小时收per_hour_fee;有daily_cap时按跨天数量乘封顶,再和原金额取小。参数说明:free_minutes是免费时长,first_hour_fee和per_hour_fee用元配置、函数内转分;daily_cap为空表示不封顶。注意这里的首小时包含免费时长,如果业务要求“免费时长后重新计首小时”,要把minutes改成minutes - free_minutes再算,规则必须在配置里写清楚。测试用例至少覆盖:
| 场景 | 停车时长 | 预期 |
|---|---|---|
| 免费内 | 25 分钟 | 0 元 |
| 刚超免费 | 31 分钟 | 首小时费用 |
| 跨小时 | 61 分钟 | 首小时 + 1 小时 |
| 跨天封顶 | 26 小时 | 不超过两天封顶 |
4.3 出入场事务与幂等:先写事件,再开闸
事务边界要克制:数据库事务只包会话和事件的写入,不包串口开闸、不包网络请求。开闸放在事务提交后,用gate_event表驱动重试;重试只重试开闸,不重新计费。并发入场时用BEGIN IMMEDIATE先拿写锁,避免两个进程同时查到“没有 OPEN 会话”然后各插一条。SQLite 适合单机和小型场站,并发上来换 PostgreSQL 或 MySQL,但事务思路不变。
import sqlite3 import time conn = sqlite3.connect("parking.db", isolation_level=None) conn.execute("PRAGMA busy_timeout=5000") def handle_pass(plate: str, pass_type: str, now: int, rule: dict): conn.execute("BEGIN IMMEDIATE") try: row = conn.execute( "SELECT id, entry_ts FROM parking_session WHERE plate_no=? AND status='OPEN'", (plate,) ).fetchone() if pass_type == "IN" and row is None: cur = conn.execute( "INSERT INTO parking_session(plate_no, entry_ts, status, created_at) " "VALUES (?, ?, 'OPEN', ?)", (plate, now, now) ) session_id = cur.lastrowid action = "OPEN_GATE_IN" elif pass_type == "OUT" and row is not None: session_id, entry_ts = row amount, detail = calculate_fee_cents(entry_ts, now, rule) conn.execute( "UPDATE parking_session SET exit_ts=?, amount_cents=?, status='CLOSED', " "rule_snapshot=? WHERE id=? AND status='OPEN'", (now, amount, str(detail), session_id) ) action = "OPEN_GATE_OUT" else: conn.execute("ROLLBACK") return {"ok": False, "reason": "state_mismatch"} conn.execute( "INSERT OR IGNORE INTO gate_event(session_id, action, status, created_at) " "VALUES (?, ?, 'PENDING', ?)", (session_id, action, now) ) conn.execute("COMMIT") return {"ok": True, "session_id": session_id, "action": action} except Exception: conn.execute("ROLLBACK") raise逻辑说明:入场时如果没有 OPEN 会话就创建,有就返回状态不匹配;出场时有 OPEN 会话就计费并关闭,没有就拒绝。INSERT OR IGNORE配合唯一键保证同一会话同一动作只建一条事件。参数说明:busy_timeout=5000是等锁 5 秒,场站并发低够用;now统一用 epoch 秒;rule从配置或费率表读取,计算明细写进rule_snapshot。开闸线程只查gate_event里PENDING或FAILED且retry_count < 3的记录,成功置SUCCESS,失败累加次数并记last_error。这样即使道闸临时离线,账单状态也不会被拖回滚。
5. 避坑与排查:车牌识别计费系统最容易翻车的 5 个点
这类项目在 demo 阶段看着都能跑,一到现场就各种翻车。
本文还有配套的精品资源,点击获取