Hiệu ứng theo thời gian thực cho hình ảnh và video

Mat Scales

Nhiều ứng dụng phổ biến nhất hiện nay cho phép bạn áp dụng bộ lọc và hiệu ứng cho hình ảnh hoặc video. Bài viết này cho biết cách triển khai các tính năng này trên web mở.

Về cơ bản, quy trình này giống nhau đối với video và hình ảnh, nhưng tôi sẽ đề cập đến một số điều cần cân nhắc quan trọng về video ở phần cuối. Trong suốt bài viết, bạn có thể giả định rằng "hình ảnh" có nghĩa là "hình ảnh hoặc một khung hình của video"

Có 3 danh mục cơ bản về hành vi bóp méo hình ảnh phổ biến:

  • Các hiệu ứng pixel như độ tương phản, độ sáng, độ ấm, tông màu nâu đỏ, độ rực màu.
  • Hiệu ứng nhiều pixel, được gọi là bộ lọc tích chập, chẳng hạn như làm sắc nét, phát hiện cạnh, làm mờ.
  • Hình ảnh bị méo toàn bộ, chẳng hạn như cắt, xiên, kéo giãn, hiệu ứng ống kính, gợn sóng.

Tất cả các thao tác này đều liên quan đến việc lấy dữ liệu pixel thực tế của hình ảnh nguồn, sau đó tạo một hình ảnh mới từ dữ liệu đó và giao diện duy nhất để thực hiện việc đó là canvas.

Do đó, lựa chọn thực sự quan trọng là bạn nên xử lý trên CPU, với canvas 2D hay trên GPU, với WebGL.

Hãy cùng tìm hiểu nhanh sự khác biệt giữa hai phương pháp này.

Canvas 2D

Về lâu, đây chắc chắn là lựa chọn đơn giản nhất trong hai cách. Trước tiên, bạn vẽ hình ảnh trên canvas.

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);

Sau đó, bạn sẽ nhận được một mảng các giá trị pixel cho toàn bộ canvas.

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

Tại thời điểm này, biến pixelsUint8ClampedArray có độ dài width * height * 4. Mỗi phần tử mảng là một byte và cứ 4 phần tử trong mảng lại đại diện cho màu của một pixel. Mỗi phần tử trong số 4 phần tử này đại diện cho lượng màu đỏ, xanh lục, xanh lam và alpha (độ trong suốt) theo thứ tự đó. Các pixel được sắp xếp theo thứ tự từ góc trên cùng bên trái và di chuyển từ trái sang phải, từ trên xuống dưới.

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
...

Để tìm chỉ mục cho pixel bất kỳ từ các toạ độ của nó, có một công thức đơn giản.

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];

Giờ đây, bạn có thể đọc và ghi dữ liệu này theo cách bạn muốn, cho phép bạn áp dụng mọi hiệu ứng mà bạn có thể nghĩ ra. Tuy nhiên, mảng này là một bản sao của dữ liệu pixel thực tế cho canvas. Để ghi lại phiên bản đã chỉnh sửa, bạn cần sử dụng phương thức putImageData để ghi lại phiên bản này vào góc trên cùng bên trái của canvas

context.putImageData(imageData, 0, 0);

WebGL

WebGL là một chủ đề lớn, chắc chắn là quá lớn để có thể trình bày đầy đủ trong một bài viết. Nếu bạn muốn tìm hiểu thêm về WebGL, hãy xem tài liệu nên đọc ở cuối bài viết này.

Tuy nhiên, dưới đây là phần giới thiệu ngắn gọn về những việc cần làm trong trường hợp thao tác trên một hình ảnh.

Một trong những điều quan trọng nhất cần nhớ về WebGL là WebGL không phải là API đồ hoạ 3D. Trên thực tế, WebGL (và OpenGL) chỉ giỏi một việc duy nhất là vẽ tam giác. Trong ứng dụng, bạn phải mô tả những gì bạn thực sự muốn vẽ bằng hình tam giác. Đối với hình ảnh 2D thì điều đó rất đơn giản, vì hình chữ nhật là hai tam giác vuông góc phải giống nhau, được sắp xếp sao cho các cạnh huyền của chúng ở cùng một vị trí.

