圖片和影片的即時特效

Mat Scales

現今許多熱門應用程式都可讓您在圖片或影片上套用濾鏡和特效。本文將說明如何在開放網路中導入這些功能。

影片和圖片的處理程序基本上相同,但我會在最後說明一些影片的重要注意事項。在本文中,「圖片」一詞代表「圖片或影片的單一影格」。

如何取得圖片的像素資料

常見的圖片操控手法基本上可分為 3 類:

  • 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 * 4 的 Uint8ClampedArray。每個陣列元素都是一個位元組,陣列中每四個元素代表一個像素的顏色。四個元素分別代表紅色、綠色、藍色和 Alpha (透明度) 的數量。像素的排序方式是從左上角開始,由左至右、由上至下。

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。
  • 建立「頂點著色器」。
  • 建立「片段著色器」。
  • 設定一些著色器變數,稱為「uniforms」。
  • 執行著色器。

我們來詳細說明。首先,在顯示卡上分配一些記憶體,稱為頂點緩衝區。您可以在其中儲存資料,描述每個三角形的每個點。您也可以設定一些變數 (稱為 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 個位元組,但只會變更三個值,這是因為這項特定轉換不會變更 Alpha 值。另請注意,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
};

處理影片時,速度尤其重要。如果是靜態圖片,使用者可能不會注意到點選按鈕和套用效果之間有 100 毫秒的延遲。不過,在動畫中,只要延遲 16 毫秒,就會造成明顯的卡頓。

意見回饋