Resimler ve Videolar İçin Gerçek Zamanlı Efektler

Mat Ölçekler

Günümüzün en popüler uygulamalarının çoğu, resimlere veya videolara filtre ve efekt uygulamanıza olanak tanır. Bu makalede, bu özelliklerin açık web'de nasıl uygulanacağı gösterilmektedir.

Süreç temelde videolar ve resimler için aynıdır, ancak videonun sonunda video ile ilgili dikkat edilmesi gereken bazı önemli noktalara değineceğim. Makalenin tamamında "resim" ifadesinin "bir videonun resmi veya tek bir karesi" anlamına geldiğini varsayabilirsiniz.

Bir resmin piksel verilerini alma

Görüntü manipülasyonunda yaygın olarak görülen 3 temel kategori vardır:

  • Kontrast, parlaklık, sıcaklık, sepya tonu, doygunluk gibi piksel efektleri.
  • Keskinleştirme, kenar algılama, bulanıklık gibi konvolüsyon filtresi adı verilen çoklu piksel efektleri.
  • Kırpma, eğme, esneme, lens efektleri, dalgalar gibi resmin tamamının bozulması.

Bunların tümü, kaynak resmin gerçek piksel verilerine ulaşmayı ve ondan yeni bir resim oluşturmayı içerir. Bunu yapmanın tek arayüzü tuvaldir.

Bu durumda asıl önemli seçenek, işlemeyi CPU'da mı, 2D tuvalle mi yoksa GPU'da WebGL'yle mi yapmaktır.

İki yaklaşım arasındaki farklara hızlı bir şekilde göz atalım.

2D tuval

Bu kesinlikle iki seçenek arasında en basit olanıdır. Önce resmi tuval üzerine çiziyorsunuz.

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

Ardından, tüm tuval için piksel değerlerinden oluşan bir dizi elde edersiniz.

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

Bu noktada pixels değişkeni, width * height * 4 uzunluğunda bir Uint8ClampedArray olur. Her dizi öğesi bir bayttır ve dizideki her dört öğe bir pikselin rengini temsil eder. Dört öğenin her biri, bu sırayla kırmızı, yeşil, mavi ve alfa (şeffaflık) miktarını temsil eder. Pikseller sol üst köşeden başlayarak soldan sağa ve yukarıdan aşağıya doğru sıralanır.

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

Koordinatlarından belirli bir pikselin dizinini bulmak için basit bir formül kullanabilirsiniz.

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

Artık bu verileri istediğiniz gibi okuyup yazabilir ve aklınıza gelen her türlü efekti uygulayabilirsiniz. Ancak bu dizi, tuvalin gerçek piksel verilerinin bir kopyası. Düzenlenen sürümü geri yazmak için putImageData yöntemini kullanarak bunu kanvasın sol üst köşesine geri yazmanız gerekir.

context.putImageData(imageData, 0, 0);

WebGL

WebGL büyük bir konudur ve tek bir makalede adaletini sağlamak için kesinlikle çok büyük bir konudur. WebGL hakkında daha fazla bilgi edinmek istiyorsanız bu makalenin sonundaki önerilen okuma materyaline göz atın.

Ancak, tek bir resmin değiştirilmesi durumunda yapılması gerekenlere ilişkin kısa bir tanıtımı burada bulabilirsiniz.

WebGL hakkında unutulmaması gereken en önemli şeylerden biri, bunun bir 3D grafik API'si olmadığıdır. Aslında, WebGL (ve OpenGL) tam olarak tek bir konuda, üçgen çizme konusunda iyidir. Uygulamanızda, gerçekte ne çizmek istediğinizi üçgenler açısından açıklamanız gerekir. 2D görüntü söz konusu olduğunda bu çok basittir, çünkü bir dikdörtgen, hipotenüsleri aynı yerde olacak şekilde düzenlenmiş, birbirine benzer iki dik üçgenden oluşur.

Temel süreç şu şekildedir:

  • Üçgenlerin köşelerini (noktaları) açıklayan verileri GPU'ya gönderin.
  • Kaynak resminizi GPU'ya doku (resim) olarak gönderin.
  • Bir "köşe gölgelendirici" oluşturun.
  • 'Parça gölgelendirici' oluşturun.
  • "üniformalar" adı verilen bazı gölgelendirici değişkenler ayarlayın.
  • Gölgelendiricileri çalıştırın.

Şimdi ayrıntıya girelim. Grafik kartında tepe noktası arabelleği adı verilen bir miktar bellek ayırarak başlayın. Her bir üçgenin her bir noktasını açıklayan verileri burada depolarsınız. Ayrıca, her iki gölgelendiricide de global değerler olan ve üniforma adı verilen bazı değişkenler ayarlayabilirsiniz.

Bir köşe gölgelendirici, her bir üçgenin üç noktasının ekranda nereye çizileceğini hesaplamak için tepe noktası arabelleğinden alınan verileri kullanır.

GPU artık tuval içindeki hangi piksellerin çizilmesi gerektiğini bilir. Parça gölgelendiriciye piksel başına bir kez çağrılır ve ekrana çizilmesi gereken rengi döndürmesi gerekir. Parça gölgelendirici, rengi belirlemek için bir veya daha fazla dokudaki bilgileri okuyabilir.

Parça gölgelendiricide bir doku okurken, 0 (sol veya alt) ile 1 (sağ veya üst) arasında iki kayan nokta koordinatlarını kullanarak resmin hangi bölümünü okumak istediğinizi belirtirsiniz.

Dokuyu piksel koordinatlarına göre okumak isterseniz her bir piksel için dönüştürme işlemi yapabilmeniz amacıyla, dokunun boyutunu piksel cinsinden tek tip bir vektör olarak geçirmeniz gerekir.

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

