ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于OpenCV的车牌识别课程作业:HSV定位到字符分割与模板匹配

基于OpenCV的车牌识别课程作业:HSV定位到字符分割与模板匹配 简介一份基于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 三个通道高度相关蓝色分量大的区域实在太多了。正确的做法是把图片从 BGROpenCV 默认读图格式转到 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很多人在这一步踩坑——在别的工具里H220是蓝色到 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:yh, x:xw]筛选之后对候选矩形使用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, connectivity8) 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, connectivity8) 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(keylambda 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, encodingutf-8) as f: json.dump(report, f, ensure_asciiFalse, indent2)把测试图片的文件名命名为“真实车牌号_序号.jpg”脚本就能自动统计识别正确数。测试集建议准备 50 到 100 张包含晴天、阴天、不同角度、远近景。报告里的failed_locate和failed_split两项非常重要它们能帮你快速找瓶颈如果定位失败占大头问题在 HSV 阈值或轮廓过滤如果分割异常占大头问题在字符分离如果定位分割都没问题但识别错那才是模板匹配需要优化。按这个顺序排错比盲目调参高效得多。5. 课程作业最容易翻车的 4 个点定位、分割、识别、环境排查这一章集中写我见过最多、自己做时也踩过的问题。每一条都按“现象 → 原因 → 解决”来梳理排查车时优先看这里。5.1 蓝色阈值一调就失效光照一变就找不到车牌现象代码在白天正常换了一张傍晚或阴天的照片inRange找出来的掩膜全是黑的车牌直接被过滤掉。原因HSV 中的 S饱和度和 V亮度对光照极其敏感。光线弱时蓝色车牌的 S 值会掉到 60 以下你的下界是 90直接把有效像素滤没了。这就是为什么阈值参数的“玄学”感这么强——它不是某一个值错了而是下界写得太死。解决把 S 下界放宽到 50V 下界放宽到 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-python4.5,4.9 numpy1.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不仅准确率更高答辩时可用“我用了机器学习方法改进识别环节”作为亮点。但注意课程作业的评分核心永远是完整链路加清晰表达炫技不如稳。我在做这个项目的最大教训是二值化阈值选错后面的所有模块全部失效反复在分割上找问题浪费了很多时间。后来学乖了每一张图都保存中间结果按“定位 → 分割 → 识别”三步逐一检查输出。检查中间结果的习惯比任何调参技巧都值钱。希望这些经验能帮你在同样的地方少浪费几个晚上希望帮到你。本文还有配套的精品资源点击获取
返回列表