如何對前端圖片主題色進行提取?這篇文章詳細告訴你

騰訊雲加社群發表於2019-02-16

本文由雲+社群發表

圖片主題色在圖片所佔比例較大的頁面中,能夠配合圖片起到很好視覺效果,給人一種和諧、一致的感覺。同時也可用在影像分類,搜尋識別等方面。通常主題色的提取都是在後端完成的,前端將需要處理的圖片以連結或id的形式提供給後端,後端通過執行相應的演算法來提取出主題色後,再返回相應的結果。

這樣可以滿足大多數展示類的場景,但對於需要根據使用者“定製”、“生成”的圖片,這樣的方式就有了一個上傳圖片—->後端計算—->返回結果的時間,等待時間也許就比較長了。由此,我嘗試著利用 canvas在前端進行圖片主題色的提取。

一、主題色演算法

目前比較常用的主題色提取演算法有:最小差值法、中位切分法、八叉樹演算法、聚類、色彩建模法等。其中聚類和色彩建模法需要對提取函式和樣本、特徵變數等進行調參和迴歸計算,用到 python的數值計算庫 numpy和機器學習庫 scikit-learn,用 python來實現相對比較簡單,而目前這兩種都沒有成熟的js庫,並且js本身也不擅長迴歸計算這種比較複雜的計算。我也就沒有深入的研究,而主要將目光放在了前面的幾個顏色量化演算法上。

而最小差值法是在給定給定調色盤的情況下找到與色差最小的顏色,使用的場景比較小,所以我主要看了中位切分法和八叉樹演算法,並進行了實踐。

中位切分法

中位切分法通常是在影像處理中降低影像位元深度的演算法,可用來將高位的圖轉換位低位的圖,如將24bit的圖轉換為8bit的圖。我們也可以用來提取圖片的主題色,其原理是是將影像每個畫素顏色看作是以R、G、B為座標軸的一個三維空間中的點,由於三個顏色的取值範圍為0~255,所以影像中的顏色都分佈在這個顏色立方體內,如下圖所示。

img

之後將RGB中最長的一邊從顏色統計的中位數一切為二,使得到的兩個長方體所包含的畫素數量相同,如下圖所示

img

重複這個過程直到切出長方體數量等於主題色數量為止,最後取每個長方體的中點即可。

img

在實際使用中如果只是按照中點進行切割,會出現有些長方體的體積很大但是畫素數量很少的情況。解決的辦法是在切割前對長方體進行優先順序排序,排序的係數為體積 * 畫素數。這樣就可以基本解決此類問題了。

八叉樹演算法

八叉樹演算法也是在顏色量化中比較常見的,主要思路是將R、G、B通道的數值做二進位制轉換後逐行放下,可得到八列數字。如 #FF7880轉換後為

R: 1111 1111
G: 0111 1000
B: 0000 0000

再將RGB通道逐列粘合,可以得到8個數字,即為該顏色在八叉樹中的位置,如圖。

img

在將所有顏色插入之後,再進行合併運算,直到得到所需要的顏色數量為止。

在實際操作中,由於需要對影像畫素進行遍歷後插入八叉樹中,並且插入過程有較多的遞迴操作,所以比中位切分法要消耗更長的時間。

二、中位切分法實踐

根據之前的介紹和網上的相關資料,此處貼上我自己理解實現的中位切分法程式碼,並且找了幾張圖片將結果與QQ音樂已有的魔法色相關演算法進行比較,圖一為中位切分法結果,圖二為後臺cgi返回結果

圖一

img

圖二

img

img

可以看到有一定的差異,但是差值相對都還比較小的,處理速度在pc上面還是比較快的,三張圖分別在70ms,100ms,130ms左右。這裡貼上程式碼,待後續批量處理進行對比之後再分析。

