承接上文我们用到了web worker优化图片取主色调逻辑,这篇文章主要讲一下ColorThief是如何实现图片取色的
取图片主色调算法逻辑
之前我们主要用了colorThief 库去实现,但由于worker不能操作dom元素,所以我把这部分直接抽离出来了
ini
export const getImgDominantColor = async (url: string) => {
if (isVideoSuffix(url)) return '000';
const colorThief = new ColorThief();
const img = await loadImageAsync(url);
const colorArr = colorThief.getColor(img);
const hexStr = colorArr.map((value) => value.toString(16).padStart(2, '0')).join('');
return hexStr;
};
现在的实现效果和之前的一样,趁这个机会我们可以了解下如何对一张图片取出主色调。
ini
const getDominantColor = (imageData: ImageData) => {
const options = validateOptions({
colorCount: 5, //我们到时候想要生成几个图片的主色调
//这个就是如果数字越大,颜色生成越快,但颜色丢失的可能性越大。
quality: 10, //(如果是10的话到时候生成像素数组就隔10个生成一个)
});
const pixelCount = imageData.width * imageData.height; //得到所有的像素数量
const pixelArray = createPixelArray(imageData.data, pixelCount, options.quality); //创建像素数组
const cmap = MMCQ.quantize(pixelArray, options.colorCount); //量化得到主色调
const palette = cmap ? cmap.palette() : null;
return palette[0];
};
创建像素数组
ini
//从图像像素数据中提取颜色值(RGB),并返回一个像素数组
function createPixelArray(imgData, pixelCount, quality) {
const pixels = imgData;
const pixelArray = [];//会收集满足条件的像素 RGB 值。
for (let i = 0, offset, r, g, b, a; i < pixelCount; i = i + quality) {
//读取每个像素的 RGBA 值
offset = i * 4;
r = pixels[offset + 0];
g = pixels[offset + 1];
b = pixels[offset + 2];
a = pixels[offset + 3];
// If pixel is mostly opaque and not white
//如果 a 不存在或大于等于 125(透明度范围是 0--255),表示该像素足够不透明。
if (typeof a === 'undefined' || a >= 125) {
//忽略接近纯白的像素
if (!(r > 250 && g > 250 && b > 250)) {
pixelArray.push([r, g, b]);
}
}
}
//从图像数据中抽取不透明、非白色像素的 RGB 值,并以降低采样率的方式提高处理效率,通常用于图像颜色分析,比如生成主色调、色卡、图像缩略图分析等。
return pixelArray;
}
Color Thief 用的是 quantize.js
,它实现了 Median Cut Quantization (中位切分算法):
可以先来看两张图:
彩色图像一般采用RGB色彩模式,每个像素由RGB三个颜色分量组成。随着硬件的不断升级,彩色图像的存储由最初的8位、16位变成现在的24位、32真彩色。所谓全彩是指每个像素由8位(28=0~255)表示,红绿蓝三原色组合共有1677万(256∗256∗256)万种颜色。
如果将RGB看作是三维空间中的三个坐标,可以得到下面这样一张色彩空间图:

当然,一张图像不可能包含所有颜色,我们将一张彩色图像所包含的像素投射到色彩空间中,可以更直观地感受图像中颜色的分布:

图像的每个像素可以表示为一个 RGB 值,比如 (128, 200, 90)
。那么一个图像就可以看作是一个 RGB 色彩空间中的很多点。
中位切分算法就是:
在这个 RGB 空间中不断地 切割像素集合,直到你切出你想要的颜色数量为止,然后取这些切分块(也叫"盒子")的平均颜色作为代表色。
直接举一个例子更好理解:
假设我们有以下像素颜色数据:
csharp
const pixels = [
[255, 0, 0], // 红色
[254, 0, 0], // 几乎红色
[0, 255, 0], // 绿色
[0, 254, 0], // 几乎绿色
[0, 0, 255], // 蓝色
[0, 0, 254], // 几乎蓝色
];
const maxcolors = 3;
这些值可以看作是三维空间中的点,形成了一个像素云。我们想从这些颜色中,提取出 3种主色(红、绿、蓝)。
生成颜色频次直方图
生成颜色直方图,统计每种压缩后的颜色在像素数组中出现的次数。
- 压缩颜色通道: 使用
sigbits
保留 RGB 每个通道的高位(常用5位),丢弃低位,减少颜色种类。 - 构建 索引 : 用
getColorIndex()
把压缩后的 RGB 值映射成一个唯一整数索引。 - 统计频率: 遍历所有像素,将每个颜色出现的次数存入直方图数组
histo
中。 - 返回结果: 返回的是一个数组
histo
,表示每种颜色出现的次数,用于后续的颜色聚类。
csharp
pixels = [
[255, 0, 0], // red
[254, 0, 0], // almost red
[0, 255, 0], // green
];
最终 histo 会记录:
红色 bin 出现 2 次
绿色 bin 出现 1 次
-
创建初始盒子(VBox)
你把整个像素集合看作一个大立方体(或长方体):
- X 轴是 R 值(0~255)
- Y 轴是 G 值(0~255)
- Z 轴是 B 值(0~255)
这个立方体包含了所有颜色点。
选择最长轴进行切割
我们现在要把这个大盒子一切为二。怎么切?
找最长的边:
- 如果 R 值的范围是
[10, 200]
,G 是[50, 180]
,B 是[30, 150]
- 那么 R 的范围最大(190),我们就按 R 值来切
按中位数切分
- 对所有像素点按 R 排序
- 找中位数位置,把这些像素一分为二
- 得到两个小盒子,每个盒子里的像素数量接近
⚠️ 为什么是中位数而不是平均值?
中位数保证了切分后的两块像素数量接近,不会一块很多,一块很少。
递归切割直到满足 colorCount
不断重复上面的操作:
- 每次选当前最长边
- 按中位数切成两个
- 直到你有了 N 个小盒子(比如 3 个代表 3 个主色)
Median Cut 的核心思想是:
不断把颜色盒子切成两半,直到你得到你想要的数量。
比如你要提取 3 种颜色,就要把颜色空间切成 3 块,每一块就是一个代表色区域。
计算每个盒子的平均颜色
对于每个盒子:
- 取所有像素的平均值
(avgR, avgG, avgB)
- 这个值就是这个盒子的代表色
这些代表色,就是你最后的主色调。
总结:
中位切分算法的原理很简单直接,将图像颜色看作是色彩空间中的长方体(VBox),从初始整个图像作为一个长方体开始,将RGB中最长的一边从颜色统计的中位数一切为二,使得到的两个长方体所包含的像素数量相同,重复上述步骤,直到最终切分得到长方体的数量等于主题颜色数量为止。
中位切分的优化
原始的中位切分法是按照颜色数量的中位数将长方体(vbox)切成两半的。Leptonica 中对此进行了优化,改成了先通过中位数将 vbox 分为左右两个vbox(只是分出左右,还未切割),然后从左右选出体积较大的vbox的中点进行切割。以下是作者原话:
Determine the cut planes, making sure that two vboxes are always produced. Generate the two vboxes and compute the sum in each of them. Choose the cut plane within the greater of the (left, right) sides of the bin in which the median pixel resides. Here's the surprise: go halfway into that side. By doing that, you technically move away from "median cut," but in the process a significant number of low-count vboxes are produced, allowing much better reproduction of low-count spot colors.
长方体体积大包含像素少问题
存在某些条件下,VBox 体积很大但只包含少量像素。解决的方法是,每次切分前先对所有 vbox 排序,再取出优先级最高的 vbox 进行中位切分。如果需要切割的 vbox 总数为 total,那么前 total * FractByPopulation
个 vbox 以 vbox包含的像素数
排序,后 total * (1-FractByPopulation)
个 vbox 以 包含像素数 * vbox体积
排序。
Leptonica 库中的 FractByPopulation 值为 0.85,在 quantize 库中为 0.75。 以上就是color thief实现图片取色的整体逻辑,具体的代码可以查看 lokeshdhakar.com/projects/co...