背景:有一天接到一個小遊戲,裡面有一個部分是 一起來找茬,一開始準備用設計給的坐標來寫,但是發現好像不太符合程序員愛鑽研的精神,於是就想著做一個能自動識別的,幾經周折,後來決定用 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中坐標對應的的下標值的對應關係

若點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;
}

效果如下

  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 {Object} 當前坐標的像素信息
*/

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 y = h + j;
let x = 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. 如果每個不同點的區域平均半徑差異過大會導致 掃描區域取值比較尷尬

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

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


推薦閱讀:
相关文章