简介:这是一套基于 Python 开发的智能停车场车牌识别计费系统完整项目包,面向 Python 课程设计、毕业设计及计算机视觉入门练习者,围绕车辆出入场管理、车牌自动识别与停车计费展开,可直接在 PyCharm 中运行调试。系统调用百度 AI 开放平台图像识别接口完成车牌识别,并按车牌判断入场或出场,配套收入统计柱状图、车位紧张预警等功能,运行前需在 key.txt 中配置接口密钥。压缩包共 2003 个文件,约 189.59MB,以 1777 个 py 源码和 61 个 pyc 编译文件为主体,另含 txt 配置与说明、xml 界面布局、pdf 与 doc 使用文档,以及 html、css、js 等前端静态资源和少量 C/C++ 扩展文件,目录层次较完整,便于按模块阅读和二次修改。已有 248 人浏览学习,适合需要参考可运行项目结构、接口调用方式、数据统计与预警逻辑的读者对照实践。
1. 从道闸前排队的六辆车说起:这套系统到底在解决什么
晚上七点半,地下车库出口排了六辆车,保安拿着对讲机一辆辆核对进场时间。这是很多小型车场的日常,也是这套 Python 智能停车场车牌识别计费系统想要替代的原始场景。它的定位很朴素:喂一张抓拍图片或一路摄像头画面进去,程序自己找到车牌、认出号码、查出进场时间、按规则算出该收多少钱,最后写一条记录进本地数据库。整套东西纯 Python 实现,识别部分靠 OpenCV 做定位与字符切分,字符识别交给 OCR 引擎兜底,界面层用 PyQt 或 Tkinter 都能挂,数据落 SQLite,不需要额外起服务。适合两类人:一类是课程设计、毕设要做完整闭环的同学,另一类是园区、小型车场想先做原型验证的开发者。它不追求卡口级准确率,追求的是从入场到出场到账单这条链路能跑通、能改。
2. 技术选型与目录结构:OpenCV 定位 + OCR 识别 + SQLite 记账的三段式
拿到这份源码,第一件事不是装依赖跑起来,而是先弄清楚它为什么这么分。停车场计费这件事,本质上是三条互相独立又必须串起来的线:看得见(图像里找到车牌)、认得清(把像素变成字符串)、算得对(字符串对应到一辆车、一段时长、一笔钱)。三条线的技术栈完全不一样,混在一起写就会变成一个几百行的巨型脚本,改一处崩三处。这份代码的价值在于它把三条线切开了。
2.1 为什么识别不用现成云 API,而是本地 OpenCV + OCR
先说结论:离线可跑是这个项目的第一约束。很多同学第一反应是调云端车牌识别接口,几行代码出结果,准确率还高。但放到实际场景里立刻翻车——地下车库没网,园区内网不通外网,课程验收现场断网演示,任何一条都能让整个系统趴窝。所以这份代码选择了全本地链路。
本地方案的分工是:OpenCV 负责「找」,OCR 负责「认」。找车牌用的是传统图像处理里最稳的一条路——HSV 色彩空间做颜色分割,形态学运算修补连通域,轮廓筛选按宽高比和面积过滤,最后拿到候选框。这条路对蓝牌、绿牌这类底色鲜明的车牌效果好,速度快,一张 1080P 图在普通笔记本上几十毫秒;缺点是遇到强逆光、车牌严重脏污、倾斜角度过大时会漏检。
认字符这一步,代码里通常给两条路:一条是 pytesseract 调 Tesseract,配--psm 7(单行文本模式)加字符白名单,省事但对车牌字体不一定友好;另一条是训练好的轻量 CNN 分类器,把分割出来的单个字符送进去分类,准确率更高但要带模型文件。我一般建议先用 Tesseract 跑通链路,确认整条流程没问题之后,再把识别模块换掉——因为这是个可替换的边界,换识别器不应该动计费逻辑。
提示:判断一个车牌识别项目写得好不好,看它有没有把「识别器」和「业务」之间的接口抽出来。抽出来了,你换 YOLO 检测 + CRNN 识别也就是改一个类;没抽出来,你就得顺着调用栈一路往上改。
2.2 目录结构:从入口脚本到模型文件的分层
这类项目常见的目录组织是这样的(不同版本细节会有出入,但分层逻辑基本一致):
parking_system/ ├── main.py # 程序入口,负责启动 UI 或命令行模式 ├── config.py # 全局配置:摄像头地址、阈值、费率表路径 ├── core/ │ ├── plate_locate.py # 车牌定位:HSV + 形态学 + 轮廓筛选 │ ├── char_segment.py # 字符分割:二值化 + 垂直投影 │ ├── recognizer.py # 识别器封装,对外只暴露 recognize(plate_img) -> str │ └── preprocess.py # 通用图像预处理:缩放、去噪、透视校正 ├── service/ │ ├── entry_exit.py # 入场/出场判定,防重复入场 │ ├── billing.py # 计费规则计算 │ └── db.py # SQLite 连接池与增删改查封装 ├── ui/ │ ├── main_window.py # PyQt/Tkinter 主界面 │ └── widgets.py # 表格、图片预览等自定义控件 ├── data/ │ ├── parking.db # SQLite 数据库文件 │ └── rates.json # 费率配置 ├── models/ # 可选:CNN 字符分类模型 ├── samples/ # 测试用抓拍图片 └── requirements.txt这个结构里最关键的一条线是core/recognizer.py只对外暴露一个函数签名。上面不管来的是摄像头帧、本地图片还是视频流解出来的帧,到了这一层都变成「一张 BGR 图进,一个车牌字符串出」。这条边界定死了,后面做二次开发才不至于牵扯到全身。
service/db.py单独抽出来也是同样的道理。很多初学者图省事,在界面回调里直接sqlite3.connect()然后execute,代码跑起来没问题,但一旦你想加个「查询某车牌历史记录」的功能,就会发现连接管理散落在十几个地方,改不动。封装一层,哪怕只是薄薄一层,后面会感谢自己。
2.3 依赖清单与版本约束
依赖不多,但版本坑不少,尤其是 OpenCV 和 NumPy 的兼容问题:
| 依赖 | 常见版本 | 用途 | 注意点 |
|---|---|---|---|
| opencv-python | 4.x | 图像读取、色彩转换、形态学、轮廓 | 别装 opencv-contrib 又装 opencv-python,会冲突 |
| numpy | 1.24+ | 掩膜运算、投影统计 | 2.x 早期版本与部分旧 OpenCV 不兼容 |
| pytesseract | 0.3.10+ | OCR 调用封装 | 只是封装,本机还得装 Tesseract 引擎 |
| pillow | 9.x+ | 图像与 UI 之间的格式转换 | 中文路径读取时用它更稳 |
| PyQt5 / tkinter | 5.15 / 内置 | 图形界面 | tkinter 免安装,PyQt 界面好看 |
| openpyxl | 3.x | 导出计费报表 | 可选,不影响主流程 |
requirements.txt我一般会写成范围约束而不是死锁版本,例如opencv-python>=4.5,<5.0。死锁版本在别人机器上装不上的概率很高,尤其是 Windows 上 pip 找不到对应 wheel 的时候,会退化成源码编译,一编半小时。
3. 核心链路拆解:一张图从进场到出账的完整代码路径
这一章把三段核心代码拆开讲。要注意的是,这三段不是并列关系,而是流水线:定位给分割提供更干净的输入,分割给识别提供单个字符图,识别结果才能拿去查数据库。任何一段出问题,后面全崩,所以每一段都要能单独跑、单独看中间结果。
3.1 车牌定位:HSV 分割加形态学修补
import cv2 import numpy as np def locate_plate(img_bgr, min_area=800, ratio_range=(2.2, 4.5)): """输入 BGR 原图,返回 (掩膜, 候选框列表, 缩放比例)""" h, w = img_bgr.shape[:2] # 固定缩放宽度,避免不同分辨率下形态学核显得过大或过小 scale = 640.0 / w img = cv2.resize(img_bgr, (640, int(h * scale))) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) lower = np.array([100, 90, 90]) # 蓝色车牌的下界 upper = np.array([124, 255, 255]) # 蓝色车牌的上界 mask = cv2.inRange(hsv, lower, upper) # 闭运算填补字符造成的空洞,开运算削掉零散噪点 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for c in cnts: x, y, bw, bh = cv2.boundingRect(c) ratio = bw / float(bh) if ratio_range[0] <= ratio <= ratio_range[1] and bw * bh > min_area: boxes.append((x, y, bw, bh)) # 面积大的更可能是车牌,排前面 boxes.sort(key=lambda b: b[2] * b[3], reverse=True) return mask, boxes, scale几个参数值得掰开说。lower/upper这组 HSV 阈值针对的是蓝底白字车牌,色相大致落在 100 到 124 之间。如果要认绿牌(新能源),这组阈值要换到 35 到 85 附近;黄牌则落在 20 到 34。实际项目里常见做法是并行跑三组阈值,把三份掩膜合并,再统一找轮廓,比反复调一组阈值靠谱得多。
形态学核(17, 5)里的 17 是横向长度,比一般教程给的 (17, 3) 宽一些。原因是车牌被识别出来时,中间被字符切出的空隙比较长,核太短补不上,会分裂成一堆小连通域。而高度方向用 5 而不是更大,是为了不把上下相邻的两个候选区粘连起来。这是调试中最容易被忽略的一点——很多人照着教程写 (17, 3),结果发现自己的图片一直连不上。
宽高比2.2~4.5的容差范围给得比较宽,因为抓拍角度一斜,宽高比就漂。如果摄像头是正装的、车牌基本水平,可以收紧到2.8~3.6,误检会明显下降。
拿到候选框后,通常还要做一步「二次筛选」:把候选区域裁出来,转灰度再做一次水平方向的边缘密度统计,车牌区域的边缘密度远高于车身和路面。这一步不加也能跑,加上之后误检率能降不少,我一般会保留。
3.2 字符分割与识别:垂直投影切分 + 白名单 OCR
定位到车牌只是拿到了整块区域,还得把七个字符一个个切出来,才能喂给识别器。最经典的做法是垂直投影:
def segment_chars(plate_bgr): """输入车牌区域图,返回从左到右排好序的字符图列表""" gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 大津法自适应阈值,比固定阈值抗光照不均 _, th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) # 垂直投影:每一列的白点数量,字符所在列数值高,字符间隙接近 0 proj = (th > 0).sum(axis=0) chars, in_char, start = [], False, 0 for i, v in enumerate(proj): if v > 2 and not in_char: # 进入一个字符块 in_char, start = True, i elif v <= 2 and in_char: # 离开字符块 in_char = False if 8 <= i - start <= 60: # 宽度过滤,去掉边框和噪点 chars.append(th[:, start:i]) if in_char and 8 <= len(proj) - start <= 60: chars.append(th[:, start:]) return chars投影阈值v > 2里的 2 是个经验值,含义是「这一列至少有两个白像素才算字符」。设成 0 会把边缘反光也算进去,设成 5 又容易把细笔画(比如「1」「7」)切掉。字符宽度过滤8~60是拿经验值卡住:太窄的是分隔圆点或者噪点,太宽的是粘连了两个字符。
粘连是这个环节最顽固的问题。国内车牌里「京」「鲁」「粤」这类字笔画密集,加上中间那个圆点分隔符,一刀下去经常把两个字符切成一个。常见补救办法是:切出来的宽度超过单字符期望宽度 1.5 倍时,强制从中间等分。这招粗暴但有效,我知道的几个开源车牌项目都在用类似处理。
识别环节,用 Tesseract 的写法大致是:
import pytesseract import cv2 # Windows 下必须显式指定引擎路径,Linux/macOS 装在 PATH 里可省略 # pytesseract.pytesseract.tesseract_cmd = r"C:\Program Files\Tesseract-OCR\tesseract.exe" CONFIG = "--psm 7 -c tessedit_char_whitelist=0123456789ABCDEFGHJKLMNPQRSTUVWXYZ京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新" def recognize(plate_bgr): gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 适度放大有助于 OCR,太小了字符笔画糊成一团 gray = cv2.resize(gray, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC) text = pytesseract.image_to_string(gray, config=CONFIG) return text.strip().replace(" ", "")--psm 7是「把图当成单行文本」的意思,比默认的自动分页模式准得多。白名单里我省掉了字母 I 和 O,因为车牌规范本身不用它们(避免和 1、0 混淆),去掉之后这两个字符的误识会被强行映射到相近候选上,反而更准。放大三倍这一步别省,Tesseract 对小字号文字的表现相当差。
如果要换成 CNN 分类器,接口保持不变就行——recognize(plate_img) -> str这个签名是整个模块的契约,换实现不影响调用方。
3.3 计费与出入场判定:时间戳、费率表与防重复入库
识别出字符串之后,业务层的事情才刚开始。第一件事是判断这次是进场还是出场:
from datetime import datetime def handle_plate(db, plate_no, now=None): now = now or datetime.now() row = db.query_one( "SELECT id, entry_time FROM records WHERE plate_no=? AND exit_time IS NULL", (plate_no,)) if row is None: # 没有未闭合记录 -> 判定为进场 db.execute( "INSERT INTO records(plate_no, entry_time) VALUES(?, ?)", (plate_no, now)) return {"action": "entry", "plate": plate_no, "time": now} # 有未闭合记录 -> 判定为出场,结算 fee = calc_fee(row["entry_time"], now) db.execute( "UPDATE records SET exit_time=?, fee=? WHERE id=?", (now, fee, row["id"])) return {"action": "exit", "plate": plate_no, "fee": fee, "duration": now - row["entry_time"]}这段逻辑的核心假设是:同一辆车在同一时刻只可能有一条未闭合记录。这个假设成立的代价是要处理「重复识别」——摄像头连续几帧都认出了同一辆车,如果不管,就会连插三条入场记录,出场时算出来的钱完全是错的。常见做法有两种:一是加冷却时间,同车牌 N 秒内只处理一次;二是在数据库层加唯一索引,靠INSERT OR IGNORE兜底。我一般两个都上,冷却时间设 30 秒,索引兜底防意外。
计费函数本身不复杂,麻烦的是规则:
def calc_fee(entry_time, exit_time, rates=None): """rates: {"first_hour": 5, "per_hour": 3, "free_minutes": 15, "daily_cap": 40}""" rates = rates or {"first_hour": 5, "per_hour": 3, "free_minutes": 15, "daily_cap": 40} total_minutes = max(0, int((exit_time - entry_time).total_seconds() // 60)) if total_minutes <= rates["free_minutes"]: return 0.0 # 不足一小时按一小时计,这是绝大多数车场的通行规则 hours = (total_minutes + 59) // 60 fee = rates["first_hour"] + max(0, hours - 1) * rates["per_hour"] # 跨天按天封顶 days = max(1, (total_minutes + 1439) // 1440) return float(min(fee, days * rates["daily_cap"]))(total_minutes + 59) // 60这个写法是向上取整的常用技巧,比math.ceil少一次函数调用,也避免了浮点误差。免费时长、首小时价、每小时价、日封顶这几个参数放在data/rates.json里,改价不用动代码,这是我强烈建议保留的设计——现实中费率调整的频率远高于你的想象。
数据库表结构大致两张:
CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, entry_time DATETIME NOT NULL, exit_time DATETIME, fee REAL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_plate_open ON records(plate_no, exit_time);索引建在(plate_no, exit_time)上,是因为handle_plate里那条查询就是按这两列过滤的。表数据量上万之后,没这个索引查询会从毫秒级掉到几百毫秒,界面上就是明显的卡顿。
4. 跑起来:环境、配置与三条调试路径
代码看懂了,接下来是让它真正跑起来。这一章按照「先单图、再批量、最后实时」的顺序推,因为反过来做的话,一旦识别不对,你分不清是算法问题还是摄像头问题。
4.1 环境搭建与 Tesseract 安装
Python 版本建议 3.8 到 3.10。3.11 之后部分旧版 OpenCV 的 wheel 还没跟上,容易在 pip 阶段卡住。
# 1. 建虚拟环境,别装全局 python -m venv venv # Windows venv\Scripts\activate # Linux / macOS source venv/bin/activate # 2. 装依赖 pip install opencv-python numpy pytesseract pillow pip install PyQt5 # 用 tkinter 界面的话这行可以不要 # 3. 验证 OpenCV 是否正常 python -c "import cv2; print(cv2.__version__)"Tesseract 引擎要单独装,pip 装的pytesseract只是个调用壳子:
- Windows:下载安装包,安装时勾选中文语言包
chi_sim,记下安装路径。 - Ubuntu/Debian:
sudo apt install tesseract-ocr tesseract-ocr-chi-sim - macOS:
brew install tesseract tesseract-lang
装完执行tesseract --list-langs,输出里能看到chi_sim才算成功。看不到的话,后面识别中文省份简称会全部变成乱码或者空白,这是最高频的环境问题。
注意:Windows 上如果报
TesseractNotFoundError,八成是没配pytesseract.pytesseract.tesseract_cmd。这个路径要么写死在代码里,要么从环境变量读,我倾向前者——写死至少换台机器能立刻看出问题在哪。
4.2 配置文件与数据库初始化
启动前先把config.py里的几项对一遍:
# config.py CAMERA_INDEX = 0 # 摄像头编号,外接 USB 相机通常从 0 开始试 SAMPLE_DIR = "samples" # 单图测试目录 DB_PATH = "data/parking.db" # SQLite 文件位置 RATE_FILE = "data/rates.json" COOLDOWN_SECONDS = 30 # 同车牌重复识别的冷却时间 CONF_THRESHOLD = 0.6 # 识别置信度下限,低于此值丢弃数据库第一次跑会自动建表,但如果data/目录不存在,SQLite 会直接抛unable to open database file。稳妥的做法是在db.py初始化时补一句os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)。这类小坑不值得踩第二次。
费率文件长这样,改完保存重启就生效:
{ "first_hour": 5, "per_hour": 3, "free_minutes": 15, "daily_cap": 40 }4.3 三条调试路径:单图、批量、实时
第一步永远是单图。把一张清晰的车牌照片丢进samples/,写个十几行的脚本跑一遍,并且把中间结果全部存下来:
import cv2 from core.plate_locate import locate_plate from core.recognizer import recognize img = cv2.imread("samples/test1.jpg") mask, boxes, scale = locate_plate(img) cv2.imwrite("debug_mask.png", mask) # 看掩膜对不对 if boxes: x, y, w, h = boxes[0] # 注意:框是在缩放后的图上算的,回原图要除以 scale x, y, w, h = int(x / scale), int(y / scale), int(w / scale), int(h / scale) plate = img[y:y+h, x:x+w] cv2.imwrite("debug_plate.png", plate) # 看裁出来的车牌正不正 print("识别结果:", recognize(plate)) else: print("没有候选框,先回去调 HSV 阈值")把这三张图并排看:如果debug_mask.png一片黑,是阈值问题;如果掩膜有白色区域但框选歪了,是宽高比或面积过滤的问题;如果裁出来的车牌是正的但识别出来是乱码,问题就到了 OCR 那一层。这个「三张图定位法」比任何日志都好用。
第二步做批量。把几十张图丢进去统计识别率,跑完输出一个简单的对比表,人工核对哪些错了、错在哪个字符:
import os, csv from core.plate_locate import locate_plate from core.recognizer import recognize rows = [] for name in os.listdir("samples"): img = cv2.imread(os.path.join("samples", name)) mask, boxes, scale = locate_plate(img) pred = "" if boxes: x, y, w, h = [int(v / scale) for v in boxes[0]] pred = recognize(img[y:y+h, x:x+w]) rows.append({"file": name, "pred": pred}) with open("debug_report.csv", "w", newline="", encoding="utf-8-sig") as f: csv.DictWriter(f, fieldnames=["file", "pred"]).writerows(rows)encoding="utf-8-sig"是为了让 Excel 打开不乱码,虽然不影响程序逻辑,但验收时导出的表格打不开会很难看。
第三步再上实时。用cv2.VideoCapture(CAMERA_INDEX)逐帧读,每隔几帧做一次识别(不用每帧都做,浪费算力)。建议实时模式下把识别结果画在画面上再imshow出来,一边看画面一边看结果,比盯着日志快得多。摄像头角度调好之后,常见做法是把车牌区域在画面里画个固定框做 ROI,只处理框内的一小块,帧率立刻翻倍。
5. 避坑与排查:四个让我在车库里蹲到半夜的故障
真实场景和测试图片之间的差距,永远比预想的大。下面这四条是我在实际调试中反复遇到的,按出现频率排序。
5.1 白天识别率 90%,晚上掉到 30%
现象:同一套参数,白天测试几十张图基本都对,晚上换一批图几乎全废,掩膜图上几乎看不到白色区域。
原因:HSV 阈值是基于饱和度S和明度V的低值都设成 90 调的。晚上车库只有一盏冷白灯,车牌整体偏暗,V常常低于 90,直接被阈值切掉了。这不是算法错,是阈值把夜间的车牌当成了背景。
解决:不要用固定阈值。先对图像做一次直方图均衡化或者 CLAHE(限制对比度自适应直方图均衡),把亮度拉回正常区间再转 HSV。同时把V下界从 90 降到 60 左右,宁可多留一些误检,也不能漏。另外可以加一条自适应逻辑:先算全图平均亮度,低于某个值时切到「夜间参数组」,两组参数都放在config.py里。
5.2 车牌裁出来了,但识别结果总是少一个字符
现象:五位的车牌识别出四位,或者是「粤B」变成「粤」。中间结果图里被裁掉的那个字符明显被切掉了一半。
原因:定位框给得太紧。轮廓筛选拿到的矩形是紧贴连通域的边界框,车牌左右两端的字符笔画有时会被形态学的开运算腐蚀掉一小部分,导致边界框内侧少了几像素。
解决:在裁图之前对框做外扩,左右各加 5 到 8 像素,上下各加 2 到 3 像素,同时做一下边界检查防止越界。这个外扩量跟缩放比例有关——如果原图被缩小到 640 宽,外扩量要按比例乘回去,不然扩了个寂寞。这一条看起来是小问题,但它是「识别不出字符」类故障里排查最久的一种,因为人眼看车牌是完整的,图裁出来才发现边缘被啃了。
5.3 同一辆车连着插了五条入场记录
现象:车停在摄像头前没动,数据库里同一个车牌出现多条exit_time为空的记录,最后出场时算出来的停车时长是乱的。
原因:实时模式下逐帧识别,车辆停留在画面里的三秒内可能产生几十次识别结果。业务层没有去重逻辑,每次都执行插入。
解决:两层防护一起上。第一层是内存里的冷却表,用一个dict记录plate_no -> 上次处理时间戳,冷却期内直接返回。第二层是数据库唯一约束,给records建一个条件唯一索引,只对未闭合记录生效,插入冲突时忽略。只做第一层的话,程序重启后冷却表清空,依然会重复;只做第二层的话,重复识别依然会打到数据库上,白白消耗 IO。
_last_seen = {} def should_handle(plate_no, cooldown=30): now = time.time() if plate_no in _last_seen and now - _last_seen[plate_no] < cooldown: return False _last_seen[plate_no] = now return True5.4 界面点一下卡三秒,数据库 locked
现象:界面上点「查询记录」,窗口直接假死,日志里时不时冒出database is locked。
原因:SQLite 默认的连接是在主线程里创建的,UI 的查询操作也在主线程执行。数据量一大(比如几千条记录直接全量SELECT *),查询阻塞了事件循环。同时如果有后台线程在写库,读写锁冲突就会报 locked。
解决:三件事。一是把查询放到后台线程或者QThread里,结果通过信号回传主线程更新界面。二是所有查询加分页,LIMIT 100 OFFSET n,别一次性拉全表。三是给连接设置timeout,写成sqlite3.connect(DB_PATH, timeout=10, check_same_thread=False),让它遇到锁时等一会儿而不是立刻抛异常。最后这条只是缓解,不是根治,根治还是靠减少长事务和全表扫描。
提示:调试数据库问题时,把
PRAGMA journal_mode=WAL打开,读写并发能力会好很多,代价是多出两个-wal和-shm文件。SQLite 在单机小型系统里完全够用,但它不是无限的,一天几万条记录就该考虑换库了。
6. 二次开发:把单机脚本改成能长出来的模块
跑通之后,绝大多数人的下一步都是「加点东西」。这套代码的可改之处大概集中在四个方向,按改动成本从低到高排:换识别器、改计费规则、加数据看板、接闸机硬件。前两个是配置级改动,后两个需要动结构。
换识别器是收益最大的。前面说过,core/recognizer.py只暴露一个函数,你完全可以把 Tesseract 换成一个 YOLOv8 做检测、CRNN 做识别的组合,接口签名不变,业务层一行都不用动。判断改动是否安全的方法很简单:把recognizer.py备份一份,写个新的实现,然后跑第 4 章那个批量脚本,对比debug_report.csv里两次的识别结果。数字涨了就是赚了,跌了就回滚,不需要靠感觉。
改计费规则要注意一点:calc_fee目前是纯函数,输入两个时间戳和一份费率表,输出一个浮点数。这个设计要保住,千万别为了「支持会员折扣」就在里面塞数据库查询。正确做法是在外面查完会员等级,把额外的折扣系数当参数传进去。纯函数好测,这是唯一理由——你可以直接写一组断言跑单测,而不用起数据库。
加数据看板的话,records表里已经有entry_time、exit_time、fee三列,按小时聚合、按天聚合都是一条 SQL 的事。可以加个openpyxl导出的按钮,把当日流水导成 Excel 给运营看。这里有个小技巧:导出时把plate_no中间两位打码,很多场景下这是合规要求,实现上就是字符串切片拼*,两行代码。
接硬件是最麻烦的。真实闸机一般走串口或者继电器板,Python 侧用pyserial发指令。我的建议是把「抬杆」封装成一个抽象方法GateController.open(),先写一个MockGate打日志的实现,把业务跑通;等硬件到位,再补一个SerialGate。这样在没硬件的时候也能调完全流程,不会出现「代码写完了但没法测」的尴尬。
| 改造方向 | 触及文件 | 风险等级 | 验证方式 |
|---|---|---|---|
| 换识别器 | core/recognizer.py | 低 | 批量脚本对比识别率 |
| 改计费规则 | service/billing.py + rates.json | 低 | 单元测试断言金额 |
| 加报表导出 | service/db.py + ui | 中 | 造数据跑一遍导出对账 |
| 接闸机 | 新增 gate.py,改 entry_exit | 高 | Mock 先行,再上真机 |
最后说个习惯。这类项目最容易出的问题不是算法不行,而是「改了 A 崩了 B」——因为入场和出场共用一条判断链路,你为了修重复入场加了个冷却,结果出场也被冷却了,车开出去没抬杆。所以从那以后我每次动handle_plate这个函数,都强制跑一遍三个场景:正常进出、超时进出、连续重复识别,三个都过了才算改完。这套手写的小回归测试比任何自动化都管用,因为它只花两分钟。
希望帮到你。
本文还有配套的精品资源,点击获取