简介这是一套基于 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-python4.x图像读取、色彩转换、形态学、轮廓别装 opencv-contrib 又装 opencv-python会冲突numpy1.24掩膜运算、投影统计2.x 早期版本与部分旧 OpenCV 不兼容pytesseract0.3.10OCR 调用封装只是封装本机还得装 Tesseract 引擎pillow9.x图像与 UI 之间的格式转换中文路径读取时用它更稳PyQt5 / tkinter5.15 / 内置图形界面tkinter 免安装PyQt 界面好看openpyxl3.x导出计费报表可选不影响主流程requirements.txt我一般会写成范围约束而不是死锁版本例如opencv-python4.5,5.0。死锁版本在别人机器上装不上的概率很高尤其是 Windows 上 pip 找不到对应 wheel 的时候会退化成源码编译一编半小时。3. 核心链路拆解一张图从进场到出账的完整代码路径这一章把三段核心代码拆开讲。要注意的是这三段不是并列关系而是流水线定位给分割提供更干净的输入分割给识别提供单个字符图识别结果才能拿去查数据库。任何一段出问题后面全崩所以每一段都要能单独跑、单独看中间结果。3.1 车牌定位HSV 分割加形态学修补import cv2 import numpy as np def locate_plate(img_bgr, min_area800, 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(keylambda b: b[2] * b[3], reverseTrue) 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(axis0) 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 rC:\Program Files\Tesseract-OCR\tesseract.exe CONFIG --psm 7 -c tessedit_char_whitelist0123456789ABCDEFGHJKLMNPQRSTUVWXYZ京沪津渝冀晋蒙辽吉黑苏浙皖闽赣鲁豫鄂湘粤桂琼川贵云藏陕甘青宁新 def recognize(plate_bgr): gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 适度放大有助于 OCR太小了字符笔画糊成一团 gray cv2.resize(gray, None, fx3, fy3, interpolationcv2.INTER_CUBIC) text pytesseract.image_to_string(gray, configCONFIG) 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, nowNone): 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, ratesNone): 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/Debiansudo apt install tesseract-ocr tesseract-ocr-chi-simmacOSbrew 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_okTrue)。这类小坑不值得踩第二次。费率文件长这样改完保存重启就生效{ 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:yh, x:xw] 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:yh, x:xw]) rows.append({file: name, pred: pred}) with open(debug_report.csv, w, newline, encodingutf-8-sig) as f: csv.DictWriter(f, fieldnames[file, pred]).writerows(rows)encodingutf-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, cooldown30): 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, timeout10, check_same_threadFalse)让它遇到锁时等一会儿而不是立刻抛异常。最后这条只是缓解不是根治根治还是靠减少长事务和全表扫描。提示调试数据库问题时把PRAGMA journal_modeWAL打开读写并发能力会好很多代价是多出两个-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这个函数都强制跑一遍三个场景正常进出、超时进出、连续重复识别三个都过了才算改完。这套手写的小回归测试比任何自动化都管用因为它只花两分钟。希望帮到你。本文还有配套的精品资源点击获取