
简介基于python-opencv打造的车牌识别系统面向计算机视觉初学者与开发者涵盖静态图片识别、GUI界面操作、识别结果导出Excel并加入opencv摄像头实时识别能力大幅提升识别效率与准确率适合车辆管理、智慧交通等场景的入门与二次开发。压缩包共16468个文件以16415张车牌样本图片为主用于训练与测试另有6个Python源码文件、9个XML配置、2个dat模型文件等代码与数据分离、目录清晰便于按模块研读。目前已有1843人学习下载。资源内包含完整项目源码、训练好的SVM模型以及大量真实车牌图像可直接运行体验也可基于GUI框架扩展功能。作者还优化了原识别模块函数识别速度更优对希望深入理解车牌定位、字符分割与识别流程的开发者有较高参考价值。1. 基于 Python 和 OpenCV 的车牌识别系统从一张车辆照片到结构化车牌号的完整链路某个下午某测试场地的摄像头对准进场车道一辆车缓缓驶入画面程序在不到一秒内完成了整条链路像素读取、车牌区域定位、字符切分、逐个识别最终把蓝底白字的物理车牌变成一行可以直接入库的文本。这就是一个典型的基于 Python 与 OpenCV 的车牌识别系统。这套源码包正是为了让这类方案可复现而存在不用 GPU不用大规模标注数据普通 CPU 就能跑起来。适合准备入门计算机视觉方向的开发者、需要完成毕设或课程设计的学生、以及想在边缘设备上落地识别能力的小团队。它把「车牌识别」这个大词拆成图像预处理、车牌定位、字符分割、字符识别四个步骤接下来我从模块架构讲起再带你把代码逐段读明白最后给出一份可以直接执行的本地运行和改造指南。2. 整体架构与技术选型一套不用 GPU 的三段式识别管线2.1 三段式流程定位、分割、识别各管一段整个系统的核心流程可以概括为三个串行阶段。第一个阶段是车牌定位输入是摄像头抓拍的整张图输出是一块只含车牌区域的候选小图第二个阶段是字符分割把车牌区域中的每个字符单独裁出来输出一张张单字符图片第三个阶段是字符识别对每个字符图片判断它是什么最终拼成一个完整的车牌字符串。三个模块之间通过图片这个中间产物解耦这意味着任何一段都可以单独替换。比如你不想用模板匹配做识别换成 SVM 或者卷积网络只需要改第三个模块的对外接口前两个模块完全不碰也可以。定位模块输出的是一张裁剪后的图像分割模块输出图片列表识别模块接收图片列表返回字符串接口边界非常干净。从流程结构上看输入分辨率通常被限制在 1280 720 以内定位后再将候选区域缩放到固定尺寸保证后续处理的耗时稳定。整条链路的执行时间通常落在 0.2 到 0.5 秒之间具体取决于图像大小和模板库的规模。相比两阶段的深度学习检测加模型识别方案CPU 负载低了一个量级这让它在树莓派、工控机这类设备上也能流畅跑起来。图中的调用关系是严格单向的main.py依次调用定位、分割、识别三个模块模块之间不互相依赖。想做单元测试的话可以把中间产物落盘分别验证每一段的效果这种设计在调试时非常舒服。我自己的习惯是先跑定位看候选框是否框准再跑分割看字符是否切得干净最后才看识别结果这样能快速定位是哪一段拖了后腿。2.2 技术选型为什么选 OpenCV 经典管线而不是深度学习拿到项目第一时间会产生疑问现在车牌识别不是都流行深度学习吗为什么还要用 OpenCV 慢慢算这个选择背后是对场景的判断。车牌识别有很强的先验知识车牌有固定的长宽比国内常见蓝牌宽高比大约在 3 比 1 左右有固定的颜色特征蓝底白字、黄底黑字、绿底黑字字符排列规律也非常固定第一位是省份简称第二位是发牌机关字母后面是数字和字母的混合。利用这些先验传统图像处理就能快速把候选区域筛出来不需要任何人工标注数据。而端到端深度学习的路线是先训练一个目标检测器检测车牌区域再训练一个识别网络做字符分类全流程需要采集大量车辆图片、标注边界框、训练两个模型。这套基建成本对于固定机位的单机小场景并不划算。很多实际场景其实是摄像头固定在某个位置拍摄角度、距离、光照相对可控传统方法在这种受限环境下反而比通用大模型更稳也不会出现 CPU 跑不动的尴尬。两者的对比可以简单梳理一下。传统方法的优势是部署轻、推理快、可解释性强哪一步错了可以直接看中间图像劣势是对极端光照、大倾角、模糊图像的鲁棒性不如深度学习模型。深度学习模型在复杂场景下的上限更高但需要数据、算力和标注资源。回到这套车牌识别系统本身它把「可控环境 先验知识」这条路走到了极致在没有标注数据的条件下是最务实的起点。如果你想在跑通之后进一步提升鲁棒性再把识别段替换成 CNN 模型本文最后一章会专门讲这个改造思路。2.3 源码目录与文件职责拿到压缩包后先看什么拿到这套源码包以后第一步不是直接运行而是先看懂目录结构。典型的使用 OpenCV 实现车牌识别的项目代码文件会按功能划分下面是一个整理后的标准目录结构解压后基本能看到类似布局plate_recognition/ ├── main.py # 入口脚本支持单张图片和批量文件夹 ├── plate_locate.py # 车牌定位模块输出候选车牌区域 ├── char_segment.py # 字符分割模块输出字符图片列表 ├── char_recognize.py # 字符识别模块模板匹配实现 ├── utils.py # 通用工具图像矫正、缩放、调试显示 ├── config.py # 可调参数集中配置 ├── templates/ # 字符模板目录按字符类别分子文件夹 ├── images/ # 测试图片目录 │ ├── test1.jpg │ └── test2.jpg └── requirements.txt # 依赖清单各文件职责表如下文件主要职责对外输出main.py参数解析、调度流水线、打印结果车牌字符串plate_locate.py颜色空间转换、二值化、候选框筛选裁剪后的车牌图char_segment.py投影分割、连通域分析、字符裁剪字符图片列表char_recognize.py模板加载、归一化、相似度比较单个字符结果utils.py透视矫正、图像缩放、调试窗口处理后的图像config.py集中管理 HSV 阈值、形态学核、宽高比范围全局参数值得强调的是config.py这个文件的分量。很多入门项目把阈值散落在各个函数里调试时得满文件搜索效率极低。这个项目把 HSV 颜色范围、形态学核大小、候选框宽高比范围、字符最小宽度统一收口到一个配置文件中改动一处即可全局生效。后面第四部分讲参数调优时所有参数的修改都围绕这个文件展开。3. 核心实现拆解定位、分割、识别三段代码级解剖3.1 车牌定位HSV 阈值、形态学与候选框过滤的组合拳车牌定位是整个系统的地基这一步失败后面全白搭。常见的实现思路是结合颜色特征和几何特征。以蓝底白字车牌为例第一步将 BGR 图像转到 HSV 色彩空间因为 HSV 对光照变化比 RGB 更鲁棒——同一个蓝色在不同的光照下RGB 数值可能差得很远但在 HSV 的 H 分量和 S 分量会保持在相对稳定的范围内。定位函数的核心实现如下import cv2 import numpy as np def locate_plate_by_color(img): hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色车牌H 分量 90~120S 分量 30 以上V 分量 40 以上 lower_blue np.array([90, 30, 40]) upper_blue np.array([120, 255, 255]) mask cv2.inRange(hsv, lower_blue, upper_blue) # 形态学闭运算把字符缝隙和背景碎块连成一个整体 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for c in contours: x, y, w, h cv2.boundingRect(c) # 过滤窄条和方块宽高比接近车牌真实比例 3:1 if 2.5 w / h 5.0 and w 60 and h 20: candidates.append((x, y, w, h)) return candidates这段代码里有三个地方值得细讲。第一是 HSV 阈值的选择蓝色车牌的 H 分量在 90 到 120 之间S 分量不能设得太高否则表面氧化变灰的车牌会被滤掉V 分量也不能太低否则大灯直射下的暗区会被丢掉。这些数值在config.py里都有对应的常量定义实际项目中按场景微调即可。第二是形态学闭运算它先膨胀后腐蚀把车牌内字符笔画造成的蓝色断裂区填平让原本破碎的蓝色区域合并成一个完整的矩形块。闭运算核的大小直接决定合并效果核太小连不上核太大容易把周围蓝色车身也并进来。第三是候选框筛选条件国内车牌宽高比约在 3 比 1代码放宽到 2.5 到 5.0 是为了容纳摄像头斜视角带来的宽度形变。定位完成之后通常还需要处理透视畸变。很多摄像头拍到的车牌因为拍摄角度是平行四边形而不是矩形直接切割字符会歪。常规做法是用cv2.getPerspectiveTransform传入原图上的四个角点和目标矩形上的四个角点算出 3×3 变换矩阵再用cv2.warpPerspective做投影映射。这套逻辑一般被封装在utils.py里调用时只需要传入候选区域的轮廓点就能得到正视角的车牌图。这里不贴完整代码但需要知道角度修正是定位到分割之间绕不开的一步跳过它会让字符分割的准确率明显下降。3.2 字符分割垂直投影与连通域把字符切干净字符分割环节的输入是一张矫正后的车牌灰度图输出是每个字符的小图。车牌字符的排列是横向的第一步应该先把上下边框和铆钉去掉再做垂直方向的分割。常用的做法是水平投影法结合垂直投影法。def segment_chars(plate_gray): # 先做二值化字符为白色背景为黑色 _, binary cv2.threshold(plate_gray, 100, 255, cv2.THRESH_BINARY) # 水平投影找出字符行的上下边界 horizontal_proj np.sum(binary, axis1) / 255 rows_with_char np.where(horizontal_proj 5)[0] h_top, h_bottom rows_with_char[0], rows_with_char[-1] plate_crop binary[h_top:h_bottom, :] # 垂直投影按列求和连续非零区间即为字符边界 vertical_proj np.sum(plate_crop, axis0) / 255 in_char False char_ranges [] start 0 for i in range(len(vertical_proj)): if vertical_proj[i] 3 and not in_char: in_char True start i elif vertical_proj[i] 3 and in_char: in_char False char_ranges.append((start, i)) return char_ranges水平投影的原理是对每一行求白色像素的总数字符行的总和远大于空白行因此可以定位出字符行的上下边界。拿到边界后把图片裁剪成只有字符行的一窄条再去掉左右边框影响垂直方向上再次做投影。对每一列求和列投影值大于阈值的连续区间就是一个字符的左右边界。这个实现有三处细节需要调。第一二值化阈值的选择设太高会把笔画较细的汉字部分切掉设太低会把背景噪声带进来一般取 100 到 150 之间配合大津算法 Otsu 自动计算会更好。第二垂直投影的判定阈值设太大容易把一个字符拆成两段比如省份汉字的内部结构复杂横竖笔画之间常有空隙设太小会把相邻字符粘成一个区间。第三车牌的第二个字符和第三个字符之间有一个小分隔点它的投影值通常较低会被阈值自然过滤掉但如果分隔点恰好和字符粘连就需要按字符最小宽度来剔除异常区间。投影法容易受噪声干扰所以很多实现还会叠加连通域分析。用cv2.connectedComponentsWithStats找到每个字符对应的连通域计算每个域的面积和宽高过滤掉过小或过扁的噪声块最后按 x 坐标排序恢复字符从左到右的顺序。两种方法互为校验投影法在字符不粘连的情况下更干脆连通域法对字符内部断笔更宽容组合使用是工程上最常见的配置。def segment_chars_by_cc(binary_img): num_labels, labels, stats, centroids cv2.connectedComponentsWithStats(binary_img) chars [] boxes [] for i in range(1, num_labels): x, y, w, h, area stats[i] if area 80 or h 10: # 滤掉噪声点 continue boxes.append((x, y, w, h)) boxes.sort(keylambda b: b[0]) # 按 x 坐标排序 for (x, y, w, h) in boxes: chars.append(binary_img[y:y h, x:x w]) return charsconnectedComponentsWithStats返回四个值labels 是每个像素的标签图stats 是各连通域的边界矩形和面积centroids 是质心坐标。代码里先用面积和高度过滤掉噪声再按 x 坐标排序确保输出顺序和车牌上的字符排列一致。实际项目中投影法和连通域法可以同时保留各自输出一版分割结果在效果不稳定时人工判断采用哪一种。3.3 字符识别模板匹配的实现、局限与优化方向字符识别在 OpenCV 里最直接的方案是模板匹配。模板库里存放着每个字符的标准样式包括省份汉字、字母和数字每个字符可能存多套变体。识别时把分割出来的字符图统一缩放逐模板计算相似度取相似度最高的模板作为识别结果。import os import cv2 import numpy as np def match_char(char_img, template_dir): char_img cv2.resize(char_img, (32, 64)) _, char_bin cv2.threshold(char_img, 100, 255, cv2.THRESH_BINARY) best_label None best_score -1 for label in os.listdir(template_dir): label_path os.path.join(template_dir, label) for template_file in os.listdir(label_path): template cv2.imread(os.path.join(label_path, template_file), 0) template cv2.resize(template, (32, 64)) _, template_bin cv2.threshold(template, 100, 255, cv2.THRESH_BINARY) diff cv2.absdiff(char_bin, template_bin) score 1.0 - np.count_nonzero(diff) / (32 * 64) if score best_score: best_score score best_label label return best_label这里的得分计算用的是逐像素差分本质上是相似像素占比。简单直观但没有任何旋转不变性和尺度鲁棒性这也是模板匹配最大的软肋字体稍有变形就会大幅掉分。字符「0」和「O」、数字「8」和「B」在模板库里如果有相似变体误判率尤其高。提高模板匹配准确率有几个明确的优化方向。第一是做笔画归一化把字符图片通过形态学膨胀统一到相近的笔画粗细减少字体差异带来的像素错位。第二是做质心对齐在匹配前计算字符的质心平移到和模板一致的位置消除整体偏移的影响。第三是采用多模板投票每个字符多存几种字体变体识别时对多个模板的得分做加权汇总而不是只取单个最大值。对一个以跑通为目标的项目来说标准模板匹配已经足够输出可用的结果数据质量正常时准确率能到可观的程度后续替换成 CNN 模型时这些优化逻辑可以作为数据预处理的基础。4. 本地运行完整指南环境准备到首次识别成功4.1 环境安装Python 虚拟环境与依赖清单项目本身是纯 CPU 方案依赖很轻。所需运行版本建议 Python 3.6 以上OpenCV 4.xnumpy 任意较新版本即可。依赖清单可以整理成requirements.txtopencv-python4.2.0 numpy1.18.0安装依赖时建议先创建虚拟环境避免和系统里其他项目的包版本互相干扰。python -m venv venv_plate # Linux/macOS 激活 source venv_plate/bin/activate # Windows 激活 venv_plate\Scripts\activate pip install -r requirements.txt如果网络条件导致 pip 默认源下载缓慢可以临时指定国内镜像源一般都能显著提升速度。安装完成后在 Python 里执行import cv2确认没有报错即可。这里有一个容易被忽视的坑如果系统里同时存在多个 Python 版本pip install安装的包会装到当前终端激活的那个解释器目录下运行时入口脚本选择的解释器必须和安装依赖的是同一个否则会报ModuleNotFoundError: No module named cv2。4.2 运行入口脚本单图模式与调试信息输出配置好环境以后运行main.py。入口脚本通常接受图片路径参数逻辑大致如下import argparse import cv2 from plate_locate import locate_plate_by_color from char_segment import segment_chars from char_recognize import match_char def main(): parser argparse.ArgumentParser(descriptionplate recognition demo) parser.add_argument(--image, typestr, helppath to a single image) args parser.parse_args() img cv2.imread(args.image) if img is None: print(failed to read image) return candidates locate_plate_by_color(img) if not candidates: print(no plate found) return # 取面积最大的候选区域作为最终车牌 x, y, w, h max(candidates, keylambda b: b[2] * b[3]) plate_img img[y:y h, x:x w] plate_gray cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) char_ranges segment_chars(plate_gray) result for (start, end) in char_ranges: char_img plate_gray[:, start:end] result match_char(char_img, templates) print(plate:, result) if __name__ __main__: main()运行命令如下python main.py --image images/test1.jpg正常会输出类似plate: 某省简称A12345的结果。建议在定位、分割两个节点分别保存一张中间图便于确认问题出在哪一段。在locate_plate_by_color返回后插入cv2.imwrite(debug_locate.jpg, plate_img)在分割之前把裁剪图写一份这几个调试图基本能覆盖 90% 的排查场景。摄像头在线识别时只需把图片来源替换成cv2.VideoCapture的逐帧读取帧间隔加一个状态判断防止重复识别同一辆车。4.3 参数调节针对性改 HSV 和投影阈值所有参数集中在config.py时调试效率会高很多。最常见的改动是颜色范围。如果现场识别的是黄底黑字的教练车牌或者大型车后车牌把蓝色 HSV 换成黄色范围即可lower_yellow np.array([15, 50, 50]) upper_yellow np.array([35, 255, 255])识别绿底黑字的新能源车牌时把 H 分量调整到绿色区间S 和 V 保持相近的下限。颜色改完之后重点观察两件事定位框是否完整包住所有字符以及是否引入了背景色块。如果框总是大一圈优先调小 S 分量下限和形态学闭运算核的宽度如果框总是缺角把 H 分量范围放宽 5 到 10。字符分割的投影阈值同样是调节重点。四五张测试图跑下来如果经常出现字符被多切一刀把垂直投影判定阈值从 3 提到 5 或 6如果两个字符粘连在一起把阈值降到 2 甚至 1同时配合最小字符宽度过滤异常区间。注意每改一次参数就重新跑一遍完整的测试集不要只看单张图的效果否则容易出现过拟合式的调参。5. 避坑记录与常见问题排查跑车牌识别最容易翻车的五个点5.1 现象定位框把周围蓝色物体一起圈了进来切出来的图里含大块背景原因HSV 阈值范围取宽了把车身蓝色或者远处蓝色招牌色块一并纳入了 mask加上形态学闭运算核太大把周围散落的蓝色区域连成了同一个连通域最终形成的候选框远大于车牌本体。常见做法失败的另一个诱因是直接取findContours的第一个结果而没有按照面积和宽高比过滤。解决第一步检查候选框排序逻辑改成取w * h面积最大且宽高比最接近 3 的那一个。第二步把 HSV 的 S 分量下限从 30 往上提例如提到 50 或 60同时把 V 分量下限也提到 60 左右滤掉车身偏灰暗的蓝色漆面。第三步把闭运算核从 (15, 5) 缩到 (9, 3)降低大范围连通的可能性。改完跑一遍测试集看定位框是否紧贴车牌边缘。5.2 现象省份汉字分割后被拆成两三段或者首字符明显比其他字符窄原因汉字结构复杂横竖笔画密集垂直投影在汉字内部笔画间隙处会出现投影低谷被代码误判成了字符边界。特别是包含口字结构或有密集竖笔画的汉字投影强度起伏非常剧烈一个字符被切成了好几块。解决给投影法加一个最小字符宽度约束例如规定字符宽度必须大于车牌总宽度的十分之一窄于此值的区间直接合并到相邻区间。更稳妥的方式是改用连通域分析做分割把字符内部断开的笔画通过二值化后的形态学膨胀先连接起来。膨胀核大小选取 3×3 即可避免把相邻字符粘连。改完以后单独拿省份汉字的样本验证不要用整张车牌的偶然成功来掩盖这个问题。5.3 现象模板匹配把数字 0 认成字母 O把 8 认成 B原因模板匹配本质上是逐像素差分对字形相似的两个字符区分度不够。如果模板库同时存了数字 0 和字母 O归一化到相同尺寸后两者的空白区域分布几乎一致只有局部笔画粗细存在差异最终得分非常接近。解决利用车牌格式规则做后置校验。国内车牌字符排列中第一位是省份汉字第二位是发牌机关字母第三位到第七位一般情况下不允许出现字母 O 和 I只使用数字。因此识别结果一旦出现 O 或 I 出现在后五位直接修正为 0 或 1。这条规则不需要改模型只需要在识别模块叠加一个后处理函数成本极低但能把误判率显著压低。更根本的办法是换用训练好的字符分类模型卷积网络对字形相似字符的区分能力远强于模板匹配。5.4 现象运行findContours时报错提示返回值数量不对代码在别人的电脑上又能跑原因OpenCV 版本差异。OpenCV 3 的cv2.findContours返回三个值分别是图像、轮廓列表和层级结构OpenCV 4 改为只返回两个值。项目里写的是contours, _ cv2.findContours(...)如果有人用 OpenCV 3 跑这段代码就会因为返回了三个值而直接报错。解决在代码里兼容两个版本获取轮廓时按版本分支处理显式判断 cv2 的主版本号cnts cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if len(cnts) 3: _, contours, _ cnts else: contours, _ cnts这类问题在新老教程、新老环境混用时极其常见遇到报错先看轮廓提取代码不要盲目改算法参数。建议整个项目统一使用 OpenCV 4.x并在requirements.txt里固定主版本号避免协作者环境不一致。5.5 现象cv2.imread读中文路径的图片时返回 None换英文路径就正常原因OpenCV 的imread函数在 Windows 平台不支持非 ASCII 路径中文和特殊字符路径下的文件无法正确解码。这个问题在测试阶段不明显一旦把程序部署到中文用户目录或者图片文件名包含中文时会频繁出现找不到图片的假象。解决使用np.fromfile结合cv2.imdecode代替imread这是处理中文路径的标准做法def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR)同样的道理cv2.imwrite在中文路径下也会失效保存调试图时用cv2.imencode配合tofile写出。6. 二次开发改造把字符识别从模板匹配换成轻量级卷积网络模板匹配在字形变化大的场景下天然吃亏最值得投入的改造就是把识别模块替换成卷积网络。改造的核心思路是保持对外接口不变只替换内部实现这样前面的定位和分割模块可以原样复用测试脚本也不需要大改。对外接口定义为一个函数输入是字符图片列表输出是拼接后的字符串def recognize_chars(char_images, model_pathmodels/cnn_plate.pth): results [] for img in char_images: pred predict_single_char(img, model_path) results.append(pred) return .join(results)训练数据可以从模板库出发做平移、旋转、缩放、加噪声的数据增强扩充到每个字符类别至少两百张。类别数按应用范围定如果只需要覆盖本地省份简称类别就是少量汉字加上 24 个字母和 10 个数字整体数据集规模不大普通 CPU 也能完成训练。网络结构不需要复杂两层卷积加一层全连接就够用输入统一缩放到 32×64 的灰度图输出维度等于类别数。改造完成后不能只看整体准确率要重点检查每个类别的混淆矩阵。最容易翻车的是字形相近的字符对以及样本量偏少的省份汉字。验证脚本应当保留改造前的十张测试图新模型跑完以后和模板匹配的旧结果逐张对比确认字符分割模块没有因为新模型的预处理引入偏差。我第一次独自跑通这套项目时在字符分割上耗了大半天才发现是投影阈值写死了换一张光线暗一点的图片就碎字。从那以后我每次调试都强制把中间变量落盘定位结果、分割结果各存一张调试图识别完成后把整条链路的中间产物一起归档。凡是改动阈值或者换模型都重新跑一遍固定测试集对比前后输出差异。这个习惯帮我省掉了大量「明明没改什么却效果变了」的困惑希望也能帮到你。本文还有配套的精品资源点击获取