(function () {

    /**
     * 顏色盒子類
     *
     * @param {Array} colorRange    [[rMin, rMax],[gMin, gMax], [bMin, bMax]] 顏色範圍
     * @param {any} total   畫素總數, imageData / 4
     * @param {any} data    畫素資料集合
     */
    function ColorBox(colorRange, total, data) {
        this.colorRange = colorRange;
        this.total = total;
        this.data = data;
        this.volume = (colorRange[0][1] - colorRange[0][0]) * (colorRange[1][1] - colorRange[1][0]) * (colorRange[2][1] - colorRange[2][0]);
        this.rank = this.total * (this.volume);
    }

    ColorBox.prototype.getColor = function () {
        var total = this.total;
        var data = this.data;

        var redCount = 0,
            greenCount = 0,
            blueCount = 0;

        for (var i = 0; i < total; i++) {
            redCount += data[i * 4];
            greenCount += data[i * 4 + 1];
            blueCount += data[i * 4 + 2];
        }

        return [parseInt(redCount / total), parseInt(greenCount / total), parseInt(blueCount / total)];
    }

    // 獲取切割邊
    function getCutSide(colorRange) {   // r:0,g:1,b:2
        var arr = [];
        for (var i = 0; i < 3; i++) {
            arr.push(colorRange[i][1] - colorRange[i][0]);
        }
        return arr.indexOf(Math.max(arr[0], arr[1], arr[2]));
    }

    // 切割顏色範圍
    function cutRange(colorRange, colorSide, cutValue) {
        var arr1 = [];
        var arr2 = [];
        colorRange.forEach(function (item) {
            arr1.push(item.slice());
            arr2.push(item.slice());
        })
        arr1[colorSide][1] = cutValue;
        arr2[colorSide][0] = cutValue;
        return [arr1, arr2];
    }

    // 找到出現次數為中位數的顏色
    function getMedianColor(colorCountMap, total) {
        var arr = [];
        for (var key in colorCountMap) {
            arr.push({
                color: parseInt(key),
                count: colorCountMap[key]
            })
        }

        var sortArr = __quickSort(arr);
        var medianCount = 0;
        var medianColor = 0;
        var medianIndex = Math.floor(sortArr.length / 2)

        for (var i = 0; i <= medianIndex; i++) {
            medianCount += sortArr[i].count;
        }

        return {
            color: parseInt(sortArr[medianIndex].color),
            count: medianCount
        }

        // 另一種切割顏色判斷方法,根據數量和差值的乘積進行判斷,自己試驗後發現效果不如中位數方法,但是少了排序,效能應該有所提高
        // var count = 0;
        // var colorMin = arr[0].color;
        // var colorMax = arr[arr.length - 1].color
        // for (var i = 0; i < arr.length; i++) {
        //     count += arr[i].count;

        //     var item = arr[i];

        //     if (count * (item.color - colorMin) > (total - count) * (colorMax - item.color)) {
        //         return {
        //             color: item.color,
        //             count: count
        //         }
        //     }
        // }

        return {
            color: colorMax,
            count: count
        }



        function __quickSort(arr) {
            if (arr.length <= 1) {
                return arr;
            }
            var pivotIndex = Math.floor(arr.length / 2),
                pivot = arr.splice(pivotIndex, 1)[0];

            var left = [],
                right = [];
            for (var i = 0; i < arr.length; i++) {
                if (arr[i].count <= pivot.count) {
                    left.push(arr[i]);
                }
                else {
                    right.push(arr[i]);
                }
            }
            return __quickSort(left).concat([pivot], __quickSort(right));
        }
    }

    // 切割顏色盒子
    function cutBox(colorBox) {
        var colorRange = colorBox.colorRange,
            cutSide = getCutSide(colorRange),
            colorCountMap = {},
            total = colorBox.total,
            data = colorBox.data;

        // 統計出各個值的數量
        for (var i = 0; i < total; i++) {
            var color = data[i * 4 + cutSide];

            if (colorCountMap[color]) {
                colorCountMap[color] += 1;
            }
            else {
                colorCountMap[color] = 1;
            }
        }
        var medianColor = getMedianColor(colorCountMap, total);
        var cutValue = medianColor.color;
        var cutCount = medianColor.count;
        var newRange = cutRange(colorRange, cutSide, cutValue);
        var box1 = new ColorBox(newRange[0], cutCount, data.slice(0, cutCount * 4)),
            box2 = new ColorBox(newRange[1], total - cutCount, data.slice(cutCount * 4))
        return [box1, box2];
    }

    // 佇列切割
    function queueCut(queue, num) {

        while (queue.length < num) {

            queue.sort(function (a, b) {
                return a.rank - b.rank
            });
            var colorBox = queue.pop();
            var result = cutBox(colorBox);
            queue = queue.concat(result);
        }

        return queue.slice(0, 8)
    }

    function themeColor(img, callback) {

        var canvas = document.createElement(`canvas`),
            ctx = canvas.getContext(`2d`),
            width = 0,
            height = 0,
            imageData = null,
            length = 0,
            blockSize = 1,
            cubeArr = [];

        width = canvas.width = img.width;
        height = canvas.height = img.height;

        ctx.drawImage(img, 0, 0, width, height);

        imageData = ctx.getImageData(0, 0, width, height).data;

        var total = imageData.length / 4;

        var rMin = 255,
            rMax = 0,
            gMin = 255,
            gMax = 0,
            bMin = 255,
            bMax = 0;

        // 獲取範圍
        for (var i = 0; i < total; i++) {
            var red = imageData[i * 4],
                green = imageData[i * 4 + 1],
                blue = imageData[i * 4 + 2];

            if (red < rMin) {
                rMin = red;
            }

            if (red > rMax) {
                rMax = red;
            }

            if (green < gMin) {
                gMin = green;
            }

            if (green > gMax) {
                gMax = green;
            }

            if (blue < bMin) {
                bMin = blue;
            }

            if (blue > bMax) {
                bMax = blue;
            }
        }

        var colorRange = [[rMin, rMax], [gMin, gMax], [bMin, bMax]];
        var colorBox = new ColorBox(colorRange, total, imageData);

        var colorBoxArr = queueCut([colorBox], 8);

        var colorArr = [];
        for (var j = 0; j < colorBoxArr.length; j++) {
            colorBoxArr[j].total && colorArr.push(colorBoxArr[j].getColor())
        }

        callback(colorArr);
    }

    window.themeColor = themeColor

})()

