用canvas實現一個自動識別兩張圖片差異(圖片找不同)的功能

特立獨爬的蝸牛發表於2019-01-31

背景:有一天接到一個小遊戲,裡面有一個部分是 一起來找茬,一開始準備用設計給的座標來寫,但是發現好像不太符合程式設計師愛鑽研的精神,於是就想著做一個能自動識別的,幾經周折,後來決定用 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. 如果每個不同點的區域平均半徑差異過大會導致 掃描區域取值比較尷尬

雖然有一定的缺點,但是基本可以滿足此次活動的需求,如果大家有更好的辦法,或者有啥疑問,都可以提出來,一起討論交流。

以上是我對圖片找不同的一些總結吧,文中如有錯漏之處,還請大家不吝賜教

相關文章