Efekty w czasie rzeczywistym w przypadku obrazów i filmów

Mat Scales

Wiele z najpopularniejszych obecnie aplikacji umożliwia stosowanie filtrów i efektów do zdjęć lub filmów. Z tego artykułu dowiesz się, jak wdrożyć te funkcje w otwartej sieci.

Proces jest zasadniczo taki sam w przypadku filmów i obrazów, ale na koniec omówię kilka ważnych kwestii dotyczących filmów. W tym artykule możesz założyć, że „obraz” oznacza „obraz lub pojedynczą klatkę filmu”.

Jak uzyskać dostęp do danych pikseli obrazu

Istnieją 3 podstawowe kategorie manipulacji obrazem:

  • efekty Pixela, takie jak kontrast, jasność, ciepło, sepia i nasycenie;
  • efekty wielopikselowe, nazywane filtrami splotowymi, takie jak wyostrzanie, wykrywanie krawędzi i rozmycie;
  • Zniekształcenie całego obrazu, np. przycinanie, pochylanie, rozciąganie, efekty obiektywu, fale.

Wszystkie te operacje wymagają dostępu do rzeczywistych danych pikseli obrazu źródłowego, a następnie utworzenia na ich podstawie nowego obrazu. Jedynym interfejsem, który to umożliwia, jest element canvas.

Najważniejszy wybór dotyczy tego, czy przetwarzanie ma się odbywać na procesorze z użyciem elementu canvas 2D, czy na GPU z użyciem WebGL.

Przyjrzyjmy się pokrótce różnicom między tymi dwoma podejściami.

Obszar roboczy 2D

To zdecydowanie prostsza z tych 2 opcji. Najpierw rysujesz obraz na obszarze roboczym.

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

Następnie otrzymasz tablicę wartości pikseli dla całego obszaru roboczego.

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

W tym momencie zmienna pixels jest obiektem Uint8ClampedArray o długości width * height * 4. Każdy element tablicy ma 1 bajt, a każde 4 elementy tablicy reprezentują kolor jednego piksela. Każdy z 4 elementów reprezentuje ilość czerwieni, zieleni, niebieskiego i alfa (przezroczystości) w tej kolejności. Piksele są uporządkowane od lewego górnego rogu, od lewej do prawej i od góry do dołu.

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

Aby znaleźć indeks danego piksela na podstawie jego współrzędnych, można użyć prostego wzoru.

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

Możesz teraz odczytywać i zapisywać te dane w dowolny sposób, co pozwala na stosowanie dowolnych efektów. Ta tablica jest jednak kopią rzeczywistych danych pikseli obszaru roboczego. Aby zapisać edytowaną wersję, musisz użyć metody putImageData, aby zapisać ją w lewym górnym rogu obszaru roboczego.

context.putImageData(imageData, 0, 0);

WebGL

WebGL to obszerny temat, którego nie da się wyczerpać w jednym artykule. Jeśli chcesz dowiedzieć się więcej o WebGL, zapoznaj się z polecanymi materiałami na końcu tego artykułu.

Poniżej znajdziesz jednak krótkie wprowadzenie do tego, co należy zrobić w przypadku manipulowania pojedynczym obrazem.

Jedną z najważniejszych rzeczy, o których należy pamiętać w przypadku WebGL, jest to, że nie jest to interfejs API grafiki 3D. W rzeczywistości WebGL (i OpenGL) jest dobry tylko w jednej rzeczy – rysowaniu trójkątów. W aplikacji musisz opisać, co naprawdę chcesz narysować, używając trójkątów. W przypadku obrazu 2D jest to bardzo proste, ponieważ prostokąt to 2 podobne trójkąty prostokątne ułożone tak, że ich przeciwprostokątne znajdują się w tym samym miejscu.

Podstawowy proces wygląda tak:

  • Wysyłanie do GPU danych opisujących wierzchołki (punkty) trójkątów.
  • Wyślij obraz źródłowy do GPU jako teksturę (obraz).
  • Utwórz „vertex shader”.
  • Utwórz „fragment shader”.
  • Ustaw zmienne cieniowania, nazywane „uniformami”.
  • Uruchom shadery.

Omówmy to szczegółowo. Zacznij od przydzielenia pamięci na karcie graficznej, która nazywa się buforem wierzchołków. Przechowujesz w nim dane opisujące każdy punkt każdego trójkąta. Możesz też ustawić pewne zmienne, nazywane uniformami, które są wartościami globalnymi w obu programach cieniujących.

Program do cieniowania wierzchołków wykorzystuje dane z bufora wierzchołków, aby obliczyć, gdzie na ekranie narysować 3 punkty każdego trójkąta.

Teraz GPU wie, które piksele w obszarze roboczym należy narysować. Fragment shader jest wywoływany raz na piksel i musi zwrócić kolor, który powinien zostać narysowany na ekranie. Program do cieniowania fragmentów może odczytywać informacje z co najmniej 1 tekstury, aby określić kolor.

Podczas odczytywania tekstury w fragment shaderze określasz, którą część obrazu chcesz odczytać, używając 2 współrzędnych zmiennoprzecinkowych z zakresu od 0 (lewa lub dolna) do 1 (prawa lub górna).

