简介:一套基于 Python 的车辆牌照识别系统完整源码,面向计算机视觉初学者、毕业设计者以及需要快速搭建车牌检测识别原型的开发者。项目覆盖图像采集、车牌定位、字符分割到车牌内容识别的全流程,内置可直接运行的 Python 脚本、界面文件和训练数据,可用于算法验证和二次开发。资源包共 48 个文件,约 30.78MB,为 zip 压缩包,主要包含 Python 源文件、UI 与资源配置、PNG/JPG 测试图片、svm/dat 模型数据、操作演示 mp4 以及 readme 说明等;其中 7 个 Python 脚本覆盖训练、分割、识别等关键环节,目录结构清晰,便于按功能模块逐个学习。已有 461 人浏览学习。通过这套源码,可理解 SVM 模型在车牌识别中的实际应用方式,掌握界面与核心识别逻辑的衔接,并借助 demo 录屏与分类测试图片快速验证识别效果,适合作为课程设计或工程起步参考。
1. 车辆牌照识别系统到底在做什么:一套源码的三个必解问题
小区地库入口那台摄像头,每天要判断一辆陌生车能不能抬杆,靠的就是一套车牌识别系统。把整套思路压缩成一段 Python 源码,无非三件事:从一整张照片里把车牌区域找出来,把车牌上的字符逐个切开,再把每个字符认出来。很多入门项目卡在“能跑 demo”和“能在真实照片上稳定识别”之间,差距不在算法多新,而在对图像处理的细节有多熟。这篇文章围绕“基于 Python 的车辆牌照识别系统设计源码”展开,讲清楚一套可复现的源码该包含哪几块、参数怎么调、哪里最容易翻车,适合想用 OpenCV 和 PyTorch 从零搭一套识别系统的开发者。
2. 方案选型与整体架构:为什么我不建议一步到位做端到端识别
2.1 三条技术路线的对比:选型决定你要填多少坑
做车牌识别,常见做法有三条路,我按工程落地的顺序给你排一下。
第一条是纯传统视觉:OpenCV 做定位、分割,再用模板匹配或者 HOG+SVM 做字符识别。优点是依赖少、单张图片跑得快、代码逻辑透明,出一行结果你都知道是哪个环节丢的;缺点是字符识别对字体和清晰度非常敏感,遇到新能源绿牌、污损车牌,准确率掉得厉害。第二条是端到端深度学习:用 LPRNet 这类网络直接把整张图送进去,输出一串车牌字符。好处是不用手工设计分割规则,训练数据够多时鲁棒性最好;坏处是你需要大量标注好的整牌数据,而且中间过程是个黑匣子,某张图识别错了,很难定位是定位错还是识别错。第三条是混合路线:OpenCV 定位和分割,字符识别交给一个小型 CNN。我一般推荐这条,因为定位和分割的规则是透明的,出错了能看图调参;字符识别部分用 CNN 扛住了字体和清晰度的变化。这套方案也是很多课程设计和生产系统的起点。
对比下来,选型逻辑很清楚:如果只是交作业或验证思路,第一条就够了;如果手上有一两万张标注图片,直接走第二条;如果是做真实场景的识别系统,用第三条最稳。下面整篇文章的代码和参数都围绕第三条展开。
2.2 源码目录结构与处理链路:拿到代码先看哪几个文件
一套能让你改得动的车牌识别源码,目录结构不会太复杂。我习惯这样组织:
plate_recognition/ ├── data/ # 训练图片、测试图片、标注文件 ├── models/ # 训练好的权重文件 ├── src/ │ ├── preprocess.py # 灰度、去噪、增强 │ ├── locate.py # 车牌定位 │ ├── segment.py # 字符分割 │ ├── recognize.py # 字符识别 │ └── utils.py # 省份简写映射、可视化 ├── config.py # 所有可调参数集中放这里 ├── train.py # 训练字符分类器 └── main.py # 命令行入口拿到源码,我建议先读config.py,再看main.py的调用顺序。因为定位、分割、识别这三个模块之间的数据流是串行的:输入一张 BGR 图,preprocess 转灰度或 HSV,locate 输出候选车牌区域,segment 从候选区切出单个字符,recognize 对每个字符做分类,最后把省份汉字和字母数字拼回一个字符串。任何一个环节输出的坐标和尺寸变了,下游都要跟着调。utils.py里那张省份简写映射表特别容易被忽略,粤、鲁、京这些汉字不在常用 3755 个一级汉字里,训练时类别数少一个都会导致识别结果乱码。
3. 车牌定位与字符分割的 OpenCV 实现:从 HSV 颜色空间到垂直投影
3.1 用 HSV 阈值把蓝色车牌从复杂背景里抠出来
定位车牌的第一步是找到候选区域。真实场景里,背景可能是路面、绿化带、车身、广告牌,直接用灰度图做边缘检测会产生大量噪声。我一般先靠颜色把蓝色车牌筛一遍,再结合宽高比过滤。
import cv2 import numpy as np def locate_plate_by_color(img_bgr): # BGR 转 HSV:OpenCV 的 H 通道范围是 0~180 hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) # 蓝牌在 H 通道约 100~124,S 和 V 取中高值抗光照干扰 lower = np.array([100, 90, 90]) upper = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower, upper) # 用长条形的核做闭运算,把断裂的字符区域连成整体 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 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) ratio = w / h # 标准车牌宽高比约 3~4,放宽到 2~5.5 兼容绿牌和倾斜 area_ratio = w * h / (img_bgr.shape[0] * img_bgr.shape[1]) if 2.0 <= ratio <= 5.5 and area_ratio > 0.002: candidates.append((x, y, w, h)) return candidates这段代码的逻辑是:把 RGB 图转到 HSV 颜色空间,因为 HSV 对光照变化比 RGB 稳定,蓝色在 RGB 里受亮度影响太大,在 HSV 里只要 H 通道落在蓝色区间就行。inRange生成二值掩膜后,车牌区域可能被字符的空洞和反光切成好几块,所以用一个 17x5 的矩形核做闭运算。核的宽高比故意做成长条形,贴合车牌形状,如果改成正方形核,很容易把车身蓝色装饰条一起并进来。最后用宽高比和面积占比过滤,ratio 下限取 2.0 是因为新能源绿牌比蓝牌更宽,上限取 5.5 给远景变形留余量。area_ratio 取 0.002 是个经验值,太小的框基本都是远处广告牌上的蓝色块。
这一步最容易翻车的是把蓝色车身误判成车牌。如果你发现 candidates 里出现大量车身区域,先把 area_ratio 调大,再看 HSV 的 S 通道下限。车身蓝色通常是深蓝或灰蓝,饱和度偏低,把 S 下限从 90 提到 120 能筛掉一大部分。
3.2 倾斜校正:不做这一步,字符分割一定错位
摄像头拍到的车牌很少是严格水平的,特别是地库出口那种大角度仰拍。字符分割前不做倾斜校正,每个字符的投影都会带斜切,切出来的框要么把两个字符并到一起,要么把一个字符从中间劈开。常见做法是用轮廓的最小外接矩形倾斜角,或者用霍夫变换检测车牌上下边缘的直线角度。
def deskew(plate_img, angle): # 根据检测到的角度做仿射旋转,保持车牌内容不裁切 h, w = plate_img.shape[:2] center = (w // 2, h // 2) matrix = cv2.getRotationMatrix2D(center, angle, 1.0) # 计算旋转后画布尺寸,避免四角被裁掉 cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1]) new_w = int(h * sin + w * cos) new_h = int(h * cos + w * sin) matrix[0, 2] += (new_w - w) / 2 matrix[1, 2] += (new_h - h) / 2 return cv2.warpAffine(plate_img, matrix, (new_w, new_h))一个非常容易踩的坑是旋转后直接用原图尺寸做 warpAffine,结果四个角被裁掉,字符缺失。上面代码里把旋转矩阵第 0 行第 2 列和第 1 行第 2 列平移量重新计算了,保证旋转后的画布能完整装下整个车牌。角度来源可以从 HoughLinesP 检测到的长直线角度取平均,也可以用轮廓的cv2.minAreaRect返回的 angle 属性。注意 OpenCV 里这个 angle 的范围是 [-90, 0],取负后才是真正需要的旋转角度。
3.3 垂直投影分割字符:汉字部分为什么要特殊处理
分割字符最常用的是垂直投影法:把二值化后的车牌按列统计白色像素数量,字符区域会出现波峰,字符间隙会出现波谷,根据波谷位置切分即可。
def split_characters(bin_img): # 输入是二值化后的车牌灰度图,白字黑底或黑字白底需统一 h, w = bin_img.shape # 按列求和,白像素计数 col_sum = bin_img.sum(axis=0) / 255 # 找出所有连续非空的列区间 segments = [] start = None for i in range(w): if col_sum[i] > 0 and start is None: start = i elif col_sum[i] == 0 and start is not None: if i - start > 3: # 过滤掉噪声导致的窄段 segments.append((start, i - 1)) start = None if start is not None: segments.append((start, w - 1)) # 宽度小于平均宽度 25% 的段,多半是铆钉或边框,丢弃 widths = [s[1] - s[0] + 1 for s in segments] avg_w = sum(widths) / len(widths) if widths else 0 chars = [seg for seg, wd in zip(segments, widths) if wd > avg_w * 0.25] return chars常见的七个字符,正常情况会切出七个段:第一个是省份汉字,第二个是字母,后面五个是数字或字母。但汉字比字母宽,且“京”“苏”这类字的内部结构本身有断开,投影后可能出现一个汉字被切成两段的情况。解决办法是切完后再做一次宽度校验:标准车牌的字符宽度比较均匀,如果某个段的宽度不到平均宽度的 60%,就检查它左右两边是不是紧挨着另一个窄段,如果是就能合并。我上面代码里只做了丢弃窄段,生产环境建议再加一个合并逻辑,最简单的方式是拿切出来的所有段宽度排序,把明显偏窄且相邻距离为一个像素的段合并回去。
还有一个细节:二值化的极性必须统一。白底黑字的图片和黑底白字的图片,在bin_img.sum(axis=0)这一行出来的结果正好相反。我在预处理时通常会加一步判断,统计整张图的平均像素值,超过 127 就取反,确保进入分割的图片都是黑底白字。
4. 字符识别模型与训练策略:PyTorch CNN 的搭建与三档参数
4.1 为什么不用模板匹配:字体和清晰度的差异扛不住
很多人第一次做字符识别时用模板匹配,拿标准字体图片跟分割出来的字符做像素对比。模板匹配在打印体、无噪声的合成图上效果不错,但真实车牌有锈蚀、泥点、反光,同一字符在粗细、倾斜、明暗上差异很大,模板匹配的准确率会掉到不足以支撑一个系统。换成 CNN 之后,模型能学到字符的结构特征而不是像素值,对轻微形变和噪声的容忍度高很多。
另一个原因是类别管理。中国车牌字符包括 31 个省份汉字、24 个字母(I 和 O 通常不用,因为容易和 1、0 混淆)、10 个数字。如果用模板匹配,每个字符要准备多套模板,工作量巨大;用 CNN 就是建一个 65 类的分类器(31+24+10),数据往文件夹一放就行。绿牌是 8 位字符,蓝牌是 7 位,我一般让分割模块先输出字符数量,再决定拼接格式。
4.2 搭建一个轻量 CNN 分类器:参数量控制在一百万以内
这里给一个适合单字符分类的 CNN 结构。输入是 32x20 的灰度图,这是我把分割出的字符统一缩放后的尺寸,长宽比接近真实字符。
import torch.nn as nn class CharClassifier(nn.Module): def __init__(self, num_classes=65): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 32, kernel_size=3, padding=1), # (32, 32, 20) nn.ReLU(), nn.MaxPool2d(2), # (32, 16, 10) nn.Conv2d(32, 64, kernel_size=3, padding=1), # (64, 16, 10) nn.ReLU(), nn.MaxPool2d(2), # (64, 8, 5) nn.Conv2d(64, 128, kernel_size=3, padding=1),# (128, 8, 5) nn.ReLU(), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 8 * 5, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))两个卷积池化加一个三层卷积,参数量不到一百万,单张字符图在 CPU 上推理时间在毫秒级。这里的关键是把输入尺寸固定好,我见过不少人直接拿原始字符图训练,尺寸不统一导致模型收敛很慢。还有一个容易被忽略的问题:汉字、字母、数字的类别数量不平衡。省份汉字虽然只有 31 类,但“京”“粤”“鲁”这些出现频率远高于“藏”“青”,如果不做类别重采样,模型会把高频类的权重学得很大,低频类基本识别不出来。我在训练时会统计每类样本数,按中位数采样,让每个类别的训练样本量一致。
4.3 训练参数三档配置:从快速验证到生产精调
训练字符分类器的参数,我按场景给三档配置。第一档是快速验证,数据量只有几千张,用 batch size 32、学习率 1e-3、Adam、训练 20 个 epoch,目标是把流程跑通,看 loss 是否下降。第二档是正常训练,数据量两万张左右,batch size 64、学习率从 1e-3 起步,每 5 个 epoch 衰减到原来的 0.1 倍,训练 40 个 epoch,加入随机亮度抖动和轻微仿射变换。第三档是生产精调,数据量五万张以上,加入 MixUp 或标签平滑,学习率用余弦退火,训练 60 个 epoch 以上,同时做交叉验证选最优权重。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=5, gamma=0.1) criterion = nn.CrossEntropyLoss() for epoch in range(40): model.train() for images, labels in train_loader: outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() print(f"epoch {epoch+1}: loss={loss.item():.4f}")这套代码是标准流程,值得注意的只有两点:一是CrossEntropyLoss自带 Softmax,模型最后一层不要额外加 Softmax;二是在验证集上保存最优模型时,不要只看整体准确率,要看每个类别的召回率,特别是低频汉字。我踩过的坑是整体准确率达到 98%,但“藏”这个字的识别率只有 60%,原因就是类别不均衡,后来加了重采样才好。
训练完成后,识别模块拿到的是每个字符的类别索引,索引要映射回字符。这一步需要一张固定顺序的类别表,比如按“省份汉字+字母+数字”排成一个 list,训练时用同样的顺序生成标签。很多人训练和识别时用了不同的排序方式,最后识别结果全是乱码,这个问题排查起来很费时间。
5. 车辆牌照识别的常见问题排查手册:5 个踩坑记录
5.1 现象一:蓝牌漏检,定位模块一个候选框都没返回
定位模块返回空结果,最直接的原因不是算法错了,而是输入的图片问题。我把排查顺序固定为:先看cv2.imread是否读到了空图,再看图片尺寸是不是太大导致车牌区域面积占比低于阈值,最后检查 HSV 阈值范围。
一次很典型的经历是:摄像头抓拍图是 1920x1080,车牌在画面里只占很小一块,area_ratio设成 0.002 时面积刚好卡在边界,车牌区域忽有忽无。解决方式是先用cv2.resize把长边压到 800 像素以内再做定位,同时把area_ratio下限降到 0.001。另一个高频原因是黄昏和夜间,车牌的蓝色在低照度下 S 通道很低,HSV 阈值里的 S 下限 90 直接把车牌过滤掉了。处理办法是预处理做一次 CLAHE 增强,或者把 S 下限降到 60,但代价是误检率上升。
5.2 现象二:字符分割把“京A”切成三段
“京”字左侧和右侧结构分离,垂直投影时中间笔画稀疏,被误判成间隙。我第一次跑分割时,输出七个框变成九个,多出来的都集中在汉字部分。
原因不只是投影本身,还有二值化质量。车牌边缘有铆钉和边框,投影后会混入窄的高亮段。解决方案分两步:先按宽度过滤——正常字符宽度在车牌宽度里占比相对均匀,窄于平均宽度一半的直接去掉;再做合并——如果两个相邻段间隔只有 1~2 个像素,且两段的宽度都偏窄,合并成一整段。这两个规则都放在split_characters函数里,不改变整体流程。
5.3 现象三:识别结果把“苏B”读成“苏宁B”
这个现象很像类别混淆,但根子在分割。汉字和字母的左右结构被切开后,模型会把半个字当成一个字符去分类,输出结果自然乱。排查时把分割可视化打开,看每个字符框是否完整覆盖一个字。
如果分割框是对的,再考虑训练数据问题。“苏”和“京”在灰度图上确实有相似笔画,如果训练集里“苏”的样本量少,模型会倾向预测成“京”。解决方式是给“苏”这类汉字增加样本,或者用数据增强里最便宜的平移加旋转,把样本扩到别的字符的三倍。注意不要用随机遮挡,车牌汉字被遮挡后的特征跟真实污损的分布不一定一致,可能学偏。
5.4 现象四:主程序跑不起来,提示 ModuleNotFoundError
这个往往是环境问题不是代码问题。拿到的源码可能基于 OpenCV 4.x 或 PyTorch 1.x,而你本地装的是新版或者没装。我处理这类问题的方式是先看几行关键 import:
pip install opencv-python numpy torch torchvision python -c "import cv2; print(cv2.__version__)" python -c "import torch; print(torch.__version__)"如果 cv2 导入报错,多半是 Python 版本太新导致没有对应的预编译 wheel,换成 Python 3.8 或 3.10 通常能解决。如果 torch 导入正常但跑起来缺torchvision,单独补一下就行。还有一个小概率情况是源码里用了cv2.CascadeClassifier或某个只在 opencv-contrib-python 里存在的接口,需要装opencv-contrib-python。判断方法是看报错是否指向cv2对象的某个属性不存在。
5.5 现象五:识别耗时太长,单张图要 800 毫秒
定位、分割、识别三段流程各自耗时,先说结论:瓶颈通常在 CNN 推理尺寸和预处理里的循环。我见过有人把每个字符残影放大到 128x128 再送模型,识别一个字符花了 60 毫秒,七个字符加起来 400 多毫秒。
优化方式有三个:字符图统一下采样到 32x20;把整牌的七个字符拼成一个 batch 喂给模型,而不是写 for 循环逐张预测;定位时先缩放图片,识别阶段再按缩放比例换算回原图坐标。做完这三步,单张图耗时一般能压到 80 毫秒以内。如果还是慢,检查是不是用了 CPU 版的 PyTorch 跑大模型,换成上面那个一百万参数的轻量模型就够了。
6. 把识别流程装进命令行:argparse 参数设计、验证脚本与调优技巧
整套流程跑通之后,我习惯把主入口收敛成一个命令行工具,这样测试不同图片、调整阈值、记录结果都方便。argparse是 Python 标准库,不需要额外装东西,设计上参考下面这个结构:
import argparse def parse_args(): parser = argparse.ArgumentParser(description="Vehicle Plate Recognition") parser.add_argument("--image", "-i", type=str, required=True, help="input image path or directory") parser.add_argument("--debug", action="store_true", help="save intermediate visualization images") parser.add_argument("--min-area", type=float, default=0.002, help="minimum plate area ratio, default 0.002") parser.add_argument("--ratio-range", type=float, nargs=2, default=[2.0, 5.5], help="plate width/height range") parser.add_argument("--model-weights", type=str, default="models/char_cnn.pth", help="path to character classifier weights") return parser.parse_args()参数拆分的原则是:图像路径必须要求、可视化开关要单独给、会直接影响定位结果的阈值全部暴露出来。--debug开关是我调试时最常用的,打开后把 HSV 掩膜、候选框、分割结果、最终识别文本一起写到output/目录,哪一步出了问题直接看图,不用去猜。
调参技巧方面,我建议每次只动一个参数。比如把--min-area从 0.002 改到 0.001,只跑同一批固定的二十张测试图,看定位召回率变化,不要同时改两个阈值。如果--ratio-range扩宽后误检变多,优先恢复窄范围再调 HSV 的 S 通道下限。验证脚本可以写成一个循环:对测试集每张图调用同一条识别命令,把输出文本和标注文件名比对,统计整牌准确率和字符准确率。整牌准确率反映系统整体能力,字符准确率能帮你定位是哪个字符容易错。
我个人养成的习惯是给每套参数组合保留一个可视化结果集,改完配置后先看十张图再跑指标。不要直接拿一千张测试集跑完看个数字,数字对了可能只是运气好,数字错了你却不知道错在哪。这套东西做到最后你会发现,车牌照识别的难点不在深度学习模型,而在那些预处理和坐标变换的细节里。希望这套从定位到识别的源码拆解,能帮你少走一段弯路。
本文还有配套的精品资源,点击获取