Quy trình cơ bản là:

  • Gửi dữ liệu đến GPU mô tả các đỉnh (điểm) của tam giác.
  • Gửi hình ảnh nguồn đến GPU dưới dạng hoạ tiết (hình ảnh).
  • Tạo "vertex shader" (trình đổ bóng đỉnh).
  • Tạo "fragment shader".
  • Đặt một số biến chương trình đổ bóng, được gọi là "bộ đồng phục".
  • Chạy chương trình đổ bóng.

Hãy cùng tìm hiểu chi tiết. Bắt đầu bằng cách phân bổ một số bộ nhớ trên thẻ đồ hoạ có tên là vùng đệm đỉnh. Bạn lưu trữ dữ liệu trong đó mô tả từng điểm của mỗi tam giác. Bạn cũng có thể đặt một số biến, gọi là đồng nhất, là các giá trị toàn cục thông qua cả hai chương trình đổ bóng.

Chương trình đổ bóng đỉnh sử dụng dữ liệu từ vùng đệm đỉnh để tính toán vị trí trên màn hình để vẽ ba điểm của mỗi tam giác.

Bây giờ, GPU đã biết những pixel nào trong canvas cần được vẽ. Chương trình đổ bóng mảnh được gọi một lần cho mỗi pixel và cần trả về màu cần được vẽ lên màn hình. Chương trình đổ bóng mảnh có thể đọc thông tin từ một hoặc nhiều hoạ tiết để xác định màu sắc.

Khi đọc một hoạ tiết trong chương trình đổ bóng mảnh, bạn chỉ định phần hình ảnh mà bạn muốn đọc bằng cách sử dụng hai toạ độ dấu phẩy động trong khoảng từ 0 (bên trái hoặc dưới cùng) đến 1 (bên phải hoặc trên cùng).

Nếu muốn đọc hoạ tiết dựa trên toạ độ pixel, bạn cần truyền kích thước của hoạ tiết theo pixel dưới dạng vectơ đồng nhất để có thể thực hiện chuyển đổi cho từng pixel.

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

Lưu ý rằng vòng lặp di chuyển 4 byte cùng một lúc, nhưng chỉ thay đổi 3 giá trị – điều này là do phép biến đổi cụ thể này không làm thay đổi giá trị alpha. Ngoài ra, hãy nhớ rằng Uint8ClampedArray sẽ làm tròn tất cả các giá trị thành số nguyên và giới hạn giá trị trong khoảng từ 0 đến 255.

Chương trình đổ bóng mảnh WebGL:

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

Tương tự, chỉ phần RGB của màu đầu ra mới được nhân cho phép biến đổi cụ thể này.

Một số bộ lọc này lấy thêm thông tin, chẳng hạn như độ sáng trung bình của toàn bộ hình ảnh, nhưng đây là những thông tin có thể được tính toán một lần cho toàn bộ hình ảnh.

Ví dụ: một cách để thay đổi độ tương phản có thể là di chuyển từng pixel về phía hoặc ra xa một số giá trị "xám", tương ứng với độ tương phản thấp hơn hoặc cao hơn. Giá trị màu xám thường được chọn là màu xám có độ chói là độ chói trung bình của tất cả các pixel trong hình ảnh.

Bạn có thể tính giá trị này một lần khi hình ảnh được tải, sau đó sử dụng giá trị đó mỗi khi cần điều chỉnh hiệu ứng hình ảnh.

Nhiều pixel

Một số hiệu ứng sử dụng màu của các pixel lân cận khi quyết định màu của pixel hiện tại.

Điều này thay đổi một chút cách bạn thực hiện các thao tác trong trường hợp canvas 2D vì bạn muốn có thể đọc màu gốc của hình ảnh và ví dụ trước đó là cập nhật các pixel tại chỗ.

