圖片和影片的即時效果

Mat Scales

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

基本上,影片和圖片的程序大致相同,但我會在結尾說明幾個重要的影片注意事項。在整個報導中,您可假設「圖片」是指 「影片的圖片或單一畫面」

如何取得圖片的像素資料

圖片操弄基本類別可分為 3 種:

  • Pixel 效果,例如對比度、亮度、色溫、深褐色調、飽和度。
  • 多像素效果,稱為卷積濾鏡,例如銳利化、邊緣偵測和模糊。
  • 整體圖片變形,例如裁剪、歪斜、延展、鏡頭效果、波紋。

這些操作包括取得來源圖片的實際像素資料,再從圖片建立新圖片,而且唯一用來進行這項操作的介面就是畫布。

最重要的是,是要透過 WebGL 在 CPU、2D 畫布或 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 個位元組,而陣列中的每四個元素都代表一個像素的顏色。四個元素分別代表該順序的紅色、綠色、藍色和 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。
  • 建立「頂點著色器」。
  • 建立「Fragment 著色器」。
  • 設定一些名為「制服」的著色器變數。
  • 執行著色器。

接著就來詳細說明。首先,在圖形卡上分配一些記憶體,稱為頂點緩衝區。並將資料儲存在其中,用來描述每個三角形每個點。您也可以透過兩個著色器設定一些稱為「制服」的變數,這些變數是全域值。

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

現在 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 Canvas 案例中執行的動作,因為您想要讀取圖片的原始顏色,而上一個範例是更新現有的像素。

但夠簡單了。一開始建立圖片資料物件時,可以複製資料。

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 元素做為來源圖片,這篇文章中的所有其他項目都已適用於影片。

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 毫秒的延遲會導致明顯抖動。

意見回饋: