2026/10/17 13:57:07

Python与K-means聚类实现图片主色提取:从像素到色卡的完整方案

Python与K-means聚类实现图片主色提取:从像素到色卡的完整方案 说到底就是一件事一群设计师朋友经常让我帮他们做封面图的主色提取手动在软件里取色一张图少说得折腾几分钟几十张图下来真的是纯粹浪费时间。后来我把整套流程写成了脚本用Python配合K-means聚类来自动化提取图片主色效果比我预想得要稳定得多处理几百张图只需要几秒钟。这篇文章就把我自己的实现思路、代码、优化过程和踩过的坑一起整理出来适合刚开始接触图像处理、或者想给设计工具链加个自动取色功能的人参考。1. 取色需求看着简单但“主色”两个字其实需要重新定义1.1 为什么“出现次数最多的颜色”不等于主色很多人第一次接触这个话题时第一反应是直接统计图片里每个像素点的RGB数值然后把出现次数最多的那几个颜色挑出来。这个思路没有错但它有一个致命的问题图片里几乎没有完全相同的像素值。一张真实照片或渲染图少说也有几十万个像素每个像素的RGB值都不一样。就算远处一面白墙受光照、阴影、噪点影响RGB值也都在一个范围里波动很少出现两个像素的RGB完全相等的情况。直接按像素值统计“出现次数最多”结果往往是一堆离散且相似的杂点根本看不出主色是什么。所以“主色提取”这个需求的核心不是统计频次而是做颜色分组——把颜色接近的像素归成一类再从每个类里找代表色。这就是我选择聚类的根本原因。1.2 常见取色方案的对比这里简单对比一下几种常见方案的优劣方便你在实际项目里做取舍。方案思路优点缺点直方图峰值对每个通道或颜色区间做直方图取峰值区间实现简单速度快色彩空间需要手动分箱箱数不好调容易丢失相近色像素频次统计统计完全相等的像素点数量逻辑最简单几乎无法用于真实图片只能用于纯色扁平图中位切分法按RGB立方体的中位点递归切分颜色空间不用指定类别数切割规则偏向均匀色块人的主色感知未必均匀K-means聚类把像素点按距离聚成K类每类中心为主色符合人对颜色分组的直觉扩展性好需要指定K值结果有随机性实际做下来的感受是中位切分法也有不少场景在使用尤其在图像量化压缩里比较常见。但如果目的是提取“人眼能感受到的主色”K-means聚类更贴近我们的主观分组方式而且后期调节参数、做颜色筛选都比较顺手。1.3 为什么K-means和主色提取这么契合K-means做的事情本质上就是在特征空间里找K个中心点让每个样本点到最近中心的距离总和尽可能小。放到取色场景里每个像素的RGB值就是三维空间里的一个点聚类完成后得到的K个中心点恰好就是K组颜色的代表。这个类比非常直观就好像你把人按兴趣分组最后每组推选一个代表出来说话。K-means就是在RGB空间里把颜色点分组然后让每个组的中心来代表整组颜色。主色这个概念本身就和聚类结果高度一致所以我最终选定它作为核心算法。2. 从像素到颜色组K-means究竟在像素矩阵上做了什么2.1 K-means聚类算法的三步迭代K-means的迭代过程并不复杂整体可以拆成三步循环初始化随机选K个点作为初始聚类中心。分配计算每个样本点到这K个中心的距离把样本归到离它最近的中心所属的类别。更新重新计算每个类别内所有样本的均值用这个均值作为新的聚类中心。然后重复“分配”和“更新”两步直到聚类中心不再发生明显变化或者达到预设的迭代次数上限。我用的库是scikit-learn里面已经把这套流程封装好了实际调用就是创建一个KMeans对象然后fit即可完成整个训练流程。需要说明的是初始聚类中心的选择对结果影响很大。scikit-learn默认会使用一个叫k-means的初始化方式它会尽量让初始中心彼此分散避免一上来就全挤在一个区域相比完全随机初始化稳定很多。我在代码里设置n_init10意思是用10组不同的初始中心分别训练最后取最优的那一组这样能进一步降低坏结果出现的概率。2.2 倒腾数据把图片变成聚类可用的特征矩阵图片读入后通常是一个三维数组形状是(高, 宽, 通道数)。以一张256x256的RGB图片为例它的形状就是(256, 256, 3)CHW则很少见这里我们注意不混淆即可。但K-means需要的输入是一个二维矩阵形状是(样本数, 特征维度)所以要做一次数组形状转换。这一步非常关键但操作起来非常简单import cv2 import numpy as np image_rgb cv2.cvtColor(cv2.imread(input.jpg), cv2.COLOR_BGR2RGB) pixels image_rgb.reshape(-1, 3).astype(np.float32) print(pixels.shape) # 比如 (65536, 3)这个reshape操作的意思很清楚把原本按网格排列的像素行全部拉平每一行变成“一个像素样本”三列分别是R、G、B三个通道的取值。之前二维的宽高信息对聚类来说没有意义我们只关心颜色分布不关心颜色在图片的哪个位置。为什么要转成float32因为聚类过程中要计算均值如果用uint8存储数值范围虽然是0到255但很多计算中间结果会产生小数用整型会损失精度。转成浮点型之后后续的均值计算、距离度量都更准确。2.3 RGB空间的距离直觉K-means默认使用欧氏距离来度量样本之间的相似程度也就是像素点在三维RGB空间里的直线距离。两个颜色越接近它们的欧氏距离就越小距离远的两个颜色肉眼看起来通常也确实有很大差异。这里要留个心眼RGB空间对颜色差异的度量不完全符合人眼感知。比如纯绿色和墨绿色在RGB空间里的距离可能很大但人眼看着都是绿色相反一些看起来差别明显的颜色在RGB空间里距离反而可能不大。大多数情况下这不是什么问题因为主色提取本身只是做整体趋势分析不需要极度精确的颜色差异模型。但如果你的场景对人眼感知要求很高后面我会提到可以尝试HSV空间来改善。3. 第一次可运行的实现完整代码与逐步拆解3.1 安装依赖和加载图片首先需要准备环境依赖我用的是这几个库opencv-python负责图片读取、缩放、颜色空间转换。numpy处理数组reshpe和数值计算。scikit-learn提供现成的KMeans实现。matplotlib用于色卡可视化展示。Pillow一部分图片格式处理场景会用到不是必选。安装只需一行命令pip install opencv-python numpy scikit-learn matplotlib pillow加载图片的时候要注意一个高频坑OpenCV的imread读出来的通道顺序是BGR不是RGB。如果不做转换直接用这个数组做聚类训练逻辑不会报错但最后输出的颜色和你在图片里看到的会存在通道错位。我在代码里统一用COLOR_BGR2RGB做了转换这点必须养成肌肉记忆。3.2 准备特征矩阵读入图片后我先做了一次降采样再把降采样后图片的像素矩阵转换成二维表。降采样的原因后面单独讲这里只需要知道它能大幅减少参与聚类的样本数量对结果影响很小对速度影响极大。def load_image_resized(image_path, max_side256): image cv2.imread(image_path) if image is None: raise FileNotFoundError(f无法读取图片{image_path}) h, w image.shape[:2] scale max_side / max(h, w) if scale 1.0: new_w int(w * scale) new_h int(h * scale) image cv2.resize(image, (new_w, new_h), interpolationcv2.INTER_AREA) image_rgb cv2.cvtColor(image, cv2.COLOR_BGR2RGB) return image_rgb这里用INTER_AREA插值它对缩小图片更友好能减少缩放带来的颜色纹波。当然如果原图本来就小于max_side就不做缩放操作保持原始尺寸。3.3 聚类与主色提取代码核心提取函数如下from sklearn.cluster import KMeans def extract_main_colors(image_rgb, k5): pixels image_rgb.reshape(-1, 3).astype(np.float32) kmeans KMeans(n_clustersk, n_init10, random_state42) kmeans.fit(pixels) colors np.round(kmeans.cluster_centers_).astype(int) labels kmeans.labels_ counts np.bincount(labels).astype(np.float32) proportions counts / counts.sum() # 按占比从高到低排序 order np.argsort(proportions)[::-1] colors colors[order] proportions proportions[order] return colors, proportions这里有几个细节值得展开说明。cluster_centers_是聚类中心的坐标也就是每一类颜色的RGB值。因为原始坐标是浮点数我做了四舍五入转成整数方便后续输出十六进制颜色码。labels_是一个一维数组长度等于像素数量每个元素表示对应像素被分到了第几个簇。用np.bincount统计每个簇的像素数量除以总像素数就得到了每个主色的占比。排序很重要。K-means的聚类中心下标是随机的第0个簇不代表占比最高的簇。如果不按占比排序每次输出的主色顺序都会让人困惑。按占比从高到低排过一遍之后输出的结果才符合直觉第一个是图片里最主流的颜色。3.4 色卡可视化光有RGB数值不够直观我写了一个简单的色卡展示函数import matplotlib.pyplot as plt def show_palette(colors, proportions): fig, axes plt.subplots(1, len(colors), figsize(2 * len(colors), 3)) for ax, color, prop in zip(axes, colors, proportions): # matplotlib 显示需要的颜色分量范围是 0~1所以要除以 255 ax.imshow([[color / 255]], extent[0, 1, 0, 1]) ax.set_title(f{prop * 100:.1f}%) ax.axis(off) fig.subplots_adjust(wspace0.05) plt.show()这样跑完之后会弹出一个横向色卡窗口每个色块下面标注占比一眼就能看出图片是冷色调还是暖色调主色分布是什么情况非常直观。第一版代码跑通之后我就拿着它批量处理了一批封面图。K值设成5的时候大部分图片的主色提取结果都比较符合我的预期但对于一些明暗对比特别强烈的图片结果里会混入不少暗色系颜色这是后面优化阶段最主要解决的问题。4. 提取结果想更贴近人眼感受这几个优化值得做4.1 K值选择肘部法则和经验范围K-means最麻烦的参数就是K也就是你想提取几个主色。K太小比如K2图片里多种颜色被强行揉到一起出来的主色很寡淡K太大比如K12主色变成一眼望过去都是细碎颜色的色板也失去了“主色”的意义。科学一点的方式是用肘部法则来判断对不同的K值分别计算聚类结果的inertia也就是样本到所属中心距离的平方和。inertia会随着K增大而不断下降下降速度会在某个点出现明显拐弯再往后下降变缓那个拐点就是比较合理的K值。但在实际取色场景里我很少真的去画肘部曲线因为主色提取这件事带有主观审美成分不是指标越优越好。经验值通常在3到6之间。做封面主色分析时我喜欢用5做UI配色提取时用3或4做商品图批量分析时用6多一个候选用不上也没关系。如果你实在拿不准一个图库的合适K值可以抽样一部分图片对比K4和K6的输出效果选整体观感更符合业务需求的那个值。4.2 降采样既保速度又保效果的采样策略第一次写代码时我没有做降采样直接用一张1920x1080的高清图全图200多万像素去训练聚类。结果单张图跑了将近十秒而且多次运行结果差异比较大。后来检查发现全量像素里有大量非常相似的邻近点聚类过程做了太多冗余计算。降采样的本质是在不显著改变颜色分布的前提下用更少的样本代表整体。我统一把图片长边缩放到256像素这样最多也就6万多像素参与聚类处理速度从秒级降到毫秒级。对结果的影响几乎看不出来因为256像素边长已经足够保留图片中所有颜色层次。还有个容易被忽略的好处降采样会顺带抹平一部分细小噪点。像素级的噪点在聚类时会产生一些不具代表性的微小簇降采样让这些噪点变成周围颜色的混合像素反而让聚类结果更干净。4.3 换到HSV空间处理颜色RGB空间聚类的结果通常可用但我后来发现它有一个明显短板同一色调下不同明度和饱和度的颜色会被分成很多类导致提取出的“主色”里出现一大片深浅不一的同族颜色。这时候换成HSV空间处理会有奇效。HSV把颜色拆成色调、饱和度、明度三个维度色调就是人眼感知的“是不是同一个颜色”。如果聚类时只使用H通道的数据就能把“红色”“蓝色”“绿色”这种大色系分开加上S和V通道后可以进一步区分饱和度和明度。实际使用时不用完全抛弃RGB可以这样处理先把图片转到HSV空间用聚类或者阈值过滤掉低饱和度和极暗的区域再回到RGB空间做最终聚类。或者更简单一点直接只在HSV空间做聚类最后把聚类中心从HSV转回RGB用于展示。但是需要注意HSV的H通道是环形结构0度到360度之间的边界处颜色是连续的欧氏距离在环状数据上会有跳变。一个常见处理方法是把H拆成正弦和余弦两个分量但这个操作对于大多数取色需求来说不是必要的除非你要处理的图库里大量存在红紫色边界。4.4 主色比例归一化与输出设计提取出主色之后占比的计算前面已经写了。这里要提一个容易忽略的细节直接在RGB像素上算出来的占比是像素面积占比并不完全等于视觉权重。举个例子一张大面积浅色背景但有少量鲜红文字的海报用面积占比算出来红色可能只有2%但鲜红文字对视觉的影响远超2%。如果你要做“视觉主色”而不是“面积主色”可以考虑按饱和度或者对比度给像素加权让高饱和的颜色在计算时权重更高。这个思路在电商场景里比较有用因为消费者注意到的往往是亮点色不是背景色。输出设计上除了终端打印RGB数值我更建议直接生成十六进制颜色码和JSON结构方便后续工作流直接调用。这个我放在后面批量处理部分一起演示。5. 几个高频坑的记录现象、定位和修复5.1 BGR通道顺序训练没有错可视化全是错的这个坑我几乎每写一次图像相关的Python脚本都会遇到一次因为实在太好踩了。使用OpenCV读取图片时如果不做任何处理通道顺序是BGR而不是人们直觉上的RGB。用它直接跑聚类算法本身不会报错因为三维空间旋转了通道而已聚类结构还是保留的。但当你把聚类中心打印出来或者用matplotlib显示色卡时会发现颜色明显不对劲。比如图片里明明是蓝色天空输出的主色却是偏红色的图片里是绿色植物输出主色却偏紫。定位方法非常简单把提取出的第一个主色直接作为一个色块显示在屏幕上和原图对比如果颜色整体错位第一反应就检查通道顺序。修复也就在一行代码之间image_rgb cv2.cvtColor(image, cv2.COLOR_BGR2RGB)这行代码我会写进每个图像处理脚本里不管当前任务会不会立即用到颜色展示。5.2 暗部像素吃掉聚类中心用K-means提取主色时我最常遇到的“看起来不对”的结果是图片主色应该是明亮的天空蓝输出的第一个主色却是深灰蓝而且占比还很高。这就是暗部区域和噪点在捣乱。一张照片通常存在大量暗部像素比如阴影、深色背景、物体暗面。这些像素在数量上很多在聚类时容易被划分成一个大簇但这个大簇对应的颜色并不是我们心理认知上的“主色”。我的修复方案是在聚类前做一次像素筛选过滤掉过暗和过饱和度过低的点。这样提取主色时只保留有色彩价值的像素。def filter_valid_pixels(image_rgb): hsv cv2.cvtColor(image_rgb, cv2.COLOR_RGB2HSV) h, s, v cv2.split(hsv) # 过滤掉过暗的像素和饱和度极低的像素 valid_mask (v 40) (s 30) valid_image image_rgb[valid_mask] return valid_image过滤之后再聚类得到的颜色会更干净。不过做这个操作时要注意如果图片本身底色就是深色系强行过滤暗部会丢失真实的主色所以阈值需要根据图像特点来调。5.3 聚类中心顺序漂移的问题K-means结果里簇的顺序每次都可能不同即使数据完全一样初始中心不同结果顺序也不同。如果你直接输出cluster_centers_的第一个元素然后说这是主色早晚会被坑。这个问题在单张图上可能不那么明显一旦做成批量处理每张图片的顺序都不一样输出结果就完全没法用。解决办法就是在提取结果时立刻按照占比排序。这也说明一个流程规范训练完聚类后第一时间计算标签占比并排序而不是等展示时再处理。排序过的结果不仅仅展示更直观后续做颜色匹配、去重、合并时也更可控。5.4 性能卡顿与内存问题如果不做降采样直接拿3000万像素的大图去训练K-means等待时间会非常离谱而且内存占用也偏高。sklearn的KMeans实现是全量计算不是流式处理样本量大的时候这个瓶颈会非常明显。减负方案就是我前面提的降采样。把长边缩到256像素样本量从几千万降到几万速度提升百倍不止内存占用也控制在几十兆以内。如果处理超大图片还想更省可以考虑用随机采样替代全量像素从原图随机抽1万个像素点参与聚类效果也十分接近但代码里我统一用缩放更直观一些。另外还有个容易忽略的点聚类前把像素矩阵转成float32会占用更小内存。uint8转float32之后数值范围还是0到255聚类结果不影响但计算速度会快一些因为不少底层优化依赖浮点类型。6. 主色提取在真实工作流中的用法与更多扩展6.1 落地场景举例主色提取这个能力单独拿出来可能觉得也就是“好玩的取色小工具”但放到真实工作流里用处很广。我在实际项目里主要用到了几个场景第一个是批量给公众号封面图提取主题色然后自动生成配色方案用来统一排版风格第二个是商品详情页分析抓取竞品图片里的主要配色辅助做视觉投放素材时的配色参考第三个是相册自动化整理按提取出的主色给图片打上色调标签在手机端或者本地点开标签就能筛出同色调的图片。这些场景有一个共同点它们需要的不是单张图的精确结果而是批量的、可比较的颜色描述。单张图手动取色可以做到很精确但批量几百张时只有自动化方案可行。6.2 批量处理和输出设计批量处理的核心是用一个循环遍历文件夹把每张图的提取结果保存下来。我习惯输出成JSON因为格式通用不管后续是接自动配色、生成报告还是前端展示都很方便。import os import json def batch_extract(image_dir, k5, output_pathpalette_results.json): results {} for filename in os.listdir(image_dir): if not filename.lower().endswith((.jpg, .jpeg, .png, .webp)): continue image_path os.path.join(image_dir, filename) image_rgb load_image_resized(image_path, max_side160) # 如果图片里可用像素太少就跳过 valid_pixels filter_valid_pixels(image_rgb) if valid_pixels.shape[0] 50: continue colors, proportions extract_main_colors(valid_pixels, kk) results[filename] { colors: [f#{r:02x}{g:02x}{b:02x} for r, g, b in colors.tolist()], proportions: proportions.tolist(), } with open(output_path, w, encodingutf-8) as f: json.dump(results, f, ensure_asciiFalse, indent2) batch_extract(images)这段代码里有两个值得注意的细节一是批量处理时把降采样边长进一步降到160因为每张图只需要大致颜色分布不需要非常精细二是对可用像素做了数量下限判断防止遇到纯白纯黑这种极端图片导致聚类结果不稳定。每张图的输出格式都包含十六进制颜色码和占比后续接别的程序非常顺手。6.3 可以扩展的方向主色提取只是一个起点顺着这个思路可以扩展出不少实用的能力。第一个扩展方向是按主色生成风格化的调色板。比如拿到主色后用色彩调和规则生成邻近色、互补色、对比色一键生成和原图风格一致的配色方案这个对做PPT和海报脚本来说很有用。第二个扩展方向是结合图像区域分析。K-means只做了全局聚类但如果先对图片做目标检测或者简单的颜色分区再在每个区域里单独提取主色就可以得到“什么地方是什么颜色”的空间化描述。比如自动分析一张穿搭图的服装区域主色这比全局主色更有语义价值。第三个方向是颜色摘要描述。提取出的主色可以继续生成“以暖橙色为主、米白为辅”这类可读文本用于内容标签体系做图片归档和检索时相当方便。我自己实际用下来的体会是K-means聚类提取主色这套方案虽然看起来简单但它真正解决了“批量处理图片配色描述”的问题。在本地写一个脚本配合少量调参和过滤逻辑基本可以应对绝大多数取色场景。如果你的图片库风格比较固定建议先把K值固定下来测试几轮再根据结果决定要不要引入HSV混合处理或者暗部过滤这些调整成本都不高但带来的效果差异相当明显。