基于Python与OpenCV的车牌识别系统实现与优化指南
2026/9/12 22:06:04 网站建设 项目流程

简介:基于Python+OpenCV实现的车牌识别系统毕业设计源码,配套完整项目运行说明,面向计算机视觉方向本科生、毕业设计开发者及OpenCV入门者。系统覆盖车牌搜索识别、数据库认证、图文检索、文件/网络图片识别、实时截图与摄像头拍照识别等场景,并通过HyperLPR优化识别率,图片可自适应窗口大小。压缩包共119个文件,含18个Python源码文件、49个jpg与33个png车牌测试图片、4个Markdown说明文档、2个SVM模型dat文件,以及Docker部署配置与shell脚本等辅助内容,整体约22.37MB。已有411人学习下载。使用者可获得可运行项目源码、车牌图片样本、模型文件与运行环境配置,配合项目运行说明可快速复现识别流程;也可在此基础扩展车牌数据库管理和图文检索功能,适合毕业设计演示、课程设计与二次开发。

1. 毕业设计做车牌识别,为什么选 Python+OpenCV 而不是深度学习

车牌识别系统是计算机视觉里少有的一个「复杂度刚好卡在毕业设计要求上」的项目。它涉及图像处理、特征提取、模式识别三段主线,每一段都有肉眼可见的中间产出,不会像纯深度学习那样调参半天只等一个 loss 值。用 Python 加 OpenCV 做,核心代码量一般在 300 到 600 行之间,不依赖 GPU 也能在普通笔记本上跑完整个流程,这决定了它适合作为本科毕设选题。

这套系统面对的典型输入是一张包含完整车辆的图片,输出是车牌字符串,比如「京A12345」。中间通常拆成三个环节:从背景里把车牌区域找出来,把车牌区域的字符逐个切开,最后对单个字符做识别。有人会想直接上 YOLO 或者 OCR 框架,但毕设阶段最怕的是模型在黑盒里跑通,答辩时讲不清楚细节。OpenCV 路线每一步都是可解释的——哪个函数做了什么事、哪个参数影响哪部分结果,都能在代码里对号入座。

后端识别环节你可以选择传统模板匹配,也可以外接 PaddleOCR 或者 SVM 分类器,这正好形成「传统方法为主、深度方法为辅」的组合,既保证工作量扎实,又能留出优化和对比的深度。后面的章节会按「定位 → 分割 → 识别」的顺序,把每一步的代码、参数和踩坑点完整过一遍。

2. 车牌定位:用颜色阈值加轮廓筛选锁定车牌区域

车牌定位是整个系统里最先写、也最容易反复改的部分。常见做法是先转 HSV 颜色空间做颜色过滤,再用形态学操作把相邻像素连成块,最后通过轮廓的外接矩形宽高比确认车牌位置。

2.1 为什么选 HSV 而不是直接用 BGR 判断颜色

BGR 三个通道的值受光照影响很大,同一块蓝色车牌在阳光下和阴影里的 B 通道数值可能差出一倍多。HSV 把色调 H、饱和度 S、明度 V 分开,筛选时主要看 H 分量,对光照变化的容忍度明显更高。就国内蓝底白字车牌而言,蓝色的 H 值大约落在 90 到 130 区间,饱和度 S 在 60 以上,明度 V 不要设上限,避免夜间偏暗的车牌被整体滤掉。

下面是读取图像并生成二值掩膜的代码:

import cv2 import numpy as np def get_plate_mask(image_path): img = cv2.imread(image_path) if img is None: raise FileNotFoundError(f"无法读取图像: {image_path}") # 原图较大时可以先缩小,提高后续轮廓检测速度 img = cv2.resize(img, None, fx=0.8, fy=0.8, interpolation=cv2.INTER_AREA) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色车牌范围:H 90~130,S 60~255,V 下限设为 50 以兼容偏暗图像 lower_blue = np.array([90, 60, 50]) upper_blue = np.array([130, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) # 闭运算:先膨胀再腐蚀,把车牌字符间的空隙填起来 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return img, mask img, mask = get_plate_mask("test_car.jpg") cv2.imwrite("plate_mask.jpg", mask)

