简介:基于 OpenCV 的车牌识别 Python 项目,源自学长的毕业设计作业,定位为计算机相关专业学生的课程设计或毕设参考。项目实现“定位车牌 → 矫正车牌 → 识别颜色 → 分割字符 → 识别字符”的完整流程,并附带简单 GUI,可识别车牌号码与牌照颜色,适合入门学习图像处理与字符匹配。包内共 8 个文件,以 Python 源码、模板压缩包、配置文件及说明文档为主,其中 function.py 为算法核心,gui.py 为界面程序,refer1.zip 为识别用模板,另含 pycache 编译文件和 gdf 依赖关系图等,资源包约 6.43MB。作者在描述中明确提示该方案采用模板匹配,单张车牌识别耗时较长且准确率有限,使用时可针对性优化。已有 429 人学习下载。通过该资源可掌握 OpenCV 车牌定位、形态学处理、模板匹配识别等基础代码思路,也能学习到如何将算法封装为简单桌面应用,适合作为课程设计快速改造的起点。
1. 基于opencv模板匹配的车牌识别,先想清楚这套路为什么能过答辩
很多人拿到“基于opencv模板匹配的车牌识别-python毕业设计”这个题目,第一反应是把车牌照片和模板库做全局模板匹配,然后直接输出结果。这个思路其实会在答辩时被问倒:真实场景下,车牌在画面中的尺度、倾斜角度、光照都会变,模板匹配对尺度和旋转变换并不鲁棒。常见做法是把模板匹配当“识别器”而不是“定位器”:先靠颜色把蓝色车牌区域从整张图里找出来,再做字符分割,最后只对单个字符做模板匹配。这个流程不但可靠,而且每一步都能讲出原理,代码也可以控制在几百行以内。本文面向需要用Python快速跑通算法、并能在论文里把过程讲清楚的读者,覆盖从环境安装、定位、分割到模板匹配调参的完整链路。
2. 车牌定位用 HSV 颜色阈值还是边缘检测,先定方案再写代码
2.1 为什么在 HSV 空间里做颜色筛选比 RGB 判断更稳
蓝底白字车牌在RGB空间里,蓝色分量通常占优势,但不同时段、不同天气下,同一个车牌的RGB值波动很大。直接对B分量做固定阈值,要么误把深色车身框进来,要么在夜间漏检。HSV把色调(H)、饱和度(S)和亮度(V)分开,车牌蓝色在H通道上有相对稳定的取值范围,把S和V作为辅助条件,可以过滤掉大部分背景干扰。
这里我一般用OpenCV的cv2.cvtColor把BGR转到HSV,然后用cv2.inRange取蓝色区域。需要注意OpenCV中H的取值范围是0到180,不是色相环的0到360。
import cv2 import numpy as np img = cv2.imread("car.jpg") hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色车牌的HSV阈值,以实际采集画面为准微调 lower_blue = np.array([100, 80, 50]) upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) cv2.imshow("mask", mask) cv2.waitKey(0)inRange输出的是一个二值图,白色区域代表命中车牌蓝底。S的下限设为80,是为了排除像素饱和度不足的浅蓝灰色物体;V下限设为50,是为了在夜间或阴影区域保住车牌基本轮廓。这两个值不是死参数,实际调试时如果mask中车牌区域有空洞,需要适当降低V的下限;如果背景大面积命中,则提高S的下限。
2.2 形态学开闭运算把字符区域连接成一个整体
颜色筛选做完后,车牌内部的白色字符、铆钉、反光点会导致蓝色区域破碎成多个小区域。直接用轮廓查找会得到一堆碎片。常见做法是先做一次开运算去掉细小的噪点,再做一次闭运算把字符之间的空隙填上,让整个车牌连成一个完整的连通域。
kernel_open = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 3)) kernel_close = cv2.getStructuringElement(cv2.MORPH_RECT, (19, 7)) mask_open = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open, iterations=1) mask_close = cv2.morphologyEx(mask_open, cv2.MORPH_CLOSE, kernel_close, iterations=1)开运算的核宽度选择7像素,用来去掉单个孤立噪点;闭运算核宽度19像素,覆盖一个正常字符在水平方向的宽度,让蓝色底能“跨过”白色字符连起来。核太小时,字缝补不上;核太大时,旁边车身的蓝色区域会和车牌粘连,破坏后续轮廓筛选。
2.3 轮廓筛选与透视纠正,拿到干净的车牌矩形
得到完整的mask后,用cv2.findContours找所有连通域,再通过长宽比、面积占比、矩形度三个条件过滤,把最像车牌的区域挑选出来。车牌的标准长宽比约为440比140,即3.14左右,实际拍摄角度会略高。
contours, _ = cv2.findContours(mask_close, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) box = None for c in contours: x, y, w, h = cv2.boundingRect(c) aspect = w / h area_ratio = (w * h) / (img.shape[0] * img.shape[1]) # 车牌长宽比一般在2.5~4.5之间,面积占比0.004~0.06 if 2.5 < aspect < 4.5 and 0.004 < area_ratio < 0.06: box = (x, y, w, h) break面积占比的上下限和图像分辨率直接相关。对于1080p画面,车牌通常占全图的0.5%到5%,低于0.004容易命中小蓝色车标,高于0.06容易误收车身大面积蓝色喷漆。选出候选矩形后,如果车牌在画面中有明显倾斜,仅用boundingRect会把字符截歪,这时候需要先提取最小外接矩形、求四角点,再用cv2.getPerspectiveTransform加cv2.warpPerspective做透视拉正。
rect = cv2.minAreaRect(points) # 输入轮廓点集 angle = rect[2] # 角度超过±3度时建议拉正,否则后续匹配误差会放大3. 字符分割的细节:垂直投影配合启发式规则把车牌拆成七个字符
3.1 从灰度图到二值图的阈值选择
车牌识别对二值化的要求比通用OCR更高。全局固定阈值在强光照下很容易把字符细节吞掉,自适应阈值能保留局部细节,但车牌边缘和铆钉干扰也同时被保留。常见做法是先在HSV的V通道上做cv2.threshold,再利用“蓝底白字”的先验做一次反色校正。比如光线均匀的车牌图,可以直接取阈值120作为亮字符和暗底色分界。
gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 蓝底车牌中,白字亮度高,Otsu分割后字符应为白色255实践中,Otsu按灰度直方图自动找谷底,适合字符占比稳定的图像。但夜间补光不足时,Otsu会把半个车牌压成黑色,这时改用固定阈值或先对V通道做直方图均衡化。
3.2 垂直投影切分字符的算法主体
分割的思路是按列统计白点个数,画投影曲线,然后找波峰和波谷。字符区域白点密集成带,字符间缝隙白点掉到极低。一次完整的垂直投影分割逻辑如下。
def vertical_projection(binary_img): h, w = binary_img.shape proj = [0] * w for col in range(w): proj[col] = np.sum(binary_img[:, col] // 255) return proj proj = vertical_projection(binary) col_nums = len(proj) split_points = [] in_char = False for i in range(col_nums): if proj[i] > 0 and not in_char: start = i in_char = True elif proj[i] == 0 and in_char: end = i in_char = False split_points.append((start, end)) # 过滤掉宽度小于5像素的细小噪声段 split_points = [(s, e) for s, e in split_points if e - s > 5]这一步的缺陷是:汉字“京”“苏”等左右结构笔画间偶尔断裂,会被切为两段;字符和车牌边框之间若没有完全断开,又会合并成一段。因此不能只依赖投影,还要叠加宽度偏差判断。
# 一段的宽度和平均字符宽度比较,过宽则可能是粘连,需要在中间找低谷再切一刀 avg_w = np.mean([e - s for s, e in split_points]) for s, e in split_points: if e - s > avg_w * 1.5: # 在该区间内找白点数最小值位置,重新分割 min_pos = np.argmin(proj[s:e]) + s # 判断min_pos两侧是否都有像素这种启发式规则在毕业设计答辩场合非常讨喜,它表明你处理过真实数据,不是只在理想图上跑通。
3.3 分割结果检查的三个硬指标
字符切完后,至少做三个判断:数量是否为7;单个字符宽高比是否落在0.4到0.8之间;字符水平方向间距是否均匀。第一个判断用于捕获汉字断成两截的情况,第二个判断用于捕获“鲁”字这种笔画多、粘连严重的案例,第三个判断用于识别螺栓孔干扰。
if len(split_points) != 7: # 常见处理:把相邻过窄的段合并,或者把过宽段重新切分 pass螺栓孔常出现在车牌四角,如果它被误判为一个字符,宽高比会比正常字符矮很多。处理方式是从分割列表中剔除面积明显异常的小块,保证后续匹配模板的输入统一。
4. 模板匹配识别的核心:matchTemplate 方法选型与多峰值去重
4.1 TM_CCOEFF_NORMED 与 TM_SQDIFF_NORMED 的取舍对比
cv2.matchTemplate提供六种匹配方式,在车牌场景最常用的是归一化相关系数法TM_CCOEFF_NORMED和归一化平方差法TM_SQDIFF_NORMED。前者输出数值越大越匹配,后者数值越小越匹配。
| 方法 | 输出含义 | 对光照变化的敏感度 | 车牌识别推荐度 |
|---|---|---|---|
| TM_SQDIFF | 像素差平方和 | 高,背景亮度直接影响阈值 | 不推荐 |
| TM_SQDIFF_NORMED | 归一化差异,0为完全一致 | 中,受二值化质量影响 | 可作为辅助判断 |
| TM_CCORR_NORMED | 相关值,亮区易产生高响应 | 高,白色背景会盖过字符 | 不推荐 |
| TM_CCOEFF_NORMED | 去均值后的相关系数,1为最佳 | 低,对亮度偏移有抑制作用 | 推荐 |
实际调参经验是:二值化后的字符模板和待匹配字符都是黑白二值图,背景占比高,用TM_CCOEFF_NORMED最稳定,它的阈值可以放心设到0.7以上。下面的代码完成单字符匹配。
res = cv2.matchTemplate(char_img, template_img, cv2.TM_CCOEFF_NORMED) _, max_score, _, max_loc = cv2.minMaxLoc(res)4.2 模板库的组织方式和训练来源
模板匹配的效果上限取决于模板本身。最稳的模板来源是真实车牌截图:自己采集几张车牌,切出7个字符,保存成和识别端相同尺寸的黑白图。模板文件命名格式可以简单直接,方便加载。
templates/ hanzi/ 京.png 津.png 沪.png letters/ A.png B.png digits/ 0.png 1.png加载时先统一模板尺寸。汉字模板宽高比约0.6,建议统一到48×80;字母和数字模板统一到32×64。过大模板不仅匹配慢,而且对分割字符的微小位置偏移更敏感。
templates = {} categories = ["hanzi", "letters", "digits"] for cat in categories: files = os.listdir(f"templates/{cat}") for f in files: if f.endswith(".png"): tm = cv2.imread(f"templates/{cat}/{f}", 0) label = f.split(".")[0] # 字母和数字统一到 32x64,中文汉字统一到 48x80 tag_size = (48, 80) if cat == "hanzi" else (32, 64) tm = cv2.resize(tm, tag_size) tm = cv2.threshold(tm, 127, 255, cv2.THRESH_BINARY)[1] templates[label] = tm模板必须保证字符笔画是白色、背景为黑色。如果采集模板时存了彩色图,cv2.imread用0读灰度后一定要再做一次二值化,否则灰度渐变会拉低匹配得分。
4.3 候选框重叠问题:阈值扫描后的一组矩形怎么合并
cv2.minMaxLoc只能给出得分最高的点,但模板匹配在真实分割字符上经常会拿到多个局部极大值。比如字符“0”内部空洞或背景纹理导致同一区域出现多个高亮点,如果直接把所有超过阈值的点全部画在图上,会输出一大堆重叠框。常见做法是设定低阈值收集所有候选位置,然后按非极大值抑制(NMS)思路保留最强框。
threshold = 0.75 loc = np.where(res >= threshold) boxes = [] for pt in zip(*loc[::-1]): boxes.append((pt[0], pt[1], char_w, char_h)) # 按得分降序排 boxes.sort(key=lambda b: res[b[1], b[0]], reverse=True) keep = [] while boxes: best = boxes.pop(0) keep.append(best) boxes = [b for b in boxes if not iou(best, b)] # iou是两个矩形交并比,超过0.4的认为是同一个字符位置,直接丢弃NMS这一步在论文里可以作为一个细化点着重描述。它能证明你理解了模板匹配会产生多响应,不是查一个最大值就完事。阈值设在0.75左右,如果结果频繁出现错识别,可以尝试把阈值升到0.8,观察得分分布。
4.4 置信度不足时留一个“不确定”分支
车牌字符库里包含31个省简称、24个字母(I和O通常不用)和10个数字,每个字符都做一遍完整匹配,最终取最高分。但如果最高分低于0.6,说明该字符在二值化阶段或者分割阶段出了问题。稳妥做法是让它输出“?”,而不是硬猜一个错误字符,后续可以用连通域分析结果兜底。
def match_char(char_img): best_label = "?" best_score = 0 char_resized = cv2.resize(char_img, (32, 64)) for label, tm in templates.items(): res = cv2.matchTemplate(char_resized, tm, cv2.TM_CCOEFF_NORMED) _, score, _, _ = cv2.minMaxLoc(res) if score > best_score: best_score = score best_label = label if best_score < 0.6: return "?", best_score return best_label, best_score5. 工程化落地的鲁棒性提升:模板库自建、环境安装与性能开销
5.1 模板库别偷懒直接下载,自己拍素材最稳
网上找的字体模板和实际拍摄车牌的笔画宽度、内核渲染方式差异很大,即使字型相同,匹配得分也会明显偏低。我一般会在白天、傍晚两个时段各采集10张不同省份车牌,切出字符后人工校验一遍,把笔画模糊的模板删掉。这样做还有一个好处:覆盖了“京”“鲁”“苏”等不同省份字符的笔画细节,而不是只用几个常见省份测试。
5.2 光照、倾斜和图像分辨率三个常见干扰处理
车牌识别在室外环境中最大的敌人不是模板库不够大,而是图像质量波动。强光导致的字符反光会让二值化后字符出现断点,处理方式是识别前对灰度图做一次直方图均衡化。
gray_eq = cv2.equalizeHist(gray)模板匹配对角度极其敏感,超过正负2度匹配分就会明显下降。前期定位阶段如果检测到车牌旋转,不能省去透视纠正这一步。分辨率方面,apeng 录像是1080p,如果直接对整幅图做模板匹配,matchTemplate在大尺寸图像上的计算量会线性增加。更合理的做法是只对裁剪出的字符区域做匹配,且车牌区域超过300像素宽时,先缩小到统一宽度再处理。
5.3 在本地Python环境安装OpenCV的最稳命令序列
有相当一部分同学在环境安装阶段就卡住了,常见的报错是ModuleNotFoundError: No module named 'cv2'。这通常是pip装进了系统全局环境,而运行代码用的是虚拟环境。推荐用venv或conda建一个干净环境,再安装opencv-python。
python -m venv plate_env # Windows 下激活环境 plate_env\Scripts\activate # Linux/macOS 下激活环境 # source plate_env/bin/activate pip install opencv-python numpy matplotlib如果下载速度慢,可以临时用清华镜像源加速。这个源在整个Python生态里都很常用,和OpenCV官方包不冲突。
pip install opencv-python -i https://pypi.tuna.tsinghua.edu.cn/simple装完跑一个最小验证命令,确认版本可用再往下写代码。
import cv2 print(cv2.__version__)这里能看到类似4.x.x的输出,就能保证后边的API调用不会因为环境问题报错。如果使用Anaconda,也可以直接执行conda install opencv-python,效果等同。
5.4 性能分析与降耗策略
在1080p图像上,色域转换加颜色阈值大约耗时5到8毫秒,轮廓筛选和形态学操作耗时约3毫秒,全字符模板匹配在1000个模板规模下耗时约20到40毫秒。实际项目如果有实时帧率要求,优先使用ROI缩小匹配范围:车牌定位完成后,直接把车牌区域裁剪出来,后面的二值化和模板匹配都基于裁剪图完成。
start = time.perf_counter() plate_img = img[y:y+h, x:x+w] # 后续处理都用plate_img,而不是整帧 elapsed_ms = (time.perf_counter() - start) * 1000这段计时在论文里面作为性能指标数据是有效的。如果帧率还是不够,考虑把模板图先转成统一尺寸,并提前加载到内存中,而不是每次识别都读一次磁盘文件。
6. 把匹配得分矩阵可视化,一眼定位是模板问题还是预处理问题
调试车牌识别时最难的一点是:识别结果错了,但不知道错在哪个环节。其实cv2.matchTemplate返回的结果矩阵就是一张得分图,它可以直接用热力图的方式展示出来。把热力图叠加到被匹配的字符图上,就可以直观判断匹配失败是发生在字符分割、模板选择,还是阈值筛选阶段。
res = cv2.matchTemplate(char_img, tm_plate, cv2.TM_CCOEFF_NORMED) # 将得分矩阵归一化到 0~255 显示 res_norm = cv2.normalize(res, None, 0, 255, cv2.NORM_MINMAX) heatmap = cv2.applyColorMap(res_norm.astype(np.uint8), cv2.COLORMAP_JET) # 和原始字符图等比显示 resized = cv2.resize(char_img, (res.shape[1], res.shape[0])) overlay = cv2.addWeighted(resized, 0.4, heatmap, 0.6, 0) cv2.imshow("match_heatmap", overlay)如果字符图上正确位置的得分区域没有出现亮块,说明二值化后的字符和模板在笔画粗细上差异太大,优先检查模板是否被cv2.resize压变形,或者模板二值化阈值是否选错。如果正确位置出现了亮块,但最终输出字符不对,问题就在NMS或候选框选择逻辑上。这个可视化技巧同时适用于汉字、字母和数字,是整个项目落地排查效率最高的一个步骤。它不用额外调试信息,看到亮斑就能决定下一刀该切在哪里。
本文还有配套的精品资源,点击获取