이미지 및 동영상에 실시간 효과 적용

Mat Scales

오늘날 가장 인기 있는 많은 앱에서 이미지나 동영상에 필터와 효과를 적용할 수 있습니다. 이 도움말에서는 개방형 웹에서 이러한 기능을 구현하는 방법을 보여줍니다.

이 프로세스는 기본적으로 동영상과 이미지에 동일하지만 마지막에 몇 가지 중요한 동영상 고려사항을 다루겠습니다. 이 도움말 전체에서 '이미지'는 '이미지 또는 동영상의 단일 프레임'을 의미한다고 가정할 수 있습니다.

이미지의 픽셀 데이터에 액세스하는 방법

일반적인 이미지 조작에는 세 가지 기본 카테고리가 있습니다.

  • 대비, 밝기, 색 온도, 세피아 톤, 채도와 같은 픽셀 효과
  • 선명하게 하기, 가장자리 감지, 흐리게 하기와 같은 컨볼루션 필터라고 하는 멀티 픽셀 효과
  • 자르기, 기울이기, 늘이기, 렌즈 효과, 물결 효과와 같은 전체 이미지 왜곡

이 모든 작업에는 소스 이미지의 실제 픽셀 데이터를 가져온 다음 이 데이터에서 새 이미지를 만드는 작업이 포함되며, 이 작업을 수행하는 유일한 인터페이스는 캔버스입니다.

따라서 정말 중요한 선택은 2D 캔버스를 사용하여 CPU에서 처리할지, WebGL을 사용하여 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 * 4인 Uint8ClampedArray입니다. 각 배열 요소는 1바이트이며 배열의 4개 요소마다 1픽셀의 색상을 나타냅니다. 4가지 요소는 각각 빨간색, 녹색, 파란색, 알파 (투명도)의 양을 순서대로 나타냅니다. 픽셀은 왼쪽 상단에서 시작하여 왼쪽에서 오른쪽으로, 위에서 아래로 정렬됩니다.

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에 관해 기억해야 할 가장 중요한 점 중 하나는 WebGL이 3D 그래픽 API가 아니라는 것입니다. 사실 WebGL (및 OpenGL)은 삼각형을 그리는 한 가지 작업에만 적합합니다. 애플리케이션에서 삼각형을 기준으로 실제로 그리려는 항목을 설명해야 합니다. 2D 이미지의 경우 매우 간단합니다. 직사각형은 두 개의 유사한 직각 삼각형이 빗변이 같은 위치에 있도록 배열된 것이기 때문입니다.

기본 프로세스는 다음과 같습니다.

  • 삼각형의 꼭짓점 (점)을 설명하는 데이터를 GPU로 전송합니다.
  • 소스 이미지를 텍스처 (이미지)로 GPU에 전송합니다.
  • '꼭짓점 셰이더'를 만듭니다.
  • '프래그먼트 셰이더'를 만듭니다.
  • 'uniform'이라는 일부 셰이더 변수를 설정합니다.
  • 셰이더를 실행합니다.

자세히 살펴보겠습니다. 먼저 그래픽 카드에 정점 버퍼라는 메모리를 할당합니다. 각 삼각형의 각 점을 설명하는 데이터를 저장합니다. 또한 두 셰이더를 통해 전역 값인 유니폼이라는 변수를 설정할 수도 있습니다.

꼭짓점 셰이더는 꼭짓점 버퍼의 데이터를 사용하여 각 삼각형의 세 점을 화면의 어디에 그릴지 계산합니다.

이제 GPU는 캔버스 내에서 어떤 픽셀을 그려야 하는지 알 수 있습니다. 프래그먼트 셰이더는 픽셀당 한 번 호출되며 화면에 그려야 하는 색상을 반환해야 합니다. 조각 셰이더는 하나 이상의 텍스처에서 정보를 읽어 색상을 결정할 수 있습니다.

조각 셰이더에서 텍스처를 읽을 때 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바이트씩 이동하지만 3개의 값만 변경합니다. 이 특정 변환은 알파 값을 변경하지 않기 때문입니다. 또한 Uint8ClampedArray는 모든 값을 정수로 반올림하고 값을 0에서 255 사이로 고정합니다.

WebGL 프래그먼트 셰이더:

    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 요소를 소스 이미지로 사용하는 경우 도움말의 다른 모든 항목은 이미 동영상에 적용됩니다.

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

동영상을 작업할 때는 빠른 처리가 특히 중요합니다. 정지 이미지의 경우 사용자는 버튼을 클릭한 후 효과가 적용되기까지 100ms의 지연을 알아차리지 못할 수 있습니다. 하지만 애니메이션의 경우 16ms의 지연만으로도 눈에 띄는 끊김 현상이 발생할 수 있습니다.

의견