简介:基于 MATLAB 的卷积神经网络车牌识别工程,面向希望借助深度学习完成图像识别任务的初学者与开发者。项目覆盖车牌定位、字符分割、数据集预处理、CNN 模型训练与部署等完整流程,并配有详细说明文档与教程视频,可引导用户从零开始逐步搭建车牌识别系统。资源为 zip 压缩包,大小约 56.62MB,内部以 .m 脚本、.mat 数据文件及文档视频为主,涵盖数据加载、网络训练、标签转换等模块,目录结构清晰,便于按需快速查阅。已有 520 人学习使用,特别适合作为课程设计、毕业设计或入门实践参考。通过该工程可掌握 CNN 在真实场景中的搭建与调参方法,理解从图像输入到字符输出的完整处理链路,并学会构造自己的图像分类数据集与评估模型效果。
1. 为什么车牌识别绕不开卷积神经网络
很多时候真实车牌照片丢给人眼,一眼就认出来了;丢给 MATLAB 程序,它连「字在哪儿」都找不到。matlab 卷积神经网络车牌识别这条方案,核心不是让程序更聪明地认字,而是先把蓝底白字区域稳定切出来,把字符干净分离,再让 CNN 把一张小字图映射成类别。它适合三类人:做课程设计想跑通整个流程的学生、预研安防识别方案的工程师、不想换 Python 生态的 MATLAB 存量用户。读完能搭出一套从照片输入到字符串输出的最小可用系统,也能知道哪些环节看起来没事、跑起来全是坑。
2. 数据准备:从现场照片到 CNN 能吃的数据集
2.1 车牌定位:HSV 颜色分割 + 形态学,先拿到一块干净矩形
做车牌识别第一个动手点不是网络,而是定位。常见做法是先用 HSV 把蓝色区域的像素捞出来,再做形态学闭操作把断裂的字符连成块,最后取最大连通域作为车牌候选。HSV 比 RGB 抗光照变化,MATLAB 里rgb2hsv输出的色相在 [0,1] 区间,蓝牌的蓝色大约落在 0.55 到 0.7 之间,这是多组现场图微调出来的经验区间,不是某个标准的固定值。
img = imread('car_01.jpg'); [h, s, v] = rgb2hsv(img); % 蓝底白字:h 取蓝色范围,s 不能太低,v 不能太暗 maskBlue = (h >= 0.55) & (h <= 0.7) & (s >= 0.4) & (v >= 0.3); % 闭运算:把被铆钉、缝隙拆散的字符区域连回一块 maskBlue = imclose(maskBlue, strel('rectangle', [15 15])); maskBlue = imfill(maskBlue, 'holes'); stats = regionprops(maskBlue, 'BoundingBox', 'Area'); areaList = [stats.Area]; [~, idx] = max(areaList); box = stats(idx).BoundingBox; plate = imcrop(img, box);逻辑说明:maskBlue同时约束色相、饱和度、明度三个通道,避免把灰色路面和深色车漆误判成蓝色。imclose用的是 15×15 矩形结构元,车牌字符笔画之间的间隙大约在这个尺度内,闭运算之后候选区域才是一个完整矩形。regionprops取最大连通域,是因为车身广告贴纸、蓝色装饰条也可能满足颜色条件,但面积通常小于整块车牌。
参数说明:照片偏暗时把v >= 0.3降到0.15,色相区间也要按实际样本微调,不同相机白平衡会让蓝色漂移 0.05 左右。box是regionprops返回的[x y w h],直接喂给imcrop。如果候选里混入大面积蓝色广告牌,再加一条宽高比筛选,车牌矩形长宽比接近 3:1,box(3) / box(4)应该大于 2。
2.2 字符分割:垂直投影、铆钉排除与汉字粘连处理
定位切出的矩形通常带拍摄角度,我一般先用四点透视校正把它拉正,再做字符分割。校正用fitgeotrans配projective变换,把四边形映射到 270×90 的矩形,这个尺寸对应标准车牌 3:1 长宽比,后续分割和网络输入都基于它,空间尺度一致。
% srcPts 是车牌四角坐标,按 左上、右上、左下、右下 排列 srcPts = [x1 y1; x2 y2; x3 y3; x4 y4]; dstPts = [0 0; 270 0; 0 90; 270 90]; tform = fitgeotrans(srcPts, dstPts, 'projective'); plateRect = imwarp(plate, tform, 'OutputView', imref2d([90 270]));逻辑说明:fitgeotrans的'projective'模式能纠正俯仰角造成的梯形畸变,比'affine'多一个自由度,适合车牌相对相机有倾斜的真实场景。imref2d([90 270])设置输出画布为高 90、宽 270,所有图校正后都在同一坐标系里。
字符分割我用垂直投影法,这是从业者用得最多、也最容易解释的一种:
gray = rgb2gray(plateRect); % 蓝底白字直接阈值,黄底黑字需要反转 bw = gray > graythresh(plateRect) * 0.9; % bw = ~bw; % 黄牌打开这一行 % 垂直投影:按列求和,找字符区间 proj = sum(bw, 1); colSum = proj' - min(proj); colMask = colSum > max(colSum) * 0.2; diffMask = diff([0; colMask(:); 0]); startCol = find(diffMask == 1); endCol = find(diffMask == -1) - 1; % 排除铆钉和噪声:宽度小于 10 像素的列区间直接丢弃 valid = (endCol - startCol) > 10; startCol = startCol(valid); endCol = endCol(valid);逻辑说明:白字比蓝底亮,二值化后字符列像素和明显高于背景列,投影曲线的峰就是字符,谷就是字符间隙。colMask用最大值的 20% 做阈值,把浅色污渍、反光条这类低响应噪声过滤掉。diff找colMask从 0 变 1、从 1 变 0 的位置,得到每个字符区间的起止列。
参数说明:宽度阈值 10 像素是针对 270 宽度的分割图定的,换成别的画布尺寸要等比缩放。最容易翻车的是汉字,像「苏」「京」这类左右结构字,投影峰值集中在两边,中间谷值不够低,会被切成两半或漏切。我的处理是检查区间宽度:如果某个区间的宽度超过字符平均宽度的 1.4 倍,就按平均宽度等分补一刀。字符平均宽度用median(endCol - startCol)算,稳定且抗个别异常字符干扰。
2.3 生成 imageDatastore:文件夹名当标签,省去手写 label.txt
分割完成后把每个字符图存成独立小图,再用imageDatastore按文件夹名自动生成标签。文件夹名就是类别名,这是 MATLAB 里最不容易出错的标注方式,比手写 label.txt 再对齐路径要可靠得多。
root = 'char_db'; % 目录结构示例:char_db/A/001.png, char_db/0/002.png imds = imageDatastore(root, 'IncludeSubfolders', true, 'LabelSource', 'foldernames'); % 按标签划分训练验证集 [imdsTrain, imdsVal] = splitEachLabel(imds, 0.8, 'randomized'); % 统一输入尺寸:宽 32、高 48,保留字符笔画细节 augTrain = augmentedImageDatastore([32 48], imdsTrain, 'ColorPreprocessing', 'none'); augVal = augmentedImageDatastore([32 48], imdsVal, 'ColorPreprocessing', 'none');逻辑说明:LabelSource设为foldernames,MATLAB 自动把文件夹名变成categorical标签,不用再维护一份映射文件。splitEachLabel按每个类别 80% 划分训练集,保证汉字、字母、数字在两边都有样本。augmentedImageDatastore是官方推荐的输入适配器,它不预先生成所有缩放图,而是在训练过程中按需读取裁剪,内存占用小,几万张小图也不会卡死。
参数说明:[32 48]是宽、高两个值,车牌字符横宽比大约 1:2,做反了训练时反而丢失笔画结构。ColorPreprocessing设为'none',因为字符图已经是灰度,不需要再做颜色空间转换。归一化不要在这里做,交给第 3 章imageInputLayer的Normalization参数统一处理。
3. 搭建 CNN:三层卷积也好用的轻量分类网络
3.1 训练用哪种 API:layerGraph 更适合小项目
MATLAB 搭卷积神经网络有三条常用路径:直接的layer数组、layerGraph对象、以及新版的dlnetwork。layer 数组最老,适合一张直筒网络;layerGraph支持 DAG 拓扑,能接跨层相加这样的结构;dlnetwork把前向过程变成可编程的dlforward,训练循环完全自己写,适合 PINN、自定义损失这类需要精细控制的任务。
车牌字符识别是固定输入、固定类别的小分类任务,我一般选layerGraph。它不用写训练循环,trainNetwork内部已经处理了反向传播、梯度更新和验证集评估,调试时只需盯损失曲线。dlnetwork功能强,但每次改一个层都要检查前向传播是否匹配,数据量只有几千张时性价比太低。如果后续想把定位、分割、分类封装成一个 MATLAB 类,用classdef包一层函数即可,网络本体留在layerGraph上反而更好改。
3.2 核心网络结构:卷积块 + BN + Dropout,65 类输出
车牌字符集是 31 个省份汉字、24 个字母(去掉 I 和 O)、10 个数字,共 65 类。用 3 个卷积块完全够用,输入尺寸小,堆太深反而过拟合。结构如下:
layers = [ imageInputLayer([32 48 1], 'Name', 'input', 'Normalization', 'rescale-zero-one') convolution2dLayer(3, 32, 'Padding', 'same', 'Name', 'conv1') batchNormalizationLayer('Name', 'bn1') reluLayer('Name', 'relu1') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool1') convolution2dLayer(3, 64, 'Padding', 'same', 'Name', 'conv2') batchNormalizationLayer('Name', 'bn2') reluLayer('Name', 'relu2') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool2') convolution2dLayer(3, 128, 'Padding', 'same', 'Name', 'conv3') batchNormalizationLayer('Name', 'bn3') reluLayer('Name', 'relu3') maxPooling2dLayer(2, 'Stride', 2, 'Name', 'pool3') fullyConnectedLayer(512, 'Name', 'fc1') dropoutLayer(0.5, 'Name', 'drop1') fullyConnectedLayer(65, 'Name', 'fc_out') softmaxLayer('Name', 'softmax') classificationLayer('Name', 'classOutput') ]; lgraph = layerGraph(layers);逻辑说明:输入尺寸[32 48 1]是宽、高、通道,灰度图通道为 1。Normalization设rescale-zero-one,让augmentedImageDatastore读出的 uint8 数值自动缩放到 [0,1],不需要手动im2double。每个卷积块都是 卷积 + BN + ReLU + 最大池化 的组合,通道数从 32 翻到 64 再翻到 128,这是小网络的经典放大方式。经过三次池化,特征图从 32×48 缩到 4×6,全连接层拿到 128×4×6 共 3072 个特征。
参数说明:卷积核固定 3×3、Padding用'same'保持尺寸,池化核 2×2、步长 2。dropoutLayer(0.5)放在全连接前,防止全连接层把 3072 个特征死记成训练集。显存吃紧时把第三层通道从 128 降到 64,识别率损失通常不超过 1 个点。plot(lgraph)能画出网络结构图,每一层的输出尺寸一目了然,适合给论文配图或做方案评审。
3.3 输出层与类别不均衡:给少数类拉一把
训练集里数字和常用字母可能有几千张,省份汉字往往只有几十张,模型会直接倾向高频类别。最简单的办法是重复采样,把少数类样本复制几份,让每个类别在MiniBatchSize里出现的概率趋近均匀。
tbl = countEachLabel(imds); minCount = min(tbl.Count); rareLabels = tbl.Label(tbl.Count < minCount * 3); for i = 1:numel(rareLabels) subdir = fullfile(root, char(rareLabels(i))); fnames = arrayfun(@(k) fullfile(subdir, sprintf('%03d.png', k)), ... 1:30, 'UniformOutput', false); copyfile(fnames(:), subdir); end逻辑说明:countEachLabel统计每个文件夹的样本数,找出数量少于最少类 3 倍的标签,把这些标签下前 30 张图复制回同一目录。复制后imageDatastore重新扫描文件夹,会自然把这些复制文件算进样本量。这是一个零自定义层的粗调方案,不需要动classificationLayer。
参数说明:复制 30 份并不代表 30 倍,因为arrayfun生成 30 个文件名,实际是把原样本重复 29 份。补到多少合适?我的经验是少数类样本量达到多数类的三分之一即可,补过头会让模型把少数类的个别噪声当成稳定特征。如果复制后验证集上汉字准确率仍然上不去,再考虑自定义带类别权重的损失层,但那是最后手段。
4. 训练与参数:让损失曲线收敛而不是玄学抖动
4.1 trainingOptions:八个参数里只看这四个
trainingOptions里的选项有十几个,真正值得逐个调的就四个:InitialLearnRate、LearnRateSchedule、ValidationFrequency、MiniBatchSize。其余多数保持默认即可。下面是一份可以直接套用的配置:
opts = trainingOptions('sgdm', ... 'InitialLearnRate', 0.001, ... 'LearnRateSchedule', 'piecewise', ... 'LearnRateDropFactor', 0.5, ... 'LearnRateDropPeriod', 10, ... 'MiniBatchSize', 64, ... 'MaxEpochs', 30, ... 'Shuffle', 'every-epoch', ... 'ValidationData', augVal, ... 'ValidationFrequency', 30, ... 'Plots', 'training-progress'); net = trainNetwork(augTrain, lgraph, opts);逻辑说明:InitialLearnRate0.001 是这类小字符分类任务的稳妥起点,默认值 0.01 在数据量小时容易震荡。LearnRateSchedule设为piecewise,每 10 个 epoch 把学习率乘 0.5,后期微调权重。ValidationFrequency按迭代次数触发,因为一个 epoch 可能只有几十次迭代,按 epoch 看验证曲线太粗。Shuffle每个 epoch 打乱一次数据顺序,避免模型学到固定 batch 内的顺序偏差。
sgdm 还是 adam?数据量几千张、噪声不强的场景我习惯用 sgdm,动量默认 0.9 不用改。adam 收敛快,但最终精度经常比 sgdm 低一点,而且对学习率更敏感。如果验证集 loss 一直抖动,先检查学习率,再换优化器。
| 参数 | 默认值 | 本场景推荐值 | 理由 |
|---|---|---|---|
| InitialLearnRate | 0.01 | 0.001 | 字符分类任务简单,大学习率容易跳过最优解 |
| MiniBatchSize | 128 | 64 | 兼顾收敛速度和显存占用 |
| ValidationFrequency | 按 epoch | 30 次迭代 | 每 epoch 迭代次数少,按 epoch 看太慢 |
| LearnRateDropPeriod | 10 | 10 | 30 epoch 内降两次,后期慢慢磨 |
4.2 训练过程与早停:OutputFcn 里做验证集检查
训练时开着'Plots', 'training-progress'能看到损失和准确率曲线,但曲线只显示当前 batch 的累计结果,验证集表现要等ValidationFrequency触发。更主动的做法是在OutputFcn里挂一个回调,一旦验证损失明显脱离训练损失就提前终止。
function stop = myEarlyStop(info) stop = false; if info.State == "iteration" && mod(info.Iteration, 50) == 0 if ~isempty(info.ValidationLoss) if info.ValidationLoss > 1.2 * info.TrainingLoss + 0.05 stop = true; end end end end逻辑说明:OutputFcn在每个 iteration 结束后被调用,info.State区分当前是"iteration"、"epoch"还是"done"。这段代码每 50 次迭代检查一次验证损失,如果验证损失大于训练损失的 1.2 倍再加 0.05 的容差,说明模型开始过拟合训练集,直接停。
参数说明:1.2 和 0.05 是经验容差,验证集噪声大就放宽到 1.5 和 0.1。不要用info.State == "done"做判断,那时训练已经跑完了,早停没有意义。早停触发后模型权重停在触发点,需要手动记录这个 iteration 的模型,trainNetwork默认返回最后一次迭代的结果,两者不一定相同。
4.3 用混淆矩阵看真实效果:别只信一个 accuracy
trainNetwork跑完,验证集准确率 98% 不代表系统可用,因为「背景」和「数字」类别样本多,整体准确率会被高频类别拉高。必须看每个类别的混淆情况。
YPred = classify(net, augVal); YReal = imdsVal.Labels; acc = mean(YPred == YReal); figure; cm = confusionchart(YReal, YPred, 'Normalization', 'row-normalized');逻辑说明:classify返回categorical类型,直接和原始标签做比较得到整体准确率。confusionchart用row-normalized归一化后,每一行代表某个真实字符被识别成各类别的概率,对角线就是每类召回率。整体 accuracy 高了,但某一行全是斜的,说明这一类字符基本没学会。
参数说明:重点看对角线低于 0.8 的行。常见问题是「8」和「B」、「0」和「D」互相串,这是字符形态天然接近,可以在训练集里把这两个类别单独多采样几份。混淆矩阵还能检查标签顺序是否错乱,如果某一行整体偏移到相邻类别,多半是分割时字符顺序没排对,不是网络问题。
5. 车牌识别避坑:训练、识别、运行三个周期的排查记录
5.1 训练期:损失不降,先查标签再查输入
现象:训练到第 5 个 epoch 后损失曲线接近水平,accuracy 卡在 20% 上下,怎么调学习率都没用。
原因:最常见的是标签和图像错位,分割出的字符图存盘顺序和文件名编号没对齐,网络一直在用错误的监督信号学习。其次是输入数据范围问题,uint8 的 0~255 数值直接送进网络,梯度量级不稳定。
解决:先抽 200 张训练图,用montage拼成一张大图逐张核对字符和标签是否一致,这个步骤半小时内能完成,但能省下后面几天调参时间。数据范围交给imageInputLayer的Normalization参数处理,不要自己额外写im2double再叠加一层缩放。标签错位是硬伤,靠调参永远救不回来。
5.2 识别期:为什么模型认得测试集、不认真实照片
现象:验证集准确率 98%,换成真实停车场照片走完整流程,识别率掉到一半以下,尤其是逆光和倾斜角度大的图。
原因:训练集是从已经定位、分割干净的字符图上切出来的,而真实流程里定位框偏两个像素、透视校正差几度、车牌上有泥点污渍,这些形变在训练集里不存在。模型从没见过带噪声的输入,自然表现差。光照差异大的场景,HSV 定位还会漏检,根本走不到分类那一步。
解决:在augmentedImageDatastore里开DataAugmentation,做 ±5° 旋转、0.95~1.05 缩放、高斯噪声三种增强,让模型见过真实分布的扰动。定位环节对亮度差异大的图,先做一次histeq亮度均衡再进 HSV,能明显减少强光下的漏检。最后用 100 张带倾斜、反光的原始照片走完整流水线测「整牌识别率」,这个指标才是验收标准。
5.3 识别期:汉字全错,数字字母全对
现象:混淆矩阵里数字行的准确率超过 98%,省份汉字行准确率不到 30%,输出结果里汉字位置永远是训练样本最多的那一个。
原因:类别样本不均衡叠加汉字笔画复杂。汉字类别样本只有数字的十分之一,网络在损失函数里看到数字的贡献远大于汉字,干脆放弃汉字,把这一位置预测成高频类别。
解决:第 3 章的重复采样策略先把少数类补上来,补到多数类三分之一以上。如果仍然不理想,把汉字单独拆一个分类器,只输出省份简称,字母和数字用另一个分类器,两阶段串联识别。这个方案牺牲一点处理速度,但每个分类器类别少、样本相对均衡,汉字准确率能稳定拉到 90% 以上。
5.4 工程期:中文标签乱码与模型重新加载错乱
现象:文件夹名用中文「京」「苏」时,命令行输出乱码;保存整个网络后重新加载,分类标签顺序和训练时不一致,识别结果全部错位。
原因:老版本 MATLAB 对中文目录和categorical标签的编码支持不完整,分类层内部记录的是字符串数组,环境 locale 不一致就会乱。网络保存时如果只存net.Layers,自定义层信息和标签映射会丢掉。
解决:字符标签全部用 ASCII 编码,汉字类别在文件夹和代码里用拼音缩写或数字 ID,比如「京」用BJ、「苏」用SU,输出阶段再用一个字符映射表转回中文。代码文件保存为 UTF-8 编码,避免中文注释也乱。保存网络用save('plateNet.mat', 'net')保存整个网络对象,不要只存层数组。
5.5 运行期:显存不足与 CPU 慢到怀疑人生
现象:显存 2G 的机器,MiniBatchSize设 128 直接报显存溢出;纯 CPU 跑 30 个 epoch 要 40 分钟,每次改参数都要重跑一遍。
原因:输入尺寸虽小,但第三个卷积块 128 通道的特征图在 batch 累积下仍然占显存。CPU 跑卷积没有专门优化,训练时间主要耗在卷积层上。
解决:MiniBatchSize降到 16,观察单个 epoch 时间,再逐步加到 64。显存仍然不够就把第三层通道数从 128 降到 64。ExecutionEnvironment设'auto',让 MATLAB 自己检测可用 GPU,不要手动指定'gpu',无 GPU 环境会直接报错。训练阶段用 28×40 的输入也能保住识别率,推理阶段再切回原始分辨率。
6. 进阶:把分类器接回完整流水线,拍照直接出车牌号
6.1 组装识别函数:定位、校正、分割、分类一次跑完
训练好网络之后,把它接回定位和分割代码,封装成一个输入图片路径、输出字符串的函数,这才是能交给别人用的东西。下面这个骨架函数把第 2 章和第 3 章的模块串起来,占位部分按你的数据集替换:
function plateStr = recognizePlate(imgPath, net, charMap) img = imread(imgPath); % 1. 定位:HSV 取蓝色区域、形态学闭运算、最大连通域 % 2. 透视校正:fitgeotrans 映射到 270x90 % 3. 垂直投影分割:得 cell 数组 charImgs chars = strings(0); for i = 1:numel(charImgs) cIm = imresize(charImgs{i}, [48 32]); cIm = im2double(cIm); lab = classify(net, {cIm}); chars(i) = charMap(char(lab)); end plateStr = strjoin(chars, ''); end逻辑说明:classify输入用{cIm}元胞数组包一层,兼容多张图输入的调用约定,单张图也能正确处理。imresize前要确认宽高顺序,[48 32]是行数(高)、列数(宽),和第 2 章输入尺寸对应。charMap是字符串映射容器,键为 ASCII 标签、值为中文字符,把网络输出的拼音缩写翻译回车牌文字。
参数说明:这个函数把三块逻辑绑在一起,排查时按返回值反推:输出全是乱码,看分割的charImgs是否按顺序排列;输出里个别字符错,看分类器;定位框偏了,输出会是乱序叠加乱码。调试顺序永远是从输入图像到字符图,再从字符图到标签。
6.2 验证方法的递进:测试集只是底线,现场样本才是验收
我习惯分三层验证这个流水线。第一层用测试集算整体准确率,这层只是底线,过了不说明能用。第二层用 100 张原始照片走完整流水线,统计两个指标:整牌完全正确的比例、单字符正确的比例,后者能定位到具体是哪个模块出错。第三层对同一块车牌连续拍 10 张,看输出是否每次一致,如果同一张牌第一次识别成功第二次失败,说明系统对裁剪位移和光照波动太敏感,必须回到预处理增强上补课。这个习惯帮我挡掉了好几次「测试集 99% 但现场没法用」的返工。希望帮到你。
本文还有配套的精品资源,点击获取