车牌识别这事儿,听起来挺唬人,其实拆开看就是一套“图像处理 + 神经网络”的组合拳。最近我把这个基于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(end+1) = col; %#ok<SAGROW> flag = true; elseif ~inChar(col) && flag endIdx(end+1) = col - 1; %#ok<SAGROW> flag = false; end end if flag endIdx(end+1) = 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:startCol+w-1) = charImg; end end归一化是训练和识别共享的一步。训练样本要归一化,识别时也要走同样的流程,否则特征分布不一致,网络效果直接打折。
4. BP神经网络的设计与训练
4.1 输入特征:像素直铺还是提取特征
很多人第一次做BP网络做字符识别,习惯直接把归一化后的32×16=512个像素全部拉直当输入。这个做法理论上没问题,但是有两点不足:一是维度高,网络参数多,收敛慢;二是相邻像素之间的相关性太强,有很多冗余信息。
我在实验里对比并最终采用的方案是:提取混合特征,包含网格密度特征和投影特征。
- 网格密度特征:把32×16的字符图像切成5×8共40个小格子,统计每个格子中白色像素所占的比例,得到40维特征。
- 投影特征:对整幅图像做水平投影和垂直投影,各32+16=48个统计值,拼接在一起。
这样总共88维特征,比512维小得多,训练速度明显提升,而识别率几乎没有下降。如果你追求极致的轻量,还可以继续降维,但对车牌识别这个场景来说,88维已经是一个很合适的平衡点。
特征提取之前,二值图像上字符的粗细也会影响投影值和密度值,所以我在特征提取前还做了一步**细化(骨架化)**处理,就是把字符的笔画收敛到一个像素宽。这样做的目的是减弱笔画粗细带来的干扰,让网络更关注字符的“结构”而不是“胖瘦”。不过这一步也会带来一些毛刺,实际使用时需要权衡。
4.2 网络结构与关键参数配置
网络结构我用的是单隐层BP网络,这也是最经典、最不容易出问题的配置。输入层节点数是88,对应特征维数;输出层节点数是65,对应“31个省份简称汉字 + 24个英文字母(去掉I和O) + 10个数字”的总类别数;隐藏层节点数我取的是50。
为什么取50?一个经验法则:隐藏层节点数通常取输入维度的一半到一倍之间。太小了网络容量不够,学不进去;太大了容易过拟合,训练还慢。50这个值在88维输入下表现不错,你也可以试试40、60,然后看验证集准确率来定,不要盲目堆节点。
训练算法我选的是traingdx(自适应学习率加动量法)。trainlm(LM算法)收敛速度非常快,但在样本量不大的时候特别容易过拟合,训练集准确率冲到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)]; %#ok<AGROW> 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),让网络自动学特征,省去手工提特征的麻烦,效果通常也会更好。
如果让我重新做一遍,我会把更多时间放在样本库建设上。算法和网络结构都是成熟的,把每个字符类别做到足够多的样本、覆盖足够多的真实场景,识别率自然就上去了。这算是这次项目我最想提醒后来人的一点。