美文网首页腾讯云技术沙龙腾讯云云+社区翻译社
如何对前端图片主题色进行提取?这篇文章详细告诉你

如何对前端图片主题色进行提取?这篇文章详细告诉你

作者: a3aac2d1b674 | 来源:发表于2019-01-11 12:11 被阅读3次

    本文由云+社区发表

    图片主题色在图片所占比例较大的页面中,能够配合图片起到很好视觉效果,给人一种和谐、一致的感觉。同时也可用在图像分类,搜索识别等方面。通常主题色的提取都是在后端完成的,前端将需要处理的图片以链接或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/~twcourse/ColorQuantization.html

    https://xcoder.in/2014/09/17/theme-color-extract/

    http://blog.rainy.im/2015/11/24/extract-color-themes-from-images/

    https://xinyo.org/archives/66115

    https://xinyo.org/archives/66352

    https://github.com/lokesh/color-thief/

    http://y.qq.com/m/demo/2018/magic_color.html

    此文已由作者授权腾讯云+社区在各渠道发布

    获取更多新鲜技术干货,可以关注我们腾讯云技术社区-云加社区官方号及知乎机构号

    相关文章

      网友评论

        本文标题:如何对前端图片主题色进行提取?这篇文章详细告诉你

        本文链接:https://www.haomeiwen.com/subject/ndxxdqtx.html