本文由雲+社區發表javascript
圖片主題色在圖片所佔比例較大的頁面中,可以配合圖片起到很好視覺效果,給人一種和諧、一致的感受。同時也可用在圖像分類,搜索識別等方面。一般主題色的提取都是在後端完成的,前端將須要處理的圖片以連接或id的形式提供給後端,後端經過運行相應的算法來提取出主題色後,再返回相應的結果。html
這樣能夠知足大多數展現類的場景,但對於須要根據用戶「定製」、「生成」的圖片,這樣的方式就有了一個上傳圖片---->後端計算---->返回結果的時間,等待時間也許就比較長了。由此,我嘗試着利用 canvas
在前端進行圖片主題色的提取。前端
目前比較經常使用的主題色提取算法有:最小差值法、中位切分法、八叉樹算法、聚類、色彩建模法等。其中聚類和色彩建模法須要對提取函數和樣本、特徵變量等進行調參和迴歸計算,用到 python
的數值計算庫 numpy
和機器學習庫 scikit-learn
,用 python
來實現相對比較簡單,而目前這兩種都沒有成熟的js庫,而且js自己也不擅長迴歸計算這種比較複雜的計算。我也就沒有深刻的研究,而主要將目光放在了前面的幾個顏色量化算法上。java
而最小差值法是在給定給定調色板的狀況下找到與色差最小的顏色,使用的場景比較小,因此我主要看了中位切分法和八叉樹算法,並進行了實踐。node
中位切分法一般是在圖像處理中下降圖像位元深度的算法,可用來將高位的圖轉換位低位的圖,如將24bit的圖轉換爲8bit的圖。咱們也能夠用來提取圖片的主題色,其原理是是將圖像每一個像素顏色看做是以R、G、B爲座標軸的一個三維空間中的點,因爲三個顏色的取值範圍爲0~255,因此圖像中的顏色都分佈在這個顏色立方體內,以下圖所示。python
以後將RGB中最長的一邊從顏色統計的中位數一切爲二,使獲得的兩個長方體所包含的像素數量相同,以下圖所示git
重複這個過程直到切出長方體數量等於主題色數量爲止,最後取每一個長方體的中點便可。github
在實際使用中若是隻是按照中點進行切割,會出現有些長方體的體積很大可是像素數量不多的狀況。解決的辦法是在切割前對長方體進行優先級排序,排序的係數爲體積 * 像素數。這樣就能夠基本解決此類問題了。算法
八叉樹算法也是在顏色量化中比較常見的,主要思路是將R、G、B通道的數值作二進制轉換後逐行放下,可獲得八列數字。如 #FF7880
轉換後爲canvas
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時減少圖片尺寸,優化切割點查找等,就須要後續進行更深一點的探索了。
此文已由做者受權騰訊雲+社區在各渠道發佈
獲取更多新鮮技術乾貨,能夠關注咱們騰訊雲技術社區-雲加社區官方號及知乎機構號