简介:围绕Matlab车牌识别课程设计整理的一份docx报告,面向信息科学、自动化等专业需要完成课程设计或图像处理入门实践的学生,帮助梳理从车辆检测、图像采集到牌照定位、字符分割与号码输出的完整实现思路。文档共1个文件,为单份docx,压缩包约1.37MB,内含设计目的、设计原理、详细步骤、结果分析、总结体会与参考文献等模块,并附源代码,可对照图像灰度化、开运算背景提取、阈值二值化、垂直投影分割及模板匹配或神经网络识别等环节理解算法流程。已有116人学习,适合用于课程设计选题分析、报告结构参考、算法复现与答辩准备,也能为不同光照、车牌质量等条件下的测试和识别率优化提供思路,报告中的进度安排与教学要求也便于按周推进。
1. 一张逆光车尾照,暴露了这套 Matlab 车牌识别课设的全部短板
去年帮人看一份课程设计,对方发来一张地库出口拍的车尾照,蓝底白字,逆光,车牌右半边糊成一团白。跑完他的 Matlab 车牌识别脚本,输出结果是「京A·8B8Q1」——七个字符里错了三个。翻代码才发现,二值化阈值是拿全图最大最小灰度做三分法算的,样本库路径写死在 F 盘的 work 目录里,字符切割的下标还硬编码成findmax-1:findmax+5。
这套东西本身不复杂,链路就六步:读图、灰度化、背景相减增强、二值化、Canny 边缘加形态学滤波、定位后投影分割再模板匹配。六七年前高校课设里这个题目出镜率极高,现在拿来当图像处理的入门实战依然合适——因为它把「预处理怎么影响后面每一步」这件事暴露得特别彻底,你调错一个结构元尺寸,后面全盘皆输。适合刚上手 Matlab 图像工具箱、想搞明白一次完整识别链路怎么落地的人。
2. 整体架构与运行环境:六个环节串成一条从像素到字符串的链路
2.1 三段式结构与六个处理环节
从工程视角看,这套系统按报告里的说法分三块:车辆检测、图像采集、牌照识别。但课程设计实际交付的代码只做了第三块,前两块是文字描述。真正跑起来的是下面这条链路,每一环的输出都是下一环的输入,中间任何一环崩掉,后面就是垃圾进垃圾出:
| 环节 | 输入 | 核心函数 | 输出 |
|---|---|---|---|
| 图像预处理 | 彩色原图 | rgb2gray、imopen、imsubtract | 增强后的灰度图 |
| 二值化 | 增强灰度图 | im2bw+ 自算阈值 | 黑白二值图 |
| 边缘与滤波 | 二值图 | edge(...,'canny')、imclose、imopen | 连通的车牌候选块 |
| 车牌定位 | 候选块 | bwlabel、regionprops、宽高比筛选 | 车牌子图 |
| 倾斜校正与切割 | 车牌子图 | 水平/垂直投影、fit、imresize | 7 张 22×14 字符图 |
| 字符识别 | 字符图 | 与样本库逐张相减取最小误差 | 车牌字符串 |
这里面有个容易被忽略的设计取舍:定位和分割全部依赖投影。水平投影用来找车牌的上下边框和字符行高,垂直投影用来找每个字符的中心和宽度。投影法的好处是不需要训练、可解释性强,坏处是对倾斜极其敏感——车牌歪三度,投影的峰谷就开始糊,所以链路里必须插一步倾斜校正,这一步在很多简化版课设里被省掉了,然后定位阶段就开始玄学。
2.2 文件与目录约定
原始交付是一个 docx 报告加一份脚本,脚本是流水账式的,从头到尾一个文件。样本库靠代码里imwrite自己往外写,路径是写死的绝对路径。我把它整理成能复现的结构大概是这样:
plate_recognition/ ├── main.m # 主流程,按环节顺序调用 ├── preprocess.m # 灰度化、背景估计、增强、二值化 ├── locate_plate.m # 边缘、形态学、连通域、宽高比筛选 ├── rectify_segment.m # 投影分析、倾斜校正、字符切割 ├── recognize.m # 与样本库匹配,输出字符 ├── samples/ # 字符样本库,按 0-9 A-Z 汉字 分目录 │ ├── 0/ 1/ ... 9/ │ ├── A/ B/ ... Z/ │ └── han/ # 汉字(省份简称) ├── images/ # 待测原图 └── output/ # 中间结果图、识别日志样本库这一步值得单独说。报告里原始做法是「切割出来的字符图,命名成 imageK.jpg 直接扔进同一个文件夹」,靠文件名顺序去猜它是什么字符。这在课堂演示里能跑,因为演示的图就是训练的那张。换成第二张图就完蛋——所以样本目录必须按字符分类,一个字符一个子目录,识别时遍历目录名当标签用。
2.3 环境准备与最小可运行脚本
Matlab 这边没有复杂依赖,Image Processing Toolbox 是必须的,bwlabel、strel、edge、imresize、fit都在里面。版本上要注意 2013b 之后imfeature已经被移除,原始报告里的Feastats = imfeature(L,'basic')在新版本上会直接报未定义函数,必须换成regionprops。这个坑后面第 5 章会展开。
最小可运行主流程长这样:
% main.m —— 主流程骨架,每一步都能单独打断点看图 clear; close all; clc; imgPath = fullfile(pwd, 'images', 'car01.jpg'); Scolor = imread(imgPath); % 读入彩色原图 if size(Scolor,3) == 3 Sgray = rgb2gray(Scolor); % 统一转灰度,避免后续维度判断 else Sgray = Scolor; end % 大图先缩小,控制结构元半径与车牌实际像素宽度的比例 if size(Sgray,2) > 800 Sgray = imresize(Sgray, 800/size(Sgray,2)); end Egray = preprocess(Sgray); % 背景相减增强后的灰度图 bw = binarize_plate(Egray); % 二值化 grd = edge(bw, 'canny'); % 边缘提取 % 形态学滤波:先闭后开,把字符连成块、把噪点抹掉 bg1 = imclose(grd, strel('rectangle',[5,19])); bg3 = imopen(bg1, strel('rectangle',[5,19])); bg2 = imopen(bg3, strel('rectangle',[19,1])); [plateImg, ok] = locate_plate(bg2, Sgray); % 定位,ok 标记是否找到 if ~ok warning('未定位到车牌区域,检查阈值和结构元尺寸'); return; end chars = rectify_segment(plateImg); % 校正 + 切割,返回 cell result = recognize(chars, fullfile(pwd,'samples')); fprintf('识别结果:%s\n', result);这段代码几个点:size(Scolor,3)==3的判断是防着有人直接拿灰度图测试导致后面对不上;imresize到固定宽度是我加的,原始代码没有,但这一步直接决定了后面strel('disk',13)的半径是不是合理;locate_plate返回ok而不是直接error,是为了批量跑图时一张失败不至于中断整批。
3. 预处理链路:灰度化、背景相减、二值化和 Canny 边缘
3.1 读图与灰度化
彩色转灰度这一步是零成本,但报告里给的理由不太准确——它说「彩色图像存储开销大、处理速度慢」,真实原因更简单:后面所有操作(阈值、边缘、投影)都是单通道运算,彩色图你没法定义「一个像素的灰度值」,也就没法做阈值分割。转灰度的公式rgb2gray用的是加权平均:
Scolor = imread('3.jpg'); % 待处理彩色图 Sgray = rgb2gray(Scolor); % 加权平均:0.2989R + 0.5870G + 0.1140B figure, imshow(Sgray), title('原始黑白图像');权重里 G 通道占比最高,这是按人眼亮度感知来的。对蓝底白字的车牌来说,这个加权有一个副作用:蓝色背景被压得比较暗,白色字符保持很亮,两者灰度差被拉开,对后面的阈值分割是有利的。如果你处理的是黄底黑字,同样的权重下黄色背景偏亮、黑字偏暗,对比依然够用,但针对蓝牌的阈值就不适用了,这是后面二值化那节要提的事。
3.2 用开运算估背景再相减
这一步是整个预处理里最有巧思的地方。常规做法是直接对灰度图做直方图均衡化或者imadjust拉对比度,但这套代码走的是「估计背景再减掉」的路子:
s = strel('disk', 13); % 圆盘结构元,半径 13 像素 Bgray = imopen(Sgray, s); % 开运算 = 先腐蚀后膨胀,抹掉比结构元小的亮块 Egray = imsubtract(Sgray, Bgray); % 原图减背景,突出局部亮区域 figure, imshow(Bgray), title('背景图像'); figure, imshow(Egray), title('增强黑白图像');开运算的物理含义是:把图像里所有比结构元小的亮目标都吃掉,只留下大面积的、结构元塞不进去的亮区。车牌上的字符笔画宽度通常十几个像素,圆盘半径 13 能覆盖掉,所以开运算之后字符被抹平,剩下的就是「这一片区域整体的亮度分布」,也就是背景。原图减去这个背景,相当于做了局部自适应的高通滤波——车灯、路面反光这类大范围亮度渐变被减掉了,字符这种局部高亮被保留下来。
半径 13 不是随便给的。它必须大于字符笔画宽度、小于车牌区域尺度。图缩到 800 像素宽时,蓝牌大概 200 到 300 像素宽,字符笔画 8 到 15 像素,13 是中间值。如果原图是 3000 像素宽的手机原片直接跑,笔画可能 40 像素,半径 13 完全盖不住,字符会被当成背景留在Bgray里,相减之后字符就没了,这就是典型的比例失配翻车。稳妥做法是像我上面主流程那样,先imresize到固定宽度再估背景。
提示:
imsubtract遇到负值会截断到 0,不产生环绕。如果你的减法是Sgray - Bgray用普通减号,uint8 会下溢成 255,图上会出现一片诡异的亮点。
3.3 三分法取阈值的二值化
im2bw需要一个 0 到 1 之间的归一化阈值,原始代码的取法是拿增强图的最大最小灰度做三等分,取靠上的那个分点:
fmax1 = double(max(max(Egray))); % 增强图全局最大灰度 fmin1 = double(min(min(Egray))); % 增强图全局最小灰度 level = (fmax1 - (fmax1 - fmin1)/3) / 255; % 三分法,偏亮的那条分界线 bw22 = im2bw(Egray, level); % 按 level 二值化 bw2 = double(bw22); % 转 double 方便后续运算 figure, imshow(bw2), title('图像二值化');把式子拆开看,fmax1 - fmin1是灰度动态范围,除以 3 得到区间高度的三分之一,从最大值往下退一个台阶,落在 2/3 处。取偏高的阈值意味着「只有足够亮的像素才判为白」,这对背景相减后的增强图是合理的——增强之后字符是少数高亮像素,背景接近 0,阈值定高一点能把残余噪声挡掉。
但这个取法的软肋非常明显:它只看全局最值,不看分布。如果图里有一块高光反光,fmax1会被拉到 255,阈值跟着抬到很高,整车牌可能全被判成黑;如果图整体偏暗,fmin1很低,动态范围被拉大,阈值又被压低,噪声全进来。所以实际调试时我一般会给一个兜底:三分法算出来的level之外再算一个graythresh(Egray)(Otsu),两个值差太多就以 Otsu 为准,或者直接跑两遍二值化看哪个定位成功。
3.4 Canny 边缘与形态学开闭运算
二值化拿到的是一个实心的亮块图,接下来要把它变成「只保留轮廓」的线图,用的是 Canny:
grd = edge(bw2, 'canny'); % Canny 算子提取边缘 figure, imshow(grd), title('图像边缘提取'); % 矩形结构元 [行 列] = [5 19],横向拉长,配合车牌扁长形状 bg1 = imclose(grd, strel('rectangle',[5,19])); % 闭运算:连断点、填小孔 bg3 = imopen(bg1, strel('rectangle',[5,19])); % 开运算:去细小噪点 bg2 = imopen(bg3, strel('rectangle',[19,1])); % 竖向开运算:切断上下方向的粘连strel('rectangle',[5,19])这个参数里,行 5、列 19,是一个横向扁长的矩形。为什么这么给?车牌的字符在水平方向排列密集、字符之间有间隙,字符内部笔画是竖向的。用横向 19 的闭运算,能把同一行里相邻字符的边缘连成一条,同时不会把上下不同的东西连起来;再用竖向 1 的开运算,把垂直方向上粘连的杂散边缘切断。
这段的调参血泪经验在于:结构元的尺寸必须和车牌在图像里实际占的像素尺寸挂钩。原图大、车牌小,结构元给大了会把整个车牌和旁边车灯连成一块;结构元给小了,字符之间的边缘连不起来,后面bwlabel会标出几十个碎片区域。我一般会先把图缩到车牌宽度 200 像素左右,这样 [5,19] 这组参数能直接复用,不用每换一张图就重调。
4. 车牌定位、倾斜校正与字符切割:从候选框到七个字符
4.1 bwlabel 连通域标记与区域特征
边缘经过形态学处理之后,图上是一堆连通的白色块,每个块可能是一个车牌、一个车灯、一段保险杠的反光。bwlabel的作用是给每个连通块编个号:
[L, num] = bwlabel(bg2, 8); % 8 邻域连通,返回标记矩阵和区域数 stats = regionprops(L, 'BoundingBox', 'Area', 'Centroid'); % 新版写法 Area = [stats.Area]; BoundingBox = [stats.BoundingBox]; % 每行是 [x y width height] RGB = label2rgb(L, 'spring', 'k', 'shuffle'); % 伪彩色显示,便于肉眼核对 figure, imshow(RGB), title('图像彩色标记');bwlabel的第二个参数 8 表示 8 邻域——上下左右加四个对角都算相连。用 4 邻域的话,斜向相连的笔画会被拆成两个区域,车牌常常因此被切成两半;用 8 邻域则会好一些,但也会让相邻但不相干的块粘在一起。这个参数没有绝对对错,取决于前面形态学处理得干不干净。
regionprops的BoundingBox返回的是[x y width height],x 是列、y 是行,顺序和矩阵索引(row, col)是反的,这是新手最容易写错的地方——按(y, x)去取子图,切出来的永远是转置的。
4.2 长宽比先验筛选车牌
拿到每个候选区域的宽高之后,用一个非常朴素但有效的先验:车牌是扁的。国内蓝牌标准尺寸 440mm×140mm,宽高比约 3.14;就算拍摄角度有透视畸变,实际图像里的比值也基本落在 2.2 到 4.5 之间。
candidateIdx = 0; bestScore = inf; for k = 1:num bb = stats(k).BoundingBox; w = bb(3); h = bb(4); if h < 10 || w < 40 % 过滤掉太小或太窄的碎片 continue; end ratio = w / h; area = stats(k).Area; % 打分:宽高比越接近 3.14、面积越大,越可能是车牌 score = abs(ratio - 3.14) - area / 1e5; if ratio > 2.0 && ratio < 5.0 && score < bestScore bestScore = score; candidateIdx = k; end end if candidateIdx == 0 plateImg = []; ok = false; return; end bb = stats(candidateIdx).BoundingBox; % 向外扩一点边,避免把边框切掉 padX = round(bb(3) * 0.05); padY = round(bb(4) * 0.10); x1 = max(1, round(bb(1)) - padX); y1 = max(1, round(bb(2)) - padY); x2 = min(size(grayImg,2), round(bb(1)+bb(3)) + padX); y2 = min(size(grayImg,1), round(bb(2)+bb(4)) + padY); plateImg = grayImg(y1:y2, x1:x2); ok = true;打分函数就是「宽高比偏差减去归一化面积」,两项量纲不同,面积那项除以 1e5 让它不至于压过比值项。这是很糙的启发式,但比报告里「谁更接近就选谁」的单指标筛选稳一点,因为车牌往往是面积最大的符合比例的区域,而车灯虽然也可能符合比例,面积通常对不上。外扩 5% 到 10% 的边是必须的,形态学操作会把车牌的细边框吃掉,不外扩切割出来的子图上下边缘会贴着字符。
4.3 水平投影峰谷分析与线性拟合求倾角
定位出来的车牌子图通常是斜的。倾斜有两个来源:拍摄角度带来的透视变形,以及车牌本身安装不平。三度以内的倾斜肉眼几乎看不出来,但对投影分析是致命的——字符的垂直投影峰会变宽、谷会变浅,切出来的字符边界就飘了。
sbw1 = im2bw(plateImg, graythresh(plateImg)); % 车牌子图重新二值化 % 逐行扫描,找每行第一个和最后一个白像素,作为车牌上下边缘的采样点 [rows, cols] = size(sbw1); xdata = []; ydata = []; for r = 1:rows c = find(sbw1(r,:), 1, 'first'); if ~isempty(c) xdata(end+1) = c; ydata(end+1) = r; end end fresult = fit(xdata', ydata', 'poly1'); % 一次多项式拟合,y = p1*x + p2 angle = atan(fresult.p1) * 180 / pi; % 斜率转角度,弧度换度 angle = max(min(angle, 15), -15); % 限幅 ±15 度,防止拟合跑飞 subcol = imrotate(plateImg, angle, 'bilinear', 'crop'); % 灰度图旋转 sbw = imrotate(sbw1, angle, 'bilinear', 'crop'); % 二值图同步旋转关键点在fit用的'poly1',也就是一阶多项式,本质是直线拟合;fresult.p1是斜率。atan把斜率转成弧度再乘180/pi转角度。imrotate的第三个参数'bilinear'是双线性插值,比最近邻平滑,但会让二值图出现中间灰度值,所以旋转完还得再二值化一次。第四个参数'crop'表示输出尺寸和原图一致,超出边界的部分被裁掉。
这里我强烈建议加限幅。如果车牌区域混进了别的亮块,拟合出来的直线可能斜 40 度,imrotate一转,车牌直接被转出画面,后面全是空数组。±15 度是个安全区间,真实场景里超过这个角度的车牌本身也很难识别了。
4.4 去上下边框、垂直投影切字符
校正之后开始做真正的字符切割。这一步分两小步:先切掉车牌的上下边框,拿到字符的准确行范围;再在行范围内做垂直投影,切出每个字符。
histrow = sum(sbw, 2); % 水平投影:每行白像素数量 rowTop = find(histrow > max(histrow)*0.3, 1, 'first'); % 字符区起始行 rowBot = find(histrow > max(histrow)*0.3, 1, 'last'); % 字符区结束行 sbw2 = sbw(rowTop:rowBot, :); % 裁掉上下边框后的车牌二值图 charH = rowBot - rowTop + 1; % 字符高度 histcol = sum(sbw2, 1); % 垂直投影:每列白像素数量 colThr = mean(histcol) * 0.5; % 投影阈值,低于此值认为是字符间隙 % 找出所有连续的白列区间 mask = histcol > colThr; d = diff([0 mask 0]); starts = find(d == 1); % 每个字符段的起始列 ends = find(d == -1) - 1; % 每个字符段的结束列histrow > max(histrow)*0.3这个 0.3 是经验系数:车牌上下边框是横向贯通的白条,投影值接近满行宽;字符行虽然也有很高的峰,但字符之间有间隙,投影值不会满。用最大值的 30% 当门限,既能找到字符的上下边界,又能把边框切掉。
垂直投影的阈值用均值的一半,含义是「这一列的白像素数低于平均的一半,就判定为字符间隙」。字符间隙在垂直方向通常只有几个像素宽,投影值接近 0,很容易被切出来。麻烦的地方在于汉字,像「京」「粤」这种结构复杂的字,内部笔画之间也会有投影低谷,按固定阈值会被切成两半。所以切完必须做一次字符段合并:相邻两段间距小于字符平均宽度的 30% 就合并。
4.5 归一化到 22×14 并落盘样本
切出来的字符图尺寸各不相同,要和模板比对必须先归一化。原始代码用的是imresize(SegBw, [22 14]),这个 22×14 的选择跟当时用的样本库尺寸绑定,字符的宽高比大致是 1:1.57,符合车牌字符的瘦长形状。
segList = {}; for k = 1:length(starts) cleft = max(1, starts(k) - 1); cright = min(size(sbw2,2), ends(k) + 1); seg = sbw2(:, cleft:cright); % 先切出原始尺寸字符 segNorm = imresize(seg, [22 14]); % 归一化到 22 行 14 列 segList{end+1} = segNorm; %#ok<SAGROW> end % 只有确认字符类别时才写样本库,别在生产识别里写 if isTraining label = 'A'; % 由人工指定 dirName = fullfile(sampleRoot, label); if ~exist(dirName, 'dir'), mkdir(dirName); end fname = fullfile(dirName, sprintf('s_%03d.jpg', numel(dir(dirName))-1)); imwrite(segNorm, fname, 'jpg'); end样本落盘这块原报告的做法有个隐患:识别流程里顺手就imwrite把切出来的图存进样本库,存的时候还没验证识别对不对,等于把错例当正样本喂回去了。训练和识别必须分开走两条路径,用isTraining开关控制。
注意:
imresize默认用双线性插值,对二值图会产生介于 0 和 1 之间的灰度值。后面做模板相减时这些灰度值会参与运算,严格来说应该im2bw再二值化一次。
5. 字符匹配识别与五类典型翻车排查
5.1 模板匹配的识别闭环
字符识别的逻辑非常简单粗暴:把待识别字符和样本库里每一个同类尺寸的图做差,算误差,取误差最小的那张图对应的标签作为结果。
function [label, score] = match_char(charImg, sampleRoot) label = '?'; score = inf; dirs = dir(sampleRoot); for i = 1:numel(dirs) if ~dirs(i).isdir || startsWith(dirs(i).name, '.'), continue; end curLabel = dirs(i).name; % 目录名就是标签 files = dir(fullfile(sampleRoot, curLabel, '*.jpg')); for j = 1:numel(files) tpl = imread(fullfile(sampleRoot, curLabel, files(j).name)); if ~isequal(size(tpl), size(charImg)) tpl = imresize(tpl, size(charImg)); % 尺寸对齐兜底 end d = double(charImg(:)) - double(tpl(:)); err = sum(abs(d)) / numel(d); % 平均绝对误差 if err < score score = err; label = curLabel; end end end end误差度量用的是平均绝对误差,除以像素总数是为了让不同尺寸的样本可以横向比较。也有人用欧氏距离sqrt(sum(d.^2)),对边缘错位的惩罚更重,实测在这套二值字符上差别不大。
这个方案的上限在哪?它本质上是像素级的最近邻,对字体、笔画粗细、轻微形变完全没有容忍度。报告里说得很实在:识别失败的那张图里,G 被认成 B、K 认成 A、0 认成 8,这三个错误全是「字形相近 + 样本单一」导致的。解决办法只有两个方向——扩样本(每个字符收十几种字体和拍摄条件下的图),或者换特征(不比对像素,比对投影直方图、网格特征这类统计量)。
5.2 神经网络方案的取舍
报告里提到了另一条路:用神经网络做识别,思路是先用样本训练四个子网络(汉字、字母、字母数字、数字),再用训练好的权值对切出来的字符分类。
在小规模字符集上,这套方法的实际收益和代价要算清楚:
| 维度 | 模板匹配 | BP 神经网络 |
|---|---|---|
| 样本量需求 | 每字符 1 到 5 张即可跑起来 | 每字符至少几十张,否则过拟合 |
| 训练耗时 | 无 | 分钟级到小时级,取决于网络规模 |
| 对字体差异的容忍 | 极差 | 较好 |
| 可解释性 | 高,能直接看误差 | 低,权值是个黑匣子 |
| 调试难度 | 低,改阈值改样本就行 | 高,不收敛时几乎无从下手 |
如果是课程设计、图个能演示,模板匹配足够。如果真要上一点工程强度,我的建议是不要一上来就上神经网络,先用「投影特征 + KNN」这种中间方案:每个字符提取垂直投影直方图当特征向量,用欧氏距离找最近邻。这个方案对字形差异的容忍度比像素比对高一个量级,样本需求又比神经网络低得多,调试时可解释性也好。
5.3 五条踩坑记录:现象、原因、解决
这套代码从报告搬到能跑,我踩过的坑按痛苦程度排一下。
现象一:运行到imfeature报「未定义函数或变量」。原因是imfeature在 MATLAB R2013b 就被移除了,接替它的是regionprops,但字段名和返回格式都变了——imfeature(L,'basic')返回的是结构体数组,字段叫Area、BoundingBox;regionprops(L,'Area','BoundingBox')返回的也是结构体数组,但需要显式列出属性名,而且默认还带一个Centroid。解决就是把Feastats = imfeature(L,'basic')换成stats = regionprops(L,'BoundingBox','Area'),取用时[stats.Area]数组化。如果代码里还用了Orientation这类属性,regionprops里也能拿到,但要显式写出来。
现象二:换一台机器跑,识别结果全空,样本目录找不到。原因是样本库路径写死成F:\MATLAB\work\sam\image1.jpg,换机器 F 盘根本不存在,imwrite静默失败或者imread报错被 try-catch 吞掉。解决是把路径全部改成fullfile(pwd, 'samples')这种相对路径,用mfilename('fullpath')拿到脚本所在目录再往上拼,并且在程序启动时用exist(sampleRoot,'dir')检查一次,不存在就mkdir并明确报错退出,别让它静默跑完输出个空字符串。
现象三:大分辨率原图上定位失败,二值图里车牌区域整块消失。原因是strel('disk',13)的半径远小于字符笔画宽度,开运算没把字符抹掉,imsubtract相减之后字符区域变成 0。现象很迷惑——预处理后的图看着「很干净」,其实是把有用信息全减没了。解决办法在预处理前统一缩放:把图缩到宽度 800 像素左右,让车牌宽度稳定在 200 到 300 像素区间,笔画宽度落到 10 像素上下,半径 13 才合理。如果不想缩放,那结构元半径得按车牌估计宽度 / 20动态算。
现象四:切割出来的字符数量不是 7,有时 5 个有时 9 个。两个原因。一是垂直投影阈值固定成均值的一半,遇到汉字这种笔画密集的字符,字内部被切成两段;遇到「1」这种窄字符,投影值太低直接被判成间隙跳过。二是原始代码里for k=findmax-1:findmax+5这种硬编码索引,findmax一旦偏移,切出来的就是连续 7 列而不是 7 个字符。解决办法是先按阈值切出所有候选段,再做合并(间距 < 平均字符宽度 30% 的合并)和过滤(宽度 < 平均宽度 40% 的丢弃),最后校验段数,不等于 7 就记录日志并把这一张扔进错例目录。
现象五:倾斜校正后车牌被转出画面,后续全报错。原因是fit拟合用的采样点混进了车牌以外的白像素,斜率算出来几十度,imrotate(...,'crop')一转,车牌整个旋出边界,输出全是黑的。解决办法是采集拟合点时只取车牌区域内行方向白像素数量在合理区间的行,剔除首尾噪声行;角度做 ±15 度限幅;旋转后检查有效像素占比,低于 5% 就判定校正失败直接返回原图不转。
6. 二次开发:把课程脚本改成能批量跑、能回捞错例的工具
单张图跑通只是起点。真正拿这套代码做二次开发,我建议按「批量入口 + 参数外置 + 错例回收」三件事改,改完你才能知道算法的真实准确率是多少,而不是靠挑一张好图自欺欺人。
第一步把流水账脚本拆成函数,参数收进一个 config 结构体,所有魔数(结构元尺寸、阈值系数、角度限幅)都从 config 读。这样换数据集时不用改代码,只改配置。
function cfg = default_config() cfg.resizeWidth = 800; % 统一缩放宽度,锁定笔画像素尺度 cfg.diskRadius = 13; % 背景估计的圆盘半径 cfg.bgRatio = 0.33; % 三分法阈值的位置系数 cfg.closeRect = [5 19]; % 闭运算结构元 cfg.openRect = [5 19]; % 开运算结构元 cfg.aspectRange = [2.0 5.0]; % 车牌宽高比筛选区间 cfg.angleLimit = 15; % 倾斜校正限幅,度 cfg.expectChars = 7; % 期望字符数 cfg.sampleRoot = fullfile(fileparts(mfilename('fullpath')), 'samples'); cfg.outputRoot = fullfile(fileparts(mfilename('fullpath')), 'output'); end第二步写批量入口,遍历images/下所有图,每张图 try-catch 包住,失败的不中断,把识别结果、耗时、是否命中期望字符数写进 CSV:
files = dir(fullfile('images', '*.jpg')); fid = fopen(fullfile('output', 'result.csv'), 'w'); fprintf(fid, 'file,plate,chars,ok,elapsed\n'); for i = 1:numel(files) t0 = tic; try [plate, n] = run_once(fullfile('images', files(i).name), cfg); catch ME plate = 'ERROR'; n = 0; fprintf('处理 %s 失败:%s\n', files(i).name, ME.message); end ok = (n == cfg.expectChars) && ~strcmp(plate, 'ERROR'); fprintf(fid, '%s,%s,%d,%d,%.2f\n', ... files(i).name, plate, n, ok, toc(t0)); end fclose(fid);第三步是错例回收,这是最有价值的一环。凡是字符数不对、或者跟人工标注不符的,把中间结果(增强图、二值图、定位框、切割出的每张字符图)全部存到output/fail/<文件名>/下。攒够二三十个错例,问题会自己浮出来——你会发现大部分失败集中在几类:夜间反光、车牌倾斜超过 15 度、字符被遮挡、样本库里缺某个汉字。
改造方向上,我列几个性价比从高到低的:
| 改造点 | 原做法 | 改法 | 预期收益 |
|---|---|---|---|
| 阈值选取 | 全局最大最小三分法 | Otsu 或局部自适应adaptthresh | 光照不均时二值化稳定 |
| 字符识别 | 像素级模板相减 | 垂直投影直方图特征 + KNN | 对字体和轻微形变更鲁棒 |
| 倾斜校正 | 全行采样做直线拟合 | 只取字符区行、加 RANSAC 去野点 | 大角度倾斜不再被带跑偏 |
| 样本库 | 单字符一张图 | 每字符 10 张以上不同字体/光照 | 相近字符混淆率明显下降 |
| 汉字处理 | 和字母数字共用一套 | 汉字单独一个模板库,加笔画数过滤 | 省份简称识别率提升 |
有一件事要提醒:先别急着动识别算法。我见过的绝大多数失败案例,根因在预处理和定位,不在识别。把中间结果图存下来逐张看,二值化糊了就把阈值治好,定位框偏了就把结构元治好,这些改完准确率往往能上一大截,比换神经网络省事得多。
最后一次调这套代码,是处理一批夜间地库拍的图,怎么调准确率都卡在 60% 上不去。后来我把每张图的Egray和bw2都存下来看,发现有一半图的二值化结果里车牌区域是一整块白——背景相减的时候字符已经被减掉了,后面所有环节都在处理一块纯白。问题不在识别端,在strel('disk',13)和那些根本没缩放过的大图。从那以后我定了条死规矩:任何图像处理流程,动手调参数之前必须先imshow一遍每一步的中间结果,把链路当透明的看,而不是当黑匣子。
希望帮到你。
本文还有配套的精品资源,点击获取