楔子
日常搬砖中有需要维护前端解析 PSD 文件的场景,PSD 文件解析导出后会有大量高度相似的重复图片,在后续的流程需要将这些图片上传,如果能剔除掉重复的图片则可以大大减小服务端资源的浪费。本着凡事客户端先试试的原则,我们试着实现一个朴素的相似图片识别。
相同图片识别的朴素实现
先抛开相似图片的比较,我们来看一下如何判断两张图片是否一致呢?最容易想到方案就是逐个比较两张图片的像素值是否一致,实现大体如下:
(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);
})();
如果两张图片的尺寸不一致,或者某个像素有差异,则两张图片就是不相同的。简单且暴力,但不太实用,但基本路线正确就行,一步步来。
假设我们有两张内容相同但尺寸不相同的图片,我们应该如何判断它们在内容上否是相同的?
既然尺寸不一致,我们何不将它们处理成一致的尺寸呢?
不需要关心图片的原始尺寸我们统一处理成 64 x 64 像素的,当然你也可以根据实际情况统一处理成更大或者更小的尺寸,尺寸更小图片信息损失更多但处理会更快,尺寸更大保留的图片信息更多处理速度慢但准确率更高。我们可直接用 canvas drawImage 实现:
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);
};
至此我们已经实现了一个最简单版本的不同尺寸相同内容的图片的识别,接下来只需要加一点细节就可以实现相似图片的图片的识别。
基于图片特征的相似图片识别
总结下上一步相同图片识别的操作:
- 将图片缩小到同一尺寸
- 逐像素比较图片的差异
接下来思考个问题,我们该如何直观评判两张图片是否相似呢?
是不是两张图片中内容的形状看起来相似,它是一朵化,它也是一朵花,它们是相似的。按照这个思路我们先来试试能否通过比较两张图片的形状来判断图片的相似性。
既然是判断图片的形状,那么那么图片颜色什么的统统不要,只保留最基本的形状信息就好,大致如下:
我们的原始图片为:
既然是去除颜色信息,第一步当然是灰度处理:
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}%`);
至此一个朴素版本的相似图片实现就完成了,总结一下总体的操作:
- 将图片缩小到同一尺寸
- 对图片进新灰度处理
- 计算图片二值化阈值
- 图片二值化输出指纹 hash
- 比较指纹 hash 的汉明距离
完整代码戳这里: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 个区:
- 0 ~ 63 为 0 区
- 64 ~ 127 为 1 区
- 128 ~ 191 为 2 区
- 192 ~ 255 为 3 区
这样 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);
})();
其他
嘛,算是啰嗦的教程,文中的实践主要基于阮一峰老师的相似图片搜索的原理。
之前是因为实际业务中有图片去重场景想试试前端能否实现相似图片的识别,意外发现没有想象中复杂,也学到不少好东西,年末无心上班,祝大家摸鱼愉快~
大头菜呀,快快涨价~