文字 图片主色调提取算法详解:K-Means、中位切分与八叉树
文字
取消

图片主色调提取算法详解:K-Means、中位切分与八叉树

最近突发奇想,想要在博客中嵌入一个音乐播放器的组件,效果类似QQ音乐:

qq

qq

背后的蒙版的颜色是专辑图片的主色决定的,需要一个提取颜色色盘的工具,所以写了这个库。

这个库的背后的核心操作只有一件事:

颜色量化(Color Quantization):给定一张包含数百万种颜色的图片,找出其中最具代表性的 N 种颜色。

解决这个问题有三个的经典算法——K-Means 聚类中位切分(Median Cut)八叉树量化(Octree)。于是研究了一下,做个笔记。


0. 先建立直觉:颜色是三维空间里的点

在计算机里,每个像素由三个数字表示:红(R)、绿(G)、蓝(B),范围均为 0–255。你可以把这三个数字想象成三维坐标——每一种颜色,就是 RGB 空间里的一个点

1
2
3
4
5
6
7
8
9
         B(蓝) ↑
               │    ● (120, 80, 200)  ← 紫色
               │
               │         ● (240, 60, 60)  ← 红色
               │
               └──────────────────→  R(红)
              /   ● (30, 180, 90)  ← 绿色
             ↙
           G(绿)

一张 800×600 的图片有 480,000 个像素,就是 RGB 空间里最多 480,000 个点(很多会重叠)。颜色量化的任务,就是把这些密密麻麻的点归纳成 N 个”代表”。

为什么不能直接取平均值?

你可能会想:把所有像素颜色加起来除以总数不就行了?

不行。 对一张红蓝各半的图取均值,结果是紫色——但图里根本没有紫色。平均值会破坏颜色信息。

正确的做法是:先把颜色相近的像素归为一组,再对每组分别取均值。这就是所有颜色量化算法的共同思路,差别只在于”如何分组”。


1. K-Means 聚类:像牧羊人归拢羊群

1.1 核心思想

K-Means 的思路非常直觉化,想象这样一个场景:

你是一位牧羊人,草场上散布着几百只羊(像素)。你在草场上插 K 面旗帜(质心),让每只羊跑向离自己最近的旗帜,然后把旗帜移到当前羊群的中心——反复重复,直到旗帜不再移动为止。

翻译成算法语言,就是四步:

  1. 放置 K 个初始质心
  2. 每个像素找离自己最近的质心,加入该簇
  3. 每个簇重新计算质心(取所有像素的颜色均值)
  4. 重复步骤 2–3,直到质心不再移动(收敛)
1
2
3
4
5
6
初始状态(K=2):         第一轮分配:              收敛后:
  ● ● ● ★(质心A)         [A组] ● ● ●             ● ● ●
  ● ●                    [A组] ● ●              ★(新质心A)
       ● ● ★(质心B)      [B组]    ● ●             ● ●
          ● ●            [B组]       ● ●      ★(新质心B)  ● ●
                                                   已稳定

1.2 距离怎么算?

两个颜色点的距离用欧氏距离的平方(省去开根号,比大小结果一样但计算更快):

1
2
3
4
dist² = (r1-r2)² + (g1-g2)² + (b1-b2)²

// 示例:红色(255,0,0) 与 橙色(255,128,0)
dist² = 0² + (-128)² + 0² = 16384

1.3 初始质心怎么选?——K-Means++ 的妙招

K-Means 最大的痛点是:初始质心的位置严重影响最终结果。如果一开始所有质心都扎堆在图片某个角落,算法很可能陷入糟糕的局部解。

K-Means++ 的思路:让初始质心尽量”分散”。第一个质心随机选;之后每新选一个质心,离已有质心越远的像素,被选中的概率越高——权重正比于到最近已有质心的距离平方(D²):

1
2
3
4
5
6
7
8
9
10
11
# K-Means++ 初始化(伪代码)
centroids = [随机选一个像素]

for i in 1..K:
    # 每个像素的权重 = 到最近质心的距离平方
    weights[p] = min(dist²(p, c) for c in centroids)

    # 按权重随机选下一个质心
    # 距离越远 → 权重越大 → 被选中概率越高
    next_centroid = weighted_random_choice(pixels, weights)
    centroids.append(next_centroid)

这个改进让结果质量大幅提升,同时收敛更快。

1.4 空簇问题:容易被忽视的陷阱

有时某个质心会在某轮迭代后失去所有归属像素,变成”空簇”。朴素实现会直接丢弃它,导致最终颜色数量少于预期。

