☰
基于Python的车牌识别系统:传统图像处理全流程与避坑指南
2026/9/28 16:28:42 网站建设 项目流程

简介:这份资源是面向高校学生与Python图像处理学习者的数字图像处理课程大作业完整源码,聚焦车牌识别系统的实现,适合需要完成期末项目、课程设计或入门计算机视觉实战的读者参考。压缩包共约2000个文件,整体26.9MB,其中1985个jpg为车牌字符与训练样本图像,9个py脚本承担图像预处理、字符分割与识别等核心逻辑,另有5个xml与1个ui文件用于界面布局与配置,目录结构清晰,便于按模块查阅。目前已有641人学习下载,具备一定参考热度。项目已获导师指导并通过97分高分,代码完整可直接运行,读者可据此掌握车牌定位、灰度化、二值化、边缘检测、字符分割与模板匹配等关键流程,并借助样本图与调试输出图快速复现实验、排查问题,为课程答辩与后续视觉项目提供可复用的实现思路。

1. 车牌识别系统到底难在哪:从一张倾斜的蓝色车牌说起

你拍了一张停车场入口的照片,车牌是斜的,光线一半在阴影里,字符边缘还有点糊。人眼扫一下就知道是「苏A·12345」,但要让 Python 在几百毫秒内把同样的结果吐出来,中间要跨过定位、矫正、分割、识别四道坎,每一道都能让整套流程翻车。这就是数字图像处理大作业里「基于 Python 实现的车牌识别系统」最真实的处境:它不是调一个库就完事,而是把冈萨雷斯那本书里的滤波、边缘、形态学、投影法全部串起来跑一遍。

这套方案适合两类人:一类是正在做数字图像处理课程设计、需要一份能跑通、能讲清楚原理、能拿到 95 分以上的完整源码;另一类是想用 Python 快速搭一个车牌识别原型、验证某个环节(比如倾斜矫正或字符分割)效果的工程师。它不依赖深度学习大模型,核心是 OpenCV + NumPy 的传统图像处理链路,CPU 上就能跑,代码量可控,每个步骤都能可视化,答辩时经得起追问。下面按「先立住原理、再动手复现、最后避坑」的顺序拆开讲。

2. 车牌定位与预处理:把车牌从整张图里「抠」出来

2.1 为什么先做灰度、高斯、Sobel 这三步

车牌定位的本质是「在整张图里找一块矩形区域,它的边缘密度和颜色分布跟车牌吻合」。直接对彩色图做处理计算量大且受光照干扰,所以第一步永远是灰度化。常见做法是加权平均:Gray = 0.299R + 0.587G + 0.114B,OpenCV 的cvtColor默认就是这个系数,不要自己写循环,速度差几十倍。

灰度之后是高斯模糊。这里有个血泪经验:核大小必须选奇数,(5,5)是起点,车牌字符笔画细,核太大(比如 11)会把字符边缘一起抹掉,后续 Sobel 就检测不到字符的竖向边缘。我一般先用(5,5),如果图片分辨率超过 2000 像素宽,再考虑(7,7)。

Sobel 算子用来提取竖直边缘。车牌字符是竖直排列的,字符与底板之间有强烈的水平方向梯度,所以对 x 方向求导、对 y 方向不敏感,正好把字符边缘留下来,把天空、路面这些大面积平滑区域压下去。这一步的输出是一张边缘强度图,后面所有形态学操作都建立在它上面。

import cv2 import numpy as np def preprocess(img_path): # 读取原图,保留彩色副本用于后续颜色判断 img = cv2.imread(img_path) if img is None: raise FileNotFoundError(f"读不到图片: {img_path}") gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊:核必须是奇数,5x5 是车牌场景的稳妥起点 blur = cv2.GaussianBlur(gray, (5, 5), 0) # Sobel 竖直边缘:ksize=3 足够,太大反而丢字符细节 sobel_x = cv2.Sobel(blur, cv2.CV_16S, 1, 0, ksize=3) abs_x = cv2.convertScaleAbs(sobel_x) # 二值化:阈值 120 是经验值,光照均匀时可到 150 _, binary = cv2.threshold(abs_x, 120, 255, cv2.THRESH_BINARY) return img, gray, binary

