用canvas实现一个自动识别两张图片差异(图片找不同)的功能

4,094 阅读6分钟

背景:之前做的一个h5小游戏,里面有一个部分是 一起来找茬,主要内容是 分析两个图片的不同,几经周折,后来决定利用 canvas分析像素来处理这个问题。

熟悉API

在处理图片找茬前,先啰嗦一下,canvas像素处理里面最重要的两个API ctx.getImageDatactx.putImageData,前者负责获取canvas像素信息,后者负责把像素信息绘制到canvas画布上。

处理像素前,首先得在画布上 画写东西,我们这里就以画两个图片为例,如下:

1.绘制图片
  ctx1.drawImage(img1, 0, 0, img1.width, img1.height, 0, 0, cavsW, cavsH);
  ctx2.drawImage(img2, 0, 0, img2.width, img2.height, 0, 0, cavsW, cavsH);
2.获取像素的API ctx.getImageData

MDN上的解释是:

CanvasRenderingContext2D.getImageData()返回一个ImageData对象,用来描述canvas区域隐含的像素数据,这个区域通过矩形表示,起始点为(sx, sy)、宽为sw、高为sh。;

sx: 将要被提取的图像数据矩形区域的左上角 x 坐标。

sy: 将要被提取的图像数据矩形区域的左上角 y 坐标。

sw: 将要被提取的图像数据矩形区域的宽度。

sh: 将要被提取的图像数据矩形区域的高度。

返回值

一个ImageData 对象,包含canvas给定的矩形图像数据。其中,

ImageData.data: Uint8ClampedArray 描述了一个一维数组,包含以 RGBA 顺序的数据,数据使用 0 至 255(包含)的整数表示。

ImageData.height: 无符号长整型(unsigned long),使用像素描述 ImageData 的实际高度。

ImageData.width: 无符号长整型(unsigned long),使用像素描述 ImageData 的实际宽度。

下面,以一个宽高分别为750400的canvas画布为例:

ctx.getImageData(x,y, caves.width, canvas.height);
// 获取的是一个包含像素信息的对象,如下
ImageData = {
    data: Uint8ClampedArray(1200000), // 4 * 750 * 400
    width: 750,
    height: 400
}

由于ImageData.data是一维数组,所以我们需要把canvas的像素平铺到一行,对应如下下面为canvas中坐标对应的的下标值的对应示意图

image

若点A坐标为 (x,y),canvas画布的宽度为width,则A的四个rgba信息是为第[n, n + 3]个

// 把二维坐标坐标转成一纬的序号
n =  y * width + x;

A.R = 4n
A.G = 4n + 1
A.B = 4n + 2
A.A = 4n + 3

3. 绘制像素信息到 canvas画布的API,ctx.putImageData

对于ctx.putImageData, MDN上的解释是:

CanvasRenderingContext2D.putImageData() 是 Canvas 2D API 将数据从已有的 ImageData 对象绘制到位图的方法。 如果提供了一个绘制过的矩形,则只绘制该矩形的像素。此方法不受画布转换矩阵的影响。

void ctx.putImageData(imagedata, dx, dy);
void ctx.putImageData(imagedata, dx, dy, dirtyX, dirtyY, dirtyWidth, dirtyHeight);

参数:

ImageData: 包含像素值的数组对象。

dx: 源图像数据在目标画布中的位置偏移量(x 轴方向的偏移量)。

dy: 源图像数据在目标画布中的位置偏移量(y 轴方向的偏移量)。

dirtyX: (可选) 在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(x 坐标)。

dirtyY: (可选) 在源图像数据中,矩形区域左上角的位置。默认是整个图像数据的左上角(y 坐标)。

dirtyWidth: (可选) 在源图像数据中,矩形区域的宽度。默认是图像数据的宽度。

dirtyHeight: (可选) 在源图像数据中,矩形区域的高度。默认是图像数据的高度。

如果在像素处理前后,宽高和个数不变,则可以直接,像下面那样使用

//把imageData2 从左上角绘制绘制,由于大小一样,因此后面的参数可不屑
ctx.putImageData(imgData2, 0, 0);

4. 显示器上的像素:

图片

图片

像素的基本使用

