画像と動画のリアルタイム エフェクト

Mat Scales

現在、最も人気のあるアプリの多くでは、画像や動画にフィルタやエフェクトを適用できます。この記事では、これらの機能をオープンウェブに実装する方法について説明します。

動画と画像のプロセスは基本的に同じですが、最後に動画に関する重要な考慮事項をいくつか説明します。この記事では、「画像」は「画像または動画の 1 フレーム」を意味します。

画像のピクセルデータを取得する方法

一般的な画像操作には、次の 3 つの基本カテゴリがあります。

  • コントラスト、明るさ、色温度、セピア調、彩度などのピクセル効果。
  • マルチピクセル効果(畳み込みフィルタと呼ばれる)。シャープ化、エッジ検出、ぼかしなど。
  • 画像全体の歪み(切り抜き、傾き、引き伸ばし、レンズ効果、波紋など)。

これらはすべて、ソース画像の実際のピクセルデータを取得し、そこから新しい画像を作成することを含みます。これを行うための唯一のインターフェースはキャンバスです。

重要なのは、2D キャンバスを使用して CPU で処理を行うか、WebGL を使用して GPU で処理を行うかを選択することです。

この 2 つのアプローチの違いを簡単に見てみましょう。

2D キャンバス

これは、2 つのオプションのうち、はるかに簡単な方法です。まず、キャンバスに画像を描画します。

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 は大きなトピックであり、1 つの記事で十分に説明するには大きすぎます。WebGL の詳細については、この記事の最後にあるおすすめの資料をご覧ください。

ただし、ここでは、単一の画像を操作する場合に必要なことについて簡単に説明します。

WebGL について覚えておくべき最も重要なことの一つは、WebGL は 3D グラフィックス API ではないということです。実際、WebGL(および OpenGL)が得意なのは、三角形の描画という 1 つのことだけです。アプリケーションでは、実際に描画する内容を三角形で記述する必要があります。2D 画像の場合、これは非常に簡単です。長方形は 2 つの類似した直角三角形であり、斜辺が同じ位置になるように配置されているためです。

基本的なプロセスは次のとおりです。

  • 三角形の頂点(点)を記述するデータを GPU に送信します。
  • ソース画像をテクスチャ(画像)として GPU に送信します。
  • 「頂点シェーダー」を作成します。
  • 「フラグメント シェーダー」を作成します。
  • 「ユニフォーム」と呼ばれるシェーダー変数を設定します。
  • シェーダーを実行します。

詳しく見ていきましょう。まず、頂点バッファと呼ばれるグラフィック カードにメモリを割り当てます。各三角形の各点を記述するデータを格納します。また、ユニフォームと呼ばれる、両方のシェーダーでグローバルな値となる変数を設定することもできます。

頂点シェーダーは、頂点バッファのデータを使用して、各三角形の 3 つの点を描画する画面上の位置を計算します。

これで、GPU はキャンバス内のどのピクセルを描画する必要があるかを認識します。フラグメント シェーダーはピクセルごとに 1 回呼び出され、画面に描画する色を返す必要があります。フラグメント シェーダーは、1 つ以上のテクスチャから情報を読み取って色を決定できます。

フラグメント シェーダーでテクスチャを読み取る場合は、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 部分のみが乗算されます。

これらのフィルタの一部は、画像全体の平均輝度などの追加情報を取得しますが、これらは画像全体に対して 1 回計算できるものです。

たとえば、コントラストを変更する方法の 1 つとして、各ピクセルを「グレー」値に近づけたり遠ざけたりして、コントラストを低くしたり高くしたりできます。グレー値は通常、輝度が画像内のすべてのピクセルの輝度の中央値であるグレー色になるように選択されます。

この値は、画像の読み込み時に 1 回計算し、画像効果を調整する必要があるたびに使用できます。

マルチピクセル

一部のエフェクトでは、現在のピクセルの色を決定する際に、隣接するピクセルの色が使用されます。

これは、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
};

動画を扱う場合、処理が高速であることが特に重要になります。静止画像の場合、ボタンをクリックしてからエフェクトが適用されるまでの 100 ミリ秒の遅延にユーザーが気付かない可能性があります。しかし、アニメーションの場合、わずか 16 ミリ秒の遅延でも、目に見えるぎこちなさの原因となります。

フィードバック