图片和视频的实时效果

垫秤

现在,许多最热门的应用都允许您为图片或视频应用滤镜和特效。本文介绍了如何在开放 Web 上实现这些功能。

处理视频和图片的过程基本相同,但在最后我会介绍一些重要的视频注意事项。在整篇文章中,您可以假定“图片”是指“视频的图片或一帧画面”

如何获取图片的像素数据

常见的图片处理有 3 类:

  • 像素效果,例如对比度、亮度、色温、深褐色调、饱和度。
  • 多像素效果(称为卷积滤镜),例如锐化、边缘检测、模糊。
  • 整张图片失真,例如剪裁、倾斜、拉伸、镜头效果、涟漪。

所有这些操作都涉及获取源图片的实际像素数据,然后从中创建新图片,唯一能够执行该操作的界面是画布。

那么,真正重要的选择在于是使用 WebGL 在 CPU 上处理,还是使用 2D 画布在 GPU 上处理。

我们来快速了解一下这两种方法之间的区别。

2D 画布

从长远来看,这绝对是两种方法中最简单的一种。首先,在画布上绘制图像。

const source = document.getElementById('source-image');

// Create the canvas and get a context
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');

// Set the canvas to be the same size as the original image
canvas.width = source.naturalWidth;
canvas.height = source.naturalHeight;

// Draw the image onto the top-left corner of the canvas
context.drawImage(theOriginalImage, 0, 0);

然后,您会获得整个画布的像素值数组。

const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;

此时,pixels 变量为长度为 width * height * 4Uint8ClampedArray。每个数组元素都是一个字节,数组中的每四个元素表示一个像素的颜色。四个元素分别代表红色、绿色、蓝色和 alpha(透明度)的量。像素按从左上角开始、从左到右和从上到下的顺序排列。

pixels[0] = red value for pixel 0
pixels[1] = green value for pixel 0
pixels[2] = blue value for pixel 0
pixels[3] = alpha value for pixel 0
pixels[4] = red value for pixel 1
pixels[5] = green value for pixel 1
pixels[6] = blue value for pixel 1
pixels[7] = alpha value for pixel 1
pixels[8] = red value for pixel 2
pixels[9] = green value for pixel 2
pixels[10] = blue value for pixel 2
pixels[11] = alpha value for pixel 2
pixels[12] = red value for pixel 3
...

要通过坐标查找任何指定像素的索引,可以使用一个简单的公式。

const index = (x + y * imageWidth) * 4;
const red = pixels[index];
const green = pixels[index + 1];
const blue = pixels[index + 2];
const alpha = pixels[index + 3];

您现在能够以任何需要的方式读取和写入这些数据,从而能够应用您能想到的任何效果。不过,此数组是画布实际像素数据的副本。如需写回修改后的版本,您需要使用 putImageData 方法将其写回画布的左上角

context.putImageData(imageData, 0, 0);

WebGL

WebGL 是一个很大的话题,当然,有太多问题无法做到在一篇文章中充分展示。如果您想详细了解 WebGL,请查看本文末尾的推荐阅读资料

不过,下面将简要介绍处理单张图片时需要执行的操作。

关于 WebGL,请务必注意,它不是 3D 图形 API。事实上,WebGL(和 OpenGL)完全擅长绘制三角形。在应用中,您必须用三角形描述您实际想要绘制的图形。就 2D 图像而言,这非常简单,因为矩形是由两个类似的直角三角形组成的,它们排列起来,让它们的斜边位于同一位置。

基本流程如下:

  • 将数据发送到 GPU 以描述三角形的顶点(点)。
  • 将源图像作为纹理(图像)发送到 GPU。
  • 创建“顶点着色器”。
  • 创建“fragment 着色器”。
  • 设置一些名为“uniform”的着色器变量。
  • 运行着色器。

让我们来详细了解一下。首先在显卡上分配一些内存(称为顶点缓冲区)。 您将在其中存储用于描述每个三角形每个点的数据存储在模型中。您还可以通过两个着色器设置一些称为 uniform 的变量,它们是全局值。

顶点着色器使用顶点缓冲区中的数据来计算每个三角形在屏幕上的绘制三个点的位置。

现在,GPU 知道了画布中的哪些像素需要绘制。fragment 着色器每像素调用一次,需要返回应绘制到屏幕上的颜色。fragment 着色器可以从一个或多个纹理读取信息来确定颜色。

在 fragment 着色器中读取纹理时,您可以使用介于 0(左侧或底部)和 1(右侧或顶部)之间的两个浮点坐标来指定要读取图像的哪个部分。

如果要根据像素坐标读取纹理,则需要将纹理大小(以像素为单位)作为均匀矢量传递,以便可以对每个像素进行转换。

varying vec2 pixelCoords;

uniform vec2 textureSize;
uniform sampler2D textureSampler;

