이미지 및 동영상에 대한 실시간 효과

Mat Scales

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

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

이미지의 픽셀 데이터를 확인하는 방법

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

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

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

따라서 정말 중요한 선택은 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 * 4Uint8ClampedArray입니다. 모든 배열 요소는 1바이트이며 배열의 요소 4개는 1픽셀의 색상을 나타냅니다. 네 요소는 각각 빨간색, 녹색, 파란색, 알파 (투명도)의 양을 순서대로 나타냅니다. 픽셀은 왼쪽 상단에서 시작하여 왼쪽에서 오른쪽, 위에서 아래로 순서가 지정됩니다.

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에 전송합니다.
  • '꼭짓점 셰이더'를 만듭니다.
  • '프래그먼트 셰이더'를 만듭니다.
  • '유니폼'이라는 셰이더 변수를 설정합니다.
  • 셰이더를 실행합니다.

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

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

이제 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바이트를 이동하지만 값은 세 개만 변경합니다. 이 특정 변환은 알파 값을 변경하지 않기 때문입니다. 또한, 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)의 픽셀의 출력 색상을 계산한다고 가정해 보겠습니다. 둘레의 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],
);

렌즈 왜곡이나 물결과 같은 더 복잡한 효과는 각 대상 좌표에 약간의 오프셋을 적용하여 소스 픽셀 좌표를 계산하는 것을 포함합니다. 예를 들어 가로 물결 효과를 내기 위해 소스 픽셀 x 좌표를 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 요소를 소스 이미지로 사용하는 경우 도움말의 다른 모든 내용은 동영상에 이미 적용됩니다.

캔버스 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밀리초의 지연이 발생해도 사용자가 이를 눈치채지 못할 수 있습니다. 하지만 애니메이션을 사용하면 16ms의 지연만으로도 눈에 띄게 끊김이 발생할 수 있습니다.

의견