简介:这份资源面向计算机相关专业的本科生,提供一套可直接用于毕业设计、期末大作业或课程设计的车牌识别与管理系统完整方案,技术栈覆盖OpenCV图像处理与深度学习字符识别,适合具备一定Python基础、希望快速完成高分项目的学生。压缩包共2000个文件,约33.47MB,其中1987个jpg为车牌与字符样本图像,另有xml标注文件、py源码、sh脚本、pptx汇报材料、docx项目报告及md说明文档,覆盖数据、代码与文档全流程。资源已有371人学习下载,代码含详细注释,新手也能看懂。内容按数据收集与预处理、基于Canny边缘检测与形态学操作的车牌定位、垂直投影字符分割、CNN字符识别等模块组织,并附项目工作总结报告与演示文稿,下载后简单部署即可运行,便于理解车牌识别系统的完整实现思路与工程结构。
1. 从一张模糊车位照说起:Python 车牌识别系统到底在做什么
地下车库入口,摄像头拍到的车牌往往只有 100 来像素宽,还带着运动模糊和顶灯反光。用纯 OpenCV 的模板匹配去认,白天勉强能出结果,一到晚上或者车牌倾斜就集体翻车。这正是「Python 基于 OpenCV 和深度学习的车牌识别与管理系统」要解决的核心问题:把传统图像处理负责的「定位」和深度学习负责的「认字」拆开,各干各擅长的事。
这套方案适合三类人:正在做 Python 毕业设计、需要一套能跑通又有技术含量的完整项目;想入门 OpenCV 图像处理和深度学习 CNN 的开发者;以及需要给停车场、小区门禁做一套轻量车牌管理后台的工程师。它不追求工业级识别率,但胜在结构清晰、依赖少、能在一台普通笔记本上从零复现。整条链路是:图像预处理 → 车牌定位 → 字符分割 → CNN 字符识别 → 数据入库与后台管理。下面按这条链路逐段拆开讲。
2. 车牌定位:用 OpenCV 把车牌从整张图里抠出来
车牌识别的第一步不是识别,是定位。整张图里车牌可能只占 5% 的面积,直接丢给 CNN 既慢又容易被背景干扰。传统做法是用颜色和边缘特征先把候选区域框出来,再交给后续模块。这一步用 OpenCV 就够了,不需要深度学习。
2.1 颜色分割与形态学:定位的第一道筛子
国内蓝牌的主色调是蓝底白字,HSV 空间下蓝色有比较集中的分布区间。先把 BGR 转成 HSV,用inRange卡出蓝色区域,再做闭运算把字符间的缝隙填上,车牌就会变成一个连通的白色块。
import cv2 import numpy as np def locate_plate_by_color(image_path): img = cv2.imread(image_path) # 高斯模糊压掉噪点,核大小 5x5 是经验值,太大车牌边缘会糊 blurred = cv2.GaussianBlur(img, (5, 5), 0) hsv = cv2.cvtColor(blurred, cv2.COLOR_BGR2HSV) # 蓝色车牌 HSV 区间,H 在 100~124 之间覆盖绝大多数蓝牌 lower_blue = np.array([100, 80, 80]) upper_blue = np.array([124, 255, 255]) mask = cv2.inRange(hsv, lower_blue, upper_blue) # 闭运算:先膨胀再腐蚀,把车牌内部字符的孔洞填平 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 5)) closed = cv2.morphologyEx(mask, 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) ratio = w / float(h) area = w * h # 蓝牌长宽比约 3.14,放宽到 2.2~4.5,面积下限 2000 像素 if 2.2 < ratio < 4.5 and area > 2000: candidates.append((x, y, w, h)) return img, candidates这段代码的逻辑是「先粗筛再精筛」。HSV 区间负责粗筛,把蓝色像素全捞出来;形态学闭运算负责把离散的蓝色像素连成块;轮廓筛选负责精筛,用长宽比和面积两个几何特征排除掉蓝色车身、广告牌这类干扰。参数上最需要调的是lower_blue和upper_blue的 H 通道范围,不同摄像头白平衡不一样,H 的上下限可能要各挪 5 到 10。kernel的尺寸也有讲究:宽度 17 是为了横向连接字符,高度 5 是为了不把上下边缘连到别的区域,这个比例是根据蓝牌字符间距反推的。
2.2 边缘检测兜底:颜色失效时怎么办
颜色分割有个硬伤:黄牌、绿牌、白牌它都认不出来,夜间偏色时蓝牌也可能漏检。这时候用 Sobel 做垂直边缘检测兜底,因为车牌区域字符密集,垂直边缘数量远高于普通车身区域。
def locate_plate_by_edge(image_path): img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # Sobel 只取 x 方向,车牌字符的竖直笔画会形成强响应 sobel_x = cv2.Sobel(gray, cv2.CV_16S, 1, 0, ksize=3) abs_x = cv2.convertScaleAbs(sobel_x) # 二值化阈值 120,低于它的边缘直接丢掉 _, binary = cv2.threshold(abs_x, 120, 255, cv2.THRESH_BINARY) # 横向闭运算把字符边缘连成矩形区域 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.2 < w / float(h) < 4.5 and w * h > 2000: candidates.append((x, y, w, h)) return img, candidates边缘法的关键参数是ksize和阈值。ksize=3是标准 Sobel 核,对细笔画响应好;阈值 120 是经验值,图像整体偏暗时要降到 80 左右,否则边缘全被砍掉。实际工程里我一般把颜色法和边缘法的候选框合并,用 IoU 去重,这样白天靠颜色、夜间靠边缘,召回率能明显提上来。这一步不需要深度学习,OpenCV 的算子在 CPU 上单帧只要几毫秒,比直接上检测网络轻量得多。
3. 字符分割:把定位框里的字符一个个切开
定位框拿到之后,里面是一整串字符。CNN 识别的是单个字符,所以必须先把它们切开。这一步的难点在于铆钉、边框、字符粘连,切歪一个后面全错。
3.1 灰度化、二值化与垂直投影
标准流程是:裁剪定位区域 → 灰度化 → 自适应二值化 → 垂直投影找字符间隙。垂直投影就是统计每一列白色像素的个数,字符所在列白点多,字符间隙列白点少,投影曲线会呈现明显的波峰波谷。
def split_characters(plate_img): gray = cv2.cvtColor(plate_img, cv2.COLOR_BGR2GRAY) # 自适应阈值,应对车牌局部光照不均 binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 19, 5) # 垂直投影:按列求和 col_sum = np.sum(binary == 255, axis=0) # 投影值低于阈值认为是间隙,阈值取列高的 1/10 threshold = binary.shape[0] // 10 chars = [] in_char = False start = 0 for i, v in enumerate(col_sum): if v > threshold and not in_char: in_char = True start = i elif v <= threshold and in_char: in_char = False # 宽度太窄的当噪点丢掉,太宽的可能是粘连 if 8 < i - start < 60: chars.append(binary[:, start:i]) return charsadaptiveThreshold的 blockSize 取 19、C 取 5,是针对车牌这种小尺寸图像调出来的。blockSize 必须是奇数,太小会把字符内部也二值化出空洞,太大又退化成全局阈值。投影阈值取列高的十分之一,是为了过滤掉车牌上下边框残留的少量白点。字符宽度限制在 8 到 60 像素之间,是因为标准蓝牌单个字符宽度大约占车牌宽度的 1/8,超出这个范围的基本是粘连或噪点。
3.2 粘连字符的二次切分
投影法最怕字符粘连,尤其是「川」「湘」这类笔画多的字,或者二值化过头导致相邻字符连在一起。遇到宽度超过 60 像素的块,我一般用垂直投影的局部极小值再切一刀。
def split_wide_block(block): col_sum = np.sum(block == 255, axis=0) # 在中间 1/3 区域找投影最小值作为切分点 mid_start = len(col_sum) // 3 mid_end = len(col_sum) * 2 // 3 if mid_end <= mid_start: return [block] split_idx = mid_start + np.argmin(col_sum[mid_start:mid_end]) return [block[:, :split_idx], block[:, split_idx:]]这个二次切分只在块宽超过阈值时触发,切分点选在中间三分之一的投影最小值处,避免切到字符笔画上。实际跑下来,粘连字符能救回七成左右,剩下的靠 CNN 的鲁棒性兜底——因为 CNN 输入是固定尺寸,轻微粘连对识别影响没那么致命。这一步的坑在于切分点如果落在「1」这种窄字符上,会把一个字符切成两半,所以切完还要检查两半的宽度是否都大于 8 像素,不满足就放弃切分。
4. 深度学习字符识别:CNN 怎么认字
分割出来的字符是二值图,尺寸不一。CNN 需要固定输入,所以先统一缩放到 20×40,再送进网络。这一步是整个系统里唯一用到深度学习的地方,也是识别率的分水岭。
4.1 网络结构选型:为什么不用现成的大模型
字符识别是个小分类问题,类别数就是省份简称加字母数字,总共 65 类左右。用 ResNet、VGG 这种大模型属于杀鸡用牛刀,参数量大、训练慢、部署重。我一般用三层卷积加两层全连接的小网络,参数量控制在 50 万以内,CPU 推理单字符 2 毫秒左右。
import torch import torch.nn as nn class PlateCNN(nn.Module): def __init__(self, num_classes=65): super().__init__() # 输入 1x40x20(灰度、高40、宽20) self.features = nn.Sequential( nn.Conv2d(1, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 20x10 nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 10x5 nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 5x2 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 5 * 2, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))三层卷积的感受野刚好覆盖 40×20 的字符图,再深就过拟合了。每层卷积后接 ReLU 和最大池化,池化把特征图逐步缩小,最后展平成 1280 维向量。全连接层加 Dropout 0.5 是防过拟合的关键,字符数据集通常只有几万张,不加 Dropout 训练集准确率能到 99% 但验证集只有 85%。类别数 65 对应:31 个省份简称 + 24 个字母(去掉 I、O)+ 10 个数字。
4.2 训练数据准备与增强
公开的字符数据集不好找,我一般从车牌数据集里自己切。CCPD、CRPD 这类车牌数据集下载后,用第 3 章的投影法批量切字符,再按字符内容分文件夹存。每个类别至少准备 500 张,65 类就是 3 万多张,够小网络收敛了。
from torchvision import transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder train_tf = transforms.Compose([ transforms.Grayscale(), transforms.Resize((40, 20)), # 随机旋转 ±5 度,模拟车牌倾斜 transforms.RandomRotation(5), # 随机调整亮度对比度,模拟光照变化 transforms.ColorJitter(brightness=0.3, contrast=0.3), transforms.ToTensor(), transforms.Normalize([0.5], [0.5]), ]) dataset = ImageFolder('chars_dataset', transform=train_tf) loader = DataLoader(dataset, batch_size=64, shuffle=True)增强策略里RandomRotation(5)对应车牌在图像里的轻微倾斜,ColorJitter对应不同时段的光照差异。归一化用 0.5 均值和标准差,是因为字符图二值化后像素集中在 0 和 255,映射到 [-1, 1] 区间能让网络训练更稳。batch_size 取 64 是显存和收敛速度的折中,小网络用 32 也行,但训练轮次要相应增加。
训练时用交叉熵损失加 Adam 优化器,学习率 1e-3,跑 30 个 epoch 基本收敛。验证集准确率能到 97% 以上,剩下的 3% 主要是「0」和「O」、「1」和「I」这种形近字符,实际车牌里这些字符有位置约束,后处理能纠正一部分。
5. 管理系统与避坑:从识别结果到能用的后台
识别出车牌字符串只是半成品,真正交付的是一个能查、能存、能管的后台。这部分用 Flask 加 SQLite 就够,不需要上重型框架。
5.1 数据入库与查询接口
识别结果要落库,字段包括车牌号、识别时间、置信度、抓拍图路径。Flask 提供两个接口:一个接收识别结果写入,一个按车牌号或时间段查询。
from flask import Flask, request, jsonify import sqlite3 app = Flask(__name__) def init_db(): conn = sqlite3.connect('plate.db') conn.execute('''CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, plate_no TEXT NOT NULL, confidence REAL, capture_time TEXT, image_path TEXT)''') conn.commit() conn.close() @app.route('/api/record', methods=['POST']) def add_record(): data = request.json conn = sqlite3.connect('plate.db') conn.execute('INSERT INTO records (plate_no, confidence, capture_time, image_path) VALUES (?,?,?,?)', (data['plate_no'], data['confidence'], data['capture_time'], data['image_path'])) conn.commit() conn.close() return jsonify({'status': 'ok'}) @app.route('/api/query', methods=['GET']) def query_record(): plate = request.args.get('plate', '') conn = sqlite3.connect('plate.db') rows = conn.execute('SELECT * FROM records WHERE plate_no LIKE ?', (f'%{plate}%',)).fetchall() conn.close() return jsonify(rows)表结构里plate_no加索引能显著提升查询速度,数据量上万后不加索引查询会明显变慢。confidence字段存 CNN 输出的最大概率,低于 0.8 的记录可以标记为待人工复核。接口用LIKE做模糊查询,方便按车牌前缀筛选,但要注意 SQL 注入,参数化查询已经处理了这个问题。
5.2 避坑与常见问题排查
现象一:白天识别正常,夜间识别率断崖式下跌。原因是颜色分割在低照度下 HSV 分布偏移,蓝色区域卡不出来。解决方法是夜间切换到边缘检测分支,或者对图像先做 CLAHE 直方图均衡再进颜色分割,实测能挽回大部分夜间样本。
现象二:CNN 训练准确率 99%,实际部署只有 70%。原因是训练集和实际场景的字符尺寸、字体差异大。解决方法是把实际场景切出来的字符人工标注后加入训练集,做一轮微调,通常再训 10 个 epoch 就能把差距补上。
现象三:ModuleNotFoundError: No module named 'cv2'。这是新手最常遇到的,装了opencv-python但环境不对。解决方法是确认 pip 和 python 是同一个环境,用python -m pip install opencv-python而不是直接pip install,避免装到系统 Python 而项目跑在虚拟环境里。
现象四:字符分割把「1」切成两半。原因是「1」的笔画细,投影值在字符内部也有低谷。解决方法是在切分后加宽度校验,宽度小于 8 像素的块合并回相邻块,或者对窄字符单独用固定宽度切分。
现象五:多辆车同时进画面,定位框选错。原因是轮廓筛选只按面积和长宽比,没考虑位置。解决方法是按候选框面积排序取最大的,或者结合车道线位置做区域约束,只保留画面下半部分的候选框。
6. 把识别率再往上推一截:CTC 与端到端思路
前面这套「定位 + 分割 + 单字符识别」的流水线,优点是每一段都可解释、可单独调优,缺点是误差会逐级累积——定位偏一点,分割就歪,识别就错。如果想把识别率从 90% 推到 97% 以上,值得了解 CTC(Connectionist Temporal Classification)这条路线。
CTC 的思路是跳过字符分割,直接把整张车牌图送进 CNN + RNN,输出一个字符序列,网络自己学会对齐。PyTorch 里可以用nn.CTCLoss实现,标签是字符序列,不需要标注每个字符的位置。
class PlateCRNN(nn.Module): def __init__(self, num_classes=65): super().__init__() self.cnn = nn.Sequential( nn.Conv2d(1, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), ) # 输入高 40 宽 160,经过两次池化后高 10 宽 40 self.rnn = nn.LSTM(128 * 10, 256, bidirectional=True, batch_first=True) self.fc = nn.Linear(256 * 2, num_classes) def forward(self, x): feat = self.cnn(x) # B,128,10,40 b, c, h, w = feat.shape feat = feat.permute(0, 3, 1, 2).reshape(b, w, c * h) out, _ = self.rnn(feat) return self.fc(out) # B,40,65CTC 的关键参数是输入图的宽高比。车牌整体宽高比约 3.14,输入设成 160×40 比较合适,池化后时间步 40,足够覆盖 7 个字符。CTCLoss的blank标签设成 0,其余类别从 1 开始编号。训练时input_lengths填 40,target_lengths填实际字符数。CTC 的坑在于收敛慢,通常要 100 个 epoch 以上,而且对学习率敏感,建议用 1e-4 起步配合梯度裁剪。
我自己的习惯是:如果只是毕业设计或者小规模部署,第 2 到第 4 章的流水线方案足够,代码量可控、调试直观;如果场景里车牌倾斜严重、字符粘连频繁,再上 CTC。两条路线不冲突,CTC 的 CNN 部分可以直接复用前面训好的字符特征。踩过的最大坑是 CTC 训练时忘了设zero_infinity=True,遇到无法对齐的样本 loss 直接变 inf,整个训练崩掉,这个参数一定要加上。
希望帮到你。
本文还有配套的精品资源,点击获取