☰
Matlab车牌识别预处理:形态学顶帽与字符切分避坑
2026/10/28 21:28:39 网站建设 项目流程

简介:面向高校信息类、自动化与计算机相关专业学生的Matlab车牌识别课程设计报告,适合正在完成图像处理、模式识别课程设计或需要撰写完整设计文档的读者参考。文档围绕车牌识别系统的设计与实现展开,从车辆检测、图像采集到牌照定位、字符分割与字符识别逐层说明,涵盖灰度化、图像增强、二值化、垂直投影分割、模板匹配与神经网络识别等关键环节,并给出设计流程、结果分析、测试验证、技术挑战与优化思路。压缩包共1个docx文件,约1.37MB,正文附有Matlab实现代码与运行结果展示,便于对照复现实验、整理报告结构和补充总结体会。已有116人学习,可作为课程设计选题、算法梳理与答辩准备的完整参考。

1. 一张 0 被认成 8 的车牌,暴露的其实是前四步的问题

很多人在跑通第一个 Matlab 车牌识别程序之后都会生出同一个错觉:只要定位框套准了,识别自然就准。真拿几张实拍图喂进去,最先崩的往往不是定位,而是字符——0 被认成 8,G 被认成 B,K 被认成 A。诡异的地方在于,定位框明明套得很正,切割出来的字符图肉眼看也挑不出毛病,可一旦和模板库做差,误差最小的偏偏是另一个字符。

这套流水线的真正难点不在识别算法,而在识别之前那几步“看不见的整形”:背景估计、阈值选取、形态学结构元素的尺寸、字符切分窗口的宽度。任何一步偏了几个像素,误差都会一路传递到最后那个相减的结果上。搞懂这条链路上每一环为什么这么设计,比背下一个识别算法有用得多。

2. 灰度化之后为什么要“减去自己”:背景估计与阈值的三层设计

2.1 rgb2gray 的加权系数决定了什么信息被保留

彩色转灰度不是简单地把三个通道平均。Matlab 的rgb2gray用的是 BT.601 的亮度加权:R 乘 0.299、G 乘 0.587、B 乘 0.114。这套系数来自人眼视锥细胞对不同波长的敏感度差异——人眼对绿光最敏感,对蓝光最迟钝。

这个细节对车牌识别不是可有可无的。典型的蓝底白字车牌,蓝色通道分量很高但权重只有 0.114,换算成灰度后底色被压成中等偏暗的灰;白色字符三个通道都接近饱和,加权后仍然是很亮的灰。两者一压一保,对比度反而被拉开了。如果改成三通道等权平均,蓝底会明显偏亮,和白色字符的灰度差被压缩,后面的阈值分割就更容易把字符和底一起判成前景。选哪组系数,本质上是在决定“哪部分信息值得留到下一步”。

2.2 disk(13) 开运算:用“比字符大、比车牌小”的尺度估背景

拿到灰度图之后,第一步不是急着二值化,而是先构造一张“背景图”。做法是对灰度图做一次开运算,结构元素是半径 13 的圆盘:

se = strel('disk', 13); % 圆盘结构元素,直径约 26 像素 Bgray = imopen(Sgray, se); % 开运算 = 先腐蚀后膨胀

开运算的几何含义很直白:腐蚀阶段取局部最小值,任何比结构元素小的亮结构都会被“吃掉”;膨胀阶段再把剩下的结构恢复回原来的大小。所以开运算的输出只剩下那些“尺寸大于结构元素”的亮块,细小的高频亮细节全部消失。

半径 13 这个数字不是拍脑袋。车牌字符的笔画宽度通常十几像素,字符整体高度几十像素,车牌区域本身横跨两三百像素。半径 13(直径 26)刚好卡在“大于笔画、小于车牌”这个区间里:字符被当成细节抹平,车牌这一整块亮区域被保留下来。半径调大到 30,车牌本身也会被吃掉一部分,背景图变得斑驳;调小到 5,字符残留进背景图,后面相减就减不干净。这类参数没有万能值,只能按输入图像的分辨率去卡尺度。