Tuy nhiên, việc này khá dễ dàng. Khi tạo đối tượng dữ liệu hình ảnh ban đầu, bạn có thể tạo bản sao của dữ liệu.

const originalPixels = new Uint8Array(imageData.data);

Đối với trường hợp WebGL, bạn không cần thực hiện bất kỳ thay đổi nào vì chương trình đổ bóng không ghi vào kết cấu đầu vào.

Danh mục hiệu ứng nhiều pixel phổ biến nhất được gọi là bộ lọc tích luỹ. Bộ lọc tích chập sử dụng một số pixel từ hình ảnh đầu vào để tính toán màu của từng pixel trong hình ảnh đầu vào. Mức độ ảnh hưởng của mỗi pixel đầu vào đối với đầu ra được gọi là trọng số.

Các trọng số có thể được biểu thị bằng một ma trận, được gọi là hạt nhân, với giá trị trung tâm tương ứng với pixel hiện tại. Ví dụ: đây là hạt nhân cho hiệu ứng làm mờ Gaussian 3x3.

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

Giả sử bạn muốn tính màu đầu ra của pixel tại (23, 19). Lấy 8 pixel xung quanh (23, 19) cũng như chính pixel, rồi nhân giá trị màu của từng pixel với độ đậm tương ứng.

    (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

Cộng tất cả lại với nhau rồi chia kết quả cho 8, là tổng của trọng số. Bạn có thể thấy kết quả sẽ là một pixel chủ yếu là pixel gốc, nhưng các pixel lân cận sẽ bị tràn vào.

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

Đây sẽ là ý tưởng cơ bản, nhưng có hướng dẫn ngoài kia sẽ đi sâu hơn nhiều và liệt kê nhiều hạt nhân hữu ích khác.

Toàn bộ hình ảnh

Một số phép biến đổi toàn bộ hình ảnh rất đơn giản. Trong canvas 2D, việc cắt và điều chỉnh theo tỷ lệ là một trường hợp đơn giản chỉ vẽ một phần hình ảnh nguồn lên canvas.

// 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);

Tính năng xoay và phản chiếu được cung cấp trực tiếp trong bối cảnh 2D. Trước khi vẽ hình ảnh vào canvas, hãy thay đổi các phép biến đổi.

// 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);

Nhưng mạnh mẽ hơn là nhiều phép biến đổi 2D có thể được viết dưới dạng ma trận 2x3 và áp dụng cho canvas bằng setTransform(). Ví dụ này sử dụng ma trận kết hợp phép xoay và bản dịch.

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],
);

Các hiệu ứng phức tạp hơn như méo ống kính hoặc gợn sóng liên quan đến việc áp dụng một số độ dời cho mỗi tọa độ đích để tính toán tọa độ pixel nguồn. Ví dụ: để có hiệu ứng sóng ngang, bạn có thể bù toạ độ x của pixel nguồn bằng một giá trị dựa trên toạ độ y.

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

Mọi nội dung khác trong bài viết đều hoạt động với video nếu bạn sử dụng phần tử video làm hình ảnh nguồn.

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>,
);

Tuy nhiên, phương thức này sẽ chỉ sử dụng khung video hiện tại. Vì vậy, nếu muốn áp dụng hiệu ứng cho một video đang phát, bạn cần sử dụng drawImage/texImage2D trên mỗi khung để lấy một khung video mới và xử lý khung đó trên mỗi khung ảnh động của trình duyệt.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Khi làm việc với video, tốc độ xử lý đặc biệt quan trọng. Với hình ảnh tĩnh, người dùng có thể không nhận thấy độ trễ 100 mili giây giữa lúc nhấp vào nút và lúc hiệu ứng được áp dụng. Tuy nhiên, khi tạo ảnh động, độ trễ chỉ 16 mili giây cũng có thể gây ra hiện tượng giật rõ ràng.

Phản hồi