逻辑说明:CV_16S是因为 Sobel 结果有负数,必须用有符号类型接,再取绝对值转回uint8。阈值 120 不是固定的,如果图片整体偏暗,先做一次直方图均衡化再二值化,否则车牌区域会被当成背景丢掉。参数上,ksize=3对应 3×3 的 Sobel 核,是精度和抗噪的平衡点。

2.2 形态学闭运算与轮廓筛选:参数怎么调

二值化之后,字符边缘是一段一段断开的,需要用闭运算(先膨胀后腐蚀)把它们连成块。这里的关键是结构元素的选择:车牌是扁长矩形,所以用(17,5)这种宽大于高的矩形核,横向连接字符,纵向不要过度膨胀,否则会把上下相邻的车牌和车身装饰条粘在一起。

闭运算之后找轮廓,cv2.findContours返回的轮廓里既有车牌,也有车灯、保险杠、文字广告。筛选条件我一般设三个:宽高比在 2.5 到 5.5 之间(中国蓝牌是 440×140,比例约 3.14)、面积大于整图面积的 0.5%、轮廓的矩形度(轮廓面积 / 外接矩形面积)大于 0.6。这三个条件一卡,误检率能降一个数量级。

def locate_plate(binary, img_area): # 宽扁矩形核,横向连接字符,纵向克制 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, 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) if h == 0: continue ratio = w / float(h) area_ratio = (w * h) / img_area rectness = cv2.contourArea(cnt) / float(w * h) # 三个条件同时满足才保留 if 2.5 < ratio < 5.5 and area_ratio > 0.005 and rectness > 0.6: candidates.append((x, y, w, h)) return candidates, closed

参数说明:(17,5)里的 17 对应横向连接力度,如果车牌字符间距大(比如新能源绿牌),可以加到 21;5 是纵向,超过 7 容易把车牌和上方车标连起来。area_ratio > 0.005意味着车牌至少占整图千分之五,太小的候选框基本是噪声。rectness > 0.6是防止把不规则的车灯轮廓误判成车牌。

2.3 颜色校验:蓝牌、绿牌、黄牌怎么区分

轮廓筛选完可能还剩两三个候选,这时候用颜色做二次确认。HSV 空间比 RGB 更适合做颜色阈值,因为 H 通道对光照不敏感。中国蓝牌的 H 大约在 100 到 124,绿牌在 35 到 85,黄牌在 20 到 35。做法是把候选区域裁出来,转 HSV,统计落在对应色相范围内的像素占比,超过 30% 就认定颜色匹配。

这一步能干掉大部分「形状像车牌但颜色不对」的误检,比如深色保险杠上的镀铬条。注意蓝牌在强光下 H 会偏移,阈值范围要留够余量,我一般把蓝牌写成100 < H < 130,宁可宽一点,后面还有字符分割兜底。

3. 倾斜矫正与字符分割:把歪车牌摆正再切开

3.1 用最小外接矩形算倾斜角,别用霍夫直线

车牌定位出来经常是斜的,直接分割字符会切歪。矫正的核心是求倾斜角。常见做法有两种:霍夫直线检测车牌上下边框,或者用cv2.minAreaRect求最小外接矩形。霍夫直线在车牌边框不完整时容易翻车,我一般用minAreaRect,它返回的(center, (w,h), angle)里 angle 就是倾斜角,稳定得多。

拿到角度后,用cv2.getRotationMatrix2D构造旋转矩阵,以车牌中心为轴反向旋转。这里有个坑:旋转后图像尺寸会变,必须用cv2.warpAffine配合新的边界尺寸,否则车牌边缘会被裁掉。旋转完再裁一次矩形区域,得到水平车牌。

def deskew_plate(img, rect): x, y, w, h = rect roi = img[y:y+h, x:x+w] gray_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY) # 用 Otsu 自动阈值,比固定阈值适应性强 _, bin_roi = cv2.threshold(gray_roi, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) coords = np.column_stack(np.where(bin_roi > 0)) if len(coords) < 50: return roi # 点太少,放弃矫正 angle = cv2.minAreaRect(coords)[-1] # OpenCV 4.5 之后角度范围变了,统一到 -45~45 if angle < -45: angle = 90 + angle if abs(angle) < 1.0: return roi # 本来就不歪,不折腾 (h2, w2) = roi.shape[:2] M = cv2.getRotationMatrix2D((w2 // 2, h2 // 2), angle, 1.0) rotated = cv2.warpAffine(roi, M, (w2, h2), flags=cv2.INTER_CUBIC, borderMode=cv2.BORDER_REPLICATE) return rotated

