资讯详情 OpenCV模板匹配实现车牌识别:从定位到字符识别的完整流程
📅 2026/10/11 0:45:52
简介这是一套面向Python与OpenCV学习者的车牌自动识别实战资源基于真实车辆图像完成检测与识别流程适合智能交通、停车场管理等场景的初学者参考。包内涵盖1999张车辆及字符图片、1个Python主程序及字符匹配模板共2000个文件压缩包大小12.37MB可直接运行调试。资源围绕图像预处理、车牌定位、字符分割、模板匹配四个核心环节展开并配有鄂、豫、黑等地的车牌字符模板便于理解不同省份字符差异。从灰度化、二值化、滤波去噪到边缘检测、轮廓提取、垂直投影分割再到与模板库匹配输出车牌号码代码结构与数据组织清晰有助于快速掌握传统图像处理方式下的车牌识别思路。该资源已有3105人学习下载对想要通过案例提升OpenCV实战能力、完成课程设计或入门车牌识别项目的读者而言是一份紧凑可用的参考素材。1. 车牌识别为什么先用模板匹配一个免费且可控的 OpenCV 基线车牌自动识别是停车管理、门禁道闸场景的刚需很多人一开始就冲着深度学习去实际部署才发现显卡、数据集、标注全是坑。这个项目反而走了一条更朴素的路用 OpenCV 图像处理做定位和分割用字符匹配模板做识别不用训练任何模型几百行 Python 就能把「输入一张图 → 输出一串车牌号」的完整链路跑通。配套源码里有完整的定位、分割、识别代码测试图片覆盖白天、阴天、倾斜视角几种常见情况模板文件就是普通的 PNG 二值图可以直接替换。适合刚接触 OpenCV 图像处理、想搞明白每个环节在干什么的新手也适合要给毕设或门禁系统快速加识别模块、又不想碰数据集标注的开发者。我拆这个项目最直接的感受是模板匹配的识别率不如深度学习但它够简单、够透明每一步错了都能看出堵在哪、改哪里能救回来。2. 车牌定位边缘检测、轮廓筛选与透视矫正三个环节怎么串车牌定位是整个识别流程里最影响后续环节质量的一步。定位不准后面分割和识别全都是无米之炊。先明确一个观点不要指望一步到位定位阶段宁可多输出几个候选框也不要漏掉真正的车牌。实际项目里我一般把定位拆成三件事预处理产出干净的边缘图轮廓筛选找出形状像车牌的连通域透视矫正把倾斜的候选块拉正。这三步各有各的参数坑下面分节说。2.1 预处理链路灰度、高斯滤波、Canny 与闭运算原始图像是 BGR 三通道直接拿来做边缘检测有两个问题颜色通道之间的梯度会产生不少伪边缘三通道的运算量也白白浪费。所以第一步永远是转灰度把亮度梯度作为唯一信息来源。接着用高斯滤波去掉传感器噪声和 JPEG 压缩带来的高频杂点这些杂点对后续找轮廓非常不友好。import cv2 import numpy as np def preprocess(image): # 转灰度丢掉颜色信息只保留亮度梯度 gray cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) # 高斯滤波5x5 核sigmaX0 表示由核尺寸自动推导 blurred cv2.GaussianBlur(gray, (5, 5), 0) # Canny 边缘检测低阈值 100高阈值 200 edges cv2.Canny(blurred, 100, 200) # 闭运算先膨胀后腐蚀把字符边缘连接成连通域 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) return gray, closedCanny 的低阈值决定哪些梯度算候选边缘高阈值决定哪些强梯度直接保留。100/200 这个组合在白天正常光线下比较稳但遇到低对比度场景比如白车上挂白牌边缘本身就很弱我习惯降到 50/150 再看效果。闭运算核的宽度 17 是经验值含义是能覆盖三到四个字符之间的空隙如果输入图中车牌区域更宽这个核要按比例放大否则字符边缘之间连不上车牌会碎成好几块。这里有一个容易被忽略的细节闭运算会同时放大小噪声如果预处理后发现轮廓数量异常多优先怀疑高斯滤波的核太小而不是闭运算的参数错了。2.2 轮廓筛选宽高比、面积与多候选策略闭运算之后真正的车牌区域通常会变成一个完整的连通域但车身、保险杠、路牌也可能连进来。所以轮廓筛选要看两个硬指标宽高比和像素面积。标准蓝色车牌的宽高比是 440:140约为 3.14我一般把范围放到 2.5 到 4.5再要求面积不小于 1500 平方像素。这个面积下限要跟着图像分辨率走1920 宽的图中车牌至少上万像素1500 几乎兜不住。def find_plate_candidates(closed, min_area1500): # 兼容 OpenCV 3.x / 4.x 的返回值差异统一取最后一项 cnt_result cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) contours cnt_result[-2] candidates [] for cnt in contours: # 最小外接矩形比 boundingRect 更贴合倾斜车牌 rect cv2.minAreaRect(cnt) (x, y), (w, h), angle rect if w h: w, h h, w ratio w / h area w * h if 2.5 ratio 4.5 and area min_area: # 拿到四个角点后续直接喂给透视变换 box cv2.boxPoints(rect) candidates.append((box, ratio, area)) # 按面积从大到小排序面积最大者优先 candidates.sort(keylambda item: item[2], reverseTrue) return candidates这里用minAreaRect而不是boundingRect是因为路边拍的车牌经常带 5 到 15 度的倾斜角水平外接矩形会把车身边缘也圈进去宽高比直接失真。minAreaRect返回的旋转矩形和四个角点能保留真实的几何尺寸。筛选条件里我没有写角度过滤因为停车角度千奇百怪只要四个点都在图像范围内透视矫正都能拉回来。实际操作中我会返回候选列表的前三个而不是只取第一个。提示多候选策略是定位阶段最值得养成的习惯。把面积前三的候选块全部送进后面的流程让字符识别的置信度来拍板。定位阶段错杀一个真车牌后面再好的算法也救不回来。2.3 透视矫正四角排序与尺寸归一化minAreaRect给出的四个点顺序不固定直接传给getPerspectiveTransform很容易得到旋转 90 度或者镜像的图。这里需要一个固定顺序的四角排序函数。我习惯用和差法左上角坐标和最小右下角坐标和最大右上角 x 减 y 最小左下角 x 减 y 最大。def order_points(pts): rect np.zeros((4, 2), dtypenp.float32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上xy 最小 rect[2] pts[np.argmax(s)] # 右下xy 最大 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上x-y 最小 rect[3] pts[np.argmax(diff)] # 左下x-y 最大 return rect def four_point_transform(image, pts): rect order_points(pts) tl, tr, br, bl rect # 目标尺寸取四条边的最大宽高保持原始长宽比 width max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)) height max(np.linalg.norm(tr - tl), np.linalg.norm(br - bl)) width, height int(width), int(height) dst np.array([ [0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1] ], dtypenp.float32) M cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, M, (width, height)) return warped这段代码的关键点是目标尺寸没有写死成 440x140而是根据输入四边形的实际对角线长度算出来的。原因很简单不同相机、不同距离拍到的车牌像素宽度差异很大强制缩到 440x140 容易把本来就模糊的图拉变形字符边缘出现锯齿。保持长宽比的矫正图后面做字符分割时投影值更平滑。另外cv2.warpPerspective默认用双线性插值矫正后字符边缘会有轻微虚化如果发现匹配得分不稳定可以把最后一个参数换成cv2.INTER_CUBIC再试。3. 字符分割OTSU 二值化与垂直投影切块的细节与边界定位矫正之后拿到一张只有车牌区域的图接下来要把七个字符逐个切出来。这一章的难点在于车牌并不只有字符边框、铆钉、分隔点、反光都会干扰投影。二值化做不好投影全是噪声分割策略选不对汉字被切碎或粘连。3.1 二值化方向与去边框先把背景噪音清干净矫正图上字符是深色底色是浅色用 OTSU 自动阈值本来能处理好。但 OTSU 并不保证“字符白、背景黑”的方向在高光或阴影下它可能把背景当前景。因此我用了一个占比检查二值化后如果白色像素占比超过一半就反色保证字符区域始终是白色。def binary_plate(plate_gray): # OTSU 自动阈值 _, binary cv2.threshold(plate_gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 白像素占比过高说明反了翻回来 if np.mean(binary) 127: binary cv2.bitwise_not(binary) # 开运算去铆钉和小噪点先腐蚀后膨胀 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) return binary开运算对铆钉很有效铆钉是车牌两边的圆形小凸点直径通常小于字符笔画用 3x3 的核开运算能去掉它们同时基本不影响笔画。如果发现铆钉残留把核改成 5x5 再跑一次但要小心字符笔划会同时变细匹配阶段得分会下降。去边框更常用的做法是先做水平投影统计每一行的白色像素数量字符所在的行区间投影明显高于上下边框区取最大连续区间就能把上下边框裁掉。def crop_band(binary): # 水平投影统计每行白点数 row_hist np.sum(binary, axis1) # 行阈值取图像宽度的 2%过滤掉零星噪声 rows np.where(row_hist binary.shape[1] * 0.02)[0] if len(rows) 0: return binary top, bottom rows[0], rows[-1] # 上下各扩 2 像素保证笔画不贴边 top max(0, top - 2) bottom min(binary.shape[0], bottom 2) return binary[top:bottom, :]水平投影之后左右边框残留可以通过检查非零像素的列范围一并裁掉。字符区域的 x 范围比左右边框更靠内所以找出最左和最右的非零列把贴边的空白裁掉即可。这一段处理做完二值图应当只剩下干净的一行字符。3.2 垂直投影分割找到每个字符的左右边界垂直投影的原理很好懂对二值图按列求和字符列的白色像素密度高字符间隙的密度接近零。从左向右遍历列投影连续的高值区间就是一个字符的左右边界。def vertical_split(binary): h, w binary.shape col_sum np.sum(binary, axis0) # 列阈值高度乘 0.05用于区分字符列和间隙列 threshold h * 0.05 segments [] in_char False start 0 for x in range(w): if col_sum[x] threshold and not in_char: in_char True start x elif col_sum[x] threshold and in_char: in_char False if x - start 8: # 宽度小于 8px 的碎片直接丢弃 segments.append((start, x)) return segmentsthreshold 取 h*0.05 的含义是这一列至少有 5% 的像素是白色才认为它是字符的一部分。阈值太高会把细笔画字符比如“1”切碎太低会把分隔符“·”也装进去。宽度 8px 的下限是过滤分隔符的第一道防线但不同分辨率下分隔符可能宽达 10 到 12px所以这道防线不够下一节还有位置过滤兜底。3.3 分割后处理过滤分隔符和归一化尺寸切出来的 segments 数量不一定是 7常见情况是 6、7、8、9 都有。多出的通常就是分隔符少数情况是汉字被横向腐蚀断开。我的处理顺序是先用位置信息过滤标准蓝牌的格式是一个汉字加一个字母加五个数字或字母分隔符固定出现在第二个字符后面约 20 像素的位置。def filter_segments(segments, img_width): # 位置过滤分隔符位于前两个字符之后且宽度 / 高度明显偏小 filtered [] for idx, (x1, x2) in enumerate(segments): width x2 - x1 # 假设第二位之后的第一个窄段是分隔符 if idx 2 and width 15: continue filtered.append((x1, x2)) return filtered真实项目中我一般直接把所有 segments 的宽度排序宽度最大的 7 个当作字符其余是噪声或分隔符。汉字虽然结构复杂但它的外接宽度不比数字窄这个排序法在绝大多数情况下够用。接下来是把每个字符裁剪并归一化到固定尺寸模板匹配阶段必须保证输入尺寸一致。def normalize_char(cell, size(20, 20)): # 找到字符实际边界去掉周围空白 rows np.any(cell, axis1) cols np.any(cell, axis0) rmin, rmax np.where(rows)[0][[0, -1]] cmin, cmax np.where(cols)[0][[0, -1]] cell cell[rmin:rmax1, cmin:cmax1] # 保持长宽比的缩放到目标尺寸 cell cv2.resize(cell, size, interpolationcv2.INTER_AREA) return cellnp.any按行和列找出字符实际占用的矩形先裁掉空白再做 resize这样“1”这种细字符不会被空白放大稀释掉特征。resize 的插值方式用 INTER_AREA它适合缩小对二值图不会产生额外的边缘伪影。我还整理了一张异常表方便快速判断分割结果哪里出了问题现象可能原因对策切割段数大于 7分隔符或噪声被当成字符优先按位置过滤再按宽度排序取前 7切割段数小于 7汉字笔画分离或两个字符粘连降阈值重试粘连时用 1x3 核腐蚀断开某段高度明显偏矮铆钉残留或投影碎片检查二值化后再跑一次开运算分割结束后的每个 cell 我都会存成单独的调试图片直接在磁盘上看一眼就能定位是哪一步出了问题比在终端里打印坐标高效得多。4. 字符识别TM_CCOEFF_NORMED 模板匹配与置信度控制字符识别是整个流程的收口。模板匹配的思路是把切割出来的字符图片和模板库里每个字符逐一比较取相似度最高者作为结果。模板匹配不是 OCR它不做语义理解但用在车牌的印刷体字符上是够用的因为车牌字符形态规整、字体变化小。4.1 模板库构建字符来源与归一化存储模板文件是二值 PNG文件名本身就是字符。项目里按“汉字”“字母”“数字”三个目录存放运行时统一加载。模板的尺寸我会统一缩放到 20x20和分割阶段的归一化尺寸保持一致。模板尺寸越小匹配越快20x20 是速度和精度的折中。from pathlib import Path def load_templates(template_dir, size(20, 20)): templates {} for path in Path(template_dir).glob(*.png): char path.stem img cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) # 模板本身就是二值图固定阈值 127 即可 _, binary cv2.threshold(img, 127, 255, cv2.THRESH_BINARY) binary cv2.resize(binary, size, interpolationcv2.INTER_AREA) templates[char] binary return templates这里有两个容易被忽略的点。第一模板二值化要固定阈值而不是用 OTSU原因是模板图片来源可能不同OTSU 会在某些模板上得到不同的二值方向导致匹配基准漂移。第二加载完成后最好加一个尺寸断言防止后续新加的模板忘记 resize一旦尺寸不一致matchTemplate 运行到一半才抛异常查起来很痛苦。4.2 匹配策略分析TM_CCOEFF_NORMED 为什么比 TM_SQDIFF 稳OpenCV 的 matchTemplate 有六种匹配方法最常被拿来对比的是平方差和归一化相关。TM_SQDIFF 计算像素差的平方和数值越小表示越相似但它对亮度偏移和整体灰度变化很敏感。同一块车牌在阴影和阳光直射下拍出来的平均亮度差很多用 TM_SQDIFF 时模板得分会剧烈变化。TM_CCOEFF_NORMED 在计算前先各自减去均值再做归一化相关运算相当于剔除掉了整体亮度的影响。def match_char(cell, templates): # 统一尺寸避免模板和待匹配图大小不一致 cell cv2.resize(cell, (20, 20)) best_char None best_score -1.0 for char, tmpl in templates.items(): tmpl cv2.resize(tmpl, (20, 20)) res cv2.matchTemplate(cell, tmpl, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ cv2.minMaxLoc(res) if max_val best_score: best_score max_val best_char char return best_char, best_score因为 cell 和 tmpl 尺寸完全一致matchTemplate 返回的是 1x1 的结果矩阵minMaxLoc 取出来的 max_val 就是这个字符和模板的归一化相关系数。范围在 -1 到 1 之间越接近 1 越像。实测正常车牌的字符得分基本在 0.7 以上0.5 以下基本属于乱匹配。模板匹配还有一个内置限制它对几何畸变和旋转几乎不设防所以前两章的透视矫正和归一化才那么重要。这也是为什么不建议直接对原图上的字符跑模板匹配必须先分割再矫正。4.3 结果串联与低置信度补救每个字符独立匹配之后把 7 个结果拼成字符串。但直接拼接的鲁棒性不够低置信度的字符应该显式标记出来而不是硬给出一个错误答案。我一般设 0.6 为置信度门槛低于门槛就输出问号让上层业务逻辑决定怎么处理。def recognize_plate(cells, templates, threshold0.6): result [] scores [] for cell in cells: char, score match_char(cell, templates) if score threshold: char ? result.append(char) scores.append(score) plate .join(result) return plate, scores模板匹配很容易把“0”和“O”、“1”和“I”、“8”和“B”搞混这些字符的骨架在二值图上非常接近。我的处理方式是利用车牌格式的先验知识第 1 位只能是汉字第 2 位只能是字母其余位字母或数字都可能。所以匹配阶段按位缩小模板集合第二位只和字母模板匹配就不会出现把“O”识别成数字 0 而把真正答案挤掉的情况。def match_char_with_constraint(cell, templates, allowed_chars): cell cv2.resize(cell, (20, 20)) best_char None best_score -1.0 for char in allowed_chars: tmpl templates[char] res cv2.matchTemplate(cell, tmpl, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ cv2.minMaxLoc(res) if max_val best_score: best_score max_val best_char char return best_char, best_score提示位置约束不一定是死的。新能源车牌有 8 位挂车车牌有 8 位做通用系统时先检测字符总数再决定模板范围比写死第二位更稳。这套「位置约束 置信度门槛」做下来常见混淆字符的错误率能明显下降。项目源码里的做法和我描述的基本一致区别在于源码里还加了候选多框识别定位阶段传进来的三个候选框各自跑一遍分割和识别取平均置信度最高的那组作为最终结果。多候选配合置信度打分是模板匹配方案里性价比最高的稳健性手段。5. 避坑与排查五个真实翻车记录与修复方法模板匹配方案看起来步骤不多但每一步都有隐藏的翻车点。这里记录五条我实际踩过的坑按处理阶段分类。5.1 预处理阶段Canny 阈值和闭运算核尺寸第一条Canny 阈值一刀切换一个场景就崩。 现象强光或夜间场景下边缘图要么全是碎片要么干干净净没有任何轮廓车牌直接消失。 原因100/200 的阈值是根据白天正常光线的图片调的光照条件变化后梯度幅值分布完全不同。 解决我先用 CLAHE 做自适应直方图均衡再用图像整体亮度估算阈值。粗调方法高阈值取 0.3 乘以图像灰度最大值低阈值取高阈值的一半跑出来的效果比固定阈值稳很多。第二条闭运算核尺寸是用血的教训试出来的。 现象同一套代码在不同分辨率下有的图车牌连不上有的图整个车头被连成一块。 原因核宽度没有根据车牌在画面中的像素宽度自适应。17px 在 640 宽的图中覆盖了三个字符间距在 1920 宽的图中只覆盖了不到一个字符间距效果完全不同。 解决先用灰度图粗略找面积较大的连通域估算车牌宽度再按车牌宽度的 1/15 到 1/20 动态生成闭运算核。虽然多跑了一次轮廓查找但换来的是不同分辨率下稳定的连通效果。5.2 分割阶段二值化反色和分隔符误判第三条OTSU 二值化方向反了投影切出的是背景。 现象垂直投影切出的片段数量和位置都怪识别的字符全是乱码调试好几轮都没头绪。 原因车牌图像面积里字符占比较小OTSU 按类间方差最大分割在某些光照下把大面积背景判成了前景白像素占比超过一半字符反而是黑色。 解决在二值化后加一个np.mean(binary) 127的判断反色一次。这条处理之后分割效果会立刻恢复正常。建议把判断写成独立函数每次加载新图都打印黑白像素比例。第四条分隔符被当成字符分割段数到了 8 甚至 9。 现象多出来的段宽度在 8 到 12 像素之间短但特征清晰直接被当成了字符。 原因宽度过滤的阈值设置得保守或者那个场景下分隔符恰好较宽。 解决不要只靠宽度过滤。分隔符的位置是固定的它在第二个字符右侧按索引判断更稳。先排掉分隔符所在区间的候选再用宽度排序取前七个。这两步叠加误判率会低很多。5.3 识别阶段模板尺寸不一致导致运行时报错第五条新加模板后 matchTemplate 直接报错。 现象程序运行到 match 阶段抛异常错误信息是类似img.size() templ.size()的断言失败。 原因新加的模板没有缩放到 20x20或者模板图片比待匹配字符图还大OpenCV 在模板比搜索图还大时无法计算。 解决在 load_templates 加载完成后统一做尺寸校验不满足的直接 resize并且断言加载出来的尺寸完全是 (20, 20)。这样问题在启动阶段就会显形而不是等到识别阶段才崩。还有一个补充经验同一字符的模板字体不统一得分会忽高忽低解释结果很痛苦。解决方案是每个字符只保留同一种字体的一个模板如果确实存在多字体形态就为每个形态建一个模板文件文件名用字符加序号区分如A_1.png、A_2.png匹配时同一字符取多个模板的最高分。6. 准确率优化技巧HSV 颜色筛选和多帧投票模板匹配的上限摆在那里但日常使用中准确率还能再拉高。我从项目里学到两个比较有效的优化方向一是定位前用 HSV 颜色空间做一次颜色筛选二是视频流场景用多帧投票消除单帧随机误差。先看 HSV 颜色筛选蓝色的车牌几乎都能先通过颜色掩膜找到候选区域。在光照稳定的场景下HSV 比 BGR 更适合描述颜色因为它把色调、饱和度、亮度拆开了。掩膜生成后和 Canny 边缘图做叠加能挡住大部分与车牌无关的纹理。hsv cv2.cvtColor(image, cv2.COLOR_BGR2HSV) # 蓝色范围H 100~124S 80~255V 80~255 mask cv2.inRange(hsv, (100, 80, 80), (124, 255, 255)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((17, 5), np.uint8))HSV 的范围不是随便给的光照变化时 S 和 V 漂移明显H 相对稳定。常用范围做一个参考表车牌颜色H 范围S 范围V 范围蓝牌100 - 12480 - 25580 - 255黄牌15 - 35100 - 25580 - 255多帧投票更直接视频流每秒 25 帧同一个车牌会被识别很多次。把最近十帧的识别结果收集起来统计整个字符串的出现次数取最高频且不低于两票的。这个策略对单帧运动模糊和反光造成的偶发错误非常有效实现只需要一个 Counter。from collections import Counter def vote_plates(plates): counter Counter(plates) plate, count counter.most_common(1)[0] if count 2: return plate return None # 票数不足等待更多帧两票的门槛是我反复测出来的平衡点。门槛太高会拖慢输出速度太低防不住偶发噪声。实际应用中我还会做一个变体不直接对整个字符串投票而是对每个字符位置分别投票再拼接这样即使单帧某个字符被噪声污染也不会因为整串不同而被丢弃对“0/O”“1/I”这类混淆字符尤其有效。除了投票我还会在每帧打印每个字符的置信度列表间隔统计一次用来判断当前模板库的短板到底在哪个字符上。从那以后我每次拿到一批新的车牌图片都会强制先跑一遍统计脚本看轮廓数量分布、二值化黑白占比、每个字符的置信度均值。这三项数据先过一遍再决定要不要动参数比拿一张图反复试快很多。模板匹配方案就是这样每个环节都有明确的输出可以检查你看得到它错在哪改起来才有的放矢。希望帮到你。本文还有配套的精品资源点击获取