[色彩之美] 基于 k-means 的图像颜色提取

178 阅读3分钟

最近想把博客的前端翻新一下,换一套渲染方案,其中有一需求就是把博客文章头图的颜色进行分析,提取出图像的 主色 用来做当前文章的 theme , 举个例子:

:root{
  --siroi-primary-color: #ffee6f;
  --siroi-bg-color: #495057;
  --siroi-color-6: var(--siroi-primary-color);
  --siroi-color-1: color-mix(in srgb, var(--siroi-color-6) 0%, white);
  --siroi-color-2: color-mix(in srgb, var(--siroi-color-6) 25%, white);
  --siroi-color-3: color-mix(in srgb, var(--siroi-color-6) 50%, white);
  --siroi-color-4: color-mix(in srgb, var(--siroi-color-6) 70%, white);
  --siroi-color-5: color-mix(in srgb, var(--siroi-color-6) 100%, white);
  --siroi-font-color: rgb(from var(--siroi-bg-color) calc(255 - r) calc(255 - g) calc(255 - b));
}

我有如上默认的主题变量,当我的文章有头图的时候,我可以把 primary-colorbg-color 根据图片的色彩动态的去做调整。 在图形图像领域,有很多算法去解 颜色提取 的问题,常见的有 K-means , 八叉树 等,本文先使用前者进行提取,八叉树后续进行对比。

什么是 K-means

K-means 是一种无监督的机器学习算法,通过样本间的相似性对数据进行归类,使类内差距最小化,类间差距最大化,将数据分成若干组。

实现 K-means

以下的实现方案均基于 typescript ,参考了 pythonc++ 以及感谢 豆包 的帮助 。

随机初值的确定

初值的选择会最终影响算法的结果,在 K-means++ 算法中调整了初值的选择从而加速收敛, 本文采用简单的随机初值,即随机打散数据取前 K 个值。随机算法采用 Fisher-Yates,感谢 @zeroToHero 的实现:

    /**
     * @param array 
     * @see https://rzx007.github.io/nav/html-dom/ts-shuffle
     * @returns
     */
    static shuffle<T>(array: T[]): T[] {
        // 创建一个数组的副本,避免修改原数组
        const shuffledArray = [...array];
        let currentIndex = shuffledArray.length;
        // 当还有未处理的元素时
        while (currentIndex !== 0) {
            // 随机选择一个未处理的元素
            const randomIndex = Math.floor(Math.random() * currentIndex);
            currentIndex--;
            // 交换当前元素和随机选择的元素
            [shuffledArray[currentIndex], shuffledArray[randomIndex]] = [
                shuffledArray[randomIndex],
                shuffledArray[currentIndex],
            ];
        }
        return shuffledArray;
    }

颜色归类

距离函数的实现 是我任务在 K-means 比较重要的一步了,向量距离的度量 决定了最终结果。在色彩领域,色差 一直都是一个很有意思的课题,业界知名的 ant design 就采用 CIE1976 色彩空间,用 欧几里得距离 来计算该空间不同颜色的差异。 upload_kd6t70xqpmxkg5hchyn26zl9hj8g7q91.png 真正讨论色差问题也有很多论文可以借鉴,其他算法本文不在论述,本文为了简单就用 rgb 色彩并直接对齐加权计算:

rˉ=C1,R+C2,R2\bar{r} = \frac{C_{1,R} + C_{2,R}}{2}
ΔC=(2+rˉ256)×ΔR2+4×ΔG2+(2+255rˉ256)×ΔB2\Delta C = \sqrt{\left(2 + \frac{\bar{r}}{256}\right) \times \Delta R^2 + 4 \times \Delta G^2 + \left(2 + \frac{255 - \bar{r}}{256}\right) \times \Delta B^2}

具体实现如下:豆包改的位运算😋

    private colorDistance(c1: Color, c2: Color) {
        const dr = c1.r - c2.r;
        const dg = c1.g - c2.g;
        const db = c1.b - c2.b;
        const wr = c1.r + c2.r;
        const wrShift = wr >> 8; 
        const term1 = ((2 << 8) + wrShift) * dr * dr;
        const term2 = (4 << 8) * dg * dg;
        const term3 = ((2 << 8) + ((255 - wr) >> 8)) * db * db;
        const wd = (term1 + term2 + term3) >> 8;
        return Math.sqrt(wd);
     }

更新聚类中心

这步也简单实现,即把所有颜色的 r, g, b 依次相加求平均

    private updateCenters(clusters: Cluster[]): Color[] {
        return clusters.map((cluster) => {
            if (cluster.pixels.length === 0) return cluster.center;
            const sum = cluster.pixels.reduce(
                (acc, pixel) => ({
                    r: acc.r + pixel.r,
                    g: acc.g + pixel.g,
                    b: acc.b + pixel.b,
                }),
                { r: 0, g: 0, b: 0 }
            );

            return {
                r: Math.round(sum.r / cluster.pixels.length),
                g: Math.round(sum.g / cluster.pixels.length),
                b: Math.round(sum.b / cluster.pixels.length),
            };
        });
    }

在小样本数据下,最大迭代 100 次,如果提取收敛则中止,否则就用最后一次迭代结果。 最终效果如下: upload_0nhqdkz0vi5bf37ug390gb2ld1lsodwu.png upload_0dvaxk3r5jc0w4i6cz4lhfcffsrcex63.png

下一步便是如何选择提取出的颜色和应用了,我们下篇见。

参考链接

[1].geekdaxue.co/read/bornew…

[2].blog.csdn.net/qq_42261630…

[3].cie.co.at/publication…

[4].rzx007.github.io/nav/html-do…