简介:一份基于OpenCV的均值漂移分割算法实现,用于抑制图像中的细小纹理并完成纹理分割,适合图像处理、计算机视觉方向的学习者和开发者参考。压缩包仅1个cpp源码文件,包体大小973B,轻量易读,代码结构清晰,可快速理解核心流程。源码演示了从灰度图像转换、带宽与窗口大小参数设置、cv::meanShift函数调用,到终止条件设定和分割结果可视化的完整链路,能够帮助读者掌握均值漂移算法通过迭代向密度峰值收敛的非参数聚类原理,以及纹理分割中的实际应用方式。由于算法效果对参数选择较为敏感,该示例也便于在此基础上调整参数并进行二次开发,进一步尝试预处理滤波或连通区域合并等优化手段。当前已有344人学习下载,适合用于OpenCV纹理分割、细小纹理消除相关学习和快速原型验证。
1. 纹理分割为什么先选MeanShift而不是边缘检测
拿到碳纤维编织布的面内照片,或者原木横截面的扫描图,第一反应通常是拉一个Canny看边缘,出来的却是断线:纹理本身就是伪边缘,真正决定分割的是哪片区域属于同一材质,而不是哪条线亮。MeanShift分割的思路反过来,先按颜色和位置把像素合并成团块,纹理在均值漂移过程里被磨平,只剩下材质边界。标题里的meanshift_segmentation.rar想表达的,就是让分割阶段先吃掉细纹理,再清掉残留下的碎块;这篇文章把这条链路从头走一遍,两个带宽参数、金字塔加速、碎块清理都会讲到。适合跑过基础OpenCV处理、想把分割做成可交付区域mask的人,也适合在缺陷检测和材质分级前做图元提取的从业者。
2. MeanShift分割的空间/颜色带宽如何吃掉纹理
2.1 MeanShift分割先看颜色分布,不假设纹理形状
均值漂移本质上是一个无参数密度聚类。每个像素被表示成五维向量 x=(s_x, s_y, R, G, B),在空间坐标和颜色共同张成的空间里,以半径 r 为窗口反复计算窗口内的加权平均值,再把窗口中心挪到平均值上,直到中心位移小于阈值。核函数取高斯核时,迭代位移 M(x)=Σx_i·K(x_i-x)/ΣK(x_i-x)-x,收敛点就是局部密度极大值所在的位置。
对纹理分割来说,最关键的一点是:均值漂移不区分“细节”和“边界”。纹理区的像素颜色上下起伏,但在密度空间里,高频抖动留下的只是低密度间隙,漂移过程直接从间隙里跨过去,把同一块纹理中的像素收到同一个密度峰。表现到图像上,就是颜色在起伏但平均色一致的区域被抹成一整片。这是Gabor、LBP、小波那套纹理分析做不到的:它们要先设计描述子、再训练分类器,参数多一套,还要面临描述子尺度选择。MeanShift只用两个半径就同时承担了特征提取和聚类两件事。
代价也很明确。当两种材质的颜色直方图混叠得厉害时,聚类边界会漂移,区域边界与真实材质边界对不齐。因此它适合纹理内部颜色起伏大、但材质之间平均色差明显的场景,比如编织类材料、木纹、喷砂面;不适合单纯靠几何凹凸来区分的表面。
2.2 pyrMeanShiftFiltering的三个参数与金字塔细节
OpenCV提供的pyrMeanShiftFiltering(sp, sr, maxLevel)是原始MeanShift的加速版本。它先对图像做maxLevel次降采样,在低分辨率上跑均值漂移,再把结果逐级放大回原图,用低分辨率上的聚类结果约束高分辨率迭代。这样做既把等效窗口尺寸拉大,又避免了在满分辨率下用超大半径反复扫描的昂贵开销。实际项目里基本可以只用这个接口,不需要自己维护重复迭代的循环。
参数语义需要分开理解:
- sp:空间窗口半径,单位是像素,决定“把多大尺度内的像素当作一个邻域”。它直接对应纹理周期。纹理颗粒直径在3~10像素时,sp取16~32;周期20像素以上要取到50以上。经验起步值是纹理周期的2~3倍,小于周期时一个纹理单元自己就能分成好几团。
- sr:颜色带宽,单位是颜色空间的欧氏距离,决定“颜色差别多少以内视为同一类”。sr越大,颜色差异被磨得越干净;sr太小,边界附近留一圈碎块。
- maxLevel:金字塔层数,范围0~5,常用1~2。maxLevel=1表示先降到1/2分辨率再升回,执行速度最快;maxLevel=3以上,薄边界和高频细节在回放大时容易黏连,不是越大越好。
- termcrit:迭代终止条件。OpenCV默认是EPS+COUNT,最大迭代5次,epsilon=1,常见场景保持默认即可。
2.3 纹理尺度到带宽参数的经验映射表
| 纹理类型 | 纹理周期/颗粒直径 | sp 起始值 | sr 起始值 | maxLevel |
|---|---|---|---|---|
| 细编织、喷砂面 | 3~10 px | 16~32 | 20~25 | 1~2 |
| 木纹、磨砂塑料 | 10~30 px | 40~80 | 15~20 | 2 |
| 粗糙石材、大颗粒 | 30 px 以上 | 80~160 | 25~40 | 2 |
| 相邻材质色差接近 | 不限 | 保持上表 | 降到10~15 | 1~2 |
这些数值只是起点。按纹理周期估算出sp后,把sr从10往40扫一遍,观察区域数的变化曲线,拐点对应的sr往往就是材质色差的分界。sp如果给得比表格大很多,分隔两种材质的颜色台阶会被平均成一条渐变带,边界不再是锐利轮廓。
2.4 最小调用:一段Python代码跑出平滑图
import cv2 img = cv2.imread("fabric.png") # 控制运行时间,先把图缩一半;纹理周期随之变小,sp按缩放后的像素算 small = cv2.resize(img, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA) seg = cv2.pyrMeanShiftFiltering( small, sp=24, # 纹理周期约4像素,放大两倍后取周期3倍 sr=24, # 材质间平均色差约30,取中值附近 maxLevel=2, termcrit=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 5, 1)) cv2.imwrite("seg.png", seg)逻辑说明:读图后先用INTER_AREA缩到一半,pyrMeanShiftFiltering在降采样再回放大两级之后,输出一张和small同尺寸的平滑图。sp=24意味着空间窗口直径约48像素,对周期4像素的纹理有足够余量抹平;sr=24把颜色差在24以内的像素吸进同一聚类。输出看起来像水彩画,纹理消失,但材质块之间的边界还留着。
3. 用pyrMeanShiftFiltering跑通纹理分割并生成区域标签
3.1 从平滑图到区域标签:颜色量化加连通域
pyrMeanShiftFiltering输出的是平滑后的彩色图,不是编号后的标签图。同一材质区域里各像素颜色并不完全相等,只是差异被压到很小。要得到可交付的区域mask,必须把“颜色近似相同且位置相连”的像素归成同一个编号。常见做法是按步长量化平滑图,让同一块内颜色落到同一个离散值上,再对量化结果做连通域分析。
颜色量化步长step取8~16比较合适。step太小,残留的微小色差还会把区域切开;step太大,材质边界被磨圆,邻接区域互相渗入。为什么不能用全局阈值替代连通域?因为区域数量未知,材质之间平均色差不同,固定阈值照顾不了全部区域;连通域能处理任意数量区域,只要区域内部颜色量化后一致、边界位置存在一个颜色台阶。台阶只要大于step,区域就在连通域算法中被正确分开。
import numpy as np step = 8 # 每通道量化步长,8对应32级 q = (seg.astype(np.int32) // step) * step # 三通道合成一个整数键,B在低位,R在高位,int32足够容纳2^24 key = q[:, :, 0].astype(np.int32) + q[:, :, 1] * 256 + q[:, :, 2] * 65536 num, labels, stats, centers = cv2.connectedComponentsWithStats( key, connectivity=8)合并键时把三通道压缩成单通道,避免把不同颜色算成同一个编号。connectedComponentsWithStats返回四个值:连通域数量num、标签图labels、统计表stats和质心centers。stats每行依次是x、y、宽度、高度、面积,背景行也是同样格式。后续的统计、可视化、面积滤波全部依赖这几个返回值的形状。
3.2 完整的纹理分割脚本与每个参数解释
把输入、分割、标签、统计串成一个独立脚本:
import cv2 import numpy as np img = cv2.imread("fabric.png") img = cv2.resize(img, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA) seg = cv2.pyrMeanShiftFiltering(img, sp=32, sr=25, maxLevel=2, termcrit=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 5, 1)) step = 8 q = (seg.astype(np.int32) // step) * step key = q[:, :, 0].astype(np.int32) + q[:, :, 1] * 256 + q[:, :, 2] * 65536 num, labels, stats, centers = cv2.connectedComponentsWithStats( key, connectivity=8) print("区域总数(不含背景):", num - 1) areas = stats[1:, cv2.CC_STAT_AREA] order = np.argsort(-areas) + 1 for i in order[:10]: print("label=", i, "area=", stats[i, cv2.CC_STAT_AREA], "x=", stats[i, cv2.CC_STAT_LEFT], "y=", stats[i, cv2.CC_STAT_TOP])逻辑说明:缩放后跑MeanShift,sp=32、sr=25抹掉细编织纹理,然后量化到8的倍数生成key,最后用连通域提取区域。输出顺序用负数argsort得到面积降序,+1是跳过背景下标0。CC_STAT_AREA、CC_STAT_LEFT这些枚举常量在OpenCV中可以直接引用。跑完能看到的典型结果是几十到几百个区域,其中最大的几个对应材质块,其余小碎块集中在真实边界两侧。
3.3 C++侧和环境配置的注意点
C++接口形态和Python很接近:
cv::Mat src = cv::imread("fabric.png"); cv::Mat seg; cv::pyrMeanShiftFiltering(src, seg, 32, 25, 2);输入的Mat必须是CV_8UC3,seg与src同尺寸同类型,sp、sr是double,maxLevel是int。C++里同样要先做cv::resize,金字塔回放大阶段的时间和像素数量成正比,减半分辨率能省下一大半耗时。环境上,PyCharm里装opencv-python,或者按anaconda prompt安装opencv教程的方式装好后,脚本就能直接跑;Windows上用VS搭配C++时,在附加依赖里链接opencv_world4xx.lib即可。pyrMeanShiftFiltering属于imgproc模块,不需要引入xfeatures2d这类扩展包。
4. 消除细小纹理:面积滤波与形态学开闭的配合顺序
4.1 细碎块来自哪里:边界混合色与欠合并
跑完第3章的脚本,labels里除了几个大区域,一定还有大量面积只有几像素到几十像素的小块。它们主要有三个来源。一是区域边界:sp窗口横跨两种材质时,窗口内平均色落在中间地带,迭代结束后在边界形成一整条混合色带,量化后切成很多细条。二是纹理周期大于sp的区域:颗粒太粗时均值漂移没有充分抹平,局部亮颗粒被单独归成一团。三是颜色量化边界:同一块边缘像素的色值正好卡在step台阶两侧,被划成两个连通域。
这三类碎块里,第一类标记着真实边界,不该去掉;第二、三类才是标题里说的细小纹理噪声。处理顺序要比“无脑开运算”更谨慎:先按面积把孤立小区域吸收到邻域,再做一次小幅形态学把边界修圆。面积滤波针对区域粒度,形态学针对区域形状,反过来做会把边界上那些细条先腐蚀出缺口。
4.2 对二值mask做开运算与闭运算的顺序
形态学处理对象应该是单个区域的mask,不是整张标签图。对整张图做开闭运算,标签间的邻接关系会被重排,区域编号错乱,事后再恢复很麻烦。对提取出来的目标mask,先开运算去掉细长凸起,再做闭运算补内部小洞,顺序不能反:先闭后开会把边界细丝先接回大区域,随后开运算又可能把它整体挪位。
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = (labels == target).astype(np.uint8) * 255 mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)用椭圆核而不是矩形核,是为了避免矩形核在45度方向制造伪卡角。kernel尺寸按纹理线宽给:细线型纹理用3x3或5x5,碎点型纹理用7x7。kernel超过纹理周期两倍后边界会被明显磨圆,7x7基本是上限;再往上就该回到第2章把sp调大,而不是靠形态学硬擦。
4.3 按面积吸收小区域:邻域众数填充法
面积滤波可以处理任意数量区域,且不需要预知哪个区域是大的。对每个面积小于min_area的连通域,先膨胀一圈取得邻域像素,统计邻域里出现最多的非自身标签,把整个小区域重贴成该标签。这比把小区域直接置0更有利:不会在材质块内部留下黑孔,后续做面积统计和轮廓提取都不会被空洞干扰。
def absorb_small_regions(labels, stats, min_area): out = np.copy(labels) kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) for i in range(1, stats.shape[0]): if stats[i, cv2.CC_STAT_AREA] >= min_area: continue mask = out == i if not mask.any(): continue mask_u8 = mask.astype(np.uint8) dilated = cv2.dilate(mask_u8, kernel).astype(bool) border = dilated & ~mask nbrs = out[border] nbrs = nbrs[nbrs != i] if nbrs.size == 0: out[mask] = 0 continue out[mask] = np.bincount(nbrs).argmax() return outnp.bincount(nbrs).argmax()取的是邻域中出现次数最多的标签,也就是占主导的那个区域。dilate用3x3核保证只外扩一圈,不会吞掉旁边的小区域边界。复杂度与区域数量线性相关,500x500的图跑几千个区域没有压力。min_area先看面积分布:把stats里的面积排序,取下四分位数的三分之一作为起点,人工确认两三次;常见经验值在20~200像素之间,具体取决于缩放后的图尺寸。
4.4 面积滤波和形态学开闭怎么串成一条链
推荐顺序是面积滤波在前、开闭运算在后,最后重新做一次连通域编号。第一步用接近纹理单元面积的min_area把孤立碎块并入大区域;第二步逐个目标区域做开闭,修整边界毛刺;第三步重新编号并重新统计面积,因为合并后标签编号已经出现空隙,直接再用connectedComponentsWithStats过一遍即可。
| 步骤 | 处理对象 | 典型参数 | 解决什么问题 |
|---|---|---|---|
| 面积滤波 | labels + stats | min_area=40~200 | 孤立碎块、颗粒噪声 |
| 开运算 | 单区域mask | 椭圆核5x5 | 边界细刺、伸出短枝 |
| 闭运算 | 同一mask | 椭圆核5x5 | 区域内部小洞、孔缝 |
| 重编号 | 合并后的labels | connectivity=8 | 恢复连续编号与面积表 |
这条链路跑完,肉眼检查点是边界是否还贴着材质真实边界。面积滤波只替换小标签,不改变大区域轮廓;开闭运算会小幅圆化边界。如果应用对边界位置要求很高,比如缺陷检测要比较分割轮廓和样本轮廓,开闭的kernel就不要超过3x3。
5. 用区域数与边界梯度验证纹理分割参数
5.1 三个能说明问题好坏的输出信号
参数调完,判断sp/sr组合好坏有两个定量信号和一个定性信号。定量信号一是区域总数随参数的变化曲线:固定sr扫sp时,区域总数通常先陡降然后进入平台,纹理被吸收得差不多时曲线变平,继续增大sp会让边界融合,区域数继续往下降。平台中点就是稳定工作点。信号二是边界处的平均梯度:用原图的Sobel幅值在分割边界像素上取平均,真实材质边界梯度高,纹理内部梯度中低。平均边界梯度升高说明贴合度好;如果反而下降,多半是sp对边界两侧颜色做了过度平均,边界台阶被吞掉了。
定性信号是可视化叠加图:把labels每个区域的轮廓画回原图,看被轮廓切开的区域里是否存在颜色台阶。如果轮廓切在颜色渐变连续的位置,说明这个参数在吞真实材质边界。人眼难判断时,边界梯度信号会先一步给出提示。
5.2 一套收敛快、可解释的调参顺序
固定顺序:先定sr,再扫sp,最后把maxLevel从2降到1看效果差多少。理由是sr决定材质色差的分类粒度,sp影响邻域尺度,两者有耦合但sr优先级更高。先固定sp=32,扫sr=10、15、20、25、30、40,观察区域数拐点;选定sr后,在拐点两侧各选一个sp做两两组合,选边界梯度更高的那组。maxLevel从2降到1后如果只有执行时间变化、区域数和边界梯度不变,说明金字塔没有压制结果,可以放心用2加速;如果边界梯度明显下降,就退回1。
进pyrMeanShiftFiltering之前,先对原图做一次双边滤波,再用较小sp跑分割,也是个常用技巧。双边滤波同样磨平细纹理、保持边界,等于把纹理周期预先压缩一档,能显著减少面积滤波的工作量。测试时拿五个参数组合都打印出区域数和边界梯度,选梯度最高且区域数在拐点前的那一组作为交付参数,比凭肉眼反复放大对比快得多。
本文还有配套的精品资源,点击获取