Efek Real-Time untuk Gambar dan Video

Timbangan Mat

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 penting di bagian akhir. Dalam artikel ini, 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 penajaman, deteksi tepi, pemburaman.
  • Distorsi gambar keseluruhan, seperti pemangkasan, kemiringan, peregangan, efek lensa, riak.

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

Pilihan yang sangat penting adalah apakah melakukan pemrosesan pada CPU, dengan kanvas 2D, atau di GPU, dengan WebGL.

Mari kita lihat sekilas perbedaan antara kedua pendekatan tersebut.

Kanvas 2D

Ini jelas, sangat, yang paling sederhana dari dua opsi yang ada. Pertama Anda menggambar gambar pada 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 berukuran satu byte dan setiap empat elemen dalam array mewakili warna satu piksel. Masing-masing dari empat elemen mewakili jumlah merah, hijau, biru, dan alfa (transparansi) dalam urutan tersebut. Piksel diurutkan mulai dari sudut kiri atas serta 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 dapat menerapkan efek apa pun yang diinginkan. Namun, array ini adalah salinan data piksel sebenarnya untuk kanvas. Untuk menulis kembali versi yang diedit, Anda harus menggunakan metode putImageData untuk menuliskannya kembali ke sudut kiri atas kanvas

context.putImageData(imageData, 0, 0);

WebGL

WebGL adalah topik yang besar, yang pasti terlalu besar untuk diterapkan dalam satu artikel. Jika Anda ingin mempelajari WebGL lebih lanjut, lihat bacaan yang direkomendasikan di akhir artikel ini.

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

Salah satu hal terpenting yang perlu diingat tentang WebGL adalah bukan API grafis 3D. Bahkan, WebGL (dan OpenGL) dapat berfungsi untuk satu hal - menggambar segitiga. Dalam aplikasi, Anda harus mendeskripsikan hal yang sebenarnya ingin Anda gambar dalam bentuk segitiga. Pada gambar 2D, hal itu sangat sederhana, karena persegi panjang adalah dua segitiga siku-siku yang serupa, disusun sedemikian rupa sehingga hipotenusnya berada di tempat yang sama.

Proses dasarnya adalah:

  • Mengirim data ke GPU yang menjelaskan verteks (titik) segitiga.
  • Kirim gambar sumber Anda ke GPU sebagai tekstur (gambar).
  • Buat 'vertex shader'.
  • Buat 'shader fragmen'.
  • Tetapkan beberapa variabel shader, yang disebut 'uniforms'.
  • Jalankan shader.

Mari kita bahas detailnya. Mulailah dengan mengalokasikan sebagian memori pada kartu grafis yang disebut buffer verteks. 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.

Shader verteks menggunakan data dari buffer verteks untuk menghitung posisi menggambar tiga titik dari setiap segitiga di layar.

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

Saat membaca tekstur dalam shader fragmen, 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 ini memindahkan 4 byte sekaligus, tetapi hanya mengubah tiga nilai - 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 mengambil informasi tambahan, seperti luminans rata-rata seluruh gambar, tetapi hal ini adalah hal yang dapat dihitung sekali untuk seluruh gambar.

Salah satu cara untuk mengubah kontras, misalnya, dapat dilakukan dengan memindahkan setiap piksel menuju atau menjauh dari beberapa nilai 'abu-abu', untuk kontras yang lebih rendah atau lebih tinggi. Nilai abu-abu biasanya dipilih sebagai warna abu-abu yang luminansnya adalah luminans median semua piksel pada gambar.

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

Multipiksel

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

Tindakan ini akan sedikit mengubah cara Anda melakukan berbagai hal dalam casing kanvas 2D karena Anda ingin dapat membaca warna asli gambar, dan contoh sebelumnya adalah memperbarui piksel yang diterapkan.

Tapi, ini cukup mudah. Saat pertama kali membuat objek data gambar, Anda dapat membuat salinan data tersebut.

const originalPixels = new Uint8Array(imageData.data);

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

Kategori efek multipiksel 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 pusat yang sesuai dengan piksel saat ini. Misalnya, ini adalah kernel untuk blur Gaussian 3x3.

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

Misalkan Anda ingin menghitung warna output piksel di (23, 19). Ambil 8 piksel di sekitarnya (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 bersama-sama, lalu bagi hasilnya dengan 8, yang merupakan jumlah bobot. Anda dapat melihat hasilnya adalah piksel yang sebagian besar adalah piksel asli, tetapi dengan piksel di dekatnya yang masuk.

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

Hal ini memberikan gambaran dasar, tetapi ada panduan di luar sana yang akan menjelaskan secara lebih detail, dan mencantumkan banyak kernel berguna lainnya.

Seluruh gambar

Beberapa transformasi seluruh gambar dapat dilakukan dengan mudah. Dalam 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 kuat, banyak transformasi 2D yang 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 ripple melibatkan penerapan beberapa offset ke setiap koordinat tujuan untuk menghitung koordinat piksel sumber. Misalnya, untuk memiliki efek gelombang horizontal, Anda dapat melakukan offset pada 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

Fitur 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 pada video yang sedang diputar, Anda harus menggunakan drawImage/texImage2D di 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 menggunakan video, pemrosesan yang cepat menjadi sangat penting. Dengan gambar diam, pengguna mungkin tidak menyadari penundaan 100 md antara mengklik tombol dan efek yang diterapkan. Namun, saat dianimasikan, penundaan hanya 16 md dapat menyebabkan keterlambatan yang terlihat.

Masukan