优雅的处理方式是从最大簇借一个像素

1
2
3
4
5
if cluster_size[i] == 0 {
    // 空簇:从像素最多的簇随机借一个像素作为新质心
    let largest = argmax(&cluster_size);
    new_centroid = random_pixel_from(&cluster[largest]);
}

1.5 收敛判断

设置一个阈值,当所有质心的移动量都低于该值时提前终止:

1
2
3
4
// 当所有质心移动距离均小于阈值时停止
if max_centroid_shift < 1.0 {  // 1.0 是 RGB 距离单位
    break;
}

1.6 优缺点总结

优点 ✅ 缺点 ⚠️
颜色质量最高,聚类结果最自然 非确定性:不同种子可能得到不同结果
K-Means++ 初始化大幅改善质量 速度较慢,复杂度 O(n·k·i)
可通过固定种子复现结果 对初始化敏感,可能收敛到局部最优
支持收敛阈值,可提前终止 空簇需要额外处理

2. 中位切分:像切西瓜一样划分颜色空间

2.1 核心思想

中位切分(Median Cut)由 Paul Heckbert 在 1982 年提出。它的思路极其优雅:

想象你面前有一个装满彩色玻璃球的盒子(所有像素在 RGB 立方体里)。你的任务是把它们分成 K 组——每次切分时,选最大的那个盒子,找它颜色范围最宽的那个维度,从正中间切开。重复 K-1 次,得到 K 个小盒子,每个盒子的平均颜色就是代表色。

1
2
3
4
5
6
7
8
9
10
11
12
初始:一个大盒子(所有像素)

  ┌─────────────────────────┐
  │  R范围: 0-255 → 最宽    │
  │  G范围: 20-180          │
  │  B范围: 10-120          │
  └─────────────────────────┘
              ↓ 沿 R 轴在中位切分
  ┌────────────┐  ┌─────────────┐
  │ R: 0-127  │  │ R: 128-255 │
  └────────────┘  └─────────────┘
    再找最大盒子继续切分...

2.2 为什么选”最宽的轴”?

核心直觉:颜色分布越分散的方向,信息量越大,切割收益越高。

举个例子:某个色块里所有像素的蓝色分量都集中在 100–110,但红色分量散布在 0–255。沿蓝色轴切毫无意义(切完两边差异极小),沿红色轴切才能有效分离颜色。

1
2
3
4
5
6
7
fn longest_axis(pixels: &[[u8; 3]]) -> usize {
    let r_range = max_r - min_r;
    let g_range = max_g - min_g;
    let b_range = max_b - min_b;
    // 哪个范围最大就沿哪个方向切
    argmax([r_range, g_range, b_range])
}

2.3 为什么选”中位”而不是”均值”?

这是理解算法的关键问题。

中位数保证两边的像素数量相等。如果按均值切,遇到颜色极度偏斜的分布(比如 90% 像素是深蓝,10% 是鲜红),切出来的两个盒子大小悬殊,最终调色板质量会变差。

1
2
3
4
5
6
7
8
9
10
像素分布(沿 R 轴):

  数量 │▇▇▇▇▇▇▇▇▇▇
       │▇▇▇▇▇▇▇▇▇▇▇▇
       │▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
       │▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇▇
       │▇▇▇▇▇▇  ▇▇▇▇▇
       └─────────┬──────→  R值
                 ↑
           中位切分点(左右各50%像素)

2.4 完整的一次切分

1
2
3
4
5
6
7
fn split_bucket(mut pixels: Vec<[u8; 3]>) -> (Vec<[u8; 3]>, Vec<[u8; 3]>) {
    let axis = longest_axis(&pixels);     // 1. 找最宽的通道
    pixels.sort_unstable_by_key(|p| p[axis]); // 2. 按该通道排序
    let mid = pixels.len() / 2;          // 3. 找中点
    let right = pixels.split_off(mid);   // 4. 切成两半
    (pixels, right)
}

2.5 主循环:每次切最大的盒子

1
2
3
4
5
6
7
while buckets.len() < k {
    // 选像素最多的盒子(优先细分颜色最杂的区域)
    let target = argmax_by(|b| b.len());
    let (left, right) = split_bucket(buckets.remove(target));
    buckets.push(left);
    buckets.push(right);
}

为什么选”像素最多”的?因为像素越多的盒子代表性越差,优先细分它能最快提升整体质量。

2.6 优缺点总结

