简介利用Python和OpenCV实现车牌识别的实战资源包面向计算机视觉初学者与Python开发者覆盖图像预处理、车牌定位到字符识别的完整链路并结合百度API和HyperLPR提升识别准确率。压缩包内含139个文件约73.86MB其中18个py脚本承载核心识别逻辑50张jpg、32张png和19张jpeg图片作为不同场景下的测试样本另有svm.dat/svmchinese.dat模型、yml配置、Dockerfile及shell脚本方便自行配置环境并跨平台复现。目前已有1608人学习使用。资源覆盖车牌搜索识别、车牌号对比、数据库认证、图文搜索、数据库搜索等系统同时支持文件图片、网络图片地址、实时截图、摄像头拍照识别并带图片自适应窗口与对比演示gif。通过这些可直接运行的示例代码和模块化结构读者能快速理解多场景车牌识别应用的工程落地方式得益于清晰的功能目录和封装好的工具接口也可将其快速改造为二次开发基础框架。1. 车牌识别这个题目卡住新手的从来不是识别车牌识别在 OpenCV 里是个特别有意思的题目听起来是「识别」但真正动手做一遍就知道字符识别反而是整条链路里最简单的一环。真正让识别率从 90% 掉到 60% 的是前两步——车牌定位和字符分割。光照不均匀、路面反光、车牌倾斜、铆钉和边框干扰每一个都能让一套看起来很完整的代码翻车。用 Python 和 OpenCV 做车牌识别适合两类人一类是刚入门计算机视觉、想拿一个完整项目串起图像处理基本操作的开发者另一类是有具体落地需求、想在门禁或停车场场景里做一个轻量级识别原型的工程师。这条路最实在的价值在于几百行代码就能跑通一个端到端的流程而且每个环节都能肉眼看到中间结果方便定位问题。2. 环境搭建与 OpenCV 版本选型这步踩坑最少但最碎2.1 为什么 OpenCV 版本会影响车牌识别结果做车牌识别OpenCV 的版本选择不是随便pip install opencv-python就完事的。不同版本之间函数命名、参数默认值、甚至是 findContours 的返回值结构都有变化。比如 OpenCV 3.x 和 4.x 之间findContours从返回 3 个值变成了返回 2 个值早期很多教程的代码在 4.x 上直接报错。做车牌识别这种需要大量轮廓操作的流程版本不一致会让你排查半天才发现是环境问题。另一个要区分的是opencv-python和opencv-contrib-python。车牌识别用不到 SIFT、SURF 这些需要专利许可的算法也不需要cv2.text模块里的 OCR 工具所以装基础版就够了。如果装了 contrib 版某些函数的加载方式会有细微差别虽然不影响主流程但没必要。2.2 最小可用环境配置与安装命令我习惯用虚拟环境隔离项目依赖避免和系统 Python 环境的包互相污染。下面是最小可用的安装步骤# 创建虚拟环境Windows 和 Linux 通用 python -m venv plate_env # 激活虚拟环境 # Windows: plate_env\Scripts\activate # Linux/macOS: source plate_env/bin/activate # 安装 OpenCV 基础版和 numpy pip install opencv-python4.8.1.78 pip install numpy1.24.3安装完成后用一段极简代码验证环境是否可用import cv2 import numpy as np print(cv2.__version__) print(np.__version__) # 生成一张 300x100 的黑色图像模拟车牌画布 img np.zeros((100, 300, 3), dtypenp.uint8) # 画一个蓝色矩形代表车牌区域 cv2.rectangle(img, (10, 20), (290, 80), (255, 0, 0), -1) cv2.imwrite(test_plate.png, img) print(OpenCV environment is ready.)这里解释几个关键点cv2.__version__输出 4.8.1.78 说明安装成功np.zeros((100, 300, 3), dtypenp.uint8)是生成一张纯黑色图像三个维度分别对应该高度、宽度和颜色通道数dtypenp.uint8是图像像素的标准数据类型取值范围 0 到 255用其他类型会导致像素值截断或图像显示异常。cv2.rectangle的最后一个参数-1表示填充整个矩形如果不传或者传 1画的就只是边框。如果import cv2时报ModuleNotFoundError: No module named cv2优先检查是不是没激活虚拟环境或者 pip 装到了系统环境。这一步看着基础但很多人的车牌识别项目还没开始写就死在了环境上。2.3 一张车牌图片的标准处理管线环境就绪后不要急着写完整算法。先建立一个最基本的图像处理管线把原始图片到灰度图再到二值图的转换跑通后面每一步都在这个管线上迭代import cv2 import numpy as np def load_and_preprocess(image_path): # 读取图片cv2.IMREAD_COLOR 表示按 BGR 三通道读入 img cv2.imread(image_path, cv2.IMREAD_COLOR) if img is None: raise ValueError(fCannot read image: {image_path}) # 统一缩放到固定宽度保持纵横比 # 后续形态学操作的核大小和图片尺寸强相关统一尺寸方便调参 height, width img.shape[:2] target_width 800 scale target_width / width new_height int(height * scale) resized cv2.resize(img, (target_width, new_height), interpolationcv2.INTER_CUBIC) # 转灰度 gray cv2.cvtColor(resized, cv2.COLOR_BGR2GRAY) # 高斯滤波降噪核大小选 5x5过大模糊边缘过小去不掉噪点 blurred cv2.GaussianBlur(gray, (5, 5), 0) return resized, blurred # 测试函数 img, gray load_and_preprocess(test_plate.png) print(fResized image shape: {img.shape}) print(fGray image shape: {gray.shape})这段代码里有个容易被忽略的参数interpolationcv2.INTER_CUBIC。缩放图片时默认用INTER_LINEAR但车牌字符是细小结构线性插值在缩小图片时会产生锯齿和伪影用三次插值能保留更多边缘细节。另外注意cv2.resize的dsize参数是(width, height)而不是(height, width)和shape的返回顺序正好相反这里写反了图片尺寸就乱了。3. 车牌定位用颜色筛选和形态学把车牌从画面里挖出来3.1 两种主流的定位思路边缘检测 vs 颜色筛选车牌定位的常见做法有两条路线。第一条是边缘检测路线车牌区域的字符密集边缘响应强烈先做 Sobel 或 Canny 边缘检测再做形态学闭运算把相邻边缘连成块最后用轮廓外接矩形的长宽比筛选。这条路对蓝底白字的传统车牌效果不错但遇到车身本身有大量水平线条的车型比如白色面包车、银色轿车干扰会非常严重。第二条是颜色筛选路线利用车牌背景颜色蓝、绿、黄在 HSV 颜色空间中的分布范围用inRange提取出候选区域。这条路对光照变化更鲁棒因为 HSV 把亮度信息和色相分离了阴影和反光的影响被压缩在 V 通道里。实际落地时两条路可以融合先用颜色筛掉大部分背景再用边缘和形态学精修。我一般会走融合路线下面按这个思路展开。3.2 HSV 颜色空间与车牌底色范围HSV 三个通道的含义分别是色相 H0 到 180、饱和度 S0 到 255、明度 V0 到 255。OpenCV 的 H 范围是 0 到 180不是通常理解的 0 到 360这一点和很多教材不一样。不同底色车牌在 HSV 里的范围大致如下车牌底色H 范围S 范围V 范围备注蓝色100-130100-25550-255最常见新能源绿牌的蓝色字不算绿色35-8040-25540-255新能源渐变绿牌S 偏低黄色15-3580-25580-255教练车、工程车白色0-1800-30180-255警车等容易和车身混淆实际项目中绿色渐变车牌是最难处理的因为渐变导致同一块车牌上的 S 和 V 值跨度极大单一阈值很容易把车牌切成碎片。我的处理方式是放宽 H 和 S 的范围再用后续的形态学闭运算把碎片连回去。3.3 基于颜色筛选的车牌定位代码import cv2 import numpy as np def locate_plate_by_color(img): # 输入为 BGR 图像转 HSV 进行颜色筛选 hsv cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色车牌的 HSV 范围参数需要按实际图片微调 # H 范围收窄到 100-124S 下限提高以过滤浅蓝和白色车身 lower_blue np.array([100, 80, 50]) upper_blue np.array([124, 255, 255]) # inRange 生成二值掩膜白色区域为候选车牌 mask cv2.inRange(hsv, lower_blue, upper_blue) # 形态学闭运算先膨胀再腐蚀 # 核大小和图片尺寸相关800px 宽的图用 15x5 的横向核 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5)) closed cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) # 再做一个开运算去掉零散的小噪点 kernel_open cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) opened cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel_open) # 找轮廓 contours, _ cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for cnt in contours: x, y, w, h cv2.boundingRect(cnt) area w * h # 过滤掉太小和太大的区域 if area 2000 or area img.shape[0] * img.shape[1] * 0.5: continue # 车牌标准尺寸 440x140长宽比约 3.14:1 # 实际拍摄有透视变形放宽到 2.0-4.5 aspect_ratio w / h if 2.0 aspect_ratio 4.5: candidates.append((x, y, w, h)) # 按面积排序返回最大的候选区域 candidates.sort(keylambda r: r[2] * r[3], reverseTrue) if candidates: x, y, w, h candidates[0] return img[y:yh, x:xw], (x, y, w, h) return None, None这段代码有几个参数值得单独说。kernel cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))用的是横向矩形核原因是车牌是宽扁结构横向的闭运算能把字符之间的空隙连起来竖向核会把上下边框的干扰也带进来。核的大小和图片尺寸强相关如果输入图是 1920 宽核可能要放大到 25x7。RETR_EXTERNAL只取最外层轮廓避免把车牌里的字符轮廓也当成独立候选。CHAIN_APPROX_SIMPLE压缩轮廓点只保留端点减少后续计算量。长宽比筛选用的是w / h但注意如果车牌有较大倾斜这个比例会偏离 3.14所以放宽到了 2.0-4.5宁可多留候选后面再做精修。3.4 定位结果的验证方法定位写完后不要着急往下走。先做一件事把定位结果可视化输出看车牌区域有没有被完整框住框内有没有包含大量非车牌内容。# 在原图上画框并保存 def draw_locations(img, rect, save_pathresult.jpg): x, y, w, h rect cv2.rectangle(img, (x, y), (x w, y h), (0, 0, 255), 2) cv2.imwrite(save_path, img) print(fSaved visualization to {save_path}) # 假设我们有一张测试图 test_img cv2.imread(car_photo.jpg) plate, rect locate_plate_by_color(test_img) if rect: draw_locations(test_img.copy(), rect) else: print(No plate candidate found. Adjust HSV range or kernel size.)这一步很重要。很多人直接往下写字符分割结果后面全乱了回头检查才发现是定位框偏了半个车身。可视化验证能帮你快速确认问题出在颜色范围还是形态学核大小。如果框住了车灯或车身反光条优先调 HSV 范围如果框偏了但颜色区域覆盖正确优先调轮廓筛选的长宽比范围。4. 字符分割与识别把鲁A12345拆成单个字符4.1 为什么字符识别不用重头训练模型车牌字符识别有一个特殊性字符集是封闭的。省份简称 31 个英文字母 24 个I 和 O 通常不用数字 10 个加起来不到 70 类。这个规模用模板匹配就能达到不错的准确率不需要上深度学习。当然如果车流量大或环境复杂用 SVM 或轻量 CNN 会更好但那已经是另一套方案了。这里先讲模板匹配的完整链路它是理解整个识别流程最好的切入点。4.2 字符分割的核心垂直投影法字符分割最常用的手段是垂直投影法。原理是把二值化后的车牌图像每一列的像素值累加字符列因为笔画多、累加值高字符间隙因为几乎全黑、累加值接近 0。根据这个投影曲线的谷底位置就能切出每个字符的左右边界。实际处理中汉字区域会出现笔画断裂的情况需要做额外的粘连处理。import cv2 import numpy as np def segment_characters(plate_img): # plate_img 是定位阶段裁剪出的车牌区域BGR 格式 # 转灰度并放大便于后续处理 gray cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) gray cv2.resize(gray, (440, 140), interpolationcv2.INTER_CUBIC) # 二值化车牌字符为白色背景为黑色 # THRESH_OTSU 自动计算阈值避免手动调参 _, binary cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) # 处理汉字笔画断裂先闭运算连接断裂笔画 # 核大小要小3x3 足够太大字符会粘在一起 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 计算垂直投影 h, w binary.shape # binary 是单通道二值图按列求和得到投影值 vertical_projection np.sum(binary, axis0) / 255 # 找出所有空白列投影值为0的列 blank_cols [i for i, v in enumerate(vertical_projection) if v 0] # 根据空白列切分字符连续的非空白列构成一个字符区域 chars [] in_char False start 0 for i in range(w): if vertical_projection[i] 0 and not in_char: in_char True start i elif vertical_projection[i] 0 and in_char: in_char False end i # 过滤掉过窄的区域噪声或铆钉宽度小于10的丢弃 if end - start 10: chars.append((start, end)) # 第一个字符是汉字第二个是字母中间是小圆点分隔符 # 需要去掉过宽的区域可能是边框 filtered_chars [] for start, end in chars: char_width end - start if 20 char_width 80: filtered_chars.append((start, end)) # 按位置排序取前7个作为车牌字符 # 实际车牌是 1个汉字 1个字母 6个字母数字共7个字符 filtered_chars filtered_chars[:7] char_imgs [] for start, end in filtered_chars: char_img binary[:, start:end] char_imgs.append(char_img) return char_imgs, filtered_chars这段代码里有两个常用技巧。第一是cv2.threshold配合THRESH_OTSU它会在内部自动计算一个全局阈值适合光照相对均匀的裁剪后车牌图。但如果车牌区域有强烈反光Otsu 会失效此时要改用局部自适应阈值cv2.adaptiveThreshold不过这个函数对参数敏感耗时也更大不是首选。第二是垂直投影的平滑处理。投影曲线不会像理想状态那样干净字符内部会出现投影值为 0 的列比如汉字「京」的中间横竖交叉处、字母「A」的镂空部分。这就是为什么代码里在二值化后做了 3x3 的闭运算——把笔画内部的细小空隙填充掉。如果闭运算后字符还是断的可以加大到 5x5但要注意别把字符间的缝隙也填上了。4.3 模板匹配识别字符import os import cv2 import numpy as np class TemplateMatcher: def __init__(self, template_dir): self.templates {} # 遍历模板目录文件名格式: A.jpg, 鲁.jpg for filename in os.listdir(template_dir): if not filename.lower().endswith((.jpg, .png)): continue label os.path.splitext(filename)[0] path os.path.join(template_dir, filename) # 模板统一缩放到 40x80和分割出的字符做匹配 template cv2.imread(path, cv2.IMREAD_GRAYSCALE) template cv2.resize(template, (40, 80), interpolationcv2.INTER_CUBIC) _, template cv2.threshold(template, 0, 255, cv2.THRESH_BINARY cv2.THRESH_OTSU) self.templates[label] template def match(self, char_img): # 输入字符图像缩放到和模板一致 char_img cv2.resize(char_img, (40, 80), interpolationcv2.INTER_CUBIC) best_score -1 best_label None for label, template in self.templates.items(): # matchTemplate 计算相似度TM_CCOEFF_NORMED 对光照和对比度鲁棒 result cv2.matchTemplate(char_img, template, cv2.TM_CCOEFF_NORMED) _, score, _, _ cv2.minMaxLoc(result) if score best_score: best_score score best_label label return best_label, best_score # 使用示例 matcher TemplateMatcher(./templates) plate_text char_imgs, positions segment_characters(plate_img) for idx, char_img in enumerate(char_imgs): label, score matcher.match(char_img) plate_text label print(fChar {idx}: {label}, confidence: {score:.2f}) print(fPlate number: {plate_text})cv2.matchTemplate的输出是一个相似度矩阵cv2.minMaxLoc取矩阵中的最大值和位置。TM_CCOEFF_NORMED是归一化相关系数法返回值范围是 -1 到 1它对图像的线性变化比如亮度整体提升 80%不敏感所以光照轻微变化时用这个最稳。如果模板和字符都是二值图也可以考虑cv2.TM_SQDIFF_NORMED但实际效果不如相关系数法。一个重要的落地细节是模板来源。很多公开的模板库字体印刷感太强和真实车牌上的冲压字体差距大。我见过一个翻车例子字符「0」和「O」、「1」和「I」在真实图片上分辨率低时几乎无法区分模板匹配很容易混淆。常见做法是从实际拍摄的车牌中抠出字符建立模板库这样和真实场景的分布最接近。4.4 一个必须处理的特殊字符汉字汉字的识别是模板匹配里的老大难。原因有两个一是省简称的字形结构复杂「京」「冀」「苏」这些字的笔画密度高二值化后容易出现笔画粘连或断裂二是汉字字体在不同批次的车牌上有细微差异同一省份新老车牌的「鲁」字可能字形不完全一致。处理汉字的常见做法是把汉字单独分类不和其他字母数字混在一起。通常车牌第一位是汉字所以分割后第一个字符直接用汉字模板库匹配后面的才用字母数字模板库。另外汉字区域的宽度明显大于后面的字母数字可以做宽度判断来确认第一个字符的位置。如果模板匹配得分低于 0.5大概率是分割出了问题优先回去看那个字符的二值化图像。5. 车牌识别避坑五个让识别率骤降的边界条件5.1 ModuleNotFoundError: No module named cv2现象import cv2直接报ModuleNotFoundError: No module named cv2。原因绝大多数情况是没激活虚拟环境或者 pip 安装到了系统 Python 而不是当前解释器。排查方法先执行which python或python -m pip list看当前环境里有没有 opencv-python。解决方式重新安装并用python -m pip install opencv-python而不是pip install因为python -m pip能确保安装到当前 Python 解释器对应的环境里。5.2 contourArea 报错「未定义标识符」现象cv2.contourArea(cnt)在 IDE 里被标红或者在 Python 环境中提示找不到函数。原因这通常是 IDE 的静态检查误报或者是在cv2模块被局部变量覆盖时出现的真实报错。解决检查代码里有没有写cv2 ...这种赋值语句覆盖了模块名如果是 IDE 误报运行代码验证即可。注意 OpenCV 4.x 中findContours的返回值是(contours, hierarchy)两个值如果按旧版本写成三个值contours接收到的是hierarchy后续调用contourArea自然出问题。5.3 蓝牌识别正常绿牌大面积漏检现象同一个场景蓝牌车能识别绿牌新能源车完全找不到车牌区域。原因绿色渐变车牌的背景颜色从深绿过渡到浅绿HSV 中 H 值跨度大S 值变化剧烈固定的inRange范围无法覆盖整个车牌。解决针对绿色车牌单独设置一组范围用两套 HSV 范围分别做掩膜再把两个掩膜做按位或合并。代码里可以这样写lower_green np.array([35, 40, 40]) upper_green np.array([80, 255, 255]) mask_blue cv2.inRange(hsv, lower_blue, upper_blue) mask_green cv2.inRange(hsv, lower_green, upper_green) mask cv2.bitwise_or(mask_blue, mask_green)5.4 白色车身反光导致定位框漂移现象定位框框住了车身反光条或车窗边缘真正的车牌区域没被选中。原因白色车身在 HSV 中 V 通道很高S 通道接近 0饱和度和蓝色车牌的筛选范围有重叠区域反光条会产生高亮的饱和区域被误判为蓝色。解决不要只依赖颜色筛选。在颜色筛选之后对候选区域做边缘检测计算区域内边缘密度车牌区域的边缘密度显著高于车身平面。另一个辅助手段是检查候选区域的宽高比是否接近车牌的 3.14:1车身反光条往往是狭长形比例在 8:1 以上可以直接排除。5.5 字符分割把「京」拆成两半现象分割结果中汉字被切成了两个部分比如「京」的「亠」和「口」分离或者反过来「川」「苏」这类左右结构的字中间被误切了一刀。原因垂直投影法天然对左右结构的汉字不友好汉字内部笔画稀疏的地方投影值会降到 0从而产生虚假分割点。解决在分割后用字符宽度一致性检查来合并碎片。正常车牌字符宽度在 20-40 像素之间440 宽标准图如果某个片段宽度只有 5-10 像素大概率是被误切的尝试把它和相邻片段合并后再匹配模板# 合并过窄的字符片段 merged_chars [] for start, end in chars: if not merged_chars: merged_chars.append([start, end]) else: last_start, last_end merged_chars[-1] if start - last_end 5 or (end - start) 10: # 与上一个片段间隙小于5像素或本身过窄合并 merged_chars[-1][1] end else: merged_chars.append([start, end])这个合并逻辑的阈值不是死的需要根据你的实际图片分辨率调整。判断标准是合并后的片段宽度应该在所有片段宽度的均值附近偏差过大就要重新审视是不是合并策略出了问题。6. 进阶用透视校正和投影精修把识别率再拉一截前面的流程跑通后你大概会发现一个问题倾斜车牌的识别率始终上不去。原因很直接——分割和匹配都假设字符是水平排列的一旦车牌旋转 10 度以上垂直投影的波谷被抹平字符边界定位就会出错。这一章的技巧解决的就是这个问题。车牌倾斜分为水平倾斜和垂直倾斜。水平倾斜指车牌整体旋转了一个角度垂直倾斜指车牌在垂直方向有透视变形比如拍摄时相机略高于车牌导致车牌上宽下窄。处理水平倾斜用仿射变换处理垂直倾斜用透视变换。先看水平倾斜的检测方法。拿到定位出的车牌区域后可以用霍夫直线检测找出车牌上下边缘的直线角度def get_plate_angle(plate_img): gray cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) edges cv2.Canny(gray, 50, 150) # 概率霍夫直线检测只取长线段 lines cv2.HoughLinesP(edges, 1, np.pi / 180, threshold100, minLineLength100, maxLineGap10) if lines is None: return 0.0 angles [] for line in lines: x1, y1, x2, y2 line[0] angle np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi # 只保留接近水平的直线角度范围控制在 ±30 度内 if abs(angle) 30: angles.append(angle) if not angles: return 0.0 return np.median(angles)拿到角度后用cv2.getRotationMatrix2D构造旋转矩阵再调用cv2.warpAffine旋正。注意旋转后车牌四个角不再是矩形切割时边缘会出现黑边要去掉黑边只保留中央有效区域。这个处理很值得做因为很多停车场的相机都是斜着安装的识别率能差出 10 个百分点。垂直倾斜的处理要复杂一些常见做法是提取车牌上下边缘的四个角点做透视变换映射到标准矩形。这里有一个比较实用的偏方先不做透视变换而是在字符分割时改用倾斜投影法。把垂直投影的求和方向从竖直改为沿倾斜方向加权这需要手动指定倾斜角度但实现简单适合角度范围固定的场景。字符识别阶段也可以做一次精修。模板匹配的得分通常不会超过 0.9如果你发现某一类字符总是混淆比如「5」和「6」、「B」和「8」可以通过增加后验规则来修正。车牌第二位是字母最后五位是字母数字混合但第二位和倒数第一位通常是字母这些规则可以组合成简单的约束逻辑在模板匹配结果上做一轮校正# 简单的车牌规则校验 def validate_plate(plate_text): if len(plate_text) ! 7: return False # 第一位是省份汉字第二位是字母 # 汉字范围检查用简单的 Unicode 区块判断 if not plate_text[0] \u4e00 or not plate_text[0] \u9fff: return False if not plate_text[1].isalpha(): return False return True最后的建议是集齐测试样本。把不同光照、不同角度、不同底色的车牌图片各存 20 张每次改完参数跑一遍全量测试记录识别率变化。我习惯把每张测试的中间结果定位框、分割后字符、识别文本都保存下来识别错了能直接看出是哪个环节出了问题。这套流程做完你的车牌识别项目才算真正能扛住真实场景的考验。希望帮到你。本文还有配套的精品资源点击获取