简介:这是一份面向计算机视觉入门者与Python实战学习者的车牌识别项目源码包,以OpenCV为核心,覆盖图片车牌定位与识别、图形界面交互、识别结果导出Excel等环节,并加入摄像头实时识别模块,可用于课程设计、毕业设计复现与算法练手。压缩包共16468个文件、约24.91MB,其中绝大多数为jpg车牌样本图片,另有py源码、xml与dat训练模型及少量配置说明文件,构成从样本数据、模型到程序入口的完整结构。目前已有1844人学习下载。读者可运行程序观察识别流程,借助样本集与模型理解字符分割与分类思路,也能在优化后的识别函数基础上对照修改,比较图片识别与摄像头实时识别在速度、准确率上的差异,并参考界面与导出模块把结果整理为表格数据。对于希望快速搭建可演示车牌识别系统的读者,这份带真实样本与模型权重的工程具备较高参考价值。
1. 从出入口那张糊掉的抓拍图说起
某仓储园区出入口,夜里十一点半,一辆货车开进来,抓拍机拍到的图偏暗、车牌倾斜、后面还跟着一辆车。保安拿手机抄号,抄错一位,第二天对账对了两小时。这种活儿交给 python opencv 车牌识别是最合适的:一路视频流或一张图进去,框出车牌位置、输出字符串和置信度。GitHub 上搜这几个词能翻出上百个仓库,但真拉到本地能跑、跑起来不抖的并不多。这套系统面向的就是这种场景——做计算机视觉入门练手的人,或者要给已有 Python 服务补一个识别能力、又不想上云的开发者。它是纯本地、纯 CPU 可跑的人工智能小工程,不依赖任何外部接口。
2. 技术栈与工程结构:一个能跑的车牌识别仓库长什么样
2.1 依赖清单:真正需要装的只有四个包
很多仓库一打开 requirements.txt 二三十行,看得人发怵,其实一半是作者自己环境里带出来的。车牌识别这条链路对依赖的要求很低,一张图进来的完整流程,核心就四个包。
| 包 | 作用 | 常见版本约束 | 备注 |
|---|---|---|---|
| opencv-python | 读写图、滤波、形态学、轮廓、透视变换 | >=4.5 | 不要装 headless 版,除非你只跑服务不弹窗 |
| numpy | HSV 区间、投影、矩阵运算 | >=1.20 | opencv 的入参全是 ndarray,绕不开 |
| imutils | 轮廓排序、缩放这类小工具 | 任意 | 可选,自己写也就十来行 |
| torch / torchvision | 只有走 CNN 识别路线时才需要 | >=1.8 | CPU 版足够,字符分类模型很小 |
提示:opencv-python 和 opencv-contrib-python 同装会互相覆盖,cv2 的路径只保留一个。装两个之后
cv2.imshow报错、或者cv2.xfeatures2d找不到,基本都是这个原因。
不选更重的方案是有原因的。PaddleOCR 这类通用 OCR 识别精度确实高,但它吃的是通用文本,对车牌这种固定排布、有底色、有边框的小图,反而不如先做定位再逐字符分类稳;调云厂商的识别 API 更省事,但一路视频流每帧一次请求,延迟和成本都扛不住,离线场景直接无法运行。纯 OpenCV 的经典链路虽然精度天花板不高,但每一步都可解释、可调、可换,出了问题能定位到具体哪一步在丢信息——这在做二次开发时比高几个点的准确率重要得多。
2.2 目录结构:数据、算法、入口三层切开
仓库如果所有代码堆在一个main.py里,改起来会很痛苦。合理的组织是按职责分层,把「跟 OpenCV 打交道的」和「跟业务打交道的」隔离开。
| 路径 | 职责 | 改动频率 |
|---|---|---|
app.py | 命令行入口,解析参数,串起整条链路 | 低 |
config.yaml或config.json | 阈值、尺寸、路径等全部可调参数 | 高 |
core/detector.py | 车牌定位:Sobel + 形态学 + 颜色掩码 | 中 |
core/rectifier.py | 倾斜校正、透视变换、裁剪 | 中 |
core/segmenter.py | 字符分割:垂直投影 + 连通域修正 | 中 |
core/recognizer.py | 字符识别:模板匹配或 CNN | 高 |
core/pipeline.py | 编排层,把上面几步按顺序串起来 | 低 |
models/ | 模板图、训练好的权重、字符集文本 | 低 |
data/samples/ | 现场图,调试时按图查问题 | 高 |
tools/train_char.py | 字符分类模型的训练脚本 | 低 |
这样分的好处是替换成本低。想把模板匹配换成 CNN,只动recognizer.py一个文件;想接一路新的摄像头,只动app.py的采集部分;定位在某个光照条件下不准,detector.py单独拿出来用样本图跑,不牵扯其它模块。我一般还会在data/samples/下按「白天/夜间/逆光/倾斜/多车」建子目录,每次改参数都整目录跑一遍,比看单张图靠谱。
2.3 架构拆解:一条四段式管道,每段都有降级方案
整条链路是管道式的,上一段的输出就是下一段的输入,每段之间用明确的契约通信,不共享全局变量。
第一段采集:从文件、视频或摄像头拿到 BGR 帧,统一缩放到长边 1280,超过就先缩,这一步决定了后面所有阈值参数的适用尺度。第二段定位:输出若干候选框(x, y, w, h),允许有误检但尽量不漏检,宁多勿缺。第三段校正分割:对每个候选框做透视校正,再切出 6 到 8 个字符块,切不出来就判该候选框无效。第四段识别:逐字符分类,拼成字符串,再按车牌规则做一次校验(长度、首位是否在字符集内、后五位是否是字母数字混合),校验不过降权。
| 阶段 | 典型耗时(1280 宽,单核) | 失败时的降级 |
|---|---|---|
| 采集与缩放 | 2~5 ms | 读帧为空直接跳过,不要往算法里塞 None |
| 定位 | 15~40 ms | 梯度通道没候选就切颜色通道,两者都没就返回空 |
| 校正与分割 | 5~15 ms | 分割段数不在 6~8 之间,丢弃该候选框 |
| 识别 | 3~10 ms(模板)/ 1~3 ms(CNN 批量) | 平均置信度低于阈值,输出字符串但标记低置信 |
每段都留了降级路径,这点在多车、夜间这类烂图多的场景里很关键。全链路串起来在普通笔记本上单帧 40~70 ms,一路 1080p 视频抽到 10 fps 处理,CPU 占用还能接受。真要做多路,就得在第 4 章讲的跳帧和跟踪上想办法了。
3. 核心链路拆解:从原始帧到车牌字符串的关键代码
3.1 定位第一路:Sobel 横向梯度加形态学闭运算
车牌最稳定的特征不是颜色(颜色种类多、夜间还会失真),而是「一个水平长条区域内密集排列着竖直边缘」。字符笔画就是竖直边缘,所以对横向梯度做增强,再用扁长的核做闭运算把字符粘成一个整体,是最经典也最稳的做法。
import cv2 import numpy as np def gradient_candidates(bgr, min_ratio=2.2, max_ratio=5.5, min_area=800): """基于横向 Sobel 梯度的车牌候选框""" gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) # 先高斯去噪,ksize 必须是奇数;太大糊掉笔画,太小留噪点 blur = cv2.GaussianBlur(gray, (5, 5), 0) # dx=1, dy=0 只求 x 方向梯度,车牌字符的竖边在这里最亮 sobel = cv2.Sobel(blur, cv2.CV_16S, 1, 0, ksize=3) grad = cv2.convertScaleAbs(sobel) # 16 位有符号转回 8 位 # OTSU 自适应阈值,比写死 100 更能适应明暗变化 _, binary = cv2.threshold(grad, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 扁长核把相邻字符连成块,17x5 是按 1280 宽度调的 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel, iterations=2) # 先腐蚀掉细碎的噪点连接,再膨胀补回主体 closed = cv2.erode(closed, None, iterations=1) closed = cv2.dilate(closed, None, iterations=2) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for c in contours: x, y, w, h = cv2.boundingRect(c) if h == 0: continue ratio = w / float(h) if min_ratio <= ratio <= max_ratio and w * h >= min_area: boxes.append((x, y, w, h)) return boxes, closed这里有三个参数直接决定召回率:ksize=3是标准的 3x3 Sobel,够用;闭运算核(17, 5)的宽度要跟图像尺度匹配,1280 宽用 17,如果输入统一缩到 640,这个值得减半,不然相邻两块车牌会被连一起;min_ratio和max_ratio卡的是车牌的长宽比,普通蓝牌约 3.14:1,新能源牌略长,2.2 到 5.5 是个宽松区间,宁可放宽让后面去筛,也不要在这里漏掉。返回的closed二值图一定要带出来,调参时把它imshow出来看,能立刻判断是梯度没提出来还是形态学连过头了,省掉大量瞎猜。
3.2 定位第二路:HSV 颜色掩码做交叉验证
梯度通道在逆光、雨雾、车身反光严重的时候会大面积失效,这时颜色通道是个有效补充。车牌底色集中在那几种,HSV 空间里划区间比 RGB 稳得多,因为 H 分量对亮度变化不敏感。
def color_candidates(bgr): """按车牌底色提取掩码,支持蓝、黄、白三类常见底色""" hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) ranges = [ ((100, 90, 90), (124, 255, 255)), # 蓝底 ((15, 90, 90), (35, 255, 255)), # 黄底 ((0, 0, 200), (180, 30, 255)), # 白底:低饱和 + 高亮度 ] mask = np.zeros(hsv.shape[:2], dtype=np.uint8) for lo, hi in ranges: mask = cv2.bitwise_or(mask, cv2.inRange(hsv, np.array(lo), np.array(hi))) # 横向闭运算把整块车牌连起来,纵向不连,避免和车身同色区域粘连 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=1) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))) return maskHSV 的区间不是随便划的。蓝色在 OpenCV 的 H 取值 0~180 范围内落在 100~124,红色分两段(0~10 和 156~180),白色没有色相概念,只能靠「饱和度低 + 亮度高」来框,所以(0, 0, 200)到(180, 30, 255)这个区间上限的饱和度给小了会漏掉发黄的白牌,给大了会把灰车身吃进来。这两路候选框最后怎么做融合,常见做法是取并集后按 IoU 去重,同一位置两路都命中的框给更高优先级——这比单纯信任某一路稳定,代价只是多几十毫秒。
3.3 候选框筛选与倾斜校正:把歪的牌子摆正
抓拍机装歪一点,或者车斜着开进来,切出来的字符就是斜的,垂直投影会糊成一片。校正这一步不能省,但也不能乱校——角度估错了硬转,本来能识别的反而识别不了。
def deskew(roi_bgr, max_angle=15.0): """基于最小外接矩形估计倾斜角,角度过大时放弃校正""" gray = cv2.cvtColor(roi_bgr, cv2.COLOR_BGR2GRAY) _, bin_ = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) pts = cv2.findNonZero(bin_) if pts is None or len(pts) < 50: return roi_bgr (cx, cy), (w, h), angle = cv2.minAreaRect(pts) # minAreaRect 返回的角度在 [-90, 0),先统一成本代码用的逆时针小角度 if w < h: angle += 90 w, h = h, w if abs(angle) > max_angle: return roi_bgr # 角度不可信,宁可不转 mat = cv2.getRotationMatrix2D((cx, cy), angle, 1.0) return cv2.warpAffine(roi_bgr, mat, (roi_bgr.shape[1], roi_bgr.shape[0]), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE)max_angle=15是个血泪换来的值。早期不设上限,结果一张背景版式类的误检框被算出 40 度,转完之后图都飞出画布了,后续分割直接崩。设了上限之后,角度超了就原图往下走,由识别阶段的低置信度兜住。另外borderMode=cv2.BORDER_REPLICATE是必须的,默认为常数填充会引入黑边,黑边在二值化之后会变成一整条连通域,投影一算全是字符。裁剪时还要留 4 到 8 像素的 padding,因为定位框经常正好卡在字符笔画上,不留边等于自己把第一个字符切掉一半。
3.4 字符分割:垂直投影为主,段数校验兜底
车牌字符是横向等间距排列的,垂直方向投影(每列黑像素数)会出现明显的峰谷交替,找「连续高于阈值」的列区间就是字符块。思路简单,但阈值和段数校验是两个容易翻车的地方。
def split_chars(plate_gray, target_h=40): """垂直投影切字符,返回统一尺寸的字符块列表""" _, bin_ = cv2.threshold(plate_gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) h, w = bin_.shape # 先砍掉上下各 8%,去掉车牌边框和铆钉 bin_[:int(h * 0.08), :] = 0 bin_[int(h * 0.92):, :] = 0 proj = bin_.sum(axis=0) / 255.0 thresh = max(2.0, proj.mean() * 0.35) # 相对阈值,避免高光整列发白 spans, start = [], -1 for i, v in enumerate(proj): if v > thresh and start < 0: start = i elif v <= thresh and start >= 0: if i - start >= 3: # 窄于 3 像素当噪点 spans.append((start, i)) start = -1 if start >= 0: spans.append((start, len(proj))) # 普通车牌 7 位,新能源 8 位;段数不在这区间说明切坏了 if not (6 <= len(spans) <= 8): return [] chars = [] for s, e in spans: patch = bin_[:, s:e] chars.append(cv2.resize(patch, (20, target_h), interpolation=cv2.INTER_AREA)) return chars这里的相对阈值proj.mean() * 0.35比写死数字稳,因为同一张图里不同车牌的对比度差别很大。段数校验6 <= len(spans) <= 8是硬门槛,不设的话后面识别会把一堆碎片硬凑成字符串,输出看着像车牌其实全是错的,比不输出更危险。如果段数总是 5 或者 9,先别改这段代码,回去查 3.3 的裁剪 padding 和 3.1 的形态学核大小,九成是定位框的问题。
3.5 字符识别:模板匹配先跑通,CNN 再上精度
模板匹配是最低成本的起步方案:把字符集里每个字符渲染成 20x40 的白字黑底图当模板,逐个算归一化相关系数,取最高分。它的上限不高(实拍字体和渲染字体差异大,中文省份位尤其容易错),但作为验证整条链路是否通的第一版足够。
def match_char(patch, templates): """templates: {字符: 20x40 二值图},返回最优字符和分数""" best_label, best_score = None, -1.0 for label, tpl in templates.items(): if tpl.shape != patch.shape: tpl = cv2.resize(tpl, (patch.shape[1], patch.shape[0])) score = cv2.matchTemplate(patch, tpl, cv2.TM_CCOEFF_NORMED)[0][0] if score > best_score: best_label, best_score = label, float(score) return best_label, best_score模板的构造方式决定了这一版的准确率。直接拿系统字体渲染出来的模板,对喷码工艺规整的车牌还行,遇到磨损、脏污、反光就掉得厉害。我一般会从data/samples/里挑几百张实拍图,人工标一遍切割出来的字符块,按类别均值合成模板,比纯字体模板能提十几个点。字符集不要硬编码在代码里,从models/charset.txt一行一个字符读进来,换业务区域时只改文本文件。
真要上精度,就换 CNN。输入 40x20 灰度图,输出类别数是字符集大小,结构不需要复杂:
import torch import torch.nn as nn class PlateCharNet(nn.Module): def __init__(self, num_classes): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 20x10 nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 10x5 nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(), ) self.head = nn.Sequential( nn.Flatten(), nn.Linear(128 * 10 * 5, 128), nn.ReLU(), nn.Dropout(0.3), nn.Linear(128, num_classes), ) def forward(self, x): return self.head(self.features(x))模型很小,CPU 上单字符 1 毫秒以内,一张车牌 7 个字符批量推理比模板匹配还快。训练数据就是前面人工标的字符块,每类几百张就够起步,做点随机旋转 ±5 度、亮度扰动的增强。注意训练时的归一化方式要和推理时完全一致(都是二值图除以 255),不一致是这类小模型最常见的精度腰斩原因。
3.6 结果封装:字符串、置信度和框坐标一起返回
最后一步是把每段的信息收纳成一个结构,别只返回一个str。业务侧需要框坐标去画图、需要置信度去做人工复核队列、需要分字符分数去定位是哪一位在错。
def build_result(box, chars, scores, threshold=0.6): text = "".join(c for c, _ in zip(chars, scores)) mean_score = sum(scores) / len(scores) if scores else 0.0 return { "box": list(box), # [x, y, w, h] "text": text, "score": round(mean_score, 4), "low_confidence": mean_score < threshold, "char_scores": [round(s, 4) for s in scores], }low_confidence这个标记非常有用。现场部署时,高置信度的直接放行,低置信度的推到人工复核队列,比强行全部自动通过要安全得多。char_scores则是在调模型阶段的关键——如果错误总是集中出现在第一位(省份简称),说明模板或训练样本的省份字符严重不足,而不是整个模型不行。
4. 跑通与调试:单图、视频流、摄像头三种接入
4.1 环境搭建:虚拟环境加四个包,五分钟能跑
python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install --upgrade pip pip install opencv-python numpy imutils # 只有走 CNN 识别路线时才需要下面两个,CPU 版即可 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu装完先验证环境,别急着跑项目代码:
import cv2, numpy as np img = np.full((40, 120, 3), 255, dtype=np.uint8) cv2.putText(img, "TEST", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.9, (0, 0, 0), 2) cv2.imshow("env", img) cv2.waitKey(0) cv2.destroyAllWindows() print(cv2.__version__)弹窗能出图、版本号能打出来,环境就没问题。如果imshow报The function is not implemented,说明装的是 headless 版,卸载重装opencv-python。
4.2 单图推理入口:先能对一张图出结果
入口脚本保持薄,只做参数解析和调用管道,业务逻辑全在core/里。
# app.py import argparse import cv2 from core.pipeline import PlatePipeline def main(): parser = argparse.ArgumentParser() parser.add_argument("--source", required=True, help="图片路径 / 视频路径 / 摄像头编号") parser.add_argument("--config", default="config.json") parser.add_argument("--save", action="store_true", help="是否把标注图写到 output/") args = parser.parse_args() pipe = PlatePipeline.from_config(args.config) src = int(args.source) if args.source.isdigit() else args.source cap = cv2.VideoCapture(src) if not cap.isOpened(): raise SystemExit(f"打不开输入源: {args.source}") while True: ok, frame = cap.read() if not ok: break results = pipe.run(frame) # 返回 list[dict],见 3.6 for r in results: x, y, w, h = r["box"] color = (0, 0, 255) if r["low_confidence"] else (0, 200, 0) cv2.rectangle(frame, (x, y), (x + w, y + h), color, 2) cv2.putText(frame, f'{r["text"]} {r["score"]:.2f}', (x, y - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.7, color, 2) if args.save: cv2.imwrite("output/last.jpg", frame) cv2.imshow("plate", frame) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows() if __name__ == "__main__": main()--source一个参数吃三种输入:传图片路径时VideoCapture也能读,只是循环一次就结束;传0走默认摄像头;传统一的视频文件路径。这种写法省掉了三套入口代码,调试时不用来回切换脚本。
4.3 视频流接入:别让采集成为瓶颈
摄像头或网络流接进来之后,最容易出的问题不是识别不准,而是越跑越卡。原因是每帧都完整走一遍定位加识别,而定位其实没必要每帧都做。
def run_stream(pipe, cap, detect_every=3): """每 detect_every 帧做一次完整识别,中间帧复用上一次的框""" cached, idx = [], 0 while True: ok, frame = cap.read() if not ok: break # grab 快速丢弃积压帧,避免缓冲区里堆着几秒前的画面 if idx % detect_every != 0: cap.grab() else: cached = pipe.run(frame) for r in cached: x, y, w, h = r["box"] cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 200, 0), 2) idx += 1cap.grab()只取帧不解码,比read()便宜得多,在缓冲区积压时用它丢帧很有效。detect_every=3意味着每三帧做一次识别,中间帧沿用上次结果——车在画面里移动没那么快,这样做视觉上完全够用,CPU 占用能降一大半。如果画面里会同时出现多辆车,还得在缓存上加一层按框位置匹配,别让上一帧的框直接贴在另一辆车上。
4.4 参数配置与调参顺序
把所有魔数抽到配置文件里,比散落在代码里改一处忘三处强太多。
{ "preprocess": { "resize_width": 1280 }, "detector": { "min_ratio": 2.2, "max_ratio": 5.5, "min_area": 800, "close_kernel": [17, 5], "use_color_fallback": true }, "rectifier": { "max_angle": 15.0, "pad": 6 }, "segmenter": { "char_height": 40, "min_spans": 6, "max_spans": 8 }, "recognizer": { "mode": "template", "score_threshold": 0.6 } }调参有顺序,乱调会互相干扰。先定resize_width,它决定后面所有尺寸相关的值;然后调detector,标准是「在样本集上召回率接近 100%,允许误检」;再调rectifier的pad,看裁剪图里车牌四条边是否都留了空白;最后才动segmenter和recognizer。跳过前面直接调识别阈值,往往是在给定位错误打补丁。
4.5 性能观察:先量再优化
跑起来之后别凭感觉说慢,加一段计时把每段耗时打出来:
import time from contextlib import contextmanager @contextmanager def timer(name, bucket): t0 = time.perf_counter() yield bucket[name] = bucket.get(name, 0.0) + (time.perf_counter() - t0) * 1000在pipeline.run里给定位、校正、分割、识别各套一层,跑完一百帧打印平均耗时。如果定位占了 70% 以上,先把resize_width降到 960 试试;如果识别占大头,说明还在用模板匹配逐类循环,换 CNN 批量推理。这类量化比「感觉有点卡」有用得多。
5. 避坑与排查:五条踩过的坑
现象一:白天识别率还行,傍晚开始几乎全空。原因是全链路依赖 OTSU 自动阈值,而这种全局阈值在逆光、大面积阴影、隧道口这类对比度极端分布的场景里会算出偏移很大的分割点,梯度图直接变成一片噪声或一片黑。解决办法不是把阈值写死,而是加曝光补偿:先用cv2.equalizeHist或者 CLAHE 在 LAB 空间的 L 通道上做局部均衡,再进梯度计算;同时把颜色通道的权重提上来,用两路候选框的并集顶住。我在夜间样本集上做这一步,召回率从三成提到了八成以上。
现象二:数字和字母基本对,第一个字符(省份位)几乎全错。原因是模板或训练样本里中文省份类别的数量和质量都不够,而省份位又只有 31 类左右,类别极不均衡,模型天然偏向样本多的数字类。解决分两步:训练侧对省份类做重采样或加权损失;工程侧把字符集从文本文件加载,定期把现场误识别的省份块挑出来补标。别指望靠调整TM_CCOEFF_NORMED的阈值能救回来,那是识别器本身没见过这类样本。
现象三:视频跑几分钟内存一直涨,最后卡死。排查下来通常有两个原因叠在一起:一是cap.read()返回 False 之后没有跳出循环,在某些网络流下会反复空转;二是每帧都新建了全尺寸的中间 ndarray(灰度图、梯度图、二值图各一份),循环引用没及时释放。解决是循环里判空即 break,中间图尽量复用缓冲区,Python 的 GC 不背这个锅,多数时候是代码真的在持有引用。
现象四:同一辆车在门口停了十秒,报了三十次。这是没有去重导致的。识别本身没错,但业务上要求一辆车只记一次。最简单有效的做法是给结果加上框位置和时间的缓存,用 IoU 大于 0.5 判定为同一目标,同一目标在冷却窗口(比如 5 秒)内只输出一次。要更稳一点就上质心跟踪,但多数出入口场景用 IoU 加冷却就够了,别一开始就上重量级方案。
现象五:换了台机器,cv2.imshow直接抛异常,或者某个 API 找不到。几乎都是 OpenCV 装了多个发行版互相覆盖。查一下pip list | grep opencv,有两条就卸掉多余的;只跑服务不弹窗的机器上装opencv-python-headless,本地调试的机器装opencv-python,两边环境分开维护。这类问题在 Docker 镜像里特别常见,构建时用 headless,本地跑 GUI 脚本时另一个环境,别混着来。
6. 二次开发:从能识别到能接业务
拿到这套东西之后,真正花时间的不是让它在示例图上跑通,而是把它改造成能接进业务的样子。分享几个我在项目里反复用的改造点和验证方式。
第一个改造点是识别器可插拔。在recognizer.py里定一个协议,模板匹配和 CNN 各实现一个类,由配置文件的mode决定加载哪个。这样在没标注数据的早期用模板顶着,标完数据再无缝切换,业务代码一行不用改。
class BaseRecognizer: def predict(self, patches): """入参 list[ndarray(40x20)],返回 list[(字符, 分数)]""" raise NotImplementedError class TemplateRecognizer(BaseRecognizer): def __init__(self, template_dir, charset_path): ... class CnnRecognizer(BaseRecognizer): def __init__(self, weight_path, charset_path, device="cpu"): ...第二个改造点是加一层业务规则校验。识别结果出来之后,用正则卡位数和后五位构成,卡不过的直接标低置信。这一步几乎零成本,却能把大量脏结果挡在自动通道外面。第三个是把整条链路包成一个函数交给 HTTP 框架,用一帧一请求的同步接口,配上超时和降级——识别失败返回空结果而不是抛异常,调用方按「无结果」处理。
验证方式我固定用同一套闭环:从现场采 500 张图,人工标出真值,跑脚本批量推理,输出三张表——准确率、误检率、低置信占比。每次改参数或者换模型,三张表一起看,只看准确率会被「少输出」的假提升骗过去。这个脚本跑一遍三分钟,比手动点几十张图可靠得多。
从那以后我每接一个新的现场,都先把半天时间花在采图和建样本集上,再谈算法要不要换。绝大多数「模型不行」的结论,最后都落在了训练样本没覆盖这个场景上。希望帮到你。
本文还有配套的精品资源,点击获取