逻辑说明:minAreaRect返回的角度在不同 OpenCV 版本里定义不一致,4.5 之前是 [-90, 0),之后是 (0, 90],所以必须做归一化。abs(angle) < 1.0直接跳过,避免小角度旋转引入插值模糊。INTER_CUBIC比默认的INTER_LINEAR更适合文字,边缘更锐。BORDER_REPLICATE防止旋转后出现黑边干扰后续二值化。

3.2 垂直投影法切字符:阈值和间距怎么定

矫正后的车牌是「省份汉字 + 字母 + 点 + 数字字母」的结构。分割的核心是垂直投影:把二值化后的车牌按列求和,字符所在列投影值高,字符间隙投影值接近零。从左到右扫描投影数组,遇到「连续低值区」就认为是一个分割点。

这里参数很讲究。低值阈值一般取投影最大值的 0.15 倍,太低会把字符内部的空隙也当成分割点,太高会漏掉真实间隙。字符最小宽度按车牌宽度的 1/8 来卡,小于这个宽度的区间直接丢弃,防止把「1」这种窄字符切碎。点号「·」通常投影很弱,会被当成噪声,需要在分割后单独判断:如果某个区间宽度特别小且位置在中间,补一个点号。

def split_characters(binary_plate): # 垂直投影:按列求和 col_sum = np.sum(binary_plate, axis=0) max_val = col_sum.max() if max_val == 0: return [] threshold = max_val * 0.15 segments = [] in_char = False start = 0 for i, v in enumerate(col_sum): if v > threshold and not in_char: in_char = True start = i elif v <= threshold and in_char: in_char = False if i - start > binary_plate.shape[1] // 12: segments.append((start, i)) if in_char: segments.append((start, len(col_sum))) # 裁出每个字符并统一到 20x40 chars = [] for s, e in segments: char_img = binary_plate[:, s:e] char_img = cv2.resize(char_img, (20, 40)) chars.append(char_img) return chars

参数说明:0.15这个系数是投影法的经验值,车牌对比度高时可以到 0.2,低对比度时降到 0.1。binary_plate.shape[1] // 12是最小字符宽度,中国车牌 7 个字符,平均每个占宽度 1/7,留点余量取 1/12。resize到 20×40 是为了后续模板匹配或简单分类器输入尺寸统一,这个尺寸不用太大,字符特征在低分辨率下反而更鲁棒。

3.3 字符归一化:为什么必须做,不做会怎样

分割出来的字符大小不一,有的 18×38,有的 22×42,直接送进分类器会严重影响准确率。归一化到固定尺寸是必须的,但插值方法有讲究:放大用INTER_CUBIC,缩小用INTER_AREA,用反了字符会糊。另外归一化前要做一次「去边框」:如果字符图像四周有大量白色像素(来自车牌底板残留),先裁掉再缩放,否则字符会被压扁。

还有一个容易被忽略的点:字符的宽高比。数字「1」很窄,强行拉到 20×40 会变形,但模板匹配时变形反而影响不大,因为模板也是同样变形的。真正影响大的是「0」和「O」、「1」和「I」的区分,这需要在分类阶段用特征而不是纯模板。

4. 字符识别:模板匹配、SVM 还是轻量 CNN

4.1 模板匹配的适用边界与制作方法

模板匹配是最容易实现的方案:把 0-9、A-Z、省份汉字各做一套标准模板,每个模板归一化到 20×40,识别时算字符图和每个模板的相似度,取最高分。相似度用归一化相关系数cv2.matchTemplate的TM_CCOEFF_NORMED模式,输出在 -1 到 1 之间,越接近 1 越像。

模板从哪来?不能网上随便下,字体、粗细、间距都对不上。正确做法是从训练集里每个字符挑 5 到 10 张清晰样本,归一化后取像素中位数作为模板。中位数比平均值抗噪,个别脏样本不会污染模板。省份汉字模板最难搞,因为汉字笔画复杂,20×40 分辨率下「苏」和「浙」的差异可能只有几个像素,模板匹配对汉字的准确率通常只有 70% 左右,这是这套方案的天然短板。

