简介:一份基于Python3和OpenCV的数字图像处理课程作业车牌识别项目资料包,面向需要完成课程设计、大作业或入门图像处理的学习者,既可作为毕设/工程实训的初始框架,也适合有基础者二次改造。资源共19个文件,以Python源码、车牌样本图片、SVM数据模型和答辩PPT为主:py文件覆盖图像预处理、车牌定位、字符分割与识别逻辑,jpg/png图片保存车牌样本与中间处理结果,dat为训练好的SVM模型,pptx为验收汇报材料,zip压缩包仅4.74MB,目录划分清晰。项目用Tkinter搭建可视化界面,并单独拆分tmp(处理过程)、chepai(车牌图)、pic(界面截图)等目录,方便逐段查看数字图像处理流程。作者注明当前版本识别率有限,因此更适合用来理解完整流程、积累调试经验,而不是直接交付;随包还附有README和SVM模型文件,可辅助快速搭建运行环境。已有219人学习,对想低成本入门车牌识别课程设计的人来说是个不错的起点。
1. 车牌识别课程作业:为什么它是数字图像处理最经典的练手题
每到学期末,数字图像处理课程里总会冒出一批“车牌识别”方向的课程作业。原因很简单:它把课本里的灰度变换、噪声抑制、边缘检测、阈值分割、形态学、投影法、模板匹配这些章节全部串成了一条完整链路,而且每一步都有肉眼可见的中间结果——你可以看着一张模糊的车辆照片,逐步变成黑白掩膜、变成车牌矩形、变成七个字符、最后变成一串车牌号码。这个“黑匣子被逐层打开”的过程,正是课程作业最想要你展示的东西。
本文要讲的,是基于 Python 和 OpenCV 实现一套车牌识别课程作业的完整落地路径,包含源码组织和答辩 PPT 的讲法。它不追求学术顶会级别的精度,目标是:在一百张测试图上达到八成以上正确识别率,在答辩现场能讲清楚每一步为什么这么做、参数为什么这么设,并且面对老师随机拷问时不翻车。适合正在做课程设计的本科生,也适合想快速上手 OpenCV 图像处理项目的从业者。
2. 车牌定位:用 HSV 颜色空间把蓝色车牌从背景里抠出来
车牌识别第一步是定位,也就是在一整张车辆照片里找到车牌所在的矩形区域。这一步做不好,后面全是空中楼阁。常见做法有两种:一种是基于边缘检测和形态学,另一种是基于颜色空间。我这里推荐颜色法,因为它对场景变化更鲁棒,而且直接对应数字图像处理教材里“色彩模型”那一章的内容,答辩时特别好讲。
2.1 预处理链路先定下来:为什么读取后马上转 HSV
很多人一开始会直接用 RGB 图片做处理,然后发现蓝色车身、蓝色广告牌全部被误检成车牌。这是因为 RGB 三个通道高度相关,蓝色分量大的区域实在太多了。正确的做法是把图片从 BGR(OpenCV 默认读图格式)转到 HSV 色彩空间,把“颜色”和“亮度”解耦。代码非常简单:
import cv2 import numpy as np img = cv2.imread("car.jpg") # 注意:OpenCV 读进来是 BGR,不是 RGB hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) lower_blue = np.array([100, 90, 90]) # H: 色相,S: 饱和度,V: 亮度 upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) cv2.imwrite("mask_raw.jpg", mask)这段代码的逻辑是:先定义一个蓝色范围的上下界,然后用inRange生成一张二值掩膜,像素落在区间内的是白色(前景),落在区间外的是黑色(背景)。这里必须声明一个参数经验:OpenCV 的 HSV 中 H 范围是 0 到 180,不是 0 到 360,很多人在这一步踩坑——在别的工具里H=220是蓝色,到 OpenCV 里直接变成了绿色。蓝色车牌的 H 集中在大约 100 到 124 的区间,但不同光照条件下 S 和 V 浮动很大,阴天、夜间车牌发灰,S 会掉到 60 以下,所以阈值不要卡太紧。上界和下界要留出冗余。
2.2 形态学闭运算:把断裂的车牌区域补成完整矩形
inRange输出来的掩膜通常不完美。车牌上的白色字符会把蓝色区域切开,变成一段一段的,如果不处理,后续找轮廓会找到一堆碎片而不是一个整体。解决手段是形态学闭运算,也就是先膨胀再腐蚀,它可以填平小孔、连接相邻区域。车牌是横向长条,所以卷积核设计成横向长方形更有效。
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 再做一个开运算去掉零星噪点 kernel_small = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) clean = cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel_small)闭运算的核宽度 17 不是随手写的。它对应的是“在二值图上,同一行像素里最大能容忍的多大断裂”。如果核太小,字符造成的断裂补不上;如果核太大,会把车标、车灯连带进来。做这个项目的经验是:核宽度取车牌预期宽度的 1/20 左右,比如图像里车牌横向约 340 像素,那就用 17。核高度 5 则是为了不让垂直方向上其他蓝色物体被融进来。这个参数直接决定后面轮廓筛选的成败,答辩时如果老师问“为什么核是 17 不是 10”,你可以用这个逻辑回答。
2.3 轮廓筛选与透视矫正:宽高比是核心过滤器
形态学做完后,用findContours找到所有连通区域,然后按几何特征过滤。标准民用车蓝牌宽高比是 440:140,约等于 3.14,但实际拍摄中因为透视、裁剪,这个比例会在 2.0 到 5.5 之间浮动。面积也需要限制,太小的区域是噪点,太大的区域可能是车身喷漆。筛完轮廓后,最好再对候选区做一次透视矫正,以应对倾斜拍照的情况。
contours, _ = cv2.findContours(clean, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) ratio = w / h area = w * h if ratio < 2.0 or ratio > 6.0: # 宽高比过滤 continue if area < 2000 or area > img.shape[0] * img.shape[1] * 0.3: # 面积过滤 continue plate_candidate = img[y:y+h, x:x+w]筛选之后,对候选矩形使用minAreaRect计算旋转角度,再通过仿射变换把车牌摆正。这里有一个非常经典的坑:cv2.minAreaRect返回的角度在 OpenCV 4.x 中范围是 [-90, 0),如果角度小于 -45 度,宽高需要交换才能当成真正的宽和高的语义来用。很多人在这一步得到“竖着的车牌”或者角度矫正反了,基本都是这个原因。透视矫正的完整代码如下:
rect = cv2.minAreaRect(cnt) box = cv2.boxPoints(rect) box = np.int0(box) angle = rect[2] if angle < -45: angle = -(90 + angle) else: angle = -angle (h, w) = plate_candidate.shape[:2] center = (w // 2, h // 2) M = cv2.getRotationMatrix2D(center, angle, 1.0) rotated = cv2.warpAffine(plate_candidate, M, (w, h))这段代码就是把矩形旋转回水平位置。注意rect[2]返回的是矩形边与 x 轴的夹角,区分长边和短边后需要做一次角度映射,否则小角度倾斜的图片会被过度旋转。到这里,定位部分已经完成了,接下来拿到的是一个干净、水平、只包含车牌的矩形图像。
3. 字符分割与识别:连通域切分、模板匹配判读
车牌定位做完,问题就从“找到车牌在哪”变成“车牌上写了什么”。这一步分两段:字符分割把七个字符一个个切出来,字符识别把它们一个个读出来。课程作业级别的实现,我建议用连通域分析加模板匹配,这套方案原理直观、代码量小、答辩好解释。它不追求 SOTA,但能稳定处理大多数正面拍摄的车牌。
3.1 字符分割第一步:先去掉边框和铆钉,不处理干净分割必崩
车牌图像里除了字符还有边框、铆钉、间隔符,它们都会干扰分割。先做灰度化和二值化,然后用轮廓分析把贴边的线状轮廓删除。车牌字符是白色,二值化时用大津法会自动找到合适阈值,但要注意车牌区如果有阴影,最好把二值化限制在车牌局部区域,而不是整张图。
def preprocess_plate(plate_img): gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) # 高斯模糊去掉细小噪点,但核不能太大,否则字符边缘被抹掉 blurred = cv2.GaussianBlur(gray, (3, 3), 0) _, binary = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 按连通域删除贴近图像边缘的线状轮廓(边框) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) h, w = binary.shape for i in range(1, num_labels): x, y, cw, ch, area = stats[i] if area < 50: binary[labels == i] = 0 # 边框通常紧贴图像边界,且宽或高接近整个图像尺寸 if y <= 2 or y + ch >= h - 2 or x <= 2 or x + cw >= w - 2: if cw > w * 0.5 or ch > h * 0.5: binary[labels == i] = 0这段代码做了两件事:先去掉面积小于 50 像素的小噪点,再去掉紧贴边框的线状区域。铆钉是车牌四个角上的金属圆点,面积小、位置固定,通常会在后面连通域过滤时被面积条件滤掉。如果你的测试集里铆钉特别明显,可以单独按“贴近四角”的位置条件删除。
3.2 连通域切字符:先按列聚合、再用宽高比过滤
二值化干净后,用connectedComponentsWithStats找到所有字符块。字符块有自然的宽高比规律:字符宽度大约在车牌宽度的 8% 到 15% 之间,高度大约占车牌高度的 60% 到 90%。但中文字符是一个大坑,它的笔画经常断开,比如“京”字会被分成上下两部分甚至更多。如果不做聚合,一个汉字会被切成四五个碎片。
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) components = [] for i in range(1, num_labels): x, y, w, h, area = stats[i] if h < binary.shape[0] * 0.4: # 高度低于车牌 40% 的直接丢弃 continue if w > binary.shape[1] * 0.3: # 宽度超过车牌 30%,大概率是边框残留 continue components.append((x, y, w, h, area)) # 按 x 坐标排序 components.sort(key=lambda item: item[0])过滤完之后,字符已经按 x 坐标排好序。汉字的碎片需要按水平距离聚合:如果两个连通域在 x 方向上的中心距离小于当前字符平均宽度的 0.5 倍,就认为属于同一个字符,合并它们的边界框。这个聚合策略非常实用。具体实现是在排好序的列表上做一遍扫描,遇到相邻元素距离过近就合并,然后重置扫描位置。
def merge_components(components): merged = [] for comp in components: if not merged: merged.append(comp) else: px, py, pw, ph, pa = merged[-1] cx = comp[0] gap = cx - (px + pw) # 间隙小于前一个字符宽度的 0.5 倍就合并 if gap < pw * 0.5: nx = px ny = min(py, comp[1]) nw = max(px + pw, comp[0] + comp[2]) - nx nh = max(py + ph, comp[1] + comp[3]) - ny merged[-1] = (nx, ny, nw, nh, pa + comp[4]) else: merged.append(comp) return merged合并之后,字符区还会有间隔符(小圆点)和可能残留的干扰。间隔符的特点是宽度极小、面积小,通常只在“省份简称后面的位置”出现,按面积过滤掉即可。最终保留下来的应该是 7 个左右的连通域,第 1 个是汉字,第 2 个是字母,第 3 到第 7 个是字母和数字混合。如果切出来是 8 个或 9 个,说明有噪声没清干净;如果只有 5 个或 6 个,说明两个字符合并到了一起,这时候可以尝试用垂直投影重新切割。
3.3 模板匹配识别字符:归一化、匹配方法和模板集构建
字符识别在课程作业层面最稳定的方案是模板匹配。做法是准备一套标准字符图片作为模板,把待识别的字符和每个模板都做一次匹配,取相似度最高的那个。这一步有三个关键点:归一化方式、匹配方法、模板集来源。
def recognize_char(char_img, templates): # 统一尺寸到 20x40 resized = cv2.resize(char_img, (20, 40)) # 归一化到 0-255,避免亮暗差异影响匹配分数 normalized = cv2.normalize(resized, None, 0, 255, cv2.NORM_MINMAX) best_score = -1.0 best_name = None for name, tpl_img in templates.items(): # 模板也要是同样的 20x40 且归一化过 result = cv2.matchTemplate(normalized, tpl_img, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ = cv2.minMaxLoc(result) if max_val > best_score: best_score = max_val best_name = name return best_name, best_score匹配方法推荐用TM_CCOEFF_NORMED,它是归一化相关系数,对亮度变化不敏感,是最适合这个场景的指标。TM_SQDIFF是差的平方,值越小越好,需要额外处理取反逻辑,初学者容易搞混方向;TM_CCORR_NORMED对亮度变化敏感,两张图一张亮一张暗也会给出高分,在这个场景下不好用。模板集的来源一般有两种:一种是从开源车牌字符图片库里切出来,另一种是拿你已经正确分割的字符自己标注几十张。我建议后者,因为你自己的模板和你的测试集在字体、形态上保持一致,匹配分数会稳定不少。模板不要求多,每个字符 3 到 5 个变体足够,多了反而引入干扰。全部识别完成后,把字符按顺序拼接成字符串,就是最终输出的车牌号。
4. 把流程串成可答辩的完整程序:环境、主循环与效果验证
前面的章节把定位、分割、识别分别讲清楚了,但课程作业交付的不只是算法片段,而是一套能跑的完整程序。这一章讲环境配置、代码组织方式和效果评估方法,这些都是答辩时老师不会写在评分标准里、但一定会看的东西。
4.1 环境配置:Python 解释器与 OpenCV 安装的常见报错
很多同学在这一步就翻车,最常见的现象是在 Anaconda Prompt 里执行pip install opencv-python后,进入 Jupyter 仍然报ModuleNotFoundError: No module named 'cv2'。原因几乎总是装错了环境:pip 命令指向的是 base 环境,而你的 Jupyter 内核用的是另一个 conda 环境。解决方法是统一用python -m pip来装。
conda activate your_env_name python -m pip install opencv-python numpy matplotlib python -c "import cv2; print(cv2.__version__)"用python -m pip而不是裸pip,可以保证装进当前激活的解释器。版本方面,建议 Python 3.8 到 3.10 配 OpenCV 4.5 以上,兼容性很好。Python 3.12 刚发布时 OpenCV 官方轮子滞后,直接pip install会去编源码,容易失败。如果你用的是最新 Python 还装不上,最简单的办法是降级到 3.10,不要在装环境上浪费一晚上。
4.2 主流程代码:从读图到输出车牌号一次跑完
源码组织建议拆成三个模块加一个主入口,答辩时说“我的代码按功能分层”会显得有条理。plate_locate.py放定位函数,char_split.py放分割函数,char_recognize.py放识别函数,main.py只做流程串联。
# main.py import cv2 from plate_locate import locate_plate from char_split import split_chars from char_recognize import recognize_chars def recognize_plate(img_path): img = cv2.imread(img_path) if img is None: raise FileNotFoundError(f"无法读取图片: {img_path}") plate = locate_plate(img) # 返回矫正后的车牌图 if plate is None: return None, "定位失败" chars = split_chars(plate) # 返回字符图片列表 if len(chars) != 7: return None, f"分割异常,得到 {len(chars)} 个字符" result = recognize_chars(chars) # 返回字符串 return result, "成功" if __name__ == "__main__": plate_number, status = recognize_plate("test_01.jpg") print(f"识别结果: {plate_number} | 状态: {status}")主循环的逻辑很简单:读图、定位、分割、识别,任何一步失败都返回失败原因,而不是让程序崩溃。定位失败返回None,分割异常提示位数不对,这些信息在批量跑测试集时非常重要,能直接告诉你瓶颈在哪。另外一个细节是len(chars) != 7的判断:新能源车牌是 8 位,如果你只做蓝牌,7 位判断是对的;如果测试集混入新能源绿牌,需要提前说明适用范围,或者在代码里放宽位数判断。
4.3 效果验证:用测试集估算准确率,而不是只跑一张图
课程作业答辩时,老师最喜欢问的一句话是:“你的准确率是多少?”如果你只跑了一张图就回答“效果很好”,基本会被追问到无法招架。正确做法是准备一个测试集,批量运行并统计多次结果。
import os import time import json test_dir = "test_images" results = [] correct = 0 total = 0 failed_locate = 0 failed_split = 0 start = time.time() for fname in sorted(os.listdir(test_dir)): if not fname.endswith(".jpg"): continue # 文件名里带真实车牌号,例如 "鲁A5B678_001.jpg" true_label = fname.split("_")[0] pred, status = recognize_plate(os.path.join(test_dir, fname)) total += 1 if status == "定位失败": failed_locate += 1 elif status.startswith("分割异常"): failed_split += 1 elif pred == true_label: correct += 1 results.append({"image": fname, "true": true_label, "pred": pred, "status": status}) elapsed = time.time() - start report = { "total": total, "correct": correct, "accuracy": correct / total if total else 0, "failed_locate": failed_locate, "failed_split": failed_split, "elapsed_seconds": round(elapsed, 2), } with open("result_report.json", "w", encoding="utf-8") as f: json.dump(report, f, ensure_ascii=False, indent=2)把测试图片的文件名命名为“真实车牌号_序号.jpg”,脚本就能自动统计识别正确数。测试集建议准备 50 到 100 张,包含晴天、阴天、不同角度、远近景。报告里的failed_locate和failed_split两项非常重要,它们能帮你快速找瓶颈:如果定位失败占大头,问题在 HSV 阈值或轮廓过滤;如果分割异常占大头,问题在字符分离;如果定位分割都没问题但识别错,那才是模板匹配需要优化。按这个顺序排错,比盲目调参高效得多。
5. 课程作业最容易翻车的 4 个点:定位、分割、识别、环境排查
这一章集中写我见过最多、自己做时也踩过的问题。每一条都按“现象 → 原因 → 解决”来梳理,排查车时优先看这里。
5.1 蓝色阈值一调就失效,光照一变就找不到车牌
现象:代码在白天正常,换了一张傍晚或阴天的照片,inRange找出来的掩膜全是黑的,车牌直接被过滤掉。
原因:HSV 中的 S(饱和度)和 V(亮度)对光照极其敏感。光线弱时,蓝色车牌的 S 值会掉到 60 以下,你的下界是 90,直接把有效像素滤没了。这就是为什么阈值参数的“玄学”感这么强——它不是某一个值错了,而是下界写得太死。
解决:把 S 下界放宽到 50,V 下界放宽到 40;或者把 H 范围放宽到 95 到 130。另一个有效手段是写一个参数扫描脚本,把 H、S、V 的上下界按步长遍历,对比同一张图在不同参数下的掩膜效果。花 20 分钟跑一遍参数扫描,比凭感觉试一晚上准得多。参数扫描脚本很值得写进答辩材料,它本身就是“你认真调过参”的证据。
5.2 汉字分割断开、“川”和“京”被切成多块
现象:字符分割结果远多于 7 个,比如“京A12345”切出了 11 块,汉字区域被拆成三四块。
原因:中文字符笔画结构复杂,连通域分析会把 “京” 的上中下结构切成多个独立区域。开运算、腐蚀操作会加剧这种断裂。
解决:在排序后加入一次基于间隙的聚合并循环处理。合并阈值是“间隙小于前一个字符宽度的 0.5 倍就合并”。如果汉字笔画断开距离较大,可以先把阈值放宽到 0.8 倍,再通过总数检查回退调整。还要排查一个细节:二值化后字符是否被高斯模糊的核抹掉了笔画,如果GaussianBlur的核超过 (3, 3),汉字细笔画很容易断。另外,字符上的噪点会加重断裂,可以先做一个 3x3 中值滤波替代高斯模糊。
5.3 模板匹配分数飘忽不定,同一字符有时对有时错
现象:识别结果不稳定,同一张图两次运行结果不一样,或者某个字符的匹配分数全部低于 0.5,只能瞎猜一个。
原因:模板和待识别字符的归一化方式不一致。最常见的是字符被resize到 20x40,但模板是 40x20;或者待识别字符已经做了归一化,模板没做。另一个原因是matchTemplate对边缘噪声敏感,字符边缘毛刺会让分数下降。
解决:写一个normalize_char函数,统一处理模板和输入字符,保证尺寸、归一化方法完全一致。匹配方法固定用TM_CCOEFF_NORMED,不要在同一流程里混合使用多种匹配方法。还有一个小技巧:对字符做 1 像素的内缩边距,去掉边缘噪声的影响,用cv2.copyMakeBorder在字符周围填充一圈黑色,让字符不贴边,匹配分数会明显更稳定。
5.4 环境问题:OpenCV 装不上、找不到 cv2、findContours 解包报错
现象:ModuleNotFoundError: No module named 'cv2'、findContours赋值报错说“not enough values to unpack”等。
原因:前一个报错绝大多数是环境错乱,pip 装到了别的解释器。后一个报错是 OpenCV 版本差异:OpenCV 3 之前的findContours返回 3 个值,OpenCV 3 及以后返回 2 个值,网上老教程的示例代码直接复制就会报错。
解决:统一按新版语法处理:
contours, hierarchy = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)写代码时不要盲目复制博客老代码,先确认对方的 OpenCV 版本。环境方面,建议写一个requirements.txt固定版本:
opencv-python>=4.5,<4.9 numpy>=1.21,<1.27用python -m pip install -r requirements.txt一次性装齐。如果虚拟环境已经被搞乱了,最省事的办法是删掉重建,不要在坏环境里反复折腾。
6. 答辩 PPT 怎么讲、往哪个方向优化容易拿分
前面的工作占七成,答辩展示占三成。PPT 不需要花哨,但逻辑要清晰。建议控制在 10 到 12 页:背景与意义 1 页、总体方案 1 页、预处理与车牌定位 2 页、字符分割 2 页、字符识别 2 页、实验展示 2 页、总结与展望 1 页。每一页只放一到两张中间结果图加一小段说明,拒绝大段文字。答辩现场演示时,先跑一张正面清晰图证明流程能跑通,再跑一张倾斜或光照差的图体现鲁棒性。第五章提到的参数扫描对比图放进去很加分,它展示了你是如何确定阈值的。
更关键的其实是答辩时怎么讲。定位部分,老师大概率会问“为什么用 HSV 而不是 RGB”,你要回答颜色和亮度的耦合关系。分割部分,问的是汉字断裂问题,你要能说出聚合策略和阈值依据。识别部分,问模板匹配的局限性,你要大方承认它对字体变化敏感,然后补一句“如果换成本项目范围之外的应用场景,可以用 HOG 特征加 SVM 或轻量 CNN 替代”。这套回答逻辑既覆盖了原理,又展示了“我能认识到当前方案的边界”。
如果还有余力做两个优化,优先级如下:第一,加入透视矫正的四边形变换,把侧面拍摄的车牌摆正,这一步对倾斜场景的准确率提升最大;第二,把字符识别中的模板匹配换成 HOG 加 SVM,不仅准确率更高,答辩时可用“我用了机器学习方法改进识别环节”作为亮点。但注意,课程作业的评分核心永远是完整链路加清晰表达,炫技不如稳。
我在做这个项目的最大教训是:二值化阈值选错,后面的所有模块全部失效,反复在分割上找问题浪费了很多时间。后来学乖了,每一张图都保存中间结果,按“定位 → 分割 → 识别”三步逐一检查输出。检查中间结果的习惯,比任何调参技巧都值钱。希望这些经验能帮你在同样的地方少浪费几个晚上,希望帮到你。
本文还有配套的精品资源,点击获取