WebGL实战篇(五) —— 图像处理

2,651 阅读6分钟

传送门:

  1. WebGL概述——原理篇
  2. WebGL实战篇(一)—— 绘制点、三角形
  3. WebGL实战篇(二)—— 绘制点、三角形(进阶)
  4. WebGL实战篇(三)—— 绘制图片
  5. WebGL实战篇(四)—— 仿射变换

前言

世界四大邪术之一的 —— 中国的PS和美颜相机占据一席之地,而这就要归功于图像处理的功劳了。今天我们就来简单的看看如何对一幅图像进行图形处理。常见的一些效果有:

  1. 灰度化(就是把彩色图像变成黑白)
  2. 锐化
  3. 模糊
  4. 调色(色相、饱和度、对比度、色温等)
  5. 其他LUT效果等(简单的说就是各类P图软件中一些常见的滤镜,比如 “夏日恋歌”,“赛博朋克”之类的滤镜)

考虑到有的朋友是从这一节开始阅读本文的,那么今天我们将会从使用Canvas2D和WebGL两个方面来讲述如何进行图像处理。

所谓的数字图像处理,其实就是将对图像中的每一个像素进行一定的运算,再将运算的结果输出到图片数据中。这也就是所谓的在空间域上的图像处理。

还有一种称为频率域上的图像处理,我们可以通过傅立叶变换,将图像从空间域转换到频率域上,在频率域中进行一些操作(比如滤波,我们可以消除一些特定频率范围的信号),再将其重新转换到空间域中。由于频率域上的图像处理比较复杂,在本文中不过多的讨论。

Let do it

黑白

我们先从一个简单的效果入手,比如灰度化效果,所谓的灰度化效果就是把彩色图片变成黑白图片。

原理

我们想一下灰色的RGB值是如何表示的?其实在没有彩色电视机之前,其实我们就是用一个分量来表示一个像素点的明亮程度的。所以推广到RGB三色时,R、G、B三个分量相等时则表示灰色。RGB值越大就越趋近于白色,反之则越趋近于黑色。 那么,让RGB分量相等方法有很多:

  1. 让其中的两个通道的颜色变成另一个通道的颜色值,比如将 RGB中的 GB都等于R的值,或者让RB变成G的值。
  2. 取RGB的平均值

我们可以编写如下的Shader:

precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
void main () {
    vec4 color = texture2D(u_texture, v_texCoord);
    gl_FragColor = vec4(vec3(dot(color.rgb, vec3(0.333, 0.333, 0.333))), color.a);
}

dot(color.rgb, vec3(0.333, 0.333, 0.333)) 这句话表示的就是对图像的RGB值求平均的意思。dot表示向量的点乘。他等价于: color.r * 0.333 + color.g * 0.333 + color.b * 0.333; 而 0.333约等于 1 / 3
为什么我们不写成 (color.r + color.g + color.b) / 3.0呢? 因为在显卡中,会对这些GLSL语言中内置的函数有加速的作用,其运行效率更高。所以我们尽可能的使用GLSL中的内置函数。

除开使用WebGL,我们同样可以在Canvas2D中进行图像处理,大致的流程如下:

  1. 绘制图像到画布上
  2. 通过getImageData获取画布的像素值
  3. 遍历所有像素进行处理
  4. 通过putImageData将处理好的数据绘制到画布上

代码的大体结构如下:

  const imageData = ctx.getImageData(0, 0, width, height);
  for (let y = 0; y < height; y++) {
      for (let x = 0; x < width; x++) {
          let r = imageData.data[(y * width + x) * 4];
          let g = imageData.data[(y * width + x) * 4 + 1];
          let b = imageData.data[(y * width + x) * 4 + 2];
          let avg = (r + g + b) / 3.0;
          imageData.data[(y * width + x) * 4] = avg;
          imageData.data[(y * width + x) * 4 + 1] = avg;
          imageData.data[(y * width + x) * 4 + 2] = avg;
      }
  }

  ctx.putImageData(imageData, 0, 0);

其效果如下:

调色

常见的调色滤镜有:调整色相,饱和度、对比度。在CSS的滤镜属性中分别对应hue-rotate, saturate, contrast。

什么是色相? 色相是色彩的首要特征,是区别各种不同色彩的最准确的标准。如下图所示,色相决定了颜色的基色。

而色相旋转就是在不改变颜色的饱和度和亮度的情况下改变其基本的色彩,就称为色相旋转。我们来实现一下CSS中的色相旋转属性。

通过查询W3C标准 (Filter Effects Module Level1),我们可以找到filter属性中的各类实现标准如下:

For type="hueRotate", values is a single one real number value (degrees). A hueRotate operation is equivalent to the following matrix operation:
对于“hueRotate”类型,其值是一个角度值表示的数字,色相旋转的操作等价于下面的矩阵操作

where the terms a00, a01, etc. are calculated as follows:
其中的a00, a01等可以按以下的方式计算

a00 = 0.213 + cos(hueRotate) * 0.787 + sin(hueRotate) * (-0.213) 依次类推

新的RGB值根据矩阵的乘法规则,结果为:
R' = a00 * R + a01 * G + a02 * B + 0 * A + 0 * 1;
G' = a10 * R + a11 * G + a12 * B + 0 * A + 0 * 1;
B' = a20 * R + a21 * G + a22 * B + 0 * A + 0 * 1;
A' = 0 * R + 0 * G + 0 * B + 1 * A + 0 * 1;

所以我们可以根据上述的规则来编写我们的图像处理程序。

precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform mat4 hueMat;
void main () {
    vec4 color = texture2D(u_texture, v_texCoord);
    gl_FragColor = hueMat * color;
}

我们通过在js代码中创建我们的色相旋转矩阵,然后像GPU中传递该矩阵,利用GPU计算矩阵乘法的优势加速运算。
(在WebGL概述——原理篇 中我讲述了如何向GPU中传递我们的参数)

export function createHueRotateMatrix(value) {
    let sin = Math.sin((value * Math.PI) / 180);
    let cos = Math.cos((value * Math.PI) / 180);
    return new Float32Array([
        0.213 + cos * 0.787 - sin * 0.213, 0.213 - cos * 0.213 + sin * 0.143, 0.213 - cos * 0.213 - sin * 0.787,0.0,
        0.715 - cos * 0.715 - sin * 0.715, 0.715 + cos * 0.285 + sin * 0.14, 0.715 - cos * 0.715 + sin * 0.715, 0.0,
        0.072 - cos * 0.072 + sin * 0.928, 0.072 - cos * 0.072 - sin * 0.283, 0.072 + cos * 0.928 + sin * 0.072, 0.0,
        0.0, 0.0, 0.0, 1.0,
    ]);
}

在Canvas2D 中的处理方式也相同,只是我们需要自己手动来计算矩阵乘法了。

const hueMat = createHueRotateMatrix(200);
for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
        let r = imageData.data[(y * width + x) * 4];
        let g = imageData.data[(y * width + x) * 4 + 1];
        let b = imageData.data[(y * width + x) * 4 + 2];
        imageData.data[(y * width + x) * 4] = hueMat[0] * r + hueMat[4] * g + hueMat[8] * b;
        imageData.data[(y * width + x) * 4 + 1] = hueMat[1] * r + hueMat[5] * g + hueMat[9] * b;
        imageData.data[(y * width + x) * 4 + 2] = hueMat[2] * r + hueMat[6] * g + hueMat[10] * b;
    }
}

以下是色相旋转200度的效果:

大家也可以自己试试实现饱和度和对比度调节的算法,至此,都是一些比较简单的图像处理效果。通过一次图像处理就可以得到最终的结果。接下来,我们尝试来实现一些比较复杂的图像处理的效果。比如:模糊效果。

模糊效果

原理

