洪哥的博客里面,当你点进去一篇的时候,会根据文章封面的图片的主色来生成文章顶部背景的颜色,那我们来通过 js 来简单实现一下这个功能

概念

何为图片的主色?图片的主色是指在一幅图像中占据较大比例并且最具代表性的颜色。

我们知道图片是由一个个带有颜色的像素点组成的,简单理解的话,在图片中出现次数最多的那个颜色就是图片的主色

但是实际上提取主色没这么简单,我们这里先讨论简单实现,后面会进一步说明如何提高准确度

思路

从概念可以得知,我们只需要获取图片中出现次数最多的那个像素的颜色就行了

对于这种图片处理,我们可以使用 canvas API 来进行操作

首先将图片绘制到 canvas 上,然后就获取 canvas 上每一个像素点的颜色信息并保存到一个 map 中,最后获取 map 中记录次数最多的那个颜色

准备

首先先准备一张图片(最好颜色丰富一点),然后准备一个 html 文件,最后还需要部署一个本地服务器,因为要获取 canvas 绘制的图片的数据的话,需要这个一个有域名的图片,要不然会报跨域这个错,可以使用 http-server 或者使用 vscode 的 Live Server 插件来解决这个问题

实现

编写一个函数,接受一个图片的 src 地址,获取到图片后画到 canvas 上然后获取像素数据,再进行简单的提取和排序形成一个对象,提取颜色需要一定的时间,所以最好是异步,这里直接返回一个 Promise 对象,Promise 的结果是一个对象,包含颜色出现次数最多的那个颜色的 rgba、rgb 和 count,代码整体比较简单,没有什么难懂的地方,稍微难一点的都加了注释这里不再说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
const getImageMainColor = (src) => {
// 获取所有像素点的rgba数据,返回一个数组
const getRGBAArr = (pixelData) => {
const colorList = []; // 颜色数据列表,每一个元素都是 {rgba, rgb, count}
const rgba = []; // rgba 数组
let rgbaStr = "";
let rgbStr = "";
const colorMap = new Map();
// 分组循环
for (let i = 0; i < pixelData.length; i += 4) {
rgba[0] = pixelData[i];
rgba[1] = pixelData[i + 1];
rgba[2] = pixelData[i + 2];
rgba[3] = pixelData[i + 3];
// 判断像素是否透明,如果透明就不添加了
if (rgba.indexOf(undefined) !== -1 || pixelData[i + 3] === 0) {
continue;
}
// 将rgba数组拼接成一个rgba字符串
rgbaStr = rgba.join(",");
rgbStr = rgba.slice(0, 3).join(",");
// 如果colorMap中存在这个rgba字符串,就将count加1,否则就添加到colorMap中
if (colorMap.has(rgbaStr)) {
colorList[colorMap.get(rgbaStr)].count++;
} else {
colorMap.set(rgbaStr, colorList.length);
colorList.push({
rgba: `rgba(${rgbaStr})`,
rgb: `rgb(${rgbStr})`,
count: 1,
});
}
}
// 排序
colorList.sort((a, b) => {
return b.count - a.count;
});
return colorList;
};
// 获取图片的主色
const getImageColor = (canvas, img) => {
const ctx = canvas.getContext("2d");
// 将图片绘制到canvas上
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 获取图片像素数据
const pixelData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
return getRGBAArr(pixelData);
};
return new Promise((resolve, reject) => {
try {
const canvas = document.createElement("canvas");
const image = new Image();
image.crossOrigin = "anonymous"; // 设置允许跨域
image.src = src;
image.onload = () => {
canvas.height = image.height; //设置画布的尺寸大小
canvas.width = image.width;
const color = getImageColor(canvas, image);
// 返回数组中的第一个,也就是出现次数最多的那个颜色,因为数组已经排序过了
resolve(color[0]);
};
} catch (e) {
reject(e);
}
});
};

结果预览

示例图片

提取的颜色预览

示例颜色

优化

现在我们最基本的要求算是实现了,但是还有很多要优化的地方

首先对性能进行优化

我们先思考一下,一张图片如果稍微大一点几 MB 的话,那么是不是有几百万的像素甚至千万,那么我们要循环上千万次进行计算吗,显然是没必要的,如果对图片整体缩放 10 倍的话,图片整体的颜色不会发生肉眼可见的变化,除非你的图片很小很小。

基于此,我们可以对图片进行缩放,比如先缩小 10 倍再画到 canvas 上,这样就可以减少 10 倍的计算量

接着我们对结果进行优化

我们先思考,我们大部分常用的图片的主色都不会是黑色或者白色,那我们是不是可以过滤掉这些颜色,这样不仅可以减少计算量,还可以提高准确度

