图像聚类实战:从特征工程到K-Means与DBSCAN的完整流程

发布时间:2026/10/14 11:05:37
图像聚类实战:从特征工程到K-Means与DBSCAN的完整流程
1. 从像素到分组图像聚类到底在解决什么问题很多人第一次听到“图像聚类”这四个字脑子里浮现的可能是把一堆照片按内容自动分文件夹。这个理解不算错但它只覆盖了聚类在图像领域最表层的一种用法。我在实际折腾图像处理项目的时候发现聚类真正有意思的地方在于它能把“看起来连续变化的像素”变成“离散的、有语义的区域”这个转换过程本身就是很多高级视觉任务的地基。举个具体的例子。你手上有一张彩色照片每个像素由红绿蓝三个通道的数值组成一张1000×1000的图就是一百万个三维数据点。人眼看这张图能立刻分辨出天空、草地、人物但计算机看到的只是一百万个坐标。聚类要做的事情就是根据这些坐标之间的“距离”或者“相似度”把它们分成若干组让同一组内的像素尽量接近不同组的像素尽量远离。分完之后每组赋予一个代表色或者标签图像就被“简化”或“分割”了。这个思路能延伸出很多实用场景。比如颜色量化把一张包含几十万种颜色的照片压缩到只用16种颜色表示文件体积大幅缩小视觉效果却不会差太多早期GIF格式的颜色表就是这么来的。再比如图像分割预处理在跑更复杂的语义分割模型之前先用聚类把图像粗分成若干区域能有效减少后续计算量。还有异常检测在工业质检场景里正常产品的图像纹理聚类后应该形成稳定的簇如果某个区域聚类结果异常分散那大概率就是瑕疵所在。关键词里虽然只给了标题但“图像聚类”这四个字本身就锁定了几个核心技术点特征提取用什么来描述像素或区域、距离度量怎么衡量两个特征像不像、聚类算法用什么策略分组、结果可视化与评估分得好不好、怎么呈现。这四个环节环环相扣任何一个环节选型不当最终结果都可能惨不忍睹。我见过不少初学者上来就调sklearn.cluster.KMeans把图像像素直接扔进去跑跑完发现结果是一堆散点噪声完全不是想象中“天空一块、草地一块”的效果。问题出在哪出在他们跳过了特征工程这一步。原始RGB像素值作为特征在高维空间里的分布往往非常复杂直接聚类很容易被光照、阴影、纹理细节干扰。所以这篇笔记我会从特征构建开始讲把每个环节的“为什么”和“怎么做”都拆开说清楚。适合读这篇内容的人已经会用Python做基本图像读写、对NumPy数组操作不陌生、想系统理解聚类在图像上怎么落地的人。如果你连cv2.imread都没用过建议先补一下OpenCV或Pillow的基础操作否则后面的代码你只能抄不能改。2. 特征工程把像素变成聚类算法能“吃”的输入2.1 为什么原始RGB像素往往不是好特征聚类算法的核心是计算样本之间的距离。在RGB空间里两个像素的欧氏距离定义为三个通道差值的平方和开根号。这个定义本身没问题但它隐含了一个假设三个通道的重要性相同且距离大小直接反映视觉差异。实际情况要复杂得多。人眼对亮度的敏感度远高于对色度的敏感度。两个像素在RGB空间里距离很大但如果它们只是亮度不同、色相相近人眼会觉得它们“差不多”。反过来两个像素RGB距离不大但一个偏红一个偏绿人眼会觉得差异明显。这就是为什么直接拿RGB做聚类结果经常和人眼感知对不上。另一个问题是光照。同一块草地在阳光下和阴影里的RGB值可能差出好几倍但它们在语义上属于同一类。如果聚类算法只认数值距离就会把同一块草地拆成两个簇。解决这个问题的思路有两种一是换颜色空间二是构造对光照不敏感的特征。2.2 颜色空间的选择RGB、HSV与Lab的实战对比我一般会优先考虑把图像转到Lab颜色空间再做聚类。Lab的设计初衷就是让颜色差异更接近人眼感知差异其中L通道表示亮度a和b通道表示色度。在Lab空间里做欧氏距离聚类效果通常比RGB好一截。HSV空间也有它的用处。H通道是色相S是饱和度V是明度。如果你只想按颜色种类分组不太关心明暗变化可以把H通道单独拿出来做一维聚类速度极快。但HSV有个坑H通道是环形的0度和360度其实是同一个颜色直接算欧氏距离会在红色附近出现断裂。处理办法是把H通道拆成sin和cos两个分量或者用环形距离公式。下面这段代码演示了三种颜色空间的转换和通道分离你可以直接拿去用import cv2 import numpy as np def extract_features(image_path, spacelab): img_bgr cv2.imread(image_path) img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) if space rgb: features img_rgb.reshape(-1, 3).astype(np.float32) elif space hsv: img_hsv cv2.cvtColor(img_rgb, cv2.COLOR_RGB2HSV) h img_hsv[:, :, 0].astype(np.float32) * 2 * np.pi / 180 s img_hsv[:, :, 1].astype(np.float32) / 255 v img_hsv[:, :, 2].astype(np.float32) / 255 features np.stack([np.sin(h), np.cos(h), s, v], axis-1).reshape(-1, 4) elif space lab: img_lab cv2.cvtColor(img_rgb, cv2.COLOR_RGB2Lab) features img_lab.reshape(-1, 3).astype(np.float32) return features, img_rgb.shape这段代码里有个细节值得说HSV转Lab的时候OpenCV的Lab取值范围和标准Lab不一样。L在0到255之间a和b在0到255之间偏移了128。做距离计算时这个偏移不影响结果但如果你要可视化或者和别的库对接记得做归一化。2.3 加入空间坐标让聚类结果具有空间连续性只用颜色特征做聚类会出现一个很尴尬的情况图像左上角有一块蓝天右下角也有一块蓝天它们颜色几乎一样聚类算法会把它们分到同一个簇。但如果你做的是图像分割你显然希望这两块蓝天是分开的区域。解决办法是把像素的空间坐标(x, y)也作为特征加进去。这样两个像素要分到同一簇不仅要颜色像位置也得近。特征向量就从3维变成了5维[L, a, b, x, y]。但这里有个权重问题。颜色值的范围可能是0到255坐标范围可能是0到1000如果直接拼在一起坐标的数值量级会压过颜色聚类结果会偏向空间连续性而忽略颜色差异。所以必须做归一化或者手动给不同维度乘权重系数。我常用的做法是颜色通道归一化到0到1坐标也归一化到0到1然后给坐标乘一个权重系数w。w越大聚类结果越偏向空间连续w越小越偏向颜色一致。这个w没有标准答案得根据图像内容试。我一般从0.5开始试如果分割区域太碎就调大如果区域边界不贴合颜色变化就调小。def build_spatial_color_features(image_path, spatial_weight0.5): img_bgr cv2.imread(image_path) img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) h, w img_rgb.shape[:2] img_lab cv2.cvtColor(img_rgb, cv2.COLOR_RGB2Lab).astype(np.float32) img_lab[:, :, 0] / 255.0 img_lab[:, :, 1] (img_lab[:, :, 1] - 128) / 127.0 img_lab[:, :, 2] (img_lab[:, :, 2] - 128) / 127.0 yy, xx np.mgrid[0:h, 0:w] xx xx.astype(np.float32) / w yy yy.astype(np.float32) / h features np.stack([ img_lab[:, :, 0], img_lab[:, :, 1], img_lab[:, :, 2], xx * spatial_weight, yy * spatial_weight ], axis-1).reshape(-1, 5) return features, (h, w)注意加入空间坐标后特征维度变高K-Means的收敛速度会变慢而且更容易陷入局部最优。如果图像很大建议先降采样再聚类最后把标签映射回原图。3. K-Means在图像上的实操细节与参数调优3.1 K-Means为什么适合图像聚类以及它的硬伤K-Means是图像聚类里出场率最高的算法没有之一。原因很直接它的计算复杂度是O(n k t)n是像素数k是簇数t是迭代次数对于百万级像素的图像只要k不太大现代计算机几秒到几十秒就能跑完。而且它的结果容易解释每个簇有一个质心质心对应的颜色就是该簇的代表色。但K-Means有两个硬伤你必须心里有数。第一它假设簇是球形的且大小差不多。图像里的颜色分布往往不是这样比如一张以蓝天为主的照片蓝色像素可能形成一个巨大的簇而其他颜色挤在几个小簇里。K-Means会强行把大簇拆开或者把小簇合并导致结果不理想。第二k值需要预先指定。你事先并不知道一张图应该分成几类k选大了区域碎k选小了区域混。对于第一个问题可以考虑用GMM高斯混合模型替代它允许簇有不同的形状和大小。但GMM计算量更大而且对初始化更敏感。对于第二个问题可以用肘部法或轮廓系数来辅助选k但这些指标在图像上不一定靠谱最终还是要靠眼睛看。3.2 k值怎么选从肘部法到肉眼判断肘部法的逻辑是随着k增大簇内平方和inertia会下降但下降速度会在某个k值处明显变缓那个拐点就是“合适”的k。我拿一张风景照试过k从2到10inertia从几万降到几千拐点大概在5左右。但实际看分割结果k5时天空和草地已经分开了但树和阴影还混在一起k7才比较干净。所以我的建议是肘部法只用来确定k的下界最终k值在肘部法结果附近手动试。具体操作是跑k3到k10把每个k的分割结果拼成一张对比图肉眼选最符合你需求的那个。如果你做颜色量化k8到16通常够用如果做分割预处理k5到10比较常见。from sklearn.cluster import KMeans import matplotlib.pyplot as plt def try_k_values(features, k_range, h, w): fig, axes plt.subplots(2, 4, figsize(16, 8)) inertias [] for idx, k in enumerate(k_range): kmeans KMeans(n_clustersk, n_init10, random_state42) labels kmeans.fit_predict(features) inertias.append(kmeans.inertia_) centers kmeans.cluster_centers_ quantized centers[labels].reshape(h, w, -1) ax axes[idx // 4, idx % 4] ax.imshow(quantized[:, :, :3]) ax.set_title(fk{k}) ax.axis(off) plt.tight_layout() plt.show() return inertias这段代码里n_init10很关键。K-Means对初始质心敏感n_init表示用不同随机种子跑10次取inertia最小的那次结果。默认值在旧版sklearn里是10新版改成了‘auto’但显式写10更稳妥。random_state固定住是为了结果可复现调试阶段很有用。3.3 加速技巧MiniBatchKMeans与降采样一张4K图像有八百多万像素直接跑K-Means即使k10也可能要等好几分钟。我常用的加速手段有两个。第一个是降采样。把图像缩小到原来的四分之一甚至十分之一在缩略图上跑聚类得到质心然后把质心应用到原图的每个像素上找最近质心。这样计算量直接降两个数量级结果和原图跑几乎一致因为质心位置对分辨率不敏感。第二个是MiniBatchKMeans。它每次只用一小批样本更新质心速度比标准K-Means快好几倍代价是结果稍微粗糙一点。对于图像聚类这种“差不多就行”的场景完全够用。from sklearn.cluster import MiniBatchKMeans def fast_cluster(image_path, k8, scale0.25): img_bgr cv2.imread(image_path) img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) h, w img_rgb.shape[:2] small cv2.resize(img_rgb, None, fxscale, fyscale, interpolationcv2.INTER_AREA) small_lab cv2.cvtColor(small, cv2.COLOR_RGB2Lab).reshape(-1, 3).astype(np.float32) mbk MiniBatchKMeans(n_clustersk, batch_size1024, n_init5, random_state42) mbk.fit(small_lab) full_lab cv2.cvtColor(img_rgb, cv2.COLOR_RGB2Lab).reshape(-1, 3).astype(np.float32) labels mbk.predict(full_lab) centers mbk.cluster_centers_ result centers[labels].reshape(h, w, 3).astype(np.uint8) result_rgb cv2.cvtColor(result, cv2.COLOR_Lab2RGB) return result_rgb, labels.reshape(h, w)batch_size1024是我试出来的经验值。太小了更新频繁但噪声大太大了速度优势不明显。1024在速度和稳定性之间平衡得比较好。scale0.25意味着缩略图只有原图十六分之一的像素量对于大多数图像足够估计质心了。4. 超越K-MeansDBSCAN与层次聚类的适用场景4.1 DBSCAN不需要指定簇数但参数更敏感DBSCAN的思路和K-Means完全不同。它不需要你告诉它分几类而是根据样本密度来发现簇。核心参数有两个eps邻域半径和min_samples成为核心点所需的最小邻居数。密度高的区域形成簇密度低的区域被标记为噪声。这个特性在图像上很有用。比如你有一张背景干净、前景物体突出的图DBSCAN能自动把前景物体聚成一类背景作为噪声排除。或者做异常检测正常纹理区域密度高形成簇瑕疵区域密度低被标为噪声天然就是缺陷检测器。但DBSCAN在图像上的问题也很明显。图像像素在颜色空间里的密度分布极不均匀eps稍微变一点结果就天差地别。而且DBSCAN的计算复杂度在没有空间索引时是O(n²)百万像素根本跑不动。所以实际用的时候我一般先对图像做超像素分割比如SLIC算法把图像变成几百个超像素再对超像素的颜色均值做DBSCAN。这样样本量从百万降到几百DBSCAN完全能胜任。from sklearn.cluster import DBSCAN from skimage.segmentation import slic def dbscan_on_superpixels(image_path, n_segments500, eps8, min_samples5): img_bgr cv2.imread(image_path) img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) segments slic(img_rgb, n_segmentsn_segments, compactness10, start_label0) n_seg segments.max() 1 seg_colors np.zeros((n_seg, 3), dtypenp.float32) for i in range(n_seg): mask segments i seg_colors[i] img_rgb[mask].mean(axis0) seg_lab cv2.cvtColor(seg_colors.reshape(1, -1, 3).astype(np.uint8), cv2.COLOR_RGB2Lab).reshape(-1, 3).astype(np.float32) db DBSCAN(epseps, min_samplesmin_samples) seg_labels db.fit_predict(seg_lab) result np.zeros_like(img_rgb) for i in range(n_seg): if seg_labels[i] -1: result[segments i] [0, 0, 0] else: color seg_colors[i].astype(np.uint8) result[segments i] color return result, seg_labelscompactness10控制超像素的形状规整程度值越大超像素越接近正方形值越小越贴合颜色边界。做聚类预处理时我一般用10到20让超像素规整一些减少噪声。4.2 层次聚类想看聚类树的时候用它层次聚类分两种自底向上凝聚和自顶向下分裂。图像处理里常用的是凝聚层次聚类它一开始把每个样本当独立簇然后不断合并最近的簇直到满足停止条件。这个过程的副产品是一棵树状图你能直观看到哪些簇先合并、哪些后合并。层次聚类在图像上的典型用途是颜色层级分析。比如你想知道一张图的主色调是怎么从细粒度合并到粗粒度的树状图能给你完整答案。但它的计算复杂度是O(n³)或O(n² log n)百万像素想都别想。所以同样要先降采样或超像素化。我个人的经验是层次聚类适合做探索性分析不适合做批量处理。你拿它分析几张代表性图像理解颜色分布规律然后换用K-Means去批量跑这是比较务实的组合。4.3 三种聚类算法的选型对照算法需要指定簇数计算复杂度对噪声敏感度图像上的典型用途K-Means是O(nkt)敏感颜色量化、分割预处理MiniBatchKMeans是O(nkt)但常数小较敏感大图快速聚类DBSCAN否O(n²)或O(n log n)不敏感异常检测、超像素聚类层次聚类否但需截断O(n³)较敏感颜色层级探索选型的时候先问自己三个问题图像多大要不要自动定簇数对噪声容忍度如何答案清楚了算法基本就定了。5. 聚类结果的后处理与可视化让结果真正可用5.1 从标签图到彩色分割图聚类算法输出的是一个标签数组每个像素对应一个整数标签。这个数组直接看是一团灰必须映射成颜色才能可视化。最简单的做法是用每个簇的质心颜色替换该簇所有像素得到颜色量化图。但如果你想看分割边界就需要给每个簇分配一个高对比度的伪彩色。def label_to_color(labels, h, w, palettetab20): import matplotlib.cm as cm n_labels labels.max() 1 cmap cm.get_cmap(palette, n_labels) colored cmap(labels)[:, :3] * 255 return colored.reshape(h, w, 3).astype(np.uint8)tab20调色板有20种区分度较高的颜色适合簇数不多的情况。如果簇数超过20颜色会开始重复这时候可以考虑用hsv调色板或者手动生成均匀分布的颜色。5.2 边界提取让分割区域一目了然光有彩色区域还不够很多时候你需要把区域之间的边界画出来。做法很简单对标签图做形态学梯度或者检测标签变化的像素位置。def extract_boundaries(labels, h, w): label_img labels.reshape(h, w) boundary np.zeros((h, w), dtypebool) boundary[:-1, :] | label_img[:-1, :] ! label_img[1:, :] boundary[:, :-1] | label_img[:, :-1] ! label_img[:, 1:] return boundary这个边界检测只比较右邻和下邻所以边界线是单像素宽。如果你想要更粗的边界可以对boundary做膨胀操作。把边界叠加到原图上分割效果就非常直观了。5.3 聚类质量评估除了肉眼还能看什么肉眼评估最直接但不适合批量处理。量化指标方面轮廓系数是常用的一个它衡量样本与同簇其他样本的相似度是否高于与其他簇样本的相似度。取值范围-1到1越大越好。但轮廓系数计算量是O(n²)大图跑不动只能在降采样后算。另一个指标是Davies-Bouldin指数它计算簇内散度和簇间距离的比值越小越好。计算量比轮廓系数小但同样不适合百万级样本。我的实际做法是批量处理时用降采样后的轮廓系数做粗筛把明显异常的图像挑出来再对这几张做肉眼检查。这样兼顾效率和准确性。6. 踩过的坑与实战经验汇总6.1 内存溢出大图直接reshape的代价一张8000×6000的图reshape成(-1, 3)后是4800万个float32占192MB。这还只是原始数据K-Means内部还要复制几份加上距离矩阵内存轻松上G。我第一次处理这种大图时16G内存的机器直接卡死。解决办法就是前面说的降采样加MiniBatch。如果非要全分辨率跑至少把数据类型从float32降到float16能省一半内存。或者分块处理把图像切成若干块分别聚类但块与块之间的边界需要额外处理比较麻烦。6.2 聚类结果不稳定随机初始化的影响K-Means的随机初始化会导致每次跑的结果略有不同。调试阶段如果不固定random_state你会怀疑是不是代码写错了。固定之后结果可复现但也要注意固定随机种子不代表结果就是最优的只是某一次初始化的结果。所以n_init还是要设大一点让算法自己选最好的。6.3 颜色空间转换的数值陷阱OpenCV读图默认是BGR顺序而matplotlib显示是RGB。我见过太多人在这上面翻车用cv2读图直接扔给plt.imshow结果颜色完全不对。转换代码写多了容易漏我的习惯是封装一个read_image_rgb函数所有读图都走这个函数从源头杜绝BGR混入。Lab空间的数值范围也要注意。OpenCV的Lab是8位无符号整数L在0到255a和b在0到255但实际有效范围大概在0到255之间偏移128。做距离计算时这些偏移不影响但如果你要把Lab值打印出来看记得减128才是真实值。6.4 超像素数量对DBSCAN结果的影响用SLIC做超像素时n_segments设多少很关键。设500每个超像素平均覆盖几百像素颜色均值比较稳定。设50超像素太大颜色均值会混合多种颜色聚类结果模糊。设5000超像素太小颜色均值噪声大DBSCAN的密度估计不准。我试下来对于1000×1000左右的图n_segments在300到800之间比较合适。6.5 聚类标签的排列顺序没有语义K-Means输出的标签0、1、2……只是簇的编号不代表任何顺序或重要性。每次跑标签对应的颜色可能都不一样。如果你要把聚类结果和别的标注做对比不能直接比标签值要先做标签匹配。简单做法是计算两个标签集合的混淆矩阵用匈牙利算法找最优匹配。7. 一个完整的图像聚类处理流程把前面所有环节串起来一个完整的处理流程大概是这样的读图并转RGB必要时降采样转Lab颜色空间归一化构造特征向量颜色空间坐标选择聚类算法和参数在降采样图上拟合在全分辨率图上预测标签映射回原图尺寸可视化颜色量化图边界叠加图评估轮廓系数降采样肉眼检查这个流程我跑过几十张不同类型的图像风景照、产品图、医学影像都试过。风景照用LabK-Meansk8效果最稳产品图背景干净DBSCAN超像素能自动分离主体医学影像纹理复杂往往需要先做滤波再聚类。代码层面我习惯把每个步骤封装成独立函数用字典传参数。这样换图像、换参数、换算法都只需要改配置不用动核心逻辑。调试的时候把中间结果存下来出问题了能快速定位是哪一步不对。最后说一个容易被忽略的点聚类只是工具不是目的。你最终要解决的是什么问题是压缩存储、是分割预处理、还是异常检测目的不同对聚类结果的要求完全不同。颜色量化追求视觉近似分割预处理追求区域完整异常检测追求不漏检。先想清楚目的再回头选特征和算法比反过来要高效得多。