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

Mat Scales

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.

Bu işlem, videolar ve resimler için temel olarak aynıdır ancak videolarla ilgili bazı önemli noktaları son kısımda ele alacağım. Makale boyunca "resim" ifadesinin "resim veya videonun tek bir karesi" anlamına geldiğini varsayabilirsiniz.

Görüntü manipülasyonunun yaygın olan 3 temel kategorisi vardır:

  • Kontrast, parlaklık, sıcaklık, sepya tonu, doygunluk gibi piksel efektleri.
  • Keskinleştirme, kenar algılama ve bulanıklık gibi, örtüşme filtreleri olarak adlandırılan çok pikselli efektler.
  • Kırpma, eğilme, esneme, lens efektleri, dalgalar gibi tüm görüntüdeki bozulmalar.

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

Bu nedenle, işleme işleminin CPU'da 2D kanvasla mı yoksa GPU'da WebGL ile mi yapılacağını belirlemek çok önemlidir.

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

2D tuval

Bu, iki seçenek arasından kesinlikle en basit olanıdır. Öncelikle resmi kanvas üzerine çizersiniz.

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, tuvalin tamamı için bir piksel değeri dizisi 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şlayıp soldan sağa ve yukarıdan aşağıya doğru olacak şekilde 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 vardır.

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 gelebilecek her türlü efekti uygulayabilirsiniz. Ancak bu dizi, tuvalin gerçek piksel verilerinin kopyasıdır. Düzenlenmiş sürümü geri yazmak için putImageData yöntemini kullanarak kanvasın sol üst köşesine yazmanız gerekir.

context.putImageData(imageData, 0, 0);

WebGL

WebGL, tek bir makalede hakkını verilemeyecek kadar büyük bir konudur. WebGL hakkında daha fazla bilgi edinmek isterseniz bu makalenin sonundaki önerilen okumalara göz atın.

Bununla birlikte, tek bir görselde değişiklik yapılması durumunda yapılması gerekenlere dair kısa bir giriş sunuyoruz.

WebGL hakkında unutulmaması gereken en önemli şeylerden biri, bir 3D grafik API'si olmamasıdır. Aslında, WebGL (ve OpenGL) tek bir konuda, yani üçgen çizme konusunda başarılıdır. Başvurunuzda, aslında ne çizmek istediğinizi üçgenlerle açıklamanız gerekir. 2D bir resim söz konusu olduğunda bu işlem çok basittir. Çünkü dikdörtgen, hipotenüsleri aynı yerde olacak şekilde düzenlenmiş iki benzer dik açılı üçgenden oluşur.

Temel süreç şu şekildedir:

  • GPU'ya, üçgenlerin köşelerini (noktalarını) açıklayan veriler gönderin.
  • Kaynak resminizi doku (görüntü) olarak GPU'ya gönderin.
  • "Köşe gölgelendirici" oluşturun.
  • "Parça gölgelendirici" oluşturun.
  • "Uniform" olarak adlandırılan bazı gölgelendirici değişkenlerini ayarlayın.
  • Gölgelendiricileri çalıştırın.

Ayrıntılara göz atalım. Grafik kartında bir köşe ucu arabelleği adı verilen bir miktar bellek ayırarak başlayın. Her üçgenin her noktasını tanımlayan verileri bu koleksiyonda depolarsınız. Her iki gölgelendirici aracılığıyla da genel değerler olan ve uniform olarak adlandırılan bazı değişkenler de ayarlayabilirsiniz.

Köşe gölgelendirici, her üçgenin üç noktasının ekranın neresinde çizileceğini hesaplamak için köşe arabelleğindeki verileri kullanır.

Artık GPU, tuvaldeki hangi piksellerin çizilmesi gerektiğini bilir. Kırıntı gölgelendirici, piksel başına bir kez çağrılır ve ekrana çizilmesi gereken rengi döndürmelidir. 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 istiyorsanız her piksel için dönüşümü yapabilmek üzere dokunun pikselli boyutunu tekdüze bir vektör olarak iletmeniz 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 seferde 4 bayt taşıdığını ancak yalnızca üç değeri değiştirdiğini unutmayın. Bunun nedeni, bu dönüşümün alfa değerini değiştirmemesidir. Ayrıca, Uint8ClampedArray'ın tüm değerleri tam sayılara yuvarladığını ve değerleri 0 ile 255 arasında tuttuğunu unutmayın.