三、八叉樹演算法實踐

也許是我演算法實現的問題,使用八叉樹演算法得到的最終結果並不理想,所消耗的時間相對於中位切分法也長了不少,平均時間分別為160ms,250ms,400ms還是主要看八叉樹演算法吧…同樣貼上程式碼

img

(function () {

    var OctreeNode = function () {
        this.isLeaf = false;
        this.pixelCount = 0;
        this.red = 0;
        this.green = 0;
        this.blue = 0;
        this.children = [null, null, null, null, null, null, null, null];
        this.next = null;
    }

    var root = null,
        leafNum = 0,
        colorMap = null,
        reducible = null;

    function createNode(index, 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 += 1;
            node.red += color.r;
            node.green += color.g;
            node.bllue += color.b;
        }
        else {
            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 index = parseInt(str, 2);

            if (null === node.children[index]) {
                node.children[index] = createNode(index, level + 1);
            }

            if (undefined === node.children[index]) {
                console.log(index, level, color.r.toString(2));
            }

            addColor(node.children[index], color, level + 1);
        }
    }

    function reduceTree() {

        // 找到最深層次的並且有可合併節點的連結串列
        var level = 6;
        while (null == reducible[level]) {
            level -= 1;
        }

        // 取出連結串列頭並將其從連結串列中移除
        var node = reducible[level];
        reducible[level] = 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++;
    }

    function buidOctree(imageData, maxColors) {
        var total = imageData.length / 4;
        for (var i = 0; i < total; i++) {
            // 新增顏色
            addColor(root, {
                r: imageData[i * 4],
                g: imageData[i * 4 + 1],
                b: imageData[i * 4 + 2]
            }, 0);

            // 合併葉子節點
            while (leafNum > maxColors) reduceTree();
        }
    }

    function colorsStats(node, object) {
        if (node.isLeaf) {
            var r = parseInt(node.red / node.pixelCount);
            var g = parseInt(node.green / node.pixelCount);
            var b = parseInt(node.blue / node.pixelCount);

            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);
            }
        }
    }

    window.themeColor = function (img, callback) {
        var canvas = document.createElement(`canvas`),
            ctx = canvas.getContext(`2d`),
            width = 0,
            height = 0,
            imageData = null,
            length = 0,
            blockSize = 1;

        width = canvas.width = img.width;
        height = canvas.height = img.height;

        ctx.drawImage(img, 0, 0, width, height);

        imageData = ctx.getImageData(0, 0, width, height).data;

        root = new OctreeNode();
        colorMap = {};
        reducible = {};
        leafNum = 0;

        buidOctree(imageData, 8)

        colorsStats(root, colorMap)

        var arr = [];
        for (var key in colorMap) {
            arr.push(key);
        }
        arr.sort(function (a, b) {
            return colorMap[a] - colorMap[b];
        })
        arr.forEach(function (item, index) {
            arr[index] = item.split(`,`)
        })
        callback(arr)
    }
})()

四、結果對比

在批量跑了10000張圖片之後,得到了下面的結果

平均耗時對比(js-cgi)

img

可以看到在不考慮圖片載入時間的情況下,用中位切分法提取的耗時相對較短,而圖片載入的耗時可以說是難以逾越的障礙了(整整拖慢了450ms),不過目前的程式碼還有不錯的優化空間,比如間隔取樣,繪製到canvas時減小圖片尺寸,優化切割點查詢等,就需要後續進行更深一點的探索了。

顏色偏差

img

所以看來準確性還是可以的,約76%的顏色與cgi提取結果相近,在大於100的中抽查後發現有部分圖片兩者提取到的主題色各有特點,或者平分秋色,比如

img

img

五、小結

總結來看,通過canvas的中位切分法與cgi提取的結果相似程度還是比較高的,也有許多圖片有很大差異,需要在後續的實踐中不斷優化。同時,圖片載入時間也是一個難以逾越的障礙,不過目前的程式碼還有不錯的優化空間,比如間隔取樣,繪製到canvas時減小圖片尺寸,優化切割點查詢等,就需要後續進行更深一點的探索了。

參考文章

http://acm.nudt.edu.cn/~twcou…

https://xcoder.in/2014/09/17/…

http://blog.rainy.im/2015/11/…

https://xinyo.org/archives/66115

https://xinyo.org/archives/66352

https://github.com/lokesh/col…

http://y.qq.com/m/demo/2018/m…

此文已由作者授權騰訊雲+社群在各渠道釋出

獲取更多新鮮技術乾貨,可以關注我們騰訊雲技術社群-雲加社群官方號及知乎機構號

相關文章