圖片主題色提取算法小結(Node.js 版)

原文連接: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. GervautzW. Purgathofer 發表的論文《A Simple Method for Color Quantization: Octree Quantization》當中。其時間複雜度和空間複雜度都有很大的優點,而且保真度也是很是的高。

  大體的思路就是對於某一個像素點的顏色 R / G / B 分開來以後,用二進制逐行寫下。

  如 #FF7800,其中 R 通道爲 0xFF,也就是 255G0x78 也就是 120B0x00 也就是 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 包——thmclrx

  在這幾天的辛勤勞做下,總算完成了某種意義上個人第一個 Node.js C++ Addon。

  跟算法相關(八叉樹、最小差值)的計算全放在了 C++ 層進行計算。你們有興趣的能夠去讀一下而且幫忙指出各類各樣的缺點,算是拋磚引玉了。

  這個包的 Repo 在 Github 上面:

https://github.com/XadillaX/thmclrx

  文檔自認爲還算完整吧。而且也能夠經過

$ npm install thmclrx

  進行安裝。

本文小結

  進花瓣兩個月了,這一次終於如願以償地碰觸到了一點點的『算法相關』的活。(我不會告訴你這不是個人任務,是我從別人手中搶來的 2333333 ଘ(੭ˊᵕˋ)੭* ੈ✩‧₊˚

  總之幾種算法和實如今上方介紹了,具體須要怎麼用仍是要看你們本身了。我反正大體找到了我使用的途徑,那大家呢。( ´・・)ノ(._.`)

相關文章
相關標籤/搜索