2.3 相减就是白顶帽:为什么它能对抗光照不均

Egray = imsubtract(Sgray, Bgray); % 灰度图减背景图

这一步在形态学里有个正式名字:白顶帽变换(Top-hat)。它的效果等价于“原图减去原图的开运算”,保留下来的正是那些被开运算抹掉的、尺寸小于结构元素的亮细节。

为什么它能对付光照不均?因为光照在图像里是低频成分——整张图缓慢地变亮变暗,梯度很小;字符边缘是高频成分——几个像素内灰度剧烈跳变。开运算估出来的背景图恰好就是那份低频,减掉它等于做了一次高通滤波。左边车灯照得亮、右边背光偏暗,这种大范围的明暗差异被消掉了,剩下的只有字符和背景的相对亮度差。这一步是整个链路里最像玄学、其实最讲道理的地方。

注意:顶帽变换假设“目标比背景亮”。蓝底白字、黑底白字都满足,白底黑字就不满足了,得先反相再做,或者改用黑顶帽(底帽变换)。

2.4 阈值取 max−(max−min)/3:一个经验公式的适用边界

增强之后的图像背景被压到接近 0,字符是很亮的孤立块。这一步要定一个阈值把它二值化:

fmax1 = double(max(Egray(:))); % 增强后的最大值 fmin1 = double(min(Egray(:))); % 增强后的最小值 level = (fmax1 - (fmax1 - fmin1)/3) / 255; % 落在动态范围上1/3处,归一化 bw2 = im2bw(Egray, level); % 生成二值图

im2bw的第二个参数是归一化到 [0,1] 的阈值。这里没有用大津法(Otsu),而是从最大值往下退三分之一动态范围。原因在于顶帽之后直方图虽然还是双峰,但背景峰极窄极陡、前景峰又长又平,谷底的位置并不清晰,Otsu 在这种偏斜分布上容易把阈值推向背景一侧,结果是字符被判成背景,二值图上只剩几根细线。

从最大值退 1/3 是个偏保守的策略:只把最亮的那一批像素判成前景,宁可漏掉字符边缘的一点暗像素,也不要把背景噪声拉进来。代价是对残差敏感——如果背景差分做得不干净,fmax1会被某个孤立的噪声亮点抬高,阈值跟着走高,字符就会出现断裂、笔画缺胳膊少腿。所以这一步的稳定性,其实完全依赖 2.2 那一步的结构元素选得准不准。

3. 从碎边缘到一块矩形:Canny 与形态学怎么“焊”出候选车牌

3.1 Canny 给的是单像素边缘,不是区域

grd = edge(bw2, 'canny');

Canny 的四个阶段值得拆开看:高斯平滑去噪、Sobel 算子求梯度幅值和方向、沿梯度方向做非极大值抑制只保留局部最大、最后用双阈值加滞后连接把弱边缘挂到强边缘上。非极大值抑制是它输出“单像素宽骨架”的原因——每条边缘只留梯度方向上的一个点。滞后阈值则解决断边问题:高于高阈值的点直接确认为边缘,低于低阈值的丢弃,夹在中间的点只有连到强边缘上才保留。

问题在于,二值图上的 Canny 会把字符的每一个笔画轮廓都画出来。得到的结果是一堆彼此独立的、包着字符的小框,而不是一整块车牌。这时候如果直接去找连通域,找到的会是单个字符,不是车牌。

3.2 闭运算 [5,19]:横向结构元素把字符焊成一块

bg1 = imclose(grd, strel('rectangle', [5 19])); % 先膨胀后腐蚀

闭运算和开运算的顺序反过来:先膨胀再腐蚀。膨胀把亮区域向外扩张,横向 19 列、纵向 5 行的矩形结构元素意味着“水平方向扩得远、垂直方向扩得少”。字符之间的间隙、字符与车牌边框之间的间隙,大多在十几像素量级,19 列的扩张幅度足以把它们填上;纵向只扩 5 行,是为了避免把上下相邻的两行文字或车身反光也糊到一起。