Jeśli chcesz odczytać teksturę na podstawie współrzędnych pikseli, musisz przekazać rozmiar tekstury w pikselach jako wektor jednolity, aby można było przeprowadzić konwersję dla każdego piksela.

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

Zwróć uwagę, że pętla przesuwa się o 4 bajty naraz, ale zmienia tylko 3 wartości, ponieważ ta konkretna transformacja nie zmienia wartości alfa. Pamiętaj też, że Uint8ClampedArray zaokrągla wszystkie wartości do liczb całkowitych i ogranicza je do zakresu od 0 do 255.

Program do cieniowania fragmentów WebGL:

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

Podobnie w przypadku tej konkretnej transformacji mnożona jest tylko część RGB koloru wyjściowego.

Niektóre z tych filtrów wymagają dodatkowych informacji, takich jak średnia luminancja całego obrazu, ale są to rzeczy, które można obliczyć raz dla całego obrazu.

Jednym ze sposobów zmiany kontrastu może być na przykład przesunięcie każdego piksela w kierunku wartości „szarości” lub od niej, aby uzyskać odpowiednio niższy lub wyższy kontrast. Wartość szarości jest zwykle wybierana jako kolor szary, którego luminancja jest medianą luminancji wszystkich pikseli na obrazie.

Możesz obliczyć tę wartość raz, gdy obraz jest wczytywany, a następnie używać jej za każdym razem, gdy chcesz dostosować efekt obrazu.

Wiele pikseli

Niektóre efekty wykorzystują kolor sąsiednich pikseli, aby określić kolor bieżącego piksela.

To nieco zmienia sposób działania w przypadku elementu canvas 2D, ponieważ chcesz mieć możliwość odczytania oryginalnych kolorów obrazu, a poprzedni przykład aktualizował piksele na miejscu.

To proste. Podczas tworzenia obiektu danych obrazu możesz utworzyć kopię danych.

const originalPixels = new Uint8Array(imageData.data);

W przypadku WebGL nie musisz wprowadzać żadnych zmian, ponieważ program do cieniowania nie zapisuje danych w teksturze wejściowej.

Najczęstszą kategorią efektu wielopikselowego jest filtr splotowy. Filtr splotowy wykorzystuje kilka pikseli z obrazu wejściowego, aby obliczyć kolor każdego piksela na obrazie wejściowym. Poziom wpływu, jaki każdy piksel wejściowy ma na wynik, nazywa się wagą.

Wagi można przedstawić za pomocą macierzy, zwanej jądrem, której centralna wartość odpowiada bieżącemu pikselowi. Na przykład to jest jądro rozmycia gaussowskiego 3 x 3.

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

Załóżmy, że chcesz obliczyć kolor wyjściowy piksela o współrzędnych (23, 19). Weź 8 pikseli otaczających piksel (23, 19) oraz sam ten piksel i pomnóż wartości kolorów każdego z nich przez odpowiednią wagę.

    (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

Zsumuj je wszystkie, a następnie podziel wynik przez 8, czyli sumę wag. W efekcie otrzymasz piksel, który jest w większości oryginalny, ale z wpływem pikseli znajdujących się w pobliżu.

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

To tylko podstawowe informacje, ale dostępne są poradniki, które zawierają znacznie więcej szczegółów i wymieniają wiele innych przydatnych jąderek.

Cały obraz

Niektóre przekształcenia całego obrazu są proste. W przypadku obszaru roboczego 2D przycinanie i skalowanie polega na narysowaniu tylko części obrazu źródłowego na obszarze roboczym.

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

Obrót i odbicie są dostępne bezpośrednio w kontekście 2D. Zanim narysujesz obraz na obszarze roboczym, zmień różne przekształcenia.

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

Co ważniejsze, wiele przekształceń 2D można zapisać jako macierze 2 x 3 i zastosować do obszaru roboczego za pomocą setTransform(). W tym przykładzie użyto macierzy, która łączy obrót i przesunięcie.

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

Bardziej skomplikowane efekty, takie jak zniekształcenie obiektywu lub fale, wymagają zastosowania przesunięcia do każdej współrzędnej docelowej, aby obliczyć współrzędną piksela źródłowego. Na przykład, aby uzyskać efekt fali poziomej, możesz przesunąć współrzędną X piksela źródłowego o pewną wartość na podstawie współrzędnej 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];
  }
}

Wideo

Wszystkie pozostałe informacje zawarte w tym artykule dotyczą już filmów, jeśli jako obrazu źródłowego używasz elementu video.

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

Spowoduje to jednak użycie tylko bieżącej klatki filmu. Jeśli więc chcesz zastosować efekt do odtwarzanego filmu, musisz użyć drawImage/texImage2D w każdej klatce, aby pobrać nową klatkę wideo i przetworzyć ją w każdej klatce animacji przeglądarki.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Podczas pracy z filmami szybkie przetwarzanie jest szczególnie ważne. W przypadku obrazu nieruchomego użytkownik może nie zauważyć opóźnienia wynoszącego 100 ms między kliknięciem przycisku a zastosowaniem efektu. Jednak w przypadku animacji opóźnienia wynoszące zaledwie 16 ms mogą powodować widoczne szarpnięcia.

Prześlij opinię