1. 为什么路牌检测要先做颜色聚类
前些天我在整理一个旧项目,正好翻到了用OpenCV+VS2015写的一个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.lib(Release版本)
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与HSV
RGB三个通道之间的相关性很高,某个像素亮一点或者暗一点,三个分量会同时变化,距离计算容易失真。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到255,a和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_32F:
cv::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里最需要拍板的事情。如果是单纯检测一块路牌,场景不是特别复杂,K=3就可以分成背景、路牌主色、文字或边缘三类;如果画面里同时出现天空、路面、树木、车,我会把K提到6到8,让背景颜色也得到充分细分,路牌反而更容易独立成簇。实验下来,在一般街景图片上,K=6是我个人比较常用的值,既不会把路牌颜色打散,也不会因为簇太多而过分切割目标。K值太小时,路牌颜色和背景色容易混在同一簇;K值太大时,同一块路牌会被拆成几块,后面连通域又得靠形态学缝合。
一个更聪明的方法是先跑一次小K值的聚类,看有没有聚类中心落在目标颜色范围附近,没有就增大K再跑。代码里可以用一个简单的for循环包住kmeans调用,从K=3试到K=8,一旦检测到某聚类中心满足红色或蓝色条件就跳出。这样就把K从一个需要人工猜测的参数变成了可以自动搜索的变量。
4.4 判断哪个簇是路牌颜色
聚类完成后,centers里每一行是一个聚类中心的Lab值。要判断它是不是红色或蓝色,最直观的思路是把聚类中心转回BGR或HSV,再用颜色范围卡一下。我自己的做法是这样的:
bool isRoadSignColor(const cv::Vec3f& centerLab) { cv::Mat tmp(1, 1, CV_32FC3); tmp.at<cv::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.at<cv::Vec3b>(0, 0); // 红色在HSV中跨越0度附近,典型范围是H<10或H>170,且S>60,V>60 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.at<int>(i, 0); cv::Vec3f center = centers.at<cv::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.at<int>(i, 0); if (std::find(targetLabels.begin(), targetLabels.end(), label) != targetLabels.end()) { mask.at<uchar>(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.at<int>(i, cv::CC_STAT_AREA); int left = stats.at<int>(i, cv::CC_STAT_LEFT); int top = stats.at<int>(i, cv::CC_STAT_TOP); int width = stats.at<int>(i, cv::CC_STAT_WIDTH); int height = stats.at<int>(i, cv::CC_STAT_HEIGHT); if (area < totalArea * 0.01) continue; if (width < 30 || height < 30) continue; double ratio = static_cast<double>(width) / static_cast<double>(height); if (ratio < 0.4 || ratio > 3.5) continue; double fillRate = static_cast<double>(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范围,或者增加一个接近目标色中心但允许一定距离偏移的阈值,而不是使用死板的上下边界。实际项目中,我会对同一幅图同时尝试K=4和K=6,选检测框面积更稳定的结果。
6.4 性能问题
这个程序不是实时算法。1080p图像有200多万像素,Kmeans每次迭代都要重新计算所有样本与K个中心的距离,即使OpenCV做了优化,也要几百毫秒到一秒左右。如果后续想提速,有几个方向:先缩小图像,比如把长边缩到600像素,聚类耗时能降到50毫秒以内;或者只对整张图做一个粗聚类,在候选区域内部再精聚类;或者把像素坐标也加入特征向量形成超像素聚类,虽然计算量更大,但分割边界会更贴近物体边缘。总的来说,这个Demo适合离线检测或对单张图片做处理,不适合直播视频流。
6.5 多路牌场景
如果画面里同时出现多个不同颜色的路牌,比如一个红色禁令标志和一个蓝色指示标志,需要把isRoadSignColor判断逻辑扩展到同时接受红色和蓝色,并且用两个掩膜分别处理。一个可选方案是循环K值,每跑一次Kmeans都收集所有符合颜色的簇,最后统一合并掩膜再做连通域。我实验过多目标场景后认为,K=8并对红色簇和蓝色簇分别生成掩膜,检出效果比单一K值好很多。
跑完这个Demo,我个人体会最深的一点是:Kmeans这种无监督方法在颜色分割任务里远比固定阈值好用,但需要你把它放到整个聚类-掩膜-形态学-连通域的流程里,而不是单独拿聚类结果当一个分割图。后续如果想让它更实用,可以把聚类结果作为候选区域生成器,再接入一个轻量级分类器判断到底是哪类路牌,这算是一个比较自然的扩展方向。