为什么矩形是横长条而不是竖长条?因为车牌字符是水平排列的。结构元素的形状本质上是在编码“我预期目标在哪个方向上连续”。这个先验直接决定连通结果,选错了方向,字符永远连不成一块。

3.3 [19,1] 开运算:删掉竖长条容不下的东西

bg3 = imopen(bg1, strel('rectangle', [5 19])); % 清掉零星小亮块 bg2 = imopen(bg3, strel('rectangle', [19 1])); % 竖长条,删水平细线

第三次形态学操作的结构元素是 19 行 1 列的竖长条。开运算会删掉“装不进这个竖长条”的亮结构——水平细线(车牌上下边框、车身腰线、保险杠反光)高度只有几像素,在竖长条里放不下,直接被抹掉;而字符块和车牌整体高度足够,能塞进 19 行里,保留下来。

这三步的顺序不是随便排的。先闭后开是标准的“先连接、后清理”套路:闭运算负责把该连的连起来,开运算负责把连错的清掉。如果顺序颠倒,先用开运算会把字符本身当噪声删掉,再闭运算就只能把残留的边框连成一条横线,越弄越糟。

3.4 bwlabel + BoundingBox:用长宽比先验给候选投票

[L, num] = bwlabel(bg2, 8); % 8邻域连通标记 stats = regionprops(L, 'Area', 'BoundingBox'); % 老版本里这个函数叫 imfeature Area = [stats.Area]; Box = reshape([stats.BoundingBox], 4, []).'; % 每行 [x y w h] ratio = Box(:,3) ./ Box(:,4); % 宽高比

bwlabel用 8 邻域做连通标记,把二值图切成若干个独立区域,每个区域给一个整数标签。regionprops对每个标签算几何特征,这里用到两个:面积和最小外接矩形。外接矩形的[x y w h]直接给出候选框的位置和尺寸。

筛选用的是长宽比这个先验。标准车牌 440×140,比例约 3.14,算上倾斜和边框余量,实际候选的宽高比会落在 2 到 5 之间。代码里不是“落在区间内就选”,而是“算每个候选的比值和先验值的差,取差最小的那个”。这个细节很关键:区间筛选可能命中多个候选,一旦有车灯、广告牌同时满足,取第一个就会翻车;取最接近先验值的那个,鲁棒性高一截。

再补一刀面积下限会更稳。面积过小的候选(几十像素)通常是残留噪点,即使宽高比碰巧接近,也不该进最终结果。

4. 投影、拟合与切分:倾斜校正和边框剥离的几何账

4.1 水平投影的峰谷对应图像里的什么

定位之后拿到的是车牌的二值子图。接下来要做的所有事——去边框、求倾角、切字符——都建立在投影之上。

histrow = sum(sbw, 2); % 水平投影:对每一行求白像素个数 histcol = sum(sbw, 1); % 垂直投影:对每一列求白像素个数

水平投影是一个长度等于图像高度的向量,第 i 个元素是第 i 行的白像素总数。字符所在的行白像素多,投影出现峰;字符之间、字符与边框之间的空隙白像素少,投影出现谷。峰宽就是字符高度,谷宽就是行间距。垂直投影同理,峰对应字符的竖直笔画,谷对应字符间的间隙。

峰谷分析要算的量不只峰和谷本身,还包括上升点、下降点、峰宽、谷宽、峰间距离、峰中心。后面所有的切分操作,都是在这几个量的基础上做加减法。

4.2 poly1 最小二乘拟合求倾角,以及它为什么会翻车

车牌倾斜会让投影的峰谷变得模糊——本来该是一条清晰的峰,因为字符整体斜着走,被摊平成一坨。所以要先校正。

