2026/10/24 3:33:39

Python车牌识别计费系统:OpenCV+OCR+Flask+SQLite

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.pyFlask 入口闸口接口和页面路由低config.yaml摄像头地址、阈值、计费规则高vision/locate.py车牌定位、透视矫正中vision/ocr.pyOCR 引擎封装、字符校正中billing/rule.py入场出场配对、阶梯计费中data/parking.dbSQLite 账本低但要备份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(pathconfig.yaml): with open(path, r, encodingutf-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, ksize5): 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, ksize3) 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, iterations2) return gray, closedpreprocess里每个参数都对应现场问题。ksize5是高斯核夜间噪点多时别用 3否则 Sobel 会把噪点当边缘。cv2.Sobel的dx1, dy0只算横向梯度车牌字符的竖边在横向梯度上响应最强反过来算dy会把地面标线也拉进来。adaptiveThreshold的blockSize17要略大于字符笔画宽度太小会把笔画内部切成空洞太大又会让背景纹理复活C-3是均值减常数往负方向偏一点能压掉弱边缘。闭运算的17×3横向核是经验值1080P 下车牌宽度大约 400 到 500 像素字符间距十几像素17 能把相邻字符连起来又不至于把两个车牌粘成一个。iterations2是补断裂雨天车牌下半截被泥水糊住时一次闭运算常常连不上。预处理完不要直接找最大轮廓先把中间图存下来。现场调参最怕“感觉不对但不知道哪步错”debug/01_gray.jpg、debug/02_closed.jpg这类落盘图比日志直观。我一般在配置里加debug.save_intermediate默认关排查时打开跑一张图看一张。3.2 轮廓筛选与颜色校验把“像车牌”的框挑出来闭运算图里会有一堆候选轮廓车牌、保险杠反光、广告牌、地面箭头。单靠宽高比不够要叠颜色校验。蓝牌在 HSV 里 H 大约 100 到 124绿牌 H 大约 35 到 85S 和 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:yh, x:xw] # 蓝牌和绿牌分别算颜色占比取高者 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(keylambda item: item[score], reverseTrue) 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字符会跟着倾斜识别率掉得厉害。透视矫正要先把四个角点排成“左上、右上、右下、左下”的固定顺序再算变换矩阵。角点排序不能靠 xy 大小硬排旋转角度大时会把顺序排乱常见做法是按象限分。def order_points(pts): # 按 x 坐标排序后左两点里 y 小的是左上右两点里 y 小的是右上 pts sorted(pts, keylambda p: p[0]) left sorted(pts[:2], keylambda p: p[1]) right sorted(pts[2:], keylambda p: p[1]) return np.array([left[0], right[0], right[1], left[1]], dtypefloat32) def warp_plate(bgr, box): x, y, w, h box roi bgr[y:yh, x:xw] 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, keycv2.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]], dtypefloat32) 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, clsFalse) if not result or not result[0]: return , 0.0 # PaddleOCR 返回 [[box, (text, score)], ...]按框的 x 坐标排序后拼接 items sorted(result[0], keylambda 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 入场出场配对与阶梯计费模型计费的核心不是乘法而是配对和边界。同一辆车入场写一条statusin的记录出场时找最近一条未结算的入场记录算时长、套阶梯、写结算金额。免费时长、首小时、后续每小时、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内重复入场只更新抓拍图不新插入场记录出场侧如果已经有statusout的最近记录再抓拍到同一车牌先查是否在去重窗口内是就丢弃。这个逻辑不放在视觉层因为视觉层不知道业务状态。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里再带一次statusin是乐观校验防止两个线程同时查到同一条记录后都去更新后一个更新影响行数为 0业务层可以据此返回“已结算”。busy_timeout给 3000 毫秒闸口高峰并发不高等 3 秒比直接失败体验好。异常里必须rollback不然连接留着事务后面的请求全堵。5. 避坑与排查五个让计费对不上账的现场翻车点5.1 五类高频翻车现象、原因和解决第一坑夜间车牌过曝定位框直接消失。现象是白天识别正常晚上同一辆车十次有六次返回空字符串中间图里车牌区域一片白。原因多半是补光灯常亮加摄像头自动曝光车牌反光把字符边缘冲掉了Sobel 梯度图里车牌块和背景连成一片。解决不是把二值化阈值调高而是先压曝光摄像头端关自动曝光固定曝光时间或者图像进来先做一次 CLAHE 加 gamma 校正。代码里可以加cv2.createCLAHE(clipLimit2.0, tileGridSize(8,8))但 CLAHE 对过曝恢复有限根子还在补光角度灯别直射牌面偏 30 度打。第二坑OCR 把省份简称认成别的字同一辆车进出对不上。现象是入场记录车牌“某A12345”出场变成“某A12345”看着一样但数据库里省份位编码不同查不到入场记录直接按临停从头计费。原因是 OCR 对汉字简称的置信度低源码里如果只做字符串清洗不做白名单错字就入库了。解决是加省份位白名单校验只允许合法的省份简称字符集不在集合里的整牌标review不自动结算。同时把plate_raw存下来人工复核时能看到原始输出。这个坑的血泪经验是别信 OCR 的汉字位信规则。第三坑同一辆车重复入场账单出现两条 in 记录。现象是车主倒车再进或者摄像头连续抓拍parking_record里同一车牌短时间出现多条statusin出场时只结算最近一条前面那条永远挂着月底对账多出一堆“僵尸车”。原因是没有去重窗口视觉层每抓一帧就调一次入场接口。解决是在业务层加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_modesingle和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 张回归错例归档下次改代码先拿旧错例验一遍。希望帮到你。本文还有配套的精品资源点击获取