news 2026/9/13 12:41:02

限速标志识别实战:HSV分割、形态学定位与数字识别的图像处理全流程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
限速标志识别实战:HSV分割、形态学定位与数字识别的图像处理全流程

简介:这套基于数字图像处理的公路交通限速标志分割与识别MATLAB程序,面向图像处理学习者、智能交通方向研究者及课设参赛者。程序自带图形界面,完整覆盖图像读入、预处理、限速标志分割、区域定位以及数字分离与识别等环节,对应自然场景中交通标志自动检测的典型任务。压缩包共含7个文件,其中4个.m源码文件给出各步骤的核心算法实现,2个.fig界面文件用于演示交互效果,1个.doc说明文档辅助梳理实验原理与流程;整个资源包仅2.65MB,下载和部署都很轻量。目前已有917人学习浏览。借助该资源,读者既能对照代码理解数字图像处理各模块的实际编程方法,也可将界面与算法稍作修改后迁移至其他标志识别场景,是完成课程设计、快速上手机器视觉实践的实用参考。

1. 限速标志识别:难的是先把红圈从野外捞出来

限速标志识别这类题,乍看是“识别”两个字最重,真正动手才发现卡在分割上。一张自然场景照片里,限速标志通常只占画面几个百分点,四周是树影、车流、广告牌和路面反光,直接拿模板去匹配,根本找不到目标在哪。所以整套流程应该拆成“预处理—红圈分割—区域定位—数字识别”,解压后的 Mat5_1.m、Mat5_2.m 和 Mat5_2rec.m 就是这个思路的逐步落地。本文按这条链路展开,中间会把每一步的阈值怎么定、坑在哪说清楚,适合正在做数字图像处理课程设计,或者想把识别程序从“能跑demo”推到“能扛现场照片”的读者。

2. HSV颜色空间与红圈提取:RGB阈值在户外为什么一碰就碎

2.1 先从限速标志的视觉特征定分割路线

国标限速标志的外观就是白底、红圈、黑字,红色圆环是全图最稳定的视觉先验。分割的第一步要解决“什么颜色算红色”。用 RGB 空间直接判断时,红色分量和光照强度耦合在一起,同一块红圈在正午强光和傍晚弱光下 R 通道数值能差出两三倍,单纯 R>200 这类规则在户外几乎不可用。很多人第一反应是上 U-Net 或最新的深度学习图像识别模型,但这类方案需要成百上千张带标注的训练图,课程设计的时间成本不值当。HSV 把色相、饱和度、亮度分开,红色在 H 通道里落在非常窄的区间内,和光照解耦,这是传统图像分割路线里性价比最高的一步。

2.2 HSV红圈掩膜的MATLAB实现与参数边界

MATLAB 里直接rgb2hsv转换,得到的 H、S、V 全是 double,范围 [0,1]。红色在色相环的两端,所以要同时保留 H 接近 0 和接近 1 的两段。下面的函数就是整个分割流程的第一块积木。

function mask = extractRedCircleMask(rgb) hsv = rgb2hsv(rgb); h = hsv(:, :, 1); s = hsv(:, :, 2); v = hsv(:, :, 3); % 红色跨色相环两端,两段分别取,再做并集 maskRedLow = (h < 0.05) & (s > 0.20) & (v > 0.15); maskRedHigh = (h > 0.95) & (s > 0.20) & (v > 0.15); mask = maskRedLow | maskRedHigh; % 3x3中值滤波去掉孤立噪点,kernel再大边缘会被磨掉 mask = medfilt2(mask, [3 3]); end

rgb2hsv的输出是 double,h < 0.05h > 0.95对应色相环上红色附近的两个尖端,这是红色分割里最容易漏参数。s > 0.20用来滤掉偏灰白的水泥路面,v > 0.15防止阴影里的暗灰被误认成红色。medfilt2的窗口如果开成 5×5,红圈边缘会被磨掉一圈,后面形态学处理时断裂口更宽,所以这里固定 3×3。

阈值不是拍脑袋定的,几个方向上的表现完全不同,整理成一张表方便对照现场调参:

参数经验取值失效表现
H< 0.05 或 > 0.95阈值放宽到 0.08 会把偏橙红的路面标线卷进来
S> 0.20阴天或逆光下红圈饱和度下降,0.20 以下直接漏检
V> 0.15画面整体偏暗时红圈变褐,低于阈值被当成背景

一旦掩膜上红圈断裂不完整,先别急着调 H,优先检查是否是整体亮度偏低。如果整张图暗,问题不在色相阈值,而在 V 通道的拉伸。

2.3 光照不理想时的预处理补偿

户外图像很少有光照均匀的。整图偏暗时,V 通道整体被压到 0.1 附近,上面那句v > 0.15会把目标整个丢掉。常见的做法是对 HSV 的 V 通道单独做imadjust,注意不要对 RGB 三个通道分别histeq,那样会产生严重的色彩偏移,红圈会变成品红色。

hsv = rgb2hsv(rgb); v = hsv(:, :, 3); vAdj = imadjust(v, [0.2 0.8], [0 1]); hsv(:, :, 3) = vAdj; rgbAdj = hsv2rgb(hsv);

imadjust里的[0.2 0.8]意思是把原图亮度在 0.2 到 0.8 这一段拉伸到 0 到 1,相当于提高整体对比度,暗部细节被抬起来。H 和 S 分量没有动,所以色相不受影响。还有一种更麻烦的情况:标志半边在树影里、半边在阳光下,V 通道上出现一条明显亮度梯度。此时要先用imtophat做顶帽变换,把大尺度亮度渐变抹平。结构元素用 disk,半径从min(size(gray))/30起步调节,太小会把数字笔画当背景抹掉,太大梯度补偿效果消失。

3. 形态学重建与圆形度筛选:从野外背景里定位限速标志区域

3.1 形态学处理链路:闭运算、孔洞填充、开运算

HSV 分割出来的掩膜不是完美的,红圈因为表面反光、边缘混色经常断成好几段。这时候直接bwlabel,一个标志会被拆成好几个碎片,后面的面积统计全部失真。标准处理链路是:先闭运算弥合断口,再imfill填充内部空白,最后开运算去掉零散误检。

seClose = strel('disk', 10); maskClosed = imclose(mask, seClose); maskFilled = imfill(maskClosed, 'holes'); maskClean = imopen(maskFilled, strel('disk', 5));

imclose先用膨胀把断口连上再用腐蚀还原尺寸,disk 半径取 10 在 800×600 的图像上约等于标志直径的 1/30。半径太小断口合不拢,太大容易把旁边的红色尾灯并进来。imfill把闭合区域内部填满,红圈变成一个实心圆盘,后面计算面积和圆度才稳定。imopen负责收拾残余的孤立小斑块,比如远处车尾灯在掩膜上留下的红色小点,半径 5 只动小目标,不影响已经成块的标志区域。

3.2 regionprops连通域统计与圆形度判据

掩膜干净之后进入区域定位。bwlabel给每个连通域编号,regionprops一次性把面积、周长、外接框、长短轴全部取出来。判断一个候选区域是不是限速标志,不能只看一个指标,至少同时过三关:面积占比、圆形度、长短轴比。

cc = bwlabel(maskClean); stats = regionprops(cc, 'Area', 'Perimeter', 'BoundingBox', ... 'MajorAxisLength', 'MinorAxisLength'); imgArea = numel(maskClean); candidates = []; for k = 1:numel(stats) areaRatio = stats(k).Area / imgArea; % 面积占比超出标志在画面中的合理范围,直接跳过 if areaRatio < 0.005 || areaRatio > 0.08 continue; end % circularity接近1说明接近正圆,透视压缩后会下降 circularity = 4 * pi * stats(k).Area / (stats(k).Perimeter^2); axisRatio = stats(k).MajorAxisLength / stats(k).MinorAxisLength; if circularity > 0.55 && axisRatio < 1.35 candidates = [candidates; stats(k)]; %#ok<AGROW> end end

三个阈值互相牵制,单独放宽任何一个都会把误检放进来。整理成参数表方便现场对照:

指标经验阈值失效表现
areaRatio0.005 ~ 0.08低于下限会有树叶阴影进来,高于上限会漏掉远处的小标志
circularity> 0.55正圆约 1,透视压缩后能低到 0.65,取 0.55 是为斜拍留的裕量
axisRatio< 1.35约等于 70° 斜视角的压扁程度,再松井盖和轮胎会进候选

只靠圆度判断时,圆形井盖、汽车轮胎、路灯罩都可能误检。加上面积占比后,这类误检通常能压到每张图 1~2 个。如果candidates里仍然多于一个,再叠加一条软约束:限速标志一般不在画面最上方的天空带,也很少出现在马路中央的近景区域。注意这是软约束,用if判断而不是直接丢弃,避免漏检。

3.3 候选区域冗余外扩与ROI裁剪

定位到候选区域后,不要直接拿BoundingBox坐标切图。外接框紧贴目标边缘时,数字区域到圆环内侧之间的留白会被切掉一部分,而且框边缘会把外部背景也带进来,后面数字二值化时干扰非常大。常见做法是外扩 20% 再裁剪。

bb = round(candidates(1).BoundingBox); expandX = round(bb(3) * 0.2); expandY = round(bb(4) * 0.2); x1 = max(bb(1) - expandX, 1); y1 = max(bb(2) - expandY, 1); x2 = min(bb(1) + bb(3) + expandX, size(rgb, 2)); y2 = min(bb(2) + bb(4) + expandY, size(rgb, 1)); roi = rgb(y1:y2, x1:x2, :);

外扩比例不是越大越好。扩太多会把旁边的树木和路面纹理大量带进来,数字分割阶段的处理负担成倍增加;20% 是我在 800×600 左右图像上的常用起始值,ROI 尺寸变化不大时可以固定使用。想确认定位结果是否正确,不依赖额外工具箱的画框方式是这样:

figure; imshow(rgb); hold on; rectangle('Position', [x1 y1 (x2-x1) (y2-y1)], ... 'EdgeColor', [1 1 0], 'LineWidth', 2);

rectangle是基础绘图函数,不需要 Computer Vision Toolbox 的insertShape。调试阶段每一张图都看一次掩膜和最终框的位置,比最后统一看识别率更能发现问题。

4. 数字分离与识别:连通域标注、投影切分与相关匹配

4.1 ROI灰度化与自适应二值化

ROI 切出来之后,限速标志内部是白底黑字,理论上大津法一个全局阈值就能分干净。实际照片里标志板很容易被阴影切成上下两半,亮区和暗区灰度差出几个量级,全局阈值会把阴影里的数字直接糊掉。稳妥做法是先做局部对比度增强,再用自适应阈值。

grayRoi = rgb2gray(roi); grayEq = adapthisteq(grayRoi, 'NumTiles', [8 8], 'ClipLimit', 0.02); bwRoi = imbinarize(grayEq, 'adaptive', 'Sensitivity', 0.5); % 数字是黑字,拿白底取反后得到黑色数字区域 digitMask = ~bwRoi; digitMask = imclearborder(digitMask);

adapthisteqNumTiles决定局部窗口划分,[8 8] 在 ROI 大约 300×300 时每个 tile 约 40×40 像素,足够覆盖单个数字的笔画范围。ClipLimit取 0.02 防止背景被过度增强变成噪点。imbinarize的 adaptive 模式对每个像素取自适应的邻域阈值,Sensitivity 越高越容易把暗的灰度背景也判成前景,0.5 在多数自然光照片下表现稳定。最后imclearborder抹掉贴在图像边缘的噪声块,它们通常是截断的圆环边而不是数字。

4.2 水平投影加垂直投影,把粘连数字劈开

数字切分最常见的坑是:两位数标志里,数字和红圈内侧的黑边在垂直方向上重叠,列投影不会在数字间隙处归零,导致切分失效。解法是先靠水平投影把 ROI 收窄到数字所在的中部水平带,把上下弧线切掉,再在这个水平带里做垂直投影找间隙。