思路是拟合车牌的上边框或下边框所在的直线。从上边往下扫,每列第一个值为 1 的点就是上边框;从下边往上扫,每列最后一个值为 1 的点就是下边框。拿到一串点之后做一次最小二乘直线拟合:

fresult = fit(xdata', ydata', 'poly1'); % 一次多项式拟合 y = p1*x + p2 p1 = fresult.p1; angle = atan(p1) * 180 / pi; % 斜率转角,弧度转度 if abs(angle) < 15 % 超过这个范围基本是拟合失败 subcol = imrotate(subcol1, angle, 'bilinear', 'crop'); sbw = imrotate(sbw1, angle, 'bilinear', 'crop'); end

poly1就是最小二乘意义下的一次多项式,返回斜率p1和截距p2,斜率取反正切就是倾角。最小二乘的软肋是对离群点极其敏感:如果扫描时混进了字符笔画上的点、或者边框中间有一段断掉导致扫到了字符顶部,拟合出来的直线会被这几颗“老鼠屎”整体带偏,算出来的角度可能差十几度,旋转之后车牌反而更歪。

所以实际实现里通常会加两道保险:一是扫描时只在图像左右两端各取一段,避开中间字符密集区;二是对角度做范围约束,超过 ±15° 就认为拟合不可信,直接跳过校正。宁可这次不校正,也不能把一个错角度套上去——旋转是不可逆的,转歪了后面全废。

提示:imrotate的两个可选参数各有分工。'bilinear'指定双线性插值,避免旋转后出现锯齿状台阶;'crop'让输出保持和输入一样的尺寸、把旋转产生的黑边裁掉,不然后面算投影时黑边会污染峰谷。

4.3 去上下边框:maxhight 是怎么算出来的

旋转之后重新算一次水平投影,接下来要把上下的水平边框剥掉,只留下字符主体。做法是给每个峰记账:

maxhight = max(markrow2); % 最宽的峰 findc = find(markrow2 == maxhight); rowtop = markrow(findc); % 该峰的上升点 rowbot = markrow(findc+1) - markrow1(findc+1); % 下一个峰的上升点减它的峰宽 = 本峰下降点 sbw2 = sbw(rowtop:rowbot, :); % 按行裁出字符区

这里的markrow数组存的是每个峰的上升点行号,markrow1存的是峰宽,markrow2存的是峰高。最宽的那个峰就是字符行——字符行横跨整幅图,白像素自然最多。rowtop是它的起点,rowbot的算法稍微绕一点:下一个峰的上升点减去下一个峰的宽度,等价于“本峰的下降点”。这种用数组下标做加减的记账方式在投影法里很常见,好处是不用单独维护下降点数组,代价是可读性差,调试时容易数错一格。

裁出来的sbw2就是去掉了上下边框的车牌字符区,剩下的高度就是字符高度。

4.4 字符中心距里的那个“点”:最大宽度不能用全局最大值

去完水平边框,再做垂直投影,准备切字符。切分需要知道每个字符的中心位置和最大字符宽度:

for k = 1:n1 down(k) = markcol(k+1) - markcol1(k+1); % 字符下降点(列号) wid(k) = down(k) - markcol(k); % 字符宽度 markcol5(k) = down(k) - double(uint16(wid(k)/2)); % 字符中心 end gap = diff(markcol5); % 相邻字符的中心距 [~, idx] = max(gap); gap(idx) = 0; % 抹掉那个不该存在的最大值 maxwidth = max(gap); % 剩下的最大值才是真正的最大字符宽度

中心位置用“下降点减半个字符宽”算,这个没问题。问题出在下一步求最大字符宽度:直觉上取中心距的最大值就行,但这套逻辑在真实车牌上一定会翻车。

标准车牌是“汉字 + 字母 + 圆点 + 五位”,圆点是一个很小的分隔符,它不占完整字符宽度,但它把汉字和第一位字母之间的中心距拉得特别大。于是diff(markcol5)的最大值永远出现在第二和第三个字符之间,这个值远超真实字符宽度。如果直接拿它当切分窗口宽度,切出来的每一格都会宽出一大截,把相邻字符的边缘也框进来。

