如今,许多最热门的应用都允许您对图片或视频应用滤镜和特效。本文介绍了如何在开放 Web 上实现这些功能。
视频和图片的处理流程基本相同,但我会在最后介绍一些重要的视频注意事项。在阅读整篇文章中,您可以假设“图片”是指“图片或视频的单个帧”
如何获取图片的像素数据
图像处理有 3 种常见的基本类别:
- 像对比度、亮度、暖色调、棕褐色调、饱和度等像素效果。
- 多像素效果,称为卷积滤镜,例如锐化、边缘检测、模糊处理。
- 整个图片失真,例如剪裁、倾斜、拉伸、镜头效果、涟漪。
所有这些操作都涉及获取源图像的实际像素数据,然后根据这些数据创建新图像,而唯一可用于执行此操作的接口就是画布。
因此,一个非常重要的选择是,是在 CPU 上使用 2D 画布进行处理,还是在 GPU 上使用 WebGL 进行处理。
我们来快速了解一下这两种方法之间的区别。
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 * 4
的 Uint8ClampedArray
。每个数组元素都是一个字节,数组中的每四个元素表示一个像素的颜色。这四个元素分别代表红色、绿色、蓝色和 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)只擅长一件事:绘制三角形。在应用中,您必须描述您实际想要绘制的三角形。对于二维图片,这非常简单,因为矩形是两个相似的直角三角形,排列方式是它们的 гипотенуза位于同一位置。
基本流程如下:
- 向 GPU 发送用于描述三角形顶点(点)的数据。
- 将源图片作为纹理(图片)发送到 GPU。
- 创建一个“顶点着色器”。
- 创建“片段着色器”。
- 设置一些着色器变量(称为“uniform”)。
- 运行着色器。
我们来详细了解一下首先,在显卡上分配一些内存,称为顶点缓冲区。您可以在其中存储用于描述每个三角形的每个点的数据。您还可以通过这两个着色器设置一些全局值(称为 uniform)变量。
顶点着色器使用顶点缓冲区中的数据来计算在屏幕上的什么位置绘制每个三角形的三个点。
现在,GPU 知道画布中需要绘制哪些像素。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) 处的像素输出颜色。取 (23, 19) 周围的 8 个像素以及该像素本身,然后将每个像素的颜色值乘以相应的权重。
(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
元素作为源图片,本文中的所有其他内容都适用于视频。
画布 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 毫秒的延迟就可能会导致明显的卡顿。
推荐的阅读内容
- WebGL 基础知识:介绍 WebGL 的网站
- 核(图像处理):维基百科页面,介绍了卷积滤镜
- 直观介绍的图片内核:介绍了一些内核以及互动式演示。
- 转换:介绍 2D 画布转换的 MDN 文章