圖片和影片的即時效果

Mat Scales

現今市面上有許多熱門應用程式,都可以讓您為圖片或影片套用濾鏡或特效。本文說明如何在開放網路上實作這些功能。

影片和圖片的處理程序基本上相同,但我會在最後介紹一些重要的影片注意事項。在整篇文章中,您可以假設「圖片」是指「圖片或影片的單一影格」。

如何取得圖片的像素資料

圖片處理作業有 3 種常見類別:

  • 像是對比度、亮度、暖色調、棕黑色調、飽和度等像素效果。
  • 多像素效果,稱為卷積濾鏡,例如銳化、邊緣偵測、模糊處理。
  • 整張圖片變形,例如裁剪、扭曲、拉伸、鏡頭效果、波紋。

這些都是取得來源圖片的實際像素資料,然後再根據圖片建立新圖片,以及唯一能夠透過畫布進行這項作業的介面。

因此最重要的選擇是在 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。每個陣列元素都是一個位元組,陣列中的四個元素則代表一個像素的顏色。四個元素分別代表紅色、綠色、藍色和 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。
  • 建立「頂點著色器」。
  • 建立「片段著色器」。
  • 設定一些稱為「均勻」的著色器變數。
  • 執行著色器。

讓我們深入探討。請先在圖形卡上分配一些記憶體,稱為頂點緩衝區。您會在其中儲存資料,用於說明每個三角形的各點。您也可以透過兩種著色器設定一些變數 (稱為「統一」),這些變數是全域值。

頂點著色器會使用頂點緩衝區的資料,計算螢幕上繪製每個三角形的三個點的位置。

現在 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) 像素的輸出顏色。取出周圍的 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],
);

更複雜的效果 (例如鏡頭變形或漣漪效果) 需要對每個目的地座標套用一些偏移值,以便計算來源像素座標。舉例來說,如要產生水平波浪效果,您可以根據 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 元素做為來源圖片,文章中的其他內容都會適用於影片。

畫布 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 毫秒,就可能會造成明顯的卡頓現象。

意見回饋