原文参考我的公众号文章 进阶!八叉树色彩量化法提取图片主要颜色
体验地址,接我上一篇文章!
大致思路
- 1.构建八叉树,将 rgb 颜色循环添加到树里(相同的颜色会跑到同一层的同一个叶子结点上,累加 count);
- 2.当叶子结点数量超过指定最大颜色数时,进行叶子结点合并(相似的颜色会跑到同一个父节点,与兄弟结点同级,因此可以合并到他们的父节点);
- 3.计算调色盘(递归遍历八叉树,累加所有叶子节点的 r,g,b 颜色值再平均);
为什么是八叉树?
比如有这么一个颜色 rgb = [31, 31, 30],给出它的入树过程!
//r = 31 => 00011111
//g = 31 => 00011111
//b = 30 => 00011110
r,g,b每个颜色位转换成了8位二进制用0,1表示,同时从左到右取r,g,b的每一位组成3位二进制数。
比如取第1位,组成列:000,转换成十进制就是 0,进入八叉树中第1层,的第1个结点;
比如取第2位,组成列:000,转换成十进制就是 0,进入八叉树中第2层,的第1个结点;
比如取第3位,组成列:000,转换成十进制就是 0,进入八叉树中第3层,的第1个结点;
比如取第4位,组成列:111,转换成十进制就是 7,进入八叉树中第4层,的第8个结点;
比如取第5位,组成列:111,转换成十进制就是 7,进入八叉树中第5层,的第8个结点;
比如取第6位,组成列:111,转换成十进制就是 7,进入八叉树中第6层,的第8个结点;
比如取第7位,组成列:111,转换成十进制就是 7,进入八叉树中第7层,的第8个结点;
比如取第8位,组成列:110,转换成十进制就是 6,进入八叉树中第8层,的第7个结点(叶子结点,累加r,g,b分量,累加pixelCount);
按照上述规则,能够将重复出现的颜色累加到第 8 层的某一个叶子结点中!
网上找了个图形象说明一下!
(图片来源于网络)
// 比如有这么几个像素颜色信息:
let pixels = [
[31, 31, 30],
[31, 31, 31],
[31, 31, 31],
]
// 最终得到颜色的统计情况会是:
let colors = { '31,31,30': 1, '31,31,31': 2 }
构建树结点
class OctTreeNode {
constructor(level) {
this.color = [];
this.level = level;
this.isLeaf = false;
this.pixelCount = 0; //该节点出现次数,当为叶子结点时,代表了某种颜色出现次数
this.r = 0; //red通道累加值
this.g = 0; //green通道累加值
this.b = 0; //blue通道累加值
this.children = [null, null, null, null, null, null, null, null]; //八个子节点
this.next = null; //在 reducible 链表中的下一个节点
}
}
依次载入像素信息,生成八叉树结构
function dec2bin(decnum, displayLength) {
let bin = decnum.toString(2);
if (displayLength) {
while (bin.length < displayLength) {
bin = `0${bin}`;
}
}
return bin;
}
function addColor(parentNode, color, level) {
if (!color) {
throw new Error("color must be provided,like [255, 0, 0]");
}
let [r, g, b] = color;
if (parentNode.isLeaf) {
// console.warn(`已满八层,是叶子节点,rgb(${color})`);
parentNode.pixelCount++;
parentNode.r += r;
parentNode.g += g;
parentNode.b += b;
return;
}
let binR = dec2bin(r, 8);
let binG = dec2bin(g, 8);
let binB = dec2bin(b, 8);
// 逐列合并bin,共3行8列,需递归执行8次,生成8层树结构
let concatColBin = `${binR[level]}${binG[level]}${binB[level]}`;
let index = bin2dec(concatColBin);
if (!parentNode.children[index]) {
parentNode.children[index] = createNode(level);
}
// 递归生成下一层
addColor(parentNode.children[index], color, level + 1);
}
为什么需要进行叶子结点合并?
一个图片中的颜色信息会非常多,经过八叉树量化后,某些像素颜色占比很大,有些很小。很小的原因可能是这种颜色本身就少,也有可能是因为它们是相似的颜色,那么将相似的颜色合并,减少提取的颜色躁点(数量极多却极其相似的颜色)浓缩就是精华!
为什么能将叶子节点合并?
let pixel1 = [31, 31, 30]
// 00011111
// 00011111
// 00011110
let pixel2 = [31, 31, 31]
// 00011111
// 00011111
// 00011111
对比pixel1
和pixel2
可以看出:
前七位完全一致,只有第八位(树中的第八层,叶子节点)是不一样的,说明这两个颜色是十分相近的颜色,
因此可以将叶子结点的r,g,b
分量和pixelCount
合并到他们的父节点去。合并后,叶子结点会被剔除,他们的父节点变成新的叶子结点。
如何合并叶子结点?
程序中有一个设计技巧,就是每一层的所有节点都用一个链表存储起来,reducible[i]
记录了这些链表的头节点,每一次合并操作都是从最底层开始,从下到上依次进行的。
/**
* 生成新结点时,顺便记录reducible链表头信息
*/
function createNode(level) {
let node = new OctTreeNode(level);
if (level == 7) {
node.isLeaf = true;
leafNum++;
} else {
// 将其丢到第 level 层的 reducible 链表中(这两行代码不能反,否则会出现循环引用)
node.next = reducible[level]; //此时reducible[level]为上一个颜色对应的
reducible[level] = node;
}
return node;
}
/**
* 合并叶子结点
*/
function reduceTree() {
// 找到最深层次的并且有可合并节点的链表
let lv = 6;
while (reducible[lv] == null) lv--;
// 取出链表头并将其从链表中移除
let node = reducible[lv];
reducible[lv] = node.next; //OctTreeNode or null
// 合并子节点
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < 8; i++) {
if (null === node.children[i]) continue;
r += node.children[i].r;
g += node.children[i].g;
b += node.children[i].b;
count += node.children[i].pixelCount;
leafNum--; //叶子数量减1(其实在树结构中存在,只是叶子结点的层级移到了上一层)
}
// 父节点变叶子节点,分量合并
node.isLeaf = true;
node.r = r;
node.g = g;
node.b = b;
node.pixelCount = count;
leafNum++;
}
统计颜色信息!
流程比较清晰:获取所有叶子节点r,g,b
分量和pixelCount
对应的平均值 color,以color
为键,出现次数count
为值,记录到一个palette
对象中,就得到了一个反映各个颜色占比的对象。最后再转换成数组,并按照count
进行降序排序。第一项就是主色调,后续项就是调色盘颜色组了。
/**
* 计算最终的颜色列表
* @param {*} node
* @param {*} paletteMap
* @returns
*/
function colorsStats(node, paletteMap) {
// 判断是否是叶子节点
if (node.isLeaf) {
// 计算当前颜色的平均
let r = parseInt(node.r / node.pixelCount);
let g = parseInt(node.g / node.pixelCount);
let b = parseInt(node.b / node.pixelCount);
let color = `${r},${g},${b}`;
// 统计当前颜色合并累计的次数
if (paletteMap[color]) paletteMap[color] += node.pixelCount;
else paletteMap[color] = node.pixelCount;
return;
}
// bfs递归处理颜色信息
for (let i = 0; i < 8; i++) {
node.children[i] && colorsStats(node.children[i], paletteMap);
}
}
最后调用得出指定数量的颜色
let leafNum = 0; //叶子结点数量
let reducible = [null, null, null, null, null, null, null]; //存储每一层链表的表头的数组
function colorThief(pixels = [], maxColors = 8) {
leafNum = 0;
let rootNode = new OctTreeNode(0);
pixels.map((item) => {
addColor(rootNode, item, 0);
// 边构建,边合并叶子结点!
while (leafNum > maxColors) reduceTree();
});
console.log("合并后的八叉树结构:", rootNode);
console.log(`共有${leafNum}个叶子结点`);
let paletteMap = {};
colorsStats(rootNode, paletteMap);
console.log("八叉树法提取的颜色有:", paletteMap);
let palette = [];
for (let key in paletteMap) {
palette.push({
color: `${key}`,
count: paletteMap[key],
});
}
palette.sort((a, b) => {
return b.count - a.count;
});
return palette;
}