☰
PyCharm+Python+OpenCV车牌识别实战:从图像预处理到字符分割
2026/10/20 13:10:24 网站建设 项目流程

简介:这份资源是一套基于Python、PyCharm与OpenCV的车牌识别项目实战包,面向具备一定Python基础、希望入门计算机视觉与深度学习应用的开发者,可用于交通监控、智能停车等场景的学习与二次开发。包内共69个文件,以21个py源码脚本、23个pyc编译文件、7个h5模型权重、7张jpg示例图及gif演示动图为主,另含xml配置、ttf字体、mp4效果视频与README说明,压缩包约35.89MB,目录结构清晰,便于按模块查阅。项目完整覆盖图像预处理、车牌定位、文本分割与字符识别等关键环节,并涉及Canny边缘检测、轮廓筛选、连通组件分析及CNN模型训练思路,同时提供可运行的检测脚本与模型文件,方便读者对照调试、理解算法流程并快速复现效果。目前已有876人学习下载,适合作为计算机视觉入门与课程设计的参考案例。

1. 从一张歪斜的车牌照片说起:PyCharm + Python + OpenCV 到底能识别到什么程度

很多人第一次做车牌识别,手里只有一张手机拍的、角度歪斜、光线不均的车牌照片,打开 PyCharm 新建一个 Python 文件,import cv2之后却不知道下一步该写什么。这个标题讲的就是这条最朴素的链路:在 PyCharm 里用 Python 调 OpenCV,把一张车牌图变成可读的字符串。它不依赖深度学习框架,不需要 GPU,一台普通笔记本就能跑通,适合刚接触计算机视觉的开发者、需要做课程设计的学生,以及想快速验证车牌识别可行性的工程师。OpenCV 负责图像预处理、边缘检测、轮廓筛选和字符分割,真正的字符分类可以用模板匹配或轻量级分类器完成。整条链路的核心难点不在模型,而在预处理——车牌定位准不准、二值化干不干净、字符切分有没有粘连,直接决定最终识别率。下面按实际动手顺序拆开讲。

2. 环境搭好只是开始:PyCharm 工程结构与 OpenCV 安装的四个关键决策

2.1 为什么选 PyCharm 而不是 Jupyter

车牌识别是一个多步骤流水线:读图、灰度、滤波、边缘检测、形态学、轮廓筛选、透视校正、字符分割、字符识别。每一步都需要反复调参和可视化中间结果。Jupyter 适合探索,但变量状态容易混乱,改一个参数要重跑整个 Notebook。PyCharm 的调试器可以打断点、看变量、单步执行,配合cv2.imshow能快速定位是哪一步把车牌弄丢了。我一般会在 PyCharm 里建一个纯 Python 项目,把每个处理步骤封装成独立函数,主流程按顺序调用,中间结果统一存到debug/目录,方便对比。

2.2 虚拟环境与 OpenCV 安装的版本坑

在 PyCharm 里新建项目时,解释器选虚拟环境,Python 版本建议 3.8 到 3.10。OpenCV 用opencv-python就够,不需要opencv-contrib-python,除非你要用 SIFT 或 OCR 模块。安装命令如下:

# 在 PyCharm 的 Terminal 中执行 pip install opencv-python==4.8.1.78 pip install numpy==1.24.3 pip install pillow==10.0.0

这里锁版本是有血泪经验的:OpenCV 4.9 之后某些形态学操作的默认行为有微调,导致同样的参数在不同版本上轮廓数量不一致。NumPy 1.24 以下和 OpenCV 4.8 搭配最稳。Pillow 只用来在调试时拼接对比图,不是必须。

2.3 工程目录怎么分

license_plate_recognition/ ├── main.py # 主流程入口 ├── preprocess.py # 灰度、滤波、边缘检测 ├── locate.py # 车牌定位与透视校正 ├── segment.py # 字符分割 ├── recognize.py # 字符识别 ├── debug/ # 中间结果输出 ├── templates/ # 字符模板图(如果走模板匹配) └── test_images/ # 测试图

这样分的好处是:调定位时只改locate.py,调分割时只改segment.py,不会互相干扰。每个模块都可以单独写一个if __name__ == '__main__'做快速测试。

2.4 读图与显示的第一个最小闭环

import cv2 import numpy as np def read_and_show(image_path): # 用 imdecode 读中文路径,避免 cv2.imread 在 Windows 上返回 None img = cv2.imdecode(np.fromfile(image_path, dtype=np.uint8), cv2.IMREAD_COLOR) if img is None: raise FileNotFoundError(f"读不到图:{image_path}") print(f"图像尺寸:{img.shape}") # (高, 宽, 通道) cv2.imshow("original", img) cv2.waitKey(0) cv2.destroyAllWindows() return img if __name__ == "__main__": read_and_show("test_images/car1.jpg")