理论上我们拿到像素,我们可以对图片进行各种操作,下面看看几个简单的例子。

在所有动作开始前,先获取到画布

  let cavs1 = this.$refs.canvas1;
  let cavs2 = this.$refs.canvas2;
  let ctx1 = cavs1.getContext("2d");
  let ctx2 = cavs2.getContext("2d");

  let cavsWidth = this.cavsW;
  let cavsHeight = this.cavsH;

  let imgData1 = ctx1.getImageData(0, 0, cavsWidth, cavsHeight);
  
  // 这一部,处理像素
  let imgData2 = dealImageData(imgData1);

  // 处理像素后,绘制到canvas画布上
  ctx2.putImageData(imgData2, 0, 0);
  
  
当上面的dealImageData为以下函数方法时,各个效果如下面所示

注意:以下的图,左边代表处理前,右边处理后

  1. 像素全部取反色
setReverseColor(imageData) {
  let d = imageData.data;
  for (let i = 0; i < d.length; i += 4) {
    d[i] = d[i] ^ 255;
    d[i + 1] = d[i + 1] ^ 255;
    d[i + 2] = d[i + 2] ^ 255;
    d[i + 3] = d[i + 3] ^ 255;
  }
  return imageData;
}

效果如下

d

  1. 下面,我们可以在 RGBA四个颜色通道上做处理,看下效果

由于每个像素有有四个数值标示,所以,如果点A为第n个像素,则点A在像素imageData上的位置为,

A.R = 4 * n 
A.G = 4 * n + 1
A.B = 4 * n + 2
A.A = 4 * n + 3

为了取值直观一些,我封装了一个可以更具坐标获取当前像素点像素信息的函数,如下:

/**
 * 传入坐标,返回当前像素的像素信息
 * @param {number} x 横坐标
 * @param {number} y 纵坐标
 * @param {Object} imageData 像素信息
 * @return {Array} 当前坐标的像素信息
  */


export const getPixelInfo = (imageData, x, y) => {

    let R = y * imageData.width * 4 + 4 * x;
    let G = R + 1;
    let B = R + 2;
    let A = R + 3;

    let orderArr = [R, G, B, A];
    let pixelInfo = {
        R,
        G,
        B,
        A,
        orderArr
    };

    return pixelInfo;
}


红色通道(R)设置为255(或者0),代码和效果如下

setSingleColor(imageData, item) {
  let d = imageData.data;
  for (let i = 0; i < d.length; i += 4) {
    d[i] = 255;
    //d[i] = 0;
  }
  return imageData;
}

R = 255 效果:

image

R = 0 效果:

image

绿色通道(G)设置为255(或者0),代码和效果如下

setSingleColor(imageData, item) {
  let d = imageData.data;
  for (let i = 0; i < d.length; i += 4) {
    d[i+1] = 255;
    //d[i+1] = 0;
  }
  return imageData;
}

G = 255 效果:

image

G = 0 效果:

image

蓝色通道(B)设置为255(或者0),代码和效果如下

setSingleColor(imageData, item) {
  let d = imageData.data;
  for (let i = 0; i < d.length; i += 4) {
    d[i+2] = 255;
    //d[i+2] = 0;
  }
  return imageData;
}

B = 255 效果:

image

B = 0 效果:

image

透明值(A)设置为255(或者0),代码和效果如下

setSingleColor(imageData, item) {
  let d = imageData.data;
  for (let i = 0; i < d.length; i += 4) {
    d[i+3] = 255;
    //d[i+3] = 0;
  }
  return imageData;
}

A = 255 效果:

image

A = 0 效果,(相当于透明度为0,因此啥都看不到)

image

实现原理:

获取canvas画布的所有像素,设置一个固定的扫描区域(长和宽都是R的矩形),然后按照从左往右,从上往下的顺序扫描,每经过一个区域的时候,计算出当前区域像素值不同的个数,连带当前区域的坐标等信息一起存到一个叫diffPoints的数组中,然后遍历数组就可以查出来图片不同的区域

大体步骤:
  • 创建两个画布,把需要比对的两个图片画到画布上。
  • 获取到两个画布的像素信息,然后遍历比对他们的差异,并统计他们的坐标等差异信息
