简介:这套基于Python与OpenCV的车牌识别系统,面向人工智能、计算机视觉方向的开发者和学生。除支持图片中的车牌识别外,还具备GUI界面操作、识别数据导出Excel、OpenCV摄像头实时识别等功能,作者并对原项目识别模块做了函数级优化,覆盖车牌定位、字符分割与识别等完整流程,识别速度与准确率均有明显提升,适合课程设计、毕业设计或工程实践参考。资源包共16468个文件,压缩后约24.91MB,以jpg车牌图像样本居多,同时包含Python脚本、sample样本、xml配置、svm.dat等模型数据,从数据集、训练配置到核心代码均有覆盖。已有1843人浏览学习。下载后可获得可直接运行的识别代码、丰富的车牌图像样本、SVM训练模型与工程配置文件,便于快速复现和二次开发,也可借助摄像头实现动态场景下的实时车牌识别。
1. 车牌识别系统没那么玄:先搞清楚定位和分割,再谈识别
做车牌识别最容易被忽悠的一点,就是上来就谈深度学习模型。实际上用 python-opencv 做一套能跑的蓝牌识别系统,核心链路只有三步:颜色定位、字符分割、字符匹配。我在一个车位管理模拟项目X里踩了一周,发现真正的难点根本不在识别网络,而在前面两步——光照一变,HSV 区间飘了,定位框就飞;车牌边框和铆钉没滤干净,分割就多一个字符。这套东西适合刚接触计算机视觉的开发者,也适合想快速验证识别逻辑的技术选型。先放下“人工智能高大上”的预设,用几何特征和颜色特征把流程跑通,后面加什么模型都只是替换最后一步。
2. 颜色定位:用 HSV 把蓝色车牌从复杂背景里抠出来
2.1 HSV 颜色空间筛选:为什么不用 RGB
车牌识别第一步是找到车牌在图片里的位置。很多新手直接拿 BGR 三通道做阈值,结果绿色树木、灰色路面全被圈进来。问题在于 BGR 对光照太敏感,同一块蓝色车牌在阴影和阳光下的 RGB 差值能超过 60。我一般会把图片转到 HSV 空间再做区间判断,因为 H 通道表示色相,对光照变化相对稳定。OpenCV 里 cvtColor 一行就能转,但要注意 OpenCV 的 HSV 范围是 H 0-180、S 0-255、V 0-255,别拿美术里的 0-360 去套。
下面这段代码是定位的起点,先读图、缩放、滤波、转 HSV,再按蓝色区间做掩码:
import cv2 import numpy as np def locate_plate(img): # 统一宽度,减少后续轮廓计算的像素量 img = cv2.resize(img, (800, int(img.shape[0] * 800 / img.shape[1]))) blur = cv2.GaussianBlur(img, (5, 5), 0) hsv = cv2.cvtColor(blur, cv2.COLOR_BGR2HSV) # 蓝色车牌常见区间,H 集中在 100~124 lower_blue = np.array([100, 90, 90]) upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) return mask, img这里 resize 到 800 宽是为了让后面的面积阈值有稳定参照,不然 1920 宽的图和 640 宽的图同一套阈值没法用。GaussianBlur 的核用 (5,5),主要是去掉传感器噪声,避免掩码上出现碎点。HSV 下界的 S 和 V 我通常设 90 而不是 0,否则暗部区域的低饱和度噪声也会被当成蓝色。
2.2 形态学闭运算和轮廓筛选:把碎片拼成完整车牌
掩码图出来后,车牌区域往往不是一整块——车牌上的白色字符会把蓝色区域切断,变成好多个小碎块。直接找轮廓只能拿到半个车牌。常见做法是先做一次闭运算,用比字符宽度稍大的核把断裂处补上。闭运算的顺序是先膨胀后腐蚀,能弥合小缺口,同时保持整体面积不膨胀太夸张。
def extract_contour(mask, img): kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) 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 if area < 2000: continue ratio = w / h # 标准车牌比例约 3~5,宽度要大于高度 if 2.0 < ratio < 6.0: candidates.append((x, y, w, h)) candidates.sort(key=lambda r: r[2] * r[3], reverse=True) return candidates[0] if candidates else None闭运算核选 (15, 5),宽是高的三倍,贴合车牌横向拉长的形状。如果车牌字符间隙较大,可以加大到 (21, 5),但核太宽会把附近蓝色物体也连进来。轮廓检索用 RETR_EXTERNAL 只取外轮廓,避免把车牌内部的字符轮廓也检出来。面积阈值 2000 是我在 800 宽图像下试出来的经验值,你如果改了 resize 宽度,这个下界要跟着调。
2.3 定位参数调试:光照过强和过暗怎么处理
HSV 区间不是一成不变的。正午阳光直射时,蓝色车牌的 S 会下降,V 很高,看起来发白;傍晚或阴影里,V 可能低于 80。我常用的调节思路是:先固定 H 区间,动态放宽 S 和 V 的下界。比如检测到的蓝色区域太少,就逐步把 S 下界从 90 降到 50,V 下界从 90 降到 60。反过来,如果误检了天空或蓝色广告牌,就收紧区间,把 H 上界从 124 降到 115。
另一个参数是面积阈值。如果你发现候选框把远处的蓝色垃圾桶也圈进来,就提高面积下界;如果近景大车牌的框被切掉,降低面积下界。这个条件很容易被忽略,因为它表现得像“找不到车牌”而非“找错了”。调参时我习惯打印出每一个候选框的 w、h、ratio,用 debug 模式画在图上,比盲调效率高很多。定位这部分是整个系统的地基,定位框歪了,后面分割和识别全白搭。
3. 字符分割:投影法切出七个字符,避开铆钉和边框的雷
3.1 灰度图和二值化:先裁剪定位框再做自适应阈值
拿到定位框之后,第一步是从原图裁出车牌区域,然后转灰度、二值化。这里不建议用全局固定阈值,因为车牌区域光照不均匀,左边亮右边暗时,固定阈值会让一半字符变成背景。我会先做一次高斯自适应阈值,块大小选 31,C 值选 5。块大小必须大于字符笔画宽度,否则每个字符内部会出现空洞。
def binarize_plate(plate_bgr): gray = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2GRAY) # 自适应阈值,块大小选奇数,C 是常数偏差 binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 5) return binaryTHRESH_BINARY_INV 是把字符反成白色、背景反成黑色,因为后面投影分割要找白色连通块。注意这里一定要用 INV,否则找出来的就是背景块。adaptiveThreshold 的 blockSize 选 31,对 800 宽图裁剪出来的大约 240×80 车牌区域来说,刚好能覆盖两到三个字符的宽度,不会让梯度变化干扰太多。
3.2 去边框和铆钉:形态学开运算干掉细线
车牌边框和固定铆钉是分割阶段最烦人的噪声。白色边框在二值化后是一条贯穿整行的白线,垂直投影时会形成一个巨大的峰,导致字符区域被错误合并。我的处理顺序是:先做一次开运算,用比较宽的核对白色边框做“瘦身”,然后通过行投影找到字符的上下边界,裁掉顶部和底部的边框残留。
def strip_frame(binary): # 开运算去掉细的边框线,保留粗字符 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 水平投影:统计每行白点数量,定位字符区域的上下边界 row_sum = np.sum(opened, axis=1) rows = np.where(row_sum > 0)[0] if len(rows) == 0: return None top, bottom = rows[0], rows[-1] # 上下各裁掉 2 像素,避免残留的边框边缘 return opened[top+2:bottom-2, :]开运算是先腐蚀后膨胀,能消除面积小于核的白色噪点。边框线在垂直方向只有 1~3 像素,而字符笔画通常有 8~15 像素宽,所以 (3,3) 核可以把边框腐蚀掉一部分,但字符主体仍然保留。水平投影找的是所有白点的最高行和最低行,这一步可以顺带把车牌顶部的白色边框排除。如果铆钉刚好在字符行范围内,水平投影切不掉,需要在后面的连通域分析里单独处理。
3.3 垂直投影分割:动态间隔切出字符块
字符分割最常见的方法是垂直投影法:统计每一列的白点数量,然后从投影曲线中找连续的“有内容”区间。但直接做会碰到两个问题:字符内部有笔画间隙导致一个字符被切成三段;左右边框与字符粘连导致两个字符并成一个块。我的做法是先用一个宽度阈值合并碎块,再用高度和宽高比过滤。
def split_chars(binary): col_sum = np.sum(binary, axis=0) # 找出所有白点数量大于0的列 cols = np.where(col_sum > 0)[0] if len(cols) == 0: return [] # 把连续的列划分为初始块 blocks = [] start = cols[0] for i in range(1, len(cols)): if cols[i] - cols[i-1] > 8: # 间隔超过8像素视为断开 blocks.append((start, cols[i-1])) start = cols[i] blocks.append((start, cols[-1])) # 合并过窄的碎块,宽度小于15像素且间隔小于字符宽度的块并入左侧 merged = [list(blocks[0])] for blk in blocks[1:]: w = blk[1] - blk[0] if w < 15 and (blk[0] - merged[-1][1]) < 30: merged[-1][1] = blk[1] else: merged.append(list(blk)) # 按宽度过滤:太宽的是两个字符粘连,太窄的是噪声 chars = [] for blk in merged: w = blk[1] - blk[0] h = np.sum(binary[:, blk[0]:blk[1]], axis=0).max() if 15 <= w <= 60 and h > 20: chars.append((blk[0], blk[1])) return chars这里有个关键逻辑:合并碎块时只看“宽度小于15”和“与前一块间隔小于30”,这两个数是我从实际车牌字符宽度反推出来的。标准蓝牌在 800×600 的原始图中,每个字符宽度大约 35~50 像素,笔画间隔 3~6 像素。如果设的合并阈值太大,字符“京”的左右两半会被强行拼回去;太小又会把一个字符切成两半。过滤条件里宽度上限 60 是为了挡掉两个字符粘连后宽度超过 70 的块,这种情况虽然发生了粘连,但优先让它进不了识别环节,总比误识别好。
4. 字符识别:模板匹配够用,先别急着上深度学习
4.1 模板库构建:统一尺寸和笔画粗细
字符分割完成后,每个字符块需要归一化到固定尺寸,然后和模板做比较。模板匹配的精度完全取决于模板库的质量。我用的模板不是手写字,而是从标准车牌字体里截出来的单字符图,每个字符做三组变体:正常、轻微倾斜、加粗。尺寸统一缩放到 40×80,背景黑、字符白,和分割出来的二值图保持同一个方向。
def build_templates(): templates = {} chars = "ABCDEFGHJKLMNPQRSTUVWXYZ0123456789" for ch in chars: # 假设 template_dir 下每个字符有多张图片 for f in glob.glob(f"templates/{ch}_*.png"): img = cv2.imread(f, cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, (40, 80)) # 二值化保证和输入字符一致 _, img = cv2.threshold(img, 128, 255, cv2.THRESH_BINARY) templates.setdefault(ch, []).append(img) return templates注意字符集合里我排除了字母 I 和 O,这两个和数字 1、0 极易混淆,车牌标准里本来也不会同时出现。归一化用 40×80 是因为字符块经过分割后高度大于宽度,保持 1:2 的比例能减少拉伸变形。模板是白字符黑底,如果输入分割图是黑字符白底,识别前要取反。
4.2 识别与置信度:用归一化相关系数代替像素差
直接求两幅图对应像素差的绝对值平均,对位置偏移非常敏感。字符分割的边界不可能恰好和模板完全对齐,差一两个像素得分就掉一大截。我一般用归一化相关系数,也就是把两幅图都拉平成一维向量后算相关性,OpenCV 的 matchTemplate 里就有对应模式。
def recognize_char(char_img, templates): char_img = cv2.resize(char_img, (40, 80)) _, char_img = cv2.threshold(char_img, 128, 255, cv2.THRESH_BINARY) best_score = -1 best_char = None for ch, tmpl_list in templates.items(): for tmpl in tmpl_list: # 用相关系数匹配 res = cv2.matchTemplate(char_img, tmpl, cv2.TM_CCOEFF_NORMED) score = res[0][0] if score > best_score: best_score = score best_char = ch if best_score < 0.6: return None, best_score return best_char, best_scoreTM_CCOEFF_NORMED 的输出范围是 -1 到 1,实际经验里同一字符的匹配分通常在 0.7~0.9 之间,不同字符在 0.3~0.5。阈值 0.6 是我定的底线,低于这个值宁可输出 None,也不要硬给一个结果。因为车牌识别最后要做综合判断,一个字符错了整个牌子就错了,不如先标记为未知,让上层逻辑决定是否重试。
4.3 模板匹配的短板和两个补救手段
模板匹配最大的短板是扛不住字体变形。新能源绿牌、老式黄牌、甚至某些省份的车牌字体边缘有细微差别,固定模板的识别率会明显下降。一个补救方式是做多模板投票,比如同一字符收集 5 种字体变体,取得分最高的前三个结果做投票,能稳定两到三个百分点。另一个补救方式是做字符的宽度归一化时保留原始宽高比,不要直接拉伸成 40×80,而是补零到目标尺寸,减少横向形变。这两个手段我都加进了完整代码,能显著提升不同摄像头下的鲁棒性。如果你手头的车牌数据包含倾斜角度较大的图片,建议先跑一下后面的倾斜校正,再进识别。
5. 避坑与常见问题:五个实测翻车场景及修复参数
5.1 现象:蓝色广告牌被当成车牌候选框
定位时 HSV 颜色过滤把商店招牌上的蓝色底也圈进来了,并且形态学闭运算后面积和宽高比都满足条件。原因是招牌蓝色和车牌蓝非常接近,而且招牌上可能有白色文字,闭运算正好把这些文字连接成了一个大块。解决方法是增加一个“内部纹理”判断:车牌的字符分布有固定排列,而招牌文字大小不均。在定位后计算候选框内白色像素的连通块数量,车牌通常有 7±2 个块,招牌可能只有 1 个或十几个。这个过滤逻辑我放在轮廓筛选之后,能干掉大约 70% 的误检。
5.2 现象:字符分割多出一个小方块
分割结果里除了七个字符,还多了一个宽度很小的窄条,通常是铆钉或者车牌边框的残留。我在前面用开运算去边框,但铆钉是圆形的,开运算核是矩形,对圆形腐蚀不彻底。解决方法是按宽度和“是否与相邻字符有重叠竖区间”来过滤。如果某个块的宽度小于标准字符宽度的一半,并且它的上下边界不在字符主体的公共区间内,直接丢弃。实测里这个条件可以过滤掉 90% 的铆钉碎片。
5.3 现象:识别结果总是少一位,比如“京A12345”变“京A1234”
先检查分割环节是否把两个字符粘在了一起。最常见的粘连发生在字符“1”和后面的字符之间,因为“1”的笔画细,垂直投影时如果间隔处被少量噪声填充,投影值会大于零,导致前一个字符的结束列和下一个字符的开始列之间没有空隙。解决方法是分割时不要用“投影值大于 0 就是字符列”的硬条件,改成“投影值大于某阈值才认为有字符”,阈值取这个区域内最大投影值的 15%。这样噪声列会被跳过,断开间隔就出来了。
5.4 现象:视频流里识别结果一闪一闪,时好时坏
单张图片识别没问题,但视频里每帧独立识别,某一帧因为运动模糊或光照突变导致分割失败,下一帧又恢复,结果就会闪烁。解决思路不是优化单帧识别,而是加一个“短时一致性”判断:连续三帧中如果两帧的识别结果相同,就采纳这个结果;如果结果在变就继续等。这其实是个状态机,非常简单,但效果很明显。后面第 6 章我会给出具体代码。
5.5 现象:换一个摄像头,识别率从 92% 掉到 70%
不同摄像头的焦距、白平衡、色彩增益差异很大。之前我在模拟项目X里用固定 HSV 区间,换摄像头后发现蓝色车牌的掩码面积缩了一大半。原因是对面那个摄像头的色彩偏淡,S 通道整体低了 30。解决方法是做一次“颜色自适应”:在画面中取一个统计蓝色像素的直方图,再把区间中心自动偏移。具体做法是先用宽松区间(比如 H 80-140)粗筛出蓝色像素,计算这些像素的 H 和 S 均值,再用均值附近 ±15 的区间重新掩码。这个逻辑几乎每个项目都需要。
6. 进阶:从单张图片到视频流实时识别,加一层去重逻辑
6.1 连续帧识别与简单状态机
把上面单帧函数封装成一个process_frame后,视频流的主要问题是时序噪声。我用的方案是维护一个长度为 3 的滑动窗口,窗口里出现两个相同结果时输出,否则继续。代码如下:
def video_recognize(video_path): cap = cv2.VideoCapture(video_path) window = [] last_output = None while True: ret, frame = cap.read() if not ret: break result = process_frame(frame) # 返回车牌字符串或 None if result is None: window.clear() continue window.append(result) if len(window) > 3: window.pop(0) if len(window) == 3 and window[0] == window[2]: # 首尾一致才输出,避免抖动 print(window[1]) window.clear()注意这里不要求连续三帧完全一致,只要求第一帧和第三帧相同,因为中间一帧如果恰好被运动模糊污染,还能靠投票兜底。window 在识别失败时清空,是为了防止几秒前的结果一直占用窗口。
6.2 结果缓存与置信度加权:最后一层后悔药
状态机输出了字符串,但还不能直接作为最终结果。我一般再加一个缓存字典,记录最近 10 秒内每个字符串出现的次数和平均置信度。当某个结果的置信度超过 0.75、出现次数超过 3 次,才正式确认。这个逻辑能挡住误识别单帧闪烁,也能在车牌快速驶过时补上漏检的帧。实现时可以只维护一个队列,不复杂,但能让最终输出稳定很多。
这套流程跑通之后,你会意识到车牌识别真正的技巧不在模型,而在预处理和参数设计。从那以后我每次调识别系统,都强制走一遍“先固定分辨率,再调 HSV,再调分割阈值,最后才碰识别”的顺序,少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取