逻辑说明:cv2.imread在 Windows 上遇到中文路径会静默返回None,用np.fromfile+cv2.imdecode绕过。img.shape返回(height, width, channels),后面所有坐标操作都要记住这个顺序。waitKey(0)表示无限等待按键,调试时按任意键关闭窗口。

参数说明:cv2.IMREAD_COLOR强制三通道,即使原图是灰度也统一成 BGR,避免后续代码分支。如果图片特别大(超过 2000 像素宽),建议先cv2.resize到 1000 像素宽再处理,否则轮廓检测会非常慢。

3. 车牌定位:从边缘到轮廓,参数怎么调才不翻车

3.1 灰度、高斯滤波、Sobel 边缘检测的串联逻辑

车牌定位的经典思路是:车牌区域有密集的垂直边缘(字符笔画),先用 Sobel 算水平方向梯度,再二值化,然后用形态学把字符连成块,最后找轮廓。步骤如下:

import cv2 import numpy as np def locate_plate(img): # 1. 灰度 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2. 高斯滤波,核大小必须是奇数 blur = cv2.GaussianBlur(gray, (5, 5), 0) # 3. Sobel 水平方向梯度,ksize=3 是默认值 sobel = cv2.Sobel(blur, cv2.CV_8U, 1, 0, ksize=3) # 4. 二值化,阈值 0 表示自动用 Otsu _, binary = cv2.threshold(sobel, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 5. 闭运算把字符连成矩形块,核大小决定连接强度 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # 6. 找轮廓 contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 7. 按面积和长宽比筛选 candidates = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) area = w * h ratio = w / float(h) if 2000 < area < 50000 and 2.0 < ratio < 5.5: candidates.append((x, y, w, h)) return candidates, binary, closed

逻辑说明:Sobel 的ddepth用cv2.CV_8U而不是cv2.CV_64F,是因为后续直接二值化,不需要保留负梯度。THRESH_OTSU会自动算阈值,适合光照不均的场景。闭运算的核(17, 5)是经验值:宽度 17 能把相邻字符连起来,高度 5 避免把上下两行连在一起。

参数说明:GaussianBlur的核越大越模糊,边缘越少,但太小会保留噪声。我一般从(5,5)开始,如果轮廓太碎就加到(7,7)。Sobel的ksize用 3 就够,5 或 7 会更粗但更慢。面积范围2000~50000是针对 1000 像素宽图片的,如果图片更大要按比例放大。

3.2 透视校正:把歪车牌拉正

找到候选轮廓后,车牌可能是倾斜的。常见做法是用cv2.minAreaRect拿到最小外接矩形,再取四个顶点做透视变换:

def correct_perspective(img, rect): # rect 是 cv2.minAreaRect 返回的 ((cx, cy), (w, h), angle) box = cv2.boxPoints(rect) box = np.int0(box) # 对四个点排序:左上、右上、右下、左下 rect_pts = order_points(box) # 目标矩形宽高 dst_w = int(max(np.linalg.norm(rect_pts[0] - rect_pts[1]), np.linalg.norm(rect_pts[2] - rect_pts[3]))) dst_h = int(max(np.linalg.norm(rect_pts[0] - rect_pts[3]), np.linalg.norm(rect_pts[1] - rect_pts[2]))) dst = np.array([[0, 0], [dst_w - 1, 0], [dst_w - 1, dst_h - 1], [0, dst_h - 1]], dtype=np.float32) M = cv2.getPerspectiveTransform(rect_pts.astype(np.float32), dst) warped = cv2.warpPerspective(img, M, (dst_w, dst_h)) return warped def order_points(pts): # 按 x 和 y 之和排序,最小的左上,最大的右下 rect = np.zeros((4, 2), dtype=np.float32) s = pts.sum(axis=1) rect[0] = pts[np.argmin(s)] rect[2] = pts[np.argmax(s)] diff = np.diff(pts, axis=1) rect[1] = pts[np.argmin(diff)] rect[3] = pts[np.argmax(diff)] return rect

逻辑说明:order_points用坐标和与坐标差来区分四个角,比按角度排序更稳。getPerspectiveTransform需要源点和目标点都是float32,否则会报错。warpPerspective的输出尺寸用(宽, 高),注意 OpenCV 的尺寸参数是(cols, rows)。