所以必须先找到这个最大的中心距、把它置零,然后才取剩下的最大值。这一步是纯粹靠对车牌格式的先验知识救回来的——算法本身意识不到中间有个圆点,是人知道。

4.5 imresize 到 22×14 的真正目的

切出字符之后,每个字符图块大小不一,没法直接和模板比。统一缩放到 22 行 14 列:

SegBw2 = imresize(SegBw1, [22 14]); % 双线性插值缩放到固定尺寸 imwrite(SegBw2, strcat('image', num2str(k), '.jpg'), 'jpg');

22×14 这个尺寸不是随手定的,它大致对应字符的真实宽高比。缩放的目的有两个:一是消除切分时窗口宽度细微差异带来的尺寸抖动,让所有样本处在同一个尺度上;二是为后面建立样本库做准备——数据库里的模板必须和待识别字符同尺寸,才能做逐像素相减。

缩放用的是双线性插值,意味着这个过程本身会引入灰度过渡。对于二值图来说,插值后会出现介于 0 和 1 之间的中间值,二值化时如果阈值定得不好,笔画会变粗或变细甚至断开。这是模板匹配误差的一个重要来源,后面第 5 章会再遇到它。

5. 避坑与排查:这套流水线最常翻车的五个地方

5.1 现象:二值图上整块车牌发白,字符糊成一片

原因通常出在阈值上。fmax1取的是增强后图像的最大值,如果背景差分没做干净,某个孤立的高亮噪点会把fmax1抬得很高,level跟着走高,本该死掉的背景像素被放进了前景;反过来,如果形态学半径过大,字符在顶帽阶段就被削掉了一部分,fmax1偏低,level跟着走低,前景膨胀得更厉害。

排查方法是把Egray的直方图画出来看。正常情况下应该是左边一个极窄的背景峰、右边一段拖得很长的前景分布。如果看到中间一大片连续分布,说明差分没成功,问题得往前追到strel的半径上。半径从 8 到 16 逐档试,看哪一档能让直方图的两个峰分离得最清楚。

5.2 现象:定位框套住了车灯或者保险杠

闭运算的结构元素横向给得太大时,车牌两侧的车灯、保险杠反光会被一起焊进同一个连通域,出来的外接矩形宽度远超车牌本身。另一种情况是筛选阶段只比了长宽比没比面积,一个细长的反光条碰巧比例接近先验值,就被选中了。

解决办法分两层。形态学层面把[5 19]的第二个参数往下调,比如改成[5 13],缩小横向扩张幅度;筛选层面加面积下限,把所有候选按面积排序,先砍掉明显过小和过大的,剩下的再比宽高比。面积阈值可以按图像总像素数的千分之一到百分之一这个量级去估。

5.3 现象:倾斜校正之后车牌反而更歪

这几乎总是因为拟合点里混进了非边框点,最小二乘被离群点带偏了。典型的触发场景是边框在某处断开,扫描时那段扫到了字符顶部,这几个点的 y 值和真实边框差了几十像素,一进入拟合就把斜率拽歪。

处理办法有三条,建议同时上:扫描时只在图像左右两端各取 20% 的列参与拟合,中间字符区直接跳过;拟合完检查残差,残差过大就丢弃这组点不做校正;给角度加一个硬约束,abs(angle) < 15之外的直接当成拟合失败,宁可不转也不能乱转。

5.4 现象:字符切多切少,第一个字符总被切成两半

中文汉字的笔画不连续,垂直投影在字内部会出现深谷,纯靠投影谷去切,一个“某”字能被切成左中右三块。另一种情况是去左右边框时用固定比例削边,把第一个字符的左半部分当边框削掉了。

