Python车牌识别计费系统:OpenCV+OCR+Flask+SQLite
简介:面向Python课程设计、毕业设计及计算机视觉入门实践的智能停车场车牌识别计费系统源码包,配套使用说明,围绕车牌识别、车辆进出场判定、收入统计与车位满预警等业务展开,适合具备Python基础、希望了解OCR接口调用与桌面端数据可视化的学习者参考。压缩包共2003个文件,约189.59MB,以1777个py源码和61个pyc文件为主体,另含txt说明、h/xml依赖配置、pdf文档及少量前端与数据文件,目录体量较完整,便于按模块查阅。当前已有247人学习。项目包含可运行源码与操作说明,能帮助读者梳理百度AI图片识别Key的接入方式、车牌识别结果与进出场记录的联动逻辑,并参考柱型图收入汇总、周维度车位紧张预警等功能实现,用于课程作业、项目复现或二次开发时对照学习。
1. 闸口 0.8 秒的判定:这套 Python 车牌识别计费系统到底管哪几段
晚上九点,出口岗亭最怕的不是排队,而是前车倒了一把又进了一次,系统把同一辆车判成两次出场,账单直接翻倍。这套 Python 车牌识别计费系统源码加使用说明,解决的就是从抓拍、定位、OCR 到入场出场配对、阶梯计费这一整条链路。它不是单纯的识别 Demo,而是把 OpenCV 传统视觉、OCR 引擎、Flask 接口和 SQLite 账本串成一个能跑在工控机上的 python 软件/插件式项目。适合做停车场改造的外包团队、需要课程设计落地的学生,也适合想把现有闸口逻辑替换成可维护脚本的运维。源码把参数集中在配置文件里,使用说明写了启动顺序和样例图,拿到后先跑通单图识别,再接摄像头,最后对账,顺序别反。
2. 环境与依赖:把 OpenCV、OCR 和 Flask 装进一台无人值守工控机
2.1 依赖选型:为什么用 OpenCV 加 OCR 双引擎,而不是端到端模型
停车场闸口的第一约束不是识别率天花板,而是稳定、可解释、断网能用。端到端车牌检测模型在样本充足时确实强,但现场常见的是逆光、脏污、遮挡、半张脸的车牌,一出错你连是哪一步错的都看不出来。传统视觉链路把定位和识别拆开,蓝牌绿牌走 HSV 颜色分割加形态学,候选框再送 OCR,哪一步掉链子看中间图就知道,这是黑匣子模型给不了的。
OCR 引擎这块,源码默认接口留了可替换实现。常见做法是本地装 PaddleOCR 的轻量检测识别模型,中文车牌字符集覆盖好,CPU 也能跑到可接受延迟;如果现场机器很老,退回 pytesseract 加字符白名单也能用,但要做位置规则校正。我一般会把ocr_engine做成配置项,默认paddle,没有 GPU 也不影响,模型文件放本地目录,避免开机下载。
Flask 只承担闸口机和后台之间的轻量接口:入场登记、出场结算、余位查询、异常重试。数据库用 SQLite,单机单写场景足够,配 WAL 模式后读写不打架。整套依赖不重,一台四核小主机加 USB 摄像头就能跑,使用说明里给的启动脚本也是按这个最小环境写的。
2.2 从零搭起可复现的运行环境
先建虚拟环境,把依赖锁在项目目录里,别装到系统 Python 里。停车场机器往往还有别的值班软件,污染环境后排查成本很高。
# 建独立环境,python3 -m venv 在工控机上比 conda 轻 python3 -m venv .venv source .venv/bin/activate # 基础依赖:OpenCV 做图像链路,numpy 做矩阵运算 pip install opencv-python numpy flask pyyaml # OCR 二选一:现场优先 PaddleOCR;老机器可退回 pytesseract pip install paddleocr paddlepaddle # pip install pytesseract # 数据库用标准库 sqlite3,不需要额外装 python -c "import cv2, numpy, flask, yaml; print('base ok')"这段命令的逻辑是先隔离环境,再装视觉、Web 和配置解析三类依赖。opencv-python选主包即可,不需要 contrib,车牌定位用不到 SIFT 那些扩展。PaddleOCR 和 paddlepaddle 要成对装,版本别跨大档,装完先跑一次空模型加载,确认模型目录能读到。pyyaml是给配置文件用的,现场调参频繁,硬编码在代码里改一次就要重启一次,不划算。
装完别急着接摄像头,先用源码里的样例图跑单图。使用说明里通常会给一条入口命令,类似python app.py --image samples/car.jpg --debug,把中间图落到debug/目录。先看定位框有没有套住车牌,再看 OCR 输出是不是整牌字符串,最后看计费接口返回的金额。三步都通了再接视频流,否则摄像头一进来,日志会被帧号淹没。
2.3 目录结构与配置项先对齐
源码包的目录一般按“入口、视觉、业务、数据、前端”分开,先认清每个文件管什么,改起来才不慌。下面这张表是这类项目的典型结构,实际文件名以压缩包为准,但职责划分基本一致。
| 路径 | 职责 | 现场改动频率 |
|---|---|---|
app.py | Flask 入口,闸口接口和页面路由 | 低 |
config.yaml | 摄像头地址、阈值、计费规则 | 高 |
vision/locate.py | 车牌定位、透视矫正 | 中 |
vision/ocr.py | OCR 引擎封装、字符校正 | 中 |
billing/rule.py | 入场出场配对、阶梯计费 | 中 |
data/parking.db | SQLite 账本 | 低,但要备份 |
samples/ | 样例图和批量回归图 | 低 |
配置读取我习惯在启动时一次性加载成字典,再做一层默认值合并,避免现场漏填某个键就崩。
import yaml DEFAULTS = { "camera": {"source": 0, "width": 1920, "height": 1080}, "locate": {"min_ratio": 2.2, "max_ratio": 4.8, "min_area": 3000}, "billing": {"free_minutes": 15, "first_hour": 5.0, "per_hour": 3.0, "cap_24h": 30.0}, } def load_config(path="config.yaml"): with open(path, "r", encoding="utf-8") as f: user_cfg = yaml.safe_load(f) or {} # 只做一层浅合并,嵌套项按现场习惯逐段覆盖 cfg = {**DEFAULTS, **user_cfg} for key in ("camera", "locate", "billing"): cfg[key] = {**DEFAULTS[key], **user_cfg.get(key, {})} return cfgload_config的关键点是默认值兜底:现场运维只改config.yaml里关心的几项,没写的走默认,不会因为少一个键导致KeyError。min_ratio和max_ratio是车牌候选框的宽高比下限上限,蓝牌一般在 2.8 到 4.2 之间,留到 2.2 到 4.8 是为了容忍倾斜和部分遮挡。min_area按 1080P 抓拍给 3000 像素,太小会把文字行当成车牌。计费参数后面第 4 章会展开,这里先保证配置能读进来。
3. 车牌定位流水线:从 1920×1080 抓拍里抠出那 440×140 的候选框
3.1 预处理:灰度、高斯、Sobel 和闭运算的参数拿捏
定位的第一步不是直接找轮廓,而是把图像变成“车牌边缘突出、背景纹理压下去”的样子。蓝牌和绿牌的共同特征是横向边缘密集、矩形区域色块集中,所以走灰度、高斯去噪、Sobel 横向梯度、形态学闭运算这条线,比直接二值化稳。夜间抓拍噪声大,高斯核用 5×5;白天可以用 3×3,省一点算力。
import cv2 import numpy as np def preprocess(bgr, ksize=5): gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # 高斯核取奇数,夜间噪声大时用 5,白天可降到 3 blur = cv2.GaussianBlur(gray, (ksize, ksize), 0) # Sobel 只取 x 方向,车牌字符的竖向边缘在横向梯度上最明显 sobel = cv2.Sobel(blur, cv2.CV_16S, 1, 0, ksize=3) abs_x = cv2.convertScaleAbs(sobel) # 自适应阈值把梯度图变成二值图,blockSize 按车牌字符高度估 binary = cv2.adaptiveThreshold( abs_x, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, 17, -3 ) # 横向矩形结构元素把字符边缘连成车牌块,17×3 适合 1080P kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 3)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=2) return gray, closedpreprocess里每个参数都对应现场问题。ksize=5是高斯核,夜间噪点多时别用 3,否则 Sobel 会把噪点当边缘。cv2.Sobel的dx=1, dy=0只算横向梯度,车牌字符的竖边在横向梯度上响应最强,反过来算dy会把地面标线也拉进来。adaptiveThreshold的blockSize=17要略大于字符笔画宽度,太小会把笔画内部切成空洞,太大又会让背景纹理复活;C=-3是均值减常数,往负方向偏一点能压掉弱边缘。闭运算的17×3横向核是经验值,1080P 下车牌宽度大约 400 到 500 像素,字符间距十几像素,17 能把相邻字符连起来又不至于把两个车牌粘成一个。iterations=2是补断裂,雨天车牌下半截被泥水糊住时,一次闭运算常常连不上。
预处理完不要直接找最大轮廓,先把中间图存下来。现场调参最怕“感觉不对但不知道哪步错”,debug/01_gray.jpg、debug/02_closed.jpg这类落盘图比日志直观。我一般在配置里加debug.save_intermediate,默认关,排查时打开,跑一张图看一张。
3.2 轮廓筛选与颜色校验:把“像车牌”的框挑出来
闭运算图里会有一堆候选轮廓:车牌、保险杠反光、广告牌、地面箭头。单靠宽高比不够,要叠颜色校验。蓝牌在 HSV 里 H 大约 100 到 124,绿牌 H 大约 35 到 85,S 和 V 给一个下限,避免灰白块混进来。下面这段把轮廓筛选和颜色占比放在一起,候选框必须同时满足几何和颜色两个条件。
def find_candidates(bgr, closed, cfg): contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if h == 0 or w == 0: continue ratio = w / float(h) area = w * h # 几何门槛:宽高比、面积、矩形度三件套 if not (cfg["min_ratio"] <= ratio <= cfg["max_ratio"]): continue if area < cfg["min_area"]: continue rect = cv2.minAreaRect(cnt) fill = cv2.contourArea(cnt) / max(area, 1) if fill < 0.45: continue roi_hsv = hsv[y:y+h, x:x+w] # 蓝牌和绿牌分别算颜色占比,取高者 blue = cv2.inRange(roi_hsv, (100, 43, 46), (124, 255, 255)) green = cv2.inRange(roi_hsv, (35, 43, 46), (85, 255, 255)) color_score = max(np.count_nonzero(blue), np.count_nonzero(green)) / float(area) if color_score < 0.25: continue candidates.append({"box": (x, y, w, h), "score": color_score + fill}) # 按颜色占比加矩形度排序,取前 5 个送 OCR candidates.sort(key=lambda item: item["score"], reverse=True) return candidates[:5]find_candidates的逻辑是先几何后颜色,顺序不能反。先算宽高比能把绝大多数细长广告牌排掉,再算面积排掉小噪点,fill是轮廓面积除以包围盒面积,车牌被遮挡时fill会掉,但低于 0.45 的基本是碎块。颜色校验用 HSV 而不是 RGB,因为光照变化时 RGB 三个通道一起漂,HSV 的 H 相对稳。蓝牌阈值(100,43,46)到(124,255,255)是 OpenCV 的 H 范围 0 到 180 下的常见区间,绿牌(35,43,46)到(85,255,255)覆盖新能源牌。color_score低于 0.25 的框直接丢,现场脏牌可能掉到 0.2,这时候别硬调低,先看补光灯是不是偏色。
排序取前 5 个是给 OCR 留候选,不是只信第一个。车牌倾斜或部分遮挡时,颜色占比最高的框可能偏到保险杠上,把前几个都送 OCR,按置信度和字符规则回选,比单框赌命稳。这个“多候选投票”在源码里可能写得简单,现场值得加。
3.3 透视矫正与字符切分:给 OCR 一张端正的图
候选框往往是斜的,直接裁出来送 OCR,字符会跟着倾斜,识别率掉得厉害。透视矫正要先把四个角点排成“左上、右上、右下、左下”的固定顺序,再算变换矩阵。角点排序不能靠 x+y 大小硬排,旋转角度大时会把顺序排乱,常见做法是按象限分。
def order_points(pts): # 按 x 坐标排序后,左两点里 y 小的是左上,右两点里 y 小的是右上 pts = sorted(pts, key=lambda p: p[0]) left = sorted(pts[:2], key=lambda p: p[1]) right = sorted(pts[2:], key=lambda p: p[1]) return np.array([left[0], right[0], right[1], left[1]], dtype="float32") def warp_plate(bgr, box): x, y, w, h = box roi = bgr[y:y+h, x:x+w] gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) # 用最大轮廓近似四边形,拿四个角点 _, th = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) contours, _ = cv2.findContours(th, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return roi cnt = max(contours, key=cv2.contourArea) peri = cv2.arcLength(cnt, True) approx = cv2.approxPolyDP(cnt, 0.02 * peri, True) if len(approx) != 4: return roi src = order_points(approx.reshape(4, 2)) # 输出尺寸按车牌标准比例 440×140,太大拖慢 OCR,太小丢笔画 dst = np.array([[0, 0], [439, 0], [439, 139], [0, 139]], dtype="float32") matrix = cv2.getPerspectiveTransform(src, dst) return cv2.warpPerspective(roi, matrix, (440, 140))order_points先按 x 分左右两组,再在每组里按 y 排上下,这样即使车牌旋转 30 度也不会把右上角排到左下。approxPolyDP的0.02 * peri是近似精度,太大四边形会塌成三角形,太小角点会抖,0.02 是车牌这种硬边框的常用档。输出440×140是按蓝牌比例给的,绿牌比例接近,统一到这个尺寸后 OCR 模型输入更稳。warp_plate里如果近似不出四个点,直接返回原 ROI,别抛异常,闸口场景宁可识别率低一点也不能让服务挂掉。
字符切分这一步,源码里可能给了垂直投影的版本,但我一般不建议在定位阶段硬切。车牌有铆钉、有边框、有汉字和字母高度不一致,垂直投影的谷值经常切错。更稳的做法是整牌送 OCR,让 OCR 引擎自己做检测框,回来再按 x 坐标排序拼字符串。切分只在 OCR 返回空或者只返回半个牌时作为兜底,用固定比例切:第一个字符占宽 1/7,后面六个各占 1/7,新能源八位再调。这个兜底逻辑要放在业务层,不要塞进视觉层,不然调参时牵扯太多。
4. 识别与计费联动:把「某A·12345」变成一个能对账的账单
4.1 OCR 引擎调用与车牌字符校正
OCR 返回的原始文本经常带空格、点号、中文标点,甚至把“0”认成“O”、把“1”认成“I”。直接入库会导致同一辆车入场和出场字符串对不上,计费时查不到记录。校正要分两层:先做字符清洗,再按车牌位置规则做白名单校正。下面以 PaddleOCR 的返回结构为例,其他引擎按字段名改。
import re # 位置规则:7 位车牌第 1 位是省份简称,第 2 位是发牌机关字母,后 5 位字母数字混合 # 新能源 8 位最后一位可能是字母,这里只做通用校验,具体以现场规则为准 CONFUSION = {"O": "0", "I": "1", "Z": "2", "S": "5", "B": "8"} def clean_plate(text): if not text: return "" # 去掉空格、点、横杠和常见中文标点,只留汉字和字母数字 text = re.sub(r"[·.\-\s,。、]", "", text) text = text.upper() # 只保留车牌可能出现的字符,长度先放宽到 6 到 9 text = re.sub(r"[^A-Z0-9\u4e00-\u9fa5]", "", text) if len(text) < 6 or len(text) > 9: return "" # 后 6 位做混淆字符校正,省份位不动 head, tail = text[:1], text[1:] tail = "".join(CONFUSION.get(ch, ch) for ch in tail) return head + tail def ocr_plate(ocr_engine, plate_img): result = ocr_engine.ocr(plate_img, cls=False) if not result or not result[0]: return "", 0.0 # PaddleOCR 返回 [[box, (text, score)], ...],按框的 x 坐标排序后拼接 items = sorted(result[0], key=lambda it: it[0][0][0]) text = "".join(it[1][0] for it in items) score = sum(it[1][1] for it in items) / max(len(items), 1) return clean_plate(text), scoreclean_plate先做通用清洗,再做位置校正,顺序不能反。CONFUSION只对第 2 位之后的字符生效,因为省份简称位是汉字,不会出现 O/0 混淆;如果把“某”这种占位汉字也走映射,反而会误伤。text.upper()放在清洗后面,避免小写字母被提前转大写后又被正则过滤。长度限制 6 到 9 是给 7 位蓝牌和 8 位新能源留余量,太短太长都直接判空,不入库。
ocr_plate里按it[0][0][0]排序是取检测框左上角的 x 坐标,PaddleOCR 的多行返回不一定按从左到右排,不排序直接拼会出现“A12345某”。置信度取平均只是粗筛,低于 0.6 的整牌我一般会标need_review,不直接放行,闸口可以先开闸但后台留人工复核记录。这一步是很多源码包省掉的,现场对账时能救命。
4.2 入场出场配对与阶梯计费模型
计费的核心不是乘法,而是配对和边界。同一辆车入场写一条status=in的记录,出场时找最近一条未结算的入场记录,算时长、套阶梯、写结算金额。免费时长、首小时、后续每小时、24 小时封顶这几个参数要在配置文件里,不要写死。下面这个函数把分钟向上取整,避免 61 分钟按 1 小时算的扯皮。
from datetime import datetime def calc_fee(entry_time, exit_time, rule): # 时长按分钟向上取整,避免 0.5 分钟被抹掉 seconds = max((exit_time - entry_time).total_seconds(), 0) minutes = int(seconds // 60) + (1 if seconds % 60 else 0) if minutes <= rule["free_minutes"]: return 0.0, minutes billable = minutes - rule["free_minutes"] # 首小时按 first_hour 收,不足一小时也按一小时 if billable <= 60: fee = rule["first_hour"] else: extra_hours = (billable - 60 + 59) // 60 fee = rule["first_hour"] + extra_hours * rule["per_hour"] # 24 小时封顶,跨天按每 24 小时重新起算的现场规则要单独改 fee = min(fee, rule["cap_24h"]) return round(fee, 2), minutescalc_fee的seconds % 60判断是为了向上取整,61 秒算 2 分钟,不是 1 分钟。billable先扣免费时长,再判断首小时,(billable - 60 + 59) // 60是向上取整的整数写法,避免浮点除法。封顶只做单次 24 小时内,跨天停车如果现场规则是“每 24 小时重新累计”,要把cap_24h改成按天循环,不能简单取min。这个边界在源码的使用说明里通常会写一句“按现场规则调整”,别忽略。
入场出场配对还要处理“同一辆车 5 秒内重复抓拍”。常见做法是在业务层加一个去重窗口:同一车牌在dedup_seconds内重复入场只更新抓拍图,不新插入场记录;出场侧如果已经有status=out的最近记录,再抓拍到同一车牌先查是否在去重窗口内,是就丢弃。这个逻辑不放在视觉层,因为视觉层不知道业务状态。
4.3 SQLite 表结构与事务边界
账本表不用多,三张够用:停车记录、月租车、异常复核。字段别省,尤其是plate_raw和confidence,出了纠纷能回溯原始 OCR 结果。
CREATE TABLE IF NOT EXISTS parking_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, plate_raw TEXT, confidence REAL DEFAULT 0, entry_time TEXT NOT NULL, exit_time TEXT, duration_min INTEGER, fee REAL DEFAULT 0, status TEXT NOT NULL DEFAULT 'in', -- in / out / review gate_id TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE INDEX IF NOT EXISTS idx_plate_status ON parking_record(plate_no, status); CREATE TABLE IF NOT EXISTS monthly_pass ( plate_no TEXT PRIMARY KEY, start_date TEXT NOT NULL, end_date TEXT NOT NULL, owner_name TEXT ); CREATE TABLE IF NOT EXISTS review_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, record_id INTEGER, reason TEXT, image_path TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')) );parking_record里status只用三个值,in表示未出场,out表示已结算,review表示金额或车牌存疑。索引建在(plate_no, status)上,出场查询“这辆车最近一条 in 记录”走索引,不会全表扫。时间字段存本地时间字符串,SQLite 没有原生日期类型,字符串比较在 ISO 格式下没问题,但跨时区场景要统一存 UTC,现场单机一般不用。
出场结算要包事务,别先查再改中间被另一个请求插进来。SQLite 用BEGIN IMMEDIATE拿写锁,配合busy_timeout等锁,避免“database is locked”直接抛给闸口。
import sqlite3 def settle(conn, plate_no, exit_time, rule): conn.execute("PRAGMA busy_timeout = 3000") try: conn.execute("BEGIN IMMEDIATE") row = conn.execute( "SELECT id, entry_time FROM parking_record " "WHERE plate_no = ? AND status = 'in' ORDER BY entry_time DESC LIMIT 1", (plate_no,) ).fetchone() if not row: conn.rollback() return None entry = datetime.fromisoformat(row[1]) fee, minutes = calc_fee(entry, exit_time, rule) conn.execute( "UPDATE parking_record SET exit_time = ?, duration_min = ?, fee = ?, status = 'out' " "WHERE id = ? AND status = 'in'", (exit_time.isoformat(sep=" "), minutes, fee, row[0]) ) conn.commit() return {"record_id": row[0], "minutes": minutes, "fee": fee} except Exception: conn.rollback() raiseBEGIN IMMEDIATE在事务开始就申请写锁,比默认的延迟锁少一次升级冲突。UPDATE的WHERE里再带一次status='in'是乐观校验,防止两个线程同时查到同一条记录后都去更新,后一个更新影响行数为 0,业务层可以据此返回“已结算”。busy_timeout给 3000 毫秒,闸口高峰并发不高,等 3 秒比直接失败体验好。异常里必须rollback,不然连接留着事务,后面的请求全堵。
5. 避坑与排查:五个让计费对不上账的现场翻车点
5.1 五类高频翻车:现象、原因和解决
第一坑:夜间车牌过曝,定位框直接消失。现象是白天识别正常,晚上同一辆车十次有六次返回空字符串,中间图里车牌区域一片白。原因多半是补光灯常亮加摄像头自动曝光,车牌反光把字符边缘冲掉了,Sobel 梯度图里车牌块和背景连成一片。解决不是把二值化阈值调高,而是先压曝光:摄像头端关自动曝光,固定曝光时间,或者图像进来先做一次 CLAHE 加 gamma 校正。代码里可以加cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8,8)),但 CLAHE 对过曝恢复有限,根子还在补光角度,灯别直射牌面,偏 30 度打。
第二坑:OCR 把省份简称认成别的字,同一辆车进出对不上。现象是入场记录车牌“某A12345”,出场变成“某A12345”看着一样,但数据库里省份位编码不同,查不到入场记录,直接按临停从头计费。原因是 OCR 对汉字简称的置信度低,源码里如果只做字符串清洗不做白名单,错字就入库了。解决是加省份位白名单校验,只允许合法的省份简称字符集,不在集合里的整牌标review,不自动结算。同时把plate_raw存下来,人工复核时能看到原始输出。这个坑的血泪经验是:别信 OCR 的汉字位,信规则。
第三坑:同一辆车重复入场,账单出现两条 in 记录。现象是车主倒车再进,或者摄像头连续抓拍,parking_record里同一车牌短时间出现多条status=in,出场时只结算最近一条,前面那条永远挂着,月底对账多出一堆“僵尸车”。原因是没有去重窗口,视觉层每抓一帧就调一次入场接口。解决是在业务层加dedup_seconds,同一车牌在窗口内重复入场只更新抓拍图不插新记录;同时出场结算后把同车牌更早的in记录批量标review,不让它们继续挂着。窗口别设太大,5 到 10 秒够用,设 60 秒会把真正折返的车也吞掉。
第四坑:免费时长边界和跨天封顶算错,车主投诉。现象是停车 15 分 01 秒被收首小时费,或者跨天停车封顶金额翻倍。原因是时长按分钟向下取整,15 分 01 秒变成 15 分,刚好卡在免费边界;跨天封顶如果简单min(fee, cap),停 25 小时也只收 30 元,现场规则如果是每 24 小时 30 元就少收了。解决是时长向上取整,边界用例写进回归脚本:14:59、15:00、15:01、23:59、24:00、24:01 各跑一遍。跨天规则在配置里加cap_mode,single和daily分开实现,别用一个min打天下。
第五坑:SQLite 写锁把闸口堵死。现象是高峰期出场接口偶发 500,日志里database is locked,重试一次又好了。原因是默认 rollback journal 模式下读写互斥,多个请求同时写就排队,超过默认 5 秒超时直接抛错。解决是开 WAL 模式、设busy_timeout、把写操作收口到一个线程或队列。WAL 模式下读不阻塞写,写写仍然串行,但闸口场景写频率不高,够用。别在 Flask 每个请求里新建连接又忘记关,连接池或g对象管理都行,关键是close要有着落。
5.2 现场排查顺序:从图像到账单的链路自检
出问题先别改代码,按链路走一遍,定位是哪一段掉链子。第一步看抓拍原图,debug/00_raw.jpg里车牌是否完整、有没有过曝、有没有被雨棚阴影切掉一半。原图不行,后面全白搭,先去调摄像头角度和补光。第二步看定位中间图,debug/02_closed.jpg里车牌块是否连成一片,候选框有没有套住。框偏了调形态学核和宽高比,框丢了看颜色阈值是不是被现场灯光带偏。第三步看矫正图,debug/03_warp.jpg里字符是否端正,倾斜严重说明角点排序或approxPolyDP精度有问题。第四步看 OCR 原始返回,别只看清洗后的字符串,原始文本里有没有多行、有没有把边框认成字符。第五步查数据库,SELECT * FROM parking_record WHERE plate_no=? ORDER BY id DESC看状态流转,是不是有残留in记录。第六步对金额,拿duration_min和配置参数手算一遍,和fee比。
这个顺序的好处是每一步都有落盘证据,不用靠猜。源码的使用说明里如果没写调试开关,自己在配置里加debug.save_intermediate,跑单图时打开,上线关掉。日志里把plate_raw、confidence、record_id打全,出了纠纷能回溯到具体那张图。还有一点,摄像头 RTSP 断流不会报错,只会读到空帧,代码里要加空帧计数,连续 N 帧空就重连,别让服务静默假死。
6. 回归验证:拿 200 张现场图把识别率和计费误差压到可交付
测试集别用网上下的干净车牌图,那只能证明模型认识印刷体。我一般从现场抓 200 张,白天、夜间、雨天、倾斜、遮挡各占一份,每张手工标真值车牌和实际进出时间。比例大概白天 80、夜间 60、雨天 30、倾斜 20、遮挡 10,覆盖到收费岗最常遇到的场景就行。
| 场景 | 张数 | 主要看什么 | 合格线 |
|---|---|---|---|
| 白天顺光 | 80 | 整牌准确率 | 不低于 95% |
| 夜间补光 | 60 | 定位召回率 | 不低于 85% |
| 雨天脏污 | 30 | 字符错误集中在哪几位 | 人工复核可兜底 |
| 倾斜/遮挡 | 30 | 矫正后是否可读 | 不低于 80% |
批量跑的时候别一张张点,写个短脚本把目录扫一遍,输出检测命中、整牌准确、字符准确三个数。字符准确按编辑距离算,整牌准确要求完全一致。跑完把错例按“定位错、OCR 错、规则误杀”分三类,定位错回去调第 3 章参数,OCR 错看是不是混淆字符,规则误杀查白名单和长度限制。阈值调参别一次改五个,改一个跑一轮,记录在表格里,不然调到最后不知道哪个参数起了作用。我吃过这个亏,后来每次上线前强制跑一遍 200 张回归,错例归档,下次改代码先拿旧错例验一遍。希望帮到你。
本文还有配套的精品资源,点击获取