def match_char(char_img, templates): best_label, best_score = None, -1 for label, tmpl in templates.items(): # 两者尺寸必须一致 res = cv2.matchTemplate(char_img, tmpl, cv2.TM_CCOEFF_NORMED) score = res[0][0] if score > best_score: best_score = score best_label = label return best_label, best_score

逻辑说明:TM_CCOEFF_NORMED对亮度线性变化不敏感,适合车牌这种光照不均的场景。res[0][0]是因为模板和字符图尺寸相同,匹配结果只有一个值。如果分数低于 0.5,建议标记为「不确定」,在输出里用?占位,比强行给一个错字符更诚实。

4.2 用 HOG + SVM 提升字母数字识别率

模板匹配对字母数字的准确率大概 85% 到 90%,想上 95 分,字母数字部分换成 HOG + SVM。HOG(方向梯度直方图)提取字符的轮廓方向特征,对笔画粗细和轻微形变不敏感,SVM 在小样本上表现稳定。OpenCV 自带HOGDescriptor,设置winSize=(20,40)、blockSize=(10,20)、cellSize=(5,10)、nbins=9,一张字符图输出 144 维特征。

训练数据从哪来?如果没有现成数据集,可以用程序生成:选几种常见车牌字体,随机加噪声、旋转 ±5 度、调整亮度,每个字符生成 200 张,7 个字符类别(数字 + 大写字母)共 36 类,7200 张样本,SVM 训练几分钟就能收敛。省份汉字建议还是用模板匹配,因为汉字类别多、样本难生成,SVM 优势不明显。

from sklearn.svm import SVC import cv2 def extract_hog(char_img): hog = cv2.HOGDescriptor((20, 40), (10, 20), (5, 10), (5, 10), 9) return hog.compute(char_img).flatten() # 假设 X_train 是特征列表,y_train 是标签 clf = SVC(kernel='rbf', C=10, gamma='scale', probability=True) clf.fit(X_train, y_train) def predict_char(char_img, clf): feat = extract_hog(char_img).reshape(1, -1) return clf.predict(feat)[0]

参数说明:C=10是惩罚系数,越大越容易过拟合,字符识别这种小特征场景 10 到 100 都合理。gamma='scale'让 sklearn 自动按特征方差缩放,比手动调省事。probability=True会慢一些,但能拿到置信度,方便对低置信结果做二次判断。HOG 的cellSize=(5,10)对应字符的笔画宽度,太小会丢结构,太大丢细节。

4.3 端到端流程串起来:从图片到车牌字符串

把前面所有步骤串成一个函数,输入图片路径,输出识别结果和中间可视化图。流程是:预处理 → 定位 → 颜色校验 → 矫正 → 二值化 → 投影分割 → 逐字符识别 → 拼接。中间任何一步失败都要有降级策略,比如定位不到车牌就返回空字符串并打印日志,而不是抛异常让程序崩掉。

def recognize_plate(img_path, svm_clf, templates): img, gray, binary = preprocess(img_path) img_area = img.shape[0] * img.shape[1] candidates, _ = locate_plate(binary, img_area) if not candidates: return "", None # 按面积排序,取最大的候选 candidates.sort(key=lambda r: r[2] * r[3], reverse=True) plate_roi = deskew_plate(img, candidates[0]) gray_plate = cv2.cvtColor(plate_roi, cv2.COLOR_BGR2GRAY) _, bin_plate = cv2.threshold(gray_plate, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) chars = split_characters(bin_plate) result = [] for i, ch in enumerate(chars): if i == 0: label, _ = match_char(ch, templates) # 汉字用模板 else: label = predict_char(ch, svm_clf) # 字母数字用 SVM result.append(label) return "".join(result), plate_roi

逻辑说明:候选按面积降序取第一个,因为车牌通常是图中最大的符合形状的矩形。汉字和字母数字分开处理是这套方案的关键设计,汉字走模板、字母数字走 SVM,各取所长。返回plate_roi是为了可视化,答辩时能展示「原图 → 定位框 → 矫正图 → 分割字符」的完整链路,比只报一个字符串有说服力得多。