优点 ✅ 缺点 ⚠️
完全确定性,相同输入必得相同结果 质量略低于 K-Means(无迭代优化)
实现简单,逻辑清晰 对颜色极度集中的图片效果稍差
速度快:O(n·k·log n) 切分是轴对齐的,不适应斜向颜色簇
不存在收敛问题,一次完成

3. 八叉树量化:把 RGB 立方体层层细分

3.1 核心思想

八叉树量化(Octree Quantization)由 Gervautz 和 Purgathofer 在 1988 年提出。它用一种叫”八叉树”的数据结构来组织颜色——每个节点有 8 个子节点,因为 RGB 三个维度各取一半,2×2×2 = 8 个子空间。

1
2
3
4
5
6
7
8
9
10
11
RGB立方体(深度0)
  ┌───────────┐
  │  根节点   │  ← 整个 RGB 空间
  └───────────┘
        │ 按 R/G/B 最高位各取1位 → 8个子节点
        ↓
  ┌──┬──┬──┬──┬──┬──┬──┬──┐
  │00│01│02│03│04│05│06│07│  ← 深度1(8个子空间)
  └──┴──┴──┴──┴──┴──┴──┴──┘
        │ 继续细分,最深到第8层
  (每个叶子精确对应一个 RGB 颜色)

关键直觉:越相近的颜色,在越高层(低深度)就会走向相同的子节点,在更深层才会分叉。 相近的颜色自然地聚集在树的同一子树里。

3.2 子节点索引怎么算?

这是八叉树最精妙的部分。对于像素 (R, G, B),在树的第 d 层,取每个通道的第 d 位(从高位到低位),组合成一个 3 位索引(0–7):

1
2
3
4
5
6
7
fn octant_index(pixel: [u8; 3], depth: usize) -> usize {
    let shift = 7 - depth;  // 第0层取最高位(bit7),第7层取最低位(bit0)
    let r_bit = ((pixel[0] >> shift) & 1) as usize;
    let g_bit = ((pixel[1] >> shift) & 1) as usize;
    let b_bit = ((pixel[2] >> shift) & 1) as usize;
    (r_bit << 2) | (g_bit << 1) | b_bit  // 0–7
}

举个具体例子——颜色 (200, 80, 180)

1
2
3
4
5
6
7
8
9
10
11
R = 200 = 1100_1000b
G =  80 = 0101_0000b
B = 180 = 1011_0100b

深度 0(取最高位,即 bit7):
  r_bit=1, g_bit=0, b_bit=1 → index = 101b = 5 → 走向第5号子节点

深度 1(取 bit6):
  r_bit=1, g_bit=1, b_bit=0 → index = 110b = 6 → 走向第6号子节点

... 8层走完后,精确定位到这个颜色

3.3 插入像素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn insert(node: &mut OctreeNode, pixel: [u8; 3], depth: usize, leaf_count: &mut usize) {
    if depth == 8 {  // 到达叶节点
        node.r_sum += pixel[0] as u64;
        node.g_sum += pixel[1] as u64;
        node.b_sum += pixel[2] as u64;
        node.count += 1;
        if !node.is_leaf {
            node.is_leaf = true;
            *leaf_count += 1;
        }
        return;
    }
    let idx = octant_index(pixel, depth);
    // 按需创建子节点并递归插入
    insert(&mut node.children[idx], pixel, depth + 1, leaf_count);
}

3.4 规约(Reduce)——如何缩减颜色数

插入完所有像素后,叶节点可能有数万个。需要将它们合并到 K 个。

规约操作:找到一个拥有多个叶子子节点的内部节点,把它的所有叶子”吸收”到自己身上——叶子的颜色数据累加进来,该节点本身变成叶节点。

1
2
3
4
5
6
7
8
9
合并前:
  [内部节点]
  ├── 叶子A(count=80,  代表红色)
  ├── 叶子B(count=120, 代表深红)
  └── 叶子C(count=40,  代表粉红)

合并后:
  [新叶节点](count=240,代表色 = 加权平均的混合红)
  r_sum = 80×rA + 120×rB + 40×rC  →  r = r_sum / 240

叶节点数从 3 变为 1(减少了 2)。优先合并最深层的节点,这样损失的颜色精度最小。

3.5 即时规约——内存优化的关键

如果等所有像素插入完再开始规约,内存峰值可能非常高。更聪明的做法是边插边规约

1
2
3
4
5
6
7
8
9
10
11
12
13
for &pixel in all_pixels {
    tree.insert(pixel, 0, &mut leaf_count);

    // 即时规约:叶节点数超过预算 8 倍时触发
    while leaf_count > k * 8 {
        if !tree.reduce(&mut leaf_count) { break; }
    }
}