% 水平投影,找数字集中的行范围 horzProj = sum(digitMask, 2); rows = find(horzProj > max(horzProj) * 0.1); top = max(rows(1) - round(numel(rows) * 0.1), 1); bot = min(rows(end) + round(numel(rows) * 0.1), size(digitMask, 1)); strip = digitMask(top:bot, :); % 垂直投影,间隙处投影值接近0,即为切分点 vertProj = sum(strip, 1); numDigits = 0; digitImgs = {}; inGap = true; for col = 1:size(strip, 2) if sum(strip(:, col)) > 0 && inGap startCol = col; inGap = false; elseif sum(strip(:, col)) == 0 && ~inGap endCol = col - 1; % 小于10像素的宽度段按噪点丢弃 if endCol - startCol + 1 >= 10 numDigits = numDigits + 1; digitImgs{numDigits} = strip(:, startCol:endCol); end inGap = true; end end

水平投影里max(horzProj) * 0.1是判断有效行的底线,低于这个值的行认为是背景。上下各扩展 10% 是为了把数字顶部和底部的笔画残余包住,避免切掉字身。垂直投影的循环是一个简单的状态机:遇到白色列进入字符,遇到全黑列结束字符。10 像素的宽度阈值过滤掉左右弧线残余形成的细碎列段,这个值在 ROI 300×300 左右时很稳定。

4.3 模板匹配与孔洞数校验

数字子图尺寸各不相同,识别前必须统一画布。直接imresize会把笔画长宽比拉变形,正确做法是等比缩放后补白边居中。模板库和待识别图必须经过完全相同的预处理流程,否则相关系数虚高或虚低,这一步最容易踩。

function n = recognizeDigit(d, templates) tgt = [40 60]; % 画布宽40、高60 scale = min(tgt(1) / size(d, 2), tgt(2) / size(d, 1)); % 等比缩放,避免数字笔画拉伸变形 resized = imresize(d, scale); canvas = false(tgt); x0 = floor((tgt(1) - size(resized, 2)) / 2) + 1; y0 = floor((tgt(2) - size(resized, 1)) / 2) + 1; canvas(y0:y0 + size(resized, 1) - 1, ... x0:x0 + size(resized, 2) - 1) = resized; % 逐个与0~9的模板算归一化相关系数 scores = zeros(10, 1); for k = 1:10 scores(k) = corr2(canvas, templates{k}); end [best, idx] = max(scores); % 相关系数太低说明切分有问题,拒绝识别而不是硬给结果 if best < 0.5 n = -1; else n = idx - 1; % templates{1}对应数字'0' end end

corr2是全图的归一化相关系数,和normxcorr2的区别在于后者是滑窗匹配,适合模板与图像之间位置不确定的场景。这里所有待识别数字都已经剪裁、缩放、居中到同一块画布,位置已经对齐,corr2更直接也更稳。

相关系数之外还要加一个几何校验。regionpropsEulerNumber和孔洞数满足:欧拉数 = 物体数 − 孔洞数。对切出来的单个字符,就是 1 减孔洞数。

数字孔洞数EulerNumber说明
010与 6、9 孔洞数相同,靠孔洞位置再细分
610孔在下方,与 0 的孔位不同
82-1唯一两个孔的数字
910孔在上方
1/2/3/5/701无孔

模板匹配结果如果和孔洞数矛盾,比如模板匹配给出 0 但欧拉数显示有 0 个孔,那大概率是前方切分切到了半个数字,这时候以欧拉数优先,把结果置为 -1 走拒绝分支。

5. 把工程做稳:GUI回显、批量回归与阈值标定

5.1 GUI回调里只放流程,不放算法

项目要求系统有界面,但界面回调函数里不应该堆算法。ideal 的结构是pipeline(rgb)返回掩膜、ROI 和识别结果,GUI 回调只负责调用和显示。图像数据用setappdata挂在 figure 上,避免回调之间频繁读写磁盘。

function btnRun_Callback(hObject, eventdata, handles) rgb = getappdata(handles.figure1, 'img'); [maskClean, roi, numStr] = pipeline(rgb); % 左侧显示原图掩膜,右侧显示识别结果 imshow(maskClean, 'Parent', handles.axesMask); imshow(roi, 'Parent', handles.axesResult); set(handles.txtResult, 'String', ['识别结果: 限速 ' numStr]); end

