
简介这是一套面向 Python 初学者与课程设计学习者的智能停车场车牌识别计费系统完整源码包围绕车辆出入管理与费用统计场景提供从车牌识别、入场出场判定到收入汇总、车位紧张预警的可运行示例。资源共 2003 个文件以 1777 个 py 源码为核心辅以 pyc 缓存、txt 配置说明、h/xml 等工程依赖文件压缩包约 189.59MB目录组织较完整便于按模块查阅和二次改造。已有 247 人学习下载。配套使用说明文档对百度 AI 图片识别 Key 的配置、识别流程及功能界面作了说明读者可据此理解车牌识别接口调用、出入记录联动计费、柱状图收入统计与基于历史数据的满预警逻辑适合用于毕业设计参考、课堂项目演练或功能扩展练习。1. 从地库出口的一次排队说起这套 Python 车牌识别计费系统能跑到什么程度早高峰一辆车停在道闸前岗亭里的人探头看牌照、低头敲键盘后面已经排了七八辆。不少园区和小区还是这个流程。用一套 Python 写的车牌识别计费系统去替换门槛比想象中低一台带 USB 摄像头的小主机配上这份源码和使用说明跑起来就是一个从入场抓拍、车牌识别、出场结算到流水查询的完整闭环。这份资源的定位是工程原型——能跑通全流程、每一段逻辑都能看懂、参数都能改不是拿去做商用交付的产品。适合三类人做课程设计需要一个能演示完整业务链的做小规模停车管理验证想先跑个原型的以及想把 OpenCV 定位、OCR 识别、业务计费串成一条能调试的链路的开发者。有个反直觉的结论先说在前面真正拖住落地的往往不是识别率而是时间戳和进出配对。2. 三条链路拆开看定位、识别、计费各自的数据契约拿到一套源码最忌讳上来就python main.py然后对着一个黑窗口猜。先把模块边界理清楚知道每一步吃什么、吐什么后面调参数才有落点。这套系统在结构上把「看见车牌」「读对车牌」「算对钱」拆成三段段与段之间只通过明确的数据结构传递不共享全局变量。这个设计看着朴素但排查问题的时候价值极大——识别错了去看定位和 OCR钱算错了去看计费函数不会互相污染。2.1 目录结构和每个文件的责任边界解压之后第一件事是看目录不要急着运行。常见做法是按职责分层核心算法、存储、界面各占一个目录配置文件单独放在根目录这样改阈值和改费率都不用翻代码。parking_ocr/ ├── main.py # 入口命令行模式 / 界面模式二选一 ├── config.yaml # 摄像头索引、HSV 阈值、费率、数据库路径 ├── core/ │ ├── capture.py # 视频流与本地图片帧获取含曝光控制 │ ├── locate.py # 车牌定位返回裁剪图和 bbox │ ├── segment.py # 字符分割模板方案用 │ ├── recognize.py # OCR 识别 多帧投票 │ └── billing.py # 计费规则纯函数不碰数据库 ├── store/ │ ├── db.py # 连接管理、建表、事务封装 │ └── schema.sql # 建表与索引语句 ├── ui/ │ └── app.py # 桌面界面只调用 core 和 store ├── data/ │ └── parking.db # 首次运行自动生成 ├── samples/ # 随包附带的示例帧用来验证环境 └── requirements.txt关键在billing.py这一层。它是纯函数——输入两个时间戳和一组费率参数输出金额和计费说明不读数据库也不读摄像头。好处是可以在没有摄像头、没有数据库的机器上单独跑单元测试把免费时长、跨零点、封顶这些边界一次性验完。很多人写停车系统把计费逻辑塞进界面事件里等到要改费率就得满代码找这是血泪经验。config.yaml里我一般会把摄像头索引、HSV 上下界、最小置信度、免费分钟数、单元价格、每日封顶集中放。改参数不动代码出问题也好回滚——配置文件留在版本库里谁的机器上用哪套参数一目了然。2.2 运行环境与依赖OCR 引擎要单独装Python 的包管理器装不上 OCR 引擎本体。pytesseract只是个包装层真正的识别程序要单独装在系统里这是新手最容易翻车的一步——代码没错报错说找不到可执行文件。依赖清单大致是这些版本不必和我完全一致能装上新版就装新版锁太死反而在别的机器上装不动# requirements.txt opencv-python4.8 numpy1.24 pytesseract0.3.10 Pillow10.0 PyYAML6.0装虚拟环境避免和系统 Python 打架python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate pip install -r requirements.txt然后是 OCR 引擎本体用系统包管理器装# macOS brew install tesseract tesseract-lang # Debian / Ubuntu 系 sudo apt install tesseract-ocr tesseract-ocr-chi-sim # Windows 装官方安装包把安装目录加进 PATH装完必须验证别跳过tesseract --version tesseract --list-langs # 输出里必须能看到 chi_sim--list-langs里没有chi_sim后面识别中文车牌一定是空的而且报错信息很含糊只告诉你识别结果为空字符串。这一步验证过后面省两小时。Windows 上还有一个附带的坑安装路径带空格或者中文pytesseract调用时可能在命令行拼接上出问题我一般把引擎装在纯英文短路径下。2.3 一次识别到落库的调用顺序把主流程写成一条直线中间任何一步失败就返回不要写层层嵌套的 try。下面这段是简化后的主干真实的main.py还要处理界面刷新和线程但调用顺序完全一致。# core/pipeline.py from dataclasses import dataclass from typing import Optional dataclass class PlateResult: text: str # 归一化后的车牌字符串大写、去空格 confidence: float # 0~1OCR 平均置信度 frame_index: int # 来自第几帧便于回溯截图 bbox: tuple # (x, y, w, h)用于存档和二次裁剪 def run_pipeline(frame, locator, recognizer, min_conf0.6, min_len7) - Optional[PlateResult]: crop locator.locate(frame) # 定位失败返回 None不抛异常 if crop is None: return None text, conf recognizer.recognize(crop) text text.replace( , ).upper() if conf min_conf: # 置信度不够直接丢弃 return None if len(text) min_len: # 位数明显不对多半是噪声 return None return PlateResult(texttext, confidenceconf, frame_indexrecognizer.frame_index, bboxlocator.last_bbox)参数含义和调整方向min_conf默认 0.6白天光照好的场景可以提到 0.7 压误识别夜间可以降到 0.5 换召回但降到 0.4 以下就会开始出现「半个车牌」也被当成有效结果的情况min_len取 7因为常见蓝牌就是 7 位新能源是 8 位所以判断条件用「小于 7 位丢弃」而不是「等于 7 位接收」多留一位。bbox一定要带上出问题的时候能直接把那帧的裁剪图存下来看比对着日志猜强得多。落库这一步不在 pipeline 里做。pipeline 只负责「这一帧有没有读出可信的车牌」读出来之后交给业务层判断这是入场还是出场再决定写哪张表。把「识别」和「业务语义」拆开后面同一套识别代码可以复用到出口、入口、甚至手持补录设备上。使用说明里如果写了「识别与业务解耦」指的就是这一层分割理解了它改费率、改界面都不会伤到识别核心。3. 车牌定位与字符识别OpenCV 预处理链和 OCR 参数怎么调识别这一段是所有人最关心的地方也是最容易被「调参玄学」带偏的地方。先把一句话说清楚这套源码走的是传统视觉路线——颜色/边缘定位加 OCR 识别不是端到端深度学习模型。这个选择有它的合理性不需要显卡不需要标注几千张图训练改一改就能在小场景跑起来代价是对光照和角度敏感参数得跟着现场调。知道自己站在哪条路线上才不会拿它去和训练好的检测模型比精度。3.1 颜色掩膜加形态学把车牌从背景里抠出来蓝牌最显著的特征是那块蓝底。做法是转 HSV框住蓝色区间再用横向的闭运算把字符之间的缝隙连成一片最后按面积和宽高比筛轮廓。import cv2 import numpy as np def locate_blue_plate(frame, hsv_low(100, 120, 70), hsv_high(124, 255, 255), min_area_ratio0.002, max_area_ratio0.08): hsv cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) mask cv2.inRange(hsv, np.array(hsv_low), np.array(hsv_high)) # 横向闭运算kernel 宽 17、高 5专门用来连接字符间的竖向缝隙 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8)) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) h, w frame.shape[:2] frame_area float(h * w) best None for c in contours: x, y, cw, ch cv2.boundingRect(c) area_ratio (cw * ch) / frame_area if not (min_area_ratio area_ratio max_area_ratio): continue ratio cw / float(ch) # 常见蓝牌宽高比大致 3:1 if not (2.2 ratio 5.5): continue if best is None or cw * ch best[2] * best[3]: best (x, y, cw, ch) if best is None: return None x, y, cw, ch best pad 4 # 外扩几像素避免把边框切掉 x0, y0 max(0, x - pad), max(0, y - pad) x1, y1 min(w, x cw pad), min(h, y ch pad) return frame[y0:y1, x0:x1]逻辑上分四步颜色筛选把候选区域从整帧里缩到很小一块闭运算把车牌内部连成实心块不然字符之间的白色缝隙会把轮廓切碎面积比例过滤掉远处的小噪点和近处的整片蓝色车身宽高比过滤掉长条形的蓝色广告牌。参数里最需要现场调的是hsv_low的 S 和 V 下限——阴天或者地库灯光偏黄的时候蓝色饱和度会被压低S 下限从 120 降到 90 左右通常能救回来但降太多灰蓝色的墙面、蓝色的消防标识也会被吞进来。kernel的宽度和摄像头分辨率直接相关1280 宽的画面用 17 是合适的640 宽的画面要缩到 9 左右否则相邻两辆车牌会被连成一块。使用说明里一般会给出「分辨率 → kernel 宽度」的对照建议没有的话按画面宽度除以 75 估一个初值再微调。3.2 字符分割与归一化模板方案的必经之路如果用的是模板匹配方案定位之后要把七个字符一块块切出来。做法是二值化后做垂直投影统计每一列的白色像素数连续的零段就是字符间隙。def segment_chars(plate_bgr, target_h40, min_width6): gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) gray cv2.equalizeHist(gray) # 光照不均时拉对比度 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV cv2.THRESH_OTSU) binary[:3, :] 0 # 清掉上下边框残留 binary[-3:, :] 0 col_sum (binary 0).sum(axis0) boxes, in_char, start [], False, 0 for i, v in enumerate(col_sum): if v 0 and not in_char: in_char, start True, i elif v 0 and in_char: in_char False if i - start min_width: # 太窄的当噪声剔掉 boxes.append((start, i)) chars [] for s, e in boxes: piece binary[:, s:e] h, w piece.shape scale target_h / float(h) piece cv2.resize(piece, (max(1, int(w * scale)), target_h), interpolationcv2.INTER_CUBIC) chars.append(piece) return charsmin_width是个容易忽略的参数。车牌上两个字符之间、字符和边框之间都会产生细窄的列段不设下限的话切出来的块数会超过 7 个后面的模板匹配就全乱了。按目标高度 40 像素归一化是因为 OCR 和模板匹配对输入尺寸敏感同一套模板必须在统一尺寸下比对。equalizeHist在处理逆光帧时有明显收益但处理本来就很干净的正面抓拍时会引入噪点可以按场景开关。分割路线和整图 OCR 路线要分清用途整图 OCR 简单、依赖少适合快速跑通分割加单字符识别对老式模板方案更稳因为每个字符单独比对个别字符被污损时不会把整牌带偏。这份源码两种都给了使用说明里通常会说「默认走整图 OCR模板资源另附」。3.3 OCR 参数与置信度过滤白名单比调阈值更管用整图 OCR 的核心不在阈值在白名单。车牌字符集是封闭的把不可能出现的字符排除掉识别错误率会明显下降——尤其是易混的 I 和 O字母 I 在某些字体下和 1 几乎一样白名单里直接删掉最省事。import cv2 import pytesseract # 省份简称按业务覆盖范围自行补全字母表里剔掉易混的 I 和 O PROVINCE_CHARS LETTER_DIGITS 0123456789ABCDEFGHJKLMNPQRSTUVWXYZ WHITELIST PROVINCE_CHARS LETTER_DIGITS def recognize_plate(plate_bgr, psm7, scale3): gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) gray cv2.resize(gray, None, fxscale, fyscale, interpolationcv2.INTER_CUBIC) # 小图放大后 OCR 更稳 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) config f--psm {psm} -c tessedit_char_whitelist{WHITELIST} data pytesseract.image_to_data(binary, langchi_sim, configconfig, output_typepytesseract.Output.DICT) text, confs [], [] for i, t in enumerate(data[text]): t t.strip() c int(data[conf][i]) if t and c 0: # conf 为 -1 表示非文字块 text.append(t) confs.append(c) if not confs: return , 0.0 return .join(text), sum(confs) / len(confs) / 100.0--psm 7是「把图像当一行文本」车牌正好是一行用 6 或者 11 都会把字符拆得过碎。scale3这一项收益很大摄像头的车牌小图往往只有 60 到 100 像素宽直接丢给 OCR 识别率很差放大三倍再二值化字符笔画能到 4 到 6 像素宽识别引擎的连通域分析才有东西可用。放大倍数超过 4 就开始糊反而掉点。置信度取平均而不是取最小值因为车牌里的省份简称最容易低分取最小值会把整块牌子的分数压到一个没法用的水平。返回 0 到 1 的浮点数交给上游的min_conf判断。还有一点OpenCV 读进来是 BGRpytesseract期望 RGB走image_to_data传数组的时候一定要转不转的话颜色整体偏二值化结果直接变形识别结果会变成一堆乱码而没有任何报错。3.4 识别失败时的降级多帧投票比单帧死磕可靠视频流的好处是同一辆车会出现几十帧。单帧偶尔识别错很正常靠多帧投票能把准确率拉上来成本几乎为零。from collections import Counter def vote_over_frames(candidates, min_votes3, min_len7): candidates: 连续若干帧的识别结果列表 cleaned [t for t in candidates if t and len(t) min_len] if not cleaned: return None, 0 counter Counter(cleaned) text, cnt counter.most_common(1)[0] if cnt min_votes: # 票数不够判为不可信 return None, cnt return text, cntmin_votes的取值和帧率挂钩。25 帧每秒、一辆车在画面里停留 2 秒理论上有 40 多次机会取 3 票是保守的如果摄像头帧率低或者车辆通过速度快只有五六帧有效那 3 票就偏严会漏车。我的做法是先统计一轮「单帧识别正确率」正确率在 70% 以上取 3 票低于 50% 说明前端定位有问题加票数没用得回去调 3.1 的阈值。降级路径还有一条定位失败时用上一帧的 bbox 附近重新裁剪再识别一次。车辆缓慢通过时相邻帧位置变化不大这个兜底能救回一部分边缘帧。但只对连续帧有效跨车辆不能复用边界要卡死。4. 计费与存储SQLite 三张表和费率函数怎么写识别对了只是成功了一半。真正决定这套系统能不能用的是同一辆车进场出场的配对有没有出错钱有没有按规则算对。这一段不涉及图像但坑一点不比识别少而且错了是直接的经济纠纷跑不了。4.1 三张核心表和索引表结构不用复杂三张够用车辆档案、通行记录、费率规则。通行记录是主表一条记录代表「一次入场」出场时回填出场时间和金额而不是进出各插一条——这种单行状态机模型让配对逻辑简单很多。CREATE TABLE IF NOT EXISTS vehicle ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL UNIQUE, plate_type TEXT DEFAULT 蓝牌, owner_tag TEXT, -- 月租 / 临停 / 访客 created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS pass_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, enter_time TEXT NOT NULL, -- ISO8601 带时区 leave_time TEXT, duration_s INTEGER, fee REAL, status TEXT DEFAULT in, -- in / settled / abnormal UNIQUE(plate_no, enter_time) ); CREATE INDEX IF NOT EXISTS idx_pass_plate ON pass_record(plate_no, status); CREATE INDEX IF NOT EXISTS idx_pass_leave ON pass_record(leave_time); CREATE TABLE IF NOT EXISTS rate_rule ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, free_minutes INTEGER DEFAULT 15, unit_minutes INTEGER DEFAULT 60, unit_price REAL DEFAULT 5.0, daily_cap REAL DEFAULT 40.0, active INTEGER DEFAULT 1 );UNIQUE(plate_no, enter_time)是防重复插入的第一道闸但它只能挡住「同一秒同一车牌」的重复识别循环里一秒钟能刷几十帧这个约束挡不住。真正的幂等要靠业务层的冷却窗口下一节展开。idx_pass_plate这个复合索引必须建。查询「这辆车有没有未结算的入场记录」是最高频的语句没索引的时候数据量上到几万行每次出场结算都要全表扫界面会明显卡顿。索引字段顺序按「等值在前、范围在后」排plate_no是等值status也是等值两个等值字段的顺序影响不大但如果把enter_time加进来做范围查询就必须排在最后。4.2 进出配对与重复识别的幂等处理配对的核心是一个查询这辆车现在有没有状态为in的记录有就是出场没有就是入场。听起来简单实际写的时候要处理三件事——重复识别、多入口并发、异常状态。from datetime import datetime, timezone, timedelta COOLDOWN_S 300 # 同一车牌 5 分钟内的重复识别视为同一次通行 def handle_pass(conn, plate_no, now, camera_idgate-1): cur conn.cursor() cur.execute( SELECT id, enter_time FROM pass_record WHERE plate_no? AND statusin ORDER BY enter_time DESC LIMIT 1, (plate_no,)) row cur.fetchone() if row is None: # 没有未结算记录 - 入场冷却窗口内重复的直接忽略 cur.execute( SELECT enter_time FROM pass_record WHERE plate_no? ORDER BY enter_time DESC LIMIT 1, (plate_no,)) last cur.fetchone() if last: last_dt datetime.fromisoformat(last[0]) if (now - last_dt).total_seconds() COOLDOWN_S: return ignored, None cur.execute( INSERT INTO pass_record(plate_no, enter_time, status, camera_in) VALUES(?, ?, in, ?), (plate_no, now.isoformat(), camera_id)) conn.commit() return enter, None # 有未结算记录 - 结算出场 record_id, enter_iso row enter_dt datetime.fromisoformat(enter_iso) duration_s int((now - enter_dt).total_seconds()) if duration_s 0: # 时间倒挂不结算标记异常 cur.execute(UPDATE pass_record SET statusabnormal WHERE id?, (record_id,)) conn.commit() return abnormal, None fee, note calc_fee(enter_dt, now) # 见 4.3 cur.execute( UPDATE pass_record SET leave_time?, duration_s?, fee?, statussettled WHERE id?, (now.isoformat(), duration_s, fee, record_id)) conn.commit() return leave, fee逻辑里的三个要点。第一查询按enter_time DESC排序取第一条因为理论上同一车牌不该有两条in记录万一有取最新的那条至少不会跨天错配。第二冷却窗口COOLDOWN_S取 300 秒比车牌在画面里停留的时间长得多比正常的出场间隔短——真正进场马上又出场的场景比如走错道倒车通常也不超过 3 分钟属于少数宁可忽略掉这一次人工补录。第三时间倒挂不直接抛异常标记成abnormal让人工复核因为这种情况真出现时硬算出来的金额是负的或者天文数字比不结算更糟。4.3 计费函数免费时长、向上取整、每日封顶计费写成纯函数输入入场、出场和费率参数输出金额和一句说明。说明那一句别省出纠纷的时候就是凭据。from datetime import datetime def calc_fee(enter_dt, leave_dt, free_minutes15, unit_minutes60, unit_price5.0, daily_cap40.0): seconds (leave_dt - enter_dt).total_seconds() if seconds 0: return 0.0, 时间异常转人工 # 防御性返回不抛异常 minutes int(seconds // 60) if minutes free_minutes: return 0.0, 免费时段 billable minutes - free_minutes units -(-billable // unit_minutes) # 向上取整不足一个单元按一个算 fee units * unit_price days max(1, (minutes 1439) // 1440) # 跨自然日粗算按天叠加封顶 cap daily_cap * days return round(min(fee, cap), 2), f{units} 个计费单元封顶 {cap:.0f} 元这里有几个容易写错的地方。minutes free_minutes用的是小于等于所以正好 15 分钟出场是免费的业务上通常就这么定义但一定要和现场管理方确认改起来就是改一个符号。向上取整那行用整数除法的写法-(-a // b)比math.ceil快一点也更直观注意billable已经是扣掉免费时长之后的数。封顶按天叠加是简化处理真实的跨天计费要按自然日切分——第一天算到 24 点第二天重新起算免费时长。这套源码默认给的是粗算版本使用说明里一般会标注「跨天封顶为近似实现多日停放建议按日拆分重算」。4.4 流水查询与对账结算完得有地方看。界面上的流水列表背后就是一条 SQL按时间段查已结算记录SELECT plate_no, enter_time, leave_time, duration_s, fee FROM pass_record WHERE status settled AND leave_time :start AND leave_time :end ORDER BY leave_time DESC LIMIT 200;注意区间用的是左闭右开 start AND end用BETWEEN两头都闭跨月统计的时候最后一条会被算进两个周期。LIMIT 200是给界面用的导报表的时候要去掉并加游标分批一次性拉十万行到内存里界面直接卡死。对账还有一个隐含需求识别失败的车怎么办。系统里应该留一个人工补录入口把车牌、时间手工填进去status直接写settledcamera_in留空标识来源。这套源码在界面上给了一个简单的手动结算按钮够用但如果要跑在真实岗亭我建议把补录操作加上操作员标记和日志表不然出了问题查不到是谁改的。5. 排查清单夜间失识别、重复入场、时间倒挂的五个高频坑跑不起来的原因翻来覆去就那么几个。下面五条是按「现象 → 原因 → 解决」整理的现场记录新手照着对能省掉大部分试错时间。5.1 白天识别很稳天一黑几乎全挂现象白天通过率八成以上晚上十几辆车只识别出两三辆日志里大量locate_fail。原因有两层。地库入口的车灯直射会让车牌区域过曝蓝色饱和度被打到接近白色HSV 掩膜里 S 下限 120 直接把它筛掉了同时夜间摄像头自动拉长曝光时间车辆移动时产生拖影字符笔画糊成一团二值化后连成一片黑块。这两个问题叠加先坏的总是定位。解决分硬件和软件两步。硬件上入口补一盏常亮的白光灯尽量斜侧打光避开正反射别用红外补光——蓝底车牌在红外下几乎失去色差颜色方案直接失效。软件上把定位从单通道改成双通道颜色掩膜失败时用 Sobel 边缘加形态学找矩形兜底def locate_by_edge(frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) grad_x cv2.Sobel(gray, cv2.CV_16S, 1, 0, ksize3) grad_x cv2.convertScaleAbs(grad_x) _, binary cv2.threshold(grad_x, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) return closed # 后续同样走 findContours 宽高比筛选另外在采集端把自动曝光关掉手动锁一个固定值。不同后端的参数含义不一致同一个数值在别的机器上未必生效所以要实测跑一段夜间的帧把曝光值从默认值往上调看车牌区域的平均亮度落在 90 到 130 之间比较合适。5.2 同一辆车连刷出好几条入场记录现象一辆车进场一次流水里出现三四条in状态的记录出场结算时金额还算到了错误的那条上。原因识别循环是逐帧跑的一辆车在画面里停两秒能产出几十条有效识别结果每一条都调用一次handle_pass。数据库的唯一约束是(plate_no, enter_time)不同帧的时间戳不同约束完全挡不住。解决业务层加冷却窗口就是 4.2 里的COOLDOWN_S。写法上有两种一种是先查最近一条记录的时间做差另一种是在内存里维护一个「最近 N 秒内已处理车牌」的字典后者快但重启丢失状态。我一般两种都上内存字典挡高频数据库查询兜底重启场景。冷却时长别设太短5 分钟是个保守值设成 10 秒车在道闸前稍微犹豫一下就会刷出两条。5.3 出场时间比入场时间还早现象结算时报负时长或者算出来的金额大得离谱几百上千块。原因三大类摄像头和后台服务器各自校时差了几分钟跨零点时用了不带时区的本地时间字符串比较时按字典序排了设备断电重启后系统时间回到出厂默认值。前两个是设计问题第三个是运维问题但都会在计费函数里爆。解决全链路统一存带时区的 ISO8601 字符串存储层不做任何时区转换展示层再转本地时间。所有时间比较前先校验leave enter不成立就写abnormal状态转人工绝不算钱。另外加一条运维巡检每天定时比对设备时间和后台时间偏差超过 60 秒就告警。这条看着和代码无关但真跑起来时间不同步造成的账单错误比识别错误多得多属于典型的黑匣子问题。5.4 跑一会儿就报 database is locked现象程序跑几分钟后写入失败报数据库被锁界面卡住重启又能跑一会儿。原因采集线程和界面线程共用一个连接或者识别循环里每帧都connect一次再关掉。SQLite 默认的日志模式在并发写入时锁粒度很大两个线程同时写必有一个等锁超时。解决连接按线程隔离或者干脆只留一个写线程其他线程把数据丢进队列由它统一落库。同时打开 WAL 模式并设置忙等待超时import sqlite3 conn sqlite3.connect(data/parking.db, timeout5.0, check_same_threadFalse) conn.execute(PRAGMA journal_modeWAL) # 读写可以并发写写仍然串行 conn.execute(PRAGMA busy_timeout5000) # 等锁最多 5 秒再报错 conn.execute(PRAGMA synchronousNORMAL) # WAL 下这个档位够用比 FULL 快check_same_threadFalse只是关掉 Python 层的线程检查不等于线程安全真正的安全靠「一个写线程」这个架构约束。WAL 模式有个副作用数据库旁边会多出-wal和-shm两个文件备份的时候要一起拷只拷parking.db会丢最近的写入。5.5 中文车牌识别为空或者读出乱码现象recognize_plate返回空字符串或者一串问号置信度是 0。原因最常见的是 OCR 引擎没装中文语言包--list-langs里没有chi_sim其次是图片路径带中文cv2.imread直接返回None后续所有操作都在对None做处理还有一种是把 BGR 数组直接传给 OCR通道顺序反了导致二值化结果完全变形。解决先跑tesseract --list-langs确认语言包。读图统一用下面这个写法绕开中文路径问题import cv2 import numpy as np def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: raise ValueError(f读图失败检查路径或文件格式: {path}) return img传数组给 OCR 之前转 RGBcv2.cvtColor(img, cv2.COLOR_BGR2RGB)。还有个小概率情况白名单字符串里如果混进了中文命令行参数拼接时编码不一致某些终端下会截断表现同样是识别为空。白名单尽量只放 ASCII 字符省份简称在业务层做后处理映射。6. 上线前的自测200 帧回归加 5 条费率边界怎么跑参数调到自己觉得差不多了别直接接到道闸上。找一批固定帧做回归把「感觉识别率还行」变成「知道具体多少」。我一般从现场各个时段各截 40 帧凑 200 帧左右人工把真值写进一个 CSV然后跑一遍批量脚本。# tools/regression.py import csv, pathlib, cv2 from core.locate import locate_blue_plate from core.recognize import recognize_plate def run(frame_dir, truth_csv, out_csvregression_out.csv): truth {} with open(truth_csv, encodingutf-8) as f: for row in csv.DictReader(f): # 两列file, plate truth[row[file]] row[plate].upper() rows, hit, locate_fail [], 0, 0 for p in sorted(pathlib.Path(frame_dir).glob(*.jpg)): img cv2.imread(str(p)) crop locate_blue_plate(img) if crop is None: locate_fail 1 rows.append((p.name, , truth.get(p.name, ), 0.00, 0)) continue text, conf recognize_plate(crop) ok int(text truth.get(p.name, )) hit ok rows.append((p.name, text, truth.get(p.name, ), f{conf:.2f}, ok)) with open(out_csv, w, newline, encodingutf-8) as f: w csv.writer(f) w.writerow([file, pred, truth, conf, hit]) w.writerows(rows) total max(1, len(rows)) print(f定位失败 {locate_fail} 帧整牌命中 {hit}/{total} {hit/total:.2%})输出里最有价值的不是那个百分比是定位失败帧数和误识别帧的分布。定位失败集中出现在某个时段说明是光照问题回去补光或者加边缘通道定位成功但识别错的把pred和truth并排看如果错在省份位是语言包或白名单问题错在数字位多半是放大倍数不够或者二值化阈值不合适。这套流程走一遍比在界面上一边看视频一边凭感觉调参数靠谱得多。费率那一侧同样要有用例把边界一个个跑过。我习惯把下面这张表直接写进测试文件每次改计费函数都全跑一遍场景入场出场期望金额校验点免费时段10:0010:140 元分钟数小于免费时长免费边界10:0010:150 元等于免费时长仍免费跨一个单元10:0011:1610 元76 分钟计 2 个单元每日封顶08:0020:0040 元单元累加超过 cap时间倒挂10:0009:000 元 abnormal负时长拦截第三行那个 76 分钟是我特意留的扣掉 15 分钟免费是 61 分钟向上取整成 2 个单元如果写成向下取整就会只收 1 个单元的钱这种错在纸面上看不出来只有跑用例才暴露。第五行是防御性测试输入本身就是错的验证的是不崩、不乱算。真实停车场的坑还有一层月租车、免费车、军警车这类特殊牌照不该走临停计费。源码里用owner_tag字段做了区分出场时先查车辆档案命中就直接放行、记 0 元不再进计费函数。这段逻辑简单但别漏——本文还有配套的精品资源点击获取