代码里cv2.inRange的作用是把 HSV 图像中落在阈值区间的像素置为白色、其余置为黑色。cv2.getStructuringElement生成 9×9 的矩形结构元素,尺寸直接关联到车牌在画面中的大小——如果拍摄距离较远、车牌像素占比小,核可以缩到 5×5,反之可以增大到 15×15。闭运算能消除字符和底色之间的缝隙,让蓝色区域连成一个整体,这一步如果省略,后面轮廓查找会把车牌裂成多块。

2.2 用轮廓外接矩形的宽高比过滤干扰区域

掩膜里除了车牌,还可能有蓝色车贴、反光条、天空或路牌。车牌按国标是 440×140mm,宽高比稳定在 3.14 左右,加上边缘留白和拍摄畸变,筛选区间可以放宽到 2.5 到 4.5。同时车牌面积在画面里不会太小,用轮廓面积cv2.contourArea过滤掉噪点。

def locate_plate_contour(mask, img): contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) aspect_ratio = w / float(h) area = cv2.contourArea(cnt) img_h, img_w = mask.shape[:2] # 车牌面积通常占整图面积的 0.5% ~ 15% if 2.5 < aspect_ratio < 4.5 and area > img_h * img_w * 0.005: candidates.append((x, y, w, h)) return candidates candidates = locate_plate_contour(mask, img) for i, (x, y, w, h) in enumerate(candidates): cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.imwrite("plate_located.jpg", img)

RETR_EXTERNAL只取最外层轮廓,避免把车牌内部字符的轮廓也当作候选区域。CHAIN_APPROX_SIMPLE压缩水平方向的冗余点,减少轮廓点的内存占用。宽高比的区间为什么不是精确的 3.14?因为正常拍摄时相机与车牌平齐的情况很少,大多有俯仰角或偏转角,投影后的宽度会被压缩或拉伸,区间放得越宽、容错越好,但也越容易混入干扰。实际项目中可以把候选队列里按面积从大到小排列,优先尝试面积最大的区域,识别失败再尝试下一个候选。

这里有一个高频踩坑点:cv2.findContours在不同版本的 OpenCV 返回值不同。OpenCV 3.x 之后返回(contours, hierarchy)两个值,但 4.x 也保持这个结构。如果代码是从老项目里抄的_, contours, hierarchy = cv2.findContours(...),在 4.5 以上版本运行会直接报错或者得到空列表,这是排查定位失效时最先要看的地方。

3. 字符分割:灰度化、二值化和投影法切出每个字符

定位拿到车牌区域之后,第一步是把它从原图中裁出来,单独做灰度化和二值化。目标是把蓝底白字变成白底黑字或者黑底白字,让字符和底色在像素值上彻底分开,这样才能依靠形态学或投影把字符一个个切出来。

3.1 车牌区域的预处理与字符级联通域分析

def extract_plate_chars(plate_img): # 统一转成灰度图 gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) # 高斯模糊,去掉拍摄产生的细小噪点 gray = cv2.GaussianBlur(gray, (3, 3), 0) # OTSU 自动求阈值,免去手动调参 _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) return gray, binary plate_crop = img[y:y + h, x:x + w] gray, binary = extract_plate_chars(plate_crop) cv2.imwrite("plate_binary.jpg", binary)

cv2.threshold配合THRESH_OTSU会自动计算一个能把前景背景分开的阈值,省去手动指定 127 这类固定值。注意THRESH_BINARYTHRESH_OTSU要用+连接,写成cv2.THRESH_OTSU会报错。计算后的二值图里字符是白色区域。

拿到二值图后,调用cv2.findContours找出所有白色字符的轮廓,对每个轮廓求外接矩形,然后按 x 坐标排序。这一步通常能直接切出字母和数字,但对第一个汉字「京」「鲁」这类封闭结构容易切碎——比如「京」的上下独立笔画会被拆成两块。

3.2 用垂直投影直方图分割粘连字符

联通域分析对相邻字符之间有空隙的情况效果很好,但一旦字符挤压、边缘沾在一起,轮廓合并会让两个字符被框成一个矩形。这时改用垂直投影法:把二值图按列求和,每一列白色像素的个数形成一条曲线,字符所在的列白点密集,字符间隔处白点趋近零。根据「白点为零的列」就能把字符边界切出来,这也是工业上处理印刷体最常用的手段。