把整个处理链路拆成函数而不是一个大脚本,调试阶段可以单独跑某一步看中间输出。掩膜显示放在左边,ROI 显示放在右边,两个 axes 并排,比只显示最终数字更容易定位是分割的问题还是识别的问题。

5.2 批量回归:用CSV顶住回归

单张调好参数不代表所有图都能过。现场照片的光照、角度、标志新旧程度都不一样,所以处理完一批测试图后要自动收集结果,人工按图核对失败模式。

files = dir('test_set/*.jpg'); results = table(); for i = 1:numel(files) rgb = imread(fullfile(files(i).folder, files(i).name)); [~, ~, numStr] = pipeline(rgb); results = [results; {files(i).name, numStr}]; %#ok<AGROW> end writetable(results, 'result.csv');

识别率只统计数字完全正确的情形,框没框准但数字碰巧对了要单独标注。CSV 里加一列 ground truth,人工比对时可以快速筛出“切分失败”和“识别失败”两类问题,分别回到第 3 章和第 4 章的流程里改对应环节。

5.3 阈值标定:把参数收进结构体

H 阈值、S 阈值、形态学半径、Sensitivity 串在一起调参时,最忌讳在函数里一个一个改数字。我会把这些参数收进一个 struct 作为配置块,然后在脚本里用双层循环做网格搜索,用 3~5 张最难的照片做样本,肉眼检查掩膜和最终识别结果。

params.hRedRange = [0 0.05 0.95 1]; params.sMin = 0.20; params.vMin = 0.15; params.diskClose = 10; params.diskOpen = 5; params.minAreaRatio = 0.005; params.maxAreaRatio = 0.08;

搜索时不追求全局最优,目标是找一组在所有困难样本上都“不崩”的平衡值。定下来之后直接写进配置文件,别人拿到手只需要改一个 struct,不用翻函数。这样整个工程从实验脚本到 GUI 再到批量回归,参数口径始终是同一份。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 12:40:57

Agent Loop 何时该放弃 while 循环?状态机重构实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 12:40:16

QLC SSD无效编程原理与实战调优指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 12:40:08

Windows平台编译BitNet 1-bit LLM推理框架指南

1. 项目背景与核心价值在大型语言模型&#xff08;LLM&#xff09;领域&#xff0c;BitNet 1-bit LLM 的出现标志着模型压缩技术的重大突破。这种仅使用1.58位表示的模型架构&#xff0c;相比传统FP16精度的模型&#xff0c;能减少约16倍的内存占用和计算资源需求。微软开源的B…

作者头像 李华
网站建设 2026/9/13 12:38:02

Deepfake检测中的面部细节特征提取算法与工程实现

简介&#xff1a;针对深度伪造假脸视频中面部细节特征提取的本科毕业设计课题&#xff0c;本份资源以Java Spring Boot为后端框架&#xff0c;搭配MySQL数据库构建Web系统&#xff0c;围绕视频数据管理、特征提取算法落地和结果展示等环节提供完整可运行代码&#xff0c;是计算…

作者头像 李华
网站建设 2026/9/13 12:36:08

汽车电子嵌入式系统工程化落地:从ASIL-B设计到HIL测试全链路

1. 这不是芯片发布会&#xff0c;而是一套能真正落地的汽车电子嵌入式系统工程方案 “赛普拉斯携先进汽车电子嵌入式系统解决方案”——这句话乍看像一句标准的展会通稿&#xff0c;但如果你在整车厂ECU开发组干过三年以上&#xff0c;或者带过两个以上ADAS域控制器项目&#x…

作者头像 李华
网站建设 2026/9/13 12:33:48

Python 3.13.8 Windows版下载与安装指南

1. Python 3.13.8 Windows版下载全指南Python作为当下最流行的编程语言之一&#xff0c;其版本迭代总是备受开发者关注。2025年10月7日发布的Python 3.13.8版本在Windows平台上提供了多种下载选项&#xff0c;这对不同需求的开发者来说意味着更灵活的选择。本文将详细介绍所有官…

作者头像 李华