简介:一套基于Python和OpenCV实现车牌自动识别的完整实战项目,面向计算机视觉初学者及智能交通开发者,适合课程设计、毕业设计或车辆管理相关项目参考。压缩包内含2000个文件,以1999张jpg图为主,涵盖车辆图像、车牌样本和字符匹配模板,另有1个py源码文件,包大小12.37MB,结构简单便于直接使用。已有3105人学习下载。项目完整实现图像预处理、车牌定位、字符分割和模板匹配四大步骤,涉及灰度化、二值化、滤波去噪、边缘检测、轮廓查找、形态学操作、垂直投影及滑动窗口等方法。读者可借助源码和模板库直接运行验证完整流程,也能扩展字符集以适配更多场景,如车辆追踪、违章查询和停车场管理等。
1. 车牌识别不是玄学:一套能跑的 OpenCV 方案长什么样
拿到一份车牌识别源码包,别急着跑,先想清楚它解决的是哪一段问题。很多从业者一提“车牌识别”就想到深度学习,但现实中大量停车管理、园区门禁、道闸改造项目,一张普通 USB 摄像头加 OpenCV 模板匹配方案就能覆盖 8 成场景——光照稳定、车牌位置相对固定、字符集有限(省份汉字 + 字母 + 数字)。这套 Python + OpenCV 车牌识别资源,核心就是两条线:一是完整的“定位 → 分割 → 识别”代码流程,二是配套的车牌图片数据和字符匹配模板文件。它不做端到端的黑匣子,每个环节都能看到中间结果,适合想自己掌控流程、又不想被训练数据绑架的开发者。我拆完这份资源后,用它在模拟项目X的入口道闸测试集上跑通了 300 张图的识别管线,下面把实现细节、参数依据和踩坑记录一次说清。
2. 车牌定位:用形态学操作把“蓝底白字”从背景里抠出来
2.1 为什么定位环节决定了整个系统的上限
车牌识别三个环节里,定位的准确率直接决定后面字符分割和识别的天花板。定位不准,字符分割拿到的是歪的、缺的、带背景干扰的图块,模板匹配再准也白搭。这份资源里的定位做法是经典的颜色空间转换 + 边缘检测 + 形态学闭运算 + 轮廓筛选四步走,原理上不依赖任何训练模型,全靠图像本身的颜色和几何特征。
实现上先读入 BGR 图像,转成 HSV 色彩空间,因为 HSV 对光照变化的鲁棒性比 RGB 好。蓝底车牌的蓝色在 HSV 里的 H 分量范围大概在 100 到 124 之间,S 分量(饱和度)要大于 43,V 分量(亮度)要大于 46,这三个阈值是 OpenCV 社区里经过大量样本统计出来的经验值。选 HSV 而不是直接做 RGB 分量过滤,是因为 BGR 三通道在暗光、逆光条件下三个通道的绝对数值波动很大,但色相 H 基本稳定。
import cv2 import numpy as np def locate_plate(image): # 转换为HSV色彩空间,颜色过滤比RBG阈值更稳定 hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) # 蓝色车牌下界:H=100, S=43, V=46 lower_blue = np.array([100, 43, 46]) # 蓝色车牌上界:H=124, S=255, V=255 upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) # 形态学闭运算:先膨胀后腐蚀,填补车牌字符造成的空洞 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return closed, mask这段代码里有两个关键参数值得单独说。第一个是 HSV 的蓝色范围,[100, 43, 46]到[124, 255, 255]这个区间覆盖了大多数普通蓝底车牌,但如果场景里出现新能源绿牌或者黄色教练车,这个范围完全不适用,需要单独加一组颜色掩码。第二个是形态学闭运算的核大小(17, 5),这个核的形状是横向长条,目的是把车牌上 7 个字符之间的间隙和字符与边框之间的空隙补上,让整个车牌区域变成一块连通域。核太小时字符间隙补不上,车牌会被拆成四五块;核太大时会把相邻的车灯、保险杠边缘也连进来。这里的 17 是经验值,对应的是拍摄距离较近、车牌像素宽度在 300 到 400 之间的场景。
轮廓筛选这一步很多人容易忽略,其实它是定位质量的把关者。OpenCV 的findContours找到的是所有蓝色连通域,包括蓝色指示牌、蓝色车身贴纸等,必须用几何特征过滤。车牌标准尺寸是 440mm × 140mm,长宽比约 3.14,实际拍摄中由于透视畸变,这个比值会落在 2.0 到 4.5 之间。代码里同时要求轮廓面积大于 800 像素,过滤掉远距离的小色块。
def filter_contours(closed, min_area=800): contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for contour in contours: x, y, w, h = cv2.boundingRect(contour) area = w * h if area < min_area: continue ratio = w / h # 标准车牌比例约3.14,允许透视变形导致的比例偏移 if 2.0 <= ratio <= 4.5: candidates.append((x, y, w, h)) return candidates2.2 定位失败的常见场景与参数调法
定位看起来简单,但实际跑起来第一波翻车率最高的是两类图。一类是夜间开闪光灯拍的图,蓝底被强光打成了青白色,HSV 的 S 通道掉到 40 以下,inRange出来的掩码是破的。这种图我一般会先做一次cv2.equalizeHist对 V 通道拉伸对比度,让车牌底色恢复蓝色倾向,再送进 HSV 过滤。另一类是车身本身就是蓝色的车,比如深蓝色轿车,车漆在 HSV 里的 H 值跟车牌非常接近,导致掩码里车牌和车身黏在一起。这种用形态学闭运算反而帮倒忙,把车身跟车牌连成了一大块。我的做法是在轮廓筛选里加一个面积上限约束,超过全图面积 30% 的轮廓直接丢掉,再用车牌宽高比二次确认。
def validate_candidate(candidate, frame_shape, max_area_ratio=0.3): x, y, w, h = candidate frame_area = frame_shape[0] * frame_shape[1] contour_area = w * h if contour_area > frame_area * max_area_ratio: return False # 车牌最小宽高比约束,防止误检到方形蓝色物体 if w / h < 2.0: return False return True裁剪车牌区域后别忘了做一个透视校正。很多资源包的代码省略了这一步,直接对透视变形的车牌做字符分割,结果字符倾斜,模板匹配全乱。OpenCV 里可以用cv2.getPerspectiveTransform配合四个角点把车牌拉正。注意角点不能用轮廓的四个边界点,边界框在倾斜时会把面积算小,最好用minAreaRect拿到旋转矩形再取顶点。
def rectify_plate(gray, candidate): x, y, w, h = candidate plate_roi = gray[y:y+h, x:x+w] # 用自适应阈值突出字符区域,为分割做准备 binary = cv2.adaptiveThreshold( plate_roi, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY_INV, 31, 15 ) return binary这里自适应阈值用了THRESH_BINARY_INV,背景变黑、字符变白,方便后续的投影分割。窗口大小 31 是处理 300 像素左右宽度的车牌比较稳妥的取值,窗口太小字符内部的笔画会被当成背景吃掉,窗口太大又没法应对光照不均。
3. 字符分割:投影法把车牌切出七个标准块
3.1 垂直投影找边界,字符宽度上限筛掉铆钉
车牌定位输出的是二值化后的车牌 ROI,接下来要做的就是把“京A12345”这样的字符串切成单独的字符块。分割方法有很多——连通域分析、垂直投影、水平投影,这套资源用的是垂直投影法,思路直观:把二值图像按列求和,有字符的列求和值大,字符间隙的列求和值接近零,根据波峰波谷就能切出字符边界。
垂直投影在数学上其实就是np.sum(binary, axis=0),得到的是一个一维数组,数组每个元素代表对应列上白色像素的总数。为什么用白色像素而不用黑色?因为我们在上一步用了THRESH_BINARY_INV,字符是白色(255),背景是黑色(0)。如果图像里有车牌的白色边框或铆钉干扰,垂直投影上这些干扰源也会产生波峰,所以切割时要加一个字符宽度阈值——车牌字符的标准宽度在 45 到 60 像素(取决于车牌 ROI 的宽度),明显小于这个值的波峰直接忽略。
def segment_chars(binary): # 垂直投影:每列白色像素数量 col_sum = np.sum(binary, axis=0) # 找出所有列投影值大于零的连续区间 char_regions = [] in_region = False start = 0 for i, val in enumerate(col_sum): if val > 0 and not in_region: start = i in_region = True elif val == 0 and in_region: end = i width = end - start # 过滤噪声:宽度小于15像素的区间视为干扰(铆钉、边框残留) if width >= 15: char_regions.append((start, end)) in_region = False return char_regions这段代码的本质是找投影曲线里连续大于零的区间。参数 15 这个下限是经过实验的:车牌的铆钉在二值化后的宽度大约 8 到 12 像素,汉字“京”的笔画宽度远大于 15,所以 15 能有效滤掉铆钉又不会误伤汉字。但要注意,如果前面自适应阈值的窗口参数调得偏大,汉字内部可能出现大块黑色空洞,导致投影在个别列上掉到零,把汉字切成两段。
拿到字符区间后,还要做一个数量校验。标准蓝牌是 7 个字符(省份简称 + 字母 + 6 位数字/字母,实际是 7 个字符),但新能源车牌是 8 位。如果分割出的区间数不是 7 也不是 8,说明定位或二值化有问题,这里要加一个分支:数量不符时就退回上一级,重新用不同的核参数做形态学闭运算,而不是硬着头皮往下走。
def validate_char_regions(regions, image_width): count = len(regions) if count not in (7, 8): return False # 检查第一个字符宽度,省份汉字通常比字母宽 first_width = regions[0][1] - regions[0][0] return first_width > 153.2 字符归一化:模板匹配前的尺寸对齐
模板匹配的前提是待识别字符和模板字符的尺寸一致。OpenCV 的matchTemplate虽然支持不同尺寸,但缩放差异会导致匹配分数下降,尤其对汉字这种笔画密集的字符特别敏感。所以分割出的每个字符都要统一 resize 到模板尺寸。这套资源里的模板尺寸是 60 × 90,对应字符区域的 2:3 宽高比,和标准车牌字符的实际比例接近。
resize 有两个坑。第一个是直接cv2.resize会破坏笔画比例,尤其对汉字里的横竖笔画,非等比缩放下横画和竖画的粗细变化不同,匹配时容易崩。常见做法是先用cv2.copyMakeBorder把字符区域补齐到正方形,再做等比缩放。第二个坑是字符区域的外接矩形可能带少量黑边,这些黑边参与匹配会拉低分数,需要在 resize 之前做一次白边填充或者直接去掉边缘的零值行。
def normalize_char(char_img, target_w=60, target_h=90): # 去掉字符上下左右的多余黑边,避免干扰匹配 coords = cv2.findNonZero(char_img) x, y, w, h = cv2.boundingRect(coords) cropped = char_img[y:y+h, x:x+w] # 在保持宽高比的前提下补边到正方形,防止字符变形 side = max(w, h) padded = np.zeros((side, side), dtype=np.uint8) x_offset = (side - w) // 2 y_offset = (side - h) // 2 padded[y_offset:y_offset+h, x_offset:x_offset+w] = cropped # 统一缩放到模板尺寸 resized = cv2.resize(padded, (target_w, target_h), interpolation=cv2.INTER_CUBIC) return resized这套资源的归一化逻辑就是这样:先去黑边、再补正方形、最后缩放。INTER_CUBIC是双三次插值,放大时比INTER_LINEAR保留更多边缘细节。有人觉得用INTER_NEAREST更快,但汉字匹配时最近邻插值会产生锯齿,影响匹配分数,不建议用。
4. 字符识别:模板匹配的评分策略与模板库组织
4.1 模板匹配原理:归一化相关系数为什么比绝对差靠谱
模板匹配的实质是拿一个待识别字符图像,在模板库里逐个滑动计算相似度。OpenCV 提供了六种匹配方法,这套资源用的是TM_CCOEFF_NORMED,即归一化相关系数。它的计算方式是先把模板图像和搜索图像都减去各自的均值,再做相关计算,最后归一化到 -1 到 1 之间。这么做的好处是对图像的整体亮度和对比度变化不敏感——两张图一个偏亮一个偏暗,只要结构相似,得分依然高。TM_SQDIFF_NORMED(归一化平方差)虽然计算快,但对笔画粗细变化非常敏感,同一个字符在不同车牌上因为油墨浓淡差异可能得分掉到 0.4 以下。
def match_char(char_img, templates): best_score = -1.0 best_label = None for label, template in templates.items(): # 统一尺寸,确保matchTemplate输入一致 template_resized = cv2.resize(template, (char_img.shape[1], char_img.shape[0])) result = cv2.matchTemplate(char_img, template_resized, cv2.TM_CCOEFF_NORMED) # matchTemplate返回一个小尺寸的结果图,取其中的最大值 min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) if max_val > best_score: best_score = max_val best_label = label return best_label, best_score这段代码里容易忽略的是template_resized = cv2.resize(...)这一步。模板库里如果已经统一存成了 60 × 90,而待识别字符经过归一化也是 60 × 90,这一步其实是多余的,但写上它能防一种意外:如果前面某一步尺寸没对齐,这里能临时兜底。不过兜底归兜底,matchTemplate要求模板小于等于搜索图,如果模板比待识别字符大,cv2.matchTemplate会直接崩溃,所以 resize 到目标尺寸是必须的安全操作。
4.2 模板库管理:按字符类型分类,提高检索效滤
这套资源的模板文件组织是按字符类型分目录的:一个目录放省份汉字(京津冀晋蒙辽吉黑沪苏浙皖闽赣鲁豫鄂湘粤桂琼渝川贵云陕甘青宁新藏),一个目录放英文大写字母(I、O 除外,因为容易和数字 1、0 混淆),另一个放数字。分目录的好处不仅是为了整洁,更是为了让匹配阶段能根据字符位置缩小范围——车牌第一个字符一定是省份汉字,第二个字符一定是字母,后面的是字母和数字混合,但不能出现 I 和 O。
import os def load_templates(template_dir): templates = {} for root, dirs, files in os.walk(template_dir): for filename in files: if not filename.lower().endswith('.png'): continue label = os.path.splitext(filename)[0] path = os.path.join(root, filename) img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) templates[label] = img return templates # 按字符位置约束候选模板范围 def constrained_match(char_img, position, templates_all): if position == 0: candidates = {k: v for k, v in templates_all.items() if '\u4e00' <= k <= '\u9fff'} elif position == 1: candidates = {k: v for k, v in templates_all.items() if k.isalpha() and k not in ('I', 'O')} else: candidates = {k: v for k, v in templates_all.items() if k.isalnum() and k not in ('I', 'O')} return match_char(char_img, candidates)position约束这一步很关键。车牌第一位是汉字,用 unicode 范围判断,第二到第七位不可能出现汉字。第三位起排除 I 和 O 是因为车牌字符集里本来就没有这两个字母,模板库里就算存了也用不上。有了位置约束后,每个字符的平均匹配次数从全库五六十次降到十几次,处理一张车牌的耗时从毫秒级差别不大,但错误率明显下降——如果不排除 I 和 O,某些手写风格的模板可能会把数字 1 误判成 I,把 0 误判成 O。
模板匹配的得分阈值也是个调参重点。正常情况下正确字符的匹配得分在 0.8 以上,误匹配得分普遍低于 0.6。如果某个字符的最高得分低于 0.5,大概率是前面分割出了问题,这时候不要强行给结果,应该返回“未知字符”,让上层逻辑决定是重试还是人工介入。
def full_recognition(plate_binary, templates_all): regions = segment_chars(plate_binary) result = [] for idx, (start, end) in enumerate(regions): char_img = plate_binary[:, start:end] char_norm = normalize_char(char_img) label, score = constrained_match(char_norm, idx, templates_all) if score < 0.5: label = '?' result.append(label) return ''.join(result)5. 避坑指南:从运行报错到识别错误,五个高频问题实录
5.1 报错:findContours 返回值的维度不对
现象:按网上的老代码写contours, hierarchy = cv2.findContours(...),在 OpenCV 4.x 下直接报错,说返回值数量不对。
原因:OpenCV 4 把findContours的返回值从三个改成了两个,旧代码多了一个变量去接。
解决:改成contours, _ = cv2.findContours(...),或者用cnts = cv2.findContours(...)[0]。如果机器上同时装了多个 OpenCV 版本,运行时检查cv2.__version__,2.x 和 3.x 的返回结构不一样。
5.2 识别结果:汉字永远匹配错误,字母都对
现象:字母和数字识别率 95%,但省份汉字经常识别成另一个省,比如“沪”识别成“浙”。
原因:汉字结构复杂,笔画密集,模板匹配对笔画粗细和缺口特别敏感。两份模板如果来自不同打印批次,笔画粗细差异足以让匹配分数倒挂。
解决:我一般会把同省份的多个字体变体都收进模板库。这套资源里每个字符只放了一个模板,我建议在匹配失败率高的汉字上补充第二模板——从实际拍摄的车牌图里切出标准字符,再补种到模板目录。一省两个模板后,汉字识别率从 78% 提到了 93%,代价是匹配时间多了不到一倍。
5.3 定位漂移:车牌在图像中位置偏左或偏右时识别率骤降
现象:车牌居中的照片识别率很高,把摄像头装在道闸左侧取景,识别率马上掉到六成以下。
原因:车牌 ROI 裁剪区域中包含了一部分背景边缘,导致字符分割时第一个字符的投影边界跟背景粘连,字符宽度阈值判断失败。
解决:在定位环节把候选框向外扩 5 个像素后再裁剪,确保车牌完整包含在 ROI 内。另外加一个预处理步骤,用cv2.GaussianBlur(roi, (3, 3), 0)对灰度图做轻量平滑,降低传感器噪点的干扰。
5.4 模板匹配结果震荡:同一张图跑两次结果不同
现象:同一张车牌图片连续跑两次,识别结果在“京 A1L234”和“京 A1L123”之间跳变。
原因:如果代码里用了cv2.imread读取时没指定灰度模式,图像颜色顺序在不同 OpenCV 版本下可能有差异,二值化结果跟着变。还有一种情况是模板读取时的cv2.imread直接读成了彩色图,灰度图模板和彩色图参与匹配,OpenCV 不会报错但结果不可复现。
解决:所有图像读取统一加cv2.IMREAD_GRAYSCALE,所有模板统一在加载时转灰度。简单一句话:不要在管线上留任何依赖默认参数的分叉。
img = cv2.imread('plate.jpg', cv2.IMREAD_GRAYSCALE) template = cv2.imread('template/京.png', cv2.IMREAD_GRAYSCALE)5.5 后处理缺失:忽略置信度导致误识别
现象:识别出的车牌号跟车库登记系统对不上,排查发现是置信度 0.35 的垃圾匹配结果被当成有效结果写进了数据库。
原因:代码里没有对匹配得分做阈值判断,任何一个字符只要匹配了就给结果。
解决:学这套资源里最后的做法——拼接识别结果前,先检查每个字符的得分,连续三个字符得分低于 0.6 直接丢弃整张车牌结果并触发重新抓帧。这个检查逻辑放在整个识别流程的最后一步,能拦截多数成像质量不足导致的全盘误判。
6. 识别率自测:搭建一个 300 张图的离线校验管线,把调参从手感变成数据
模板匹配类方案的调参很多时候靠手感,但手感不能量化,今天调的参数明天可能就翻车。我习惯把识别率的验证做成批处理脚本,输入一批测试图片,输出每张图的识别结果和每字符置信度,自动汇总总体识别率。核心代码如下,用的数据集就是这套资源自带的样本图片。
import csv def batch_evaluate(image_list, template_dir, output_csv='eval_result.csv'): templates_all = load_templates(template_dir) rows = [] correct_chars = 0 total_chars = 0 for image_path, expected_plate in image_list: img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) mask, _ = locate_plate(img) candidates = filter_contours(mask) if len(candidates) != 1: rows.append([image_path, expected_plate, 'FAIL_LOCATE', '']) continue x, y, w, h = candidates[0] binary = rectify_plate(img, (x, y, w, h)) result = full_recognition(binary, templates_all) # 按字符逐个比对,统计字符级准确率 min_len = min(len(expected_plate), len(result)) for i in range(min_len): if expected_plate[i] == result[i]: correct_chars += 1 total_chars += len(expected_plate) rows.append([image_path, expected_plate, result, '']) # 写CSV,方便用表格工具做进一步分析 with open(output_csv, 'w', newline='', encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['image', 'expected', 'predicted', 'confidence']) writer.writerows(rows) print(f'char accuracy: {correct_chars/total_chars:.2%}') image_list = [ ('data/sample_001.jpg', '京A1L234'), ('data/sample_002.jpg', '沪B9X876'), # 300张测试图片逐一加入 ] batch_evaluate(image_list, 'templates')验证管线里有几个细节值得留意。第一,测试集要分三类:正常光线、侧光、夜间,三类图片分别统计识别率,不要混在一起算一个总数——混在一起可能掩盖夜间识别率不足 50% 的真相。第二,字符级准确率比整牌准确率更有诊断价值。如果整牌准确率 70%,但字符级准确率 95%,说明常错的是其中一个字符位,聚焦去处理那一类字符的模板就行;如果字符级准确率只有 85%,就要回溯定位和二值化。第三,把每个字符的置信度写进 CSV,不只看是否识别对,还要看对的时候是不是“勉强对”——如果正确字符平均置信度刚过 0.6,说明模板跟实际字符的风格偏差大,即使当前对了,换一批图片就可能错。
这套资源的模板里,汉字模板的数量偏少,我在跑完第一轮 300 张图后,识别率是 82.7%,问题集中在汉字字符。后来从测试集里给高频汉字各补了一个模板,第二轮整牌识别率到了 91.3%,商用门禁场景基本够用。从那以后我每次拿到新的模板资源,都会强制先跑一遍离线校验管线再做在线试用,避免把整个系统的赌注押在“看着差不多”上。希望这些拆解和踩坑记录帮到你,也愿你手里的车牌识别项目少走几段弯路。
本文还有配套的精品资源,点击获取