简介:这份基于Matlab的车牌识别课程设计报告面向信息科学、电子信息等专业的学生,可用于课程设计、综合实验或图像处理入门项目。正文按课程设计规范组织,从设计目的、原理、详细步骤写到结果分析、总结体会和参考文献,完整呈现车辆检测、图像采集与牌照识别三部分的设计思路;算法部分涉及图像灰度化、开运算背景提取与差分增强、最佳阈值二值化、车牌区域定位、垂直投影法字符分割,以及模板匹配和人工神经网络两类字符识别方案,并讨论光照不均、牌照污损倾斜等实际因素对识别率的影响与优化方向,同时附有Matlab源代码和中间处理效果图。压缩包共1个docx文件,约1.37MB,图文与代码同册编排,便于按章节对照复现。目前已有116人学习下载,适合需要参考报告结构、算法流程和实验分析的读者。
1. 一份能跑通的 Matlab 车牌识别课设:从图像到车牌号的完整链路
想用 matlab 做一套车牌识别,又不想从零啃论文,这份带源码的课程设计报告基本能省掉两周摸索。它没有停在原理图上,而是把一个能跑的完整链路摊开:读图、灰度化、开运算估背景、背景相减增强、二值化、Canny 边缘、开闭运算滤波、连通域定位车牌、倾角校正、垂直投影切字符,最后拿样本库做模板匹配输出号码。适合三类人:课程设计要交活的本科生、想快速搭个图像处理 demo 的工程师、以及想把 OpenCV 那套形态学思路对照着用 matlab 复现一遍的人。下面按真实调试顺序拆,重点放在参数怎么定、哪一步最容易崩。
2. 预处理链路:灰度化、背景相减与二值化的参数定法
预处理这一段的产出质量,直接决定后面定位能不能收敛。很多人栽在第一步:拿一张光照均匀的样例图调通了,换一张地下车库拍的图,二值化后整幅图一片白,连通域筛选全是噪声。问题不在算法选错,而在预处理的每一步都埋了一个隐式假设——假设背景足够平、假设车牌区域比周围亮、假设阈值能自动适配。这些假设在真实图片里八成不成立,所以参数必须理解着设,不能抄个数字就走。
2.1 灰度化不是可选项,是一笔速度账
彩色图是 M×N×3 的 uint8 张量,灰度图是 M×N。转成灰度之后,内存降到三分之一,后续每一次形态学运算、每一次投影统计的循环次数也跟着降。对课设这种要在笔记本上跑几十张图验证的场景,这个差别是能感知到的。
rgb2gray用的是加权平均,权重按人眼对绿的敏感度分配,大约是 0.2989R + 0.5870G + 0.1140B,不是简单三通道求平均。这一点对车牌有实际影响:蓝底白字的车牌,蓝色分量被压到很低,白色字符三个通道都高,转换后字符和底色的亮度差被拉大了,正好利于后面的阈值分割。
%% 读图与灰度化 clear; close all; clc; Scolor = imread('3.jpg'); % 图片路径别带中文和空格,MATLAB 对这两样很挑 figure, imshow(Scolor), title('原始彩色图像'); Sgray = rgb2gray(Scolor); % 加权平均转灰度 figure, imshow(Sgray), title('原始黑白图像');代码只有两行,但imread的路径问题是新手翻车最多的地方。相对路径是相对当前工作目录(pwd的输出),不是相对 .m 文件所在目录。图放在脚本旁边却报「文件不存在」,八成是工作目录没切。稳妥做法是在脚本开头加一句cd(fileparts(mfilename('fullpath'))),把工作目录钉死在脚本所在文件夹。
想进一步验证灰度化到底带来了什么,可以打印一下size和class:彩色图是uint8的 M×N×3,灰度图是uint8的 M×N。后面所有运算的前提都是这个uint8,而uint8的截断特性会在第 2.3 节给我们埋一个不大不小的坑。
2.2 用开运算估背景,再相减做增强
真实场景的光照基本不可能均匀,一张图左边被阳光晒得发白,右边在阴影里发灰。这时候拿一个全局阈值去二值化,要么左边全白,要么右边全黑。常规解法是先估计出一张「背景亮度面」,再让原图减掉它,剩下的就是相对局部背景的亮部——车牌字符、白车身边缘、反光条都在这个结果里。
灰度开运算是先腐蚀后膨胀,腐蚀会把比结构元小的亮区域抹平,膨胀再把剩下的结构长回去。所以imopen的输出是一张只保留大尺度亮度分布、细节全被抹掉的图,正好可以当背景面用。原图减背景面这个操作在形态学里有个正式名字叫顶帽变换,matlab 里直接有imtophat,手写一遍是为了看清内部到底发生了什么。
%% 开运算估计背景 + 背景相减 se = strel('disk', 13); % 圆盘结构元,半径 13 像素 Bgray = imopen(Sgray, se); % 灰度开运算:抹掉所有比圆盘小的亮细节 figure, imshow(Bgray), title('背景图像'); Egray = imsubtract(Sgray, Bgray); % 逐像素相减,负值被 uint8 截断为 0 figure, imshow(Egray), title('增强黑白图像');strel('disk', 13)这个 13 是整条链路里最需要动脑子调的参数之一。判断标准是:结构元直径(26 像素)必须明显大于字符笔画宽度,否则开运算会把字符本身一起抹掉,相减之后字符消失;同时又必须小于车牌区域的整体尺度,否则背景面会跟得太紧,把车牌区域整个吞进背景里,相减后什么都不剩。
一个可操作的估算方法是:先在图上量一下车牌的像素高度,假设是 60 像素,字符笔画宽约 4~6 像素,那结构元直径取到笔画宽的 3~5 倍即可,也就是 15~30,半径 8~15。图片分辨率越高,这个值要跟着放大。如果换了一张 4K 图还用 13,开运算基本等于没做,增强效果会大打折扣。
imsubtract对uint8输入会做饱和截断,负值直接变 0,这对增强是有利的——背景区域被压成纯黑,亮部保留。但它也带来一个副作用,就是下一节要讲的阈值退化。
2.3 那个「三分之一法则」到底在算什么
原始报告里用了一个经验公式求阈值:取增强图的最大值和最小值,把阈值放在动态范围靠上三分之一的位置。思路是对的——车牌字符是亮部,希望阈值偏高一点,只留下最亮的那些像素。但写法上有个隐蔽问题。
%% 阈值计算:原始写法 fmax1 = double(max(Egray(:))); % 通常等于 255 fmin1 = double(min(Egray(:))); % 通常等于 0 level = (fmax1 - (fmax1 - fmin1)/3) / 255; bw2 = im2bw(Egray, level);代入fmax1 = 255、fmin1 = 0算一下:(255 - 85) / 255 = 0.6667。也就是说,只要增强图里存在纯白像素和纯黑像素(这在几乎所有图上都成立),这个「自适应阈值」就恒等于 0.667,本质是一个写死的常数。换图不换阈值,泛化性自然无从谈起。这不是公式写错了,是uint8的饱和截断把动态范围锁死了。
更稳的写法是先转double再做归一化,让动态范围真正反映图像内容;或者干脆用 Otsu 类间方差法自动找分割点:
%% 改进写法一:转 double 保留真实动态范围 EgrayD = double(Egray); lo = min(EgrayD(:)); hi = max(EgrayD(:)); levelD = (hi - (hi - lo)/3) / 255; % 现在 lo/hi 才是这张图真实的范围 bw2 = im2bw(Egray, levelD); %% 改进写法二:Otsu 自动阈值,适合光照差异大的批处理 levelO = graythresh(Egray); % 返回 (0,1) 之间的归一化阈值 bw2 = im2bw(Egray, levelO); %% 改进写法三:新版函数,等价但语义更清楚 bw2 = imbinarize(Egray, 'adaptive', 'ForegroundPolarity', 'bright');三种写法各有适用面。第一种保留「阈值偏上」的先验,适合车牌区域确定比背景亮的情况;第二种完全不依赖先验,批处理多张图时最省心,但对对比度低的图容易切过头;第三种按局部邻域自适应,光照极不均匀时效果最好,代价是运算慢,而且窗口大小又是一个要调的新参数。
实操建议是:课设验收只跑几张图,用第二种;如果要做成能应对各种环境的 demo,把三种都封装成函数,用同一个上层接口切换,出问题的时候能快速定位是阈值策略的问题还是后面的问题。
2.4 Canny 边缘与结构元方向性的配合
二值图到手,下一步是找边缘。Canny 的优势在于双阈值加非极大值抑制,出来的边缘是单像素宽的细线,比 Sobel、Prewitt 干净得多,不会出现粗边糊成一片的情况。
%% 边缘检测 + 形态学滤波 grd = edge(bw2, 'canny'); % 默认双阈值自动估计 % grd = edge(bw2, 'canny', [0.06 0.16]); % 手动指定低/高阈值 bg1 = imclose(grd, strel('rectangle', [5 19])); % 横向闭运算:连接字符间断裂 figure, imshow(bg1), title('闭运算 [5,19]'); bg3 = imopen(bg1, strel('rectangle', [5 19])); % 同尺寸开运算:去掉孤立小点 figure, imshow(bg3), title('开运算 [5,19]'); bg2 = imopen(bg3, strel('rectangle', [19 1])); % 竖向开运算:削掉上下边框横线 figure, imshow(bg2), title('开运算 [19,1]');strel('rectangle', [5 19])里,第一个数是高度,第二个数是宽度,这个顺序经常有人写反。写成[19 5]就变成竖向的短粗结构元,闭运算会把上下方向的断裂连起来,字符反而被糊成竖条,后面投影切分必崩。
三步的意图要分清:第一步闭运算是在同一个连通域内部把字符之间的缝隙填上,让整个车牌区域变成一个连通的实心块,结构元宽度 19 要略大于字符间距;第二步开运算清掉边缘噪声产生的小碎块;第三步竖向开运算高度 19,专门针对车牌上下那两条水平边框线——它们的竖直厚度只有几个像素,用一个高 19 的竖向结构元能干净地削掉,而字符本身高度有几十像素,不受影响。
这三步的顺序不能换。先开运算再闭运算,噪声点会先被清掉但也可能把本来就细弱的字符边缘一起削掉,闭运算就失去了连接的对象。稳妥顺序永远是「先闭后开」,先把该连的连起来,再清理不该有的。
3. 车牌定位与倾斜校正:连通域筛选、投影峰谷与拟合求角
定位这一段是整条链路的瓶颈。前面的预处理做得再好,这里选错候选区域,后面切出来的字符就是一堆废图。原始报告在这一段用了很多「先验知识」——长宽比接近车牌、面积在某个区间、位置偏下——这些先验本身没错,问题在于每一条先验都要配一个数值范围,而范围的松紧直接决定漏检率和误检率能不能同时压下去。
3.1 bwlabel 连通域与长宽比筛选的取值逻辑
二值图里每一个连通块都可能是车牌,也可能是一块白车身、一个车标、一段反光条。要区分它们,靠的是几何特征。
%% 连通域标记与特征提取 [L, num] = bwlabel(bg2, 8); % 8 邻域连通,斜向相邻也算连通 stats = regionprops(L, 'BoundingBox', 'Area', 'Centroid'); % 注意:老版本教程里的 imfeature 在新版本已移除,统一换成 regionprops ratioMin = 2.2; ratioMax = 4.2; % 长宽比窗口 areaMin = 800; % 最小面积,按图片分辨率调 candIdx = []; for k = 1:num bb = stats(k).BoundingBox; % 返回 [x y width height] ar = bb(3) / bb(4); % 宽 / 高 if ar > ratioMin && ar < ratioMax && stats(k).Area > areaMin candIdx(end+1) = k; end end % 面积最大的候选当车牌 [~, pos] = max([stats(candIdx).Area]); plateIdx = candIdx(pos); bb = round(stats(plateIdx).BoundingBox); subcol1 = Scolor(bb(2):bb(2)+bb(4)-1, bb(1):bb(1)+bb(3)-1, :); % 彩色子图 sbw1 = bw2( bb(2):bb(2)+bb(4)-1, bb(1):bb(1)+bb(3)-1); % 二值子图 figure, imshow(subcol1), title('定位出的车牌区域');长宽比窗口 2.2~4.2 的来历:国内常见蓝牌物理尺寸 440×140 毫米,理论比值 3.14。但照片里车牌不总是正对镜头,透视会让这个比值在 2.5 到 3.8 之间浮动,加上定位框本身有膨胀余量,窗口留到 2.2~4.2 是留了足够余量又不至于太松。
窗口放宽到 5 以上会怎样?车头灯、进气格栅、车身腰线都能满足长宽比,候选区一下子冒出七八个。收紧到 2.8~3.4 会怎样?稍有倾斜的车牌直接被漏掉,程序在该有结果的地方返回空。这两个方向都是血泪经验,实际调的时候建议按 2.5~4.0 起步,看漏检和误检哪边先出问题再做单边调整。
面积下限areaMin = 800不是通用值,它和图片分辨率强相关。1080p 图里车牌区域大约 200×60 = 12000 像素,800 的门槛相当于允许车牌只占画面的很小一块;如果图片是 4K,这个值至少要乘 4。更稳的做法是用相对值:areaMin = 0.005 * numel(bw2),面积小于全图 0.5% 的候选直接丢弃。
bwlabel的第二个参数 8 表示 8 邻域,斜向相邻的像素算连通。改成 4 的话,斜向断裂会被拆成两个连通域,车牌区域被切成碎片。这个参数默认不写就是 8,但显式写出来能省掉后来人一次查文档。
3.2 水平投影峰谷:自动找出上下边框和字符高度
定位得到的子图还带着车牌的外框,直接切字符会把金属边框当成字符的一部分。要去掉边框,得先知道字符区在哪——这就用到水平投影。
%% 水平投影找字符上下边界 histrow = sum(sbw1, 2); % 按行求和,得到列向量 figure, bar(histrow), title('水平投影(含边框)'); axis tight; view(90, -90); % 把柱状图转 90 度看更直观 thrRow = mean(histrow) * 0.6; % 阈值取均值的六成 rowMask = histrow > thrRow; d = diff([0; rowMask; 0]); rowTop = find(d == 1, 1, 'first'); % 第一段连续白区起点 rowBot = find(d == -1, 1, 'last') - 1; % 最后一段连续白区终点 sbw2 = sbw1(rowTop:rowBot, :); % 去掉上下边框后的二值子图 charH = rowBot - rowTop + 1; % 字符区高度 figure, imshow(sbw2), title(['去水平边框,字符高度 ', int2str(charH)]);投影阈值的 0.6 是个经验系数。取值太高,字符笔画里那些窄的横线会被判成背景,字符区高度估小;取值太低,车牌上下的反光边框混进来,高度估大。0.5~0.7 之间都可以,0.6 是个不容易出错的中点。
这段代码相对原始报告做了简化。原版对投影曲线做了完整的峰谷分析——求峰的上升点、下降点、峰宽、谷宽、峰间距离、峰中心——功能更完整但代码量翻倍,而且每个中间量都是一个待调参数。课设场景下用「连续区段」的思路已经够用,把省下来的精力留给后面的字符切分更划算。如果后面发现某些图字符区高度估得离谱,再回头补峰谷分析不迟。
有个细节值得留意:diff([0; rowMask; 0])前后各补一个 0,是为了让起始和结束的跳变都能被find抓到。不补的话,如果白区正好从第一行开始或者到最后一行结束,diff出来的长度为 n-1,边界判断会漏掉。
3.3 倾斜校正:用上边沿点做线性拟合
手机随手拍的车牌,倾斜个三五度很常见。倾斜带来的后果是垂直投影的峰谷变得平缓——本来字符之间应该是干净的零值谷,倾角一大,相邻字符的投影就叠在一起,切分点找不准,切出来的字符一半带隔壁的笔画。
校正的思路很直接:找出车牌上边沿的每一个点,拟合出一条直线,直线和水平方向的夹角就是倾斜角。
%% 提取上边沿点 [h, w] = size(sbw2); xdata = []; ydata = []; for col = 1:w rowIdx = find(sbw2(:, col) == 1, 1, 'first'); % 每列第一个白点 if ~isempty(rowIdx) xdata(end+1) = col; ydata(end+1) = rowIdx; end end %% 一次多项式拟合求斜率 p = polyfit(xdata, ydata, 1); % p(1) 是斜率,p(2) 是截距 angle = atan(p(1)) * 180 / pi; % 弧度转角度 fprintf('车牌倾斜角:%.2f 度\n', angle); %% 旋转校正 subcol = imrotate(subcol1, angle, 'bilinear', 'crop'); sbw = imrotate(sbw1, angle, 'bilinear', 'crop'); figure, subplot(2,1,1), imshow(subcol), title('校正后灰度子图'); subplot(2,1,2), imshow(sbw), title(['旋转角 ', num2str(angle, '%.2f'), ' 度']);原始报告用的是fit(xdata', ydata', 'poly1'),需要 Curve Fitting Toolbox。polyfit是基础函数,不需要额外工具箱,结果等价,迁移到没有安装该工具箱的机器上不会报错。这是个小替换,但如果验收时在别人的电脑上跑,能省掉一次解释。
imrotate的参数'bilinear'指双线性插值,旋转后像素位置不在整数格点上,必须插值。'nearest'快但边缘会有明显锯齿,字符笔画变成阶梯状,影响后面的匹配。'bicubic'更平滑但会引入轻微振铃,双线性是这一档里的平衡点。
'crop'这个参数很关键。不加的话,imrotate会把整幅图放进一个更大的画布以容纳旋转后的完整内容,尺寸变了,后面所有基于行列坐标的切割全部错位。加上'crop',输出尺寸和输入一致,越界的部分被裁掉——代价是四个角可能丢一点内容,但车牌在图像中心,实际上丢的是无关背景。
角度绝对值超过 15 度就要警惕了。正常的拍照倾斜不会这么大,真出现这么大的角度,八成是拟合点里混进了非车牌区域的点,比如车牌左侧的车标或者右侧的灯具。可以在拟合前加一道过滤:只保留ydata在中位数上下 20% 范围内的点,剔除离群点再拟合。
4. 字符分割与模板匹配识别:垂直投影切分、归一化与样本库
到这一步,手里应该是一张去掉了上下边框、倾斜已经校正过的车牌二值子图。剩下的活儿是把里面七个字符一个个抠出来,缩放到统一尺寸,然后跟样本库比对。
4.1 垂直投影切字符:阈值、中心距与宽度上限
垂直投影的原理和水平投影一样,把二维的字符分布压到一维,字符所在的列投影值高,字符之间的间隙投影值低。找低值区段的中点,就是切分点。
%% 垂直投影 histcol = sum(sbw2, 1); % 按列求和,得到行向量 figure, bar(histcol), title('垂直投影(去水平边框后)'); thrCol = mean(histcol) * 0.3; % 阈值取均值的三成,比水平投影更低 colMask = histcol > thrCol; %% 找连续白区段 d = diff([0 colMask 0]); segStart = find(d == 1); segEnd = find(d == -1) - 1; segWidth = segEnd - segStart + 1; %% 宽度过滤:太窄的是噪声,太宽的是粘连 wMin = round(charH * 0.15); wMax = round(charH * 0.9); keep = find(segWidth >= wMin & segWidth <= wMax); segStart = segStart(keep); segEnd = segEnd(keep); fprintf('切出 %d 个候选字符\n', numel(segStart));阈值系数从水平投影的 0.6 降到 0.3,是有原因的。字符内部存在竖笔画和空白列交替,投影曲线在字符内部也会出现局部低谷,阈值取高了会把一个字符切成两半。取低一点,宁可让相邻字符粘连,也不要在字符内部断开——粘连可以靠宽度上限补救,断开的字符则没法自动缝合。
宽度上下限的基准是字符高度charH。车牌字符的宽高比大致在 0.4~0.7 之间,考虑到二值化和膨胀带来的余量,取 0.15 到 0.9 这个宽松区间,先把明显不合理的过滤掉,剩下的交给后面的排序环节。
字符顺序的确定不能靠segStart的天然顺序,因为噪声块可能插在中间。稳妥做法是先按中心 x 坐标排序,再用「第二个和第三个字符之间距离最大」这个先验定位省份汉字的位置——国内车牌的第一个字符是汉字(省份简称),第二个是字母,两者之间有个明显的间隔。
%% 按中心位置排序 centers = (segStart + segEnd) / 2; [centers, order] = sort(centers); segStart = segStart(order); segEnd = segEnd(order); %% 找最大间隔,定位汉字后的断点 gap = diff(centers); [~, maxGapPos] = max(gap); fprintf('最大间隔出现在第 %d 和 %d 个字符之间\n', maxGapPos, maxGapPos+1);这段逻辑和原始报告里那套markcol3、markcol4、markcol5、markcol6的一长串中间变量是一个意思,只是变量命名更贴近含义。那些markcol系列变量的写法在调试时很难看出来哪个是哪一步,改个名字不影响功能但能省掉半天时间。
4.2 22×14 归一化与样本库的建立规范
切出来的字符尺寸各不相同,没法直接比对。统一缩放到 22 行 14 列,这是字符识别领域的经典尺寸,长宽比接近 1.57,和车牌字符的实际比例吻合。
%% 归一化并入库 sampleDir = 'F:\MATLAB\work\sam\'; % 样本库目录,末尾必须有斜杠 if ~exist(sampleDir, 'dir'), mkdir(sampleDir); end for k = 1:numel(segStart) cleft = max(1, segStart(k)); cright = min(size(sbw2, 2), segEnd(k)); SegBw1 = sbw2(:, cleft:cright); SegBw2 = imresize(SegBw1, [22 14]); % 强制缩放到 22×14 fname = fullfile(sampleDir, sprintf('char_%02d.png', k)); imwrite(SegBw2, fname); % 用 png 而非 jpg,避免有损压缩 imwrite(SegBw2, fullfile(sampleDir, sprintf('char_%02d_preview.png', k))); endimresize的插值方式默认是双三次,对小图缩放会产生过冲,二值图缩完之后可能出现中间灰度值。严谨一点可以加imresize(SegBw1, [22 14], 'nearest')保持二值性,代价是边缘略糙。模板匹配阶段对二值性不敏感,因为后面统一做了double转换,两种都能用。
存png不存jpg,这一条看着琐碎但很要命。jpg 是有损压缩,二值图存进去再读出来,原本纯 0 和纯 255 的边缘会出现 254、1 这样的中间值,imread读回来是uint8不是logical,参与sum(abs(...))计算时误差被放大,本来该匹配上的字符差了几分就被判成不匹配。这类问题在报告里写不出,但调试时能耗掉一整天。
样本库的字符集要提前规划好。国内蓝牌七位,第一位是省份汉字(31 个),第二位是发牌机关字母(A-Z 去掉 I、O),后五位是数字和字母混合(去掉 I、O 的 34 个字符)。总共 31 + 24 + 34 = 89 类。课设场景下不用全做,挑十来个常见的先跑通流程,比如「京」「沪」「苏」加 0-9 和几个字母,样本文件命名用样本_字符.png的形式,加载时从文件名反解标签。
4.3 模板匹配与神经网络,课设场景怎么选
原始报告的结论是模板匹配能跑通,部分字符会误识——G 认成 B、K 认成 A、0 认成 8。这不是实现问题,是模板匹配这个方法的固有局限。
%% 模板匹配识别 function [label, score] = matchTemplate(charImg, templates, labels) charImg = imresize(charImg, [22 14]); charVec = double(charImg(:)); best = inf; label = '?'; for k = 1:numel(templates) tpl = double(templates{k}(:)); d = sum(abs(charVec - tpl)); % L1 距离 if d < best best = d; label = labels{k}; end end score = best / numel(charVec); % 平均每像素误差,用于置信度判断 end模板匹配的本质是像素级距离,它假设同一个字符在不同图上长得几乎一样。但真实情况是字体粗细略有差异、二值化阈值浮动、缩放插值带来亚像素偏移,这些都会累积成距离。G 和 B 的差别只在右下角一个小弧;0 和 8 的差别在中间一横;K 和 A 的差别在左下竖笔。这些局部差异在整个 22×14=308 维空间里只占十几个像素,被大量相同的背景像素稀释掉了,L1 距离排不出高低。
想改善有两条路。一条是换距离度量,把 L1 换成加权的——给字符中心区域更高权重,边缘更低权重,因为差异往往集中在中部。另一条是特征提取后匹配,用字符的投影直方图、穿越次数、笔画端点数量这些结构特征,维度从 308 降到 20 以内,G 和 B 的弧线差异在穿越次数上体现得非常明显。
神经网络这条路原始报告也提到了,思路是先提取特征训练网络,或者直接把图像喂进网络让它自己学特征。课设场景下用不上深度学习,一个双隐层的 BP 网络配 89 类输出,训练样本每类二十张,就能把准确率推到模板匹配之上。代价是样本采集和训练时间,看进度安排值不值。
选型建议按时间预算来:进度紧就用模板匹配,把字符集缩小到十几个类,接受 80% 左右的准确率,报告里写清楚局限和改进方向;进度宽松就上 BP 网络,但样本采集至少占掉三分之一的工作量,别低估这部分。
5. 避坑清单:五个把识别率拉回地面的排查记录
前面讲的都是「应该怎么做」,这一节讲实际调试时怎么翻的车。以下五条按踩到的概率从高到低排,每一条都是现象、原因、解决三段式,遇到问题可以直接对号入座。
5.1 换一张图就全白或全黑
现象:在样例图上跑得好好的,换一张自己拍的图,bw2出来整幅纯白或者整幅纯黑,后面的连通域要么一个没有要么铺满全图。
原因:第 2.3 节那个阈值退化成常数 0.667。样例图对比度好,0.667 正好卡在字符和底色之间;换了图之后亮部整体偏暗或者偏亮,0.667 就落到了错误的位置。level变量打印出来一看永远是 0.6667,这就是信号。
解决:把Egray转成double再算min/max,让动态范围反映真实内容;或者直接换graythresh做 Otsu。改完之后level在不同图上应该有明显变化,如果还是接近常数,说明Egray里确实存在大量饱和像素,这时候要在imsubtract之前先把原图归一化到[0,1]。
5.2 报错「未定义函数或变量 imfeature」
现象:从旧教程抄的代码,运行到imfeature(L, 'basic')直接报错,或者报Feastats.Area取不到值。
原因:imfeature是 Image Processing Toolbox 很早版本里的函数,在 2010 年前后的版本就已经被regionprops取代并移除。网上大量课设报告和博客还停留在那个年代。
解决:全部替换成regionprops,字段名也变了——Feastats.Area变成stats(k).Area,Feastats.BoundingBox变成stats(k).BoundingBox,原来是数组现在要通过索引取。用regionprops(L, 'Area', 'BoundingBox', 'Centroid')显式声明需要的属性,比'basic'一次性全算要快。
5.3 旋转之后切割坐标越界
现象:倾斜校正做完,imrotate之后size变了,后面的sbw(rowTop:rowBot, cleft:cright)报索引超出范围,或者切出来的图明显错位。
原因:imrotate默认输出画布会扩大以容纳完整旋转结果,尺寸从 h×w 变成接近 (h+w)×(h+w),行列坐标和原来的子图对不上。原始报告里代码写了'crop',但如果手抄时漏掉这个参数就会出现。
解决:所有imrotate调用统一加'crop',保证输入输出尺寸一致。加完之后再补一道防护:cleft = max(1, cleft); cright = min(w, cright);,宁可切窄一点也不能越界。另外,角度绝对值大于 15 度时先查拟合点是否有离群值,别指望imrotate能救回一张拟合错误的角度。
5.4 样本库加载失败或标签对不上
现象:imread读样本图报「文件不存在」,或者样本读进来了但识别结果全是同一个字符。
原因:三个高发点。一是路径含中文或空格,fullfile拼出来的路径在imread里被截断;二是dir返回的顺序在不同系统下不一样,样本数组和标签数组的索引对应错了位;三是存样本时用 jpg 覆盖了同名文件,读回来的是压过的旧图。
解决:路径全部用英文、无空格;用dir(fullfile(sampleDir, '*.png'))拿文件列表后,按sort({fileList.name})显式排序,标签从文件名反解,不要另建一个标签数组靠索引对应;样本一律存 png 并在文件名里带上标签,比如京_01.png,加载时strsplit(name, '_')取第一段当标签,这样样本增删都不会错位。
5.5 结构元尺寸和图像分辨率不匹配
现象:同一套参数,在 640×480 的小图上定位准确,换成 1920×1080 的图之后完全失效,要么定位框巨大,要么什么都找不到。
原因:strel('disk', 13)、strel('rectangle', [5 19])、areaMin = 800这些全是绝对像素值。分辨率翻三倍,车牌区域的像素尺寸也翻三倍,原来的结构元相对就变得太小,形态学操作等同于没做。
解决:把所有绝对参数改成相对于图像尺寸的比例。结构元半径改成round(0.01 * min(size(Sgray))),面积下限改成0.005 * numel(bw2),宽度上限改成round(charH * 0.9)这种以字符高度为基准的相对值。如果课程设计只交付一个固定分辨率的场景,也可以简单点——在脚本开头写一句SCALE = min(size(Sgray)) / 480;,然后所有参数乘SCALE,至少注释里写清楚这个参数和分辨率的依赖关系。
6. 把识别率从「能认出」推到「敢交差」:验证与调参技巧
调试这类图像处理链路,最忌讳的是盯着最终输出猜问题出在哪。
我的做法是加一个开关,把每一级中间结果都 dump 出来:Egray增强图、bw2二值图、grd边缘、bg2形态学滤波结果、定位框、去边框后的sbw2、校正后的sbw、每个切出来的字符。八个图排成 4×2 的 subplot,一次全看见。这样任何一级出问题,直接对比前后两级就能定位,不用靠改参数碰运气。
参数敏感度心里得有个数。下面这张表是我调过的几个关键参数,按对最终结果的影响从大到小排:
| 参数 | 所在环节 | 典型取值 | 调大的后果 | 调小的后果 |
|---|---|---|---|---|
disk半径 | 背景估计 | 8~15 | 背景面太平,字符增强不足 | 背景面跟太紧,字符被抹掉 |
| 二值化阈值 | 增强后二值 | 0.5~0.75 | 只留最亮像素,字符断裂 | 噪声全进来,连通域糊成一片 |
[5 19]闭运算 | 边缘连接 | 高 3~7,宽 15~25 | 相邻车牌被连成一个 | 字符间隙补不上 |
| 长宽比窗口 | 定位筛选 | 2.2~4.2 | 误检增加,车灯格栅都算候选 | 倾斜车牌漏检 |
| 投影阈值系数 | 切分 | 0.3~0.6 | 字符内部断开 | 相邻字符粘连 |
表里的取值只在 640×480 到 1280×720 这一档分辨率下成立,往上要走第 5.5 节的比例换算。
还有一个容易被忽略的验证角度:不要只看识别对了几个,要看错在哪些字符上。把所有识别错误的字符连同原图切出来摆在一起,如果错的都是 G/B、0/8、K/A、5/S、2/Z 这种「形近组」,说明问题在分类器,换特征或换距离度量;如果错得毫无规律,同一张图里有的对有的错,那问题大概率在切分——切歪了半列,什么分类器都救不回来。这两类错误的排查方向完全不同,混在一起看只会越来越乱。
从那以后我每次碰这类图像处理链路,第一件事不是写主流程,而是先把 dump 中间图的脚手架搭好。看着慢,实际能省掉反复改参数重跑的循环,尤其是在报告要交付、答辩要现场演示的时候,能指着某一级说清楚问题出在哪,比一句「调了调参数就好了」有说服力得多。希望帮到你。
本文还有配套的精品资源,点击获取