projections = np.sum(binary, axis=0) # 按列求和,得到垂直投影 zero_zones = [] in_zero = False for col in range(len(projections)): if projections[col] == 0 and not in_zero: start = col in_zero = True elif projections[col] > 0 and in_zero: zero_zones.append((start, col)) in_zero = False char_split_positions = [] for start, end in zero_zones: zone_width = end - start # 过窄的空白通常是字符本身的内部间隙,而不是字符间分隔 if zone_width > 3: char_split_positions.append(start) char_split_positions.append(end)

代码里的zero_zones保存的是所有连续空白列的起止坐标。中间那个zone_width > 3的判断很关键:汉字「川」「三」这类字符内部存在大块空白,如果全部当作切分点,一个字符会被误切成两半。宽度小于 3 个像素的空隙直接忽略,只有大于 3 像素的才当作字符间分隔。这个阈值要按实际处理后图像的缩放比例来调,图像放大后空白宽度会同步变大。

垂直投影法的局限是它只做一维切分,如果图像拍摄倾斜、字符底边不在同一水平线上,投影曲线会出现重叠,分割位置会偏移。所以正式的分割流程应该先做「水平投影确定上下边界 → 透视矫正摆正 → 垂直投影切分左右」,每个环节都有独立的排错输出。

3.3 分割结果归一化,统一送进识别模块

切出来的字符宽窄不一、位置也不统一,「京」字整体偏高,「A」字粗细不同。直接拿去做模板匹配,尺寸不一致必然影响相似度。归一化有两个常见做法:保持宽高比例缩放到统一高度,或者直接拉伸成固定尺寸。前者保留字符原始比例,模板匹配效果更好;后者适合喂给 CNN 或 SVM。

plate_height, plate_width = binary.shape char_width = plate_width // 7 # 标准车牌只有 7 个字符 chars = [] for pos in range(7): # 按等分切出 7 个区域,比投影结果更稳定 start_x = pos * char_width end_x = (pos + 1) * char_width if pos < 6 else plate_width char_img = binary[:, start_x:end_x] # 去掉上下空白部分,只保留字符最小包围盒 rows_with_content = np.any(char_img == 255, axis=1) top = np.argmax(rows_with_content) bottom = len(rows_with_content) - np.argmax(rows_with_content[::-1]) car_img = char_img[top:bottom, :] # 保持比例缩放到 20x40(宽 x 高) car_img = cv2.resize(car_img, (20, 40), interpolation=cv2.INTER_NEAREST) chars.append(car_img)

这段按均分宽度切 7 个区域的写法看起来比投影法「笨」,但在实拍图中更可靠,原因是车牌字符虽然宽度不一样,但整体分布是均匀的,第一个汉字和最后一位数字都会贴在车牌左右边缘内。均分切分不依赖任何阈值参数,反而避免了投影法在边缘处因钉孔、边框干扰产生的多切或少切。

后面接识别模块时,7 个字符的类别不是均匀的:第一位是省份汉字,第二位是大写字母,第三到七位是字母数字混合。这意味着识别模块最好分两个分支,前面用汉字分类器,后面用数字字母分类器,分开训练两张表,而不是一个模型强行分类 70 多个类别。

4. 字符识别:模板匹配和距离度量怎么选,识别结果为什么总错在相似字符上

切出单个字符后,识别环节就是「拿未知字符和已知字符模板比相似度,挑最像的」。虽然听起来简单,但字符形变、干扰、边框的存在都会影响结果,需要结合二值化方式和匹配度量来统筹设计。

4.1 模板匹配的两种典型做法:像素比对和轮廓比对

像素级比对是把字符图和模板都缩放到相同尺寸,逐像素计算差异度,常用指标包括绝对值差总和(SAD)、归一化相关系数(NCC)和汉明距离。像素比对实现简单,但对字体、笔画粗细非常敏感,模板里的字符「E」和未知图的「E」笔画宽度差两三个像素,排名就可能被打乱。轮廓比对比的是字符边缘的形状,抗粗细变化能力强,适合实拍图。

