1. 项目背景与方案取舍:为什么BP神经网络仍能胜任车牌识别
先说个有意思的现象。这几年深度学习火得一塌糊涂,做车牌识别的人一开口就是CNN、YOLO、PaddleOCR,好像不用神经网络检测个车牌就落伍了。但真正落到工业项目、嵌入式设备、老旧生产线改造这类场景时,**基于BP神经网络的车牌识别系统(Matlab代码实现)**这种方案反而能活得很好,甚至比堆算力的方案更实用。
我最近完整梳理了一套车牌识别流程,从图像输入到字符输出,整条链路用Matlab全部实现,核心分类器就是BP神经网络,整个过程走下来踩了不少坑,也积累了不少经验,这篇就当一次系统的复盘。
先说清楚,为什么这种看起来“过时”的方案在车牌识别领域依然能打。
1.1 车牌字符识别的任务特性决定了BP网络依然适用
车牌识别的本质是一个小规模、低自由度、高规范的字符分类问题。先看字符集:常见蓝底白字车牌的字符由省份汉字简称(31个左右)、24个英文字母(I和O在正式号牌中不出现,但识别系统一般还是会纳入)、10个数字组成,满打满算也就65类左右;如果按位置拆分,首位只需要识别汉字,后面几位只识别字母和数字,每个子任务的类别数就更少了,通常30多类。类别少意味着输出维度低、网络容量需求小,BP网络天生就能驾驭。
再看字符形态:车牌字符是印刷体,字体由国家强制标准统一,笔画结构、宽高比例、字符间距都有严格规定。这和手写识别、自然场景文字识别完全不是一回事——字符形态的方差小得多。BP神经网络本质上是一个带隐层的非线性分类器,只要特征表达合理、样本覆盖到位,它完全能把印刷体字符的类别边界学出来。
还有一个容易被忽视的因素:车牌识别在传统流程里已经被拆成了定位、分割、识别三个独立环节。分类器只需要处理单个字符,输入是已经归一化好的小图,不需要像目标检测那样在一整张图里搜索目标。任务被降维之后,BP网络的能力边界足够了。
1.2 与深度学习方案的客观对比
我见过很多团队一上来就上Faster R-CNN,最后要么标注样本不够,要么训练时间太长,要么部署环境跑不动,又回头做传统方案。不是说深度学习方案不行,而是要结合任务复杂度看投入产出比。
| 对比维度 | BP神经网络方案 | 深度学习检测/识别方案 |
|---|---|---|
| 样本需求量 | 每类字符几十到几百个即可 | 端到端方案通常需要上万张标注图 |
| 训练资源 | 普通CPU几分钟到十几分钟 | 需要GPU,训练时间以小时计 |
| 模型体积 | 一个.mat文件几KB到几十KB | 模型动辄几十MB甚至上百MB |
| 推理速度 | 毫秒级前向计算 | 单帧推理也快,但部署链路重 |
| 可解释性 | 每个环节可单独调试,错误可定位 | 端到端黑盒,错了难查根因 |
| 对恶劣环境的鲁棒性 | 对模糊、强光、大角度倾斜鲁棒性较弱 | 泛化能力更强,能端到端学习 |
| 维护成本 | 低,流程透明 | 依赖框架环境、版本兼容性问题多 |
我的结论很明确:如果你的车牌识别场景是出入口道闸、固定角度相机、光照相对稳定,BP网络方案在性价比上完全能打。如果场景是移动执法车、全天候恶劣环境、多相机大角度抓拍,那确实得乖乖上深度模型。方案选型先看任务复杂度,再看硬件约束,最后才看技术潮流。
1.3 我为什么坚持用Matlab做整套流程
很多人问,车牌识别都上生产了,怎么还用Matlab这种偏研究的工具?说句公道话,Matlab在算法验证和快速原型阶段效率极高,尤其是图像处理和神经网络工具箱搭配起来,代码量比Python少一大截。特别是做BP网络训练,newff/feedforwardnet一条命令就能建网,train一条命令训练,可视化训练误差、混淆矩阵都是现成的,这对验证算法思路来说太友好了。
另外,Matlab在高校、研究所和老牌制造企业的存量用户非常多,很多工程师的第一块深度学习敲门砖就是Matlab神经网络工具箱。直接把整套系统在Matlab里跑通、验证、调参,后面真要移植到C++或者嵌入式,思路也是完全一致的。所以这篇分享的代码和流程,我按Matlab的语法来写,但核心逻辑任何一个语言都能复现。
2. 车牌定位:颜色分割与形态学处理的关键细节
车牌定位是整个系统的第一个环节,也往往是新手最容易翻车的地方。很多人直接拿灰度图找边缘,或者直接对整张图做轮廓检测,结果定位框歪七八扭,后续分割识别跟着崩。我采用的思路是先颜色后几何,这是工程上最稳的组合。
2.1 为什么选择HSV颜色空间而非RGB
在Matlab里读入的图像默认是RGB三通道。理论上直接判断“蓝色像素”也能做,但实际拍摄中光照变化对RGB的打击是毁灭性的——同一个蓝色的车牌,在顺光、逆光、树荫、夜间路灯下,R/G/B三个通道的值会大幅漂移,单纯按固定RGB阈值分割出来的区域要么碎成渣,要么把深色车身大片划进来。
HSV空间把颜色分解为色调(Hue)、饱和度(Saturation)、明度(Value),其中H分量表征“是什么颜色”,受光照影响相对小。蓝色车牌的H值大约在0.55到0.65(归一化到0~1)之间,即角度制95到120度左右,亮暗变化主要影响V分量,对H影响有限。所以实际项目中我从不看RGB,一律转HSV再分割。
% 读取图像 img = imread('car_plate.jpg'); % RGB 转 HSV hsvImg = rgb2hsv(img); H = hsvImg(:,:,1); S = hsvImg(:,:,2); V = hsvImg(:,:,3); % 蓝色掩膜:H在蓝色范围,S不能太低(排除偏灰),V不能太低(排除过暗) blueMask = (H >= 0.55) & (H <= 0.65) & (S >= 0.15) & (V >= 0.15);这里有几个调参心得值得说。
- H的上下限一定要保留余量。不同相机的白平衡算法差异很大,有的相机拍出来的蓝色偏紫(H略大),有的偏青(H略小),把范围卡死在0.57~0.62很容易丢失边缘像素。
- S下限不能设太高。在阴天或黄昏,车牌的蓝色会发灰,S值掉得厉害,设0.3以上会把大量真实车牌像素滤掉;但也不能设太低,否则灰色路面、浅色车身会混进来。
- V下限建议设在0.1到0.15。夜间场景车牌区域即使亮灯也不是特别亮,设太高会漏检。但V太低时整张图都几乎全黑,此时应优先做光照增强而不是抠阈值。
如果拿到的图像色偏实在太严重,可以先做白平衡再做HSV分割。Matlab里简单一点可以用imhist看看H分量的分布,找出峰值位置再微调阈值,不要凭感觉写死。
2.2 形态学闭运算把散碎字符区域连通成车牌面
颜色分割出来之后,你会看到掩膜图里字符和字符之间的笔画是断开的,车牌边框也可能断成一截一截。如果直接拿这个掩膜去筛选连通域,会有大量碎块候选,每个碎块都像字符区域,完全没法定位。
这时用形态学闭运算:先膨胀让邻近的白色区域连成一片,再腐蚀把膨胀带来的边角缩回来。对车牌这种结构来说,闭运算的效果就是把字符笔画之间、字符与边框之间的缝隙填上,最终形成一个紧凑的连通块。
% 形态学闭运算,结构元素选圆形,半径根据图像尺寸调整 se = strel('disk', 15); closeMask = imclose(blueMask, se); % 去掉面积过小的噪点 closeMask = bwareaopen(closeMask, 500);结构元素的半径需要结合图像分辨率动态调整。处理1920x1080的图时,15像素半径比较合适;如果图像缩小到640x480,半径可以降到8~10。我建议写代码时用min(width, height)的比例来计算结构元素半径,比如radius = round(min(size(img,1), size(img,2)) / 64),而不是写死一个常量。
闭运算做完之后,再用regionprops函数提取所有连通域的属性,按车牌的几何先验筛选。
2.3 连通域筛选:几何先验比什么都重要
标准蓝牌照的尺寸是440mm×140mm,宽高比接近3.14:1。实际拍摄时因为相机角度略有透视,宽高比大概在2.5到4之间波动。除了宽高比,还要看候选区域的面积占比、旋转角度、填充率。
stats = regionprops(closeMask, 'BoundingBox', 'Area', 'Orientation', 'Solidity'); plateBox = []; for i = 1:length(stats) w = stats(i).BoundingBox(3); h = stats(i).BoundingBox(4); area = stats(i).Area; % 宽高比 2.5~4.2,排除细长噪点 if w/h < 2.5 || w/h > 4.2 continue; end % 面积占比:过小的候选直接排除,过大的多半是整块蓝色物体 imgArea = size(closeMask,1) * size(closeMask,2); if area/imgArea < 0.005 || area/imgArea > 0.15 continue; end % 填充率,车牌区域应该比较“实” if stats(i).Solidity < 0.4 continue; end plateBox = stats(i).BoundingBox; break; end特别注意一点:solidity(填充率)是区分车牌和车身贴纸、蓝色垃圾桶、蓝色告示牌的关键指标。车牌内部有字符镂空,填充率通常在0.5到0.8之间;如果是实心的蓝色广告牌,填充率接近1。但填充率太高不一定好,因为字符笔画造成镂空是正常情况,这也正是车牌的特征。
实际项目里我见过把车标、蓝色车灯区域误判成车牌的情况。这类干扰和车牌区域大小接近,单靠连通域几何特征很难滤掉。保守做法是:定位之后再对候选区域做一步字符存在性校验,即在候选区域内再做一次二值化和投影分析,如果投影后能切出5个以上宽度合理的字符区间,才认定是真正的车牌。这一步虽然多点计算量,但能极大降低后续的空识别率。
3. 字符分割:垂直投影法及其防切碎处理
如果把车牌识别系统当作一条流水线,字符分割就是最脆弱的环节。分割错了,BP神经网络训练得再好也白搭。我做过的项目里,端到端识别失败的原因,至少有一半出在分割而不是识别。所以这一节我会把分割的坑一个个列清楚。
3.1 字符分割的标准流程
从定位框抠出车牌区域后,先做灰度化、直方图均衡化增强对比度,再用中值滤波去除颗粒噪声,最后二值化。二值化千万别用固定阈值——不同图像的光照差异会让固定阈值要么把笔画融成一团,要么把底色全变成前景。Matlab里直接用graythresh计算Otsu自适应阈值:
% 提取定位框内的车牌区域 plateImg = imcrop(img, plateBox); % 灰度化 + 直方图均衡化 grayPlate = rgb2gray(plateImg); enhancedPlate = histeq(grayPlate); % 中值滤波去噪 enhancedPlate = medfilt2(enhancedPlate, [3 3]); % Otsu 自适应二值化(白字黑底注意取反) level = graythresh(enhancedPlate); bwPlate = imbinarize(enhancedPlate, level); if mean(bwPlate(:)) > 0.5 bwPlate = ~bwPlate; end二值化之后,需要去除车牌的外边框和铆钉。边框在垂直投影上会表现为左右两条很宽的连续列,铆钉则表现为两个扁圆的小噪块。我的处理顺序是:先用形态学开运算把细小的铆钉去掉,然后逐行逐列扫描,裁掉连续为白色的边缘行/列,再对上下边界各收缩几个像素,确保边框残余不进入字符区域。
% 去除小面积噪点(铆钉) bwPlate = bwareaopen(bwPlate, round(size(bwPlate,1) * size(bwPlate,2) * 0.001)); % 找第一个和最后一个非全零行/列,裁剪边缘 [rows, cols] = find(bwPlate); top = min(rows); bottom = max(rows); left = min(cols); right = max(cols); bwPlate = bwPlate(top:bottom, left:right); % 再收缩一圈,防边框残留 shrink = 2; bwPlate = bwPlate(1+shrink:end-shrink, 1+shrink:end-shrink);3.2 垂直投影法的核心实现与标准字符数量校验
接下来的关键步骤是垂直投影。把二值图按列统计前景像素个数,得到一个向量,字符之间的间隔投影值接近0,字符本身的列投影值明显大于0。根据投影的连续区间就能确定每个字符的左右边界。
% 垂直投影 verticalProj = sum(bwPlate, 1); % 阈值判定,存在字符的列 charCols = verticalProj > 1; % 找出连续区间的起止位置 d = diff([0, charCols, 0]); startIdx = find(d == 1); endIdx = find(d == -1) - 1;但直接按投影区间切分,在真实车牌图像上会踩好几个坑。
先看行不行得通的校验逻辑:常见蓝牌有7个字符(新能源绿牌8个,警牌、使馆牌等特殊牌照另说),切分后区间数量应该落在7~8这个范围。如果区间数量小于7,说明有字符被粘连或者有字符丢失;如果大于7,说明有字符被切碎,或者把铆钉、噪点当成了字符。这个数量校验要在代码里显式写出来,作为后续处理的分支判断条件。
3.3 汉字被切碎的根因与区间合并策略
垂直投影切碎汉字是新手最容易遇到、也最难以理解的问题。以“皖”字为例,它是典型的左右结构,左边“白”和右边“完”之间有一道明显的竖向空隙。在垂直投影图上,这道空隙会让投影值骤降,甚至降到接近0,于是“皖”字就被硬生生切成了左右两个区间。
处理思路有两个方向。第一个是形态学膨胀优先:在投影之前先对二值图做一次轻微的水平方向膨胀,把汉字左右结构之间的缝隙填上,让内部结构连通,再做投影。第二个是宽度合并策略:统计所有切分区间宽度的中位数,把宽度明显小于中位数50%的相邻区间合并成一个字符——左右结构的汉字切碎后,碎片的宽度大约只有正常字符宽度的一半甚至更少。
我实际用的是第二种,更稳。原因在于水平膨胀很容易把字符和字符之间的间隔也糊上,一旦字间间隔被填死,两个字符粘连起来,后续就再也没有挽回余地。宽度合并虽然也会遇到边界情况,但只要用中位数作为参考,鲁棒性还算不错。
% 根据投影区间计算各段宽度 segWidths = endIdx - startIdx + 1; medWidth = median(segWidths);合并时要注意一个细节:合并方向应该从右向左还是从左向右,取决于切碎区间的位置。如果第一个区间(最左侧汉字)宽度过小,把它与右侧相邻区间合并;如果最后一个区间(最右侧)过小,与左侧相邻区间合并;中间某个位置过小,则优先和右侧合并。建议用循环迭代的方式,每次合并后重新计算宽度,直到所有区间宽度都大于阈值。
这里要特别提醒:字符“1”和“I”的宽度天生就窄,它的宽度可能只有中位数的一半左右。直方图统计时,这类宽度正常的窄字符会被当作切碎的碎片。所以单纯的宽度合并策略会误伤“1”这类字符。我的解决办法是预留一个窄字符白名单——当合并后的字符总数正好等于7(车牌字符总数)时停止合并,不要再继续把窄字符往里并。
3.4 分隔符圆点与边框干扰的剔除
蓝牌第2个字符后面有个圆形分隔符“·”,在投影图上表现为一个宽度只有1-3个像素的小凸起。如果不管它,切分出来的区间数量会比正常多一个,后面的字符对齐就全乱了。
判断方法:如果某个投影区间的宽度小于正常字符宽度的30%,并且该区间在整行的前1/3区域(分隔符只在第2和第3字符之间),直接丢弃。
还有一种容易忽视的情况:车牌两侧的边缘(底边)在裁剪不干净时,会在投影图左右两端形成很高的“高原”,导致第一个字符和最后一个字符的边界被推向外侧,造成字符图像里混入大量边框像素。这个问题的处理需要在投影前完成——把裁剪后的图像再做一次边缘腐蚀,或者把疑似边框的高密度行/列强行切除。
4. BP神经网络的结构设计与训练细节
分割完成之后,每张字符小图就准备好了。接下来进入核心环节:怎么设计一个靠谱的BP网络,并把它训练到可用的识别精度。这个环节里面的坑更多,而且很多坑是Matlab神经网络工具箱特有的,网上资料很少说。
4.1 输入特征:统一尺寸与归一化的正确姿势
字符图像分割出来的大小各不相同,必须统一尺寸后才能喂给网络。我的做法是:先把所有字符等比缩放到固定高度(比如20像素),宽度按比例缩放,不足固定宽度(比如10像素)的部分在左右两侧补零;如果缩放后宽度超过固定宽度,再整体二次缩放,保证最终尺寸统一为20×10。
为什么不直接imresize成20×10?因为imresize是等比拉伸的,字符的宽高比会被强行改变。比如“A”本身是窄高字形,拉伸后可能变成矮胖字形,D和O这类弧度较大的字符就更难区分了。字符识别里,保持宽高比是避免形状畸变的底线。
缩放完成后,把二维矩阵拉成一维向量,并除以255归一化到0~1区间。如果你的二值字符图是处理的0/1逻辑值,那直接转double即可,不需要除以255,我这里写的是灰度图的处理方式。归一化这一步看似琐碎,但对BP网络的收敛速度影响非常大。输入值全在0~1范围内,sigmoid类激活函数的输入不会饱和,梯度更新更有效。
function charVec = prepareCharImg(bwChar, H, W) % bwChar : 二值字符图 % H, W : 目标高度、宽度 [h, w] = size(bwChar); scale = H / h; newW = round(w * scale); resizedChar = imresize(bwChar, [H, newW]); if newW > W resizedChar = imresize(resizedChar, [H, W]); else % 补零到固定宽度 charStd = zeros(H, W); leftPad = floor((W - newW) / 2); charStd(:, leftPad+1:leftPad+newW) = resizedChar; resizedChar = charStd; end charVec = double(resizedChar(:)) / 255; end4.2 网络结构:单隐层还是双隐层,神经元数量怎么定
Matlab里构造BP网络很简单,但参数设置背后的道理要说清楚。
对于30多类的字符识别任务,单隐层是完全够用的。BP网络的万能逼近定理告诉我们,单隐层前馈网络只要神经元数量足够,可以逼近任意连续函数。很多人的误解是“隐层越多网络越强”,但在样本量只有几千的小型任务里,双隐层不仅训练更慢,还更容易过拟合。
神经元数量的经验公式可以参考:hidden = ceil(sqrt(inputDim * outputDim)),也就是输入维度与输出维度乘积开根号。输入维度是200(20×10),输出维度是34(10数字+24字母),计算下来约82个,我个人习惯在这个基数上下浮动20%再通过实验确定。太小了欠拟合,太大必然过拟合。
% 输入维度 inputDim = H * W; % 20*10 = 200 % 输出类别数:数字10 + 字母24 = 34 outputDim = 34; % 隐层神经元数量 hiddenSize = ceil(sqrt(inputDim * outputDim)); % 约82 % 创建 BP 网络,注意 R2010a 之后 newff 仍可用,但推荐 feedforwardnet net = feedforwardnet(hiddenSize, 'trainscg'); net.layers{1}.transferFcn = 'tansig'; net.layers{2}.transferFcn = 'logsig';这里有个很多人忽略的关键点:网络输入维度不是随便定的,它和字符归一化尺寸强相关。如果你把字符归一化成32×16,输入维度变成512,隐层神经元也得相应增大,训练时间成倍增加,但准确率不一定提升——因为二值字符的信息量就那么多,2048维的输入并不会比512维的输入多出更多有用信息。我做过对比实验,20×10和24×12的最终准确率差距不到0.5%,训练时间却差了近一倍。
4.3 输出层设计:one-hot编码与最大激活值判定
输出层用logsig激活函数,输出值在0~1之间,每个神经元对应一个字符类别,目标向量是one-hot编码。训练时目标矩阵就是34×N,每个样本对应位置为1,其余为0。
识别时注意:不要每个输出神经元单独设阈值(比如大于0.5就判为该类),直接取输出向量中最大值对应的下标作为识别结果。原因很简单,当字符特征模糊时,正确类别的激活值可能只有0.3,但其他类别更低,0.3已经是最大值了。单独阈值会导致大量本可正确识别的样本被拒识。
% 识别单个字符 outputVec = sim(net, charVec); [~, predictIdx] = max(outputVec);样本的标签组织也要提前规划好。我的做法是把字符类别放一个cell数组里,下标直接对应输出神经元编号,这样训练样本和识别结果的映射关系一目了然。
4.4 训练数据准备与增强:同样本不同成败
训练样本我从两张渠道来:一是直接用手动分割好的真实车牌字符图,再把字符切割精度不足的半成品样本也加进去;二是对已有样本做数据增强,扩充样本多样性。
每类字符建议至少收集150~300个真实样本,再用增强手段翻倍。增强操作我控制在以下范围内:
- 水平/垂直方向随机平移1~2像素
- 随机缩放0.9~1.1倍
- 随机旋转-3度到+3度
- 随机膨胀或腐蚀一次,模拟笔画粗细变化
- 添加少量椒盐噪声
这几种增强手段很朴素,但效果非常好。尤其是随机平移,它让网络对不同字符位置的容忍度大幅提升,分割边界稍微偏一点也能识别正确。相比之下,旋转超过5度的增强反而会让印刷体字符变得不真实,还可能把“0”变成“O”、把“B”变成“8”,所以我限定在±3度以内。
% 训练集构建示意 trainData = []; trainLabels = []; for i = 1:size(realSamples, 1) % realSamples 是 N*1 的 cell,每项是字符图像 for augIdx = 1:10 augChar = augmentChar(realSamples{i}); trainData = [trainData, prepareCharImg(augChar, H, W)]; trainLabels = [trainLabels, idxVector(:, i)]; end end4.5 训练参数与可复现性:一个容易翻车的隐藏坑
Matlab里训练BP网络最坑的一点是初始化随机性。两个完全相同的训练代码,连续运行两次,最终准确率可能差1到2个百分点,原因是网络权重随机初始化和数据划分都是随机的。在一个需要稳定复现的项目里,这是绝对不可接受的。
解决方案是固定随机种子,并且在数据划分时使用固定索引:
% 固定随机种子,保证结果可复现 rng(0); % 切分数据:70% 训练,15% 验证,15% 测试,使用固定索引 trainRatio = 0.7; valRatio = 0.15; testRatio = 0.15; net.divideFcn = 'divideind'; net.divideParam.trainInd = 1:round(size(trainData,2)*trainRatio); net.divideParam.valInd = round(size(trainData,2)*trainRatio)+1:round(size(trainData,2)*(trainRatio+valRatio)); net.divideParam.testInd = round(size(trainData,2)*(trainRatio+valRatio))+1:size(trainData,2);训练算法我首选trainscg(缩放共轭梯度法)。原因有两条:第一,它不需要存储近似的Hessian矩阵,内存占用比trainlm小得多,训练几千个样本不费力;第二,它对学习率的敏感性低,不容易出现震荡。trainlm(Levenberg-Marquardt)在数据量小的时候确实收敛更快,但数据量一大内存就吃紧,而且容易陷入局部最优还不自知。
训练目标误差设0.01,最大训练轮数1000,这是经验值。实测大部分场景几百轮内就能收敛到目标误差,超过800轮还在震荡的话,问题多半在样本质量或网络结构,而不是训练轮数不够。
net.trainParam.epochs = 1000; net.trainParam.goal = 0.01; net.trainParam.showWindow = false; net = train(net, trainData, trainLabels);训练完成后要留意Matlab训练日志里的验证集误差曲线。如果训练集误差一路降到接近0,但验证集误差先降后升,说明过拟合了。这时优先减隐层神经元数量,或者增加训练样本,不要急着加隐层深度。
5. 识别结果分析:典型错分样本与针对性调优
网络训练好之后,真正的考试才开始。我把自己积累的实测数据和典型错误摊开,这些才是课本上不会写的东西。
5.1 用真实场景验证集检验端到端效果
测试集不要用训练时用的同源数据。我习惯单独收集一批现场实拍图,刻意覆盖几种恶劣条件:傍晚逆光、夜间闪光灯直射、车头部分遮挡、车牌表面泥点、高速运动模糊等。只有这些场景下的结果才是系统真实水平。
实测结果显示,单字符识别率(纯BP分类器)能做到99%以上,但端到端完整识别率通常只有96%~98%。差距主要来自分割环节,这也印证了我前面的判断——整条流水线的短板在分割,不在分类器。
5.2 典型错分样本清单与处理策略
我把反复出现的错误归纳为下面几类,每类都附上根因和针对性解法。
| 错误模式 | 典型场景 | 根因分析 | 处理策略 |
|---|---|---|---|
| B 误判为 8 | 字符笔画较粗或断裂 | 二值化后B的开口被噪声堵住,特征与8接近 | 调整二值化阈值,保留笔画间隙;适度腐蚀别过度 |
| 0 与 O、D 混淆 | 低分辨率、字符下采样后细节丢失 | 0和O区别仅在于中间是否镂空,尺寸太小时下采样抹平差异 | 提高字符归一化高度到24;保证分割时宽高比不变形 |
| 汉字左右结构错分为一个字 | “皖”“冀”等笔画密集汉字 | 垂直投影在字中间出现极窄低谷,被当成字符间隔 | 宽度合并策略兜底;训练时单独增加密集汉字样本 |
| “I”与“1”混 | 宽度相近且中心笔画极细 | 下采样后I的顶部底横消失,变成一条竖线 | 增加I的样本增强,尤其是带上下横的形态 |
| 首字符被误识别为字符类型 | 车牌边框残余干扰 | 裁剪边缘不干净,边框像素混入第一字符 | 裁剪后开运算去边;对首字符单独做形态学净化 |
这里拿“B误判为8”展开讲讲。B和8的特征差距主要在左上部分:B的左上角是敞开的,8的左上角是封闭的。如果二值化时B的笔画之间因为阈值太低而粘在一起,B的左上角就被“焊死”了,特征瞬间变成8。解决办法不是增加训练样本——样本再多,喂给网络的特征本身就是错的——而是先修正二值化参数。我用过的最有效手段是:二值化后统计字符区域的前景像素密度,如果密度低于正常值说明阈值偏低,笔画丢失;如果密度过高且连通域数量减少,说明阈值偏高,笔画粘连,然后反向调整Otsu结果。
对于0和O的混淆,有一个工程技巧:车牌字体里数字0比字母O略窄,如果分割后的字符宽高比明显超过正常范围,优先判为O。把这个先验知识写进后处理逻辑,比让网络硬学更有用。
5.3 后处理规则:用业务逻辑兜底
BP网络的输出只是一个概率分布,实际工程里还需要用业务规则兜底。车牌字符位置有严格规范:首位必须是省份汉字,第二位是发牌机关代号字母,之后是字母数字混合。利用这些规则做合法校验:
- 首位结果如果不是已知的省份汉字集合,直接标记为低置信度,触发二次识别
- 第二位如果不是字母,判定为识别错误
- 后期如果某一位字符的低置信度只有0.4,且候选集里有多个相似候选,采用约束回填
这套后处理逻辑不需要改动网络结构,纯粹是在推理阶段加规则,却能再拉高1到2个百分点。记住一点:在传统图像处理流程里,后处理规则和分类器同等重要。BP网络负责“猜”,规则负责“不敢乱猜”。
6. 部署与扩展:从Demo到可实跑识别模块的最后一公里
如果项目只停留在跑通Demo,那这篇分享写到这里就够了。但真正的工程问题都在部署阶段。我把自己踩过的几个非算法坑也拿出来说。
6.1 倾斜校正:不做校正,前面全白干
相机虽然大多是固定角度,但车牌在画面中依然可能倾斜几度。倾斜角度超过5度时,字符投影的分割精度就会明显恶化——字符上下边缘不齐,垂直投影出现渐变,切分边界就会歪。
最实用的校正方法是Radon变换。先把车牌区域的二值图像做边缘检测,再对二值图做Radon变换,找出响应最强的角度,按该角度做旋转校正。这段逻辑代码不复杂:
% 边缘检测 edgeImg = edge(bwPlate, 'canny'); % Radon变换求角度 theta = 0:179; [R, rho] = radon(edgeImg, theta); [maxVal, idx] = max(R(:)); [~, angleIdx] = ind2sub(size(R), idx); rotateAngle = theta(angleIdx) - 90; % 校正旋转 correctedPlate = imrotate(bwPlate, rotateAngle, 'bilinear', 'crop');实测下来,校正与否对端到端识别率的影响在3到5个百分点。如果你不校正,那前面BP网络训练得再好也只是在错误的输入上强撑。
6.2 视频流场景:单帧识别不如连续帧投票
道闸场景里,相机输出的是视频流,不是单张图片。很多人在单张图片上测试准确率极高,一上视频就频繁闪识别结果,原因不是算法崩了,而是单帧噪声无解。
我在实际项目里的处理策略是:连续取5帧,每帧独立走完整识别流程,对结果做逐字符投票,输出最高票结果。这个策略把帧间差异抹平,效果比任何模型调优都明显。要注意的是投票窗口不能太长——车是运动的,帧差过大时同一个车牌可能已经移动出画面,反而引入错误。
6.3 模型导出与跨环境一致性
Matlab训练好的模型在Matlab环境里用没问题,但如果部署到现场服务器,机器上没有Matlab就麻烦了。两种常见方案:一是把训练好的权重矩阵(输入层到隐层、隐层到输出层)导出成原始数组,用C++或者Python重新实现一遍前向计算;二是用Matlab Compiler打包成独立可执行程序,免安装运行。
我个人强烈推荐第一种。BP网络的前向计算就是两个矩阵乘加,再用激活函数处理一遍,代码量不到50行。把Matlab当训练工具、把权重导出做推理,既绕开了License问题,也避开了Matlab运行时环境的兼容性坑。
6.4 数据集记录与错误回放
最后说一个容易被忽略但是性价比极高的小事:给识别系统加识别日志。每帧识别结果、置信度、处理耗时、原始图像路径,全部记录到一张表里。出问题的时候直接查日志拉原始图,定位是分割问题还是识别问题,几分钟就能判断。这套系统做了一年之后,我发现绝大多数错误集中在特定光照角度下,照片一翻就能找出共性规律。做算法项目,数据洞察比调参更值钱。
这套基于BP神经网络的车牌识别系统,从HSV定位到投影分割再到网络训练,每一步单独拆开看都不算难,但连起来能稳定跑通的细节非常多。我最后的体会是,这类项目的难点从来不在“用不用深度学习”,而在于你对数据的理解够不够细致、对流程的调试够不够耐心。字符分割花的时间最多,BP网络反而是最省心的。算法选型别被热度带跑,先把任务边界和场景约束想清楚,答案自己就出来了。