参数说明:如果车牌倾斜角度超过 30 度,minAreaRect的angle可能返回 90 度附近的値,需要手动归一化到[-45, 45]。透视变换后如果字符被拉得太扁,检查dst_w和dst_h是否算反了。

3.3 颜色辅助定位:HSV 阈值怎么设

蓝色车牌在 HSV 空间有固定范围,可以作为边缘检测的补充:

def blue_mask(img): hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # 蓝色范围,H 在 100~124,S 和 V 下限根据光照调 lower = np.array([100, 80, 80]) upper = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower, upper) # 开运算去噪 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) return mask

逻辑说明:HSV 的 H 通道在 OpenCV 里是 0~179,不是 0~360。蓝色对应 100~124。S 和 V 的下限决定颜色容差,光照暗时 V 要降低,颜色淡时 S 要降低。

参数说明:如果车牌是绿色(新能源),H 范围改成 35~85。黄色车牌 H 在 20~35。白色车牌没有明显色相,只能靠边缘检测。实际项目中我一般把颜色掩码和边缘检测结果做bitwise_or,再找轮廓。

4. 字符分割与识别:模板匹配够用吗,什么时候必须上分类器

4.1 二值化与垂直投影分割

定位到车牌后,先转灰度、二值化,再用垂直投影找字符间隙:

def segment_chars(plate_img): gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) # 自适应二值化,应对光照不均 binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 19, 5) # 垂直投影:统计每列白色像素数 col_sum = np.sum(binary, axis=0) // 255 # 找字符区间 chars = [] in_char = False start = 0 for i, v in enumerate(col_sum): if v > 2 and not in_char: in_char = True start = i elif v <= 2 and in_char: in_char = False if i - start > 5: # 过滤太窄的噪声 chars.append((start, i)) # 按宽度过滤,车牌第一个字符通常是汉字,宽度较大 result = [] for s, e in chars: w = e - s if 8 < w < 60: result.append(binary[:, s:e]) return result

逻辑说明:adaptiveThreshold的THRESH_BINARY_INV让字符变白、背景变黑,方便投影统计。col_sum除以 255 得到每列白色像素个数。阈值v > 2是经验值,太小会把噪声当字符,太大会漏掉细笔画。

参数说明:blockSize=19是自适应二值化的邻域大小,必须是奇数。C=5是常数,从均值里减去,值越大二值化越激进。如果字符断裂,把C调小;如果噪声太多,把C调大。

4.2 模板匹配的适用边界

模板匹配适合字符字体固定、尺寸归一化后的场景。把每个字符缩放到统一大小(如 20x40),和模板库做cv2.matchTemplate:

def match_template(char_img, templates): char_resized = cv2.resize(char_img, (20, 40)) best_score = -1 best_label = '?' for label, tmpl in templates.items(): res = cv2.matchTemplate(char_resized, tmpl, cv2.TM_CCOEFF_NORMED) score = res[0][0] if score > best_score: best_score = score best_label = label return best_label, best_score

逻辑说明:TM_CCOEFF_NORMED返回 -1 到 1 的相似度,越接近 1 越像。模板库需要提前准备,每个字符至少 3 个不同字体的样本。

参数说明:如果最高分低于 0.5,说明模板库覆盖不够,或者字符分割有问题。模板匹配对汉字效果差,因为汉字笔画复杂,缩放后差异大。常见做法是汉字单独用一个小型 CNN 分类,数字和字母用模板匹配。

4.3 什么时候必须上分类器

当出现以下情况时,模板匹配会集体翻车:车牌有磨损、光照导致字符断裂、字体不是标准字体、字符有粘连。这时候需要训练一个轻量级分类器。我一般用 3 层 CNN,输入 20x40 灰度图,输出 65 类(10 数字 + 24 字母 + 31 省份简称)。训练数据可以用公开车牌数据集生成,不需要自己标注。推理时用cv2.dnn加载 ONNX 模型,不依赖 PyTorch:

net = cv2.dnn.readNetFromONNX("plate_char.onnx") blob = cv2.dnn.blobFromImage(char_img, 1/255.0, (40, 20), (0, 0, 0), swapRB=False) net.setInput(blob) preds = net.forward() label = np.argmax(preds)

逻辑说明:blobFromImage的swapRB=False因为输入是灰度图。size=(40, 20)是(宽, 高),和训练时保持一致。preds是 65 维向量,取最大值下标。

参数说明:如果识别率突然下降,先检查输入尺寸和归一化是否和训练一致。ONNX 模型的输入名可以用net.getLayerNames()查看。

5. 避坑与排查:车牌识别里最容易翻车的五个地方

