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

매트 저울

오늘날 가장 많이 사용되는 앱에서는 이미지나 동영상에 필터와 효과를 적용할 수 있습니다. 이 도움말에서는 오픈 웹에서 이러한 기능을 구현하는 방법을 설명합니다.

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

이미지의 픽셀 데이터를 가져오는 방법

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

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

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

따라서 정말 중요한 선택은 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 * 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 이미지의 경우 매우 간단합니다. 직사각형은 빗변이 같은 위치에 있도록 배열된 2개의 비슷한 직각 삼각형이기 때문입니다.

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

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

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

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

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

프래그먼트 셰이더에서 텍스처를 읽을 때 0 (왼쪽 또는 하단)과 1 (오른쪽 또는 상단) 사이의 부동 소수점 좌표 2개를 사용하여 이미지의 어느 부분을 읽으려는지 지정합니다.

픽셀 좌표를 기준으로 텍스처를 읽으려면 각 픽셀에 변환을 실행할 수 있도록 텍스처 크기(픽셀)를 균일 벡터로 전달해야 합니다.

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)에서 픽셀의 출력 색상을 계산한다고 가정해 보겠습니다. 주변 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
};

동영상 작업 시에는 신속한 처리가 특히 중요합니다. 정지 이미지를 사용하면 버튼을 클릭한 후 효과가 적용되는 시간 사이에 100ms의 지연이 발생하지 않을 수 있습니다. 그러나 애니메이션에서는 16ms의 지연만 눈에 띄게 떨어질 수 있습니다.

의견