// 最终规约到 K
while leaf_count > k {
    tree.reduce(&mut leaf_count);
}

这将内存峰值从 O(唯一颜色数) 降低到接近 O(K),处理大图时收益显著。

3.6 收集最终调色板

1
2
3
4
5
6
7
8
9
10
11
12
fn collect_leaves(node: &OctreeNode, palette: &mut Vec<Color>, total: f32) {
    if node.is_leaf && node.count > 0 {
        let r = (node.r_sum / node.count) as u8;
        let g = (node.g_sum / node.count) as u8;
        let b = (node.b_sum / node.count) as u8;
        palette.push(Color { r, g, b, percentage: node.count as f32 / total });
        return;
    }
    for child in node.children.iter().flatten() {
        collect_leaves(child, palette, total);
    }
}

3.7 优缺点总结

优点 ✅ 缺点 ⚠️
速度快:O(n·log n) 实现复杂度高于中位切分
完全确定性 规约策略影响颜色质量,较难调优
即时规约,内存峰值最低 合并顺序不同可能导致质量差异
处理大图或批量任务最稳

4. 三种算法横向对比

4.1 性能与质量一览

算法 速度 颜色质量 确定性 时间复杂度
K-Means 聚类 中等 ★★★ 最高 否(可设种子) O(n·k·i),i=迭代次数
中位切分 ★★☆ 良好 O(n·k·log n)
八叉树量化 ★★☆ 良好 O(n·log n),内存最优

4.2 算法选择决策树

1
2
3
4
5
6
7
8
9
10
11
12
你最在意什么?
         │
    ─────┴──────────────────────────────────
    │                    │                  │
  颜色质量              速度/确定性        内存占用
    │                    │                  │
    ▼                    │                  ▼
 K-Means           ──────┴──────          Octree
                   │           │
              图片较小      大批量/大图
                   │           │
              MedianCut     Octree

4.3 各场景推荐

使用场景 推荐算法 理由
网页/App 主题色提取 K-Means 视觉效果优先
专辑封面调色板生成 K-Means 颜色质量最重要
图片压缩(颜色减少) 中位切分 速度快、确定性,工业首选
服务端批量处理 八叉树 内存占用低,吞吐量高
嵌入式 / WebAssembly 八叉树 资源受限环境,内存控制关键
需要完全一致的结果 中位切分 最简单的可复现方案

5. 实现时容易踩的坑

5.1 采样优化——最值得做的性能优化

三种算法的复杂度都含 n(像素数)。一张 4K 图有 800 多万像素,直接处理很慢。

解决方案: 先把图片缩放到较小尺寸(如 256×256),再提取颜色。颜色分布的”形状”几乎不变,但像素数从 800 万降到 6.5 万——速度提升约 100 倍,质量几乎无损

1
2
3
4
5
// 下采样到 256px(最长边),然后提取颜色
let img = image.thumbnail(256, 256);
let pixels: Vec<[u8; 3]> = img.pixels()
    .map(|p| [p[0], p[1], p[2]])
    .collect();

5.2 透明像素的处理

处理 PNG 图片时,完全透明的像素(alpha=0)必须跳过,否则白色或黑色背景会混入调色板:

1
2
3
4
5
6
// RGBA → 跳过全透明像素
let pixels: Vec<[u8; 3]> = rgba_data
    .chunks_exact(4)
    .filter(|p| p[3] > 0)           // 跳过 alpha == 0
    .map(|p| [p[0], p[1], p[2]])    // 只保留 RGB
    .collect();

5.3 K 值怎么选?

用途 建议的 K
UI 主题 / 调色板预览 4–6
数据可视化配色 6–8
特征提取 / 图片相似度比较 8–16
图片压缩(GIF 等) 16–256

总结

三种算法本质上都在做同一件事——把 RGB 空间里密集的像素云归纳成 K 个代表点——只是分组策略不同:

  • K-Means 聚类:像牧羊人归拢羊群,迭代收敛找最优中心,质量最高,有随机性。
  • 中位切分:像切西瓜,每次沿最宽维度切一刀,快速确定,工程上最成熟。
  • 八叉树量化:把 RGB 立方体层层细分,插入时即时控制内存,适合大规模处理

理解了”颜色 = RGB 空间中的点”这个基础模型,三种算法的设计思路就都变得自然而然了。

如果你想上手实验,可以用 Python 的 colorthiefscikit-learn KMeans 直接验证,或者参考本文配套的 Rust 实现(dominant-colors),三种算法均有完整代码和测试。

本文由作者按照 CC BY 4.0 进行授权