5. 避坑与排查:那些让识别率一夜回到解放前的问题

5.1 现象:白天能识别,晚上全错

原因:夜间车灯造成局部过曝,Sobel 边缘图里车牌区域被高亮淹没,二值化后车牌和背景连成一片,轮廓筛选直接失效。解决:在预处理阶段加自适应直方图均衡化(CLAHE),clipLimit=2.0、tileGridSize=(8,8),把局部对比度拉回来。如果还不行,对过曝区域先做 gamma 校正,gamma 取 0.6 到 0.8 压暗高光。

5.2 现象:车牌定位框比实际车牌大一圈

原因:形态学闭运算的核太大,把车牌周围的保险杠、车标一起连进来了。解决:把(17,5)降到(13,3),同时把rectness阈值从 0.6 提到 0.7。如果车牌本身有倾斜,闭运算前先做一次小角度矫正,否则矩形核和倾斜车牌不匹配,连接效果差。

5.3 现象:字符分割多切或少切

原因:投影阈值0.15不适应具体图片。对比度低的图片,字符投影值整体偏低,阈值相对偏高,字符被切碎;对比度高的图片,阈值偏低,字符间隙被忽略。解决:不要用固定系数,改成threshold = max_val * 0.15之后再和col_sum.mean()取较小值,让阈值随图片自适应。另外分割后加一步校验:如果切出的字符数不是 7 个(新能源 8 个),按宽度重新合并或拆分。

5.4 现象:汉字识别率极低,字母数字正常

原因:汉字模板分辨率不够,20×40 下「湘」和「鄂」的左偏旁几乎一样。解决:汉字单独用 40×80 的模板,匹配时把字符图也放大到 40×80。或者干脆放弃汉字识别,输出时用*代替省份,把精力放在字母数字上,毕竟大作业评分里字母数字占大头。

5.5 现象:程序跑几张图就内存暴涨

原因:cv2.findContours返回的轮廓没有及时释放,或者循环里反复创建 HOG 对象。解决:HOG 对象在循环外创建一次,findContours的结果用完置None。另外warpAffine的输出尺寸如果设得过大(比如按原图尺寸),每张图都会多占几 MB,改成按车牌实际尺寸加边距。

6. 把 95 分变成可复现的工程习惯

想让这套车牌识别系统在答辩时站得住,光跑通不够,得让每个环节可验证、可调参、可解释。我的习惯是给每个阶段存一张中间图:01_gray.jpg、02_sobel.jpg、03_closed.jpg、04_plate.jpg、05_chars/,答辩时按顺序放出来,评委一眼就能看出你懂每一步在干什么,而不是调了个黑匣子。

参数调优上,别凭感觉改。做一个简单的网格搜索:把高斯核在(3,3)、(5,5)、(7,7)里遍历,Sobel 阈值在 80 到 160 里按 20 步进,闭运算核在(13,3)、(17,5)、(21,5)里遍历,用 20 张测试图统计端到端准确率,选最高的一组。这个过程跑一遍大概十几分钟,但能让你在答辩时说出「为什么选 120 而不是 100」这种细节,分数差距就在这。

# 简易参数网格搜索框架 best_acc, best_params = 0, {} for ksize in [(3,3), (5,5), (7,7)]: for thresh in range(80, 161, 20): for morph in [(13,3), (17,5), (21,5)]: acc = evaluate(test_images, ksize, thresh, morph) if acc > best_acc: best_acc = acc best_params = {'ksize': ksize, 'thresh': thresh, 'morph': morph} print(f"最佳参数: {best_params}, 准确率: {best_acc:.2%}")

最后说一个我踩过的坑:不要为了追求单张图的完美效果去调参,那样一定过拟合。用一批图(至少 20 张,涵盖白天、夜晚、倾斜、模糊)统计整体准确率,哪怕单张图看起来没那么「干净」,整体稳才是真的稳。这套传统图像处理方案的上限大概在 92% 到 96% 之间,想再往上就得引入深度学习检测和 CRNN 识别,但那是另一个话题了。把传统链路吃透,数字图像处理这门课的核心考点——滤波、边缘、形态学、投影、特征提取——就全串起来了,这比单纯刷分有价值得多。希望帮到你。

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

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

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

立即咨询