如何对前端图片主题色进行提取?这篇文章详细告诉你
本文由云+社区发表
图片主题色在图片所占比例较大的页面中,能够配合图片起到很好视觉效果,给人一种和谐、一致的感觉。同时也可用在图像分类,搜索识别等方面。通常主题色的提取都是在后端完成的,前端将需要处理的图片以链接或id的形式提供给后端,后端通过运行相应的算法来提取出主题色后,再返回相应的结果。
这样可以满足大多数展示类的场景,但对于需要根据用户“定制”、“生成”的图片,这样的方式就有了一个上传图片---->后端计算---->返回结果的时间,等待时间也许就比较长了。由此,我尝试着利用 canvas
在前端进行图片主题色的提取。
一、主题色算法
目前比较常用的主题色提取算法有:最小差值法、中位切分法、八叉树算法、聚类、色彩建模法等。其中聚类和色彩建模法需要对提取函数和样本、特征变量等进行调参和回归计算,用到 python
的数值计算库 numpy
和机器学习库 scikit-learn
,用 python
来实现相对比较简单,而目前这两种都没有成熟的js库,并且js本身也不擅长回归计算这种比较复杂的计算。我也就没有深入的研究,而主要将目光放在了前面的几个颜色量化算法上。
而最小差值法是在给定给定调色板的情况下找到与色差最小的颜色,使用的场景比较小,所以我主要看了中位切分法和八叉树算法,并进行了实践。
中位切分法
中位切分法通常是在图像处理中降低图像位元深度的算法,可用来将高位的图转换位低位的图,如将24bit的图转换为8bit的图。我们也可以用来提取图片的主题色,其原理是是将图像每个像素颜色看作是以R、G、B为坐标轴的一个三维空间中的点,由于三个颜色的取值范围为0~255,所以图像中的颜色都分布在这个颜色立方体内,如下图所示。
之后将RGB中最长的一边从颜色统计的中位数一切为二,使得到的两个长方体所包含的像素数量相同,如下图所示
重复这个过程直到切出长方体数量等于主题色数量为止,最后取每个长方体的中点即可。
在实际使用中如果只是按照中点进行切割,会出现有些长方体的体积很大但是像素数量很少的情况。解决的办法是在切割前对长方体进行优先级排序,排序的系数为体积 * 像素数。这样就可以基本解决此类问题了。
八叉树算法
八叉树算法也是在颜色量化中比较常见的,主要思路是将R、G、B通道的数值做二进制转换后逐行放下,可得到八列数字。如 #FF7880
转换后为
R: 1111 1111 G: 0111 1000 B: 0000 0000
再将RGB通道逐列粘合,可以得到8个数字,即为该颜色在八叉树中的位置,如图。
在将所有颜色插入之后,再进行合并运算,直到得到所需要的颜色数量为止。
在实际操作中,由于需要对图像像素进行遍历后插入八叉树中,并且插入过程有较多的递归操作,所以比中位切分法要消耗更长的时间。
二、中位切分法实践
根据之前的介绍和网上的相关资料,此处贴上我自己理解实现的中位切分法代码,并且找了几张图片将结果与QQ音乐已有的魔法色相关算法进行比较,图一为中位切分法结果,图二为后台cgi返回结果
图一
图二
可以看到有一定的差异,但是差值相对都还比较小的,处理速度在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还是主要看八叉树算法吧...同样贴上代码
(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)
可以看到在不考虑图片加载时间的情况下,用中位切分法提取的耗时相对较短,而图片加载的耗时可以说是难以逾越的障碍了(整整拖慢了450ms),不过目前的代码还有不错的优化空间,比如间隔采样,绘制到canvas时减小图片尺寸,优化切割点查找等,就需要后续进行更深一点的探索了。
颜色偏差
所以看来准确性还是可以的,约76%的颜色与cgi提取结果相近,在大于100的中抽查后发现有部分图片两者提取到的主题色各有特点,或者平分秋色,比如
五、小结
总结来看,通过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
此文已由作者授权腾讯云+社区在各渠道发布
获取更多新鲜技术干货,可以关注我们腾讯云技术社区-云加社区官方号及知乎机构号
低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
客户端单周发版下的多分支自动化管理与实践
背景 目前,互联网产品呈现出高频优化迭代的趋势,需求方希望尽早地看到结果,并给予及时反馈,所以技术团队需要用“小步快跑”的姿势来做产品,尽早地交付新版本。基于以上背景,美团客户端研发平台适时地推行了单周发版的迭代策略。单周版本迭代的优点可以概括为三个方面:更快地验证产品创意是否符合预期,更灵活地上线节奏,更早地修复线上Bug。 首先说一下美团平台的发版策略,主要变更点是由之前的每四周发一版改为每周都有发版。具体对比如下: (旧)三周迭代指的是2周开发+1周半测试,依赖固定的排期和测试时间,如果错过排期,则需要等待至少20天方可跟着下个版本迭代发布,线上验证产品效果的时间偏长。具体示例描述如下: (新)单周版本迭代指一周一发版,单周迭代版本排期、测试不再依赖固定时间节点,需求开发并测试完成,就可以搭乘最近一周的发版“小火车”,跟版发布直接上线。对于一般需求而言,这将会大大缩短迭代时间。 业务方研发人员的痛点 在之前按月发版的迭代节奏下,基本上所有的需求都属于串行开发,每个版本的开发流程比较固定。从“评审-开发-提测-灰度-上线”各个环节都处于一个固定的时间点来顺序执行,开发人力资源的协调...
- 下一篇
编写你的第一个 Java 版 Raft 分布式 KV 存储
前言 本文旨在讲述如何使用 Java 语言实现基于 Raft 算法的,分布式的,KV 结构的存储项目。该项目的背景是为了深入理解 Raft 算法,从而深刻理解分布式环境下数据强一致性该如何实现;该项目的目标是:在复杂的分布式环境中,多个存储节点能够保证数据强一致性。 项目地址:https://github.com/stateIs0/lu-raft-kv 欢迎 star :) 什么是 Java 版 Raft 分布式 KV 存储 Raft 算法大部分人都已经了解,也有很多实现,从 GitHub 上来看,似乎 Golang 语言实现的较多,比较有名的,例如 etcd。而 Java 版本的,在生产环境大规模使用的实现则较少; 同时,他们的设计目标大部分都是命名服务,即服务注册发现,也就是说,他们通常都是基于 AP 实现,就像 DNS,DNS 是一个命名服务,同时也不是一个强一致性的服务。 比较不同的是 Zookeeper,ZK 常被大家用来做命名服务,但他更多的是一个分布式服务协调者。 而上面的这些都不是存储服务,虽然也都可以做一些存储工作。甚至像 kafka,可以利用 ZK 实现分布式存储。...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- Red5直播服务器,属于Java语言的直播服务器
- SpringBoot2初体验,简单认识spring boot2并且搭建基础工程
- SpringBoot2更换Tomcat为Jetty,小型站点的福音
- SpringBoot2整合Redis,开启缓存,提高访问速度
- SpringBoot2整合Thymeleaf,官方推荐html解决方案
- CentOS7,CentOS8安装Elasticsearch6.8.6
- Hadoop3单机部署,实现最简伪集群
- CentOS8编译安装MySQL8.0.19
- MySQL8.0.19开启GTID主从同步CentOS8
- CentOS7,8上快速安装Gitea,搭建Git服务器