Resimler ve videolar iç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ğı açıklanmaktadır.

Süreç, videolar ve resimler için temelde aynıdır ancak videolarda dikkat edilmesi gereken bazı önemli noktaları en sonda ele alacağım. Makale boyunca "resim" ifadesinin "resim veya videonun tek bir karesi" anlamına geldiğini varsayabilirsiniz.

Bir resmin piksel verilerine erişme

Yaygın olarak kullanılan 3 temel resim manipülasyonu kategorisi vardır:

  • Kontrast, parlaklık, sıcaklık, sepya tonu, doygunluk gibi piksel efektleri.
  • Keskinleştirme, kenar algılama, bulanıklaştırma gibi evrişim filtreleri olarak adlandırılan çok pikselli efektler.
  • Kırpma, eğme, esnetme, lens efektleri, dalgalar gibi tüm görüntüyü etkileyen bozulmalar

Tüm bunlar, kaynak resmin gerçek piksel verilerine ulaşmayı ve ardından bu verilerden yeni bir resim oluşturmayı içerir. Bunu yapmanın tek arayüzü tuvaldir.

Bu durumda asıl önemli seçim, işlemenin 2D tuval ile CPU'da mı yoksa WebGL ile GPU'da mı yapılacağıdır.

İki yaklaşım arasındaki farklara kısaca göz atalım.

2D tuval

Bu, iki seçenek arasında açık ara en basit olanıdır. Önce resmi tuval ü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, tüm tuval için piksel değerleri 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 sırasıyla 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
...

Herhangi bir pikselin dizinini koordinatlarından 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 gelen tüm efektleri uygulayabilirsiniz. Ancak bu dizi, tuvalin gerçek piksel verilerinin bir kopyasıdır. Düzenlenen sürümü geri yazmak için putImageData yöntemini kullanarak bunu tuvalin sol üst köşesine geri yazmanız gerekir.

context.putImageData(imageData, 0, 0);

WebGL

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

Ancak tek bir görüntüyü değiştirme durumunda yapılması gerekenlere dair çok kısa bir giriş yapalım.

WebGL ile ilgili unutmamanız gereken en önemli noktalardan biri, bunun bir 3D grafik API'si olmadığıdır. Aslında WebGL (ve OpenGL) tam olarak tek bir şeyde iyidir: üçgen çizme. Uygulamanızda, aslında çizmek istediğiniz şeyi üçgenler cinsinden tanımlamanız gerekir. 2D görüntü söz konusu olduğunda bu çok basittir. Çünkü dikdörtgen, hipotenüsleri aynı yerde olacak şekilde düzenlenmiş iki benzer dik açılı üçgendir.

Temel süreç şu şekildedir:

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

Şimdi ayrıntılara geçelim. Ekran kartında köşe arabelleği adı verilen bir miktar bellek ayırarak başlayın. Her üçgenin her noktasını tanımlayan verileri burada depolarsınız. Ayrıca, her iki gölgelendiricide de genel değerler olan "uniform" adlı bazı değişkenler ayarlayabilirsiniz.

Tepe gölgelendirici, her üçgenin üç noktasının ekranda nerede çizileceğini hesaplamak için tepe arabelleğindeki verileri kullanır.

Artık GPU, tuvaldeki hangi piksellerin çizilmesi gerektiğini bilir. Parça gölgelendirici, 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 dokudan bilgi okuyabilir.

Bir parça gölgelendiricide doku okurken, 0 (sol veya alt) ile 1 (sağ veya üst) arasında iki kayan nokta koordinatı kullanarak görüntünün 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 amacıyla dokunun boyutunu piksel cinsinden tek tip 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 her seferinde 4 bayt ilerlediğini ancak yalnızca üç değeri değiştirdiğini unutmayın. Bunun nedeni, bu özel dönüşümün alfa değerini değiştirmemesidir. Ayrıca Uint8ClampedArray'in tüm değerleri tam sayılara yuvarlayacağını ve değerleri 0 ile 255 arasında olacak şekilde sınırlayacağını unutmayın.

WebGL parça gölgelendiricisi:

    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 filtrelerden bazıları, tüm görüntünün ortalama parlaklığı gibi ek bilgiler alır ancak bunlar tüm görüntü için bir kez hesaplanabilecek şeylerdir.

Örneğin, kontrastı değiştirmenin bir yolu, her pikseli bir "gri" değere yaklaştırmak veya bu değerden uzaklaştırmak olabilir. Bu şekilde sırasıyla daha düşük veya daha yüksek kontrast elde edilir. Gri değer, genellikle parlaklığı resimdeki tüm piksellerin ortalama parlaklığı olan gri renk olarak seçilir.

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

Çoklu piksel

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

Bu, 2D tuval örneğinde yaptığınız işlemleri biraz değiştirir. Çünkü resmin orijinal renklerini okuyabilmek istersiniz ve önceki örnekte pikseller yerinde güncelleniyordu.

Ancak bu oldukça kolaydır. Görüntü verileri nesnenizi ilk oluşturduğunuzda verilerin bir kopyasını oluşturabilirsiniz.

const originalPixels = new Uint8Array(imageData.data);

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

En yaygın çok pikselli efekt kategorisi, konvolüsyon filtresi olarak adlandırılır. Konvolüsyon filtresi, giriş görüntüsündeki her pikselin rengini hesaplamak için giriş görüntüsündeki birkaç pikseli kullanır. Her bir giriş pikselinin çıkış üzerindeki etki düzeyine ağırlık denir.

Ağırlıklar, çekirdek adı verilen bir matrisle temsil edilebilir. Bu matristeki merkezi değer, mevcut piksele karşılık gelir. Örneğin, bu 3x3 Gauss bulanıklığı için çekirdektir.

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

Örneğin, (23, 19) konumundaki pikselin çıkış rengini hesaplamak istediğinizi varsayalım. (23, 19) pikselini çevreleyen 8 pikseli 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

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 karıştığı bir piksel elde edilir.

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 fikri verir ancak çok daha ayrıntılı bilgi veren ve diğer birçok yararlı çekirdeği listeleyen kılavuzlar vardır.

Resmin tamamı

Bazı tam resim dönüştürme işlemleri basittir. 2D tuvalde kırpma ve ölçekleme, kaynak resmin yalnızca bir kısmını tuvale çizme 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ıtma, doğrudan 2D bağlamda 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 matris olarak yazılabilir ve setTransform() ile tuvale uygulanabilir. Bu örnekte, 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],
);

Lens bozulması veya dalgalanma gibi daha karmaşık efektler, kaynak piksel koordinatını hesaplamak için her hedef koordinata 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 göre belirli bir değer kadar 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 her şey video için zaten çalışır.

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 işlemde yalnızca mevcut video karesi kullanılır. Bu nedenle, oynatılan bir videoya efekt uygulamak istiyorsanız her karede drawImage/texImage2D kullanarak yeni bir video karesi almanız ve her tarayıcı animasyon karesinde bu kareyi işlemeniz gerekir.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Videolarla çalışırken işleme hızınız özellikle önemlidir. Hareketsiz bir resimde kullanıcı, bir düğmeyi tıklama ile efektin uygulanması arasındaki 100 ms'lik gecikmeyi fark etmeyebilir. Ancak animasyonlu olduğunda, yalnızca 16 ms'lik gecikmeler görünür sarsıntılara neden olabilir.

Geri bildirim