5.1 现象:cv2.imread返回 None,后面全部报错

原因:图片路径含中文,或者路径根本不存在。Windows 上imread对中文路径静默失败。

解决:统一用np.fromfile+cv2.imdecode读图,并在读图后立刻判断img is None,打印路径。

5.2 现象:轮廓找到几百个,车牌混在里面找不到

原因:边缘检测后没有做形态学闭运算,或者闭运算核太小,字符没连成块。

解决:先可视化closed图,确认车牌区域是一个白色矩形块。如果还是碎的,把闭运算核宽度加到 21 或 25。同时用面积和长宽比双重过滤,面积范围按图片宽度等比缩放。

5.3 现象:字符分割后多出很多小碎片

原因:二值化后噪声没去掉,或者垂直投影阈值太低。

解决:在二值化后加一次开运算cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel),核大小(2,2)。投影阈值从v > 2提到v > 3,并过滤宽度小于 8 像素的区间。

5.4 现象:模板匹配把 8 认成 B,把 0 认成 D

原因:模板库字体和实际车牌字体差异大,或者字符缩放时插值方式不对。

解决:模板库至少覆盖两种常见字体。缩放用cv2.INTER_AREA而不是默认的双线性,前者在缩小图像时更少引入伪影。如果还是混淆,对易混字符对(8/B、0/D、5/S)单独训练一个二分类器做二次判断。

5.5 现象:换一张图识别率骤降,参数完全不能用

原因:所有参数都是针对某一张图硬调的,没有做归一化。

解决:把图片统一缩放到固定宽度(如 1000 像素),所有核大小、面积范围、阈值都基于这个宽度设定。如果原图宽度差异大,先cv2.resize再处理。另外,HSV 颜色范围要根据光照条件动态调整,不要写死。

6. 把识别率从 70% 推到 90% 的一个具体技巧:双通道定位加置信度过滤

前面几章把单张图的流程跑通了,但实际用起来会发现:有些图边缘检测能定位,有些图颜色掩码能定位,单独用任何一种都有漏检。我后来固定用一个双通道策略:边缘通道和颜色通道各自出候选框,取交集或并集,再用置信度打分排序。具体做法是,边缘通道按第 3 章的流程拿到候选框列表,颜色通道按 HSV 掩码拿到候选框列表,两个列表做 IOU 匹配,IOU 大于 0.3 的合并为一个候选,分数加 1;只在一个通道出现的候选分数为 0.5。最后按分数排序,取最高分且长宽比在 2.0 到 5.5 之间的框。

def iou(box1, box2): x1, y1, w1, h1 = box1 x2, y2, w2, h2 = box2 xi1 = max(x1, x2) yi1 = max(y1, y2) xi2 = min(x1 + w1, x2 + w2) yi2 = min(y1 + h1, y2 + h2) inter = max(0, xi2 - xi1) * max(0, yi2 - yi1) union = w1 * h1 + w2 * h2 - inter return inter / union if union > 0 else 0 def merge_candidates(edge_boxes, color_boxes): scored = [] for eb in edge_boxes: best_iou = 0 for cb in color_boxes: best_iou = max(best_iou, iou(eb, cb)) scored.append((eb, 1.0 if best_iou > 0.3 else 0.5)) for cb in color_boxes: if not any(iou(cb, eb) > 0.3 for eb in edge_boxes): scored.append((cb, 0.5)) scored.sort(key=lambda x: x[1], reverse=True) return scored

逻辑说明:iou计算两个矩形的交并比,大于 0.3 认为两个通道都看到了同一个车牌。分数 1.0 表示双通道确认,0.5 表示单通道。排序后取第一个,如果第一个分数是 0.5,说明两个通道都没同时确认,这时候可以拒绝识别,返回“未检测到车牌”,而不是硬猜。

参数说明:IOU 阈值 0.3 是经验值,调高到 0.5 会更严格,漏检增加;调到 0.2 会更宽松,误检增加。长宽比范围 2.0 到 5.5 覆盖了蓝牌、黄牌、新能源绿牌,如果只做蓝牌可以缩到 2.5 到 4.5。

这个技巧把识别率从 70% 推到 90% 的关键不在算法多复杂,而在“拒绝识别”这个动作。单通道方案遇到模糊图会强行输出一个错误结果,双通道加置信度过滤后,低置信度的图直接返回未检测,后续可以提示用户重拍。我自己的习惯是:任何一步的输出如果置信度低于阈值,宁可返回空,也不要返回一个看起来像但实际错的字符串。车牌识别最终是要用的,错一个字符比不识别更麻烦。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询