车牌识别一直是数字图像处理和模式识别课程里非常经典的综合性案例。一个完整的车牌识别系统从图像中定位车牌、切分字符再到识别字符每一步都对应着图像预处理、特征工程和分类器设计中的核心知识点。这套基于BP神经网络的车牌识别系统配合Matlab代码输入一张含车牌的车辆图片就能依次完成定位、分割和识别最后输出车牌号。整套流程不依赖深度学习框架只用Matlab基础工具箱就能跑通原理摊得很开。对正在学模式识别、数字图像处理的学生或者想自己动手做一套车牌识别Demo的工程师来说都是很好的参考样本。我最初做这个方案是为了验证“传统图像处理浅层神经网络”这套组合在一个真实任务上到底能走到什么程度。做完之后整体感受是BP网络本身不复杂真正的工程量在预处理和字符分割上。下面我把整个项目的思路、数据结构、网络设计、关键代码和踩坑记录都整理出来尽量讲清楚每一步背后的“为什么”。1. 项目整体思路拆解为什么要用BP神经网络做车牌识别1.1 车牌识别的三段式框架车牌识别系统的经典结构是“定位—分割—识别”三段式少一段都不行。定位是从整张车辆图像里找到车牌所在的矩形区域输出车牌图块。分割是在车牌图块内把若干个字符逐一切出来输出一组字符小图。识别则是把每个字符小图送入分类器输出具体的字符类别。为什么是这种三段式因为车牌本身有非常强的几何和颜色先验。定位可以用颜色、边缘、长宽比例去做不需要学习分割可以靠投影、连通域去做也不需要学习。真正需要学习的是“这张小图是什么字符”这是典型的模式分类问题正好交给BP神经网络。所以这套系统的设计逻辑非常清晰能用规则就用规则规则搞不定才用学习模型。1.2 BP神经网络在系统中的位置与选型理由字符识别环节可选的方案其实不少模板匹配、SVM、BP神经网络、CNN都有。我在这个项目里选BP神经网络主要有几个考虑。模板匹配实现最简单把每个字符的模板保存下来待识别字符逐个模板做相关运算取最大值。但模板匹配的痛点在于字符有倾斜、粗细不均、光照不均时匹配分数会剧烈波动稍微一个形变就把分数打乱了鲁棒性不够。SVM在小样本上表现很好但Matlab里用SVM做多分类要么做一对一、一对多组合要么装额外工具箱代码量会膨胀。而且SVM输出的是决策值而不是直观的概率后续要判“拒识”还得再包一层。BP神经网络则把特征向量映射到类别空间输出层每个节点对应一个字符类别训练好之后前向传播一次就出结果。Matlab神经网络的工具箱封装完整写训练代码只用几行。而且BP网络是理解梯度下降、反向传播原理的最佳载体对教学和实验来说非常合适。有同学可能会问为什么不直接上CNN我的回答是对于字母数字字符识别这种上百个类别的小图分类任务CNN当然效果更好但训练CNN需要更多数据、GPU和框架配置对课程设计或入门项目来说属于过度设计。BP网络训练速度快结果可控更容易把“特征—网络—训练—评估”这条链路理解透。先掌握BP再迁移到CNN理解成本会低很多。1.3 这套系统适合什么场景这套系统定位的是离线或准实时的车牌识别场景比如实验数据集批量识别、课程演示、算法原型验证。在真实工程中摄像头抓拍、道闸控制这类环境光照可控、车速可约束的场景这套流程稍微优化就能用。但如果是复杂路面多目标识别、夜间强光或极端倾斜的情况就需要更重的模型和更精细的预处理这是Matlab原型之外的事了。从影响范围看这套代码和思路可以作为后续做LPR算法、智能交通相关课程设计、毕业设计甚至小型项目预研的起点。2. 数据准备与图像预处理2.1 图像数据从哪来又该怎么组织训练BP网络之前必须先准备字符样本集。样本来源可以是采集的车辆图像中切出的字符也可以是公开字符库甚至可以用程序批量合成字符图像。我更推荐用真实切图再配合合成数据。原因很简单真实切图能覆盖相机视角、光照、噪声的真实分布但数量有限合成数据能快速生成大规模样本覆盖字体、倾斜、干扰等变化但和真实环境存在域差异。两者混合使用训练出来的模型泛化能力最稳。数据组织形式建议做成“一类别一个文件夹”dataset/ train/ 0/ 1/ ... A/ B/ 京/ 津/ ... test/ 0/ 1/ ...训练脚本读文件夹时直接把类别名作为标签一次性构建特征矩阵和标签向量。Matlab里可以用dir函数列出类别文件夹再循环读取每张图像。另外要注意样本量。BP网络的容量不算大每类字符建议至少50~100张原始样本经过扩增后每类保持在500张左右。如果类别涵盖汉字、数字、字母全部字符总数可能上百类训练时间会明显增加。很多入门方案为了降低复杂度只做数字和字母识别或者只做省份简称字符识别效果会好很多。2.2 车牌定位颜色先验与边缘信息结合使用车牌定位最稳的做法是颜色分割加形态学筛选。车牌区域有很强的颜色特征常见样式是蓝底白字也有黄底黑字和新能源绿牌。以蓝牌为例先把RGB图像转到HSV空间再做阈值分割。Matlab里rgb2hsv输出的H、S、V范围是0到1我常用的蓝色阈值范围是mask (hsv(:,:,1) 0.55 hsv(:,:,1) 0.75) ... (hsv(:,:,2) 0.30) ... (hsv(:,:,3) 0.30);这个阈值不是死的不同相机的白平衡差异会让蓝色偏移实际操作时先取几帧样例看看H分量的直方图再微调。只做颜色分割还不够图像里蓝色的车身、蓝色的指示牌都会产生干扰。所以还要结合边缘和形态学操作。我的做法是对原图做灰度化用Sobel算子提取垂直边缘。为什么要垂直边缘因为车牌字符笔画和边框会产生大量垂直方向的梯度而自然背景中垂直边缘密集的区域远少于水平边缘这样能更快锁定候选区。边缘图做完闭运算把字符缝隙连通成一个整体块。颜色掩膜和边缘闭运算结果做交集再用regionprops筛选连通域筛选条件通常包括宽高比在2到5之间面积占全图比例在一个合理范围矩形度不能太低符合条件的最小外接矩形就是车牌区域。用坐标裁剪原图就得到车牌图块。实操里定位失败最多的原因是用纯颜色分割结果车身和背景的相似颜色干扰。颜色边缘双重约束之后虚警率会明显下降。2.3 字符分割垂直投影是主力手段拿到车牌图块后先灰度化再做灰度拉伸把对比度拉开。灰度拉伸这一步别省很多车牌图像对比度不高直接二值化会得到一团糊。二值化可以选择固定阈值或Otsu。Matlab里我一般用imbinarize它默认采用Otsu方法在大部分光照条件下表现稳定。分割字符最常用的是垂直投影法。把二值图像按列统计白色像素数画出投影曲线字符与字符之间会出现明显的波谷。找到这些谷底位置就能把连续的字符块切出来。这是最直观、解释性最强的方法。但有两个问题需要注意。第一个是边框干扰。车牌区域二值化之后白色边框会和字符连成一片导致投影曲线没有明显的谷底。所以在切割之前先做一次横向形态学腐蚀把细边框断开或者直接根据车牌比例裁掉上下边缘附近的条带。第二个是铆钉干扰。车牌上下边缘的铆钉在二值图像上留下的凸起会粘连到相邻字符的投影区域造成误切。处理方法是在投影切割后用连通域高度做校验如果某个候选块的物理高度明显小于字符平均高度直接丢弃合并。也可以先做形态学开运算用小尺寸结构元素把细小凸起去掉。切割出的字符区域尺寸不一要统一缩放到固定大小。我用imresize把每个字符归一化到20×20像素。缩放的插值方式选择双线性插值比最近邻效果平滑也比双三次更快训练字符识别网络足够了。如果车牌本身有倾斜还需要先做水平矫正。可以用Hough变换检测车牌上下边缘的直线角度然后旋转。这一步不做的后果是字符投影会偏移后期识别率明显下降。倾斜角度在5度以内时Hough矫正的帮助最明显。3. BP神经网络的结构设计与训练细节3.1 输入特征和输出编码怎么定神经网络的输入是每个字符图块对应的特征向量。我以20×20灰度图像为例把图像矩阵按列展开成400维向量直接作为输入特征。为了让特征分布稳定需要先做归一化把像素值压到0到1区间。归一化这一步很重要。BP网络使用Sigmoid类激活函数时如果输入值过大加权求和后容易进入Sigmoid函数的饱和区梯度几乎为零训练会停滞。把输入控制到0到1区间能有效避免这个问题。如果觉得400维输入让网络规模偏大可以对图像做分块特征降维。比如把20×20图像分成4×4的子块统计每个子块的像素均值或白色像素占比得到16维特征。分块特征对微小位移不敏感鲁棒性会更好但信息丢失也多。从实验角度看400维原始像素特征配合足够样本量效果往往优于过分降维的特征。输出编码我用的是One-Hot形式。每个类别对应一个输出神经元该神经元的目标输出为1其余为0。代码实现时类别标签要转换成0/1矩阵。假设共31类字符每条样本的标签就是31维向量。为什么不建议用单个输出节点输出1到31这种数值编码因为类别之间没有天然的数值顺序关系单节点网络必须自己去学“字符A对应10、字符B对应11”这种任意映射分类面会变得很怪异。One-Hot让每个类有独立的输出维度语义清晰反向传播时误差分量也容易解释。字符集合的组成需要提前定好。常见车牌字符分为汉字、大写字母和数字三类。如果做完整识别汉字字符数量较多为降低实现难度可以先做“字母数字”共34类排除容易混淆的I和O后续扩到含汉字的大集合。输出层节点数跟着类别数走34类就是34个节点。方案字符范围类别数输出编码方式输出节点数简化识别数字大写字母34One-Hot34完整识别汉字数字大写字母约70One-Hot70实验极简0-9数字10One-Hot103.2 隐藏层节点数和激活函数选择BP网络我采用三层结构输入层、一个隐藏层、输出层。车牌字符分类任务复杂度有限一个隐藏层足够拟合。隐藏层再加一层训练时间变长还容易过拟合。隐藏层节点数的选择有经验公式h sqrt(m n) a其中m是输入节点数n是输出节点数a是1到10之间的调节常数。以输入400维、输出34类为例sqrt(40034)约等于21加上调节常数3初始隐藏层取24个节点。这个值只是起点实际训练后可以再做增减实验隐藏节点太少欠拟合太多过拟合且训练慢。激活函数的选择上我在隐藏层用tansig双曲正切Sigmoid输出层用logsig对数Sigmoid。隐藏层的tansig输出范围是-1到1具备正负双向激活对梯度传播更友善输出层的logsig把结果压到0到1之间接近概率输出方便判定最终类别。如果输出层换用purelin线性函数网络也能训练但输出可能会出现明显偏离0和1的值解释起来不如Sigmoid直观。3.3 数据集扩增与训练参数设置样本扩增是提升泛化能力性价比最高的手段。字符二值图像可以做小角度旋转±10度、上下左右平移1~2像素、缩放0.9~1.1倍、加高斯噪声、膨胀腐蚀。每张原始样本扩增出5~10张训练集规模马上翻几倍。扩增时注意尺度不要太大旋转超过15度之后字符形状已经不真实了反而会误导训练。训练数据按7:2:1划分训练集、验证集和测试集。验证集用来观察是否过拟合测试集最后统一评估准确率。划分时用randperm打乱顺序避免同类样本扎堆。训练参数我是这么设的net.trainFcn traingdx; net.trainParam.lr 0.05; net.trainParam.mc 0.9; net.trainParam.epochs 2000; net.trainParam.goal 1e-5; net.trainParam.max_fail 20;学习率取0.05配合动量项0.9。动量项相当于给梯度更新加了一个惯性能让损失下降更平滑抑制震荡。max_fail设为20表示验证集误差连续上升20次就停止训练这个早停机制能有效防止过拟合。训练目标误差goal设为1e-5实际中如果全部3000个epoch跑完还没到这个值只要验证集误差已经平稳也可以提前手动终止。训练函数我选traingdx即自适应学习率加动量梯度下降。数据量不大时trainlmLevenberg-Marquardt收敛更快但LM算法内存占用高在特征维度和样本量增加后容易卡顿而且更容易陷入局部极值。traingdx虽然慢一点但稳定性更好对入门实验来说更友好。4. Matlab关键代码解析4.1 主流程代码框架整个系统的主流程可以写成下面这个结构函数的职责边界非常清晰% 载入训练好的网络 load(plate_net.mat, net); % 读取待识别车辆图像 img imread(car.jpg); % 车牌定位 plateImg locatePlate(img); % 字符分割 charImgs segmentChars(plateImg); % 逐字符识别 for i 1:length(charImgs) feat extractFeature(charImgs{i}); out net(feat); [~, idx] max(out); fprintf(第%d个字符识别结果: %s\n, i, labelSet{idx}); end这里有几个实用细节。第一网络训练前要先准备labelSet它把输出节点索引映射到具体字符比如{‘0’,‘1’,...,‘A’,‘B’,...}。第二net(feat)要求输入是一列向量如果特征是行向量需要转置。第三识别时用max取输出向量最大值对应的索引就是最终类别。4.2 车牌定位函数核心定位函数的核心是颜色掩膜、边缘检测、形态学闭运算和连通域筛选的组合function plateImg locatePlate(img) hsv rgb2hsv(img); maskColor (hsv(:,:,1) 0.55 hsv(:,:,1) 0.75) ... (hsv(:,:,2) 0.30) ... (hsv(:,:,3) 0.30); gray rgb2gray(img); edgeImg edge(gray, sobel, vertical); mask maskColor edgeImg; mask imclose(mask, strel(rectangle, [15, 15])); mask imfill(mask, holes); stats regionprops(mask, BoundingBox, Area); maxArea 0; plateBox []; for i 1:length(stats) w stats(i).BoundingBox(3); h stats(i).BoundingBox(4); ratio w / h; areaRatio stats(i).Area / numel(mask); if ratio 2 ratio 5 areaRatio 0.01 stats(i).Area maxArea maxArea stats(i).Area; plateBox stats(i).BoundingBox; end end if ~isempty(plateBox) plateImg imcrop(img, plateBox); else error(未定位到车牌区域); end end颜色掩膜和边缘图求交集再用闭运算把字符间的缝隙连上这一步很关键。闭运算的结构元素尺寸不能太小否则字符区域连不成一个块也不能太大否则会把车身边的无关区域也并进来。实测15×15的矩形结构元素对常见拍摄距离比较稳。4.3 字符分割函数核心字符分割函数按垂直投影切割字符先做灰度拉伸和二值化function charImgs segmentChars(plateImg) gray rgb2gray(plateImg); gray imadjust(gray); bw imbinarize(gray); bw ~bw; % 白字在蓝底上二值化后字符为0取反让字符为1 % 去除上下边框干扰 bw(1:2, :) 0; bw(end-1:end, :) 0; proj sum(bw, 1); charCols proj 5; % 列阈值需要根据实际图像高度调整 % 找出连续区间 diffCols diff([0, charCols, 0]); startIdx find(diffCols 1); endIdx find(diffCols -1) - 1; charImgs {}; for i 1:length(startIdx) colRange startIdx(i):endIdx(i); if length(colRange) 5 continue; end charImgs{end1} imcrop(plateImg, [startIdx(i), 1, ... length(colRange), size(bw,1)]); end end灰度拉伸用imadjust后字符和背景的对比度会拉开。二值化后字符区域是0还是1取决于原始车牌颜色布局蓝底白字在gray图上表现为亮背景暗字符imbinarize后字符为0投影前取反才能让字符列和背景列有明显区分。这就是实操中容易忽略的反色问题。4.4 BP网络训练代码训练部分用feedforwardnet建立前馈网络并手动指定隐藏层激活函数% 构建特征矩阵 trainFeatures: 每列一个样本 % 构建标签矩阵 trainLabels: One-Hot 编码每列对应一个样本 hiddenSize 24; net feedforwardnet(hiddenSize); net.layers{1}.transferFcn tansig; net.layers{2}.transferFcn logsig; net.trainFcn traingdx; net.trainParam.lr 0.05; net.trainParam.mc 0.9; net.trainParam.epochs 2000; net.trainParam.goal 1e-5; net.trainParam.max_fail 20; net.divideFcn divideblock; [net, tr] train(net, trainFeatures, trainLabels); save(plate_net.mat, net);注意feedforwardnet返回的默认网络包含一个隐藏层和一个输出层layers{1}是隐藏层layers{2}是输出层。默认的divideFcn是dividerand随机划分用divideblock按块划分时要小心如果训练样本按类别顺序排列块划分会让验证集和测试集看不到部分类别。稳妥的做法是先把样本随机打乱再交给train函数自动划分。5. 实测中的常见故障与排查技巧5.1 训练不收敛或收敛后识别率低训练阶段最常见的问题是损失一直在高位震荡降不下去。排查顺序很固定先看输入特征归一化再看标签编码最后看网络参数。特征没有归一化到0到1是头号原因。像素值范围0到255直接进网络加权求和后很大Sigmoid饱和梯度消失训练自然停滞。我在调试时用min(feat(:))和max(feat(:))打印特征范围一眼就能判断。标签编码错误也很隐蔽。One-Hot矩阵的维度必须和输出节点数严格一致。如果类别数记错训练过程不报错但识别结果会全部错位。网络参数方面学习率过大表现为损失剧烈震荡过小表现为下降极慢。先用0.1跑10个epoch看趋势震荡就减到0.01下降太慢再升到0.05这是最快的调试套路。识别率低的另一个因素是样本不均衡。某个字符的样本量特别少网络就倾向于把该类判成样本量大的类别。解决方法是按类别统计样本数目标统一到每类500张左右少的用扩增补齐多的可以随机抽样。5.2 分割阶段的各种异常分割错误是识别错误的根源。投影法最怕字符粘连和边框残留。字符粘连多发生在拍摄模糊或二值化阈值不当的情况下解决办法是先把二值化阈值调高再把字符细化。我更常用的是对连通域做高度统计去掉明显过高的连通区域再进行投影切分这样能减少粘连带来的误切。边框残留的问题我在前面提到过用形态学腐蚀处理。实际操作中腐蚀的尺度很敏感腐蚀太少断不开边框腐蚀太多会把字符笔画也削弱。我的经验是先裁掉上下边缘大约3%高度的条带再做一次3×3的开运算对大多数拍摄端正的车牌都能处理干净。还有一类问题是候选块数量不对。标准车牌一般有7个字符但定位框稍微偏大边缘产生的噪点会被切成多余的候选块。所以在字符切割完成后加一层校验如果候选块数量大于7检查每块的宽度把宽度明显小于字符平均宽度60%的块剔除。5.3 工程化方向上的几点建议Matlab原型跑通之后如果要往实际场景推有几个方向值得考虑。第一网络训练好后要把参数持久化保存。save net.mat之后识别阶段只需要载入网络做前向计算训练代码和识别代码分离。第二批量测试脚本一定要写。遍历测试集文件夹对每张图跑完整流程统计端到端识别准确率和单字符识别率。没有量化评估就不知道改动预处理对系统到底有没有提升。第三换到Python平台时BP网络的结构和参数可以直接移植。Matlab里已经训练好的权重导出成矩阵Python端用NumPy做前向传播不依赖任何深度学习框架也能复现同样的结果。第四如果想提升整个系统的鲁棒性优先在定位模块投入时间而不是换个更大的神经网络。定位框抖动几像素字符分割就会出错识别率直接掉一截。把定位做稳比把网络做深收益更大。最后分享一个我在调试时反复踩过的坑Matlab的神经网络工具箱版本更迭后老一点的代码里newff的写法可能不再推荐但feedforwardnet和自带train函数的用法是稳定的。如果你的Matlab版本较旧newff还能用版本新一点就直接用feedforwardnet然后把transferFcn、trainFcn这些参数按上面代码配置清楚训练逻辑是完全一致的。