简介:这份资源是一套基于OpenCV的车牌号码识别Python实现,面向具备一定Python基础、希望入门计算机视觉与模式识别的开发者,可用于课程设计、毕业设计或自学练手。压缩包共47个文件,约28.8MB,包含6个py源码脚本、21张jpg与15张png测试图片、2个dat模型文件及2个7z字符数据集,覆盖图像预处理、车牌定位、字符分割与识别等完整流程,图片与模型文件可直接用于验证识别效果。代码围绕灰度化、二值化、去噪、边缘检测、霍夫变换定位车牌,再结合连通组件分析与SVM模板匹配完成字符识别,并配有配置文件与测试脚本,便于调整阈值参数、观察中间结果。目前已有263人学习下载,适合想理解车牌识别系统原理、动手跑通完整流程并在此基础上尝试深度学习改进的读者参考。
1. 从一张车牌照片到一串号码:这套 Python 代码到底在做什么
手头有一张停车场抓拍图,或者一段路口监控截帧,你想把画面里那辆车的车牌号抠出来变成文本——这件事用 OpenCV 加 Python 就能跑通,而且不需要深度学习框架、不需要 GPU、不需要联网调接口。整套流程拆开就是四步:把彩色图转成灰度、用形态学操作把车牌区域从背景里「顶」出来、对候选区域做字符分割、最后拿模板匹配或者简单分类把每个字符认出来。听起来像教科书流水线,但真正跑起来你会发现,光照一变、角度一歪、车牌脏一点,中间任何一步都可能翻车。
这套方案适合谁?适合刚学完 OpenCV 基础、想找一个完整项目练手的 Python 入门者;也适合需要在嵌入式设备或树莓派上做轻量车牌识别的工程师——整套依赖只有 opencv-python 和 numpy,装完就能跑。它不适合追求高精度工业级识别的场景,那种情况得上深度学习模型。但作为理解图像处理流水线、掌握 OpenCV 函数组合的实战项目,它的性价比极高。下面我从环境搭建开始,把每一步的参数、坑和调优方法讲清楚。
2. 环境搭建与最小可运行验证:别让 cv2.error 挡在第一步
2.1 安装 Python 与 opencv-python 的正确姿势
很多人卡在第一步不是代码问题,是环境问题。最常见的报错是ModuleNotFoundError: No module named 'opencv',原因通常有两个:一是装成了opencv-python-headless(无 GUI 版本,cv2.imshow会挂),二是 Python 版本和 wheel 不匹配。
我一般推荐 Python 3.8 到 3.10 这个区间,太新的版本有时候 opencv-python 的预编译包还没跟上。安装命令就一行:
pip install opencv-python numpy如果你在 Ubuntu 上跑,可能还需要补系统依赖:
sudo apt-get update sudo apt-get install -y libgl1-mesa-glx libglib2.0-0这两条是给cv2.imshow和视频解码用的,缺了会报cv2.error: OpenCV(4.x) ... error: (-2:Unspecified error) ...这类看起来吓人但其实只是缺库的错。
提示:如果你用 conda,建议
conda install -c conda-forge opencv,避免 pip 和 conda 混装导致的.so冲突。
验证安装是否成功,跑这段最小代码:
import cv2 import numpy as np print("OpenCV version:", cv2.__version__) # 创建一个 100x300 的黑色图像,画个白色矩形模拟车牌 img = np.zeros((100, 300, 3), dtype=np.uint8) cv2.rectangle(img, (50, 20), (250, 80), (255, 255, 255), -1) cv2.imshow("test", img) cv2.waitKey(0) cv2.destroyAllWindows()这段代码做了三件事:打印版本号确认 cv2 能导入、用 numpy 造一张假图、调用 GUI 显示。如果窗口正常弹出,说明环境没问题。如果报cv2.error且信息里带gtk或cocoa,那就是 GUI 后端缺失,Ubuntu 上补libgtk2.0-dev,macOS 上重装opencv-python而不是 headless 版本。
2.2 读图、转灰度、高斯模糊:预处理三件套的参数怎么定
拿到一张车牌图,第一步不是直接找车牌,而是把图像「洗干净」。标准流程是:读图 → 转灰度 → 高斯模糊去噪。
import cv2 import numpy as np # 读取图像,第二个参数 1 表示彩色模式 img = cv2.imread("car_plate.jpg", cv2.IMREAD_COLOR) if img is None: raise FileNotFoundError("图像没读到,检查路径和文件名") # 转灰度:三通道变单通道,后续形态学操作只处理一个通道 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯模糊:核大小 (5,5),标准差 0 表示由核大小自动推算 blurred = cv2.GaussianBlur(gray, (5, 5), 0) cv2.imshow("gray", gray) cv2.imshow("blurred", blurred) cv2.waitKey(0)cv2.cvtColor的COLOR_BGR2GRAY用的是加权平均公式,绿色通道权重最高,因为人眼对绿光最敏感。GaussianBlur的核大小必须是奇数,(5,5)是常用起点;如果图像分辨率很高(比如 4K),可以调到(7,7)或(9,9)。标准差传 0 让 OpenCV 自己算,通常够用。
这里有个容易忽略的点:cv2.imread如果路径写错不会报错,只会返回None,后面所有操作都会崩。所以我在每段代码开头都加if img is None检查,这是血泪经验——调试半小时才发现文件名少了个字母的事没少干。
2.3 用 Sobel 和形态学把车牌区域「顶」出来
车牌区域的特点是:水平方向边缘密集(字符排列),垂直方向相对平滑。所以用 Sobel 算水平梯度,再做闭运算把字符连成块。
# Sobel 水平方向梯度,ksize=3 表示 3x3 卷积核 sobel_x = cv2.Sobel(blurred, cv2.CV_16S, 1, 0, ksize=3) # 取绝对值并转回 uint8 abs_x = cv2.convertScaleAbs(sobel_x) # 二值化:阈值 0 表示自动用 Otsu 方法找最佳阈值 _, binary = cv2.threshold(abs_x, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 闭运算:先膨胀后腐蚀,把断开的字符连成一片 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) cv2.imshow("sobel", abs_x) cv2.imshow("binary", binary) cv2.imshow("closed", closed) cv2.waitKey(0)cv2.Sobel的CV_16S是因为梯度值可能为负,用 16 位有符号整数存,后面convertScaleAbs取绝对值再转回 8 位。ksize=3是最小核,对噪声敏感但定位准;如果图像噪声大,改成ksize=5。
cv2.threshold里的THRESH_OTSU会自动分析直方图找双峰之间的谷底作为阈值,比手动设 127 靠谱得多。闭运算的核(17,5)是经验值:宽度 17 是为了把相邻字符连起来,高度 5 是为了不把上下无关区域也连进来。这个参数需要根据实际图像分辨率调整——图像越大,核也要相应变大。
注意:形态学核的尺寸没有万能值。我一般会先跑一遍看
closed图的效果,如果车牌区域还是碎的,加大宽度;如果整个画面糊成一片,减小宽度。
3. 车牌定位与字符分割:从候选框到单个字符的完整链路
3.1 轮廓筛选:用长宽比和面积把真车牌挑出来
上一步得到closed图后,用cv2.findContours找所有连通区域,然后按长宽比过滤。中国蓝牌的长宽比大约是 440:140,接近 3:1。
# 找轮廓,RETR_EXTERNAL 只取最外层,CHAIN_APPROX_SIMPLE 压缩水平垂直斜线 contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) aspect_ratio = w / float(h) area = w * h # 长宽比在 2.0 到 5.5 之间,面积大于 2000 像素 if 2.0 < aspect_ratio < 5.5 and area > 2000: candidates.append((x, y, w, h)) # 按面积从大到小排序,取最大的几个 candidates.sort(key=lambda b: b[2] * b[3], reverse=True) for (x, y, w, h) in candidates[:5]: cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.imshow("candidates", img) cv2.waitKey(0)cv2.findContours在 OpenCV 4.x 里返回两个值(轮廓列表和层级),3.x 返回三个,网上很多老代码会报ValueError: not enough values to unpack,这是版本差异导致的。
长宽比范围2.0到5.5是覆盖蓝牌、黄牌、新能源绿牌的宽松区间。面积阈值2000是针对 720p 以上图像的经验值,如果图像小,要相应降低。排序后取前 5 个候选,是因为有时候车牌旁边有类似长宽比的干扰区域(比如车标、装饰条)。
3.2 透视校正:把歪着的车牌「掰正」
实际抓拍图里车牌往往有倾斜,直接分割字符会出错。用cv2.minAreaRect拿到最小外接旋转矩形,再做透视变换。
def deskew_plate(img, cnt): rect = cv2.minAreaRect(cnt) box = cv2.boxPoints(rect) box = np.int0(box) # 确定宽高,保证变换后车牌是横的 width = int(rect[1][0]) height = int(rect[1][1]) if width < height: width, height = height, width # 源点:旋转矩形的四个角 src_pts = box.astype("float32") # 目标点:标准矩形 dst_pts = np.array([[0, height-1], [0, 0], [width-1, 0], [width-1, height-1]], dtype="float32") # 透视变换矩阵 M = cv2.getPerspectiveTransform(src_pts, dst_pts) warped = cv2.warpPerspective(img, M, (width, height)) return warpedcv2.minAreaRect返回的rect[1]是(宽, 高),但顺序不固定,所以用if width < height强制把长的边作为宽。cv2.getPerspectiveTransform需要四个对应点,顺序必须一致(都是左上、右上、右下、左下或者按代码里的顺序)。cv2.warpPerspective的输出尺寸就是校正后的车牌图。
这一步做完,车牌就变成一张水平的标准矩形图,后面分割字符会稳定很多。
3.3 字符分割:垂直投影法找每个字符的边界
校正后的车牌图,先二值化,再算垂直投影——每一列白色像素的数量。字符所在列投影值高,字符间隙投影值低。
def segment_characters(plate_img): # 转灰度并二值化 gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 垂直投影:对每一列求和 projection = np.sum(binary, axis=0) / 255 # 找字符边界:投影值从低到高是字符开始,从高到低是字符结束 char_regions = [] in_char = False start = 0 threshold = max(projection) * 0.15 # 动态阈值 for i, val in enumerate(projection): if val > threshold and not in_char: in_char = True start = i elif val <= threshold and in_char: in_char = False if i - start > 5: # 过滤太窄的噪声 char_regions.append((start, i)) # 按区域裁剪字符 chars = [] for (s, e) in char_regions: char_img = binary[:, s:e] char_img = cv2.resize(char_img, (20, 40)) # 统一尺寸 chars.append(char_img) return charsnp.sum(binary, axis=0)对每一列求和,因为二值化后白色是 255,所以除以 255 得到白色像素个数。阈值用max(projection) * 0.15是动态的,比固定值适应性强。i - start > 5过滤掉宽度小于 5 像素的噪声区域。
裁剪出来的字符统一 resize 到(20, 40),是为了后面模板匹配时尺寸一致。这个尺寸不用太纠结,只要所有字符和模板都一致就行。
提示:如果分割出来的字符数量不对(中国车牌 7 个字符),先检查二值化效果。常见问题是车牌有边框,边框会被当成字符。可以在二值化后做一次
cv2.morphologyEx开运算去掉细边框。
4. 字符识别与结果输出:模板匹配的实战细节
4.1 模板库的构建:每个字符需要多少张样本
模板匹配的核心是「拿每个字符图和模板库里的图逐一比对,取最相似的」。模板库可以自己造:找一批车牌图,手动分割出字符,按类别存好。
我一般每个字符准备 5 到 10 张不同字体、不同光照的样本。中国车牌字符集包括:省份简称(京沪粤等)、字母(A-Z,去掉 I 和 O)、数字(0-9)。总共约 65 个类别。
模板库目录结构:
templates/ 京/ 01.png 02.png A/ 01.png 02.png 0/ 01.png 02.png加载模板的代码:
import os import cv2 import numpy as np def load_templates(template_dir): templates = {} for char_name in os.listdir(template_dir): char_path = os.path.join(template_dir, char_name) if not os.path.isdir(char_path): continue char_templates = [] for fname in os.listdir(char_path): fpath = os.path.join(char_path, fname) img = cv2.imread(fpath, cv2.IMREAD_GRAYSCALE) if img is not None: img = cv2.resize(img, (20, 40)) _, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) char_templates.append(img) templates[char_name] = char_templates return templatescv2.imread第二个参数用IMREAD_GRAYSCALE直接读成灰度图,省一步转换。每个模板都 resize 到(20,40)并二值化,保证和待识别字符在同一尺度上。
4.2 模板匹配识别:cv2.matchTemplate 的三种方法对比
def recognize_char(char_img, templates): best_char = "?" best_score = -1 for char_name, char_templates in templates.items(): for tmpl in char_templates: # TM_CCOEFF_NORMED 返回归一化相关系数,范围 -1 到 1 result = cv2.matchTemplate(char_img, tmpl, cv2.TM_CCOEFF_NORMED) _, max_val, _, _ = cv2.minMaxLoc(result) if max_val > best_score: best_score = max_val best_char = char_name return best_char, best_scorecv2.matchTemplate支持多种方法,常用的三种:
| 方法 | 含义 | 适用场景 |
|---|---|---|
| TM_SQDIFF | 平方差匹配,值越小越相似 | 图像亮度一致时 |
| TM_CCORR_NORMED | 归一化相关匹配 | 亮度有变化时 |
| TM_CCOEFF_NORMED | 归一化相关系数匹配 | 综合最稳定,推荐 |
TM_CCOEFF_NORMED对亮度变化不敏感,返回 0 到 1 之间的相似度(实际范围 -1 到 1,但字符图都是正相关)。cv2.minMaxLoc返回四个值:最小值、最大值、最小值位置、最大值位置。我们只关心最大值。
如果最高分低于 0.5,说明这个字符可能不在模板库里,或者分割质量太差。我一般会设一个置信度阈值,低于阈值就输出?而不是硬猜。
4.3 完整流水线串联与结果可视化
把前面所有步骤串起来:
def recognize_plate(image_path, template_dir): img = cv2.imread(image_path) if img is None: return "图像读取失败" gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) blurred = cv2.GaussianBlur(gray, (5, 5), 0) sobel_x = cv2.Sobel(blurred, cv2.CV_16S, 1, 0, ksize=3) abs_x = cv2.convertScaleAbs(sobel_x) _, binary = cv2.threshold(abs_x, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if 2.0 < w / float(h) < 5.5 and w * h > 2000: candidates.append((x, y, w, h, cnt)) if not candidates: return "未检测到车牌区域" candidates.sort(key=lambda b: b[2] * b[3], reverse=True) x, y, w, h, cnt = candidates[0] plate_img = deskew_plate(img, cnt) chars = segment_characters(plate_img) templates = load_templates(template_dir) result = "" for char_img in chars: char, score = recognize_char(char_img, templates) result += char # 在原图上画框 cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 0), 2) cv2.putText(img, result, (x, y - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2) cv2.imshow("result", img) cv2.waitKey(0) return result这段代码把前面所有函数串起来,输入图像路径和模板目录,输出识别结果字符串。cv2.putText把结果画在原图上方,方便直观验证。
注意:
deskew_plate和segment_characters函数需要定义在前面,这里为了篇幅没重复贴。实际运行时把三个函数放在同一个文件里即可。
5. 避坑与排查:车牌识别代码最常见的 5 个翻车现场
5.1 报错 ModuleNotFoundError: No module named 'cv2'
现象:import cv2直接报模块找不到。
原因:包名和导入名不一致。pip 安装的是opencv-python,但导入用的是cv2。很多人pip install cv2装了个空包或者装错包。
解决:卸载重装。pip uninstall cv2 opencv-python opencv-python-headless,然后pip install opencv-python。如果用的是虚拟环境,确认 pip 和 python 是同一个环境的。
5.2 车牌定位框跑到背景上去了
现象:画出来的绿色框不在车牌位置,而是框住了保险杠、车灯或者背景。
原因:形态学核尺寸不合适,导致非车牌区域也被连成大块。或者长宽比范围太宽,把其他矩形物体也放进来了。
解决:先单独显示closed图,看车牌区域是否完整、背景是否干净。如果背景噪声多,缩小闭运算核的宽度;如果车牌碎了,加大宽度。长宽比范围可以收紧到2.5到4.5,面积阈值根据实际图像调整。
5.3 字符分割数量不对,多一个或少一个
现象:中国车牌应该是 7 个字符,但分割出来 6 个或 8 个。
原因:车牌边框被当成字符,或者两个字符粘连在一起没分开。
解决:二值化后先做一次开运算去掉细边框:cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((3,3), np.uint8))。如果字符粘连,在垂直投影找边界时降低阈值系数(从 0.15 降到 0.1),让分割更敏感。
5.4 模板匹配识别率低,经常认错
现象:字符分割对了,但识别结果乱七八糟,比如把「京」认成「津」。
原因:模板库样本太少,或者模板和待识别字符的字体差异太大。也可能是二值化后字符笔画粗细不一致。
解决:每个字符至少 5 张样本,覆盖不同光照和字体。识别前对字符图做归一化:统一 resize 到(20,40),统一用 Otsu 二值化。如果还是不行,考虑用cv2.matchShapes做轮廓匹配作为补充。
5.5 在 Ubuntu 上 cv2.imshow 报 GTK 错误
现象:cv2.imshow报cv2.error: OpenCV(4.x) ... The function is not implemented. Rebuild the library with Windows, GTK+ 2.x or Cocoa support。
原因:装的是opencv-python-headless,这个版本不含 GUI 功能。
解决:pip uninstall opencv-python-headless,然后pip install opencv-python。如果系统缺 GTK 库,补sudo apt-get install libgtk2.0-dev pkg-config。在服务器上没法弹窗的话,把cv2.imshow换成cv2.imwrite保存到文件再看。
6. 从能跑到好用:三个提升识别率的实战技巧
6.1 用颜色掩膜先筛蓝牌区域
前面用的是纯边缘检测,对蓝色车牌其实可以先用颜色信息缩小范围。HSV 空间里蓝色有明确的取值范围:
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色的 HSV 范围,需要根据实际图像微调 lower_blue = np.array([100, 80, 80]) upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue)cv2.inRange输出二值掩膜,蓝色区域为 255,其他为 0。把这个掩膜和边缘检测结果做与运算,可以大幅减少背景干扰。lower_blue和upper_blue的 H 通道范围100到124覆盖了大部分蓝色车牌,S 和 V 的下限80是为了排除灰暗区域。
这个技巧对蓝牌效果明显,但黄牌和绿牌需要另外调范围。如果场景里车牌颜色不固定,还是用纯边缘方案更通用。
6.2 多帧投票:视频流里用连续帧提升稳定性
单张图识别容易受噪声影响,如果是视频流,可以连续处理多帧,对结果做多数投票:
from collections import Counter results = [] cap = cv2.VideoCapture("traffic.mp4") frame_count = 0 while cap.isOpened() and frame_count < 30: ret, frame = cap.read() if not ret: break # 每隔 5 帧处理一次,减少计算量 if frame_count % 5 == 0: result = recognize_plate_frame(frame, templates) if result and len(result) == 7: results.append(result) frame_count += 1 cap.release() if results: # 取出现次数最多的结果 final = Counter(results).most_common(1)[0][0] print("最终识别结果:", final)Counter统计每个结果出现的次数,most_common(1)取最高频的。这个方法对偶尔的单帧误识别有很好的过滤效果。注意只统计长度为 7 的结果,过滤掉明显错误的输出。
6.3 置信度阈值与人工复核的平衡
模板匹配的分数不是万能的。我一般设两个阈值:高于 0.7 直接输出,0.5 到 0.7 之间标记为「待确认」,低于 0.5 输出?。这样在批量处理时,可以把低置信度的结果单独拿出来人工看,而不是全部重新跑。
def recognize_with_confidence(char_img, templates, high=0.7, low=0.5): best_char, best_score = recognize_char(char_img, templates) if best_score >= high: return best_char, "确认" elif best_score >= low: return best_char, "待确认" else: return "?", "失败"这套逻辑在实际项目里比单纯追求「全自动」更实用。车牌识别做到 95% 准确率不难,但最后 5% 往往需要人工兜底。把置信度信息暴露出来,比硬猜一个错误答案有价值得多。
我自己在这个项目上踩过最大的坑,是早期太迷信「调参能解决一切」,花了两天调形态学核尺寸,结果发现根本问题是输入图像分辨率太低,车牌区域只有 80 像素宽,字符都糊成一团了。后来换了一批 1080p 的图,同样的参数直接跑通。所以如果你也卡在识别率上,先回头看看输入图像的质量——这个后悔药我替你吃了。希望帮到你。
本文还有配套的精品资源,点击获取