WebGL kırıntı gölgelendirici:

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

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

Bu filtrelerden bazıları, tüm resmin ortalama parlaklığı gibi ek bilgiler alır. Ancak bunlar, resmin tamamı için bir kez hesaplanabilecek değerlerdir.

Örneğin, kontrastı değiştirmenin bir yolu, her pikseli sırasıyla daha düşük veya daha yüksek kontrast için bir "gri" değere doğru veya bu değerden uzaklaştırmaktır. Gri değer genellikle, parlaklık değeri resimdeki tüm piksellerin ortalama parlaklığı olan bir gri renk olarak seçilir.

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

Çok pikselli

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

Bu, resmin orijinal renklerini okuyabilmek istediğiniz ve önceki örnekte piksellerin yerinde güncellendiği için 2D kanvas kılıfında işlemleri yapma şeklinizi biraz değiştirir.

Bu işlem oldukça kolaydır. Görüntü verisi nesnenizi ilk kez oluşturduğunuzda verilerin 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 evrimsel filtre olarak adlandırılır. Difüzyon filtresi, giriş resmindeki her bir pikselin rengini hesaplamak için giriş resmindeki birkaç pikseli kullanır. Her giriş pikseli çıkış üzerindeki etki düzeyine ağırlık denir.

Ağırlıklar, merkezi değer mevcut piksele karşılık gelen bir çekirdek adı verilen bir matrisle temsil edilebilir. Örneğin, bu, 3x3'lük bir Gauss bulanıklığının çekirdeğidir.

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

(23, 19) konumundaki pikselin çıkış rengini hesaplamak istediğinizi varsayalım. Çevresindeki 8 pikseli (23, 19) ve pikselin kendisini alın ve her birinin renk değerlerini 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

Bunların hepsini toplayın ve sonucu ağırlıkların toplamı olan 8'e bölün. Sonuçta, çoğunlukla orijinal olan ancak yakındaki piksellerin de sızdığı bir piksel elde edildiğini 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ı bir şekilde ele alınacak ve diğer birçok kullanışlı çekirdek'i listeleyecek kılavuzlar mevcuttur.

Resmin tamamı

Bazı tüm resim dönüştürme işlemleri basittir. 2D bir tuvalde kırpma ve ölçeklendirme, kaynak görüntünün yalnızca bir kısmının tuvale çizilmesiyle ilgili basit bir işlemdir.

// 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ğlamda kullanılabilir. Resmi kanvas üzerine çizmeden önce çeşitli dönüştürme işlemlerini 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 kanvasa uygulanabilir. Bu örnekte, bir dönme ve bir kaydırma işlemini 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],
);

Lens bozulması veya dalgalar gibi daha karmaşık efektler, kaynak piksel koordinatını hesaplamak için her hedef koordinata bir miktar ofset uygulamayı içerir. Örneğin, yatay dalga efekti elde etmek için kaynak piksel x koordinatını y koordinatına göre bir değere göre kaydırabilirsiniz.

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 bir video öğesi kullanırsanız makaledeki diğer tüm öğeler video için de kullanılabilir.

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

Ancak bu durumda yalnızca mevcut video karesi kullanılır. Bu nedenle, oynatılan bir videoya efekt uygulamak istiyorsanız yeni bir video karesi yakalamak ve her tarayıcı animasyon karesi üzerinde işlemek için her karede drawImage/texImage2D kullanmanız gerekir.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Videolarla çalışırken özellikle işleme hızınızın yüksek olması önemlidir. Hareketsiz bir görüntü olduğunda, kullanıcı bir düğmeyi tıklaması ile efektin uygulanması arasında 100 ms'lik bir gecikme fark etmeyebilir. Ancak animasyonluyken yalnızca 16 ms'lik gecikmeler bile belirgin bir sarsıntıya neden olabilir.

Geri bildirim