所以我们可以优化代码如下,在获取到图片之后先缩小 10 倍再画到 canvas 上,然后再对图片像素循环的时候,过滤掉我们不需要的颜色

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
const getImageMainColor = (src) => {
// 获取所有像素点的rgba数据,返回一个数组
const getRGBAArr = (pixelData) => {
const colorList = []; // 颜色数据列表,每一个元素都是 {rgba, rgb, count}
const rgba = []; // rgba 数组
let rgbaStr = "";
let rgbStr = "";
const colorMap = new Map();
// 分组循环
for (let i = 0; i < pixelData.length; i += 4) {
rgba[0] = pixelData[i];
rgba[1] = pixelData[i + 1];
rgba[2] = pixelData[i + 2];
rgba[3] = pixelData[i + 3];
// 判断像素是否透明,如果透明就不添加了
if (rgba.indexOf(undefined) !== -1 || pixelData[i + 3] === 0) {
continue;
}
// 过滤掉近乎纯黑和纯白
if (
(rgba[0] > 220 && rgba[1] > 220 && rgba[2] > 220) ||
(rgba[0] < 30 && rgba[1] < 30 && rgba[2] < 30)
) {
continue;
}
// 将rgba数组拼接成一个rgba字符串
rgbaStr = rgba.join(",");
rgbStr = rgba.slice(0, 3).join(",");
// 如果colorMap中存在这个rgba字符串,就将count加1,否则就添加到colorMap中
if (colorMap.has(rgbaStr)) {
colorList[colorMap.get(rgbaStr)].count++;
} else {
colorMap.set(rgbaStr, colorList.length);
colorList.push({
rgba: `rgba(${rgbaStr})`,
rgb: `rgb(${rgbStr})`,
count: 1,
});
}
}
// 排序
colorList.sort((a, b) => {
return b.count - a.count;
});
return colorList;
};
// 获取图片的主色
const getImageColor = (canvas, img) => {
const ctx = canvas.getContext("2d");
// 将图片绘制到canvas上
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// 获取图片像素数据
const pixelData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
return getRGBAArr(pixelData);
};
return new Promise((resolve, reject) => {
try {
const canvas = document.createElement("canvas");
const image = new Image();
image.crossOrigin = "anonymous"; // 设置允许跨域
const shrinkFactor = 10; // 图片缩放比例
image.src = src;
image.onload = () => {
// 设置canvas的大小为图片缩放10倍后的大小
canvas.height = image.height / shrinkFactor;
canvas.width = image.width / shrinkFactor;
const color = getImageColor(canvas, image);
// 返回数组中的第一个,也就是出现次数最多的那个颜色,因为数组已经排序过了
resolve(color[0]);
};
} catch (e) {
reject(e);
}
});
};

进阶与尾声

到这里我们还可以继续思考,其实大部分情况下我们提取的主色应该不是颜色出现最多的那个,而是一个综合的颜色,比如一张图片如果颜色很丰富的话,那么他的主色就不应该是某一个占比比较多的色块的颜色,而应该是一种颜色的组合,比如红色、黄色、蓝色、绿色等等,这样才能更准确的描述这张图片的主题。

我们可以对提取到的所有颜色进行排序,然后选取其中出现次数前五的那个颜色进行综合,比如颜色加起来再除以 5 获得一个平均颜色,或者自己创建一个合适的算法进行计算都可以

这里以前五个颜色的平均值作为主色,代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
const getImageMainColor = (src) => {
const getRGBAArr = (pixelData) => {
const colorList = [];
const rgba = [];
let rgbaStr = "";
let rgbStr = "";
const colorMap = new Map();
// 分组循环
for (let i = 0; i < pixelData.length; i += 4) {
rgba[0] = pixelData[i];
rgba[1] = pixelData[i + 1];
rgba[2] = pixelData[i + 2];
rgba[3] = pixelData[i + 3];
if (rgba.indexOf(undefined) !== -1 || pixelData[i + 3] === 0) {
continue;
}
rgbaStr = rgba.join(",");
rgbStr = rgba.slice(0, 3).join(",");
if (colorMap.has(rgbaStr)) {
colorList[colorMap.get(rgbaStr)].count++;
} else {
colorMap.set(rgbaStr, colorList.length);
colorList.push({
rgba: `rgba(${rgbaStr})`,
rgb: `rgb(${rgbStr})`,
count: 1,
r: rgba[0],
g: rgba[1],
b: rgba[2],
});
}
}
colorList.sort((a, b) => {
return b.count - a.count;
});
return colorList;
};
const getImageColor = (canvas, img) => {
const ctx = canvas.getContext("2d");
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const pixelData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
return getRGBAArr(pixelData);
};
return new Promise((resolve, reject) => {
try {
const canvas = document.createElement("canvas");
const image = new Image();
const shrinkFactor = 10;
image.crossOrigin = "anonymous";
image.src = src;
image.onload = () => {
canvas.height = image.height / shrinkFactor;
canvas.width = image.width / shrinkFactor;
const colors = getImageColor(canvas, image);
let r = 0,
g = 0,
b = 0;
colors.slice(0, 5).forEach((rgba) => {
r += rgba.r;
g += rgba.g;
b += rgba.b;
});
r = Math.round(r / 5);
g = Math.round(g / 5);
b = Math.round(b / 5);
resolve(`rgb(${r}, ${g}, ${b})`);
};
} catch (e) {
reject(e);
}
});
};

结果预览

示例图片

颜色预览

示例颜色

我们拿到了图片所有的颜色数据,所以我们可以任意修改,比如对颜色取反,就是用 255 减去现在的颜色也可以得到一个很特殊的效果,或者我们可以对颜色进行加权平均等等。