利用js实现图片相似度算法

1,507 阅读4分钟

一、平均哈希算法

第一步:

缩小尺寸为8×8,以去除图片的细节,只保留结构、明暗等基本信息,摒弃不同尺寸、比例带来的图片差异。

第二步:

简化色彩。将缩小后的图片转为灰度图像 (取每个像素rgb三色的平均值,[228, 233, 253, 255]-> [238, 238, 238, 255] )。

第三步:

计算平均值。计算所有像素的灰度平均值。(根据每个像素平均后的值, 计算整张图片的平均颜色值)

第四步:

比较像素的灰度。将64个像素的灰度,与平均值进行比较。大于或等于平均值,记为1;小于平均值,记为 0。

第五步:

计算哈希值。将上一步的比较结果,组合在一起,就构成了一个64位的整数,这就是这张图片的指纹。

第六步:

计算指纹的相似度,得出两张图片是否相似 。

// 将图片压缩成 8*8大小
function compressImg (imgSrc, imgWidth = 8) {
  return new Promise((resolve, reject) => {
    if (!imgSrc) {
      reject('imgSrc can not be empty!')
    }
    const canvas = document.getElementById('canvas1')
    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) 
      resolve(data)
    }
    img.src = imgSrc
  })
}
// 色彩简化、计算RGB的平均值,分别设置给RGB
 function createGrayscale (imgData) {
  const newData = 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)
}
// 因为每个像素是 RGBA 组成的、 A 固定255, 所以每4个数里取一个就行
function getHashFingerprint (imgData) {
  const grayList = imgData.data.reduce((pre, cur, index) => {
    if ((index + 1) % 4 === 0) {
      pre.push(imgData.data[index - 1])
    }
    return pre
  }, [])
  console.log("64个颜色的平均rgb:",grayList)
  const length = grayList.length
  const grayAverage = Math.round(grayList.reduce((pre, next) => (pre + next), 0) / length)
  console.log("简化后的图片所有像素平均色彩值:",grayAverage)
  return grayList.map(gray => (gray >= grayAverage ? 1 : 0)).join('')
}

image.png

二、颜色分布法

借用一篇文章的解释:

1、 每张图片都可以生成颜色分布的直方图、如果两张图的直方图很接近,就可以认为他们相似

2、 任何一种颜色都是由红绿蓝三原色组成的、我们平时用的每种原色有256个色值,整个颜色空间有1600万种颜色(256的三次方)

3、 如果用1600万种颜色去比较直方图、计算量太大了、因此需要采用简化方法,可以将0~256分城4个区、 0-63为0区,64-127为1区,128-191为2区,192-256为3区;

也就以为这简化后的图片总共会有64种颜色(4的3次方)、

4、统计64种颜色组合包含的像素数量

5、我们这里想展示每一步操作后的图片变化,取 0,1,2,3的话整张图片都是黑色、所以 0-63取的是32,64-127 取 96,128-191取 160, 192-256取 224;

image.png 6、因为颜色的组合都是固定的,所以把表格中的最后一栏(像素数量)提出来,组成一个64维向量, 便是这张图片的 “指纹”, 寻找相似的图片就是寻找相似的“指纹”

let keyList = []
function compressImg (imgSrc, imgWidth = 120) {
  return new Promise((resolve, reject) => {
    if (!imgSrc) {
      reject('imgSrc can not be empty!')
    }
    const canvas = document.getElementById('canvas1')
    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) 
      resolve(data)
    }
    img.src = imgSrc
  })
}
// 根据 RGBA 数组生成 ImageData
function createImgData (dataDetail) {
  const canvas = document.getElementById('canvas2')
  const ctx = canvas.getContext('2d')
  const imgWidth = Math.sqrt(dataDetail.length / 4)
  const newImageData = ctx?.createImageData(imgWidth, imgWidth)
  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
  }
  ctx.putImageData(newImageData,0,0);
  return newImageData
}

// 划分颜色区间,默认区间数目为4个
// 把256种颜色取值简化为4种

 function simplifyColorData (imgData, zoneAmount=4) {
  const colorZoneDataList = []
  const zoneStep = 256 / zoneAmount
  const zoneBorder = [0] // 区间边界
  for (let i = 1; i <= zoneAmount; i++) {
    zoneBorder.push(zoneStep * i - 1)
  }
  imgData.data.forEach((rgb, index) => {
    if ((index + 1) % 4 !== 0) {
      // for (let i = 0; i < zoneBorder.length; i++) {
      //   if (rgb > zoneBorder[i] && rgb <= zoneBorder[i + 1]) {
      //     rgb = i
      //   }
      // }
      if(rgb<64){
        rgb= 32
      }else if(rgb<128){
        rgb= 96
      }else if(rgb<192){
        rgb= 160
      }else{
        rgb= 224
      }
    }
    colorZoneDataList.push(rgb)
  })
  return createImgData(colorZoneDataList)
  // return createImgData(imgData.data)
}
//  把色值归类到不同的分组里
 function seperateListToColorZone (simplifiedDataList) {
  const zonedList = []
  let tempZone = []
  simplifiedDataList.forEach((data, index) => {
    if ((index + 1) % 4 !== 0) {
      tempZone.push(data)
    } else {
      zonedList.push(JSON.stringify(tempZone))
      tempZone = []
    }
  })
  return zonedList
}
// 统计相同的分组数量
 function getFingerprint (zonedList, zoneAmount=256) {
  const colorSeperateMap = {}
  const list = []
  for (let i = 32; i < zoneAmount; i+=64) {
    for (let j = 32; j < zoneAmount; j+=64) {
      for (let k = 32; k < zoneAmount; k+=64) {
        list.push(JSON.stringify([i, j, k]))
        colorSeperateMap[JSON.stringify([i, j, k])] = 0
      }
    }
  }
  keyList=list
  zonedList.forEach(zone => {
    colorSeperateMap[zone]++
  })
  return Object.values(colorSeperateMap)
}

