Efek Real-Time untuk Gambar dan Video

Mat Scales

Banyak aplikasi terpopuler saat ini yang memungkinkan Anda menerapkan filter dan efek ke gambar atau video. Artikel ini menunjukkan cara menerapkan fitur ini di web terbuka.

Prosesnya pada dasarnya sama untuk video dan gambar, tetapi saya akan membahas beberapa pertimbangan video yang penting di bagian akhir. Di seluruh artikel, Anda dapat mengasumsikan bahwa 'gambar' berarti 'gambar atau satu frame video'

Cara mendapatkan data piksel untuk gambar

Ada 3 kategori dasar manipulasi gambar yang umum:

  • Efek piksel seperti kontras, kecerahan, kehangatan, warna cokelat keabu-abuan, saturasi.
  • Efek multipiksel, yang disebut filter konvolusi, seperti mempertajam, deteksi tepi, dan memburamkan.
  • Distorsi seluruh gambar, seperti pemangkasan, pemiringan, peregangan, efek lensa, riak.

Semua ini melibatkan pengambilan data piksel aktual dari gambar sumber, lalu membuat gambar baru darinya, dan satu-satunya antarmuka untuk melakukannya adalah kanvas.

Jadi, pilihan yang benar-benar penting adalah apakah akan melakukan pemrosesan di CPU, dengan kanvas 2D, atau di GPU, dengan WebGL.

Mari kita lihat perbedaan antara kedua pendekatan tersebut.

Kanvas 2D

Ini jelas merupakan opsi yang paling sederhana dari kedua opsi tersebut. Pertama, gambarlah gambar di kanvas.

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);

Kemudian Anda mendapatkan array nilai piksel untuk seluruh kanvas.

const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;

Pada tahap ini, variabel pixels adalah Uint8ClampedArray dengan panjang width * height * 4. Setiap elemen array adalah satu byte dan setiap empat elemen dalam array mewakili warna satu piksel. Masing-masing dari empat elemen tersebut mewakili jumlah merah, hijau, biru, dan alfa (transparansi) dalam urutan tersebut. Piksel diurutkan mulai dari sudut kiri atas, kemudian bekerja dari kiri ke kanan, dan dari atas ke bawah.

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
...

Untuk menemukan indeks piksel tertentu dari koordinatnya, ada rumus sederhana.

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];

Sekarang Anda dapat membaca dan menulis data ini sesuai keinginan, sehingga Anda dapat menerapkan efek apa pun yang dapat Anda pikirkan. Namun, array ini adalah salinan data piksel sebenarnya untuk kanvas. Untuk menulis versi yang diedit kembali, Anda perlu menggunakan metode putImageData untuk menulisnya kembali ke sudut kiri atas kanvas

context.putImageData(imageData, 0, 0);

WebGL

WebGL adalah topik besar, yang tentu saja terlalu besar untuk dilakukan secara adil dalam satu artikel. Jika Anda ingin mempelajari WebGL lebih lanjut, lihat bacaan yang direkomendasikan di bagian akhir artikel ini.

Namun, berikut adalah pengantar singkat tentang hal yang perlu dilakukan jika memanipulasi satu gambar.

Salah satu hal terpenting yang perlu diingat tentang WebGL adalah bukan API grafis 3D. Faktanya, WebGL (dan OpenGL) hanya mahir dalam satu hal - menggambar segitiga. Dalam aplikasi, Anda harus menjelaskan apa yang sebenarnya ingin Anda gambar dalam bentuk segitiga. Dalam kasus gambar 2D, hal ini sangat sederhana, karena persegi panjang adalah dua segitiga siku-siku yang serupa, yang disusun sedemikian rupa sehingga hipotenusnya berada di tempat yang sama.

Proses dasarnya adalah:

  • Kirim data ke GPU yang menjelaskan vertex (titik) segitiga.
  • Kirim gambar sumber ke GPU sebagai tekstur (gambar).
  • Buat 'vertex shader'.
  • Buat 'fragment shader'.
  • Tetapkan beberapa variabel shader, yang disebut 'uniform'.
  • Jalankan shader.

Mari kita bahas detailnya. Mulailah dengan mengalokasikan beberapa memori pada kartu grafis yang disebut buffer vertex. Anda menyimpan data di dalamnya yang menjelaskan setiap titik dari setiap segitiga. Anda juga dapat menetapkan beberapa variabel, yang disebut uniform, yang merupakan nilai global melalui kedua shader.

Vertex shader menggunakan data dari buffer vertex untuk menghitung tempat di layar untuk menggambar tiga titik dari setiap segitiga.

Sekarang GPU mengetahui piksel mana dalam kanvas yang perlu digambar. Shader fragmen dipanggil satu kali per piksel, dan harus menampilkan warna yang harus digambar ke layar. Shader fragmen dapat membaca informasi dari satu atau beberapa tekstur untuk menentukan warna.

Saat membaca tekstur dalam fragment shader, Anda menentukan bagian gambar yang ingin dibaca menggunakan dua koordinat floating point antara 0 (kiri atau bawah) dan 1 (kanan atau atas).

Jika ingin membaca tekstur berdasarkan koordinat piksel, Anda harus meneruskan ukuran tekstur dalam piksel sebagai vektor seragam sehingga Anda dapat melakukan konversi untuk setiap piksel.

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;
}

