简介一份基于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。maxLevel1表示先降到1/2分辨率再升回执行速度最快maxLevel3以上薄边界和高频细节在回放大时容易黏连不是越大越好。termcrit迭代终止条件。OpenCV默认是EPSCOUNT最大迭代5次epsilon1常见场景保持默认即可。2.3 纹理尺度到带宽参数的经验映射表纹理类型纹理周期/颗粒直径sp 起始值sr 起始值maxLevel细编织、喷砂面3~10 px16~3220~251~2木纹、磨砂塑料10~30 px40~8015~202粗糙石材、大颗粒30 px 以上80~16025~402相邻材质色差接近不限保持上表降到10~151~2这些数值只是起点。按纹理周期估算出sp后把sr从10往40扫一遍观察区域数的变化曲线拐点对应的sr往往就是材质色差的分界。sp如果给得比表格大很多分隔两种材质的颜色台阶会被平均成一条渐变带边界不再是锐利轮廓。2.4 最小调用一段Python代码跑出平滑图import cv2 img cv2.imread(fabric.png) # 控制运行时间先把图缩一半纹理周期随之变小sp按缩放后的像素算 small cv2.resize(img, None, fx0.5, fy0.5, interpolationcv2.INTER_AREA) seg cv2.pyrMeanShiftFiltering( small, sp24, # 纹理周期约4像素放大两倍后取周期3倍 sr24, # 材质间平均色差约30取中值附近 maxLevel2, termcrit(cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_COUNT, 5, 1)) cv2.imwrite(seg.png, seg)逻辑说明读图后先用INTER_AREA缩到一半pyrMeanShiftFiltering在降采样再回放大两级之后输出一张和small同尺寸的平滑图。sp24意味着空间窗口直径约48像素对周期4像素的纹理有足够余量抹平sr24把颜色差在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, connectivity8)合并键时把三通道压缩成单通道避免把不同颜色算成同一个编号。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, fx0.5, fy0.5, interpolationcv2.INTER_AREA) seg cv2.pyrMeanShiftFiltering(img, sp32, sr25, maxLevel2, 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, connectivity8) 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])逻辑说明缩放后跑MeanShiftsp32、sr25抹掉细编织纹理然后量化到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_8UC3seg与src同尺寸同类型sp、sr是doublemaxLevel是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 statsmin_area40~200孤立碎块、颗粒噪声开运算单区域mask椭圆核5x5边界细刺、伸出短枝闭运算同一mask椭圆核5x5区域内部小洞、孔缝重编号合并后的labelsconnectivity8恢复连续编号与面积表这条链路跑完肉眼检查点是边界是否还贴着材质真实边界。面积滤波只替换小标签不改变大区域轮廓开闭运算会小幅圆化边界。如果应用对边界位置要求很高比如缺陷检测要比较分割轮廓和样本轮廓开闭的kernel就不要超过3x3。5. 用区域数与边界梯度验证纹理分割参数5.1 三个能说明问题好坏的输出信号参数调完判断sp/sr组合好坏有两个定量信号和一个定性信号。定量信号一是区域总数随参数的变化曲线固定sr扫sp时区域总数通常先陡降然后进入平台纹理被吸收得差不多时曲线变平继续增大sp会让边界融合区域数继续往下降。平台中点就是稳定工作点。信号二是边界处的平均梯度用原图的Sobel幅值在分割边界像素上取平均真实材质边界梯度高纹理内部梯度中低。平均边界梯度升高说明贴合度好如果反而下降多半是sp对边界两侧颜色做了过度平均边界台阶被吞掉了。定性信号是可视化叠加图把labels每个区域的轮廓画回原图看被轮廓切开的区域里是否存在颜色台阶。如果轮廓切在颜色渐变连续的位置说明这个参数在吞真实材质边界。人眼难判断时边界梯度信号会先一步给出提示。5.2 一套收敛快、可解释的调参顺序固定顺序先定sr再扫sp最后把maxLevel从2降到1看效果差多少。理由是sr决定材质色差的分类粒度sp影响邻域尺度两者有耦合但sr优先级更高。先固定sp32扫sr10、15、20、25、30、40观察区域数拐点选定sr后在拐点两侧各选一个sp做两两组合选边界梯度更高的那组。maxLevel从2降到1后如果只有执行时间变化、区域数和边界梯度不变说明金字塔没有压制结果可以放心用2加速如果边界梯度明显下降就退回1。进pyrMeanShiftFiltering之前先对原图做一次双边滤波再用较小sp跑分割也是个常用技巧。双边滤波同样磨平细纹理、保持边界等于把纹理周期预先压缩一档能显著减少面积滤波的工作量。测试时拿五个参数组合都打印出区域数和边界梯度选梯度最高且区域数在拐点前的那一组作为交付参数比凭肉眼反复放大对比快得多。本文还有配套的精品资源点击获取