def match_by_template(car_img, template_images): best_score = -1 best_label = None car_resized = cv2.resize(car_img, (30, 60), interpolation=cv2.INTER_NEAREST) for label, template in template_images.items(): template_resized = cv2.resize(template, (30, 60), interpolation=cv2.INTER_NEAREST) # 归一化相关系数,越接近 1 越相似 score = cv2.matchTemplate( car_resized.astype(np.float32), template_resized.astype(np.float32), cv2.TM_CCOEFF_NORMED )[0][0] if score > best_score: best_score = score best_label = label return best_label, best_score # 真实项目里模板集会长这样:标签 -> 二值化模板图 templates = {"0": 模板图0, "1": 模板图1, "A": 模板图A, "B": 模板图B} label, score = match_by_template(chars[1], templates)

cv2.matchTemplate通常用于在图像中滑窗找目标子图,但这里把两图尺寸统一后,窗口大小等于图像大小,滑窗只计算一次,得到的就是像素级匹配分数。TM_CCOEFF_NORMED对亮度变化做了归一化,相比TM_SQDIFF更稳定。预先要把模板归一化到相同尺寸,避免原模板图因为边缘留白不同而引入噪声。

4.2 后续接 OCR 或训练一个小分类器的接口设计

很多人做到模板匹配就收工了,但毕设答辩老师大概率要问一句「如果不认识的字体怎么办」。这时可以拿出对比方案:自己训练一个 SVM 或轻量 CNN 做字符分类。模板匹配的定位是「快速基线」,深度模型作为准确率提升项。为了兼容两套方案,识别接口要抽象成统一的输入输出:

class PlateRecognizer: def __init__(self): self.template_branch = TemplateRecognizer() self.model_branch = None # 预留 CNN 分支 def recognize_char(self, char_img, position): if position == 0: # 第一位走汉字识别 return self._recognize_chinese_char(char_img) else: # 后续位走字母数字识别 return self._recognize_alnum_char(char_img)

注意这里特意把「位数」和「识别分支」绑定:第一位不会出现字母,第二位不会出现汉字,第七位如果是新能源车牌则是汉字「D」或「F」。这种先验知识能直接剪掉一半以上的候选类,显著压低误识别率——同样的字符,70 类分类器出错概率一定比 35 类高。

4.3 相似字符的常见混淆对与实用容错

「0 和 O」「1 和 I」「B 和 8」是识别错误率最高的三组。模板匹配层面,可以用结构特征辅助去重:数字「0」的宽高比更扁,字母「O」更接近正方形;「1」的上方有小横杠而「I」没有;「B」的左半部分有上下两个半圆而「8」上下不齐。把这些规则写在识别后处理里,当一个字符的 top1 和 top2 得分差小于 0.05 时,用规则投票决定最终结果,正确率能提高 2% 到 5%。

下面是用 SVM 做分类时的一张参数建议表,把这步作为项目升级点写在设计文档里,比只写「模板匹配」要扎实:

模块推荐配置说明
特征HOG(winSize 20×40,block 4×4)对笔画形变不敏感
训练集每类 200~500 张字符图自己截图切出来再旋转平移扩充
核函数RBF 核类别多且特征维度不高时效果稳定
约束每个字符图归一化到 20×40保持宽高比例,不能直接拉伸变形

SVM 训练的细节展开讲能再写一章,毕设阶段做到「能训练、能保存模型、能加载识别」,再在论文里和模板匹配做一次准确率对比实验,这部分工作量和亮点就够了。

5. 项目运行说明里的隐藏要点:环境版本、中文路径和依赖清单

毕设源码包里的「项目运行说明」通常是最容易被忽略、但实际上决定了整个项目能不能跑起来的文件。大多数同学拿到代码第一步是装环境,第二步就报错。报错点往往不在算法本身,而在 OpenCV 版本差异、Python 位数不匹配、文件路径里有中文这三处。

5.1 环境版本建议与依赖安装命令

车牌识别这类项目不挑最新版本,挑稳定性。OpenCV 4.5.2 是一个值得锁定的版本,它的findContours返回值、matchTemplate接口行为都已经定型,网上能找到的中文资料也以 4.x 为主。Python 推荐 3.8 到 3.10 之间,太低装不上新版 numpy,太高又可能因为某些预编译包不兼容而报错。