Döngünün bir kerede 4 bayt boyunca hareket ettiğini, ancak yalnızca üç değer değiştirdiğini unutmayın. Bunun nedeni, söz konusu dönüşümün alfa değerini değiştirmemesidir. Ayrıca, bir Uint8ClampedArray'in tüm değerleri tam sayılara yuvarlayacağını ve kenet değerlerinin 0 ile 255 arasında olacağını unutmayın.

WebGL parçası gölgelendirici:

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

Benzer şekilde, bu dönüşüm için yalnızca çıkış renginin RGB kısmı çarpılır.

Bu filtrelerin bazıları, bütün resmin ortalama parlaklığı gibi ek bilgiler alır ancak bunlar, resmin tamamı için bir kez hesaplanabilir.

Örneğin, kontrastı değiştirmenin bir yolu, daha düşük veya yüksek kontrast için her bir pikseli sırasıyla bir "gri" değere doğru ya da belirli bir değerden uzaklaştırmak olabilir. Gri değeri genellikle gri bir renk olarak seçilir. Parlaklık değeri resimdeki tüm piksellerin ortanca parlaklığı kadardır.

Bu değeri resim yüklendiğinde bir kez hesaplayabilir ve her resim efektini ayarlamanız gerektiğinde kullanabilirsiniz.

Çoklu piksel

Bazı efektler, geçerli pikselin rengine karar verirken komşu piksellerin rengini kullanır.

Bu değişiklik, resmin orijinal renklerini okuyabilmek istediğiniz ve önceki örnekte piksellerin yerlerinde güncellenmesi gerektiği için 2D tuval kılıfı üzerinde yaptığınız işlemlerde küçük bir değişiklik yapılmıştır.

Ama bu yeterince kolay. Görüntü veri nesnenizi ilk kez oluşturduğunuzda verilerin bir kopyasını oluşturabilirsiniz.

const originalPixels = new Uint8Array(imageData.data);

WebGL durumunda, gölgelendirici giriş dokusuna yazmadığından herhangi bir değişiklik yapmanız gerekmez.

Çoklu piksel efektinin en yaygın kategorisi, konvolüsyon filtresi olarak adlandırılır. Konvolüsyon filtresi, giriş resmindeki her bir pikselin rengini hesaplamak için giriş resminden birkaç piksel kullanır. Her bir giriş pikselinin çıkış üzerindeki etki düzeyine ağırlık adı verilir.

Ağırlıklar, çekirdek adı verilen ve merkezi değeri geçerli piksele karşılık gelen değere sahip bir matrisle temsil edilebilir. Örneğin, bu, 3x3 Gauss bulanıklaştırması için çekirdektir.

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

(23, 19) noktasındaki pikselin çıkış rengini hesaplamak istediğinizi varsayalım. Pikseli çevreleyen (23, 19) 8 pikseli alın ve her birinin renk değerini, karşılık gelen ağırlıkla çarpın.

    (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

Hepsini toplayın ve sonra sonucu ağırlıkların toplamı olan 8'e bölün. Sonucun büyük oranda orijinal olan, ancak yakındaki piksellerin taştığı bir piksele dönüştüğünü görebilirsiniz.

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

Bu, temel bir fikir verir, ancak çok daha ayrıntılı bilgiler verecek ve diğer birçok yararlı çekirdeği listeleyecek kılavuzlar vardır.

Resmin tamamı

Bazı bütün resim dönüştürme işlemleri basittir. 2D kanvasta kırpma ve ölçeklendirme, tuvale kaynak resmin yalnızca bir bölümünü çizmenin basit bir işlemidir.

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

Döndürme ve yansıma doğrudan 2D bağlamında kullanılabilir. Resmi tuvale çizmeden önce çeşitli dönüşümleri değiştirin.

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

Ancak daha da önemlisi, birçok 2D dönüşüm, 2x3 matrisler olarak yazılabilir ve setTransform() ile tuvale uygulanabilir. Bu örnekte, bir döndürme ve çevirmeyi birleştiren bir matris kullanılmaktadır.

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

Mercek bozulması veya dalgalar gibi daha karmaşık efektler, kaynak piksel koordinatını hesaplamak için her hedef koordinatına bir miktar ofset uygulanmasını içerir. Örneğin, yatay bir dalga efekti elde etmek için kaynak pikselin x koordinatını, y koordinatına bağlı olarak bir değerle belirli bir değere ayarlayabilirsiniz.

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

Kaynak resim olarak video öğesi kullanırsanız makaledeki diğer her şey video için de çalışır.

2D Tuval:

context.drawImage(<strong>video</strong>, 0, 0);

WebGL:

gl.texImage2D(
  gl.TEXTURE_2D,
  0,
  gl.RGBA,
  gl.RGBA,
  gl.UNSIGNED_BYTE,
  <strong>video</strong>,
);

Ancak, bu yalnızca geçerli video karesini kullanır. Dolayısıyla, oynatılan bir videoya efekt uygulamak isterseniz yeni bir video karesi yakalamak ve bunu her tarayıcı animasyon karesinde işlemek için her karede drawImage/texImage2D kullanmanız gerekir.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Video üzerinde çalışırken, işlemenin hızlı olması çok önemlidir. Kullanıcı sabit bir resimle, bir düğmeyi tıklama ile efektin uygulanması arasında 100 ms.lik bir gecikme fark etmeyebilir. Ancak animasyonlu olarak yalnızca 16 ms.deki gecikmeler, ekranda gözle görülür sarsıntılara neden olabilir.

Geri bildirim