image.png

image.png

三、内容特征法

”内容特征法“是指把图片转化为灰度图后再转化为”二值图“,然后根据像素的取值(黑或白)形成指纹后进行比对的方法。这种算法的核心是找到一个“阈值”去生成二值图。

1、首先,将原图转化成一张灰度图片 、(可以压缩可不压缩、 最好是压缩成固定大小:可以相似图片因为图片大小不一样带来的差异, 减少一定的计算,减小最终指纹的长度)

2、确定一个阈值,将灰度图片转化为黑白图片

// 根据 RGBA 数组生成 ImageData
function createImgData (dataDetail,canvasId) {
  const canvas = document.getElementById(canvasId)
  const ctx = canvas.getContext('2d')
  const imgWidth = Math.sqrt(dataDetail.length / 4)
  const newImageData = ctx?.createImageData(imgWidth, imgWidth)
  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
  }
  ctx.putImageData(newImageData,0,0);
  return newImageData
}

const GrayscaleWeight = {
  R : .299,
  G : .587,
  B : .114
}
function compressImg (imgSrc, imgWidth = 240) {
  return new Promise((resolve, reject) => {
    if (!imgSrc) {
      reject('imgSrc can not be empty!')
    }
    const canvas = document.getElementById('canvas1')
    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) 
      resolve(data)
    }
    img.src = imgSrc
  })
}
function toGray (imgData) {
  const grayData = []
  const data = imgData.data
  for (let i = 0; i < data.length; i += 4) {
    // ~~两次按位取反,就是保持原值 不过会把布尔值转换成 0 或 1
    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
}
// 大律法 u=w0*u0+w1*u1
// w0:前景点数占图像比例
// u0:前景点数平均灰度
// w1:背景点数占图像比例
// u1:背景点平均灰度为
// u = 62
// 100 个      80个  
// 120         4

// 黑色部分的
// g=w0*(u0-u)**2 + w1*(u1-u)**2
// 直接应用大津法计算量较大,因此我们在实现时采用了等价的公式 g=w0*w1*(u0-u1)**2。

// 通过toGray() 方法获取到灰度值列表,根据“大津法”算出最佳阈值
function OTSUAlgorithm (imgData) {
  const grayData = toGray(imgData)
  console.log("灰度值列表!!!!", grayData)
  let ptr = 0
  let histData = Array(256).fill(0)
  let total = grayData.length
  // 统计图片灰度值的引用点数
  while (ptr < total) {
     // grayData[ptr++] % 256 其实不用,灰度值最大也就256
    let h = 0xFF & grayData[ptr++]
    histData[h]++
  }
  let sum = 0
  for (let i = 0; i < 256; i++) {
    sum += i * histData[i] // 灰度值 * 灰度值数量
  }
  // sum  图片所有灰度值总和 * 灰度值
  let w0 = 0  // 灰度值数量总和
  let w1 = 0  // 剩余灰度数量
  let sumB = 0 // 灰度值 * 灰度值数量  总和  前景点灰度总数
  let varMax = 0  // 最大阈值
  let threshold = 0
  console.log("histData  histData",histData)
  for (let t = 0; t < 256; t++) {
    w0 += histData[t]
    if (w0 === 0) continue
    w1 = total - w0
    if (w1 === 0) break
    // t 灰度值 * 灰度值数量
    sumB += t * histData[t]
    // console.log("sumB sumB  sumB",sumB)
    // 前景点平均灰度 mB 平均灰度
    let u0 = sumB / w0
    // 剩余灰度总值 / 剩余灰度key数量
    // 后景点平均灰度
    let u1 = (sum - sumB) / w1
    // 最大类间方差   x **2 表示 x的二次方
    // 灰度值数量 * 平均灰度  - 
    let varBetween = w0 * w1 * (u0 - u1) ** 2
    // 每个灰度值的方差、取最大的
    if (varBetween > varMax) {
      varMax = varBetween
      threshold = t
    }
  }
  return threshold
}

function binaryzation (imgData, threshold) {
  const canvas = document.createElement('canvas')
  const ctx = canvas.getContext('2d')
  const imgWidth = Math.sqrt(imgData.data.length / 4)
  const newImageData = ctx?.createImageData(imgWidth, imgWidth) 
  const fingerprint =[]
  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
    fingerprint.push(sum > threshold ? 1 : 0)
    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
  }
   createImgData(newImageData.data,'canvas4')
  return fingerprint
}

image.png

image.png