# 创建虚拟环境,避免污染系统 Python(Windows 下把 python3 换成 python) python3 -m venv plate_env source plate_env/bin/activate # Windows 下执行 plate_env\Scripts\activate # 安装核心依赖 pip install opencv-python==4.5.2.52 pip install numpy==1.23.5 pip install matplotlib # 可选,用于画调试图像 # 如果需要用 SVM 分支,再装 scikit-learn pip install scikit-learn==1.2.0

安装顺序有讲究:先装 numpy 再装 opencv,避免 OpenCV 依赖的 numpy 版本被后面覆盖。opencv-python是预编译的 wheel 包,自带运行所需动态库,不需要再找系统级 OpenCV 依赖。

5.2 运行主程序时的路径与编码陷阱

源码包里最常见的两个问题是cv2.imread返回Nonecv2.imshow窗口白屏。前者最常见的根因是文件路径包含中文,OpenCV 的imread内部用的是 C++ 标准库的文件读取接口,对中文路径支持不稳定,在 Windows 上尤其明显。解决办法是改用np.fromfile配合cv2.imdecode读取:

import numpy as np import cv2 def imread_unicode(path): # 用 numpy 以二进制读取,绕过 OpenCV 对中文路径的兼容问题 data = np.fromfile(path, dtype=np.uint8) img = cv2.imdecode(data, cv2.IMREAD_COLOR) if img is None: raise IOError(f"图像读取失败: {path}") return img

cv2.imwrite写中文路径也有同样问题,处理方式是先cv2.imencode成字节流,再写入文件:

def imwrite_unicode(path, img): ext = path.split('.')[-1] result, buffer = cv2.imencode(f'.{ext}', img) if not result: return False buffer.tofile(path) return True

这两个函数放进项目的utils.py里,全项目统一走这两条路径读写图片,能绕开毕设场上绝大多数「代码本身没错但图读不出来」的尴尬。另一个常见报错是cv2.imshow在无图形界面的服务器上崩溃,项目运行说明里应该提示:本地调试用imshow,服务器批处理模式直接注释掉显示代码或改用imwrite保存中间结果。

6. 用 HSV 阈值调试工具快速验证识别效果,把准确率从「跑通」做到「稳定」

大多数毕设做到「能识别几张测试图」就结束,但答辩时老师会现场换一张图,这时候识别失败就非常被动。与其拆东墙补西墙,不如做一个小工具把整个流水线的每一步中间结果可视化,现场换图后能快速定位是定位失败、分割失败还是识别失败。

用一行命令启动调试工具,核心代码用 OpenCV 的滑动条实时调整 HSV 阈值:

def on_trackbar(value): # 每次滑动条变化时重新计算掩膜 h_min = cv2.getTrackbarPos("H Min", "Debug Window") s_min = cv2.getTrackbarPos("S Min", "Debug Window") v_min = cv2.getTrackbarPos("V Min", "Debug Window") lower = np.array([h_min, s_min, v_min]) upper = np.array([130, 255, 255]) mask = cv2.inRange(hsv, lower, upper) cv2.imshow("Mask", mask) cv2.namedWindow("Debug Window") cv2.createTrackbar("H Min", "Debug Window", 90, 180, on_trackbar) cv2.createTrackbar("S Min", "Debug Window", 60, 255, on_trackbar) cv2.createTrackbar("V Min", "Debug Window", 50, 255, on_trackbar)

现场换图后如果蓝色掩膜里没有车牌,拖动 H 最小值到 80 再看看;如果掩膜里全是蓝色噪点,把 S 最小值往上拉。这个方法不需要改代码重新运行,调完几个值再点一次识别按钮就能对比前后结果。真正的项目调优不是改一下跑一下,而是把「输入 → 中间状态 → 输出」全链路可视化,找出失败环节再针对性调整参数。

最后留一个提升识别稳定性的冷门技巧:对切出的 7 个字符,每个字符要做一次垂直方向的裁剪对齐,把字符底部顶到图像底部再送进识别器。因为模板匹配对位置偏移极其敏感,同一个「E」,上下偏移 2 个像素,匹配分数可能从 0.92 掉到 0.78,这比任何算法改进都更直接地提升整体识别率。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询