ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/9/13 12:41:15 拓冰建站 浏览量
限速标志识别实战: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 通道数值能差出两三倍单纯 R200 这类规则在户外几乎不可用。很多人第一反应是上 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]); endrgb2hsv的输出是 doubleh 0.05和h 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)]; %#okAGROW 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);adapthisteq的NumTiles决定局部窗口划分[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 endcorr2是全图的归一化相关系数和normxcorr2的区别在于后者是滑窗匹配适合模板与图像之间位置不确定的场景。这里所有待识别数字都已经剪裁、缩放、居中到同一块画布位置已经对齐corr2更直接也更稳。相关系数之外还要加一个几何校验。regionprops的EulerNumber和孔洞数满足欧拉数 物体数 − 孔洞数。对切出来的单个字符就是 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}]; %#okAGROW 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 再到批量回归参数口径始终是同一份。本文还有配套的精品资源点击获取