用canvas實現一個自動識別兩張圖片差異(圖片找不同)的功能
背景:有一天接到一個小遊戲,裡面有一個部分是 一起來找茬,一開始準備用設計給的座標來寫,但是發現好像不太符合程式設計師愛鑽研的精神,於是就想著做一個能自動識別的,幾經周折,後來決定用 canvas的畫素來處理這個問題。
熟悉API
在處理圖片找茬前,先囉嗦一下,canvas畫素處理裡面最重要的兩個API ctx.getImageData
和 ctx.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 的實際寬度。
下面,以一個寬高分別為 750
和 400
的canvas畫布為例:
ctx.getImageData(x,y, caves.width, canvas.height); // 獲取的是一個包含畫素資訊的物件,如下 ImageData = { data: Uint8ClampedArray(1200000), // 4 * 750 * 400 width: 750, height: 400 }
由於ImageData.data是一維陣列,所以我們需要把canvas的畫素平鋪到一行,如下圖:

若點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
為以下函式方法時,各個效果如下面所示
注意:以下的圖,左邊代表處理前,右邊處理後
- 畫素全部取反色
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; }
效果如下

- 下面,我們可以在 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 效果:

R = 0 效果:

綠色通道(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 效果:

G = 0 效果:

藍色通道(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 效果:

B = 0 效果:

透明值(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 效果:

A = 0 效果,(相當於透明度為0,因此啥都看不到)

說完畫素的一些基本應用後,我們就要進入正題了,一起來看看如何找不同。
實現原理:
獲取canvas畫布的所有畫素,設定一個固定的掃描區域(長和寬都是R的矩形),然後按照從左往右,從上往下的順序掃描,每經過一個區域的時候,計算出當前區域畫素值不同的個數,連帶當前區域的座標等資訊一起存到一個叫diffPoints的陣列中,然後遍歷陣列就可以查出來圖片不同的區域
;
大體步驟:
- 建立兩個畫布,把需要比對的兩個圖片畫到畫布上。
- 獲取到兩個畫布的畫素資訊,然後遍歷比對他們的差異,並統計他們的座標等差異資訊
大概如下圖
以下面的圖片為例,

掃描他們不同的,過程示例如下:

接下來,看看核心程式碼部分,也就是尋找差異的部分
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. 如果每個不同點的區域平均半徑差異過大會導致 掃描區域取值比較尷尬
雖然有一定的缺點,但是基本可以滿足此次活動的需求,如果大家有更好的辦法,或者有啥疑問,都可以提出來,一起討論交流。
以上是我對圖片找不同的一些總結吧,文中如有錯漏之處,還請大家不吝賜教