第一个问题的解法是放弃“按谷切”改成“按中心加固定宽度切”:先用 4.4 的方法算出每个字符的中心和最大宽度,然后以中心为基准、左右各扩半个最大宽度,得到固定宽的窗口。窗口宽了会带入邻字边缘,但不会把一个字劈成两半。第二个问题是把左右边框的判定从“按比例削”改成“看垂直投影两端的大峰”——边框投影是一个又高又窄的峰,位置在图像两端,特征比固定比例可靠得多。

5.5 现象:G 认成 B、0 认成 8,肉眼明明能分清

这两个错误有个共同点:错的那个字符和正确的字符,在某种笔画归一化之后结构极其相似。G 和 B 的差别主要在右下角那一小段开口,0 和 8 的差别在中间那道横。如果二值化时笔画偏粗,G 的开口被填上,就变成了 B;如果imresize到 22×14 时插值把中间那道横糊淡了,8 就退化成了 0。

根子不在识别算法,在预处理。可以改的地方有三处:切分完成后先做一次笔画粗细归一化(统一膨胀或细化到固定笔画宽度),再送去匹配;模板库里每个字符类别不要只放一张样本,按不同光照、不同粗细各放几张,匹配时取类内最小误差;误差度量从原始欧氏距离改成归一化后的距离,避免模板图整体偏亮导致的系统性偏差。

还有一处容易被忽略:样本库本身的质量。切出来的字符图和库里的模板做逐像素相减,误差最小的那个决定输出结果,所以库里任何一张脏样本都会变成一次误识别的直接原因。建库时逐张肉眼过一遍,比事后调算法省事得多。

6. 设计取舍:这套“老派”流水线今天还值得逐行读的理由

6.1 分阶段验证,别拿整体准确率骗自己

端到端跑一百张图,得到“识别率 85%”这个数字,信息量几乎为零。这个 85% 到底是定位没框住、分割没切开,还是识别认错了?不知道。这套流水线最大的好处恰恰在于每一阶段都有明确的中间输出,可以逐级量化:

阶段可量化的指标出问题时的典型征兆
定位候选框与人工标注的 IoU框住车灯、框太宽、多个候选
分割切出的字符数是否等于 7多切、少切、首字被劈开
识别单字符正确率相似字符成对出错(G/B、0/8)

逐级看下来,哪一层的通过率掉得最厉害,问题就在那一层,不用在整条链路上瞎调参。这个思路放到今天任何一套检测加识别的系统上都成立。

6.2 模板匹配、BP 网络与端到端方法的分工

模板匹配的优点是零训练成本、结果完全可解释——误差最小的那张模板就是答案,出错时能直接看出是被哪张模板带偏的。缺点是它对几何形变、笔画粗细、光照残差全都敏感,泛化能力差,稍微换个字体或者沾点泥,误差排序就变了。

BP 网络的思路是把特征提取交给网络自己学,输入可以是归一化后的字符图,也可以先提取方向梯度、投影特征再送进去。它比模板匹配更能容忍形变,但需要样本量支撑,而且训练过程本身是个黑匣子,出错时不好定位。

至于现在流行的端到端检测识别,把定位和识别塞进一个网络,省掉了所有手工设计的中间步骤。代价是可解释性——中间过程看不到,出了错只能整体重训。对课程设计或者想搞懂原理的场景来说,一步步拧螺丝的流水线反而更有价值,因为每一颗螺丝你都知道它为什么在那儿。

我头几次跑这套流程时,习惯性地只盯着最后的识别结果,图省事跳过了打印中间图像这一步,结果一张图错了,从头到尾重跑了七八遍才发现是形态学半径设大了。从那以后我每次改参数,都会强制把顶帽结果、二值图、连通域标记图、投影曲线这四张中间图打出来扫一眼,确认每一步的输入都是对的,再看最终输出。这个习惯比任何一个算法技巧都省时间。希望帮到你。

资源里附了完整的.m源文件和逐阶段的运行结果图,从读图到出识别结果是一条能跑通的链路,对着代码核一遍每一步的参数含义,比隔着屏幕猜要快得多。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询