简介:基于Python与卷积神经网络的车牌识别完整项目资源,面向计算机视觉、深度学习的入门与进阶学习者,可用于智能交通、自动车辆等场景中的车牌检测与识别任务。包内聚焦CNN建模全流程,涵盖数据预处理、Keras/TensorFlow模型构建、训练调参、评估指标与部署思路等核心内容。资源共25个文件,以jpg图像数据集、png示例图、py模型脚本与md说明文档为主,辅以权重数据与辅助配置,整体容量29.2MB,目录按工程模块组织,便于对照学习与二次开发。目前已有206人学习下载,适合希望以完整可运行项目理解图像识别实战流程的开发者。通过该资源,读者能掌握从车牌图像数据准备到CNN模型训练、优化与部署的关键步骤,并获得可直接扩展应用的项目结构思路,是理解深度学习解决现实视觉问题的良好范例。
1. Python-CNN车牌识别:一个压缩包能跑通的完整链路
提到车牌识别,很多人第一反应是YOLO、车牌检测、OCR那一整套重型工程。但这个项目不一样,解压开 License-Plate-Recognition-master ,你会发现它走的是另一条路:用 OpenCV 做车牌定位和字符分割,再用 CNN 对分割好的字符图片做分类识别。两个阶段各司其职,单张图片从输入到输出车牌号,一条链路完整走通,没有拆成两三个项目拼凑。
这套思路特别适合两类人。一是刚接触视觉深度学习的开发者,项目里模型结构简洁,训练脚本、预测脚本标注清晰,能一口气看到数据怎么喂给网络、损失怎么算、准确率怎么评估。二是有具体落地需求但不想为识别引擎付高额授权费的人,比如停车场道闸、园区门禁的离线识别场景。虽然这个项目不算工业级系统,但作为识别引擎的原型足够用了。
这份资源的核心价值不在于模型有多新,而是它把「采集车牌图像 → 字符分割 → CNN 分类 → 输出号码」的每个环节都用 Python 代码串了起来。你拿到的不是一个孤零零的模型文件,而是一整套可以拆开改造的源码包。
2. CNN 在车牌字符识别里怎么工作:网络结构与数据流
2.1 为什么选 CNN 而不是传统模板匹配
车牌字符识别和通用 OCR 有一个显著差异:车牌字符集是封闭的。国内车牌通常由省份汉字、英文字母、数字组成,类别总数不超过 80 个。封闭字符集意味着分类问题可以做得非常聚焦,不需要像通用 OCR 那样考虑上万种字形。但即便是封闭字符集,传统模板匹配方法也顶不住拍摄角度、光照、污损带来的形变。同一张车牌在不同天气、不同角度下,字符的灰度分布差异很大,模板匹配的鲁棒性很差。
CNN 的优势在于它自动学习特征层级。底层卷积核学习边缘、角点这类局部模式,高层卷积核组合成字符的笔画结构特征。这个特征提取过程是数据驱动的,不需要人工设计 HOG、LBP 这些特征描述子。在车牌识别这个场景下,训练数据只要覆盖足够多的车牌字体和拍摄角度,CNN 对形变的容忍度明显高于传统方法。
这个项目用的正是典型的卷积-池化-全连接路线。卷积层负责提取特征图,池化层压缩特征图尺寸、保留主要响应,全连接层把特征图展平后映射到字符类别概率。整体结构对新手很友好,不需要理解 ResNet 的残差连接或者注意力机制,把基础模块串起来就能跑。
2.2 识别流程中的数据流拆解
整套识别链路不是一步到位的。原始图像先要经过车牌定位,把车牌区域从整图中抠出来;抠出来的车牌图再经过字符分割,切成单个字符的小图;每个字符小图分别送入 CNN 分类,最后把分类结果拼成完整车牌号。
数据流大致如下:
输入整图(640x480) → 车牌定位(OpenCV 边缘检测+轮廓筛选) -> 车牌区域图(约 440x140) -> 灰度化/二值化 -> 字符分割(连通域分析/投影法) -> 字符图(约 40x80) -> 缩放至 CNN 输入尺寸(32x32 或 64x64) -> CNN 前向传播 -> 输出字符类别各阶段输出数据的格式、尺寸必须对齐,这是整个流程最容易翻车的地方。字符图缩放尺寸和 CNN 输入层尺寸不一致,模型会直接报错或者给出毫无意义的预测结果。我在拆这个项目时,第一步就是确认训练脚本里输入层尺寸,再倒推字符分割脚本的输出尺寸,确保两者匹配。
2.3 数据预处理:灰度、缩放、归一化一个都不能少
项目里的训练数据是字符级别的小图,不是整张车牌。预处理环节挂在字符分割和模型输入之间,处理顺序有讲究。我一般按下面这个顺序来做:
def preprocess_char(img, target_size=(32, 32)): # 输入是单字符灰度图,可能是分割后的不规则 ROI # 先做灰度化,如果原图是三通道 if len(img.shape) == 3: img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 二值化,突出字符前景,抑制背景噪声 _, img = cv2.threshold(img, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) # 统一缩放到固定尺寸,保持字符长宽比 h, w = img.shape scale = min(target_size[0] / w, target_size[1] / h) nw, nh = int(w * scale), int(h * scale) resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_CUBIC) # 贴到黑色画布中央,避免变形 canvas = np.zeros(target_size, dtype=np.uint8) x_off = (target_size[0] - nw) // 2 y_off = (target_size[1] - nh) // 2 canvas[y_off:y_off+nh, x_off:x_off+nw] = resized # 归一化到 [0, 1] return canvas.astype(np.float32) / 255.0这段逻辑里有两个容易被忽略的细节。缩放到目标尺寸时,我用的是等比例缩放加居中贴板,而不是直接cv2.resize(img, target_size)。直接拉伸会把字符压扁,尤其是汉字字符,笔画结构对形变极其敏感,压扁之后分类准确率会掉好几个百分点。归一化这一步把像素值从 0-255 映射到 0-1,是为了配合网络输出层的激活函数,让梯度传播更平稳。如果训练代码里用了categorical_crossentropy做损失,对应标签必须做 one-hot 编码,这个是配套动作,容易漏。
3. 从压缩包到第一次训练:环境搭建与数据集组织
3.1 环境依赖怎么装才不会翻车
解压License-Plate-Recognition-master.zip之后,第一步不是看代码,而是先确认依赖版本能不能匹配上。这个项目的代码风格属于 TensorFlow 1.x 到 2.x 过渡时期的写法,很多老项目会有tf.placeholder、tf.Session()这些 API,在 TF 2.x 下直接跑会报AttributeError。
我建议直接新建虚拟环境,别往系统 Python 里塞依赖,不然装坏了影响其他项目。
# 创建 Python 3.7 虚拟环境,这个版本对 TensorFlow 兼容性最稳 conda create -n plate_recog python=3.7 conda activate plate_recog # 安装 TensorFlow。如果代码是 1.x 风格,装 1.15.0 pip install tensorflow==1.15.0 # 需要的话,TensorFlow 2.x 下跑旧代码可以用兼容模式 # pip install tensorflow==2.4.0 # 然后在代码里加一行 # import tensorflow.compat.v1 as tf # tf.disable_v2_behavior() # 图像处理与科学计算库 pip install opencv-python==4.5.3.56 numpy==1.19.5选 TensorFlow 1.15 而不是 2.x 的原因是,老项目训练脚本里如果直接调用了tf.Session、tf.train.Saver,在 TF 2.x 原生模式下无法运行,强行用 compat 模式适配反而会增加排查成本。Python 3.7 是这两代 TensorFlow 都能覆盖的版本,往上 3.8、3.9 在 TF 1.15 下会有编译兼容问题。OpenCV 版本不用追求最新,4.5.x 足够稳定。
3.2 数据集目录结构:标签藏在文件夹名里
模型训练需要大量标注数据。这个项目里字符图片的标签不是写在 JSON 或 CSV 里的,而是直接利用文件夹名做标签。这是早期数据集的常用组织方式,优点是直观,缺点是标签和文件一一对应,没有额外信息(比如拍摄条件、车牌底色)。
典型的目录结构如下:
dataset/ ├── train/ │ ├── 0/ │ │ ├── 0001.jpg │ │ ├── 0002.jpg │ │ └── ... │ ├── 1/ │ ├── ... │ ├── A/ │ ├── B/ │ ├── 京/ │ └── ... └── val/ ├── 0/ ├── 1/ └── ...项目自带的数据集如果不够用,可以到开源的 CCPD 数据集(中国城市车牌数据集)里提取字符级图片补齐。需要注意训练集和验证集的划分不要按文件名随机切,最好按车牌图来源划分,保证同一张车牌衍生出的字符图片不会同时出现在两个集合里,否则验证集指标会虚高。
3.3 训练入口脚本:跑起来前先改这三处
网上下载的老项目,解压后第一件事是找到训练入口文件。一般叫train.py或train_cnn.py。打开后先找data_dir、batch_size、epochs这几个变量,大概率要改。
# 训练脚本关键配置段 data_dir = "./dataset/train" # 改成你本地数据集的绝对路径 val_dir = "./dataset/val" # 验证集路径 batch_size = 64 # 显卡显存不够就降到 32 epochs = 50 # 首次跑可以降到 10 验证流程 lr = 0.001 # 初始学习率 img_size = (32, 32) # 必须和预处理脚本保持一致 num_classes = len(os.listdir(data_dir)) # 自动从文件夹数量读取类别数num_classes用os.listdir自动读取可以避免手数类别数量的低级错误。img_size必须和字符分割脚本里的缩放尺寸一致,否则模型输入维度不匹配。epochs第一次跑建议调小,先验证数据加载和模型构建没问题,再调回完整训练轮数。
训练过程会输出每个 epoch 的损失和精确率,留意验证集精确率是否持续上升。如果训练损失下降但验证损失升高,说明过拟合了,常见对策是按需调整网络层数或增加数据增强。
4. 训练参数与评估方法:指标怎么算、曲线怎么看
4.1 CNN 的训练配方:优化器、损失函数、Dropout 与模型的搭配
这个项目原版模型结构是三个卷积层搭配两个全连接层,每个卷积层后跟一个最大池化层,全连接层之间加了 Dropout 做正则化。这种结构在字符分类任务上属于经典配方,作为 baseline 完全够用。如果你改进了模型结构,训练参数也需要同步调整。
我习惯的适配方式如下:
from keras.models import Sequential from keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout def build_model(input_shape=(32, 32, 1), num_classes=65): model = Sequential([ # 第一个卷积块:8 个小卷积核,提取低级边缘特征 Conv2D(8, (3, 3), activation='relu', padding='same', input_shape=input_shape), MaxPooling2D((2, 2)), # 第二个卷积块:16 个卷积核,组合出笔画级特征 Conv2D(16, (3, 3), activation='relu', padding='same'), MaxPooling2D((2, 2)), # 第三个卷积块:32 个卷积核,抽象局部结构 Conv2D(32, (3, 3), activation='relu', padding='same'), MaxPooling2D((2, 2)), # 展平后接全连接层,Dropout 控制过拟合 Flatten(), Dense(128, activation='relu'), Dropout(0.5), Dense(num_classes, activation='softmax') ]) model.compile( optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'] ) return model这里的关键决策是:卷积核数量从 8 到 16 到 32 逐层翻倍,因为越靠后的层感受野越大,需要更多卷积核来覆盖不同位置的模式组合。Dropout 放在最后一个全连接层前面,作用是在训练时随机屏蔽一半神经元,迫使网络不依赖单个路径做决策,这是防止字符分类过拟合性价比最高的手段。
优化器我通常用 Adam 而不是 SGD,Adam 自带自适应学习率调整,对新手友好,不用手动做学习率退火。如果追求极致准确率,可以等 Adam 训到接近收敛后,换成低学习率的 SGD 再精调几个 epoch,不过车牌字符集封闭、数据量有限,这个收益不明显。
4.2 评估指标:准确率之外还要看混淆矩阵
训练脚本最后会打印测试集准确率,但准确率高不意味着模型能直接上线。车牌字符里有几个天然难点:汉字省份简称「沪」和「苏」在低分辨率下很容易混淆;数字「0」和字母「O」在很多车牌字体下几乎一样;字母「B」和数字「8」在模糊时也难以区分。只看总准确率是发现不了这些问题的。
评估时应该加一个混淆矩阵输出:
import numpy as np from sklearn.metrics import confusion_matrix, classification_report # 完成预测后 y_pred = model.predict(test_generator, verbose=1) y_pred_classes = np.argmax(y_pred, axis=1) y_true_classes = test_generator.classes # 打印每个类别的精确率和召回率 print(classification_report( y_true_classes, y_pred_classes, target_names=class_names )) # 混淆矩阵建议存成文本,后续定位具体错误用 cm = confusion_matrix(y_true_classes, y_pred_classes) np.savetxt("confusion_matrix.txt", cm, fmt='%d')逐个类别的精确率和召回率比总准确率更说明问题。如果「沪」的召回率明显低于其他字符,说明这个字符的训练样本可能太少,或者字符分割阶段经常把它的左半部分切掉。车牌识别系统里字符级别的错误会直接导致整张车牌号识别失败,一张车牌 7 个字符,哪怕每个字符准确率 99%,整牌准确率也只剩 93% 左右,所以字符级别的弱点必须单独清理。
4.3 字符分割质量直接影响 CNN 上限
CNN 训练得再好,字符分割阶段出问题,整张图也白搭。分割阶段最常见的输出问题是字符粘连、字符断裂、边缘噪声残留。
def segment_chars(plate_gray): # 先做二值化 binary = cv2.adaptiveThreshold( plate_gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2 ) # 形态学开运算去除细小噪声点 kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 按列投影统计字符区域位置 col_sum = np.sum(binary, axis=0) col_blank = col_sum == 0 # 找出字符列的连续区间,再按宽度过滤掉噪声段 # 车牌字符宽度占比相对固定,过滤条件按图像宽度比例设置 # 找到每个连通域后,按 x 坐标排序 # 再根据已知字符宽度做二次筛选 return char_regionsadaptiveThreshold比固定阈值好在能适应车牌上光照不均的情况,但它的参数(如blockSize=11和C=2)需要根据实际图像微调。字符分割这个环节的玄学程度不亚于模型调参,后面单独写一节讲踩坑记录,这里先按下不表。
5. 车牌识别翻车现场:五个高频踩坑与排查路径
5.1 现象:ModuleNotFoundError: No module named 'keras'或 TensorFlow 报错
原因:压缩包里有些版本代码用from keras.models import Sequential,有些用from tensorflow.keras.models import Sequential。TensorFlow 2.x 环境下keras和tensorflow.keras是两个不同的包路径,混用时会互相覆盖或直接报找不到模块。
解决:统一改成from tensorflow.keras.models import Sequential,同时把tensorflow.keras.optimizers、tensorflow.keras.layers里的所有 Keras 组件全部改成tensorflow.keras.*前缀。如果用的 TF 1.15,则反过来统一用独立的keras包。两套路径混用是编译阶段最常见的报错来源。
5.2 现象:训练时损失震荡不下降,准确率卡在几个点
原因:学习率设置过高(lr=0.01或更高)时,Adam 优化器容易在损失曲面边缘震荡;另一种情况是输入图片没有归一化到[0,1],像素值在 0-255 范围直接进入网络,导致梯度数值不稳定。
解决:先检查预处理阶段是否做了img / 255.0。然后用lr=0.001起步,观察前五个 epoch 的损失是否单调下降。如果仍然震荡,把学习率降到0.0001。网络结构的全连接层单元数也可以从 128 降到 64,参数量减少之后,小数据集上的训练会更平稳。
5.3 现象:验证集准确率远低于训练集,训练曲线后期分离
原因:典型的过拟合。字符训练集通常只有几百张到几千张图片,CNN 参数量远多于样本量,特征提取层会把训练集中的背景纹理、噪声模式一并记进去,导致泛化能力差。
解决:顺序依次是:第一,增加 Dropout 比例,从 0.5 提到 0.7;第二,做数据增强,把字符图片随机轻微旋转(±10 度)、平移(±2 像素)、缩放(±10%);第三,检查训练集和验证集是否来自同一张车牌,是的话改为按车牌图分组划分。增强代码可以用ImageDataGenerator,三行就能配置完。
5.4 现象:识别结果里「京」「津」「沪」等汉字老是错
原因:汉字字符在低分辨率下笔画密集,分割时容易切掉偏旁部首,或者和旁边字符粘连。CNN 接收到的输入图片本身是残缺的,分类准确率自然上不去。
解决:优先检查字符分割脚本输出的二值图有没有完整的汉字轮廓。分割宽度阈值如果只按数字和字母的宽度范围设置,会漏掉宽度较大的汉字。我会把分割后的字符图按批次保存到文件夹里,肉眼扫一遍再决定改分割参数还是补训练数据。
5.5 现象:OpenCV 定位不到车牌,整图输入直接报错
原因:蓝底白字的车牌在 HSV 颜色空间有明显特征,但老项目里常用的定位方式是灰度图边缘检测加轮廓筛选。如果目标车牌在深色背景下,边缘特征不清晰,轮廓筛选会直接落空。
解决:改用 HSV 颜色过滤先行定位蓝色区域,再在原图对应位置裁剪。把 BGR 转 HSV 后,蓝色车牌对应的色相范围约在100~124,饱和度> 100,明度> 80。这个方式对蓝色车牌的命中率比纯边缘检测高很多,代价是只能识别蓝色车牌,对新能源绿色车牌需要另加一路判断。如果你要处理混合颜色的车牌,代码里要分别定义每种颜色的 HSV 区间。
6. 从静态图片到实时视频流:模型推理与批处理技巧
训练完模型拿到model.h5文件之后,下一步不是部署上线,而是先封装一个推理函数,把「图片 → 车牌号」的完整链路固定下来。我习惯把加载模型、预处理、预测这组动作封装成一个函数,输入路径输出字符串,互不依赖,方便后续接摄像头或接 HTTP 接口。
import cv2 import numpy as np from tensorflow.keras.models import load_model model = load_model("model.h5") def predict_plate(image_path): # 1. 读取整图 img = cv2.imread(image_path) # 2. 定位车牌区域(这里用 HSV 蓝色过滤) hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, (100, 100, 80), (124, 255, 255)) # 用 mask 找到蓝色区域外接矩形,裁剪出 plate_img # 3. 车牌图灰度化、二值化后做字符分割 char_imgs = segment_chars(plate_gray) # 4. 每个字符图预处理后送入 CNN result = [] for char_img in char_imgs: processed = preprocess_char(char_img, target_size=(32, 32)) prob = model.predict(processed.reshape(1, 32, 32, 1), verbose=0) char = np.argmax(prob) result.append(char) return ''.join(result)preprocess_char的处理顺序可以复用第 2 章里那套灰度化、二值化、等比例缩放、居中贴板、归一化的逻辑,只要训练时和推理时使用同一套预处理,模型的输入分布就能保持一致。result.append(char)里的char实际上是一个类别索引,需要逆映射回字符本身。这个映射关系在class_names(训练时由文件夹名生成的列表)里,推理脚本一定要保留这个列表文件,否则预测结果就是一堆没有意义的数字。
从单张图片过渡到实时视频流,要注意的地方在于帧率。我的做法是每隔 N 帧抽取一帧做完整识别,其余帧直接跳过。车牌识别本身不需要逐帧处理,因为车辆进出道闸时车牌在视野内停留的时间足够长,间隔 0.3 秒抽一帧已经能满足使用场景。Python 版本可以用cv2.VideoCapture读摄像头,对抽出的帧执行predict_plate的定位部分;C++ 版本则是把同一套逻辑翻译过去,核心不变。
批量处理一批车牌图片时,可以加一个简单的并发优化:用multiprocessing.Pool把不同图片的定位、分割、识别分配到多个进程并行执行。CNN 推理本身在 GPU 上是高吞吐的,但 OpenCV 的定位、分割这些 CPU 操作占了整个链路的一半以上时间,并行化之后整批图片的吞吐能明显提升。
这个项目我从下载到跑通,前后花了一天时间,最大教训是不要上来就调网络结构。先把预处理、训练、推理这条链路原封不动地跑一遍,确认基线准确率,再改模型或加数据。从那以后我每次拆这类老项目,都强制自己先按原作者的参数跑通一轮,记录 baseline,之后的所有改动才有对照意义。希望这份拆解能帮你省下一些试错的时间。
本文还有配套的精品资源,点击获取