1. 为什么路牌检测要先做颜色聚类前些天我在整理一个旧项目正好翻到了用OpenCVVS2015写的一个Kmeans颜色聚类路牌检测程序。这个程序做的事情很简单给一张包含路牌的图片通过Kmeans颜色聚类找出图片中的路牌区域并用矩形框标出来。虽然现在深度学习检测器满地走但对于只想理解传统图像处理思路、或者做一个快速原型验证的人来说这个Demo依然很有学习价值。本文把整个实现思路、代码细节、调参踩坑从头到尾捋一遍适合刚接触OpenCV图像分割的读者也适合想了解Kmeans怎么落地到实际视觉任务的人。这里有一个核心选择为什么用Kmeans而不是直接用HSV颜色阈值分割我在做这个项目前也先试了HSV阈值结果在阴影、偏色、晴天逆光等场景下同一组阈值经常失效。而Kmeans的聚类过程不需要人为指定颜色范围它会根据图片本身的颜色分布自动把像素分成若干簇再从这些簇里选出目标颜色对应的簇鲁棒性明显好一截。1.1 整体处理流水线程序的处理链路并不复杂整体可以分为五步读取图像、颜色空间转换、Kmeans聚类、目标簇掩膜提取、连通域筛选。先说结论后面会逐步展开。读图之后先把BGR转成Lab颜色空间目的是让颜色距离更符合人的视觉感知然后对图像做一点高斯模糊把噪声平滑掉接着把每个像素当作一个样本丢给Kmeans聚类K值一般取3到8聚类完成后每个聚类中心都代表一种主色我们要做的就是从这些主色里挑出路牌颜色比如红色或蓝色并把对应的像素组成掩膜最后用形态学操作清理小噪点再通过连通域计算面积、宽高比等特征把真正像路牌的候选框画出来。这条流水线最关键的零件就是Kmeans那一环。它把“分割颜色”这件事从人工调阈值变成了“自动找颜色分布”本质上是用无监督学习去拟合图片的颜色分布换来的好处是适应性强坏处是每次聚类的初始中心不同结果可能有一点点随机性这个我在后面第6节会专门讲怎么处理。2. VS2015 OpenCV环境配置要点这个项目用的是VS2015和OpenCV 3.x系列。说实话这套组合放到现在不算新但如果你手头正好是旧电脑旧工程或者课程作业指定的环境就是VS2015那它会非常实用。OpenCV 3.4.x对VS2015的官方支持比较到位我这边用的是OpenCV 3.4.1配VS2015 Update 3整个过程没有遇到大问题。2.1 配置步骤回顾第一步是下载OpenCV源码包并解压到指定目录比如D:\opencv341然后打开VS2015新建一个空项目或Win32控制台应用。在项目属性里需要配置VC目录可执行目录、包含目录、库目录分别指向OpenCV对应的bin、include、lib文件夹。我这里的习惯是把OpenCV相关的路径单独写清楚包含目录D:\opencv341\opencv\build\include、D:\opencv341\opencv\build\include\opencv2库目录D:\opencv341\opencv\build\x64\vc14\lib链接器-输入-附加依赖项opencv_world341.libRelease版本Debug版本一般需要opencv_world341d.lib但很多OpenCV安装包没有带d后缀版本所以一个比较省事的方案是直接用Release模式运行。我早期在Debug下编译一直报“无法打开opencv_world341d.lib”后来才反应过来是预编译包里没有这份lib换到Release就没问题了。如果你非要调试那只能自己用CMake重新编译一版带Debug符号的库整个过程比较耗时不值得在路牌检测这种Demo上花这个功夫。2.2 运行环境里的三个坑配置完之后把OpenCV的bin目录加到系统PATH里否则运行时会提示找不到opencv_world341.dll。还有两个很常见的坑一个是解决方案平台要选x64而项目代码里所有路径也要匹配另一个是图像路径最好用绝对路径因为VS的工作目录默认在工程目录下使用相对路径经常读不到图。我都是直接把测试图片放到和main.cpp同目录下然后用完整路径加载。顺便说一句OpenCV 3.4之后的高版本可能会要求VS2017或VS2019所以如果你不想折腾版本兼容就在VS2015环境里使用OpenCV 3.4.x这是最稳妥的搭配。3. 预处理细节Lab颜色空间与高斯模糊Kmeans聚类需要把每个像素表示成特征向量而特征向量的选择会直接影响聚类结果。最初我直接用BGR三通道做聚类效果在简单背景下还行但一旦光线变化明显皮肤色、水泥色、树叶色这些相近颜色容易混在一起聚类边界很毛糙。后来把颜色空间换成Lab问题明显改善。3.1 Lab颜色空间对比RGB与HSVRGB三个通道之间的相关性很高某个像素亮一点或者暗一点三个分量会同时变化距离计算容易失真。HSV虽然把色调和亮度分开了但H通道在低饱和度区域会剧烈跳变比如灰色像素的H值根本不稳定。Lab里的L代表亮度a代表红绿分量b代表黄蓝分量其中a和b与人眼感知的颜色差异比较一致对Kmeans这种基于欧氏距离的算法特别友好。在实际项目中L通道仍然会受到光照影响所以我试验后发现只取a和b两个通道作为聚类特征效果比三通道更稳定。这样做可能会丢失亮度信息但对于路牌这种颜色特征非常强的目标亮度本来就是干扰项。3.2 高斯模糊的作用高斯模糊在Kmeans之前有两个作用一是平滑掉传感器噪声和图像压缩产生的伪影避免这些高频噪点被单独聚成一类二是让颜色过渡更均匀减少同一块路牌中出现过多细小簇的概率。核尺寸我用5x5σ设0由函数自动计算。如果你处理的图只有300x300左右3x3就够了如果图很大或者噪点很明显可以用7x7但别开太大否则路牌边缘会糊掉后面掩膜边缘不好修。3.3 预处理代码下面是这一小段预处理代码cv::Mat image cv::imread(test.png); if (image.empty()) { std::cerr image load failed std::endl; return -1; } cv::Mat lab; cv::cvtColor(image, lab, cv::COLOR_BGR2Lab); cv::Mat blurred; cv::GaussianBlur(lab, blurred, cv::Size(5, 5), 0);注意Lab的取值范围和归一化问题。OpenCV中Lab的L范围是0到255a和b在8位图里大致是0到255内部还会偏移128聚类时这些数值直接作为特征使用是可以的不需要额外归一化。但如果你把图像转成32F再处理则要注意OpenCV的Lab浮点表示和8位表示范围差异很大我建议直接用8位图参与聚类或者明确做一次除以255的归一化否则聚类中心会偏移。4. Kmeans聚类实现与参数详解Kmeans的原理一句话就能讲完给定K个聚类中心迭代地把每个样本分给最近的中心再重新计算中心直到中心不再显著移动。放到图像场景里样本就是像素的特征向量聚类中心就是图像中出现频率较高的几种颜色。这段代码用OpenCV自带的kmeans函数实现不需要自己手写迭代。4.1 样本矩阵构造要调用cv::kmeans第一步是把图像Mat转成一行一个样本的矩阵。假设图像宽W高H经过Lab转换后通道数为3先将Mat reshape成单行再用convertTo转成CV_32Fcv::Mat samples blurred.reshape(1, blurred.rows * blurred.cols); samples.convertTo(samples, CV_32F);reshape的第一个参数是通道数写1表示把每个像素拍平成一个行向量第二个参数是行数写总像素数WH。转换之后samples的尺寸是(WH) x 3每一行是Lab三个值。如果你决定只用ab通道可以在reshape前split出两个通道再用hconcat拼成两列我这里为了演示和稳定性默认三通道。4.2 调用cv::kmeans核心调用如下int K 4; cv::TermCriteria criteria(cv::TermCriteria::EPS cv::TermCriteria::COUNT, 10, 1.0); cv::Mat labels, centers; cv::kmeans(samples, K, labels, criteria, 3, cv::KMEANS_PP_CENTERS, centers);参数里K是需要事先指定的簇数criteria是停止条件第一个10代表最大迭代10轮第二个1.0代表聚类中心移动距离小于1.0像素就停止attempts表示使用不同随机初始中心跑几次最后取误差最小的结果flags选KMEANS_PP_CENTERS它使用Kmeans的初始化策略能让初始中心分布更均匀避免落入局部最优。centers是K x 3的矩阵每一行是一个聚类中心labels是一个N x 1的矩阵每个样本对应一个0到K-1的标签。4.3 K值到底选多少K值是Kmeans里最需要拍板的事情。如果是单纯检测一块路牌场景不是特别复杂K3就可以分成背景、路牌主色、文字或边缘三类如果画面里同时出现天空、路面、树木、车我会把K提到6到8让背景颜色也得到充分细分路牌反而更容易独立成簇。实验下来在一般街景图片上K6是我个人比较常用的值既不会把路牌颜色打散也不会因为簇太多而过分切割目标。K值太小时路牌颜色和背景色容易混在同一簇K值太大时同一块路牌会被拆成几块后面连通域又得靠形态学缝合。一个更聪明的方法是先跑一次小K值的聚类看有没有聚类中心落在目标颜色范围附近没有就增大K再跑。代码里可以用一个简单的for循环包住kmeans调用从K3试到K8一旦检测到某聚类中心满足红色或蓝色条件就跳出。这样就把K从一个需要人工猜测的参数变成了可以自动搜索的变量。4.4 判断哪个簇是路牌颜色聚类完成后centers里每一行是一个聚类中心的Lab值。要判断它是不是红色或蓝色最直观的思路是把聚类中心转回BGR或HSV再用颜色范围卡一下。我自己的做法是这样的bool isRoadSignColor(const cv::Vec3f centerLab) { cv::Mat tmp(1, 1, CV_32FC3); tmp.atcv::Vec3f(0, 0) centerLab; cv::Mat tmpBGR; cv::cvtColor(tmp, tmpBGR, cv::COLOR_Lab2BGR); cv::Mat tmpHSV; cv::cvtColor(tmpBGR, tmpHSV, cv::COLOR_BGR2HSV); cv::Vec3b hsv tmpHSV.atcv::Vec3b(0, 0); // 红色在HSV中跨越0度附近典型范围是H10或H170且S60V60 if ((hsv[0] 10 || hsv[0] 170) hsv[1] 60 hsv[2] 60) return true; // 蓝色典型范围 H 100~130 if (hsv[0] 100 hsv[0] 130 hsv[1] 60 hsv[2] 60) return true; return false; }需要注意浮点centerLab在转8位BGR的时候可能越界稳妥点先clip到[0,255]再转。这里把疑似目标的标签记录到vector里后面生成掩膜就只用这几个标签。4.5 聚类结果可视化为了调参时不至于瞎猜我习惯把聚类结果渲染出来看一眼。做法很简单把每个像素用它的聚类中心颜色替换。cv::Mat result(image.size(), CV_8UC3); for (int i 0; i labels.rows; i) { int label labels.atint(i, 0); cv::Vec3f center centers.atcv::Vec3f(label, 0); // 将center转回BGR并赋给result对应像素 }实际写的时候要先把centers转成CV_8UC3的Mat再通过label索引查表。这样渲染出来的图会让你一眼看出聚类边界是否合理颜色有没有被拆散比只看最终检测框有用得多。5. 从聚类结果到路牌区域掩膜、形态学与连通域筛选聚类只是给每个像素贴了标签接下来要处理的是标出哪些像素属于路牌。这一步的核心是把目标簇转成一张二值掩膜然后在这张掩膜上做形态学清洗和连通域筛选。5.1 生成目标颜色掩膜假设已经确定了目标标签列表targetLabels遍历所有像素把对应标签的像素置255其余置0即可cv::Mat mask cv::Mat::zeros(image.size(), CV_8UC1); for (int i 0; i labels.rows; i) { int label labels.atint(i, 0); if (std::find(targetLabels.begin(), targetLabels.end(), label) ! targetLabels.end()) { mask.atuchar(i) 255; } }如果希望加快速度可以先统计所有targetLabels再一次性生成掩膜。注意labels是W*H行顺序是从左到右、从上到下排列正好对应mask的每个像素所以这里索引要一致。5.2 形态学开闭运算刚生成的掩膜通常会有两类问题一类是路牌周围细小的高频噪点另一类是路牌内部因为反光或文字遮挡出现的空洞。开运算可以去掉孤立的小白点闭运算可以填补内部空洞。核我习惯用5x5矩形核cv::Mat kernel cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)); cv::morphologyEx(mask, mask, cv::MORPH_OPEN, kernel); cv::morphologyEx(mask, mask, cv::MORPH_CLOSE, kernel);开运算的顺序是先腐蚀后膨胀闭运算是先膨胀后腐蚀。如果路牌边缘碎得很厉害可以把开运算核增大到7x7但注意不要大到把路牌整个腐蚀掉。5.3 连通域筛选掩膜清洗完之后用connectedComponentsWithStats找出所有连通块再根据面积、宽高比、外接矩形大小这些条件过滤。路牌在画面里通常不会太碎太小或者形状特别长的区域基本可以认为是误检。cv::Mat labels, stats, centroids; int nComponents cv::connectedComponentsWithStats(mask, labels, stats, centroids); double totalArea image.cols * image.rows; for (int i 1; i nComponents; i) { int area stats.atint(i, cv::CC_STAT_AREA); int left stats.atint(i, cv::CC_STAT_LEFT); int top stats.atint(i, cv::CC_STAT_TOP); int width stats.atint(i, cv::CC_STAT_WIDTH); int height stats.atint(i, cv::CC_STAT_HEIGHT); if (area totalArea * 0.01) continue; if (width 30 || height 30) continue; double ratio static_castdouble(width) / static_castdouble(height); if (ratio 0.4 || ratio 3.5) continue; double fillRate static_castdouble(area) / (width * height); if (fillRate 0.25) continue; cv::rectangle(image, cv::Rect(left, top, width, height), cv::Scalar(0, 255, 0), 2); }这几个过滤条件里填充率是最好用的一个。路牌一般是个实心的矩形或圆形白字黑字虽然会产生镂空但整体填充率不会太低而树枝、栏杆这类细长物体的填充率往往很低直接排除。5.4 最终显示与保存过滤完之后把原图imshow出来或者imwrite保存到结果文件整个程序就算跑通了。我习惯把中间过程的mask也保存一份方便对照检查是哪一步出了问题。6. 实际调参经验与常见问题排查这部分是我跑了很多张图片之后总结出来的心得按踩坑频率排列。6.1 Kmeans结果不稳定怎么办Kmeans每次初始化中心不同理论上结果会有一点随机波动。如果检测框在两次运行之间跳来跳去先检查K是不是太小导致中心初始化对结果影响过大再把attempts调大比如改成5。OpenCV的kmeans本身没有提供随机种子参数但可以通过增大attempts来降低波动。我实测过把attempts从3调到5后绝大多数图片的结果就稳定下来了。6.2 复杂背景下的误检怎么压有一类典型误检是红色汽车、蓝色垃圾桶也被当成路牌。单纯靠颜色聚类必然挡不住这类问题因为聚类只看颜色不看形状。我在这个问题上加了两个后置条件一是外接矩形宽高比要接近路牌的常见比例二是可选地加一个轮廓矩形度检查也就是用findContours找到外轮廓后用approxPolyDP拟合成多边形计算轮廓面积与外接矩形面积之比如果太小就拒绝。圆形禁令标志虽然矩形度不高但填充率依然够高不会被误伤。更进一步的方案是先放大面积阈值再用颜色在HSV空间做二次校验排除暗色或低饱和度的物体。6.3 光照条件变化Kmeans本身对光照有一定适应能力但在极亮或极暗场景下还是会出问题。极亮时路牌颜色过曝红色变成粉白色极暗时红色变成暗棕色。我的处理技巧是如果只使用ab通道做聚类过曝粉白色和暗棕色在Lab空间会偏离正常路牌色很远二值化判定时很难命中这时候可以放宽HSV的S和V范围或者增加一个接近目标色中心但允许一定距离偏移的阈值而不是使用死板的上下边界。实际项目中我会对同一幅图同时尝试K4和K6选检测框面积更稳定的结果。6.4 性能问题这个程序不是实时算法。1080p图像有200多万像素Kmeans每次迭代都要重新计算所有样本与K个中心的距离即使OpenCV做了优化也要几百毫秒到一秒左右。如果后续想提速有几个方向先缩小图像比如把长边缩到600像素聚类耗时能降到50毫秒以内或者只对整张图做一个粗聚类在候选区域内部再精聚类或者把像素坐标也加入特征向量形成超像素聚类虽然计算量更大但分割边界会更贴近物体边缘。总的来说这个Demo适合离线检测或对单张图片做处理不适合直播视频流。6.5 多路牌场景如果画面里同时出现多个不同颜色的路牌比如一个红色禁令标志和一个蓝色指示标志需要把isRoadSignColor判断逻辑扩展到同时接受红色和蓝色并且用两个掩膜分别处理。一个可选方案是循环K值每跑一次Kmeans都收集所有符合颜色的簇最后统一合并掩膜再做连通域。我实验过多目标场景后认为K8并对红色簇和蓝色簇分别生成掩膜检出效果比单一K值好很多。跑完这个Demo我个人体会最深的一点是Kmeans这种无监督方法在颜色分割任务里远比固定阈值好用但需要你把它放到整个聚类-掩膜-形态学-连通域的流程里而不是单独拿聚类结果当一个分割图。后续如果想让它更实用可以把聚类结果作为候选区域生成器再接入一个轻量级分类器判断到底是哪类路牌这算是一个比较自然的扩展方向。