车牌识别这事儿听起来挺唬人其实拆开看就是一套“图像处理 神经网络”的组合拳。最近我把这个基于BP神经网络的车牌识别系统在Matlab里完整实现了一遍从车牌定位、字符分割到字符识别每一步都踩了不少坑。这篇东西就是给准备做课程设计、毕业设计或者单纯想搞懂“神经网络怎么落地到图像识别”的朋友一份实操记录。先交代一下最终效果整套系统在测试集上的字符识别准确率能做到95%左右整张车牌的识别率大概在90%上下处理一张离线图片的时间足够快不至于让人等得心焦。相比于传统的模板匹配BP神经网络对字符形变、模糊和噪声有更强的容忍能力这也是它在这个场景里真正的价值所在。如果你手头正好有一个车牌识别的需求或者想拿一个经典项目练手这篇文章可以帮你少走很多弯路。1. 项目背景与整体思路拆解1.1 为什么是BP神经网络而不是模板匹配很多人上来就问我车牌字符识别不就是一个“比对形状”的问题吗直接用模板匹配不行吗说实话行但仅限于那种“干净到不能再干净”的图片。模板匹配的核心思路是拿待识别字符和标准模板做相似度计算谁的相似度高就选谁。这个思路在印刷体、无倾斜、无噪声的情况下效果不错可一旦遇到光照不均、字符笔画断裂、车牌倾斜、图片模糊模板匹配的识别率就会肉眼可见地崩掉。因为它本质上是在“原样比对”没有抽象和泛化能力。BP神经网络不一样。它在训练阶段做的事情是不断调整网络里的权值参数让网络学到“什么样的笔画组合更像数字1”“什么样的一坨黑色像素更像汉字”。训练完成之后哪怕字符图片有一点倾斜、有一点残破网络依然能根据已经学到的特征给出一个合理的输出。用一句话概括模板匹配是“死记硬背”BP网络是“理解了规律再判断”。这就是我最终选择BP神经网络的根本原因——不追求花哨只追求在真实图像上能稳定工作。1.2 系统流程从一张图片到一串车牌号整套系统的流程其实非常经典基本是标准四步车牌定位在一整张汽车图片里找到车牌所在的矩形区域。图像预处理与校正对截取出来的车牌区域做灰度化、二值化、去噪、倾斜校正、去边框等操作。字符分割把车牌区域里的7个字符新能源车牌是8个字符这里先按传统蓝牌讨论逐个切出来。字符识别对每个字符图片做归一化和特征提取输入BP神经网络输出识别结果。很多人以为最难的是神经网络训练实际做下来你会发现前面三步才是真正决定识别上限的环节。神经网络能识别的字符前提是分割得干净、归一化做得规整。前面烂了后面再好的网络也救不回来。1.3 环境与工具箱选择实验环境用的是Matlab原因很直接图像处理工具箱和神经网络工具箱都很成熟矩阵操作也顺手非常适合做视觉类的原型验证。版本方面我使用的是较新的Matlab版本。如果你用的是老版本部分神经网络函数接口会稍有差异比如老版本习惯用newff新版本推荐feedforwardnet两者都能用只是后面的代码我会以新接口为主。图像处理部分的函数像imread、rgb2gray、im2bw推荐改用imbinarize这些跨版本差异不大放心用。2. 车牌定位与图像预处理决定识别上限的环节2.1 车牌定位的主流思路与选择定位车牌的思路大概分两类一类是基于边缘加形态学另一类是基于颜色特征。边缘加形态学的思路是车牌区域里字符和底色之间存在剧烈的灰度变化用边缘检测算子比如Sobel扫一遍就会在车牌位置产生密集的边缘响应然后通过形态学闭运算把这些边缘小块连成一个大的矩形连通域最后根据长宽比、面积等条件筛选出最像车牌的区域。颜色定位的思路则是利用车牌底色的颜色特征比如蓝牌的蓝色、黄牌的黄色转换到HSV颜色空间后做阈值分割再筛选连通域。这个思路在车牌颜色比较标准时很可靠但如果遇到车身颜色和车牌颜色接近或者强光下颜色偏色严重就容易翻车。我最终选择的做法是以边缘形态学为主用颜色特征做辅助验证。这样在大多数场景下都能稳定定位又不至于在特定颜色场景下完全失效。2.2 预处理流水线的实操细节车牌区域被截取出来后不要急着做分割先做一轮预处理。我的处理顺序是灰度化rgb2gray把RGB三通道压缩成单通道减少数据量。中值滤波medfilt2配合一个3×3的窗口去掉一些孤立噪点。这一步要注意窗口别开大了否则会把字符笔画边缘也抹掉。二值化用imbinarize做自适应二值化避免用固定阈值。固定阈值遇到光照不均就会出现“一半白一片、另一半黑一团”的尴尬情况。形态学处理这里分两步走。第一步先用开运算去掉一些细小的干扰线第二步根据车牌字符区域的连通性做必要的膨胀保证字符笔画连成一片。有一个细节容易被忽略二值化之后字符应该是白色像素值为1背景是黑色像素值为0这样后续做垂直投影才直观。如果你的图是反的可以用~bw取反。2.3 倾斜校正、去边框与铆钉干扰车牌在照片里不可能是绝对水平的总会有几度倾斜。倾斜如果不管字符分割投影就会糊成一团。我的校正方法是先用Hough变换检测车牌区域里最长的直线一般就是车牌上边缘或下边缘算出直线的倾斜角度然后对整个车牌图像做旋转校正。rotate函数在旋转后会产生黑色边角记得用裁剪或填充的方式处理掉。去边框和铆钉是另一个容易翻车的点。车牌外侧有一圈边框中间有铆钉这些都会干扰投影分割。常用的处理方式有两种用形态学腐蚀把细长的边框腐蚀掉对二值图像做行列投影把上下左右边缘处像素值分布稀疏的行列裁掉。实际操作中我更喜欢先做行列投影裁剪再配合一个宽度阈值过滤掉残留的边框段。边框和铆钉对分割的影响很大这一块值得多花时间调试。注意预处理的目标不是把图片变得“好看”而是让字符区域在二值图像中尽量干净、连续、背景纯净。我见过很多人花大力气做滤波美化结果字符笔画也被抹掉了反而更糟。3. 字符分割全项目最容易翻车的环节3.1 垂直投影法原理与实现字符分割最经典的方法是垂直投影法。原理很简单把二值化后的车牌图像按列统计白色像素的个数得到一个一维向量。在字符区域白色像素会比较多投影值会形成“峰”在字符与字符之间的间隙几乎没有白色像素投影值会掉到接近0形成“谷”。从第一个峰开始到下一个谷结束中间这一段就是一个字符的左右边界。这段话翻译成代码逻辑就是一个“找峰谷”的过程。先计算垂直投影然后遍历这个投影序列连续大于阈值的一段视为字符区域连续小于阈值的一段视为间隙。% 垂直投影统计每列白色像素个数 proj sum(bw, 1); % bw是二值化且去边框后的图像白色为1 threshold 0.05 * max(proj); inChar proj threshold; % 遍历找连续区域 startIdx []; endIdx []; flag false; for col 1:length(inChar) if inChar(col) ~flag startIdx(end1) col; %#okSAGROW flag true; elseif ~inChar(col) flag endIdx(end1) col - 1; %#okSAGROW flag false; end end if flag endIdx(end1) length(inChar); end3.2 分割异常的三种急救方案投影法虽然简单但真实图片里的情况总是不按套路出牌。我调试过程中遇到的最典型问题有三种。第一种一个字符被切成了两半。比如汉字“京”左右结构分得比较开中间有可能有一列刚好投影值特别低被误判为间隙。解决办法是设置一个最小宽度阈值比如一个字符的最小宽度不能小于车牌宽度的8%。如果切出来的区域宽度远小于正常字符宽度就把它和相邻区域合并。第二种多个字符黏连在一起。这种情况常见于二值化后字符笔画粘连或者车牌本身磨损导致字符边界不清。处理办法是检查切出来的区域数量正常情况下蓝牌是7个字符如果明显少于7个就往“黏连”方向排查尝试在投影值的局部最低点尝试切开。第三种多出了几个细小噪点区域。铆钉、边框残留都会形成一些宽度很小的假字符。处理起来最简单按区域宽度和面积做一个过滤宽度太小的直接扔掉。我在代码里加了一个判断逻辑先正常切分然后统计区域数量。如果数量不对再走合并和过滤的逻辑分支。这样比一次性写一个完美算法要稳得多毕竟真实世界的图片千奇百怪不如用先验知识去约束。3.3 字符归一化与特征统一分割完成后每个字符图片的大小、位置都不一样不能直接塞进神经网络。常见的做法是统一缩放到一个固定尺寸我用的尺寸是32×16高×宽。这个比例比较接近车牌字符的真实宽高比缩放之后不会让字符变形得太夸张。归一化要注意一点不要直接暴力拉伸否则汉字会被压扁数字会被拉长。更好的做法是先等比缩放到固定高度比如32像素宽度随之变化然后放到一个32×16的空白画布中央左右填充背景像素。function normImg normalizeChar(charImg, targetH, targetW) charImg imresize(charImg, [targetH, NaN]); [h, w] size(charImg); normImg zeros(targetH, targetW); if w targetW normImg imresize(charImg, [targetH, targetW]); else startCol floor((targetW - w) / 2) 1; normImg(:, startCol:startColw-1) charImg; end end归一化是训练和识别共享的一步。训练样本要归一化识别时也要走同样的流程否则特征分布不一致网络效果直接打折。4. BP神经网络的设计与训练4.1 输入特征像素直铺还是提取特征很多人第一次做BP网络做字符识别习惯直接把归一化后的32×16512个像素全部拉直当输入。这个做法理论上没问题但是有两点不足一是维度高网络参数多收敛慢二是相邻像素之间的相关性太强有很多冗余信息。我在实验里对比并最终采用的方案是提取混合特征包含网格密度特征和投影特征。网格密度特征把32×16的字符图像切成5×8共40个小格子统计每个格子中白色像素所占的比例得到40维特征。投影特征对整幅图像做水平投影和垂直投影各321648个统计值拼接在一起。这样总共88维特征比512维小得多训练速度明显提升而识别率几乎没有下降。如果你追求极致的轻量还可以继续降维但对车牌识别这个场景来说88维已经是一个很合适的平衡点。特征提取之前二值图像上字符的粗细也会影响投影值和密度值所以我在特征提取前还做了一步**细化骨架化**处理就是把字符的笔画收敛到一个像素宽。这样做的目的是减弱笔画粗细带来的干扰让网络更关注字符的“结构”而不是“胖瘦”。不过这一步也会带来一些毛刺实际使用时需要权衡。4.2 网络结构与关键参数配置网络结构我用的是单隐层BP网络这也是最经典、最不容易出问题的配置。输入层节点数是88对应特征维数输出层节点数是65对应“31个省份简称汉字 24个英文字母去掉I和O 10个数字”的总类别数隐藏层节点数我取的是50。为什么取50一个经验法则隐藏层节点数通常取输入维度的一半到一倍之间。太小了网络容量不够学不进去太大了容易过拟合训练还慢。50这个值在88维输入下表现不错你也可以试试40、60然后看验证集准确率来定不要盲目堆节点。训练算法我选的是traingdx自适应学习率加动量法。trainlmLM算法收敛速度非常快但在样本量不大的时候特别容易过拟合训练集准确率冲到100%测试集却惨不忍睹。traingdx收敛慢一些但是稳定泛化能力更好。对于车牌识别这种“离线训练、在线识别”的场景稳定压倒一切。关键训练参数如下参数取值说明隐藏层节点数50可调范围40~60学习率0.05初始值训练中自动调整动量因子0.9抑制震荡加速收敛目标误差1e-4达到即停止训练最大迭代次数2000防止训练时间过长激活函数隐藏层tansig双曲正切激活函数输出层purelin配合误差计算4.3 训练数据准备与数据增强训练数据是这次项目里耗时最长、也最影响最终效果的部分。样本的来源我主要分三块网上找的公开车牌图片、自己拍的照片、以及对手头少量样本做数据增强扩展出来的样本。样本库一开始很惨数字和字母的样本还好说每个类别凑了三四百张汉字样本少得可怜某些省份简称只有三五十张。这时候如果不做处理汉字识别率肯定拉胯。数据增强操作我做了四类小角度旋转在±15度范围内随机旋转模拟车牌倾斜平移上下左右随机平移1~3个像素模拟分割误差加高斯噪声模拟传感器噪声和污损轻微模糊用高斯核做卷积模拟失焦。经过增强后每个类别的样本量都扩充到了500以上汉字类别也做到了300左右。需要提醒的是数据增强不是无脑加不要把角度转得太过、噪声加得太狠否则生成的样本和真实场景差别太大反而拖累识别效果。4.4 训练过程监控与调参经验训练BP神经网络的时候不要干等一定要看训练误差曲线。我通常会在训练时同时打开误差变化曲线和验证集准确率曲线观察两个关键点训练误差有没有在持续下降如果前几十轮误差纹丝不动多半是学习率太小或者数据没做归一化特征值范围跨度过大。验证集准确率和训练集准确率是否同步如果训练集准确率一路飙升验证集却在某个位置开始掉头向下这就是过拟合的典型信号。此时正确做法是降低隐藏层节点数、增大样本量或者提前停止训练。我在调参过程中最深的体会是先固定训练数据再去调网络结构。很多人一边加样本一边改参数最后出了问题都不知道该怪谁。最好先把训练集定下来网络参数一轮一轮调每轮只改一个变量这样才有可对比性。5. Matlab核心代码实现与串联5.1 定位与预处理代码定位这部分我用的是经典的“边缘检测 形态学 连通域筛选”路线。核心代码如下function [plateImg, plateBox] locatePlate(img) if size(img, 3) 3 gray rgb2gray(img); else gray img; end % 去噪 gray medfilt2(gray, [3 3]); % 边缘检测 edgeImg edge(gray, sobel); % 形态学闭运算把字符边缘连成块 se strel(rectangle, [17 3]); closeImg imclose(edgeImg, se); closeImg imfill(closeImg, holes); % 连通域筛选 cc bwlabel(closeImg); stats regionprops(cc, BoundingBox, Area); plateBox []; maxScore 0; for k 1:length(stats) w stats(k).BoundingBox(3); h stats(k).BoundingBox(4); area stats(k).Area; ratio w / h; % 蓝牌长宽比大约在3~4.5之间 if ratio 2.2 ratio 5 h 20 area 1000 score ratio * area; if score maxScore maxScore score; plateBox stats(k).BoundingBox; end end end if ~isempty(plateBox) plateImg imcrop(img, plateBox); else plateImg []; end end这里的几个阈值长宽比范围、最小高度、最小面积是参考了常见蓝牌规格后设的经验值在实际使用中可以根据图片的拍摄距离微调。5.2 投影分割代码分割部分我已经在前面展示了找峰谷的代码这里补充一下基于先验知识的字符数量修正逻辑。传统蓝牌是7个字符所以我会在分割完成后做一个数量检查超过7个滤掉小区域少于7个尝试合并断开的字符。function chars segmentChars(bwPlate) % 去边框 bwPlate removeBorder(bwPlate); % 垂直投影切分 proj sum(bwPlate, 1); threshold 0.05 * max(proj); [~, regions] findRegions(proj threshold); % 按宽度阈值合并过窄区域 minW size(bwPlate, 2) * 0.06; idx 1; while idx length(regions) if regions(idx).w minW idx 1 regions(idx-1) mergeRegions(regions(idx-1), regions(idx)); regions(idx) []; else idx idx 1; end end % 提取每个字符图像 chars cell(length(regions), 1); for i 1:length(regions) chars{i} bwPlate(:, regions(i).c1:regions(i).c2); end endmergeRegions和findRegions是我自己封装的两个小函数逻辑很简单就是区域的合并与遍历。说实话这段代码在遇到极端倾斜的车牌时依然会失误所以我建议你在分割之前一定先做倾斜校正。5.3 BP训练与识别代码训练阶段的代码我用的是新版的feedforwardnet。脚本先把所有样本图片读取、提特征、组装成矩阵然后创建网络并训练% featTrain: 每行一个样本特征向量, nSamples x 88 % labelTrain: 每行一个one-hot向量, nSamples x 65 net feedforwardnet(50, traingdx); net.trainParam.lr 0.05; net.trainParam.mc 0.9; net.trainParam.goal 1e-4; net.trainParam.epochs 2000; net.layers{1}.transferFcn tansig; % 输入输出归一化 [net, tr] train(net, featTrain, labelTrain); % 保存模型 save(bp_plate_net.mat, net);识别阶段就轻松多了加载网络对每个字符提特征然后前向传播取最大输出对应的类别function result recognizeChar(charImg, net) feat extractFeature(charImg); % 1 x 88 特征向量 output sim(net, feat); % 65 x 1 [~, idx] max(output); result classLabels{idx}; % classLabels是类别名称cell数组 end这里有一个容易忽略的坑feedforwardnet默认会对训练数据做归一化但你在识别阶段直接sim的时候它会自动套用相同的归一化规则所以不存在需要手动归一化的问题。但如果你的特征值范围跨度过大训练样本和识别样本的分布不一致网络输出就会很奇怪。因此检查训练数据本身的分布一致性比纠结归一化方式更重要。5.4 主流程串联与结果输出主流程其实就是一个顺序调用的问题没有太多技巧但是有一个小的设计经验可以分享每一步都应该有失败处理不要假设输入图片一定会成功定位和分割。我的主流程是这样的img imread(test_car.jpg); % 1. 定位 plateImg locatePlate(img); if isempty(plateImg) error(未定位到车牌区域); end % 2. 预处理 倾斜校正 plateProc preprocessPlate(plateImg); % 3. 分割 chars segmentChars(plateProc); if length(chars) 7 warning(分割结果异常字符数为 %d, length(chars)); end % 4. 识别 resultStr ; for i 1:length(chars) normChar normalizeChar(chars{i}, 32, 16); resultStr [resultStr, recognizeChar(normChar, net)]; %#okAGROW end fprintf(识别结果: %s\n, resultStr);加这些判断不是为了好看而是调试时的救命稻草。定位失败就报出来分割异常就警告出来你能立刻知道问题出在哪一层不用瞎猜。6. 常见问题排查与避坑实录6.1 几个高频问题速查表我把整个调试过程中遇到的头疼问题整理成了一个表格方便对照排查。问题表现可能原因解决方案定位框圈到了车灯或车身装饰长宽比筛选条件太宽形态学闭运算把相邻区域连成一片收紧长宽比范围到2.5~4.5增大最小面积阈值或用颜色特征辅助验证二值化后字符断成碎块光照不均固定阈值失效改用自适应二值化imbinarize或用局部阈值分割出来7个以上的区域边框残留、铆钉、噪点被当成字符增加宽度和面积过滤优先去掉最细最窄的区域汉字识别率远低于数字字母汉字样本少、结构复杂、字符分割导致笔画缺失扩充汉字样本重点做汉字的数据增强分割时允许汉字区域宽度略大训练集准确率接近100%测试集却很低过拟合减少隐藏层节点增大训练样本量使用traingdx并开启验证集提前停止训练误差一直卡在高位不动特征未归一化或学习率过大导致震荡用mapminmax处理特征到[-1,1]区间把学习率降到0.01~0.05重新训练6.2 我从反复试错中总结的3条经验第一测试图片的选择一定要“坏”一点。刚开始测试时我用了几张非常标准的车牌图片识别率奇高觉得自己能毕业了。结果换成真实随手拍的照片后识别率瞬间掉了两成。后来我特意准备了一批倾斜、模糊、逆光的困难图片集合每次改动都拿这批图来验证。做视觉识别千万不要用“空调房里的样本”骗自己。第二分割环节要预留人工检查的调试接口。我一开始把预处理、分割和识别全串在一个函数里出问题时只能加断点一句一句看。后来改成每一步都输出中间图像定位结果、二值化结果、分割结果都分别可视化出来调试效率提升了不止一倍。在Matlab里这只是一个figure和imshow的事但价值非常大。第三样本库要按类别做均衡。中文车牌的特殊性在于数字字母类别多但每个类别样本容易收集汉字类别少但每个类别的样本往往不够。训练出来之后汉字类别识别率普遍偏低。这个问题的根子就在数据不均衡上靠调网络参数救不回来只能老老实实地扩充汉字样本和数据增强。7. 测试效果与扩展思考最后说一点主观感受。整套系统跑下来BP神经网络的反向传播机制在理论上足够成熟实现起来并没有太多让人意外的地方。真正需要下功夫的反而是它前面的图像预处理和字符分割——这部分决定了神经网络能“吃到”什么样的数据也就决定了它能发挥出什么样的水平。在测试集上的成绩单字符识别率大约95%完整车牌识别率约90%。这个成绩对于蓝牌的标准场景来说已经够用但遇到新能源绿牌或者模糊不清的图片还有明显提升空间。如果这个项目要继续往深了做我觉得可以有两个方向一个是把输入特征换成更丰富的纹理描述比如方向梯度直方图HOG另一个是干脆把BP网络换成卷积神经网络CNN让网络自动学特征省去手工提特征的麻烦效果通常也会更好。如果让我重新做一遍我会把更多时间放在样本库建设上。算法和网络结构都是成熟的把每个字符类别做到足够多的样本、覆盖足够多的真实场景识别率自然就上去了。这算是这次项目我最想提醒后来人的一点。