简介:面向毕业设计场景的Python车牌识别完整项目,采用OpenCV图像处理与SVM机器学习算法实现车牌定位和字符识别,同时预留百度AI平台接口作为兜底方案;项目包含图像边缘和车牌颜色两种定位方式,覆盖训练数据生成、SVM建模、字符识别及API调用等关键环节,适合计算机、人工智能、通信等专业学生用于毕设、课设或项目初期演示。压缩包共171个文件,类型涵盖Python源码、SVM训练数据(svm.dat/svmchinese.dat)、车牌图片样本(jpg/jpeg/png)、MySQL数据库脚本、XML配置、效果对比GIF、演示视频及文档说明等,整体约94.55MB,文件结构清晰便于按模块查阅。目前已有77人学习下载。资源提供可直接运行的工程代码,附带README与运行说明,可快速搭建环境;还支持远程教学答疑,帮助排错。即便基础薄弱,也能在现有代码基础上二次开发,非常适合作为毕业设计或课程设计的完整参考方案。
1. 车牌识别系统的技术组合怎么选
一个课程设计或毕业设计里最常踩的坑,是把系统做成“全部交给OCR”或“全部自己用OpenCV硬抠”。深度学习模型检测车牌,准确率高,但你需要训练数据、GPU和标注时间;纯OpenCV定位加传统OCR,又容易被光照和倾斜角拖垮。这个项目的做法是把OpenCV的几何检测、SVM的字符分类和百度AI平台的OCR接口串成一条流水线:OpenCV负责快速锁定车牌区域并分割字符,SVM在本地完成字符识别,百度AI平台作为复杂场景下的容错方案。这样既能展示机器学习算法的完整落地过程,也有可演示的实时识别效果,适合需要在答辩现场跑demo的场景。
2. OpenCV车牌定位:颜色空间与形态学滤波的组合筛选
2.1 用HSV颜色阈值锁定车牌底色
识别流程里第一个决定成败的步骤是定位,如果把非车牌区域当成了车牌,后面所有字符识别都在错区域里白算。蓝色车牌是普通燃油车的主流,识别策略就以“找蓝色”为第一优先。不要直接对BGR三个通道做范围判断,因为RGB通道对亮度非常敏感:同一个蓝色,在树荫下和补光灯下像素值差出好几档,三通道联合阈值很难用一个矩形盒子同时罩住。HSV把色相H、饱和度S、亮度V分开,我们只锁H和S,放开V的绝对值,才对光照变化有一点宽容度。
OpenCV里需要留意H范围是0到180,而蓝色实际落在100到124之间。如果你照搬某些老博客里的0到130范围,会把紫色和红色也当蓝底收进来。不同车牌底色固定,建议直接做成字典配置:
| 车牌底色 | H范围 | S范围 | V范围 | 典型对应 |
|---|---|---|---|---|
| 蓝色 | 100~124 | 60~255 | 60~255 | 蓝底白字小型车 |
| 黄色 | 18~34 | 43~255 | 60~255 | 黄底黑字大型车 |
| 绿色渐变 | 35~80 | 43~255 | 40~255 | 新能源车 |
S下限不能太低,否则灰色车漆、水泥地面会被误认为车牌。V下限我只放到40或60,因为夜间抓拍的车牌常常补光不足,V卡得太死会把车牌从中间切成碎片。表格里蓝色用了60是为了挡暗部噪声,绿色降到40是给渐变绿的新能源车牌留余量,这两处取值靠实测校正,不同摄像头品牌差距很大。
2.2 形态学闭运算连通候选区域
得到二值mask后不能直接找轮廓,字符笔画和车牌边框之间有大面积空隙,轮廓会碎成几十块。常见做法是用横向矩形核做一次闭运算。核心参数是核的形状和尺寸:
import cv2 import numpy as np def build_plate_mask(bgr_frame, config): hsv = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2HSV) lower = np.array([config['h_low'], config['s_low'], config['v_low']]) upper = np.array([config['h_high'], config['s_high'], config['v_high']]) mask = cv2.inRange(hsv, lower, upper) # 车牌是横向矩形,做闭运算也用横向核 kernel = cv2.getStructuringElement( cv2.MORPH_RECT, (config['kernel_w'], config['kernel_h'])) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return closed核尺寸默认取(17, 5):横向17个像素足以跨过字符之间的空隙和字符内部笔画断裂,纵向只取5个像素,避免把上下两条干扰线或邻车区域合并进来。如果图像来自1080p全幅画面,车牌横向宽度通常在300像素上下,这个核尺寸是合理的起点;换更低分辨率摄像头时要按比例缩小核宽,否则小目标会被整个闭运算吞掉。闭运算对白色高光的反光区域也有抑制作用,反光点通常面积小,先膨胀再腐蚀能把它们留在mask外。
2.3 轮廓过滤与透视校正
闭运算之后用cv2.findContours提取外部轮廓,然后做三连过滤:面积下限、宽高比、矩形度。车牌宽高比一般在2.0到5.5之间,标准蓝牌按440mm×140mm计算是3.14,加上拍摄视角偏差会有波动,但不会超过2到5.5。面积阈值之所以重要,是因为HSV阈值常把远处红色尾灯也带出来,那一小块区域的宽高比虽然不像车牌,但面积太小,直接删掉最省事:
contours, _ = cv2.findContours( closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) area = w * h ratio = w / h rectness = area / (w * h) if w * h > 0 else 0 if 2000 < area < 200000 and 2.0 < ratio < 5.5 and rectness > 0.6: candidates.append((x, y, w, h))这段过滤逻辑看着简单,但对最后识别结果影响最大。矩形度是轮廓实际面积与外接矩形面积之比,用来剔除L型、Z型的不规则联通域。1920×1080分辨率下,车牌在画面里占150×50像素左右,面积大约7500,所以下限给2000是安全的。再往下取矩形度与面积太严,会漏掉大角度倾斜的车牌,建议第一版先宽松,等后面字符识别结果再收敛候选框。
倾斜车牌在颜色通道里经常被切掉一角,常见处理是再加一条Sobel垂直梯度通道,对灰度图求水平方向的梯度:
gray = cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2GRAY) grad_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3) grad_x = cv2.convertScaleAbs(grad_x) _, grad_bin = cv2.threshold(grad_x, 0, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU)ksize=3已经够用,更大的核会产生较厚的边缘响应,把相邻字符连成一整块。Otsu自动阈值比固定120更稳,因为梯度幅值随整幅图亮暗变化,固定阈值在夜间会丢失所有弱边缘。颜色通道和梯度通道各自产出一批候选框,取交集后才交给字符分割。若是俯拍或斜拍,再用cv2.minAreaRect拿旋转角度,把车牌旋转到水平;角度误差小于5度时,直接仿射变换比透视校正更稳,透视变换在四角定位不精时反而会扭曲字符。
3. SVM字符识别:从投影分割到分类器训练
3.1 字符分割的垂直投影方法
车牌区域校正成水平后,接下来要把一串字符切开。字符切割的准确率决定SVM输入质量,切歪一个字符,后面分类器再强也救不回来。经典做法是垂直投影:对二值化后的车牌图像按列统计白色像素数量,字符列有白色像素,字符间空隙列接近零,按这个规律切出字符边界。
def split_chars(plate_bin): col_sum = np.sum(plate_bin > 0, axis=0) in_char = False start = 0 boxes = [] # 按列扫描,白像素数从0变正说明进入字符 for i, val in enumerate(col_sum): if val > 0 and not in_char: start, in_char = i, True elif val == 0 and in_char: boxes.append((start, i)) in_char = False return boxes对二值化使用Otsu还是自适应阈值,取决于车牌是否有明显阴影。均匀光照用Otsu一步到位;阴影横穿车牌时,自适应阈值cv2.adaptiveThreshold能保留字符笔画,但会把边框和铆钉也带出来。所以切字符之前先做一次开运算,把细小的边框残留清理掉。投影切出来的区域还要按宽度阈值过滤,宽度不足平均字符宽度三分之一的区域,多半是噪点或汉字偏旁断裂,直接丢掉。
汉字是分割难点,因为“京”“苏”这类字的内部结构复杂,笔画断开后列投影会在字中间掉到零,把一个汉字切成两三块。缓解办法是设定最小字符宽度,当两个相邻空隙间隔小于平均宽度的40%时强制合并。更稳的方案是先按7个字符的等宽先验切,再用投影微调边界,我实际测试下来,等宽先验对标准蓝牌最有效,因为车牌字符间距由制牌标准固定。
3.2 HOG特征如何描述字符形状
切出来的单个字符要先缩放到统一尺寸,再提特征。为什么不直接展平像素喂给SVM?字符位置有微小偏移时,像素级特征变化剧烈,而车载抓拍里字符很难做到完全居中。HOG把图像划分成小格子,统计每个格子内梯度方向的分布,相当于描述“这块区域里笔画朝哪个方向走”,对笔画粗细和位置抖动有较高容忍度。
常见参数组合如下,winSize就是归一化后的字符图像尺寸:
| 参数 | 取值 | 作用 |
|---|---|---|
| winSize | 20×20 | 所有字符统一缩放到的尺寸 |
| blockSize | 10×10 | 由2×2个cell组成 |
| blockStride | 5×5 | block滑动步长 |
| cellSize | 5×5 | 梯度直方图的最小统计单元 |
| nbins | 9 | 梯度方向分为9个区间 |
用OpenCV的cv2.HOGDescriptor可以拿到这个配置的默认特征向量,维度是72。字符样本量不大的情况下,特征维度再往上加容易过拟合,20×20窗口配9个bin足够区分汉字和字母。提取特征时还要做一次归一化,把向量L2范数缩放到1,不同光照下笔画灰度差异会被压平,SVM训练起来收敛更快。
3.3 用sklearn训练SVM分类器
样本集是训练阶段最花人工的部分。开源车牌字符库能覆盖大部分汉字和字母数字,但真实抓拍里的模糊、污损样本仍然要自己从分割结果里挑出来补进训练集。字符类别集合要提前定清楚:车牌号不用字母I和O,数字1和字母I、数字0和字母O在标注时要合并成同一类,否则预测阶段会出现两个高置信度互相打架的结果。省份汉字按31个省级行政区收全,加上24个字母和10个数字,模型输出类数为65。
from sklearn.svm import SVC from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report X = [] # HOG特征向量列表 y = [] # 字符标签,如"京"、"A"、"8" for sample_path, label in sample_loader(): image = cv2.imread(sample_path, cv2.IMREAD_GRAYSCALE) image = cv2.resize(image, (20, 20)) hog = cv2.HOGDescriptor((20, 20), (10, 10), (5, 5), (5, 5), 9) X.append(hog.compute(image).flatten()) y.append(label) X_train, X_test, y_train, y_test = train_test_split( X, y, test_size=0.2, random_state=42, stratify=y) svm_model = SVC( kernel='rbf', # 字符特征非线性可分,RBF核最稳 C=10.0, # 惩罚系数,偏大能提升拟合度 gamma='scale', # 按特征维数自动确定gamma decision_function_shape='ovr', probability=True) # 后续要用predict_proba做置信度仲裁 svm_model.fit(X_train, y_train) print(classification_report(y_test, svm_model.predict(X_test)))C=10.0是经验起调值,样本量几百时这个值不会过拟合;样本量过千后可以按网格搜索在1到100之间再扫一遍。gamma='scale'让sklearn自己用1/(特征数量×X方差)计算,省去手动调参的麻烦。decision_function_shape='ovr'表示一对多策略,65类问题下每类训练一个二分类器,预测时取所有分类器得分最高者。训练完成后用joblib把模型导出成.pkl文件,部署环境直接加载,不要在每帧图像里重复fit。
SVM在这里替代了神经网络,对字符这种小图分类任务有明确优势:数据量需求低,几十个样本一类就能有可用的准确率,而神经网络在样本量不足时很容易把噪声当特征。缺点是对样本分布敏感,某类字符训练样本只有5张时,预测偏差会明显放大。解决办法是给样本做轻微数据增强,对原图做±1像素平移、±5%缩放和轻度高斯模糊再各生成一份副本,能缓解SVM对小样本类别的过拟合。
4. 百度AI平台OCR接入与本地分类器的降级策略
4.1 AK/SK鉴权与Access Token缓存
百度AI平台接入的第一步是拿密钥换token。控制台开通车牌识别服务后,会生成API Key和Secret Key,请求业务接口之前先调用鉴权接口换取Access Token。这个token有较长有效期,但每次进程启动都重新取一次比较稳妥,取到后放到进程内缓存,避免每帧都发鉴权请求把接口配额耗尽。
import requests import base64 import time _token_cache = {"value": None, "expire_ts": 0} def get_access_token(api_key, secret_key): if _token_cache["value"] and _token_cache["expire_ts"] > time.time() + 600: return _token_cache["value"] resp = requests.post( "https://aip.baidubce.com/oauth/2.0/token", params={ "grant_type": "client_credentials", "client_id": api_key, "client_secret": secret_key, }, timeout=5) data = resp.json() _token_cache["value"] = data["access_token"] _token_cache["expire_ts"] = time.time() + data["expires_in"] return _token_cache["value"]这段代码的缓存逻辑多留了600秒余量,token实际过期前10分钟就主动刷新,避免边界时刻突然返回401。expires_in字段由服务端返回,单位为秒。api_key和secret_key不要直接写在代码里,从环境变量或配置文件读取,否则代码一旦被推到公开仓库,密钥会被刷爆额度。
4.2 车牌识别接口调用与参数说明
拿到token后请求车牌识别接口,图片以base64编码放在表单里。这里注意OpenCV读进来的是BGR三通道的numpy数组,要先用cv2.imencode转成jpg格式的字节串再做base64,直接把数组转bytes会导致编码错误:
def baidu_plate_ocr(crop_bgr, token): ok, buf = cv2.imencode(".jpg", crop_bgr, [cv2.IMWRITE_JPEG_QUALITY, 95]) if not ok: return None resp = requests.post( "https://aip.baidubce.com/rest/2.0/ocr/v1/plate_license", params={"access_token": token}, data={"image": base64.b64encode(buf.tobytes())}, headers={"Content-Type": "application/x-www-form-urlencoded"}, timeout=8) result = resp.json() if "words_result" in result: return result["words_result"]["number"] return None接口返回体里words_result.number是识别出的完整车牌号,color字段返回车牌颜色。IMWRITE_JPEG_QUALITY设为95而不是默认的90,是为了减少压缩产生的字符边缘振铃,SVM对振铃很敏感,但百度接口对jpeg压缩不敏感,这项反而可以降。单张请求对图片大小有上限,抓拍原图超过限制时要先等比缩到宽度1000再编码,缩放会稍微影响本地SVM准确率,所以这里传进去的是已经切好的车牌小图而不是全画面。
4.3 双引擎结果仲裁策略
两个识别引擎各有长处:SVM对正常光照下的标准车牌速度快,单张字符推理加起来不到10毫秒,且不依赖网络,适合离线处理;百度AI对模糊、倾斜、夜间补光不足的极端场景更鲁棒。生产上不搞二选一,而是根据置信度做仲裁,这个策略在车牌识别系统中比任何单模型都稳:
| 条件 | 本地SVM结果 | 百度OCR结果 | 最终输出 |
|---|---|---|---|
| SVM top1置信度≥0.85且两引擎一致 | 采纳 | 采纳 | 直接返回 |
| 置信度≥0.85但两引擎不一致 | 高置信 | 低置信 | 采纳百度结果并标注需复核 |
| 置信度<0.85且两引擎一致 | 低置信 | 低置信 | 返回共同结果 |
| 置信度<0.85且两引擎不一致 | 低置信 | 低置信 | 交给人工复核队列 |
仲裁逻辑的落点不在代码里散落一堆if/else,而是把这个决策过程抽象成函数,返回结果同时带上置信度和来源引擎。SVM的predict_proba输出的不是真实概率,只是决策函数值的概率化映射,不同类别之间不能直接拿0.85这个阈值横向对比,所以阈值要用验证集统计整牌正确率后标定。这里把网络请求超时控制在8秒,仲裁分支里超过就跳过百度侧,防止视频流卡死。
5. 端到端整合与识别率调优顺序
5.1 流水线耗时预算与接口约定
整个系统跑起来后,先不要急着调模型,而是把四个阶段分别计时:车牌定位、字符分割、SVM推理、百度请求。用time.perf_counter()记录每一帧各阶段耗时,打印到日志里。定位和分割合起来应该压在30毫秒以内,SVM对7个字符推理在10毫秒量级,网络请求在200到500毫秒。如果定位阶段已经超过50毫秒,说明HSV形态学核尺度过大或候选框太多,先查这里。各模块之间用统一的数据结构传递:定位输出一个(x, y, w, h)的框,分割输出七个字符的裁剪图列表和它们的坐标,SVM输出字符数组和置信度,别让中间结果散落在各自模块的私有变量里。
5.2 用测试集做字符级与整牌级评估
准备一个测试集,每张图片一行,格式是“图片路径 正确车牌号”,跑完流水线后分别统计字符级和整牌级准确率。字符级准确率等于正确字符数除以总字符数,整牌级要求七个字符全对才算对。SVM单独调参时只看字符级准确率,但系统验收必须看整牌级,因为一个字符的错误会导致整牌匹配失败。常见情况是字符级已经99%了,整牌级还是卡在85%左右,这时定位模块的切割抖动往往才是主因,SVM调参反而没有帮助。
5.3 模型导出与初始化加载
SVM模型训练完用joblib.dump导出,推理进程初始化时一次性加载。这里有个容易踩的问题:SVC(probability=True)生成的模型文件比不带概率的版本大好几倍,加载时间也长,如果仲裁逻辑里不需要概率,就在训练时把probability关掉,改用decision_function的得分做阈值。模型加载放在类构造函数里,不要放在帧处理函数里,否则每帧都要反序列化一次模型,性能会直接被打到不可用。部署在视频流场景时,SVM的predict要加线程锁,sklearn的scikit-learn分类器不是线程安全的,多线程并发预测会导致结果错乱。
本文还有配套的精品资源,点击获取