资讯动态

k-means图像分割从原理到实战:聚类算法详解与调参攻略

发布时间:2026/10/20 6:04:30 来源:尧图企业网站定制
做计算机视觉的朋友应该都有个体会图像分割这个方向看起来门槛很高搜索引擎一搜全是U-Net、DeepLab、Mask R-CNN这类深度学习模型动辄需要标注数据、GPU训练。但在深度学习真正普及之前k-means这类经典聚类算法一直是图像分割的入门标配。即使放在今天当你想快速给一批图片做初步分割、或者想给某个区域快速抠个轮廓k-means仍然是性价比极高的选择。这篇我来详细拆解基于k-means的图像分割到底是怎么一回事从原理到代码到调参经验一次性讲透适合刚入门计算机视觉、还没碰深度学习的读者也适合所有想用轻量级算法快速解决分割问题的从业者。1. 为什么图像分割会想到k-means一个被低估的入门起点1.1 图像分割到底在解决什么问题图像分割本质上是给每个像素打标签。一张图片由几十万甚至几百万个像素组成分割就是把颜色、纹理相近的像素归为一类把不同的目标区域区分开。比如一张风景照里有天空、山、草地分割的结果就是天空一坨、山一坨、草地一坨每坨内部像素的属性高度一致坨与坨之间有明确的边界。这个需求和聚类问题的表述惊人地相似给定一堆数据点让机器自动把它们分成若干簇簇内相似、簇间相异。所以当人们把图像像素当作数据点、把像素特征当作坐标时图像分割就直接转化成了一个聚类任务——这就是k-means能用于图像分割最根本的逻辑也是它容易理解的关键。1.2 和阈值分割、深度学习分割的真实差距很多教材会把图像分割按技术路线分成三类。第一类是阈值分割固定某个灰度或颜色阈值把像素划分成前景背景。最大的问题是只能处理两分类且对光照、阴影特别敏感一张图上不同区域的亮度不一致时效果立刻崩坏。第二类就是基于聚类的分割k-means是其中代表。它可以处理多分类不依赖固定阈值而是自适应地寻找簇中心对光照变化有一定容忍度因为它本质上是在“颜色分布的稠密区域”找代表色。第三类才是深度学习方法效果好但成本高。需要标注数据、训练时间小数据集上容易过拟合。对一个只有几十张图、想快速出个分割效果的需求而言上深度学习属于杀鸡用牛刀。1.3 本篇内容范围和适用边界k-means图像分割适合以下几类典型场景颜色差异明显的图片分割如遥感地物分类的粗分、简单背景下的目标提取、图像压缩前的颜色量化、以及作为复杂分割流程的预处理步骤。它不适合纹理复杂、目标与背景颜色高度相近、或者需要精细到每个物体实例的场景——这是它算法的物理极限心里有数就好。我会按“原理拆解 → 代码实现 → 参数调优 → 踩坑与解决方法”四步走每一步都结合我自己的实操体验讲源码可直接复制运行。2. k-means原理不玄乎聚类是怎么变成“分割”的2.1 把像素当成特征空间里的点理解k-means图像分割最关键的思维转换就是把图像“矢量化”来看。一张宽为W、高为H的彩色图像有W×H个像素。如果用RGB表示每个像素有三个数值红、绿、蓝分量那么每个像素就可以看作三维空间中的一个点坐标为(R, G, B)。整张图就是三维空间里的W×H个点。如果这幅图像有蓝天、白云、绿树三种区域那么这些点在RGB空间里会自然地聚集在三团区域附近蓝天的蓝色分量高白云的RGB整体都很高绿树的绿色分量占主导。颜色相似的点在空间中距离近颜色不同的点距离远。k-means要做的就是在这些点中找出k个“代表色”的位置然后把每个像素划分到离它最近的代表色上。这个过程就是图像分割。灰度图的道理也一样每个像素只有一个灰度值是一维特征如果再加上像素的(x, y)坐标就变成三维特征能生成带有空间连续性的分割效果。后面代码部分我会两种都演示一下。2.2 k-means迭代的两步循环和收敛逻辑k-means的核心算法流程可以用一个两步循环概括记忆起来就两句话分配、更新。第一步分配随机初始化k个聚类中心后计算每个像素点到每个聚类中心的距离通常是欧氏距离把这个像素归到距离最近的那个中心所属的簇。这一步结束后所有像素都有了一个暂时的标签。第二步更新对每个簇计算该簇内所有像素点的均值用这个均值作为新的聚类中心。注意这里是在特征空间里求均值不是图像空间。然后开始分配和更新的交替迭代。每次迭代后聚类中心会朝“簇内点的重心”方向移动整体目标函数所有像素到其所属中心距离的平方和简称SSE会不断下降。当簇中心不再发生明显变化或者迭代次数到达上限算法就收敛了。整个收敛过程有一个很直观的类比把k个中心看作是几个推销员他们要各自负责一片区域的客户收敛的过程就是推销员们不断调整自己的驻地直到每个驻地就是自己负责区域内所有客户的地理重心。2.3 初始化方式决定了结果的上限标准k-means对初始聚类中心非常敏感。如果初始中心选得不好很容易收敛到局部最优比如两个中心落在同一个大颜色簇内而另一个真实存在的颜色簇却无人认领。解决办法中最常用的是k-means初始化策略第一个中心随机选择之后每个新中心的选择概率与它到已有中心的距离成正比这样保证初始中心尽可能地分散。scikit-learn里的KMeans类默认就用k-means这也是我推荐直接用它的原因之一——手写纯随机初始化的k-means十次有八次效果不稳定。为了更稳妥还可以设置n_init参数让算法用多个随机初始点跑多次最终返回SSE最小的那个结果。默认n_init10实测下来效果已经相当稳定。3. 实战代码走一遍从加载图像到分割可视化3.1 环境准备和依赖说明开始动手之前先把环境准备好。用到的核心库有三个。第一个是numpy负责数组操作图像在Python里本质上就是numpy数组。第二个是opencv-python负责图像的读取、缩放、颜色空间转换和结果保存。第三个是scikit-learn提供KMeans聚类实现。三个都是Python生态里的标准工具直接pip安装即可。pip install numpy opencv-python scikit-learn matplotlib我建议同时装matplotlib方便在Notebook里直接画对比图。文件路径我习惯于把所有图片放在images目录下输出结果放在output目录下代码里统一用相对路径这样项目结构清楚也不容易出错。3.2 灰度图分割最简单的起点先来一个最简单的例子对一张灰度图做二分类k2。这里的逻辑是把每个像素的灰度值当作一维特征。import cv2 import numpy as np from sklearn.cluster import KMeans # 读取灰度图 img cv2.imread(images/sample.jpg, cv2.IMREAD_GRAYSCALE) h, w img.shape # 把二维灰度图展平成一维像素数组 pixels img.reshape(-1, 1).astype(np.float32) # 执行k-means聚类2个类别 kmeans KMeans(n_clusters2, random_state42) labels kmeans.fit_predict(pixels) # 用聚类中心值替换每个像素并还原成图像形状 centers kmeans.cluster_centers_.astype(np.uint8) segmented centers[labels].reshape(h, w) # 保存结果 cv2.imwrite(output/seg_gray_k2.jpg, segmented)这段代码的巧妙之处在于labels数组本来就长度和像素数相同按(高度, 宽度)还原后就是一张分割图。而用聚类中心值替代原像素值是很有用的技巧——它不是简单地把像素标成类别号而是把原图“重新着色”成k个代表色视觉效果上像是海报风格化。想看每个类别的蒙版图把labels重新reshape、像素值为0和255保存即可。3.3 RGB图分割彩色图像的标准做法灰度图只是开胃菜实际项目里彩色图才是主角。彩色图的处理逻辑与灰度图几乎一样只不过每个像素是三维的RGB特征。import cv2 import numpy as np from sklearn.cluster import KMeans def kmeans_image_segmentation(image_path, k, output_path): # 读取图像并转为RGBOpenCV默认是BGR转换保持后续可视化时颜色正常 img_rgb cv2.cvtColor(cv2.imread(image_path), cv2.COLOR_BGR2RGB) h, w, c img_rgb.shape # 像素矩阵h*w行 × 3列 pixels img_rgb.reshape(-1, 3).astype(np.float32) # 聚类 kmeans KMeans(n_clustersk, random_state42, n_init10) labels kmeans.fit_predict(pixels) centers kmeans.cluster_centers_.astype(np.uint8) # 用中心色重建图像 segmented centers[labels].reshape(h, w, c) # 保存 result_bgr cv2.cvtColor(segmented, cv2.COLOR_RGB2BGR) cv2.imwrite(output_path, result_bgr) # 同时返回labels和center便于后续分析 return labels.reshape(h, w), centers labels, centers kmeans_image_segmentation(images/flower.jpg, 3, output/flower_k3.jpg)跑完这段代码后你会得到一张看起来“颜色被压扁”的图像整个图只有k种颜色每种颜色就是原图某个区域的代表色。如果k3的效果不理想调整k到4、5、6反复对比直到目标和背景被清晰分离。3.4 加入坐标特征空间连续性的魔力上面的RGB分割有一个常见问题聚类只认颜色不管空间位置。如果图像里两个完全不挨着的区域颜色恰好接近它们会被归到同一类导致分割结果中夹着很多零碎噪点。解决办法很简单在特征里加上像素的(x, y)坐标。这个技巧在很多教材里都只是一笔带过但在实际项目中是效果差异最大的一个环节。def kmeans_segmentation_with_xy(image_path, k, xy_weight1.0, output_pathNone): img_rgb cv2.cvtColor(cv2.imread(image_path), cv2.COLOR_BGR2RGB) h, w, c img_rgb.shape # 构造坐标网格 x_map, y_map np.meshgrid(np.arange(w), np.arange(h)) pixels img_rgb.reshape(-1, 3).astype(np.float32) # 归一化坐标到[0,1]区间避免坐标数值量纲压过颜色 x_norm (x_map.reshape(-1) / w).astype(np.float32) y_norm (y_map.reshape(-1) / h).astype(np.float32) # 拼接特征R,G,B, x*w_weight, y*w_weight features np.column_stack([ pixels, x_norm * xy_weight, y_norm * xy_weight ]) kmeans KMeans(n_clustersk, random_state42) labels kmeans.fit_predict(features) centers kmeans.cluster_centers_[:, :3].astype(np.uint8) segmented centers[labels].reshape(h, w, c) if output_path: result_bgr cv2.cvtColor(segmented, cv2.COLOR_RGB2BGR) cv2.imwrite(output_path, result_bgr) return labels.reshape(h, w)xy_weight参数控制空间位置的重要性。设为0就是纯RGB分割设得越大分割越倾向于按照“空间连续区域”来划分结果有点接近超像素的效果。我的经验是对自然图像xy_weight在0.5到2之间比较合适。具体取值要看图像内容如果目标颜色和背景差别很大空间权重可以小一点如果图像里有很多颜色接近、靠位置区分的区域就调大这个权重。调参过程我建议每次只变一个量否则你很难知道到底是K变了还是权重变了导致结果变化。4. 参数选择的实践经验K值、特征空间与效果优化4.1 K值怎么选才算合理k-means分割最折磨人的就是K值。K设小了目标被吞并设大了背景被碎割。这个问题没有标准答案但有三个层次的实践经验。第一层是肘部法则。画出不同K值对应的SSE曲线曲线拐弯的那个点就是推荐的K值。SSE下降速度由快到慢的转折处就是拐点。这个方法理论上很成熟但我在实际项目里发现图像分割场景下的SSE曲线往往拐得不明显经常是平缓下降加个微小凸起很难精确判断。第二层是轮廓系数。计算每个像素轮廓系数的平均值取值在-1到1之间越接近1说明聚类越合理。这个方法比肘部法更实用一些。但轮廓系数计算量大几十万像素要跑很久建议在小比例采样上计算。第三层是我自己最常用的方法——视觉对比法。直接跑K2到K8把所有结果并列摆出来肉眼对比。图像分割是个视觉任务最终判断标准就是看起来干不干净、边界是否清晰。这个方法看似原始但非常有效一直被低估。对一张具体图片靠谱的K值往往就是某几个区间比如自然风景照3到5医学细胞图2到4多目标工业分拣图可能要到6以上。4.2 标准化一个常被忽略的关键细节这一节要说的坑很多人到工作几年才想起来。RGB三个通道的数值范围都在0到255量纲相同理论上可以直接参与距离计算。但如果特征中加入坐标位置坐标值比如1200像素宽就会远超RGB数值量纲距离计算时坐标几乎主导了相似度判断颜色差异被完全淹没。解决方法是特征标准化。最常用的两种做法最小最大标准化让所有特征映射到[0,1]区间零均值标准化让所有特征均值0、方差1。用sklearn的StandardScaler可以一键完成。from sklearn.preprocessing import StandardScaler scaler StandardScaler() features_scaled scaler.fit_transform(features) # features 是拼接好的特征矩阵 kmeans KMeans(n_clustersk, random_state42) labels kmeans.fit_predict(features_scaled)标准化后聚类中心的数值含义不再直观但从分割效果来看这个步骤几乎是必备的。另一个变体是不同维度设置不同权重颜色通道权重高一点位置通道权重低一点这在需要“颜色相似优先”的场景中特别有用。4.3 色彩空间选择为什么建议试试LabRGB色彩空间最大的问题是它的欧氏距离与人眼感知的颜色差异不完全一致。举个例子深红和浅红的RGB距离可能比红色和绿色的RGB距离还要大但在人眼中红色和绿色的区别显然更大。这个感知误差会直接传导到k-means的距离度量中。实践中更好的选择是Lab色彩空间。Lab把亮度和色彩解耦L通道表示亮度a和b通道表示颜色对立维度且它的设计目标就是让欧氏距离近似于人眼感知差异。在Lab空间做分割结果往往比RGB更符合视觉直觉。切换方法很简单img_lab cv2.cvtColor(cv2.imread(image_path), cv2.COLOR_BGR2LAB) pixels img_lab.reshape(-1, 3).astype(np.float32)分割完成后要还原成BGR显示记得用cv2.COLOR_LAB2BGR。我在同一个数据集上对比过RGB和Lab的效果Lab在光线变化剧烈的场景下明显更稳定比如树叶间的阴影区域RGB分割时会碎成噪声Lab则能保持大块完整性。4.4 不同K值的效果对照参考用一张包含天空、山体、草地三个大色块的风景图做实验下面是我实测得到的效果描述。K值分割效果适用判断2天空与地面被分离但草地和山体被合并为一类想要极简前后景分离时可用3天空、山体、草地被清晰分开边界干净目标恰好是三类区域时最理想5草地被细分为亮部和暗部山体出现层次过度细分除非需要纹理细节8画面出现明显碎片化单类内部产生噪声簇基本失去语义分割意义从这张表可以发现一个规律K值增加分割从“大块语义区域”向“小块纹理细节”过渡。绝大多数应用场景需要的是语义块清晰所以K值宁可稍小也不宜过大——这是一个从实验里得来的重要心得。5. 实战中的隐蔽坑与解决思路5.1 图像太大跑不动怎么办下采样策略k-means的时间复杂度是O(nkt)n是像素数k是簇数t是迭代次数。一张1920×1080的图有207万个像素RGB特征维度虽然只有3但几十万行矩阵的迭代也相当耗时。如果再加坐标特征变成5维内存和耗时都会明显上涨。最实用的策略是先对图像做下采样缩小到长边约300到500像素在缩略图上跑k-means调参确定K值和权重后再回到原图上执行最终分割。缩放用OpenCV的cv2.resize插值方式用INTER_AREA在下采样时效果最好、也最不容易产生锯齿纹理。img_small cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_AREA)如果原图太大比如卫星遥感图有上亿像素还必须把图像分块处理。分割结束后对相邻块的边缘做一个小范围内的聚类中心统一避免出现接缝。这个分级处理思路能应付绝大多数大图场景。5.2 初始点随机性导致的“薛定谔结果”同一个参数跑两次结果不稳定这是初学阶段经常遇到、但没人解释的问题。随机种子没有固定每次初始中心不同收敛到的局部最优结果也不同。解决这个问题有两个办法。最简单的办法是固定random_state让结果可复现方便对比不同K值的差异。更稳妥的办法是调大n_init比如从默认的10改成20或30。n_init控制了独立初始化的次数算法会返回所有初始化中SSE最小的那一次。代价是耗时几乎线性增加对大图谨慎使用。我一般小图设30大图设10。另外如果发现分割结果里有某个簇特别小、只有零星几个像素大概率是初始中心落在了离群点上。除了增加n_init还可以考虑对像素做轻微的高斯模糊预处理减少极端的孤立颜色值聚类稳定性会提升不少。5.3 边缘毛刺和后处理补救k-means是纯像素级分类完全不考虑像素之间的空间关系这也导致分割边界经常有毛刺、单像素孤立点。尤其是当某簇的颜色范围和背景非常接近时边界处的噪点会很密集。后处理我常用两个手段。第一个是形态学开闭运算先腐蚀后膨胀可以去掉孤立小点先膨胀后腐蚀可以填平小孔。OpenCV一行搞定kernel np.ones((3, 3), np.uint8) labels_clean cv2.morphologyEx(labels.astype(np.uint8), cv2.MORPH_OPEN, kernel, iterations2)第二个是用连通域分析剔除面积过小的区域。把每个簇的掩膜提取出来后用cv2.connectedComponentsWithStats计算连通区域面积面积小于阈值的区域直接合并到相邻面积最大的类。这个方法在工业检测里特别常用。5.4 与后续处理任务的衔接建议k-means分割的结果很少是最终目的更多时候是中间步骤。用k-means拿到的labels数组有两个非常实用的下游用途。第一个是生成前景掩膜。二分类结果中目标区域往往对应某一个簇把该簇的掩膜保存成黑白图就能作为后续背景虚化、目标抠图、图像合成的输入。这个流程比传统的颜色阈值分割鲁棒得多因为簇中心会自动适应当前图像的颜色分布。第二个是提取目标区域的外接轮廓。把labels转为单通道掩膜后用cv2.findContours提取轮廓坐标就能进一步做尺寸测量、位置定位。我曾经在某个模拟项目里用这个流程处理一批零件图像先k-means二分割再轮廓提取最后根据面积筛选出有瑕疵的零件整个过程几乎没写多少额外逻辑。需要提醒的是k-means分割输出的labels是聚类类别号不是真正的目标编号。如果图像里有三个互相重叠的同类目标它们会被归到同一个簇里无法区分彼此。这种情况需要结合连通域分析才能拆分成实例。6. 我的实际使用心得与扩展建议6.1 什么时候别用k-means技术选型首先要承认边界不能一个方法包打天下。当我面对以下情况时会毫不犹豫放弃k-means第一目标与背景在颜色分布上大面积重叠肉眼都难分辨第二需要分割的对象有复杂纹理比如皮毛、草地纹理信息比颜色信息更重要第三图中有大量同色但相互遮挡的目标实例。前两种需要换基于纹理的算法如超像素结合图割、GrabCut第三种直接考虑检测模型更靠谱。6.2 k-means分割在完整项目中的定位在实际项目里k-means分割的独特优势在于它是无监督的不需要任何标注数据。这在很多快速原型阶段至关重要。拿到一批新图片不知道里面有什么颜色类别先用k-means跑一遍能迅速了解图片的颜色构成和分布这个信息对后续选择更复杂的算法非常有价值。我常用的最简流程是先缩略图下采样k-means粗分割然后观察各类别的代表色和占比如果代表色的数量符合预期再上全分辨率精细分割最后用形态学后处理清理边缘输出掩膜。整个流程在一张图上跑完不过几秒钟却能为后续的人工标注或模型训练节省大量时间。k-means图像分割的价值不在于跟深度学习模型比拼精度而在于它提供了一个快速、可解释、参数直观的基线方案。遇到视觉分割类问题时先用k-means摸清图像底数再决定要不要上重型算法这个习惯能帮你避开很多不必要的弯路。如果你也是刚入门的初学者我建议你动手把灰度图、RGB图、加坐标的RGB图都实现一遍再把K值从2调到8逐一观察结果差异。做完这几组实验你对聚类算法和图像特征的理解会扎实很多之后再接触GrabCut、语义分割模型也自然顺滑得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