背景:之前做的一个h5小游戏,里面有一个部分是 一起来找茬,主要内容是 分析两个图片的不同,几经周折,后来决定利用 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的像素平铺到一行,对应如下下面为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});
}
}
},
结尾
缺点:
- 比如扫描的半径(scanR)需要根据不同点的区域稍作调整(一般需要scanR大于不同点的的平均半径)
- 如果每个不同点的区域平均半径差异过大会导致 扫描区域取值比较尴尬
虽然有一定的缺点,但是基本可以满足此次活动的需求,如果大家有更好的办法,或者有啥疑问,都可以提出来,一起讨论交流。
文中如有错漏之处,还请大家不吝赐教