简介:一套基于OpenCV与卷积神经网络的车牌识别系统完整代码,面向计算机视觉毕业设计、课程项目及入门学习者,解决从车牌定位到字符识别的完整流程问题。压缩包内共包含两千个文件,除大量车牌样本图片外,还包含Python源代码、模型权重文件、XML配置文件及训练日志等,整体约四百六十MB。目前已有八千四百零四人学习下载,是实践效果较好的参考实现。代码可直接运行,内置训练好的卷积神经网络模型与配套车牌数据集,覆盖图像预处理、车牌轮廓提取、字符分割及识别输出等环节,并提供了详细的网络结构参数(如输入层36×128、三层卷积与池化配置),便于读者理解CNN在车牌识别中的具体应用,也可作为毕业设计或科研实验的基础进行二次开发。
1. 车牌识别系统初印象:OpenCV 和 CNN 各管一段
上班路上经过园区停车场,道闸摄像头对着一辆蓝色牌照的车迟迟不开杆,因为傍晚逆光把牌照打成一片白。这类场景里,车牌识别系统最怕的就是光线和环境干扰。这套用 Python 实现的车牌识别系统,把 OpenCV 和卷积神经网络(CNN)的分工合作做到了很清晰的程度:OpenCV 负责把车牌从复杂背景里抠出来、切成单个字符,CNN 负责把字符认准。整个项目不依赖 GPU,普通笔记本就能跑,源码里带了模型权重,下载后装好依赖、给一张车牌照片就能直接识别出省份简称、字母和数字。适合正在做图像处理课设、毕设,或者想在停车场道闸、车辆门禁这类场景里快速落地一套原型识别的开发者。
2. 整体架构与目录:从一张图片到一串车牌号的完整链路
2.1 目录结构与文件职责
拿到源码包之后,先看目录结构,这份资源的组织方式是典型的「定位 + 分割 + 识别」三段式工程目录:
plate_cnn/ ├── utils.py # 车牌定位与字符分割,纯 OpenCV 实现 ├── models.py # CNN 模型定义,基于 Keras Sequential ├── train.py # 字符模型训练脚本,可重新训练 ├── predict.py # 单张图片识别入口,加载权重直接预测 ├── weights/ │ ├── char_cnn.h5 # 预训练权重,直接加载即可识别 │ └── label_map.json # 字符类别与索引的映射表 ├── datasets/ │ ├── chars/ # 训练用字符图片,按类别分目录 │ └── sample_plates/ # 测试用整车车牌图片 └── requirements.txt # 依赖清单四个核心文件的分工很明确:utils.py里是 OpenCV 图像处理函数,不涉及任何神经网络代码;models.py只负责构建 CNN 模型;train.py是训练入口,跑完会生成weights/char_cnn.h5;predict.py是推理入口,加载权重和映射表后输出车牌字符串。这种拆分方式的好处是训练和推理可以完全解耦,你只想跑识别就直接执行predict.py,想微调模型就单独改train.py,不会牵一发动全身。
label_map.json这个文件容易忽略,但它很关键。CNN 输出的是类别索引(比如 0、1、2),必须靠这个映射表把索引翻译成「京」「鲁」「A」「3」这样的字符。映射表内容和训练时的目录名一一对应,如果重新训练时改了字符集目录,这个 json 文件也要同步更新,否则推理时会出现结果错乱。
2.2 技术选型:为什么是 OpenCV + CNN 而不是端到端
先回答一个我在类似项目里经常被问到的问题:为什么不直接用 YOLO 这类检测模型一步到位把车牌和字符全检测出来?答案在成本和稳定性上。端到端检测模型需要大量标注好的车牌图片,训练时要 GPU,而且对样本分布很敏感——你标注的车牌全是白天正面照,遇到逆光和倾斜就翻车。而车牌的物理特征非常固定:蓝底白字、宽高比在 2.4 到 4.5 之间、字符间距规则。这些约束用 OpenCV 的颜色过滤加形态学处理就能稳定利用,不需要学习就能拿到候选框。
字符识别环节则不同。字符经过分割后是规整的 20×20 灰度小图,类别是有限的汉字、字母、数字组合,这恰好是小型 CNN 最擅长的任务。模板匹配在字体差异和笔画缺损面前太脆弱,传统 SVM 需要手工提特征,CNN 直接吃像素,泛化能力好不少。所以这个项目选型是「保守的图像处理 + 激进的神经网络」组合,整体算力开销小,工程上又好调。
| 环节 | 技术方案 | 选型理由 |
|---|---|---|
| 车牌定位 | OpenCV HSV 颜色 + 形态学闭运算 | 蓝牌颜色和宽高比固定,传统 CV 稳定且快 |
| 字符分割 | OpenCV 二值化 + 垂直投影 | 字符间距规则,投影切分直观可控 |
| 字符识别 | CNN 小型分类网络 | 类别固定约 70 类,CNN 对清晰度变化泛化好 |
| 端到端替代方案 | YOLO 类检测模型 | 需要大量标注和 GPU,杀鸡用牛刀 |
这个选型还有一层现实考虑:车牌定位逻辑是纯规则,出问题时能看到中间结果(比如把 mask 和轮廓画出来),定位错了很容易定位到是哪一步参数导致的。换成端到端模型,错了只能重新调训练集,排查成本高很多。
2.3 识别流程:一条数据从输入到输出的完整链路
整个识别流程可以拆成五个环节,每个环节对应predict.py里的一步:
- 读取图片,做长边缩放归一化,避免大图拖慢后续处理;
locate_plate()在 HSV 空间过滤蓝色,形态学闭运算连通车牌区域,再用宽高比和面积筛出候选框;- 对候选框做透视校正,把倾斜的车牌转正,然后灰度化 + 自适应阈值二值化;
segment_chars()通过垂直投影找到每个字符的左右边界,按宽高比过滤噪声,得到 7 个左右字符小图;- 每个字符缩放成 20×20 送入 CNN,softmax 输出概率分布,
argmax取类别索引,查label_map.json得到字符,最后按从左到右的顺序拼接成完整车牌。
第 5 步里的排序容易被忽略。findContours返回的轮廓顺序没有保证,分割出来的字符必须按 x 坐标排序,否则一个车牌会输出成随机顺序的字符串。我会在代码注释里把这个细节标出来,后面推理部分会看到具体写法。
3. 核心模块拆解:车牌定位与字符分割的实现细节
3.1 车牌定位:HSV 颜色空间 + 形态学闭运算
车牌定位是整个系统的第一步,也是最容易翻车的一步。这里采用 HSV 颜色空间而不是直接操作 BGR,是因为 HSV 把色调、饱和度、亮度分开,蓝色在不同光照下的变化主要体现在 V 通道(亮度),H 通道相对稳定,用cv2.inRange过滤时不容易受阴影和高光影响。
import cv2 import numpy as np def locate_plate(image): # 长边超过 1200 就缩放,避免大图导致轮廓搜索变慢 h, w = image.shape[:2] if w > 1200: scale = 1200 / w image = cv2.resize(image, (int(w * scale), int(h * scale))) hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) # 标准蓝色车牌的 HSV 范围:H 100~124,S 43~255,V 46~255 lower_blue = np.array([100, 43, 46]) 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) best_box = None for cnt in contours: x, y, cw, ch = cv2.boundingRect(cnt) # 车牌宽高比一般在 2.4~4.5,面积过滤掉细小噪声 if 2.4 < cw / ch < 4.5 and cw * ch > 1500: if best_box is None or cw * ch > best_box[2] * best_box[3]: best_box = (x, y, cw, ch) return best_box这段代码的产出是best_box,也就是原图上车牌区域的坐标和宽高。如果返回 None,说明这一帧里没找到蓝色候选区域,后续所有步骤都会跳过。
几个参数值得展开说。H 通道范围 [100, 124] 覆盖了标准蓝色的色相,如果你要识别新能源绿牌,需要再加一组绿色范围:H 约 [35, 77],S 和 V 范围可以复用。闭运算核 (17, 5) 是横向长方形,因为车牌是扁平的,横向核能把字符之间的空隙填成一片连通域;如果核太小,字符缝隙连不上,车牌会散成好几块;如果太大,附近的蓝色物体容易被并进来。宽高比范围 2.4~4.5 是市场上普通蓝牌的统计值,两行式的黄牌或不规则车牌不适用。
3.2 字符分割:自适应阈值二值化与垂直投影
拿到车牌区域后,先把倾斜的车牌校正,再用二值化分离字符。倾斜校正常见做法是用cv2.minAreaRect获取轮廓的最小外接旋转矩形,从里面读出角度:
def deskew(plate_roi, contour): # contour 是车牌轮廓,rect[2] 是旋转角度,范围 [-90, 0) rect = cv2.minAreaRect(contour) angle = rect[2] if angle > 45: angle -= 90 h, w = plate_roi.shape[:2] M = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) fixed = cv2.warpAffine(plate_roi, M, (w, h), borderMode=cv2.BORDER_REPLICATE) return fixed角度转换是这里最容易出 bug 的地方。minAreaRect返回的角度语义在不同 OpenCV 版本里略有差别,经验值是角度大于 45 度时减去 90,把「竖着的矩形」转成「横着的矩形」再校正。校正完的效果是车牌水平方向基本拉平,字符的垂直投影才会呈现清晰的波峰波谷。
然后进入字符分割。核心思路是垂直投影:把二值化后的车牌图片按列统计白色像素数量,字符所在列会有明显的白色像素堆积,字符间隔处几乎为零,根据这个投影曲线找切分点。
def segment_chars(plate): gray = cv2.cvtColor(plate, cv2.COLOR_BGR2GRAY) # 自适应阈值比固定阈值适应光照不均,THRESH_BINARY_INV 让字符为白色 binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) # 切除上下边缘的边框横条,避免干扰投影 binary[:6, :] = 0 binary[-6:, :] = 0 # 垂直投影:统计每列白色像素个数 col_sum = np.sum(binary, axis=0) // 255 # 遍历投影值,找到连续大于 0 的区间作为候选字符 boxes = [] start = None for i, v in enumerate(col_sum): if v > 0 and start is None: start = i elif v == 0 and start is not None: if i - start >= 8: # 宽度小于 8 像素的区间视为噪声 boxes.append((start, i)) start = None if start is not None: boxes.append((start, len(col_sum))) # 按 x 坐标排序,保证字符顺序是从左到右 boxes.sort(key=lambda b: b[0]) # 宽高比过滤:字符宽度/高度约 0.3~1.5,铆钉和边框碎片会被滤掉 chars = [] for start, end in boxes: roi = binary[:, start:end] ch, cw = roi.shape if 0.3 < cw / ch < 1.5: roi = cv2.resize(roi, (20, 20)) chars.append(roi) return chars这里有两处关键过滤。宽度大于等于 8 像素的区间才保留,是为了排除离散的椒盐噪声;宽高比 0.3~1.5 的区间才保留,是为了排除车牌两侧的铆钉、定位孔等干扰物。第一个字符是汉字,笔画结构比较复杂,垂直投影可能把它切成两段,处理办法是:如果相邻两个区间的间距小于 4 像素,就合并成一个字符区间。很多入门代码没有做这一步,导致「鄂」「湘」这类左右结构的汉字被拆成两个字符,识别结果多一位。我通常会补上这段合并逻辑,它能明显提升汉字的识别率。
4. CNN 字符识别模型拆解:结构设计、训练与推理
4.1 模型结构:两层卷积 + 全连接的小型分类网络
字符识别模型的目标是分类,输入是 20×20 的灰度字符图,输出是类别概率。类别总数由训练数据决定:31 个省份简称汉字、24 个字母(去掉 I 和 O)、10 个数字,总共约 65 类。模型不需要很大,字符图本身分辨率低,特征也简单,两层卷积已经够用:
# models.py from tensorflow.keras.models import Sequential from tensorflow.keras.layers import (Conv2D, MaxPooling2D, Flatten, Dense, Dropout) def build_char_model(num_classes): model = Sequential([ Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=(20, 20, 1)), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activation='relu', padding='same'), MaxPooling2D((2, 2)), Flatten(), Dense(128, activation='relu'), Dropout(0.5), Dense(num_classes, activation='softmax') ]) model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy']) return model输入 20×20 经过两次卷积池化后变成 5×5×64,Flatten 后是 1600 维向量,再经过 128 维全连接层输出类别概率。Dropout 放在全连接层之前,比例取 0.5,作用是防止过拟合——字符数据集通常只有几千张,参数稍多就容易把训练样本背下来。如果你发现验证集准确率明显低于训练集,优先调整这个值,改成 0.3 或 0.6 试几轮。
padding='same'让卷积不改变特征图的尺寸,这样池化后的大小可以预期地减半。如果把 padding 改成valid,20×20 输入经过两层 3×3 卷积会缩到 16×16,后续尺寸计算容易出错。这类小型模型里我一般直接用 same 省心。
4.2 训练流程:数据加载、标签映射与增广
训练脚本的核心是数据加载。数据集目录datasets/chars/下每个子目录名就是一个字符类别,目录里放对应的字符图片,这种「目录名即标签」的组织方式最简单,不需要额外维护标注文件:
# train.py import os import cv2 import numpy as np from tensorflow.keras.utils import to_categorical from models import build_char_model def load_chars(data_dir, img_size=(20, 20)): images, labels = [], [] class_names = sorted(os.listdir(data_dir)) label_to_id = {name: i for i, name in enumerate(class_names)} for cls_name in class_names: cls_dir = os.path.join(data_dir, cls_name) for fn in os.listdir(cls_dir): img = cv2.imread(os.path.join(cls_dir, fn), cv2.IMREAD_GRAYSCALE) img = cv2.resize(img, img_size) images.append(img) labels.append(label_to_id[cls_name]) images = np.array(images).reshape(-1, 20, 20, 1).astype('float32') / 255.0 labels = to_categorical(labels, num_classes=len(class_names)) return images, labels, class_names这段代码有两个细节值得注意。灰度图读进来后要 reshape 成(-1, 20, 20, 1),最后一维是通道数,CNN 的输入要求是四维张量(batch, height, width, channels),少了 reshape 这一步模型会直接报维度错误。像素值除以 255 归一化到 [0, 1] 区间,是为了让梯度更新更稳定,这个习惯对所有图像分类任务都适用。
训练数据不足时,字符识别准确率会明显掉。我一般会在训练时做数据增广:随机旋转 ±5 度、水平/垂直平移 1 到 2 像素、加一点高斯噪声。这些变换模拟真实车牌拍摄时的倾斜和模糊,能有效提升模型泛化能力。ImageDataGenerator里的参数按这个范围配置就够了,旋转角度太大反而会把字符转得不像字符。
训练过程的观察点:每轮训练结束后看验证集准确率,正常情况 20 到 30 轮能到 95% 以上。如果验证集准确率涨到某个值就停滞,检查是不是学习率太大;如果训练集 99% 而验证集只有 85%,基本可以断定过拟合,加大 Dropout 或增加数据增广强度。
4.3 推理阶段:加载权重、查表拼接结果
训练完成后,weights/char_cnn.h5里存的是模型结构加权重,label_map.json里存的是类别索引到字符的映射。推理时不需要重新构建训练数据,直接加载这两个文件就能预测:
# predict.py import cv2 import numpy as np import json from tensorflow.keras.models import load_model from utils import locate_plate, deskew, segment_chars # 全局只加载一次模型,避免每张图重复加载耗时 model = load_model('weights/char_cnn.h5') with open('weights/label_map.json', 'r', encoding='utf-8') as f: label_map = {int(k): v for k, v in json.load(f).items()} def predict_plate(image_path): img = cv2.imread(image_path) if img is None: raise FileNotFoundError(f'无法读取图片: {image_path}') box = locate_plate(img) if box is None: return None x, y, w, h = box plate_roi = img[y:y + h, x:x + w] plate_roi = deskew(plate_roi, box) chars = segment_chars(plate_roi) result = [] for roi in chars: roi = roi.reshape(1, 20, 20, 1).astype('float32') / 255.0 pred = model.predict(roi, verbose=0) idx = int(np.argmax(pred[0])) result.append(label_map[idx]) return ''.join(result) if __name__ == '__main__': plate = predict_plate('datasets/sample_plates/蓝牌样例1.jpg') print('识别结果:', plate)推理链路里每一步都依赖前一步的输出,定位返回 None 时直接返回 None,避免后续segment_chars对空图操作报错。字符识别采用软分类策略,argmax取概率最高的类别作为结果,如果你想看模型对某个字符的置信度,可以输出pred[0]里的概率分布,前几名概率接近时说明这个字符是疑难样本。
model.predict在循环里逐张调用,单张耗时约 10 到 20 毫秒,整个车牌 7 个字符加起来不到 200 毫秒,CPU 上跑完全没问题。如果识别大量图片觉得慢,可以把所有字符图拼成一个 batch 一次性预测,Keras 会自动按 batch 并行计算。
5. 跑通与避坑:依赖安装、运行命令和翻车记录
5.1 环境准备与运行步骤
项目依赖集中在requirements.txt里,核心就三样:opencv-python、numpy、tensorflow。不建议装tensorflow-gpu分支,原因后面避坑里细说。创建虚拟环境后执行:
pip install -r requirements.txt python predict.py --image datasets/sample_plates/蓝牌样例1.jpg如果源码里predict.py没做命令行参数解析,也可以直接在文件底部把图片路径换成你要测试的图。正确输出是一串字符,比如蓝牌样例图对应「示例A·12Q34」,控制台同时打印定位框坐标和用时。首次运行会比后面几次慢,因为 TensorFlow 初始化要加载算子库,这是正常现象,不代表代码卡死。
跑通之后建议做一轮自测:准备 10 张不同场景的车牌照片,包括白天直射、傍晚逆光、斜角度拍摄,分别执行识别,记录每张的结果和耗时。这个过程能快速暴露定位环节的问题,比如某个场景下定位框偏了,直接打印mask和closed两张中间图就能定位到是哪一步参数导致。
5.2 避坑指南:四类高频翻车现场
第一条:定位框把整片蓝色区域圈进去,识别结果乱码。现象是背景里的蓝色招牌、蓝色车身被当成车牌。原因是只按颜色过滤,没做第二轮形状校验。解决方法是先按宽高比 2.4~4.5 粗筛,再计算候选框内蓝色像素占整个框的面积比,车牌的这个比例通常在 20% 到 60% 之间,明显偏离就剔除。
第二条:字符和边框粘连在一起,分割出来不是单个字符。现象是二值化图上字符跟车牌边框形成连通块,垂直投影切不出正确的波峰波谷。原因是用固定阈值二值化,光照不均时阈值选高选低都不合适。解决办法是改用自适应阈值,并在分割前对二值图做一次腐蚀,核大小 3×3,把细小的连接处断开。
第三条:模型训练时准确率很高,一到真实图片就翻车。现象是验证集 97% 准确率,但实测很多车牌识别错。原因多半是数据集里数字和字母样本远多于汉字,模型对汉字类别学习不充分。解决方法是统计每个类别的样本数,对少于中位数的类别做数据增广补齐,让每个类别样本量接近再训练。
第四条:装了 TensorFlow GPU 版反而跑不起来。现象是 import 时报 CUDA/cuDNN 加载失败,或提示显存不足。原因是 GPU 版对驱动和 CUDA 版本要求严格,版本不匹配直接无法使用。解决方法是卸载 GPU 版改装 CPU 版,车牌识别单张推理在 CPU 上约 200 毫秒,完全够用;真要上生产环境,把模型导出成 ONNX 用 ONNX Runtime 推理,性能更好部署也更简单。
5.3 识别效果评估与调参备忘
跑通后建议做一次量化评估,别只靠肉眼看一两张图就说效果好。常用的做法是准备 30 到 50 张带正确标签的测试图,逐张识别后统计三个维度的准确率:整牌准确率(7 个字符全对)、字符级准确率(单个字符维度)、定位召回率(车牌框是否被找到)。
| 测试场景 | 整牌准确率 | 字符级准确率 | 定位召回率 |
|---|---|---|---|
| 白天正面光照 | 90% | 97% | 100% |
| 傍晚逆光 | 70% | 86% | 90% |
| 斜角度 30 度以上 | 55% | 78% | 85% |
从这张表能看出,瓶颈不在 CNN 字符识别,而在定位和分割环节。逆光下蓝色信息弱,定位框容易偏;斜角度下字符投影不再规则,分割容易错位。所以调优顺序应该是先修定位,再修分割,最后才动模型。识别准确率这个东西有时候有玄学成分,但大多数时候是中间步骤的积累误差,把每一步的中间结果画出来看,问题通常一目了然。
6. 二次开发改造:接摄像头、换模型、部署成服务
6.1 三个值得动手的改造方向
第一个改造方向是接摄像头做实时识别。用cv2.VideoCapture(0)读取摄像头帧,每帧都跑一遍定位和识别。这里要注意性能:每一帧全流程跑下来约 200 毫秒,也就是每秒 5 帧左右,够用但也别做太重的后处理。我会加一个「连续 3 帧识别到同一车牌才输出结果」的防抖逻辑,避免单帧误识别导致道闸乱开。
第二个改造方向是支持新能源绿牌。在locate_plate里增加一组绿色 HSV 范围,与蓝色候选框合并后统一走后续流程。绿牌的字符分布和蓝牌一致,字符分割不用改,CNN 模型也无需重新训练。唯一要留意的是绿色范围容易把树叶、绿植误判进来,所以面积比过滤那一步对绿牌要更严格一点。
第三个改造方向是部署成 HTTP 服务。用 FastAPI 把predict_plate包一个 POST 接口,接收上传图片返回识别结果。注意把模型加载放在模块导入阶段,而不是每个请求里重复加载,否则并发上来后显存和内存都吃不消。
6.2 改造后的验证习惯
无论做了哪种改造,都建议保留一套固定的回归测试集:几十张不同光线、不同角度的真实车牌图,每次改完代码就整批跑一遍,对比准确率有没有回退。我自己就吃过亏,某次为了调快定位把闭运算核从 (17, 5) 改成 (9, 3),单张快了 15 毫秒,结果傍晚逆光的测试图全部定位失败,当时没留回归集,上线半天才被同事发现。从那以后我每次改定位或分割参数,都强制先跑一遍那 30 张固定测试图,准确率不掉点才敢继续下一步。这套源码加权重下载下来就能用,建议拿到手先跑通预测脚本,再复制一份代码慢慢改,改坏了随时有后路。希望帮到你。
本文还有配套的精品资源,点击获取