平常搬磚中有須要維護前端解析 PSD 文件的場景,PSD 文件解析導出後會有大量高度類似的重複圖片,在後續的流程須要將這些圖片上傳,若是能剔除掉重複的圖片則能夠大大減少服務端資源的浪費。本着凡事客戶端先試試的原則,咱們試着實現一個樸素的類似圖片識別。html
先拋開類似圖片的比較,咱們來看一下如何判斷兩張圖片是否一致呢?最容易想到方案就是逐個比較兩張圖片的像素值是否一致,實現大致以下:前端
(async function () {
// canvas drawImage 有跨域限制,先加載圖片轉 blob url 使用
const loadImage = (url) => {
return fetch(url)
.then(res => res.blob())
.then(blob => URL.createObjectURL(blob))
.then(blobUrl => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = blobUrl;
});
});
};
const getImageData = (image) => {
const { naturalWidth: width, naturalHeight: height } = image;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
return ctx.getImageData(0, 0, width, height);
};
const compareImage = (imageData1, imageData2) => {
const { width, height } = imageData1;
// 尺寸不一樣直接 pass
if (imageData2.width !== width || imageData2.height !== height) {
return false;
}
// 逐個比較每一個像素差別
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const idx = (x + y * width) * 4;
const dr = imageData1.data[idx + 0] - imageData2.data[idx + 0];
const dg = imageData1.data[idx + 1] - imageData2.data[idx + 1];
const db = imageData1.data[idx + 2] - imageData2.data[idx + 2];
const da = imageData1.data[idx + 3] - imageData2.data[idx + 3];
if (dr || dg || db || da) {
return false;
}
}
}
return true;
};
const image1 = await loadImage('https://xxx.com/pic0.jpeg');
const image2 = await loadImage('https://xxx.com/pic1.jpeg');
const isEqual = compareImage(getImageData(image1), getImageData(image2));
console.log('isEqual', isEqual);
})();
複製代碼
若是兩張圖片的尺寸不一致,或者某個像素有差別,則兩張圖片就是不相同的。簡單且暴力,但不太實用,但基本路線正確就行,一步步來。git
假設咱們有兩張內容相同但尺寸不相同的圖片,咱們應該如何判斷它們在內容上否是相同的?github
既然尺寸不一致,咱們何不將它們處理成一致的尺寸呢?算法
不須要關心圖片的原始尺寸咱們統一處理成 64 x 64 像素的,固然你也能夠根據實際狀況統一處理成更大或者更小的尺寸,尺寸更小圖片信息損失更多但處理會更快,尺寸更大保留的圖片信息更多處理速度慢但準確率更高。咱們可直接用 canvas drawImage 實現:canvas
const getImageData = (image, size = 64) => {
const { naturalWidth: width, naturalHeight: height } = image;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
// ctx.drawImage(image, 0, 0);
ctx.drawImage(image, 0, 0, width, height, 0, 0, size, size);
return ctx.getImageData(0, 0, width, height);
};
複製代碼
至此咱們已經實現了一個最簡單版本的不一樣尺寸相同內容的圖片的識別,接下來只須要加一點細節就能夠實現類似圖片的圖片的識別。跨域
總結下上一步相同圖片識別的操做:數組
接下來思考個問題,咱們該如何直觀評判兩張圖片是否類似呢?markdown
是否是兩張圖片中內容的形狀看起來類似,它是一朵化,它也是一朵花,它們是類似的。按照這個思路咱們先來試試可否經過比較兩張圖片的形狀來判斷圖片的類似性。async
既然是判斷圖片的形狀,那麼那麼圖片顏色什麼的通通不要,只保留最基本的形狀信息就好,大體以下:
咱們的原始圖片爲:
既然是去除顏色信息,第一步固然是灰度處理:
const canvasToGray = (canvas) => {
const ctx = canvas.getContext('2d');
const data = ctx.getImageData(0, 0, canvas.width, canvas.height);
const calculateGray = (r, g, b) => parseInt(r * 0.299 + g * 0.587 + b * 0.114);
const grayData = [];
for (let x = 0; x < data.width; x++) {
for (let y = 0; y < data.height; y++) {
const idx = (x + y * data.width) * 4;
const r = data.data[idx + 0];
const g = data.data[idx + 1];
const b = data.data[idx + 2];
const gray = calculateGray(r, g, b);
data.data[idx + 0] = gray;
data.data[idx + 1] = gray;
data.data[idx + 2] = gray;
data.data[idx + 3] = 255;
grayData.push(gray);
}
}
ctx.putImageData(data, 0, 0);
return grayData;
};
複製代碼
圖片轉灰度後雖然顏色信息會有大幅度的壓縮,但這還不是咱們想要的,咱們須要的是一張非黑即白的圖片。
下一步就是將原圖片中像素點轉換成黑色或白色,這時咱們須要選取一個顏色閾值,大於閾值的置爲白色(255),小於閾值的置爲黑色(0),這個過程稱爲二值化。
圖片二值化閾值生成算法有不少,咱們可使用最簡單的均值哈希(aHash)實現:
const average = (data) => {
let sum = 0;
// 由於是灰度圖片,RGB 通道的顏色都是相同的取一個通道顏色就行了
for (let i = 0; i < data.length - 1; i += 4) {
sum += data[i];
}
return Math.round(sum / (data.length / 4));
};
複製代碼
獲得閾值便可對圖片進行二值化處理,若是咱們將每一個白色的像素點標識爲 1 黑色標識 0,二值化後的圖片則能夠經過一串 01 數值來表示,這其實就是圖片的「指紋」信息。
// 二值化圖片 && 指紋生成
const binaryzationOutput = (canvas, threshold) => {
const ctx = canvas.getContext('2d');
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const { width, height, data } = imageData;
const hash = [];
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const idx = (x + y * canvas.width) * 4;
const avg = (data[idx] + data[idx + 1] + data[idx + 2]) / 3
const v = avg > threshold ? 255 : 0;
data[idx] = v;
data[idx + 1] = v;
data[idx + 2] = v;
hash.push(v > 0 ? 1 : 0);
}
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.putImageData(imageData, 0, 0);
return hash;
}
複製代碼
最終咱們獲得一張只包含輪廓信息的黑白圖片,和對應的圖片指紋 hash(注意這裏輸入的 canvas 是原始圖片的 canvas 不是灰度處理後的!)
還記的上文逐個比較兩張圖片像素點的差別嗎?如今咱們拿到了圖片的指紋 hash,對比圖片的差別只須要比較兩張圖片指紋中對應位置不一樣數值的個數,其實就是比較兩個 hash 數組的 漢明距離。
const hash1 = [0, 0, 1, 0];
const hash2 = [0, 0, 1, 1];
const hammingDistance = (hash1, hash2) => {
let count = 0;
hash1.forEach((it, index) => {
count += it ^ hash2[index];
});
return count;
};
const distance = hammingDistance(hash1, hash2);
console.log(`類似度爲:${(hash1.length - distance) / hash1.length * 100}%`);
複製代碼
至此一個樸素版本的類似圖片實現就完成了,總結一下整體的操做:
完整代碼戳這裏:github.com/kinglisky/b…
通常實際操做中會將圖片縮小到 8x8 的大小,這樣咱們只須要處理 64 個像素值,這樣能夠大大提高程序的處理速度,比較漢明距離時,若是值爲 0 ,則表示這兩張圖片很是類似,若是漢明距離小於 5 ,則表示有些不一樣,但比較相近,若是漢明距離大於 10 則代表徹底不一樣的圖片。二值化的圖片看起來就會是這樣,摘掉眼鏡隱約仍是能看到原圖的樣子~
二值化閾值的算法實現也會影響最後的圖片指紋生成,除了上面使用的均值哈希常見的還有:
實際使用中 pHash 和 otsu 算法的效果會更好,這裏貼個 otsu 的實現:
// 大津法獲取圖片閾值
const otsu = (data) => {
let ptr = 0;
let histData = Array(256).fill(0); // 記錄0-256每一個灰度值的數量,初始值爲0
let total = data.length;
while (ptr < total) {
let h = data[ptr++];
histData[h]++;
}
let sum = 0; // 總數(灰度值x數量)
for (let i = 0; i < 256; i++) {
sum += i * histData[i];
}
let wB = 0; // 背景(小於閾值)的數量
let wF = 0; // 前景(大於閾值)的數量
let sumB = 0; // 背景圖像(灰度x數量)總和
let varMax = 0; // 存儲最大類間方差值
let threshold = 0; // 閾值
for (let t = 0; t < 256; t++) {
wB += histData[t]; // 背景(小於閾值)的數量累加
if (wB === 0) continue;
wF = total - wB; // 前景(大於閾值)的數量累加
if (wF === 0) break;
sumB += t * histData[t]; // 背景(灰度x數量)累加
let mB = sumB / wB; // 背景(小於閾值)的平均灰度
let mF = (sum - sumB) / wF; // 前景(大於閾值)的平均灰度
let varBetween = wB * wF * (mB - mF) ** 2; // 類間方差
if (varBetween > varMax) {
varMax = varBetween;
threshold = t;
}
}
return threshold;
};
複製代碼
既然咱們能夠經過圖片的形狀來識別兩張圖片是否類似,那換個思路咱們是否是可經過比較兩張圖片的各類顏色的數值差別來比較兩張圖片的類似性,類似的圖片在圖片的配色也應該是類似的。
顏色構成的 rgb 通道都有 256(0 ~ 255)個值,整個 rgb 配色組合將有 256 * 256 * 256 = 16777216 約爲 1600 萬種,直接排列全部顏色的組合計算量太大了,與圖片的灰度與二值化相似,咱們須要壓縮圖片的顏色信息。
咱們能夠將 256 拆分紅 4 個區:
這樣 rgb 通道的顏色組合就能夠簡化成 4 * 4 * 4 = 64(0 ~ 63)種組合了,每一個像素的能夠簡單映射成 0123 構成的組合,每一個組合能夠換算對應的索引值:
const index = r * Math.pow(4, 2) + b * Math.pow(4, 1) + b * Math.pow(4, 0);
複製代碼
rgb(0, 0, 0) => [0, 0, 0] => index 0
rgb(100, 100, 100) => [1, 1, 1] => index 21
rgb(150, 150, 150) => [2, 2, 2] => index 42
rgb(255, 255, 255) => [3, 3, 3] => index 63
複製代碼
這樣一張圖片的全部顏色均可以落在 0 ~ 63 索引範圍內,咱們只須要統計每一個像素顏色在索引內出現的次數,就能夠獲得一份圖片的顏色分佈的數組。
實現大體以下:
const getColorsIndexs = (imageData) => {
const { width, height, data } = imageData;
const indexs = Array.from({ length: 64 }).fill(0);
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const idx = (x + y * width) * 4;
const r = Math.round((data[idx + 0] + 32) / 64) - 1;
const g = Math.round((data[idx + 1] + 32) / 64) - 1;
const b = Math.round((data[idx + 2] + 32) / 64) - 1;
// r * Math.pow(4, 2) + b * Math.pow(4, 1) + b * Math.pow(4, 0)
const index = r * 16 + g * 4 + b;
indexs[index] += 1;
}
}
return indexs;
};
複製代碼
假設咱們已經拿到兩張圖片的顏色分佈數組,下一步該如何比較兩張圖片的類似性呢?
[1570,0...,690,1007]
[1671,0...,0, 2000]
複製代碼
答案是三角函數,專業的術語描述是餘弦類似性判斷,簡單描述就是將顏色分佈數組當成一個 64 維的向量,比較其類似性則能夠映射爲比較兩個空間向量之間的夾角大小(餘弦值),兩個向量間的夾角越小則標識兩個向量越接近,cos
的值越接近 1 則越類似。
阮一峯老師的這篇文章 (TF-IDF與餘弦類似性的應用(二):找出類似文章) 講得十分的通俗易懂,這裏就不贅述了,大概還記得初中的三角函數就好了。
咱們按照上面的公式求出顏色分佈數組向量餘弦求值就得出了兩張圖片的類似比了:
const calculateCosine = (vector1, vector2) => {
let a = 0;
let b = 0;
let c = 0;
for (let i = 0; i < vector1.length; i++) {
a += vector1[i] * vector2[i];
b += Math.pow(vector1[i], 2);
c += Math.pow(vector2[i], 2);
}
return a / (Math.sqrt(b) * Math.sqrt(c));
};
複製代碼
但用顏色來比較圖片的類似度不必定能保證內容的類似,兩張圖片即便各個顏色佔比類似,但卻有多是徹底不相同的圖片:
上面的兩張圖片內容其實並不相同,但配色比例一致時其圖片的餘弦類似值倒是 1,不過餘弦類似性用來匹配顏色類似的圖片卻是一個很不錯的方法。
嗯,完整的代碼實現能夠參考:
(async function () {
// canvas drawImage 有跨域限制,先加載圖片轉 blob url 使用
const loadImage = (url) => {
return fetch(url)
.then(res => res.blob())
.then(blob => URL.createObjectURL(blob))
.then(blobUrl => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = (e) => reject(e);
img.src = blobUrl;
});
});
};
const getImageData = (image) => {
const { naturalWidth: width, naturalHeight: height } = image;
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0);
return ctx.getImageData(0, 0, width, height);
};
const getColorsIndexs = (imageData) => {
const { width, height, data } = imageData;
const indexs = Array.from({ length: 64 }).fill(0);
for (let x = 0; x < width; x++) {
for (let y = 0; y < height; y++) {
const idx = (x + y * width) * 4;
const r = Math.round((data[idx + 0] + 32) / 64) - 1;
const g = Math.round((data[idx + 1] + 32) / 64) - 1;
const b = Math.round((data[idx + 2] + 32) / 64) - 1;
const index = r * 16 + g * 4 + b;
indexs[index] += 1;
}
}
return indexs;
};
const calculateCosine = (vector1, vector2) => {
let a = 0;
let b = 0;
let c = 0;
for (let i = 0; i < vector1.length; i++) {
a += vector1[i] * vector2[i];
b += Math.pow(vector1[i], 2);
c += Math.pow(vector2[i], 2);
}
return a / (Math.sqrt(b) * Math.sqrt(c));
};
const image1 = await loadImage('https://xxx.com/pic0.jpeg');
const image2 = await loadImage('https://xxx.com/pic2.jpeg');
const imageData1 = getImageData(image1);
const imageData2 = getImageData(image2);
const vector1 = getColorsIndexs(imageData1);
const vector2 = getColorsIndexs(imageData2);
const cosine = calculateCosine(vector1, vector2);
console.log('類似度爲', cosine);
})();
複製代碼
嘛,算是囉嗦的教程,文中的實踐主要基於阮一峯老師的類似圖片搜索的原理。
以前是由於實際業務中有圖片去重場景想試試前端可否實現類似圖片的識別,意外發現沒有想象中複雜,也學到很多好東西,年底無意上班,祝你們摸魚愉快~
大頭菜呀,快快漲價~