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

Cân thảm

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 hướng dẫn cách triển khai những tính năng này trên web mở.

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

Cách nhận dữ liệu pixel cho một hình ảnh

Có 3 loại thao túng hình ảnh cơ bản thường gặp:

  • 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ờ.
  • Biến dạng toàn bộ hình ảnh, chẳng hạn như cắt, làm lệch, kéo giãn, hiệu ứng ống kính, gợn sóng.

Tất cả những việ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ừ hình ảnh đó và giao diện duy nhất để thực hiện việc đó là canvas.

Sau đó, sự lựa chọn thực sự quan trọng là sẽ 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 dài, đây chắc chắn là cách đơn giản nhất trong hai lựa chọn. 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 là width * height * 4. Mỗi phần tử mảng là một byte và cứ 4 phần tử trong mảng đó biểu thị màu của một pixel. Mỗi phần tử trong 4 phần tử này đại diện cho lượng đỏ, xanh lục, xanh lam và alpha (độ trong suốt) theo thứ tự đó. Các pixel được sắp xếp bắt đầu từ góc trên cùng bên trái và hoạt động 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 của bất kỳ pixel đã cho nào từ toạ độ của nó, bạn có thể sử dụng 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 bất kỳ cách nào bạn muốn, cho phép bạn áp dụng bất kỳ hiệu ứng nào mà bạn có thể nghĩ ra. Tuy nhiên, mảng này là 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ể thực hiện công bằng trong một bài viết duy nhất. Nếu bạn muốn tìm hiểu thêm về WebGL, hãy xem nội dung đọc đề xuất ở cuối bài viết này.

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

Một trong những điều quan trọng nhất cần nhớ về WebGL không phải là API đồ họa 3D. Trên thực tế, WebGL (và OpenGL) hoạt động hiệu quả ở một lĩnh vực duy nhất, đó là vẽ hình tam giác. Trong ứng dụng của mình, bạn phải mô tả hình mà mình thực sự muốn vẽ dưới dạng tam giác. Trong trường hợp hình ảnh 2D, điều này rất đơn giản vì một hình chữ nhật là hai hình tam giác góc vuông tương tự 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 mô tả các đỉnh (điểm) của tam giác đến GPU.
  • Gửi hình ảnh nguồn tới GPU dưới dạng hoạ tiết (hình ảnh).
  • Tạo "trình đổ bóng đỉnh".
  • Tạo "trình đổ bóng mảnh".
  • Đặt một số biến trong chương trình đổ bóng, được gọi là "uniforms".
  • Chạy chương trình đổ bóng.

Hãy cùng đi vào chi tiết. Hãy bắt đầu bằng cách phân bổ một số bộ nhớ trên thẻ đồ hoạ được gọi là vùng đệm đỉnh. Bạn lưu trữ dữ liệu 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ẽ 3 điểm của mỗi tam giác.

Giờ đây, GPU đã biết cần vẽ pixel nào trong canvas. 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 vẽ vào 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 hoạ tiết trong chương trình đổ bóng mảnh, bạn chỉ định phần nào của hình ảnh mình muốn đọc bằng cách sử dụng hai toạ độ dấu phẩy động giữa 0 (bên trái hoặc dưới cùng) và 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, thì bạn cần truyền kích thước của hoạ tiết tính bằng pixel dưới dạng vectơ đồng nhất để có thể 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;
}

Xin 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 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á trị kẹp nằm 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 được nhân cho phép biến đổi cụ thể này.

Một số bộ lọc trong số này lấy thêm thông tin, chẳng hạn như độ chói trung bình của toàn bộ hình ảnh, nhưng đây là những thứ có thể tính được 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 tới hoặc ra xa một giá trị "xám" nào đó, để có độ tương phản thấp hơn hoặc cao hơn tương ứng. 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 toán giá trị này một lần khi hình ảnh được tải, sau đó sử dụng giá trị này mỗi lần bạn 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 sẽ thay đổi một chút cách bạn thực hiện mọi thứ trong trường hợp canvas 2D vì bạn muốn có thể đọc được màu gốc của hình ảnh và ví dụ trước là cập nhật các điểm ảnh tại chỗ.

Tuy nhiên, điều này khá dễ dàng. Khi bắt đầu tạo đối tượng dữ liệu hình ảnh, bạn có thể tạo một bản sao 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 phổ biến nhất của hiệu ứng nhiều pixel được gọi là bộ lọc tích chập. Bộ lọc tích chập sử dụng một vài pixel của hình ảnh đầu vào để tính màu của mỗi pixel trong hình ảnh đầu vào. Mức độ ảnh hưởng của mỗi pixel đầu vào đối với dữ liệu đầu ra được gọi là một trọng số.

Trọng số có thể được biểu thị bằng một ma trận, 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 |

Vì vậy, giả sử bạn muốn tính màu đầu ra của pixel ở mức (23, 19). Lấy 8 pixel xung quanh (23, 19) cũng như chính pixel rồi nhân các giá trị màu cho mỗi pixel với trọng số 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

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

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

Cách này đưa ra ý tưởng cơ bản, nhưng vẫn có hướng dẫn trình bày chi tiết hơn 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 khá đơn giản. Trong canvas 2D, việc cắt và điều chỉnh tỷ lệ là trường hợp đơn giản chỉ vẽ một phần của 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ó sẵn trực tiếp trong bối cảnh 2D. Trước khi bạn vẽ hình ảnh vào canvas, hãy thay đổi các phép biến đổi khác nhau.

// 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, 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 xoay và 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ư biến dạng ống kính hoặc hiệu ứng gợn sóng liên quan đến việc áp dụng một số độ lệch cho từng toạ độ đích để tính toán toạ độ pixel nguồn. Ví dụ: để có hiệu ứng sóng ngang, bạn có thể bù trừ toạ độ pixel nguồn theo 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 thứ khác trong bài viết này đã hoạt động đối 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, thao tác này sẽ chỉ sử dụng khung hình video hiện tại. Vì vậy, nếu muốn áp dụng hiệu ứng cho video đang phát, bạn cần sử dụng drawImage/texImage2D trên mỗi khung hình để lấy khung hình video mới và xử lý khung hình đó trên từng khung ảnh động của trình duyệt.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Khi xử lý video, điều đặc biệt quan trọng là tốc độ xử lý của bạn phải nhanh chó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 từ lúc nhấp vào nút đến khi hiệu ứng được áp dụng. Tuy nhiên, khi tạo ảnh động, độ trễ chỉ 16 mili giây có thể gây ra hiện tượng giật có thể nhìn thấy.

Ý kiến phản hồi