1. 项目概述:从一张图片到七个字符
车牌识别是计算机视觉领域一个非常经典且实用的课题,它连接着图像处理、模式识别和实际工程应用。我们平时在停车场、高速路口看到的自动识别系统,其核心流程通常都包含“车牌定位”、“字符分割”和“字符识别”这三个关键步骤。今天要聊的,就是其中承上启下的“字符分割”环节,具体来说,是如何用Matlab这个强大的工具,实现对国内常见的蓝色和绿色车牌的字符切割。
为什么是蓝色和绿色车牌?因为这是目前国内道路上最主要的两种车牌类型。蓝牌是普通小型汽车,绿牌是新能源车。它们的共同特点是底色单一(蓝或绿)、字符为白色,这种高对比度为我们进行图像分割提供了天然的便利。但即便如此,想把车牌上的7个字符(新能源车牌可能是8位)干净利落地切分开,也并非易事。光照不均、车牌污损、拍摄角度倾斜、边框干扰等因素,都会给分割带来挑战。
这个项目的目标很明确:给定一张已经定位并校正好的车牌区域图像,写一套Matlab算法,能稳定地把上面的每一个字符单独提取出来,为后续的OCR识别准备好“原料”。整个过程就像把一串紧密排列的珠子(字符)从绳子上(车牌背景)一颗颗解下来。下面,我就结合自己的实践,把从思路设计到代码实现,再到踩坑填坑的全过程拆解一遍。
2. 核心思路与方案设计:为何选择这条路径
面对车牌字符分割问题,有很多条路可以走。比如基于投影的方法、基于连通域分析的方法、基于模板匹配的方法,甚至现在直接用深度学习做端到端的检测。对于蓝色和绿色车牌这种规整场景,我选择了“图像预处理 -> 二值化 -> 形态学处理 -> 垂直投影分析 -> 字符区域定位与切割”这条经典路径。为什么这么选?
首先,经典方法透明、可控。每一步操作的结果都肉眼可见,出了什么问题很容易追溯到具体环节进行调整。这对于算法调试和教学理解都非常友好。其次,对于蓝、绿车牌这种高对比度、字符排列整齐的场景,经典方法的效率和效果已经足够好,没必要上更复杂的模型。最后,用Matlab实现这套流程有天然优势,其强大的图像处理工具箱(Image Processing Toolbox)和直观的矩阵操作,能让算法原型快速搭建起来。
整个方案的核心思想是利用字符与背景的灰度差异,将字符区域变成白色前景(值为1),背景变成黑色(值为0),然后分析白色像素在水平方向上的分布情况。字符所在的位置,白色像素会密集分布,形成波峰;字符之间的间隙,白色像素很少,形成波谷。找到这些波谷,也就找到了切割点。
2.1 针对蓝绿车牌的预处理考量
虽然都是单底色车牌,但蓝色和绿色的光谱特性不同,直接转灰度图的效果有差异。蓝色在灰度图中通常较暗,绿色则相对亮一些。如果使用默认的rgb2gray(加权平均法),可能无法最大化前景与背景的对比度。因此,预处理的第一步,往往是尝试从RGB通道中分离出对比度最高的那个分量。对于蓝牌,蓝色通道(B)的背景值很高,字符(白色)在三个通道的值都很高,所以在B通道上,字符与背景的对比度反而最差;而在红色通道(R)上,蓝色背景很暗,白色字符很亮,对比度最好。绿牌也是类似道理,在红色和蓝色通道上,绿色背景都较暗。因此,一个常见的技巧是:优先使用红色通道图像作为后续处理的输入,这通常能获得比直接灰度化更好的初始效果。
2.2 二值化策略的选择
得到灰度图像后,需要将其转化为黑白二值图。二值化的关键在于阈值的选取。全局阈值(如imbinarize使用Otsu方法)简单快捷,但对于光照不均或底色略有渐变的车牌效果可能不稳定。自适应阈值(如imbinarize的‘adaptive’选项)能更好地处理局部光照变化,但可能会引入更多噪声。我的经验是,对于拍摄质量较好的车牌图片,全局Otsu阈值法完全够用,且速度快。如果图像质量较差,可以尝试自适应阈值,但需要仔细调整邻域大小和常数参数。
注意:二值化后的目标是什么?是让字符区域为白色(1),背景为黑色(0)。但有时由于原图字符颜色或光照问题,二值化结果可能反了(字符黑,背景白)。这时务必记得使用
imcomplement函数进行反转,确保后续投影分析的对象是字符像素。
3. 详细实现步骤拆解与代码解析
接下来,我们一步步走通整个流程。假设我们读入的车牌图像变量名为plate_img,它是一个已经裁剪好的RGB图像。
3.1 图像预处理与增强
第一步是提取红色通道并增强对比度。
% 1. 提取红色通道 red_channel = plate_img(:, :, 1); % 2. 对比度拉伸,增强前景背景差异 % 使用imadjust自动拉伸强度范围,也可以手动指定范围[low_in high_in] red_enhanced = imadjust(red_channel); % 3. 中值滤波,去除细小噪声点(如灰尘、图像压缩噪点) red_filtered = medfilt2(red_enhanced, [3, 3]);这里选择medfilt2进行中值滤波是因为它能有效去除“椒盐噪声”同时较好地保留边缘,而车牌字符的边缘正是我们需要保护的关键信息。滤波器的窗口大小[3,3]是一个常用起点,如果图像噪点较大,可以适当增大到[5,5]。
3.2 稳健的二值化处理
对滤波后的图像进行二值化。
% 4. 使用Otsu方法计算全局阈值并进行二值化 level = graythresh(red_filtered); % 计算全局阈值 bw = imbinarize(red_filtered, level); % 二值化 % 5. 检查二值化结果,确保字符为白色(1) % 计算图像中心一小块区域的像素均值,如果背景是白色则均值>0.5 center_region = bw(round(end/4):round(3*end/4), round(end/4):round(3*end/4)); if mean(center_region(:)) > 0.5 % 背景为白,字符为黑,需要反转 bw = imcomplement(bw); end这一步的检查机制很重要,能自动处理少数反色的情况,提高算法的鲁棒性。
3.3 形态学处理:连接字符断点与去除干扰
二值化后的图像,字符笔画可能因为污损或光照出现断裂,车牌边框、螺丝钉等非字符区域可能被误识别为前景。我们需要用形态学操作来“修补”和“清洁”。
% 6. 形态学闭运算,连接字符内部可能的断点 se_close = strel('rectangle', [2, 1]); % 创建一个2行1列的矩形结构元素 bw_closed = imclose(bw, se_close); % 7. 形态学开运算,去除细小孤立的噪声点(如边框上的点) se_open = strel('square', 2); % 创建一个2x2的正方形结构元素 bw_cleaned = imopen(bw_closed, se_open);- 闭运算(先膨胀后腐蚀):用
[2,1]的竖长条结构元素,主要目的是连接字符在垂直方向上可能出现的细小断裂(比如数字“1”的笔画不连续),而对水平方向影响小,避免将两个字符误连。 - 开运算(先腐蚀后膨胀):用小的方形结构元素,目的是消除那些比字符小得多的孤立白点,这些通常是噪声。
实操心得:结构元素的大小和形状是调参关键。
[2,1]的矩形是一个针对中文字符和数字笔画的常用起点。如果车牌图像分辨率很高,可能需要适当增大。务必通过imshow在每一步后查看结果,确保字符笔画连通良好,且没有明显误连。
3.4 垂直投影分析与切割点定位
这是分割的核心。我们计算二值图像每一列上白色像素的个数,得到一个投影直方图。
% 8. 计算垂直投影 vertical_projection = sum(bw_cleaned, 1); % 对每一列求和 % 9. 平滑投影曲线,减少毛刺干扰 window_size = 5; vertical_smooth = movmean(vertical_projection, window_size); % 10. 寻找波谷(切割候选点) % 波谷即投影值局部最小的点。这里用一个简单的方法:投影值低于平均值的点可能是间隙。 mean_val = mean(vertical_smooth); valley_candidates = find(vertical_smooth < mean_val * 0.5); % 阈值设为平均值的一半,可根据情况调整 % 11. 合并过于接近的候选点,并确定最终切割点 min_char_width = 10; % 预估最小字符宽度(像素),用于合并过近间隙 final_valleys = []; if ~isempty(valley_candidates) final_valleys(1) = valley_candidates(1); for i = 2:length(valley_candidates) if valley_candidates(i) - final_valleys(end) > min_char_width final_valleys = [final_valleys, valley_candidates(i)]; end end end平滑操作是为了避免因单个像素列的噪声产生虚假波谷。寻找波谷的策略有很多,比如找局部最小值点,或者像上面一样设置一个阈值。这里使用动态阈值(与平均值相关)比固定阈值适应性更强。
3.5 字符区域提取与后处理
找到切割点后,我们需要根据这些点把字符框出来。但切割点只是间隙位置,字符的左右边界还需要确定。
% 12. 确定字符左右边界 % 假设切割点数组final_valleys包含了字符之间的间隙位置。 % 那么第一个字符的左边界可能是图像起点,右边界是第一个波谷。 % 最后一个字符的右边界是图像终点,左边界是最后一个波谷。 % 中间字符的左右边界是相邻的两个波谷。 char_bboxes = {}; % 用于存储每个字符的边界框[x, y, width, height] img_height = size(bw_cleaned, 1); all_valleys = [1, final_valleys, size(bw_cleaned, 2)]; % 在首尾添加边界 for i = 1:length(all_valleys)-1 left = all_valleys(i); right = all_valleys(i+1); width = right - left; % 如果宽度太窄,可能是干扰或残留边框,跳过 if width < min_char_width continue; end % 在垂直方向上,截取有字符的部分,去除上下多余空白 column_segment = bw_cleaned(:, left:right); row_proj = sum(column_segment, 2); % 行投影 row_idx = find(row_proj > 0); % 找到有像素的行 if isempty(row_idx) continue; end top = min(row_idx); bottom = max(row_idx); height = bottom - top + 1; % 存储边界框信息 char_bboxes{end+1} = [left, top, width, height]; end % 13. 根据边界框提取字符图像 char_images = {}; for i = 1:length(char_bboxes) bbox = char_bboxes{i}; % 从原始灰度图或二值图上提取,保留更多信息供后续识别 char_img = bw_cleaned(bbox(2):bbox(2)+bbox(4)-1, bbox(1):bbox(1)+bbox(3)-1); % 可选:统一字符图像大小(归一化),便于后续识别 target_size = [40, 20]; % 目标高度和宽度 char_img_resized = imresize(char_img, target_size); char_images{i} = char_img_resized; end这一步有几个关键点:
- 宽度过滤:用
min_char_width过滤掉因边框残留或噪声产生的过窄区域。 - 垂直裁剪:对每个候选字符区域进行行投影,只保留有像素的行,这样可以去除字符上下方多余的空白区域,使字符居中。
- 归一化:将切割出的字符图像缩放到统一尺寸(如40x20),这是很多传统字符识别算法(如模板匹配、神经网络)的常见预处理要求。
4. 难点突破与优化策略实录
在实际测试中,直接套用上述流程可能会遇到各种问题。下面分享几个我遇到过的典型难题及解决方案。
4.1 车牌边框与螺丝钉干扰
问题:二值化后,车牌的白色边框和固定螺丝也可能被识别为白色前景,干扰垂直投影,导致在字符两侧产生错误的波峰。
解决方案:在形态学处理前或后,增加去除边框的步骤。
% 方法A:利用先验知识,车牌字符通常位于图像中部,上下边框可以裁剪掉一定比例 [height, width] = size(bw_cleaned); crop_ratio = 0.1; % 裁剪掉上下各10% crop_height_start = round(height * crop_ratio); crop_height_end = round(height * (1 - crop_ratio)); bw_no_border = bw_cleaned(crop_height_start:crop_height_end, :); % 方法B:投影辅助去除。计算水平投影,找到投影值持续很高的区域(可能是边框),将其置黑。 horizontal_proj = sum(bw_cleaned, 2); border_threshold = max(horizontal_proj) * 0.8; % 阈值 border_rows = horizontal_proj > border_threshold; bw_cleaned(border_rows, :) = 0; % 将这些行全部设为背景通常,方法A简单有效,适合边框较规则的情况。方法B更自适应,但需要调整阈值。
4.2 字符粘连问题
问题:特别是像“京”、“苏”这样的汉字,或者数字“8”,其内部可能存在闭合区域,经过形态学闭运算后,可能导致两个字符(如“8”和“A”)因为距离太近而被错误地连接成一个连通域。
解决方案:
- 调整形态学参数:减小闭运算结构元素的宽度,避免过度连接。
- 采用更精细的切割策略:当投影法切出一个宽度明显大于平均字符宽度的区域时,怀疑是粘连字符。可以在这个区域内,再次使用垂直投影寻找内部的波谷进行二次分割。
- 连通域分析辅助:使用
bwlabel和regionprops函数标记并分析每个白色连通域。如果一个连通域的宽度远大于高度,且其宽高比异常,则可能是粘连字符,可以尝试在其质心位置进行强制分割。
% 检测并处理粘连字符示例 stats = regionprops(bw_cleaned, 'BoundingBox', 'Area'); avg_char_width = mean([stats.BoundingBox(3)]); % 平均宽度 for i = 1:length(stats) if stats(i).BoundingBox(3) > avg_char_width * 1.8 % 宽度超过平均1.8倍 % 疑似粘连,在该连通域内部进行二次投影分割 % ... (具体代码略) end end4.3 光照不均导致二值化失败
问题:在强光或阴影下,车牌图像部分区域过亮或过暗,全局阈值二值化会导致字符部分缺失或背景误识别。
解决方案:
- 使用自适应二值化:Matlab的
imbinarize函数支持自适应阈值。
调整bw_adaptive = imbinarize(red_filtered, 'adaptive', 'Sensitivity', 0.6);‘Sensitivity’参数(0到1之间),值越大,越多的像素被归为前景。需要根据图像质量反复试验。 - 预处理增强:在二值化前,使用
imtophat和imbothat进行顶帽和底帽变换,可以校正不均匀光照。se = strel('disk', 15); % 结构元素半径要大于字符大小 background = imopen(red_filtered, se); % 估计背景 corrected = red_filtered - background; % 减去背景 % 然后再对corrected进行二值化 - 尝试其他颜色空间:如果RGB通道效果都不好,可以转换到HSV或YCrCb颜色空间,在亮度分量(Y或V)或色度分量上进行处理,有时对光照变化更鲁棒。
5. 完整代码整合与测试要点
将上述所有步骤整合成一个函数,并加入一些异常处理,使其更健壮。
function [char_images, char_bboxes] = segmentLicensePlate(plate_img) % SEGMENTLICENSEPLATE 分割蓝色或绿色车牌字符 % 输入:plate_img - RGB格式的车牌区域图像 % 输出:char_images - 单元格数组,每个元素是一个二值字符图像 % char_bboxes - 单元格数组,每个元素是字符在原图中的边界框[x,y,w,h] % 参数预设(可根据实际情况调整) min_char_width_px = 15; target_char_size = [40, 20]; % [高度, 宽度] % 1. 预处理 red_channel = plate_img(:, :, 1); red_enhanced = imadjust(red_channel); red_filtered = medfilt2(red_enhanced, [3, 3]); % 2. 二值化与矫正 level = graythresh(red_filtered); bw = imbinarize(red_filtered, level); % 自动判断是否需要反转 center_region = bw(round(end/4):round(3*end/4), round(end/4):round(3*end/4)); if mean(center_region(:)) > 0.5 bw = imcomplement(bw); end % 3. 形态学处理 se_close = strel('rectangle', [2, 1]); bw = imclose(bw, se_close); se_open = strel('square', 2); bw = imopen(bw, se_open); % 4. 去除上下边框(简单裁剪法) [h, w] = size(bw); crop_h = round(h * 0.1); if crop_h > 0 bw = bw(crop_h:end-crop_h, :); [h, ~] = size(bw); % 更新高度 end % 5. 垂直投影与平滑 vertical_proj = sum(bw, 1); smooth_proj = movmean(vertical_proj, 5); % 6. 寻找切割点(波谷) mean_proj = mean(smooth_proj); valley_idx = find(smooth_proj < mean_proj * 0.4); % 阈值系数可调 % 7. 合并邻近波谷,确定最终切割间隙 if isempty(valley_idx) % 如果没有找到明显波谷,可能字符间距极小或粘连严重 % 可以尝试按预估字符数等分(作为保底策略,效果可能不佳) estimated_char_num = 7; valley_idx = round(linspace(1, w, estimated_char_num+1)); valley_idx = valley_idx(2:end-1); else final_valleys = valley_idx(1); for i = 2:length(valley_idx) if valley_idx(i) - final_valleys(end) > min_char_width_px final_valleys = [final_valleys, valley_idx(i)]; end end valley_idx = final_valleys; end % 8. 生成所有边界(图像左、右边界 + 切割点) all_bounds = [1, valley_idx, w]; % 9. 提取字符区域 char_images = {}; char_bboxes = {}; for i = 1:length(all_bounds)-1 left = all_bounds(i); right = all_bounds(i+1); width = right - left; if width < min_char_width_px continue; end % 垂直方向裁剪 col_seg = bw(:, left:right); row_proj = sum(col_seg, 2); nonzero_rows = find(row_proj > 0); if isempty(nonzero_rows) continue; end top = nonzero_rows(1); bottom = nonzero_rows(end); height = bottom - top + 1; % 存储边界框(注意坐标是相对于裁剪后的bw图像) % 如果需要相对于原plate_img的坐标,需要加上crop_h的偏移 bbox = [left, top + crop_h, width, height]; char_bboxes{end+1} = bbox; % 提取并归一化字符图像 char_img = bw(top:bottom, left:right); char_img_resized = imresize(char_img, target_char_size); char_images{end+1} = char_img_resized; end % 10. 后处理:如果分割出的字符数量异常(太多或太少),可能是分割失败 % 这里可以添加一些启发式规则,比如车牌字符数通常是7或8个 % 如果数量偏差太大,可以返回空或尝试其他分割参数(本例略) end测试要点:
- 构建测试集:收集几十张不同光照、角度、清晰度的蓝牌和绿牌图片,手动裁剪出车牌区域。
- 可视化调试:在代码关键节点(如二值化后、形态学后、投影曲线、最终切割结果)加入
imshow和plot语句,直观观察每一步的效果。 - 评估指标:人工检查分割出的字符图像是否正确、完整。可以计算字符分割的准确率(正确分割的字符数 / 总字符数)。
- 参数调优:重点关注
min_char_width_px、投影平滑窗口大小、波谷检测阈值系数、形态学结构元素尺寸这几个参数。针对测试集中效果差的图片,分析原因并调整对应参数。
6. 常见问题排查与经验技巧
在实际运行中,你可能会遇到以下问题,这里提供排查思路:
问题1:分割出的字符数量不对,多了或少了。
- 可能原因1:边框干扰。检查二值化图像,看上下或左右是否有白色边框残留。加强预处理中的边框去除步骤。
- 可能原因2:字符粘连或断裂。检查形态学处理步骤。如果字符断了,尝试增大闭运算结构元素的高度;如果不同字符连在一起,尝试减小其宽度或先进行开运算去除细小连接。
- 可能原因3:波谷检测阈值不合适。绘制平滑后的垂直投影曲线,观察字符间隙处的波谷是否明显低于阈值线。调整
mean_proj * 0.4中的系数(0.4),这个值越小,对波谷的要求越严格,找到的切割点越少;值越大,则更敏感,可能找到更多切割点(包括噪声产生的假波谷)。
问题2:切割位置不准,切到了字符身上或包含多余背景。
- 可能原因1:投影曲线不平滑,毛刺多。增大
movmean的平滑窗口大小。 - 可能原因2:字符区域垂直方向裁剪不准。检查行投影
row_proj的计算和nonzero_rows的查找逻辑,确保能准确找到字符的顶行和底行。 - 解决方案:在确定左右边界后,可以不仅仅用行投影的非零点,而是设定一个阈值(比如行投影值大于最大行投影的1/5),这样能更精确地界定字符上下边界,避免包含笔画上方或下方的零星噪声点。
问题3:对于某些深蓝色或亮绿色车牌,红色通道对比度不佳。
- 解决方案:实现一个自动选择最佳通道的机制。分别计算RGB三个通道的对比度(例如,用标准差来衡量),选择对比度最高的通道进行后续处理。
% 计算各通道标准差作为对比度粗略估计 contrast_r = std2(plate_img(:,:,1)); contrast_g = std2(plate_img(:,:,2)); contrast_b = std2(plate_img(:,:,3)); [~, best_channel] = max([contrast_r, contrast_g, contrast_b]); gray_img = plate_img(:,:,best_channel);
问题4:算法速度慢,处理一张图要好几秒。
- 优化点1:调整图像大小。如果输入图像分辨率很高(如超过200像素宽),可以先用
imresize缩放到一个固定宽度(如100像素),再进行所有处理。这能极大减少计算量,且对分割精度影响不大。 - 优化点2:向量化操作。Matlab中避免使用循环,尤其是对像素的操作。本算法中的投影计算
sum(bw,1)和sum(column_segment,2)都是向量化操作,效率很高。检查代码中是否还有不必要的循环。 - 优化点3:预编译。将核心算法部分封装成函数,并考虑使用Matlab Coder工具将其编译为MEX文件,可以显著提升运行速度。
最后的经验之谈:车牌字符分割没有“一招鲜,吃遍天”的万能参数。上面给出的所有参数(形态学结构元素大小、投影平滑窗口、波谷阈值系数等)都需要在你的具体数据集上进行微调。最好的方法是准备一个具有代表性的测试集,编写一个简单的评估脚本,批量运行并统计分割准确率,然后有方向地调整参数。这个过程虽然繁琐,但却是算法工程化必经的一步。记住,鲁棒性往往来自于对大量 corner cases(边缘情况)的处理和经验积累。