2026/10/17 11:46:09

用K-means聚类实现图片主色提取:从像素到配色方案

用K-means聚类实现图片主色提取:从像素到配色方案 1. 项目思路拆解为什么用K-means来“取色”做图片主色提取最朴素的想法是直接统计像素直方图把出现次数最多的颜色挑出来。但这个方案在实际项目里基本不能用原因很简单一张照片里背景、主体、光影渐变会产生成千上万种接近的颜色直方图统计出来的“top N”往往是一堆肉眼看起来一模一样的近似色根本没法代表图片真正的视觉主调。我最初接触这个需求是想做一个自动生成PPT封面的小工具需要从用户上传的图片里提取3到5个主色用来生成配色方案。试了直方图方案之后果断放弃转而用了K-means聚类。K-means在这里的逻辑非常贴合取色场景把图片里所有像素的颜色值当作数据点聚成N类每个聚类中心就是一个“主色”。这样做的好处是相近的颜色会被自动合并成一个代表色而不是被当成独立颜色去统计。比如一片蓝天在直方图里可能有几万种深浅不一的蓝色但在K-means里它们会被归为一簇聚类中心就是那片天空最有代表性的蓝。另一个选择是用更简单的色彩量化算法比如中位切分法Median Cut这个在GIF图片压缩里很常用。它速度极快但结果是等分色彩空间不擅长把“视觉上重要”的颜色挑出来。K-means的聚类中心是按像素分布密度迭代收敛的天生就偏向密集区更接近人眼感知的“主色”。不过K-means有个前提颜色的“距离”要用合适的度量。RGB空间里直接用欧氏距离虽然可行但效果不够好。我后面在做调参时对比过RGB、HSV和Lab三种色彩空间下的聚类结果Lab空间的视觉一致性最好这个细节等到了调参章节再展开。还有一个很多人忽视的问题K值聚类数量该怎么定。取3个主色和取6个主色视觉结果差异很大没有标准答案取决于你的使用场景。做UI配色的可能需要4到6个做图片检索的可能只需要1到2个做PPT封面的则是3个左右最稳妥。我最终在这套代码里把K值做成了可配置参数默认取5。1.1 核心需求解析这段代码到底要解决什么问题用一句话概括输入任意一张图片输出N个能代表这张图片视觉主调的颜色值。听起来简单但有几个隐性的需求点很容易被忽略。第一效率。图片动辄几百万像素如果直接对每个像素做K-means迭代内存和时间都会爆炸。核心做法是先对像素做降采样或者用小尺寸图片跑聚类再用聚类中心映射回原图。这个思路我在项目里做了两重优化一是读取图片时直接缩放到较小的尺寸二是用MiniBatchKMeans替代标准KMeans速度提升明显。第二输出要有用。聚类中心是浮点型的RGB值不能直接用。需要量化为整数并且最好按“占比”排序这样主色有优先级。我在代码里加了cluster_centers.astype(int)的转换然后统计每个簇的像素数量按数量降序排列这样返回的颜色列表第一项就是整张图最主打的颜色。第三抗噪。图片里可能有大面积的白边、黑边或者水印这些都会干扰聚类结果。我在项目里做了两个处理一是先把图片转换成RGB模式处理RGBA透明通道的问题二是提供了可选的背景色去除逻辑——如果检测到纯白或纯黑占比过高可以从像素集合里剔除避免白色背景主导聚类。第四场景适配性。不同领域的取色需求差异很大所以我最终封装成了一个函数接口输入图片路径、K值、色彩空间输出颜色列表和占比调用方只需要关心业务逻辑不用管底层的聚类细节。1.2 技术选型对比RGB、HSV还是Lab选色彩空间这件事我踩过不少坑。最开始图省事直接用RGB结果发现聚类出来的颜色经常发灰、发暗或者把视觉上明显的橙色系拆成了一堆暗红和深黄。原因在于RGB的三通道是高度相关的而且各通道的数值范围和对亮度的贡献不均匀欧氏距离在RGB空间里并不能真实反映人眼的颜色差异。换成HSV之后色调H、饱和度S、明度V分离理论上更接近人眼感知。但HSV有个痛点H分量是环形的0度和360度是同一个颜色直接算欧氏距离会出问题——红色和橙色在色调上虽然接近但数值上可能一边是10另一边是350距离被错误地拉大了。需要做环形距离处理这增加了代码复杂度。最后我选择了Lab色彩空间。Lab是感知均匀的颜色空间欧氏距离在这边与实际视觉差异呈正比聚类结果几乎不需要额外调优就比RGB好一个档次。OpenCV的cvtColor函数可以直接把BGR转成Lab成本很低。实际项目中默认用Lab但如果追求极致速度或者颜色风格偏卡通、浓艳的图用HSV配合环形距离处理效果也不错。这部分的结论是默认走Lab遇到特殊图像风格再做切换不要一上来就调参先确认色彩空间没选错。2. 环境准备与代码骨架搭建2.1 依赖选型OpenCV、scikit-learn、Pillow还是纯Python做这个项目的依赖选择我前后试过几套方案。第一套纯Pillow 手写K-means。Pillow读图没问题但手写K-means在Python里跑大图非常吃力几百万个像素点迭代几十次慢到让人怀疑人生。而且手写代码容易在初始化质心上出问题K-means对初始质心敏感实现不好容易收敛到局部最优。第二套OpenCV scikit-learn。这是我最推荐的组合。OpenCV负责图像读取和色彩空间转换scikit-learn提供MiniBatchKMeans底层调的是优化的C代码速度飞快。两者的安装都走pip没有编译负担环境适配性极好。第三套纯scikit-learn Pillow。适合不想装OpenCV的场景但色彩空间转换就要自己写HSV和Lab的转换公式虽然不复杂但手写容易引入边界bug不如直接用OpenCV的cvtColor省心。我最终的项目代码结构是OpenCV读图 缩图 转色彩空间 像素矩阵重塑scikit-learn的MiniBatchKMeans做聚类最后再用OpenCV把聚类中心转回RGB并格式化输出。pip install opencv-python scikit-learn numpy三件套就够了。Pillow在OpenCV读不了某些特殊格式时作为兜底方案但常规的JPG、PNG、WebPOpenCV都能搞定。2.2 项目文件结构设计这个项目我做成了一个小型工具包方便后续复用color_extractor/ ├── extractor.py # 核心取色类 ├── utils.py # 颜色格式化、占比统计辅助函数 ├── demo.py # 命令行演示入口 └── requirements.txt # 依赖清单extractor.py放核心逻辑utils.py放颜色转换和RGB到Hex格式化的工具函数demo.py用于直接跑测试。拆这么细的原因是想让核心逻辑独立以后接Flask服务、接命令行、接GUI都一样方便。2.3 requirements.txt 内容opencv-python4.5.0 scikit-learn1.0.0 numpy1.20.0版本不要太新尤其OpenCV新版有时会有API变动但核心的cvtColor和imread接口很稳定基本不用担心。3. 核心代码实现与逐步解析3.1 图像读取与预处理这一步是整个流程的地基处理不好后面全白搭。import cv2 import numpy as np def load_and_preprocess(image_path, target_size(200, 200)): 读取图片并降采样返回RGB像素矩阵 img cv2.imread(image_path) if img is None: raise ValueError(f无法读取图片: {image_path}) # 处理大图统一缩放到200x200以内保留比例 h, w img.shape[:2] scale min(target_size[0] / w, target_size[1] / h) if scale 1.0: new_w, new_h int(w * scale), int(h * scale) img cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_AREA) # 转RGBOpenCV默认BGR img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 重塑为像素列表 pixels img_rgb.reshape(-1, 3) return pixels几个关键细节INTER_AREA插值法是降采样时的最佳选择它对图像做了区域平均能减少缩放带来的颜色失真。如果直接用默认的INTER_LINEAR缩放后的颜色会偏锐利影响聚类精度。为什么缩放到200x200就够用。因为取主色关心的是颜色分布比例不是细节纹理。200x200有4万个像素点足够让K-means稳定收敛。再大只会增加计算时间不会显著改变聚类中心。测试过一张1200万像素的图片和缩到200x200后提取出的主色序列视觉效果几乎一样。透明通道的处理如果图片是RGBA比如带透明背景的PNG直接读会丢失A通道透明区域的RGB值通常是(0,0,0)会把黑色当成主色很坑。我在load函数里加了一个参数可选的透明处理逻辑在后续版本中补充。3.2 K-means聚类的代码实现与参数解读from sklearn.cluster import MiniBatchKMeans def extract_colors(pixels, n_colors5, use_labTrue): 对像素点做K-means聚类返回主色RGB列表和占比 if use_lab: # BGR - Lab 需要先还原成BGR再转 bgr cv2.cvtColor(pixels.reshape(-1, 1, 3).astype(np.uint8), cv2.COLOR_RGB2Lab) pixels_lab bgr.reshape(-1, 3) cluster_data pixels_lab else: cluster_data pixels.astype(np.float64) # 归一化加快收敛 cluster_data_normalized cluster_data / 255.0 kmeans MiniBatchKMeans( n_clustersn_colors, batch_size4096, max_iter100, n_init5, random_state42 ) kmeans.fit(cluster_data_normalized) # 统计每个簇的像素数量 labels kmeans.labels_ counts np.bincount(labels, minlengthn_colors) # 聚类中心转回RGB格式 if use_lab: centers_lab (kmeans.cluster_centers_ * 255.0).astype(np.uint8) centers_bgr cv2.cvtColor(centers_lab.reshape(-1, 1, 3), cv2.COLOR_Lab2BGR) centers_rgb cv2.cvtColor(centers_bgr, cv2.COLOR_BGR2RGB).reshape(-1, 3) else: centers_rgb (kmeans.cluster_centers_ * 255.0).astype(np.uint8) # 按占比降序排列 sorted_idx np.argsort(counts)[::-1] colors centers_rgb[sorted_idx] ratios counts[sorted_idx] / counts.sum() return colors, ratios这段代码有四个关键设计点MiniBatchKMeans的参数调参是重点。batch_size4096意味着每次迭代只随机采样4096个点做质心更新而不是对全部像素。这大幅提升了速度代价是收敛结果的微小波动。n_init5表示跑5次不同初始化取最优可以部分抵消随机性。实测下来对普通图片这组参数能在1秒内完成聚类结果稳定度足够。归一化是很多人容易忽略的。如果不把RGB值归一化到[0,1]聚类时的距离计算会受大数值影响虽然对最终结果影响不大但会拖慢收敛速度尤其是在Lab空间下L通道的数值范围是0到100a和b通道是-128到127三通道的尺度差异很大不归一化几乎必然导致聚类结果偏向L通道。随机种子的设置random_state42保证了每次运行结果一致。这在调试和对比调参时非常重要不然你以为改了参数有效果实际只是随机波动。Lab色彩空间的转换顺序这里有一个OpenCV的大坑。OpenCV的COLOR_RGB2Lab要求输入是uint8类型的RGB三通道而且要reshape成(-1, 1, 3)的维度才能正确转换。我第一次写的时候直接传了一个二维数组结果报错或者转出来的颜色全乱了。这个细节是从报错信息里一点一点试出来的。3.3 结果格式化与输出聚类出来的颜色中心是浮点数直接输出没法用。需要做一层格式化def format_color(rgb, as_hexFalse): 将RGB数组转为可读格式 r, g, b int(rgb[0]), int(rgb[1]), int(rgb[2]) if as_hex: return f#{r:02X}{g:02X}{b:02X} return f({r}, {g}, {b}) def format_palette(colors, ratios): 将主色和占比格式化为描述文本 lines [] for i, (color, ratio) in enumerate(zip(colors, ratios)): hex_color format_color(color, as_hexTrue) pct ratio * 100 lines.append(f主色{i1}: {hex_color} RGB{format_color(color)} 占比: {pct:.1f}%) return \n.join(lines)输出的格式对齐颜色和占比信息这个格式化函数后面接Web展示或生成可视化色卡都很方便。3.4 demo入口if __name__ __main__: import sys if len(sys.argv) 2: print(用法: python demo.py 图片路径 [颜色数量]) sys.exit(1) image_path sys.argv[1] n_colors int(sys.argv[2]) if len(sys.argv) 2 else 5 pixels load_and_preprocess(image_path) colors, ratios extract_colors(pixels, n_colorsn_colors) print(f图片: {image_path}) print(f提取主色数量: {n_colors}) print(format_palette(colors, ratios))4. 调参优化与效果对比从能用到好用4.1 K值怎么定三种策略K值是最重要的超参数。我总结了三种策略策略一固定值。适合场景固定的工具比如做PPT封面固定3个主色最简单。策略二按图片复杂度动态选择。用轮廓系数或者肘部法则先确定最优K值再跑K-means。但问题是为了算轮廓系数得跑多次K-means反而更慢。我试过对200x200的图跑K从2到10的循环用肘部法则找拐点总耗时差不多5到8秒对于离线任务可以接受但实时应用不行。策略三固定K 合并近似色。取一个较大的K比如8聚类完后把距离过近的中心合并最后输出3到5个主色。这个方案兼顾了灵活性和稳定性是我最终采用的方案。合并逻辑比较简单如果两个聚类中心的欧氏距离小于阈值就按像素数量加权合并成一个颜色。实际项目中大家的需求五花八门把K值直接暴露给上层让调用方自己定是最省事也最不会出错的做法。4.2 色彩空间对比实测记录我自己做过一组评测选了三张风格不同的图一张风景照蓝天绿树渐变多、一张矢量插画色块分明、一张美食照片暖色调为主。用RGB、HSV带环形校正、Lab三种空间分别提取主色然后让几个人盲评“哪个结果更像图片的主色调”。结果是色彩空间风景照表现插画表现美食照表现综合评分RGB偏灰蓝天色被拆分良好色块分明偏暗食欲感丢失7分HSV环形校正色调准确但饱和度过高优秀偏浓艳8分Lab最接近人眼感知优秀自然饱满9分RGB在低饱和、渐变多的图上问题最明显因为欧氏距离在RGB里会把相近亮度的不同颜色拉近导致聚类中心偏向灰色区域。Lab空间是明确的最优选择。HSV做环形校正后也不错但处理环形距离的代码量不小没必要。4.3 MiniBatchKMeans 和标准 KMeans 的取舍标准KMeans在200x200的4万像素点上跑5个簇大约需要200到400毫秒其实并不慢。MiniBatchKMeans在同一场景下只需要30到60毫秒差距在5倍左右。但MiniBatchKMeans有一个小缺点由于批量采样有随机性即使设置了random_state聚类中心也可能有微小的批次间抖动。在严格的A/B测试场景下可能对结果一致性有要求。这时候可以回退到标准KMeans。我项目里的做法是提供了一个use_minibatch参数默认True追求稳定复现的场景改成False。4.4 阈值合并相近颜色这一步是提升结果质量的隐藏大招。比如一张蓝天白云的图片K-means聚类后可能给出两个相近的蓝色一个浅蓝一个深蓝人眼看会觉得它们是同一个色系。这时候做合并def merge_close_colors(colors, ratios, threshold30): 合并欧氏距离小于阈值的聚类中心 merged_colors [] merged_ratios [] for color, ratio in zip(colors, ratios): merged False for i, existing in enumerate(merged_colors): dist np.sqrt(np.sum((color - existing) ** 2)) if dist threshold: # 按像素占比加权合并颜色 total_ratio ratio merged_ratios[i] merged_colors[i] (existing * merged_ratios[i] color * ratio) / total_ratio merged_ratios[i] total_ratio merged True break if not merged: merged_colors.append(color) merged_ratios.append(ratio) # 按占比重新排序 sorted_idx np.argsort(merged_ratios)[::-1] return np.array(merged_colors)[sorted_idx], np.array(merged_ratios)[sorted_idx]阈值30在RGB空间里是一个经验值可以在20到50之间调整。太小合并不了近似色太大又会过度合并导致损失色彩丰富度。5. 常见问题与排查技巧实录5.1 OpenCV读取图片返回None最常见的问题是路径带中文或者图片格式特殊。OpenCV的imread在遇到中文路径时经常返回None这是个老毛病3.x版本就存在。解决方案是先用np.fromfile读取再解码def imread_unicode(path): 支持中文路径的图片读取 data np.fromfile(path, dtypenp.uint8) img cv2.imdecode(data, cv2.IMREAD_COLOR) return img实测这个方案在Windows和Linux上都可以稳定工作。5.2 聚类结果中白色或黑色占比过高这个问题在电商场景里很常见商品图经常是大面积白底。聚类结果第一个主色往往是白色第二个才是商品颜色。如果目标是提取商品自身的主色白底就是干扰。我提供的方案是背景色剔除检测聚类中心中白色占比超过某个阈值比如40%就把它从结果里去掉然后顺延下一个主色。这些离散的细节并不复杂但它让工具在多场景下更可靠。5.3 MiniBatchKMeans结果不稳定如果你发现两次跑同样一张图输出颜色有轻微差别这就需要对n_init和batch_size做调整。提高n_init到10缩小batch_size到2048结果会更稳定但耗时略有增加。另外设定random_state是个好习惯。除非你刻意做随机性分析否则固定随机种子能让结果可复现。5.4 色彩转换的数值陷阱Lab空间转回RGB时如果聚类中心的值不在合法范围内比如聚类中心的L值可能超出0到100的范围转换出来的RGB会出现越界。我实测发现加上clip操作更安全centers_lab_clipped np.clip(centers_lab, 0, 255)六、实操心得与后续扩展方向整个项目做下来最大的体会是“取主色”看似是个微不足道的小功能但真正生产化的时候处处是细节。色彩空间的选择比调K值影响更大合理的降采样比堆算力更管用后处理合并比无脑加大K值更有效。这套取色工具后续可以做很多方向的功能扩展比如按区域取色、按主体分割后再取色、视频关键帧的主色提取等等。我目前在工作里主要用它来自动生成配色调色板再接上CSS变量输出设计同学直接就能拿来用省去了一张张素材里挑色的功夫。最后分享一个小技巧如果你输出的主色要用于网页或者UI界面记得把RGB转成带透明度友好的Hex或者HSL格式这比打印RGB三元组直观得多。我自己在格式化输出中默认使用Hex很大程度上是因为实际使用场景里直接复制粘贴更方便。