原文连接:http://xcoder.in/2014/09/17/theme-color-extract/javascript
所谓主题色提取,就是对于一张图片,近似地提取出一个调色板,使得调色板里面的颜色能组成这张图片的主色调。java
以上定义为我我的胡诌的。node
你们不要太把个人东西当成严谨的文章来看,不少东西什么的都是我用我本身的理解去作,并无作多少考证。git
解析中都会以 Node.js 来写一些小 Demo。github
写该文章主要是为了对我这几天对于『主题色提取』算法研究进行一个小结。算法
花瓣网须要作一件事,就是把图片的主题色提取出来加入到花瓣网搜索引擎的索引当中,以供用户搜索。npm
因而有了一个需求:提取出图片中在某个规定调色板中的颜色,加入到搜索引擎。c#
接下去就开始解析两种不一样的算法以及在这种业务场景当中的应用。数组
这个算法你们能够忽略,多是我使用的姿式不对,总之提取出来(也许它根本就不是这么用的)的东西错误很大。xcode
不过看一下也好开阔下眼界,尤为是我这种想作游戏又不当心掉进互联网的坑里的蒟蒻来讲。
首先该算法我是从这里找到的。想当年我仍是常常逛 GameRes 的。ヾ(;゚;Д;゚;)ノ゙
而后展转反侧最终发现这段代码是提取自 Allegro 游戏引擎。
具体我也就不讲了,毕竟找不到资料,只是粗粗瞄了眼代码里面有几个魔法数字(在游戏和算法领域魔法数字却是很是常见的),也没时间深刻解读这段代码。
我把它翻译成了 Node.js,而后放在了 Demo 当中。你们有兴趣能够本身去看看。
这个算法在颜色量化中比较常见的。
该算法最先见于 1988 年,M. Gervautz 和 W. Purgathofer 发表的论文《A Simple Method for Color Quantization: Octree Quantization》当中。其时间复杂度和空间复杂度都有很大的优点,而且保真度也是很是的高。
大体的思路就是对于某一个像素点的颜色 R / G / B 分开来以后,用二进制逐行写下。
如 #FF7800
,其中 R 通道为 0xFF
,也就是 255
,G 为 0x78
也就是 120
,B 为 0x00
也就是 0
。
接下去咱们把它们写成二进制逐行放下,那么就是:
R: 1111 1111 G: 0111 1000 B: 0000 0000
RGB 通道逐列黏合以后的值就是其在某一层节点的子节点编号了。每一列一共是三位,那么取值范围就是 0 ~ 7
也就是一共有八种状况。这就是为何这种算法要开八叉树来计算的缘由了。
举个例子,上述颜色的第一位黏合起来是 100(2)
,转化为十进制就是 4,因此这个颜色在第一层是放在根节点的第五个子节点当中;第二位是 110(2)
也就是 6,那么它就是根节点的第五个儿子的第七个儿子。
因而咱们有了这样的一个节点结构:
var OctreeNode = function() { this.isLeaf = false; this.pixelCount = 0; this.red = 0; this.green = 0; this.blue = 0; this.children = new Array(8); for(var i = 0; i < this.children.length; i++) this.children[i] = null; // 这里的 next 不是指兄弟链中的 next 指针 // 而是在 reducible 链表中的下一个节点 this.next = null; };
isLeaf
: 代表该节点是否为叶子节点。pixelCount
: 在该节点的颜色一共插入了几回。red
: 该节点 R 通道累加值。green
: G 累加值。blue
: B 累加值。children
: 八个子节点指针。next
: reducible 链表的下一个节点指针,后面会做详细解释,目前能够先忽略。根据上面的理论,咱们大体就知道了往八叉树插入一个像素点颜色的步骤了。
就是每一位 RGB 通道黏合的值就是它在树的那一层的子节点的编号。
大体能够看下图:
图片来源:http://www.microsoft.com/msj/archive/S3F1.aspxX92X
由此能够推断,在没有任何颜色合并的状况下,插入一种颜色最坏的状况下是进行 64 次检索。
注意:咱们将会把该颜色的 RGB 份量分别累加到该节点的各份量值中,以便最终求平均数。
大体的流程就是从根节点开始 DFS,若是到达的节点是叶子节点,那么份量、颜色总数累加;不然就根据层数和该颜色的第层数位颜色黏合值获得其子节点序号。若该子节点不存在就建立一个子节点并与该父节点关联,不然就直接搜下一层去。
建立的时候根据层数来肯定它是否是叶子节点,若是是的话须要标记一下,而且全局的叶子节点数要加一。
还有一点须要注意的就是若是这个节点不是叶子节点,就将其丢到 reducible 相应层数的链表当中去,以供以后颜色合并的时候用。关于颜色合并的内容后面会进行解释。
下面是建立节点的代码:
function createNode(idx, level) { var node = new OctreeNode(); if(level === 7) { node.isLeaf = true; leafNum++; } else { // 将其丢到第 level 层的 reducible 链表中 node.next = reducible[level]; reducible[level] = node; } return node; }
以及下面是插入某种颜色的代码:
function addColor(node, color, level) { if(node.isLeaf) { node.pixelCount++; node.red += color.r; node.green += color.g; node.blue += color.b; } else { // 因为 js 内部都是以浮点型存储数值,因此位运算并无那么高效 // 在此使用直接转换字符串的方式提取某一位的值 // // 实际上若是用位运算来作的话就是这样子的: // https://github.com/XadillaX/thmclrx/blob/7ab4de9fce583e88da6a41b0e256e91c45a10f67/src/octree.cpp#L91-L103 var str = ""; var r = color.r.toString(2); var g = color.g.toString(2); var b = color.b.toString(2); while(r.length < 8) r = '0' + r; while(g.length < 8) g = '0' + g; while(b.length < 8) b = '0' + b; str += r[level]; str += g[level]; str += b[level]; var idx = parseInt(str, 2); if(null === node.children[idx]) { node.children[idx] = createNode(node, idx, level + 1); } if(undefined === node.children[idx]) { console.log(color.r.toString(2)); } addColor(node.children[idx], color, level + 1); } }
这一步就是八叉树的空间复杂度低和保真度高的另外一个缘由了。
勿忘初心。
咱们用这个算法作的是颜色量化,或者说我要拿它提取主题色、调色板,因此确定是提取几个有表明性的颜色就够了,不然茫茫世界中 RRGGBB 一共有 419430400 种颜色,怎么概括?
咱们可让指定一棵八叉树不超过多少多少叶子节点(也就是最后能概括出来的主题色数),好比 8,好比 1六、64 或者 256 等等。
因此当叶子节点数超过咱们规定的叶子节点数的时候,咱们就要合并其中一个节点,将其全部子节点的数据都合并到它身上去。
举个例子,咱们有一个节点有八个子节点,而且都是叶子节点,那么咱们把八个叶子节点的通道份量全累加到该节点中,颜色总数也累加到该节点中,而后删除八个叶子节点并把该节点设置为叶子节点。那么一会儿咱们就合并了八个节点有木有!
为何这些节点能够被合并呢?
咱们来看看某个节点下的子节点颜色都有神马类似点吧——它们的三个份量前七位(或者说若是已经不是最底层的节点的话那就是前几位)是相同的,那么好比说刚才的 FF7800
,跟它同级而且拥有相同父节点(也就是它的兄弟节点)的颜色都是什么呢:
R: 1111 111(0,1) G: 0111 100(0,1) B: 0000 000(0,1)
整合出来一看:
FE7800 FE7801 FE7900 FE7901 FF7800 FF7801 FF7900 FF7901
怎么样?是否是确实很相近?这就是八叉树的精髓了,全部的兄弟节点确定是在一个相近的颜色范围内。
因此说咱们要合并就先去最底层的 reducible 链表中寻找一个能够合并的节点,把它从链表中删除以后合并叶子节点而且删除其叶子节点就行了:
function reduceTree() { // 找到最深层次的而且有可合并节点的链表 var lv = 6; while(null === reducible[lv]) lv--; // 取出链表头并将其从链表中移除 var node = reducible[lv]; reducible[lv] = node.next; // 合并子节点 var r = 0; var g = 0; var b = 0; var count = 0; for(var i = 0; i < 8; i++) { if(null === node.children[i]) continue; r += node.children[i].red; g += node.children[i].green; b += node.children[i].blue; count += node.children[i].pixelCount; leafNum--; } // 赋值 node.isLeaf = true; node.red = r; node.green = g; node.blue = b; node.pixelCount = count; leafNum++; }
这样一来,就合并了一个最深层次的节点了,若是满打满算的话,一次合并最多会烧掉 7 个节点(我大 FFF 团壮哉)。
上面的函数都有了,咱们能够开始建树了。
实际上建树的过程就是遍历一遍传入的像素颜色信息,对于每一个颜色都插入到八叉树当中;而且每一次插入以后都判断下叶子节点数有没有溢出,若是满出来的话须要及时合并。
function buildOctree(pixels, maxColors) { for(var i = 0; i < pixels.length; i++) { // 添加颜色 addColor(root, pixels[i], 0); // 合并叶子节点 while(leafNum > maxColors) reduceTree(); } }
整棵树建好以后,咱们应该获得了最多有 maxColors
个叶子节点的高保真八叉树。其根节点为 root
。
有了这么一棵八叉树以后咱们就能够从里面提取咱们想要的东西了。
主题色提取实际上就是把八叉树当中剩下的叶子节点 RGB 通道份量求平均,求出来的就是近似的主题色了。(也许有更好的,不是求平均的方法能得到更好的主题色结果,可是我没有深刻去研究,欢迎你们一块儿来指正 (❀╹◡╹))
因而咱们深度遍历这棵树,每遇到叶子节点,就求出颜色加入到咱们所存结果的数组或者任意数据结构当中了:
function colorsStats(node, object) { if(node.isLeaf) { var r = parseInt(node.red / node.pixelCount).toString(16); var g = parseInt(node.green / node.pixelCount).toString(16); var b = parseInt(node.blue / node.pixelCount).toString(16); if(r.length === 1) r = '0' + r; if(g.length === 1) g = '0' + g; if(b.length === 1) b = '0' + b; var color = r + g + b; if(object[color]) object[color] += node.pixelCount; else object[color] = node.pixelCount; return; } for(var i = 0; i < 8; i++) { if(null !== node.children[i]) { colorsStats(node.children[i], object); } } }
八叉树主题色提取算法提取出来的主题色是一个无固定调色板(Non-palette)的颜色群,它有着对原图的尽可能保真性,可是因为没有固定的调色板,有时候对于搜索或者某种须要固定值来解释的场景中仍是欠了点火候。可是活灵活现非它莫属了。好比某种图片格式里面预先存调色板而后存各像素的状况下,咱们就能够用八叉树提取出来的颜色做为该图片调色板,能很大程度上对这张图片进行保真,而且图片颜色也减到必定的量。
该算法的完整 Demo 你们能够在个人 Github 当中找到。
这是一个很是简单又实用的算法。
大体的思想就是给定一个调色板,过来一个颜色就跟调色板中的颜色一一对比,取最小差值的那个调色板里的颜色做为这个颜色的表明。
对比的过程就是分别将 R / G / B 通道的值两两相减取绝对值,将三个差相加,获得的这个值就是颜色差值了。
反正最后就是调色板中哪一个颜色跟对比的颜色差值最小,就拿过来就是了。
var best = 0; var bestv = pal[0]; var bestr = Math.abs(r - bestv.r) + Math.abs(g - bestv.g) + Math.abs(b - bestv.b); for(var j = 1; j < pal.length; j++) { var p = pal[j]; var res = Math.abs(r - p.r) + Math.abs(g - p.g) + Math.abs(b - p.b); if(res < bestr) { best = j; bestv = pal[j]; bestr = res; } } r = pal[best].r.toString(16); g = pal[best].g.toString(16); b = pal[best].b.toString(16); if(r.length === 1) r = "0" + r; if(g.length === 1) g = "0" + g; if(b.length === 1) b = "0" + b; if(colors[r + g + b] === undefined) colors[r + g + b] = -1; colors[r + g + b]++;
八叉树的缺点我在以前的八叉树小结中提到过了,就是颜色不固定。对于须要有必定固定值范围的主题色提取需求来讲不是那么合人意。
而最小差值法的话又太古板了。
因而个人作法是将这两种算法都过一遍。
好比我要将一张图片提取出少于 256 种颜色,我会用八叉树过滤一遍得出保证的两百多种颜色,而后拿着这批颜色和其数量再扔到最小插值法里面将颜色规范化一遍,得出的最终结果可能就是我想要的结果了。
这期间第二步的效率能够忽略不计,毕竟若是是上面的需求的话第一步的结果也就那么两百多种颜色。
这个方法我已经实现而且用在我本身的颜色提取包 thmclrx 里了。大体的代码能够看这里。
在这几天的辛勤劳做下,总算完成了某种意义上个人第一个 Node.js C++ Addon。
跟算法相关(八叉树、最小差值)的计算全放在了 C++ 层进行计算。你们有兴趣的能够去读一下而且帮忙指出各类各样的缺点,算是抛砖引玉了。
这个包的 Repo 在 Github 上面:
文档自认为还算完整吧。而且也能够经过
$ npm install thmclrx
进行安装。
进花瓣两个月了,这一次终于如愿以偿地碰触到了一点点的『算法相关』的活。(我不会告诉你这不是个人任务,是我从别人手中抢来的 2333333 ଘ(੭ˊᵕˋ)੭* ੈ✩‧₊˚
总之几种算法和实如今上方介绍了,具体须要怎么用仍是要看你们本身了。我反正大体找到了我使用的途径,那大家呢。( ´・・)ノ(._.`)