模糊的反义词是清晰,我们是如何判断一张图片清晰与否的呢?我们会观察一张图片的线条和一些边缘(比如毛发边缘与背景之间)是否清晰。反应到具体的像素点来说,就是两个像素点的值相差很大,这样才会形成所谓的边缘。那如果我们将两个像素点的值求一个平均,然后在重新给这个像素点赋值,那么,这条边缘就会减弱。模糊的理论基础正是基于此。就是对一个像素点的周围的像素点求平均,然后将平均值赋值给这个像素点。

如下图所示:

我们对中间的像素求平均,平均后的值为: (1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9) / 9 = 5。(这个例子可能不太恰当,求平均之后的值仍然为5,不过过程是一样的)。

基于这个原理,我们可以写出以下的图像处理程序:

precision mediump float;
varying vec2 v_texCoord;
uniform sampler2D u_texture;
uniform vec2 u_resolution;
void main () {
    vec2 one_pixel = 1. / u_resolution;
    vec4 color = texture2D(u_texture, v_texCoord);
    vec4 l = texture2D(u_texture, v_texCoord + vec2(-1.0, 0.0) * one_pixel);
    vec4 r = texture2D(u_texture, v_texCoord + vec2(1.0, 0.0) * one_pixel);
    vec4 t = texture2D(u_texture, v_texCoord + vec2(0.0, -1.0) * one_pixel);
    vec4 b = texture2D(u_texture, v_texCoord + vec2(0.0, 1.0) * one_pixel);
    gl_FragColor = vec4((l + r + t + b + color).rgb / 5., color.a);
}

通过一次上述的模糊操作过后我们发现一个问题:

仅仅通过一次图像处理,模糊的效果并不明显

image.png

如果我们想要增强模糊的效果,则必须使图像经过多次的上述的模糊程序处理。此时,我们需要用到一个叫作Framebuffer 的东西。

Framebuffer

Framebuffer中文名为“帧缓冲”对象。在默认情况下,WebGL在颜色缓冲区中进行绘图。总之,绘制的结果图像是存储在颜色缓冲区中的。帧缓冲区对象可以用来代替颜色缓冲区或者深度缓冲区(这里我们不考虑深度缓冲区)。在帧缓冲区中绘制的过程又称为离屏绘制。

但是绘制操作并不是直接发生在帧缓冲区中的,而是发生在帧缓冲区所关联的对象上。一个帧缓冲区又3个关联对象:颜色关联对象深度关联对象模板关联对象,分别用来替代颜色缓冲区、深度缓冲区和模板缓冲区。这里我们需要用到的是 颜色关联对象

image.png

所以,我们现在要做的就是创建帧缓冲区并设置一个颜色关联对象到当前帧缓冲区。代码如下:


export function createFramebufferTexture(gl, number, width, height) {
    let framebuffers = [];
    let textures = [];
    for (let i = 0; i < number; i++) {
        let framebuffer = gl.createFramebuffer();
        gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
        // 这里的texture就是颜色关联对象,它替代了颜色缓冲区
        let texture = createTexture(gl);
        gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA,width,height,0,gl.RGBA,gl.UNSIGNED_BYTE,null);
        // 将texture与framebuffer关联起来 
        gl.framebufferTexture2D(gl.FRAMEBUFFER,gl.COLOR_ATTACHMENT0,gl.TEXTURE_2D,texture,0);
        texture && textures.push(texture);
        framebuffer && framebuffers.push(framebuffer);
    }
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    return [framebuffers, textures];
}

我们这里进行图像处理只需要创建2个帧缓冲区就好了,然后就像打乒乓一样,先在A帧缓冲区中进行处理,然后以A帧缓冲区中的纹理为输入,然后在B缓冲区中在进行处理,然后又回到A缓冲区中,依次循环下去。此时有朋友会问,能不能在一个缓冲区中进行处理呢?答案是不能,WebGL是不支持这样的以自身为输入又以自身为输出的形式的。所以我们必须至少要使用2个帧缓冲区。

