
简介面向图像处理与计算机视觉学习者的车牌自动识别实战项目资源内含可直接运行的完整工程源码、车牌图片数据集与字符匹配模板基于Python和OpenCV实现可帮助初学者从零逐步完成车牌定位、字符分割和模板匹配识别并迁移到车辆追踪、停车场管理等实际场景对智能交通、安防监控等方向同样具有参考价值。资源共2000个文件其中1999张车牌图片用于训练与测试1个Python源码文件为主程序压缩包仅12.37MB轻量易用。已有3105人学习/下载。借助这套资源读者可系统掌握图像预处理、边缘检测、轮廓提取、垂直投影分割等关键流程并结合模板库完成字符识别代码结构清晰数据组织规整适合课程设计、毕业设计或入门计算机视觉的实践参考。1. 车牌自动识别Python OpenCV 就能落地源码、数据集与字符模板的完整闭环车牌识别这个需求在各行各业出现的频率远超想象——小区道闸、工地出入口、地下车库、甚至某些内部园区的访客登记系统都需要把“车牌号”从图像里稳定地抠出来。很多人第一反应是上深度学习但实际做下来你会发现对于固定场景、固定角度的车牌识别传统图像处理方案不仅部署简单而且推理速度快、依赖少一台树莓派都能跑得动。本文要拆解的这份资源正是这样一种经典实现路径Python OpenCV 完成车牌定位、字符分割、模板匹配识别附带完整源码、用于验证的图片数据集和字符匹配模板。这套方案的核心逻辑不复杂先用颜色特征锁定车牌区域再通过轮廓分析精确定位接着做字符分割最后用模板匹配逐个识别字符。但“不复杂”不意味着“没坑”——光照变化、车牌倾斜、字符粘连、模板尺寸不统一任何一个环节处理不到位识别率就会断崖式下跌。本文会从环境搭建开始一路走到参数调优和踩坑记录你可以把它当成一份可复现的工程笔记也可以直接基于这份源码去改造成你自己的车牌识别模块。2. 环境准备与项目结构先把代码跑起来再谈识别率2.1 依赖安装与版本陷阱这份源码基于 Python 3 OpenCV 实现我拆解时使用的版本组合是 Python 3.8 OpenCV 4.5.5numpy 1.21。为什么强调版本因为 OpenCV 在 4.x 之后部分 API 有变动比如cv2.findContours的返回值从 2 个变成了 3 个image, contours, hierarchy如果你用的是 3.x 老版本源码里解包那行就会直接报错。# 建议先用虚拟环境隔离避免污染全局 Python python -m venv plate_env source plate_env/bin/activate # Windows 下是 plate_env\Scripts\activate # 安装核心依赖 pip install opencv-python4.5.5.62 pip install numpy1.21.6这里有个细节很多人会忽略OpenCV 的cv2.imread()读进来的是 BGR 通道顺序而matplotlib显示时默认 RGB如果你在调试阶段用 matplotlib 显示中间结果会发现颜色完全偏掉。源码里如果直接用cv2.imshow()看就没这个问题但一旦你切到 Jupyter 里用plt.imshow()调试图就会被这个通道顺序坑到。我的习惯是写一个小工具函数统一封装显示逻辑把 BGR 转成 RGB 再输出。2.2 项目文件构成与运行入口解压这份资源后目录结构大致如下plate_recognition/ ├── src/ │ ├── main.py # 主入口单张图片识别演示 │ ├── plate_locate.py # 车牌定位模块 │ ├── char_segment.py # 字符分割模块 │ ├── char_recognize.py # 模板匹配识别模块 │ └── utils.py # 公共工具函数 ├── data/ │ ├── test_images/ # 测试图片数据集 │ ├── templates/ # 字符模板图片 │ └── templates_config.json # 模板配置文件 ├── output/ # 识别结果输出目录 └── requirements.txt运行入口极其简单在项目根目录下执行python src/main.py --image data/test_images/鲁A12345.jpg如果一切顺利你会在控制台看到类似识别结果: 鲁A12345的输出同时在output/目录下生成标注了车牌框和字符分割结果的图片。我先跑通这张图再逐步深入每个模块的细节。注意源码里默认读的是单张图片如果你想批量跑整个目录需要自己写个循环这个我放到最后一章讲。2.3 核心流程概览四步走每步都有独立模块整个识别流程拆成四个阶段每个阶段对应一个 Python 模块这样设计的好处是可以单独调试——定位不准就先调定位不用每次都跑全流程。流程如下车牌定位读取图片后通过颜色识别找到蓝色或黄色区域再用轮廓检测锁定车牌外接矩形。图像预处理对定位到的车牌区域做灰度化、二值化、降噪为字符分割做准备。字符分割通过轮廓分析和垂直投影把车牌上的字符一个个切出来。字符识别将分割出的字符与模板库逐张比对输出最终车牌号。下面两章我会把定位和识别这两个最关键的环节拆开讲因为 80% 的识别失败都出在这两步。3. 车牌定位与图像预处理颜色空间转换和轮廓筛选的配合3.1 为什么用 HSV 颜色空间而不是直接 RGB 阈值车牌定位的第一步是找到“蓝色的区域”。初学者最容易踩的坑是直接拿 BGR 的像素值做阈值判断比如if b 200 and r 50这样在晴天、阴天、夜晚的结果会差异巨大因为 RGB 对光照极度敏感。正确做法是先转换到 HSV 色彩空间把色相H、饱和度S、明度V分开然后只对 H 和 S 通道做阈值。import cv2 import numpy as np def locate_plate(image): 通过 HSV 颜色阈值 轮廓筛选定位车牌区域 :param image: BGR 原始图像 :return: 车牌区域的 ROI 图像或 None # 1. 转换色彩空间 hsv cv2.cvtColor(image, cv2.COLOR_BGR2HSV) # 2. 蓝色范围阈值OpenCV 的 H 范围是 0-180 lower_blue np.array([100, 100, 60]) upper_blue np.array([130, 255, 255]) mask cv2.inRange(hsv, lower_blue, upper_blue) # 3. 形态学操作先闭运算填充内部孔洞再开运算去除噪点 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) # 4. 查找轮廓 contours, _ cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) return contours, mask这段代码里最值得关注的是 HSV 阈值的选择。H 通道取值 100-130 对应蓝色但这里有个细节国内车牌有蓝底白字也有黄底黑字新能源车是渐变绿这个场景暂不讨论如果你要适配黄牌需要再加一组黄色阈值做并集。S饱和度下限设 100 是为了过滤掉偏灰白的区域V明度下限设 60 是因为过暗的像素大概率是阴影而不是车牌底面。形态学操作的核尺寸也很有讲究(15, 5)的长条形状是为了适配车牌的形状——车牌本身是扁长方形用横向长的核做闭运算能把字符间的缝隙填起来让车牌变成一个整体连通域。3.2 轮廓筛选逻辑面积、宽高比、外接矩形三重要求拿到轮廓之后不能直接裁因为蓝色区域可能不止一个——蓝色的车、蓝色的广告牌、蓝色的天空都可能被圈进来。需要通过三个条件做几何筛选def filter_plate_contours(contours, image_width, image_height): 从候选轮廓中筛选出最像车牌的矩形区域 :param contours: findContours 输出的轮廓列表 :return: 筛选后的车牌轮廓或 None candidates [] for cnt in contours: # 1. 轮廓面积筛选车牌在画面中不应该太小 area cv2.contourArea(cnt) if area image_width * image_height * 0.001: continue # 2. 外接矩形宽高比标准车牌宽高比约为 3.14:1 x, y, w, h cv2.boundingRect(cnt) aspect_ratio w / h if aspect_ratio 2.0 or aspect_ratio 5.0: continue # 3. 矩形面积与轮廓面积比值越接近矩形越可疑 rect_area w * h if rect_area 0: continue extent area / rect_area if extent 0.4: continue candidates.append((x, y, w, h)) if not candidates: return None # 取面积最大的候选区域作为车牌 candidates.sort(keylambda b: b[2] * b[3], reverseTrue) return candidates[0]面积下限设为整张图的千分之一是经验值——如果车牌比这个还小字符分割阶段根本没法做识别率会惨不忍睹。宽高比控制在 2.0 到 5.0 是相对宽松的范围因为实际拍摄中车牌可能有旋转透视变形会压缩宽高比卡死了反而丢真。extent轮廓面积与外接矩形面积之比是我比较喜欢用的一个筛选条件车牌是个实心矩形extent值通常在 0.6 以上而一团不规则形状的蓝色区域这个值会明显偏低。这里设 0.4 是留了裕量能够兼容车牌的安装角度偏差。3.3 俯仰角校正与透视变换筛选出轮廓之后直接裁外接矩形有个隐患如果摄像头安装角度偏了车牌在图像里是个梯形而不是矩形。不做校正后续字符分割时上下边缘的投影会互相干扰。源码里用的是cv2.minAreaRect()获取最小外接矩形然后判断旋转角度再做仿射变换def correct_plate_rotation(plate_region, rect): 根据最小外接矩形的旋转角度校正车牌 :param plate_region: 原始图像中的车牌区域 :param rect: cv2.minAreaRect 返回值 :return: 校正后的车牌图像 angle rect[2] # 旋转角度 if angle -45: angle -(90 angle) else: angle -angle # 旋转校正 rows, cols plate_region.shape[:2] M cv2.getRotationMatrix2D((cols / 2, rows / 2), angle, 1) rotated cv2.warpAffine(plate_region, M, (cols, rows)) return rotated这里有个非常容易搞混的细节cv2.minAreaRect()返回的角度范围是 [-90, 0)需要根据角度值判断矩形是“躺着”还是“竖着”然后做不同的调整。很多人在这个角度换算上翻车原因是对 OpenCV 的坐标系理解不透——它的 y 轴向下导致角度为负值需要手动转成正的旋转角。校正完角度后再根据轮廓的透视关系做一次四点透视变换会更稳但源码里这一步是可选的。我拆解时发现对于绝大多数固定摄像头场景仿射变换校正已经足够透视变换留给你在遇到严重侧拍场景时再打开。4. 字符分割与识别从二值化到模板匹配的完整链路4.1 车牌区域的灰度化与二值化大津法与自适应阈值的取舍定位和校正完车牌区域后下一步是把彩色图转成能让字符“浮出来”的二值图。这里的关键是二值化阈值的选取——不同光照条件下车牌底色亮度差异很大固定阈值比如cv2.THRESH_BINARY, 127会在逆光场景下完全失效。def preprocess_plate(plate_roi): 车牌区域预处理灰度化、二值化、去边框 :param plate_roi: 校正后的车牌彩色图像 :return: 二值化之后的图像字符为白色底色为黑色 # 1. 灰度化 gray cv2.cvtColor(plate_roi, cv2.COLOR_BGR2GRAY) # 2. 大津法二值化自动计算阈值 # 注意THRESH_BINARY_INV 是因为蓝底白字反转后字符为白色 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 3. 去边框车牌边缘有白色或蓝色边框干扰字符分割 # 简单方式上下各去掉 2 行左右各去掉 2 列 h, w binary.shape binary binary[2:h-2, 2:w-2] return binary大津法Otsu的原理是自动寻找一个阈值让前景和背景的类间方差最大。对车牌这种有明显双峰直方图的图像效果通常不错。但如果你遇到的是倾斜光照、一侧亮一侧暗的情况大津法会“顾此失彼”这时我会切到自适应阈值# 自适应阈值替代方案适用于光照不均的场景 binary cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2)块大小设为 11必须是奇数C 值取 2。自适应阈值的问题是容易产生更多噪点但它对光照不均的鲁棒性确实比大津法好。源码里默认用大津法我建议你把两种方式都封装好遇到光线复杂的场景自动切换。4.2 字符分割轮廓外接矩形 垂直投影双重验证字符分割是车牌识别里最容易出问题的环节。最简单的做法是直接在二值图上找轮廓然后按轮廓的外接矩形从左到右排序切出来。但实际车牌上有边框、铆钉、间隔符都会生成干扰轮廓def segment_characters(binary): 从二值化车牌图中分割出字符 :param binary: 二值化后的车牌图像 :return: 字符图像列表按从左到右排序 # 1. 查找轮廓只取外层轮廓 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 2. 筛选字符轮廓 char_contours [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) # 字符高度应占车牌高度的 60% 以上去掉上下边框后的比例 if h binary.shape[0] * 0.5: continue # 字符宽度不能太宽或太窄 if w binary.shape[1] * 0.02 or w binary.shape[1] * 0.2: continue char_contours.append((x, y, w, h)) # 3. 按 x 坐标排序 char_contours.sort(keylambda b: b[0]) # 4. 裁出字符图 char_imgs [] for x, y, w, h in char_contours: char_img binary[y:yh, x:xw] char_imgs.append(char_img) return char_imgs高度阈值是 0.5但国内车牌字符高度通常占车牌高度的 80% 左右这里设 0.5 是为了兼容某些字体偏小的情况。宽度阈值 0.02-0.2 是根据经验值来的——一个字符占车牌宽度的比例一般在 0.05 到 0.15 之间。有一个容易忽略的问题汉字“鲁”和字母“A”的连通域可能存在断裂。比如“鲁”字结构复杂如果二值化后笔画断开会被识别成多个轮廓导致分割结果多出几个碎片。解决办法是做一次形态学闭合操作把相邻笔画连起来。源码里在二值化之后、查找轮廓之前可以对图像做MORPH_CLOSE核尺寸用(3, 5)或(4, 4)这样能明显减少碎片。4.3 字符尺寸归一化训练模板匹配的前提分割出的字符图大小不一模板匹配要求所有字符图必须与模板图尺寸一致。这步在源码里由char_recognize.py完成def normalize_char_image(char_img, target_size(40, 80)): 将字符图像缩放到统一尺寸并居中到目标画布 :param char_img: 分割出的字符图像 :param target_size: (宽, 高) :return: 归一化后的图像 h, w char_img.shape # 1. 先等比缩放让长边匹配目标尺寸 scale max(target_size[0] / w, target_size[1] / h) new_w int(w * scale) new_h int(h * scale) resized cv2.resize(char_img, (new_w, new_h), interpolationcv2.INTER_CUBIC) # 2. 创建目标尺寸的画布把缩放后的图放中间 canvas np.zeros(target_size, dtypenp.uint8) x_offset (target_size[0] - new_w) // 2 y_offset (target_size[1] - new_h) // 2 canvas[y_offset:y_offset new_h, x_offset:x_offset new_w] resized return canvas等比缩放后居中比直接拉伸成目标尺寸要好——直接拉伸会破坏字符的宽高比让“1”和“I”这类原本靠宽高比区分的字符更难分辨。缩放插值用cv2.INTER_CUBIC是因为它适合放大场景如果是缩小用cv2.INTER_AREA效果更好模板图通常比较大这里用 CUBIC 是合理的。4.4 模板匹配的匹配度计算与误判处理模板匹配的核心是逐像素相似度计算。源码里用的是归一化相关性系数TM_CCOEFF_NORMED这个方法的输出范围是 -1 到 1值越接近 1 表示匹配度越高def recognize_char(char_img, templates): 对单个字符图像进行模板匹配识别 :param char_img: 归一化后的字符图像 (H, W) :param templates: 模板字典 {字符: 模板图} :return: 识别结果字符及相似度 best_score -1 best_char None for char, template_img in templates.items(): # 确保模板尺寸与字符图一致 template_resized cv2.resize(template_img, (char_img.shape[1], char_img.shape[0])) # 计算匹配度 result cv2.matchTemplate(char_img, template_resized, cv2.TM_CCOEFF_NORMED) score result[0][0] # 单个位置时直接取值 if score best_score: best_score score best_char char return best_char, best_score这里的参数有一个关键点模板匹配的分数阈值。如果最佳匹配分数低于 0.5我会认为这次识别不可信直接丢弃而不是硬给一个结果。因为字符分割如果出了碎片任何模板的匹配分都会很低硬识别出来的字符基本是错的。源码里默认没做这层校验我强烈建议你加上if best_score 0.5: return ?, best_score # 不可信结果用 ? 标记还需要注意一个方向性坑汉字“鲁”和“A”这类字符的模板在制作时必须和实际车牌使用的字体保持同一类字体。如果模板用的是宋体而实际车牌是黑体匹配分数会低不少。这份资源里自带的模板是基于常见车牌字体制成的如果你遇到特定字体识别率低需要自己额外采集字符样本补充模板。5. 避坑指南车牌识别实战中的 5 个高频翻车点5.1 蓝色阈值把深色车标识别成车牌现象定位阶段圈出的候选区域不是车牌而是深蓝色车标或深色车窗贴膜。原因HSV 中蓝色范围设置过宽深蓝色的车标在饱和度较低时和车牌底色落在同一区段。解决把lower_blue的 S 通道从 100 提高到 120同时 V 通道下限从 60 提高到 80。调完之后车标这类低饱和度区域会被过滤掉。调整后再看轮廓筛选的宽高比条件如果候选区域明显不是扁长形也会被aspect_ratio拦下。5.2 字符分割时汉字被拆成多个碎片现象某个汉字被分割成 2-3 个独立块后续模板匹配全军覆没。原因二值化后汉字笔画间出现断裂尤其是“鲁”“苏”“赣”这类笔画密集的汉字。解决在二值化之后、查找轮廓之前做一次形态学闭运算核尺寸设(3, 5)。闭运算能把断开的笔画连起来。注意核不能太大否则会把相邻字符也连成一个整体。5.3 模板匹配分数整体偏低但结果正确现象识别出来的字符是对的但最佳匹配分数只有 0.4 左右低于我前面建议的 0.5 阈值被误判为“?”。原因模板图和实际字符的字体粗细不一致或归一化后字符的笔画粗细产生变化。解决对模板图和字符图统一做一次膨胀或腐蚀操作让笔画粗细对齐。常见做法是写一个preprocess_template函数把所有模板先做一次cv2.dilate让模板笔画加粗和车牌喷漆字体的粗笔画更接近。5.4 新能源绿牌完全识别不了现象绿色的新能源车牌在定位阶段直接被过滤找不到候选区域。原因源码里只写了蓝色和黄色的 HSV 阈值绿牌不在范围内。解决添加一组绿色阈值H 范围约 35-77S 大于 100V 大于 60。但绿牌的识别还有一个额外麻烦——渐变底色导致二值化后背景不均匀字符分割易碎片化。我的经验是对绿牌单独使用自适应阈值替代大津法效果会好很多。5.5 夜间或强逆光场景识别率暴跌现象白天识别正常晚上或逆光时要么找不到区域要么字符分割全乱。原因光照不足导致车牌区域明暗对比度低HSV 阈值法在暗光下直接失效。解决在进入 HSV 定位之前先对原始图做一次 CLAHE 直方图均衡化增强局部对比度clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) gray_original cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) enhanced clahe.apply(gray_original)这招能救回一部分夜间场景但如果是完全没有补光的黑夜传统图像处理方案的上限就在这了这时候需要考虑红外补光灯或者在识别前加一个亮度判断亮度低于阈值时直接提示“光照不足”而不是硬识别。6. 批量识别与性能优化把单张脚本改成可用的小工具源码默认单张图片识别但实际使用中你大概率需要批量处理。我把main.py改造成了一个支持目录遍历、结果输出到 CSV 的小工具改造过程刚好也把性能优化的几个要点带出来了import os import csv import time import cv2 from plate_locate import locate_plate from char_segment import segment_characters from char_recognize import recognize_char def process_single_image(image_path, templates): 单张图片完整识别流程返回车牌号和耗时 start time.time() image cv2.imread(image_path) if image is None: return None, 0, 图像读取失败 contours, mask locate_plate(image) plate_box filter_plate_contours(contours, image.shape[1], image.shape[0]) if plate_box is None: return None, time.time() - start, 未定位到车牌 x, y, w, h plate_box plate_roi image[y:yh, x:xw] fine correct_plate_rotation(plate_roi, cv2.minAreaRect(...)) binary preprocess_plate(fine) chars segment_characters(binary) # 跳过第一个字符汉字按省份简称单独处理模板单独一套 result for char_img in chars[1:]: normalized normalize_char_image(char_img) char, score recognize_char(normalized, templates) if score 0.5: result ? else: result char return result, time.time() - start, OK首字符省份简称是汉字和后面的字母数字字体差异较大我是把它和字符模板单独分开的——templates字典里直接按“汉字模板”和“字母数字模板”两个子字典管理识别时分段调用。这么做的好处是汉字模板不需要做厚省得干扰后面字母数字的匹配。批量处理时有个容易踩的坑OpenCV 在连续读图时如果图片尺寸差异大处理耗时波动会很明显。实践下来最有效的手段是先把所有图片统一缩放到相同宽度比如 800px再进识别流程这样既能提升处理速度又能让定位参数面积下限、宽高比的一致性更好。我在批量脚本里直接加了这一步def batch_process(image_dir, templates, output_csvresult.csv): 遍历目录下所有 jpg/png 图片批量识别并记录结果 results [] for fname in os.listdir(image_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): path os.path.join(image_dir, fname) plate, elapsed, status process_single_image(path, templates) results.append([fname, plate, status, f{elapsed:.3f}s]) print(f{fname}: {plate} ({status}) 耗时 {elapsed:.2f}s) # 写入 CSV with open(output_csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([文件名, 识别结果, 状态, 耗时]) writer.writerows(results)最后再聊一个验证技巧。源码的模板匹配是返回分数但未设置阈值我在改造时加了一步“仿真验证”——把测试数据集分成两部分一部分用来调参一部分用来验证。每调一次参数就跑一遍整个验证集统计识别率和单张平均耗时。这套流程走下来你能直观看到哪一步改动对结果的影响最大而不是凭感觉乱调。从那以后我每次拿到新的车牌识别需求都会强制自己先跑一遍这个流程环境搭建、单图跑通、批量验证、失败样本分析逐步迭代参数。这套源码加数据加模板的组合给我节省了大量从零开始写的时间希望这篇拆解笔记也能帮你在车牌识别上少走几个弯路——代码拿回去先跑通再考虑改造成你自己的场景。本文还有配套的精品资源点击获取