Perhatikan bahwa loop memindahkan 4 byte dalam satu waktu, tetapi hanya mengubah tiga nilai - hal ini karena transformasi khusus ini tidak mengubah nilai alfa. Ingat juga bahwa Uint8ClampedArray akan membulatkan semua nilai ke bilangan bulat, dan membatasi nilai antara 0 dan 255.

Shader fragmen WebGL:

    float brightness = 1.1;
    gl_FragColor = textureColor;
    gl_FragColor.rgb *= brightness;

Demikian pula, hanya bagian RGB dari warna output yang dikalikan untuk transformasi khusus ini.

Beberapa filter ini memerlukan informasi tambahan, seperti luminans rata-rata seluruh gambar, tetapi ini adalah hal yang dapat dihitung sekali untuk seluruh gambar.

Misalnya, salah satu cara untuk mengubah kontras adalah dengan memindahkan setiap piksel ke arah atau menjauh dari beberapa nilai 'abu-abu', masing-masing untuk kontras yang lebih rendah atau lebih tinggi. Nilai abu-abu biasanya dipilih sebagai warna abu-abu yang luminensinya adalah luminensi median dari semua piksel dalam gambar.

Anda dapat menghitung nilai ini sekali saat gambar dimuat, lalu menggunakannya setiap kali Anda perlu menyesuaikan efek gambar.

Multi-piksel

Beberapa efek menggunakan warna piksel tetangga saat menentukan warna piksel saat ini.

Hal ini sedikit mengubah cara Anda melakukan sesuatu dalam kasus kanvas 2D karena Anda ingin dapat membaca warna asli gambar, dan contoh sebelumnya memperbarui piksel di tempat.

Namun, hal ini cukup mudah. Saat pertama kali membuat objek data gambar, Anda dapat membuat salinan data.

const originalPixels = new Uint8Array(imageData.data);

Untuk kasus WebGL, Anda tidak perlu melakukan perubahan apa pun, karena shader tidak menulis ke tekstur input.

Kategori efek multi-piksel yang paling umum disebut filter konvolusi. Filter konvolusi menggunakan beberapa piksel dari gambar input untuk menghitung warna setiap piksel dalam gambar input. Tingkat pengaruh yang dimiliki setiap piksel input terhadap output disebut bobot.

Bobot dapat direpresentasikan oleh matriks, yang disebut kernel, dengan nilai tengah yang sesuai dengan piksel saat ini. Misalnya, ini adalah kernel untuk pemburaman Gaussian 3x3.

    | 0  1  0 |
    | 1  4  1 |
    | 0  1  0 |

Jadi, misalnya Anda ingin menghitung warna output piksel di (23, 19). Ambil 8 piksel yang mengelilingi (23, 19) serta piksel itu sendiri, lalu kalikan nilai warna untuk setiap piksel dengan bobot yang sesuai.

    (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

Jumlahkan semuanya, lalu bagi hasilnya dengan 8, yang merupakan jumlah bobot. Anda dapat melihat bagaimana hasilnya akan berupa piksel yang sebagian besar adalah piksel asli, tetapi dengan piksel di sekitarnya yang tumpang-tindih.

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;
  }
}

Ini memberikan ide dasar, tetapi ada panduan di luar sana yang akan membahas lebih mendetail, dan mencantumkan banyak kernel berguna lainnya.

Seluruh gambar

Beberapa transformasi gambar secara keseluruhan bersifat sederhana. Di kanvas 2D, pemangkasan dan penskalaan adalah kasus sederhana yang hanya menggambar sebagian gambar sumber ke kanvas.

// 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);

Rotasi dan refleksi tersedia langsung dalam konteks 2D. Sebelum menggambar gambar ke dalam kanvas, ubah berbagai transformasi.

// 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);

Namun, yang lebih canggih, banyak transformasi 2D dapat ditulis sebagai matriks 2x3 dan diterapkan ke kanvas dengan setTransform(). Contoh ini menggunakan matriks yang menggabungkan rotasi dan terjemahan.

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],
);

Efek yang lebih rumit seperti distorsi lensa atau riak melibatkan penerapan beberapa offset ke setiap koordinat tujuan untuk menghitung koordinat piksel sumber. Misalnya, untuk memiliki efek gelombang horizontal, Anda dapat meng-offset koordinat x piksel sumber dengan beberapa nilai berdasarkan koordinat 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

Semua hal lain dalam artikel ini sudah berfungsi untuk video jika Anda menggunakan elemen video sebagai gambar sumber.

Kanvas 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>,
);

Namun, tindakan ini hanya akan menggunakan frame video saat ini. Jadi, jika ingin menerapkan efek ke video yang sedang diputar, Anda harus menggunakan drawImage/texImage2D pada setiap frame untuk mengambil frame video baru dan memprosesnya di setiap frame animasi browser.

const draw = () => {
  requestAnimationFrame(draw);

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Saat menangani video, kecepatan pemrosesan menjadi sangat penting. Dengan gambar tetap, pengguna mungkin tidak melihat penundaan 100 md antara mengklik tombol dan efek yang diterapkan. Namun, saat dianimasikan, keterlambatan yang hanya berdurasi 16 md dapat menyebabkan efek konyol yang terlihat jelas.

Masukan