大概如下图

以下面的图片为例,

image

扫描他们不同的,过程示例如下

image

图中:外面的矩形,代表扫描的区域。里面的数字代表的是当前区域各个像素值(每个像素点有四个)不同的个数和的平方根,之所以求平方,是因为有的数太大显示不全。

接下来,看看核心代码部分,也就是寻找差异的部分

calcArea() {
  //计算不同点
  let ctx1 = cavsDom1.getContext("2d");
  let ctx2 = cavsDom2.getContext("2d");
  //获取像素信息
  let imgData1 = ctx1.getImageData(0, 0, cavsW, cavsH).data;
  let imgData2 = ctx2.getImageData(0, 0, cavsW, cavsH).data;

  // 数组用来存储各个区域像素信息
  this.diffPoints = [];
  for (let h = 0; h < cavsH - scanR / 2; h += scanStep) {
    for (let i = 0; i < cavsW - scanR / 2; i += scanStep) {
      //当前区域不同像素值的个数,(i,h) 即当前区域块左上角像素点的坐标值
      let diffNum = 0;
      // 当前区第一个点的下标
      let pIndex = h * cavsW * 4 + i * 4;
      // 区域内部遍历像素值,统计该区域不同像素的个数
      for (let j = 0; j < scanR; j++) {
        for (let k = 0; k < scanR * 4; k++) {
          let data1 = imgData1[pIndex + j * cavsW * 4 + k];
          let data2 = imgData2[pIndex + j * cavsW * 4 + k];

          //通过设置容差来判断是不同色值个数
          if ((data1 - data2) ** 2 > 400) {
            diffNum++;
          }
        }
      }
      
      // 获取当前区域中心点的坐标
      let x = Math.round(i + 0.5 * scanR);
      let y = Math.round(h + 0.5 * scanR);
      
      // 虚拟坐标
      let vX = i;
      let vY = h;
    
      this.diffPoints.push({diffNum, x, y, vX, vY});
    }
  }
},

为了更直观一点,我们借用一下上面封装好的 getPixelInfo方法,这样取像素值更直观一点


    calcArea() {
      //计算不同点
      let ctx1 = cavsDom1.getContext("2d");
      let ctx2 = cavsDom2.getContext("2d");
      let imgData1 = ctx1.getImageData(0, 0, cavsW, cavsH);
      let imgData2 = ctx2.getImageData(0, 0, cavsW, cavsH);

      this.diffPoints = [];
      for (let h = 0; h < cavsH - scanR / 2; h += scanStep) {
        for (let i = 0; i < cavsW - scanR / 2; i += scanStep) {
          let diffNum = 0;
          
          for (let j = 0; j < scanR; j++) {
            for (let k = 0; k < scanR; k++) {
              let x = h + j;
              let y = i + k;
              // 获取点(x,y)的像素信息              
              let pixelArr = getPixelInfo(imgData1, x, y).orderArr;
              
              pixelArr.map(order => {
                let disPixel = imgData1.data[order] - imgData2.data[order];
                if (disPixel ** 2 > 100) {
                  diffNum++;
                }
              });
            }
          }
        
          let x = Math.round(i + 0.5 * scanR);
          let y = Math.round(h + 0.5 * scanR);
          // 虚拟坐标
          let vX = i;
          let vY = h;

          if (!isNaN(diffNum)) {
            this.diffPoints.push({diffNum, x, y, vX, vY});
          }
          
          // 获取当前区域中心点的坐标
          let x = Math.round(i + 0.5 * scanR);
          let y = Math.round(h + 0.5 * scanR);
          
          // 虚拟坐标
          let vX = i;
          let vY = h;
        
          this.diffPoints.push({diffNum, x, y, vX, vY});
        }
      }
    },

结尾

缺点:

  1. 比如扫描的半径(scanR)需要根据不同点的区域稍作调整(一般需要scanR大于不同点的的平均半径)
  2. 如果每个不同点的区域平均半径差异过大会导致 扫描区域取值比较尴尬

虽然有一定的缺点,但是基本可以满足此次活动的需求,如果大家有更好的办法,或者有啥疑问,都可以提出来,一起讨论交流。

文中如有错漏之处,还请大家不吝赐教