平常搬砖中有须要维护前端解析 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);
})();
复制代码
嘛,算是啰嗦的教程,文中的实践主要基于阮一峰老师的类似图片搜索的原理。
以前是由于实际业务中有图片去重场景想试试前端可否实现类似图片的识别,意外发现没有想象中复杂,也学到很多好东西,年底无意上班,祝你们摸鱼愉快~
大头菜呀,快快涨价~