main() {
  vec2 textureCoords = pixelCoords / textureSize;
  vec4 textureColor = texture2D(textureSampler, textureCoords);
  gl_FragColor = textureColor;
 }
Pretty much every kind of 2D image manipulation that you might want to do can be done in the
fragment shader, and all of the other WebGL parts can be abstracted away. You can see [the
abstraction layer](https://github.com/GoogleChromeLabs/snapshot/blob/master/src/filters/image-shader.ts) (in
TypeScript) that is being in used in one of our sample applications if you'd like to see an example.

### Which should I use?

For pretty much any professional quality image manipulation, you should use WebGL. There is no
getting away from the fact that this kind of work is the whole reason GPUs were invented. You can
process images an order of magnitude faster on the GPU, which is essential for any real-time
effects.

The way that graphics cards work means that every pixel can be calculated in it's own thread. Even
if you parallelize your code CPU-based code with `Worker`s, your GPU may have 100s of times as many
specialized cores as your CPU has general cores.

2D canvas is much simpler, so is great for prototyping and may be fine for one-off transformations.
However, there are plenty of abstractions around for WebGL that mean you can get the performance
boost without needing to learn the details.

Examples in this article are mostly for 2D canvas to make explanations easier, but the principles
should translate pretty easily to fragment shader code.

## Effect types

### Pixel effects

This is the simplest category to both understand and implement. All of these transformations take
the color value of a single pixel and pass it into a function that returns another color value.

There are many variations on these operations that are more or less complicated. Some will take into
account how the human brain processes visual information based on decades of research, and some will
be dead simple ideas that give an effect that's mostly reasonable.

For example, a brightness control can be implemented by simply taking the red, green and blue values
of the pixel and multiplying them by a brightness value. A brightness of 0 will make the image
entirely black. A value of 1 will leave the image unchanged. A value greater than 1 will make it
brighter.

For 2D canvas:

```js
const brightness = 1.1; // Make the image 10% brighter
for (let i = 0; i < imageData.data.length; i += 4) {
  imageData.data[i] = imageData.data[i] * brightness;
  imageData.data[i + 1] = imageData.data[i + 1] * brightness;
  imageData.data[i + 2] = imageData.data[i + 2] * brightness;
}

请注意,循环一次移动 4 个字节,但仅更改三个值,这是因为此特定转换不会更改 alpha 值。另请注意,Uint8ClampedArray 会将所有值四舍五入为整数,并将值限制在 0 到 255 之间。

WebGL fragment 着色器:

    float brightness = 1.1;
    gl_FragColor = textureColor;
    gl_FragColor.rgb *= brightness;

同样,对于这一特定转换,仅会乘以输出颜色的 RGB 部分。

其中一些滤镜会获取额外的信息(如整张图片的平均亮度),但对于整张图片,只需计算一次即可。

例如,改变对比度的一种方式是,让每个像素接近或远离某个“灰色”值,以分别降低或提高对比度。灰度值通常被选为灰色,其亮度是图片中所有像素的亮度中位数。

您可以在加载图片时计算一次此值,然后在每次需要调整图片效果时都使用此值。

多像素

某些效果在确定当前像素的颜色时使用相邻像素的颜色。

这会略微改变您在 2D 画布场景中的操作方式,因为您希望能够读取图片的原始颜色,而上一个示例是在原地更新像素。

不过,这很简单。最初创建图片数据对象时,您可以创建数据的副本。

const originalPixels = new Uint8Array(imageData.data);

对于 WebGL 用例,您无需进行任何更改,因为着色器不会写入输入纹理。

最常见的多像素效果类别称为卷积过滤器。卷积过滤器使用输入图片中的多个像素来计算输入图片中每个像素的颜色。每个输入像素对输出的影响级别称为权重

权重可以由一个称为内核的矩阵表示,其中心值对应于当前像素。例如,这是 3x3 高斯模糊的内核。

    | 0  1  0 |
    | 1  4  1 |
    | 0  1  0 |

因此,我们假设您想要计算 (23, 19) 处的像素输出颜色。取周围的 8 个像素 (23,19) 及像素本身,然后将每个像素的颜色值乘以相应的权重。

    (22, 18) x 0    (23, 18) x 1    (24, 18) x 0
    (22, 19) x 1    (23, 19) x 4    (24, 19) x 1
    (22, 20) x 0    (23, 20) x 1    (24, 20) x 0

将所有这些函数相加,然后将结果除以 8,即权重的总和。您可以看到,结果将多大程度上是原始像素,但附近的像素又出现了。

const kernel = [
  [0, 1, 0],
  [1, 4, 1],
  [0, 1, 0],
];

for (let y = 0; y < imageHeight; y++) {
  for (let x = 0; x < imageWidth; x++) {
    let redTotal = 0;
    let greenTotal = 0;
    let blueTotal = 0;
    let weightTotal = 0;
    for (let i = -1; i <= 1; i++) {
      for (let j = -1; j <= 1; j++) {
        // Filter out pixels that are off the edge of the image
        if (
          x + i > 0 &&
          x + i < imageWidth &&
          y + j > 0 &&
          y + j < imageHeight
        ) {
          const index = (x + i + (y + j) * imageWidth) * 4;
          const weight = kernel[i + 1][j + 1];
          redTotal += weight * originalPixels[index];
          greenTotal += weight * originalPixels[index + 1];
          blueTotal += weight * originalPixels[index + 2];
          weightTotal += weight;
        }
      }
    }

    const outputIndex = (x + y * imageWidth) * 4;
    imageData.data[outputIndex] = redTotal / weightTotal;
    imageData.data[outputIndex + 1] = greenTotal / weightTotal;
    imageData.data[outputIndex + 2] = blueTotal / weightTotal;
  }
}

以上介绍了基本概念,不过,我们还有一些指南详细介绍了这些指南,并列出了许多其他有用的内核。

整张图片

有些整个图片转换比较简单。在 2D 画布中,剪裁和缩放是一个简单的例子,就是仅将源图片的一部分绘制到画布上。

// Set the canvas to be a little smaller than the original image
canvas.width = source.naturalWidth - 100;
canvas.height = source.naturalHeight - 100;

// Draw only part of the image onto the canvas
const sx = 50; // The left-most part of the source image to copy
const sy = 50; // The top-most part of the source image to copy
const sw = source.naturalWidth - 100; // How wide the rectangle to copy is
const sh = source.naturalHeight - 100; // How tall the rectangle to copy is

const dx = 0; // The left-most part of the canvas to draw over
const dy = 0; // The top-most part of the canvas to draw over
const dw = canvas.width; // How wide the rectangle to draw over is
const dh = canvas.height; // How tall the rectangle to draw over is

context.drawImage(theOriginalImage, sx, sy, sw, sh, dx, dy, dw, dh);

旋转和反射可直接在 2D 环境中使用。在将图片绘制到画布之前,请更改各种转换。

// Move the canvas so that the center of the canvas is on the Y-axis
context.translate(-canvas.width / 2, 0);

// An X scale of -1 reflects the canvas in the Y-axis
context.scale(-1, 1);

// Rotate the canvas by 90°
context.rotate(Math.PI / 2);

但更强大的是,许多 2D 转换都可以写为 2x3 矩阵,并通过 setTransform() 应用于画布。此示例使用结合了旋转和平移的矩阵。

const matrix = [
  Math.cos(rot) * x1,
  -Math.sin(rot) * x2,
  tx,
  Math.sin(rot) * y1,
  Math.cos(rot) * y2,
  ty,
];

context.setTransform(
  matrix[0],
  matrix[1],
  matrix[2],
  matrix[3],
  matrix[4],
  matrix[5],
);

镜头失真或涟漪等更复杂的效果涉及为每个目标坐标应用一些偏移,以计算源像素坐标。例如,为了实现水平波效果,您可以基于 y 坐标将源像素 x 坐标偏移某个值。

for (let y = 0; y < canvas.height; y++) {
  const xOffset = 20 * Math.sin((y * Math.PI) / 20);
  for (let x = 0; x < canvas.width; x++) {
    // Clamp the source x between 0 and width
    const sx = Math.min(Math.max(0, x + xOffset), canvas.width);

    const destIndex = (y * canvas.width + x) * 4;
    const sourceIndex = (y * canvas.width + sx) * 4;

    imageData.data[destIndex] = originalPixels.data[sourceIndex];
    imageData.data[destIndex + 1] = originalPixels.data[sourceIndex + 1];
    imageData.data[destIndex + 2] = originalPixels.data[sourceIndex + 2];
  }
}

视频

如果您使用 video 元素作为源图片,本文中的所有内容都已适用于视频。

Canvas 2D:

context.drawImage(<strong>video</strong>, 0, 0);

WebGL:

gl.texImage2D(
  gl.TEXTURE_2D,
  0,
  gl.RGBA,
  gl.RGBA,
  gl.UNSIGNED_BYTE,
  <strong>video</strong>,
);

不过,这样只会使用当前视频帧。因此,如果您希望对正在播放的视频应用某种效果,则需要对每一帧使用 drawImage/texImage2D 来获取新的视频帧,并在每个浏览器动画帧上对其进行处理。

const draw = () => {
  requestAnimationFrame(draw);

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

处理视频时,快速处理尤为重要。如果使用静态图片,用户可能不会注意到在点击按钮和应用效果之间会有 100 毫秒的延迟。但在采用动画效果时,仅 16 毫秒的延迟会导致明显的卡顿。

反馈