function drawByShader() {
    // 设置原始纹理为第一次的输入
    gl.bindTexture(gl.TEXTURE_2D, texture);
    for (let i = 0; i < 10; i++) {
        // 进入到其中一个帧缓冲区
        gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffers[i % 2]);
        // 绘制到当前帧缓冲区中的纹理对象上
        gl.drawArrays(gl.TRIANGLES, 0, 6);
        // 绑定当前缓冲区的纹理对象作为下一次处理的输入
        gl.bindTexture(gl.TEXTURE_2D, textures[i % 2]);
    }
    // 设置帧缓冲区为NULL时,则会绘制到颜色缓冲区中(即屏幕上)
    gl.bindFramebuffer(gl.FRAMEBUFFER, null);
    gl.drawArrays(gl.TRIANGLES, 0, 6);
}

那么我们现在再一次看看效果: image.png

经过10次处理,我们得到了一个不错的模糊效果,如果我们继续增加处理的次数,图片会变得更加的模糊。 在Canvas2D的环境中,我们可以写出以下代码:


function drawImage(img) {
    ctx.drawImage(img, 0, 0);
    const width = canvas2.width;
    const height = canvas2.height;

    const imageData = ctx.getImageData(0, 0, width, height);
    // 做20次的重复处理
    for (let i = 0; i < 20; i++) {
        processImageData(imageData, (x, y, texture2D) => {
            const color = texture2D(x, y);
            const l = texture2D(x - 1, y);
            const r = texture2D(x + 1, y);
            const t = texture2D(x, y - 1);
            const b = texture2D(x, y + 1);
            const sum = [color, l, r, t, b].reduce((prev, cur) => {
                return [
                    prev[0] + cur[0],
                    prev[1] + cur[1],
                    prev[2] + cur[2],
                    prev[3] + cur[3],
                ]
            }, [0, 0, 0, 0]);
            return sum.map(item => item /= 5);
        });
    }
    ctx.putImageData(imageData, 0, 0);
}

function processImageData(
    imageData,
    processFunc = (x, y, texture2D) => {
        return texture2D(x, y);
    }
) {
    const width = imageData.width;
    const height = imageData.height;
    const texture2D = (x, y) => {
        if (x < 0) {
            x = 0;
        }
        if (x >= width) {
            x = width - 1;
        }
        if (y < 0) {
            y = 0;
        }
        if (y >= height) {
            y = height - 1;
        }
        let r = imageData.data[(y * width + x) * 4];
        let g = imageData.data[(y * width + x) * 4 + 1];
        let b = imageData.data[(y * width + x) * 4 + 2];
        let a = imageData.data[(y * width + x) * 4 + 3];
        return [r, g, b, a];
    }

    for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
            [
                imageData.data[(y * width + x) * 4],
                imageData.data[(y * width + x) * 4 + 1],
                imageData.data[(y * width + x) * 4 + 2],
                imageData.data[(y * width + x) * 4 + 3]
            ] = processFunc(x, y, texture2D);
        }
    }
}

我们可以利用Framebuffer多次处理图像的这个性质得到一些比较好的效果,比如常见的Bloom(辉光)效果:
Bloom效果是一种游戏中常见的效果,它让画面中较亮的区域像是“扩散”到了周围的区域中,造成一种朦胧的效果。 image.png

基本的原理是: 将原图中较亮的部分提取出来,然后对提取出来的部分使用模糊效果,最后将模糊的效果和原图叠加在一起即可。 具体的实现本文中就不列举了。读者可自行查找资料进行练习。

总结

今天我们学习了如何利用canvas进行图像处理,学习了一些基本的图像处理的原理:

  1. 黑白:r,g,b值相等时图片就会呈现黑白的表示形式。
  2. 色相:我们通过查阅W3C标准,找到相应的矩阵表示形式,我们通过矩阵乘法完成了色相旋转。

接着,我们了解了 Framebuffer的概念,利用framebuffer我们可以将上一次处理完成的图像作为下一次处理的输入接着进行处理。我们利用这一特点完成了 模糊 效果的图像处理。

最后,我们做出了Bloom这样的需要结合多个图像处理程序的特效。

以上,就是今天的所有内容,如果你觉得本文对你有用,别忘了点个赞再走哦!