在搜索领域,早已出现了“查找类似图片/类似商品”的相关功能,如 Google 搜图,百度搜图,淘宝的拍照搜商品等。要实现相似的计算图片类似度的功能,除了使用听起来高大上的“人工智能”之外,其实经过 js 和几种简单的算法,也能八九不离十地实现相似的效果。javascript
在阅读本文以前, 强烈建议先阅读完阮一峰于多年所撰写的 《类似图片搜索的原理》相关文章,本文所涉及的算法也来源于其中。体验地址:https://img-compare.netlify.com/html
为了便于理解,每种算法都会通过“特征提取”和“特征比对”两个步骤进行。接下来将着重对每种算法的“特征提取”步骤进行详细解读,而“特征比对”则单独进行阐述。java
参考阮大的文章,“平均哈希算法”主要由如下几步组成:算法
第一步,缩小尺寸为8×8,以去除图片的细节,只保留结构、明暗等基本信息,摒弃不一样尺寸、比例带来的图片差别。第二步,简化色彩。将缩小后的图片转为灰度图像。typescript
第三步,计算平均值。计算全部像素的灰度平均值。canvas
第四步,比较像素的灰度。将64个像素的灰度,与平均值进行比较。大于或等于平均值,记为1;小于平均值,记为0。数组
第五步,计算哈希值。将上一步的比较结果,组合在一块儿,就构成了一个64位的整数,这就是这张图片的指纹。函数
第六步,计算哈希值的差别,得出类似度(汉明距离或者余弦值)。编码
明白了“平均哈希算法”的原理及步骤之后,就能够开始编码工做了。为了让代码可读性更高,本文的全部例子我都将使用 typescript 来实现。人工智能
咱们采用 canvas 的 drawImage()
方法实现图片压缩,后使用 getImageData()
方法获取 ImageData
对象。
export function compressImg (imgSrc: string, imgWidth: number = 8): Promise<ImageData> { return new Promise((resolve, reject) => { if (!imgSrc) { reject('imgSrc can not be empty!') } const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') const img = new Image() img.crossOrigin = 'Anonymous' img.onload = function () { canvas.width = imgWidth canvas.height = imgWidth ctx?.drawImage(img, 0, 0, imgWidth, imgWidth) const data = ctx?.getImageData(0, 0, imgWidth, imgWidth) as ImageData resolve(data) } img.src = imgSrc }) }
可能有读者会问,为何使用 canvas 能够实现图片压缩呢?简单来讲,为了把“大图片”绘制到“小画布”上,一些相邻且颜色相近的像素每每会被删减掉,从而有效减小了图片的信息量,所以可以实现压缩的效果:
在上面的 compressImg()
函数中,咱们利用 new Image()
加载图片,而后设定一个预设的图片宽高值让图片压缩到指定的大小,最后获取到压缩后的图片的 ImageData
数据——这也意味着咱们能获取到图片的每个像素的信息。
关于
ImageData
,能够参考 MDN 的
文档介绍。
为了把彩色的图片转化成灰度图,咱们首先要明白“灰度图”的概念。在维基百科里是这么描述灰度图像的:
在计算机领域中,灰度(Gray scale)数字图像是每一个像素只有一个采样颜色的图像。
大部分状况下,任何的颜色均可以经过三种颜色通道(R, G, B)的亮度以及一个色彩空间(A)来组成,而一个像素只显示一种颜色,所以能够获得“像素 => RGBA”的对应关系。而“每一个像素只有一个采样颜色”,则意味着组成这个像素的三原色通道亮度相等,所以只须要算出 RGB 的平均值便可:
// 根据 RGBA 数组生成 ImageData export function createImgData (dataDetail: number[]) { const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') const imgWidth = Math.sqrt(dataDetail.length / 4) const newImageData = ctx?.createImageData(imgWidth, imgWidth) as ImageData for (let i = 0; i < dataDetail.length; i += 4) { let R = dataDetail[i] let G = dataDetail[i + 1] let B = dataDetail[i + 2] let Alpha = dataDetail[i + 3] newImageData.data[i] = R newImageData.data[i + 1] = G newImageData.data[i + 2] = B newImageData.data[i + 3] = Alpha } return newImageData } export function createGrayscale (imgData: ImageData) { const newData: number[] = Array(imgData.data.length) newData.fill(0) imgData.data.forEach((_data, index) => { if ((index + 1) % 4 === 0) { const R = imgData.data[index - 3] const G = imgData.data[index - 2] const B = imgData.data[index - 1] const gray = ~~((R + G + B) / 3) newData[index - 3] = gray newData[index - 2] = gray newData[index - 1] = gray newData[index] = 255 // Alpha 值固定为255 } }) return createImgData(newData) }
ImageData.data
是一个 Uint8ClampedArray 数组,能够理解为“RGBA数组”,数组中的每一个数字取值为0~255,每4个数字为一组,表示一个像素的 RGBA 值。因为ImageData
为只读对象,因此要另外写一个 creaetImageData()
方法,利用 context.createImageData()
来建立新的 ImageData
对象。
拿到灰度图像之后,就能够进行指纹提取的操做了。
在“平均哈希算法”中,若灰度图的某个像素的灰度值大于平均值,则视为1,不然为0。把这部分信息组合起来就是图片的指纹。因为咱们已经拿到了灰度图的 ImageData
对象,要提取指纹也就变得很容易了:
export function getHashFingerprint (imgData: ImageData) { const grayList = imgData.data.reduce((pre: number[], cur, index) => { if ((index + 1) % 4 === 0) { pre.push(imgData.data[index - 1]) } return pre }, []) const length = grayList.length const grayAverage = grayList.reduce((pre, next) => (pre + next), 0) / length return grayList.map(gray => (gray >= grayAverage ? 1 : 0)).join('') }
经过上述一连串的步骤,咱们即可以经过“平均哈希算法”获取到一张图片的指纹信息(示例是大小为8×8的灰度图):
关于“感知哈希算法”的详细介绍,能够参考这篇文章:《基于感知哈希算法的视觉目标跟踪》。
简单来讲,该算法通过离散余弦变换之后,把图像从像素域转化到了频率域,而携带了有效信息的低频成分会集中在 DCT 矩阵的左上角,所以咱们能够利用这个特性提取图片的特征。
该算法的步骤以下:
- 缩小尺寸:pHash以小图片开始,但图片大于88,3232是最好的。这样作的目的是简化了DCT的计算,而不是减少频率。
- 简化色彩:将图片转化成灰度图像,进一步简化计算量。
- 计算DCT:计算图片的DCT变换,获得32*32的DCT系数矩阵。
- 缩小DCT:虽然DCT的结果是3232大小的矩阵,但咱们只要保留左上角的88的矩阵,这部分呈现了图片中的最低频率。
- 计算平均值:如同均值哈希同样,计算DCT的均值。
- 计算hash值:这是最主要的一步,根据8*8的DCT矩阵,设置0或1的64位的hash值,大于等于DCT均值的设为”1”,小于DCT均值的设为“0”。组合在一块儿,就构成了一个64位的整数,这就是这张图片的指纹。
回到代码中,首先添加一个 DCT 方法:
function memoizeCosines (N: number, cosMap: any) { cosMap = cosMap || {} cosMap[N] = new Array(N * N) let PI_N = Math.PI / N for (let k = 0; k < N; k++) { for (let n = 0; n < N; n++) { cosMap[N][n + (k * N)] = Math.cos(PI_N * (n + 0.5) * k) } } return cosMap } function dct (signal: number[], scale: number = 2) { let L = signal.length let cosMap: any = null if (!cosMap || !cosMap[L]) { cosMap = memoizeCosines(L, cosMap) } let coefficients = signal.map(function () { return 0 }) return coefficients.map(function (_, ix) { return scale * signal.reduce(function (prev, cur, index) { return prev + (cur * cosMap[L][index + (ix * L)]) }, 0) }) }
而后添加两个矩阵处理方法,分别是把通过 DCT 方法生成的一维数组升维成二维数组(矩阵),以及从矩阵中获取其“左上角”内容。
// 一维数组升维 function createMatrix (arr: number[]) { const length = arr.length const matrixWidth = Math.sqrt(length) const matrix = [] for (let i = 0; i < matrixWidth; i++) { const _temp = arr.slice(i * matrixWidth, i * matrixWidth + matrixWidth) matrix.push(_temp) } return matrix } // 从矩阵中获取其“左上角”大小为 range × range 的内容 function getMatrixRange (matrix: number[][], range: number = 1) { const rangeMatrix = [] for (let i = 0; i < range; i++) { for (let j = 0; j < range; j++) { rangeMatrix.push(matrix[i][j]) } } return rangeMatrix }
复用以前在“平均哈希算法”中所写的灰度图转化函数createGrayscale()
,咱们能够获取“感知哈希算法”的特征值:
export function getPHashFingerprint (imgData: ImageData) { const dctData = dct(imgData.data as any) const dctMatrix = createMatrix(dctData) const rangeMatrix = getMatrixRange(dctMatrix, dctMatrix.length / 8) const rangeAve = rangeMatrix.reduce((pre, cur) => pre + cur, 0) / rangeMatrix.length return rangeMatrix.map(val => (val >= rangeAve ? 1 : 0)).join('') }
首先摘抄一段阮大关于“颜色分布法“的描述:
阮大把256种颜色取值简化成了4种。基于这个原理,咱们在进行颜色分布法的算法设计时,能够把这个区间的划分设置为可修改的,惟一的要求就是区间的数量必须可以被256整除。算法以下:
// 划分颜色区间,默认区间数目为4个 // 把256种颜色取值简化为4种 export function simplifyColorData (imgData: ImageData, zoneAmount: number = 4) { const colorZoneDataList: number[] = [] const zoneStep = 256 / zoneAmount const zoneBorder = [0] // 区间边界 for (let i = 1; i <= zoneAmount; i++) { zoneBorder.push(zoneStep * i - 1) } imgData.data.forEach((data, index) => { if ((index + 1) % 4 !== 0) { for (let i = 0; i < zoneBorder.length; i++) { if (data > zoneBorder[i] && data <= zoneBorder[i + 1]) { data = i } } } colorZoneDataList.push(data) }) return colorZoneDataList }
把颜色取值进行简化之后,就能够把它们归类到不一样的分组里面去:
export function seperateListToColorZone (simplifiedDataList: number[]) { const zonedList: string[] = [] let tempZone: number[] = [] simplifiedDataList.forEach((data, index) => { if ((index + 1) % 4 !== 0) { tempZone.push(data) } else { zonedList.push(JSON.stringify(tempZone)) tempZone = [] } }) return zonedList }
最后只须要统计每一个相同的分组的总数便可:
export function getFingerprint (zonedList: string[], zoneAmount: number = 16) { const colorSeperateMap: { [key: string]: number } = {} for (let i = 0; i < zoneAmount; i++) { for (let j = 0; j < zoneAmount; j++) { for (let k = 0; k < zoneAmount; k++) { colorSeperateMap[JSON.stringify([i, j, k])] = 0 } } } zonedList.forEach(zone => { colorSeperateMap[zone]++ }) return Object.values(colorSeperateMap) }
”内容特征法“是指把图片转化为灰度图后再转化为”二值图“,而后根据像素的取值(黑或白)造成指纹后进行比对的方法。这种算法的核心是找到一个“阈值”去生成二值图。
对于生成灰度图,有别于在“平均哈希算法”中提到的取 RGB 均值的办法,在这里咱们使用加权的方式去实现。为何要这么作呢?这里涉及到颜色学的一些概念。
具体能够参考这篇《Grayscale to RGB Conversion》,下面简单梳理一下。
采用 RGB 均值的灰度图是最简单的一种办法,可是它忽略了红、绿、蓝三种颜色的波长以及对总体图像的影响。如下面图为示例,若是直接取得 RGB 的均值做为灰度,那么处理后的灰度图总体来讲会偏暗,对后续生成二值图会产生较大的干扰。
那么怎么改善这种状况呢?答案就是为 RGB 三种颜色添加不一样的权重。鉴于红光有着更长的波长,而绿光波长更短且对视觉的刺激相对更小,因此咱们要有意地减少红光的权重而提高绿光的权重。通过统计,比较好的权重配比是 R:G:B = 0.299:0.587:0.114。
因而咱们能够获得灰度处理函数:
enum GrayscaleWeight { R = .299, G = .587, B = .114 } function toGray (imgData: ImageData) { const grayData = [] const data = imgData.data for (let i = 0; i < data.length; i += 4) { const gray = ~~(data[i] * GrayscaleWeight.R + data[i + 1] * GrayscaleWeight.G + data[i + 2] * GrayscaleWeight.B) data[i] = data[i + 1] = data[i + 2] = gray grayData.push(gray) } return grayData }
上述函数返回一个 grayData
数组,里面每一个元素表明一个像素的灰度值(由于 RBG 取值相同,因此只须要一个值便可)。接下来则使用“大津法”(Otsu's method)去计算二值图的阈值。关于“大津法”,阮大的文章已经说得很详细,在这里就不展开了。我在这个地方找到了“大津法”的 Java 实现,后来稍做修改,把它改成了 js 版本:
/ OTSU algorithm // rewrite from http://www.labbookpages.co.uk/software/imgProc/otsuThreshold.html export function OTSUAlgorithm (imgData: ImageData) { const grayData = toGray(imgData) let ptr = 0 let histData = Array(256).fill(0) let total = grayData.length while (ptr < total) { let h = 0xFF & grayData[ptr++] histData[h]++ } let sum = 0 for (let i = 0; i < 256; i++) { sum += i * histData[i] } let wB = 0 let wF = 0 let sumB = 0 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] 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 }
OTSUAlgorithm()
函数接收一个 ImageData
对象,通过上一步的 toGray()
方法获取到灰度值列表之后,根据“大津法”算出最佳阈值而后返回。接下来使用这个阈值对原图进行处理,便可获取二值图。
export function binaryzation (imgData: ImageData, threshold: number) { const canvas = document.createElement('canvas') const ctx = canvas.getContext('2d') const imgWidth = Math.sqrt(imgData.data.length / 4) const newImageData = ctx?.createImageData(imgWidth, imgWidth) as ImageData for (let i = 0; i < imgData.data.length; i += 4) { let R = imgData.data[i] let G = imgData.data[i + 1] let B = imgData.data[i + 2] let Alpha = imgData.data[i + 3] let sum = (R + G + B) / 3 newImageData.data[i] = sum > threshold ? 255 : 0 newImageData.data[i + 1] = sum > threshold ? 255 : 0 newImageData.data[i + 2] = sum > threshold ? 255 : 0 newImageData.data[i + 3] = Alpha } return newImageData }
若图片大小为 N×N,根据二值图“非黑即白”的特性,咱们即可以获得一个 N×N 的 0-1 矩阵,也就是指纹:
通过不一样的方式取得不一样类型的图片指纹(特征)之后,应该怎么去比对呢?这里将介绍三种比对算法,而后分析这几种算法都适用于哪些状况。
摘一段维基百科关于“汉明距离”的描述:
在信息论中,两个等长字符串之间的汉明距离(英语:Hamming distance)是两个字符串对应位置的不一样字符的个数。换句话说,它就是将一个字符串变换成另一个字符串所须要替换的字符个数。
例如:
- 1011101与1001001之间的汉明距离是2。
- 2143896与2233796之间的汉明距离是3。
- "toned"与"roses"之间的汉明距离是3。
明白了含义之后,咱们能够写出计算汉明距离的方法:
export function hammingDistance (str1: string, str2: string) { let distance = 0 const str1Arr = str1.split('') const str2Arr = str2.split('') str1Arr.forEach((letter, index) => { if (letter !== str2Arr[index]) { distance++ } }) return distance }
使用这个 hammingDistance()
方法,来验证下维基百科上的例子:
验证结果符合预期。
知道了汉明距离,也就能够知道两个等长字符串之间的类似度了(汉明距离越小,类似度越大):
类似度 = (字符串长度 - 汉明距离) / 字符串长度
从维基百科中咱们能够了解到关于余弦类似度的定义:
余弦类似性经过测量两个向量的夹角的余弦值来度量它们之间的类似性。0度角的余弦值是1,而其余任何角度的余弦值都不大于1;而且其最小值是-1。从而两个向量之间的角度的余弦值肯定两个向量是否大体指向相同的方向。两个向量有相同的指向时,余弦类似度的值为1;两个向量夹角为90°时,余弦类似度的值为0;两个向量指向彻底相反的方向时,余弦类似度的值为-1。这结果是与向量的长度无关的,仅仅与向量的指向方向相关。余弦类似度一般用于正空间,所以给出的值为0到1之间。注意这上下界对任何维度的向量空间中都适用,并且余弦类似性最经常使用于高维正空间。
余弦类似度能够计算出两个向量之间的夹角,从而很直观地表示两个向量在方向上是否类似,这对于计算两个 N×N 的 0-1 矩阵的类似度来讲很是有用。根据余弦类似度的公式,咱们能够把它的 js 实现写出来:
export function cosineSimilarity (sampleFingerprint: number[], targetFingerprint: number[]) { // cosθ = ∑n, i=1(Ai × Bi) / (√∑n, i=1(Ai)^2) × (√∑n, i=1(Bi)^2) = A · B / |A| × |B| const length = sampleFingerprint.length let innerProduct = 0 for (let i = 0; i < length; i++) { innerProduct += sampleFingerprint[i] * targetFingerprint[i] } let vecA = 0 let vecB = 0 for (let i = 0; i < length; i++) { vecA += sampleFingerprint[i] ** 2 vecB += targetFingerprint[i] ** 2 } const outerProduct = Math.sqrt(vecA) * Math.sqrt(vecB) return innerProduct / outerProduct }
明白了“汉明距离”和“余弦类似度”这两种特征比对算法之后,咱们就要去看看它们分别适用于哪些特征提取算法的场景。
首先来看“颜色分布法”。在“颜色分布法”里面,咱们把一张图的颜色进行区间划分,经过统计不一样颜色区间的数量来获取特征,那么这里的特征值就和“数量”有关,也就是非 0-1 矩阵。
显然,要比较两个“颜色分布法”特征的类似度,“汉明距离”是不适用的,只能经过“余弦类似度”来进行计算。
接下来看“平均哈希算法”和“内容特征法”。从结果来讲,这两种特征提取算法都能得到一个 N×N 的 0-1 矩阵,且矩阵内元素的值和“数量”无关,只有 0-1 之分。因此它们同时适用于经过“汉明距离”和“余弦类似度”来计算类似度。
明白了如何提取图片的特征以及如何进行比对之后,最重要的就是要了解它们对于类似度的计算精度。
本文所讲的类似度仅仅是经过客观的算法来实现,而判断两张图片“像不像”倒是一个很主观的问题。因而我写了一个简单的服务,能够自行把两张图按照不一样的算法和精度去计算类似度:
https://img-compare.netlify.com/
通过对不一样素材的多方比对,我得出了下列几个很是主观的结论。
总结一下,三种特征提取算法和两种特征比对算法各有优劣,在实际应用中应该针对不一样的状况灵活选用。
本文是在拜读阮一峰的两篇《类似图片搜索的原理》以后,通过本身的实践总结之后而成。因为对色彩、数学等领域的了解只停留在浅显的层面,文章不免有谬误之处,若是有发现表述得不正确的地方,欢迎留言指出,我会及时予以更正。