我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

OpenCV车牌识别实战:定位、分割与模板匹配轻量方案

OpenCV车牌识别实战:定位、分割与模板匹配轻量方案 简介这份资源围绕 Python 与 OpenCV 的车牌自动识别实战展开面向具备一定图像处理基础、希望把课堂算法落到代码中的学习者和开发者可用于理解从车辆图像预处理、车牌定位到字符分割、模板匹配识别的完整链路也适合智能交通、停车场管理等场景的入门实验。压缩包共 2000 个文件、约 12.37MB其中 1999 张 jpg 覆盖车辆样本图与车牌字符匹配模板另有 1 个 py 源码文件串联主要处理流程方便对照数据调试与复现。现有 3109 人学习下载说明该案例在 OpenCV 入门与课程设计中有一定参考面。读者可据此搭建可运行的车牌识别小项目观察灰度化、二值化、滤波去噪、边缘检测、轮廓查找与形态学操作对定位效果的影响并借助字符模板库完成分割字符的匹配实验积累参数调整与排错思路。1. 一段道闸日志引出的老办法上个月帮朋友翻他小区门口那套道闸的抓拍记录一辆车压过地感线圈到后台日志打出车牌号前后不到 200 毫秒。他以为后台一定跑着什么大模型拆开一看一份 Python OpenCV 的车牌识别源码配一套字符匹配模板在一台没有独显的办公机上就把活干完了。这类项目的价值不在算法多新而在于链路短、依赖少、每一行都能改——你想换成自己的相机、自己的牌色、自己的字符集改几个阈值和目录就能跑起来。它适合两类人一类是刚学完 OpenCV 基础 API、想找个完整闭环练手的开发者另一类是想在边缘设备上做一个轻量识别模块、又不想背深度学习框架的人。下面我按自己拆包的顺序从目录结构一路讲到字符匹配模板怎么改。2. 工程结构与运行链路四步流水线怎么落到文件上不管源码写得多花这类项目的骨架永远是四步定位、校正、分割、识别。差别只在每步用什么手段。先把文件结构和主流程看明白后面调参才不会像无头苍蝇。2.1 目录结构拿到源码包先看这几层一个能二次开发的车牌识别工程目录大致长这样plate_recognition/ ├── main.py # 入口把四步串起来 ├── config.py # 所有阈值、尺寸常量集中在这 ├── core/ │ ├── locate.py # 车牌定位Sobel 形态学 轮廓筛选 │ ├── rectify.py # 倾斜校正最小外接矩形 仿射变换 │ ├── segment.py # 字符分割垂直投影 │ └── recognize.py # 字符识别模板匹配 ├── templates/ │ ├── province/ # 省份简称模板一个字符一张小图 │ └── chars/ # 数字 0-9 与字母 A-Z去掉 I、O ├── data/ │ └── samples/ # 测试图建议自己再补一批难例 └── requirements.txtconfig.py这个文件看着不起眼实际是调参时最该先打开的地方。我见过不少同学把阈值直接写死在locate.py里改一次要翻五六个文件试十组参数就是五十次翻找血泪经验——所有跟图像尺寸、颜色范围、宽高比相关的数字全部提出来做常量后面换场景只动一个文件。templates/的命名也有讲究。省份目录里的文件名就是字符本身比如province/某简称.pngchars/里用0.png到9.png、A.png到Z.png。加载时直接拿文件名当 key省掉一张映射表代码短一截改起来也直观。2.2 环境与依赖OpenCV 版本是第一个坑python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install opencv-python4.9.0.80 numpy1.26.4三行命令但有两点要留意。一是锁版本。cv2.findContours的返回签名在 OpenCV 3.x 和 4.x 之间变过3.x 返回三个值(image, contours, hierarchy)4.x 只返回(contours, hierarchy)。源码包如果是老版本写的直接pip install最新版运行时会抛ValueError: not enough values to unpack很多人第一反应是源码坏了其实是环境不对。二是别混装opencv-python和opencv-contrib-python。两个包都提供cv2模块同时装会互相覆盖表现是某些函数时有时无。这个项目只用到基础图像处理opencv-python一个就够。顺便提一句numpy 2.x 和部分老版本 opencv 的二进制接口不兼容报错信息通常是一句很含糊的numpy.core.multiarray failed to import看到这句先降 numpy 版本。2.3 主流程一个函数把四步串起来入口文件保持薄只负责流程编排具体算法全藏在core/下面# main.py import cv2 from core.locate import locate_plate from core.rectify import rectify_plate from core.segment import segment_chars from core.recognize import load_templates, recognize def run(image_path): img cv2.imread(image_path) # BGR 三通道原图 if img is None: raise FileNotFoundError(image_path) box locate_plate(img) # 返回 (x, y, w, h, score) 或 None if box is None: return None, [] # 定位失败直接短路 plate rectify_plate(img, box) # 裁剪 倾斜校正后的车牌小图 chars segment_chars(plate) # 字符小图列表长度一般是 7 或 8 templates load_templates(templates) text, scores recognize(chars, templates) return text, scores if __name__ __main__: text, scores run(data/samples/car_01.jpg) print(text, [round(s, 3) for s in scores])locate_plate返回五元组而不是四个数多出来的score是给兜底逻辑用的定位框的置信度低于阈值时宁可返回 None 让上层去重试也不要把一个歪框送进分割否则后面每一步都在错误数据上继续算错得越来越离谱。rectify_plate内部做的是最小外接矩形加cv2.warpAffine这一步不能省——抓拍图里车牌倾斜 5 到 15 度是常态不校正直接切字符垂直投影会把两个字符粘成一坨。recognize返回两个列表识别出来的文本以及每个字符的匹配得分。这两个东西要一起看文本对了但某个位置得分只有 0.4说明这个字是碰运气蒙对的换张图就会翻车。流程图省了就记一句话定位决定上限分割决定下限模板匹配只负责最后一公里的查表。3. 车牌定位从一张彩色大图里框出那个矩形定位是整个流水线里最吃调参的一步。输入是 1920×1080 的完整车头照输出是一个几十像素高的矩形。蓝色车牌在灰度图上表现为一块横跨多个字符的强边缘区域Sobel 算子对横向排列的竖直边缘响应最猛这就是整套方法的物理依据。3.1 预处理灰度、高斯、Sobel 横向梯度# core/locate.py import cv2 import numpy as np def locate_plate(img): # 1. 灰度化后续只关心亮度跳变颜色留给 4.1 的辅助分支 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2. 高斯模糊5x5 核。核太大会把字符笔画抹平太小噪点全留下 gray cv2.GaussianBlur(gray, (5, 5), 0) # 3. 只对 x 方向求导车牌是横条字符竖直边缘在水平方向的梯度最强 sobel cv2.Sobel(gray, cv2.CV_16S, 1, 0, ksize3) abs_x cv2.convertScaleAbs(sobel) # CV_16S 有负数必须转回 uint8 # 4. Otsu 自动阈值二值化比手动写 127 稳 _, binary cv2.threshold(abs_x, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) return binary说明几个参数为什么这么取。ksize3是 Sobel 核尺寸取 3 已经能覆盖字符笔画宽度取 5 或 7 会把相邻字符的边缘糊在一起轮廓连成一大片。CV_16S是带符号的 16 位类型梯度有正有负直接用cv2.Sobel输出会看到一片黑必须走convertScaleAbs取绝对值再截断到 0-255这一步漏掉是新手最常见的翻车点。高斯核用(5, 5)sigmaX0让 OpenCV 自己按核大小推。如果抓拍图噪点特别重夜间红外补光那种可以升到(7, 7)但同时要把后面的形态学核适当放大否则连通性变差。3.2 形态学闭运算把断裂的边缘连成矩形Sobel 出来的是密密麻麻的短线条直接找轮廓会得到几百个小碎片。闭运算先膨胀后腐蚀作用是把字符之间的空隙填上让整块车牌区域变成一个实心矩形。# 5. 矩形结构元素宽 17 高 5。宽度大于高度是因为车牌横向延展 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 6. 再腐蚀一次把非车牌区域的细小连接断开 closed cv2.erode(closed, kernelNone, iterations2) closed cv2.dilate(closed, kernelNone, iterations1) return closed结构元素的尺寸直接决定定位框能不能把整块车牌包进去。(17, 5)是我一般会用的起点值宽度大致对应 7 个字符之间的间隙总和高度对应字符笔画。图像分辨率翻倍时这个核也要跟着放大否则闭运算连不起来候选框碎成一地。比例关系比绝对数值更重要——宽度大概是高度的 3 倍。有同学直接照抄(17, 5)去跑 4K 图结果一个框都没有回头质疑算法不行其实是尺度对不上。我的习惯是在config.py里定义PLATE_SCALE img.shape[1] / 1280.0让核尺寸随图宽自适应。3.3 轮廓筛选宽高比和面积是两道硬门槛闭运算之后的二值图上车牌是一块近似实心的白色矩形但车灯、散热格栅、字标牌也是。筛掉它们靠两个硬指标宽高比和面积。contours, _ cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) if h 0: continue ratio w / float(h) area w * h # 标准车牌 440:140 ≈ 3.14留出宽容区间 if 2.2 ratio 4.5 and area 1500: candidates.append((x, y, w, h, area)) if not candidates: return None # 面积最大的那个当结果车牌通常比格栅小、比车标大 x, y, w, h, area max(candidates, keylambda c: c[4]) score min(1.0, area / 8000.0) return x, y, w, h, scorecv2.RETR_EXTERNAL只取最外层轮廓因为闭运算之后车牌已经是一整块内部字符的轮廓是干扰项。CHAIN_APPROX_SIMPLE只保留拐点省内存。宽高比区间2.2 ~ 4.5是有意放宽的。标准车牌是 3.14 左右但透视变形会让远处的车牌看起来更扁倾斜时又会变窄卡死在 3.0 附近反而漏检。面积阈值1500是经验值对应 1280 宽度下大约 100×15 像素的车牌更小的目标即使框出来字符也只有几个像素高模板匹配根本认不了不如放弃。score用的是面积归一化纯属启发式不要当成概率看。它的作用只有一个让上层在多个候选框之间排序时有个依据。3.4 倾斜校正别让后面的分割背锅框出来的车牌经常带角度直接裁会切到相邻字符。用最小外接矩形拿角度再做仿射变换拉正# core/rectify.py import cv2 import numpy as np def rectify_plate(img, box): x, y, w, h, _ box roi img[y:y h, x:x w] gray cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return roi rect cv2.minAreaRect(max(contours, keycv2.contourArea)) angle rect[2] # minAreaRect 的角度在 [-90, 0)换算成小角度倾斜 if angle -45: angle 90 # 以 ROI 中心为原点旋转边界用白色填充避免黑边影响二值化 M cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) rotated cv2.warpAffine(roi, M, (w, h), flagscv2.INTER_CUBIC, borderModecv2.BORDER_REPLICATE) return rotatedcv2.minAreaRect返回的角度范围是[-90, 0)接近 0 表示水平接近 -90 表示竖直。用这种换算方式把大角度排除只保留小角度倾斜。BORDER_REPLICATE用边缘像素填充比默认的黑色填充友好——黑边会被后续二值化当成背景字符分割时切出一堆空列看着像分割算法错了其实是填充方式选错了。这一步的收益在后面的垂直投影上体现得最明显。校直前投影曲线是一团糊校直之后字符之间会露出清晰的低谷。4. 字符分割与模板匹配把七个字一个个认出来定位拿到的是整块车牌识别之前得先切成单个字符。国内车牌格式固定普通蓝牌七位、新能源绿牌八位第一位是省份简称第二位是发牌机关字母后面五位数字字母混排。知道格式就能用规则约束分割结果这比纯靠连通域硬切靠谱得多。4.1 颜色辅助HSV 空间里把蓝牌和绿牌分开Sobel 定位只认边缘遇到黄色车牌或者车身贴纸容易误框。加一路颜色判断做交叉验证成本很低# core/segment.py 片段 import cv2 import numpy as np def plate_color_mask(roi): hsv cv2.cvtColor(roi, cv2.COLOR_BGR2HSV) # 蓝色车牌色相集中在 100-124 blue cv2.inRange(hsv, np.array([100, 80, 80]), np.array([124, 255, 255])) # 新能源绿牌色相 35-85饱和度要求更高 green cv2.inRange(hsv, np.array([35, 100, 80]), np.array([85, 255, 255])) mask cv2.bitwise_or(blue, green) # 开运算去掉零散噪点核 3x3 足够 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) return cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)inRange的两个数组分别是下界和上界顺序是(H, S, V)。H 在 OpenCV 里范围是 0-179不是 0-360蓝色对应的 200-248 度要除以 2 才得到 100-124。这个细节坑过太多人网上抄来的阈值如果按 0-360 写inRange会静默返回全黑不报错也不提示。饱和度下界给 80是为了躲开灰白色的车身区域。亮度下界同样给 80避免夜间过暗的像素被误判。这两个值随光照变化大天亮和黄昏要分别标定别指望一组参数吃全天。4.2 垂直投影数每一列有多少个白点二值化后的车牌图字符是白、背景是黑。把每一列的白点数量统计出来字符所在位置会形成高峰字符间隙形成低谷切分点就在低谷里。def segment_chars(plate_bgr): gray cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 反色 Otsu让字符变白、底色变黑 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) h, w binary.shape # 逐列求和再除以 255得到每列的白点个数 proj (binary 0).sum(axis0) # 低于阈值视为字符间隙阈值取高度的 5% thresh h * 0.05 chars, start [], None for i in range(w): if proj[i] thresh and start is None: start i elif proj[i] thresh and start is not None: # 宽度小于 5 像素的多半是噪点或边框丢掉 if i - start 5: chars.append(binary[:, start:i]) start None if start is not None and w - start 5: chars.append(binary[:, start:w]) # 普通车牌 7 位、新能源 8 位多出来的按宽度排序后截断 chars sorted(chars, keylambda c: c.shape[1], reverseTrue)[:8] return chars[::-1] if len(chars) 7 else charsTHRESH_BINARY_INV是为了让字符变白。Otsu 自动找阈值对光照不均的图比固定 127 稳。投影阈值取高度的 5%这个比例能滤掉车牌边框那条细线——边框在投影上是一条贯穿全高的峰不滤掉的话会被当成一个超宽字符。实际跑的时候经常切出 8 段或 9 段因为车牌上的铆钉、分隔圆点在投影上也会形成一个窄峰。所以我加了一步按宽度排序截断再按原顺序还原。这一步不是万能药遇上川桂这类笔画分散的字符投影低谷不明显两个字符会粘在一起得靠后面的模板匹配得分来自检。4.3 模板匹配统一尺寸再算相关系数模板匹配的核心约束是尺寸必须一致。cv2.matchTemplate要求模板不大于搜索图且只有在同尺度下相关系数才有意义。所以每个切出来的字符先缩放到固定尺寸模板在制作阶段也缩放到同样尺寸。# core/recognize.py import cv2 import os import numpy as np TPL_SIZE (20, 40) # 宽 20、高 40比常见字符略瘦抗横向挤压 def load_templates(root): templates {} for sub in (province, chars): folder os.path.join(root, sub) for name in os.listdir(folder): if not name.lower().endswith(.png): continue path os.path.join(folder, name) # 用 np.fromfile 读绕开 cv2.imread 不支持中文路径的问题 img cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_GRAYSCALE) _, img cv2.threshold(img, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU) templates[os.path.splitext(name)[0]] cv2.resize(img, TPL_SIZE) return templates def recognize(chars, templates): text, scores [], [] for ch in chars: norm cv2.resize(ch, TPL_SIZE) norm cv2.threshold(norm, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)[1] best_char, best_score , -1.0 for label, tpl in templates.items(): res cv2.matchTemplate(norm, tpl, cv2.TM_CCOEFF_NORMED) score float(res.max()) if score best_score: best_score, best_char score, label text.append(best_char) scores.append(best_score) return .join(text), scoresTM_CCOEFF_NORMED是归一化相关系数输出范围大致在 -1 到 1 之间1 表示完全一致。选它而不是TM_SQDIFF是因为相关系数对整体亮度偏移不敏感抓拍图明暗变化大时更稳。TPL_SIZE取(20, 40)而不是正方形是因为车牌字符本身是竖长的强行拉成正方形会引入形变同一字符在不同倾斜角度下缩出来的形状差异变大匹配得分整体下滑。这个尺寸是我试过几组之后固定下来的改成(30, 60)精度略有提升但速度慢一倍看你的场景取舍。得分低于 0.45 的字符我一般会标红人工复核不直接输出。工程上宁可不认也别认错——一个错字在道闸场景意味着开错闸或者记错费代价比多一次人工确认高得多。5. 避坑与排查五个真实翻车现场这套流程跑通不难难的是换一张图就崩。下面这五条是我和身边同学踩过的按「现象 → 原因 → 解决」记下来。现象一cv2.imshow窗口一闪而过什么都看不清。原因脚本执行完就退出GUI 事件循环没机会处理重绘。解决imshow后面必须跟cv2.waitKey(0)阻塞再cv2.destroyAllWindows()。调试阶段我通常写成cv2.waitKey(0) if DEBUG else None用一个开关控制免得批量跑图时每张都卡住。现象二cv2.imread返回None但文件明明存在。原因cv2.imread在 Windows 下不支持中文路径和中文文件名遇到就静默返回 None不抛异常很难查。解决改用cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_COLOR)写文件同理换成cv2.imencode加tofile。这个坑在读取模板目录时尤其容易撞上。# 中文路径读图的通用写法 img cv2.imdecode(np.fromfile(path, dtypenp.uint8), cv2.IMREAD_COLOR) # 中文路径写图 cv2.imencode(.jpg, img)[1].tofile(out_path)现象三定位框把整个车头都框进去了或者一个框都没有。原因形态学核尺寸和图像分辨率不匹配。核太小连不成块核太大把相邻的车灯格栅也连进来。解决把核尺寸写成相对值kw max(9, int(w / 80))kh max(3, int(h / 200))随图宽高自适应。改完先在data/samples里挑三五张不同分辨率的图对比看效果别只盯一张。现象四所有字符都识别成同一个字比如全是「8」。原因模板图没有做二值化和统一尺寸就丢进matchTemplate或者模板本身是从彩色图上直接裁的带背景色。解决加载模板时就走一遍THRESH_BINARY_INV Otsu加resize保证模板和待测图在同一个表示空间里。另外检查templates/chars目录里是不是混进了非字符图片比如说明图或者缩略图os.listdir会把它一起加载名字带特殊字符时权重异常高。现象五新能源绿牌死活定位不到。原因Sobel 定位对边缘不敏感颜色理论上不该失效但绿牌的底色亮度和蓝牌差异大Otsu 全局阈值会把绿底当背景一起二值化掉边缘随之消失。解决在locate_plate之前加一路 HSV 颜色掩码蓝色和绿色各一个inRange把掩码和 Sobel 结果做bitwise_and再走形态学。绿牌的 H 范围比蓝牌宽阈值要单独标定别直接套用蓝牌那组。还有一条不算 bug 但很耗时间cv2.findContours在 OpenCV 3.x 返回三个值、4.x 返回两个值。拿到老源码先看requirements.txt里锁的版本对不上就在解包处加一层兼容contours cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours contours[-2] # 3.x 取第二个4.x 取第一个-2 两边都对这行[-2]是我在一个维护多年的老仓库里学到的写法比写if len(...) 3干净也不用担心以后版本再变。6. 二次开发从能跑到敢换场景拿到源码跑通第一张图只是开始。真正决定这套东西能不能留在你项目里的是换一个场景之后还能不能跑。先说最容易改的加一辆新能源绿牌。plate_color_mask里已经有绿色的inRange你要做的是把它和 Sobel 结果做与运算而不是或运算——与运算要求两个条件同时满足能显著降低车身绿色贴纸的误检。改完拿五张绿牌图验证看定位框是不是稳稳落在牌面上别只看最终识别结果中间环节对了结果才对。再说字符集。模板匹配的扩展成本极低加一个省份简称只需要往templates/province/里丢一张干净的字符图文件名就是那个字本身代码一行不用改。这是模板匹配相对深度学习的最大优势不用重新训练不用准备几千张标注图加完立刻生效。代价是模板质量直接决定准确率模板图如果带噪点或者笔画粘连对应字符的识别率会明显低于其他字符。建议模板图统一从同一批正样本里裁尺寸和字体保持一致。如果模板匹配的准确率顶不住了比如你的场景里车牌反光严重、字符磨损明显可以换成轻量 CNN 或现成的 OCR 引擎。两条路的差别维度模板匹配轻量 CNN冷启动成本几十张模板图即可需要几千张标注样本单字符耗时毫秒级CPU 足够推理几毫秒可上 CPU抗形变能力弱倾斜或模糊就掉分较强需数据覆盖新增字符加一张图立即生效重新训练或增量微调可解释性高能看每个字符的得分低只能看输出概率我的建议是先用模板匹配把整条链路跑顺定位和分割调稳再考虑换识别器。顺序反了会很痛苦——你分不清是分割切错了还是模型不行两头猜最后哪个都没调好。验证方法也要提前定好。我会准备一套自己的刁钻图集逆光、夜间、倾斜 20 度、车牌被部分遮挡、绿牌蓝牌各一半一共二三十张。每改一次参数就整批跑一遍记录两个数字——定位命中率和字符准确率。只看单张图的效果很容易自欺欺人改 A 图好了 B 图坏了来来回回没有尽头。从那以后我每次拿到这类识别项目都先跑一遍自己攒的这二三十张图把两个指标写在纸上贴在屏幕边改一次参数就重跑一次不达标就不往下走。这套习惯救过我好几次——有一次调了半天的定位核最后发现是测试集里根本没有夜间图指标虚高得离谱。希望这套流程帮到你少走几个来回。本文还有配套的精品资源点击获取
返回列表