Efekty w czasie rzeczywistym w obrazach i filmach

Mat Scales

Wiele z najpopularniejszych 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 otwartym Internecie.

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

Jak uzyskać dane pikseli obrazu

Istnieją 3 podstawowe kategorie manipulacji obrazem:

  • Efekty pikseli, takie jak kontrast, jasność, ciepło, sepia, nasycenie.
  • Efekty wielopikselowe, zwane filtrami konwolucyjnymi, takie jak wyostrzanie, wykrywanie krawędzi i rozmycie.
  • Zniekształcenie całego obrazu, np. przycinanie, zniekształcenie, rozciąganie, efekty soczewki, fale.

Wszystkie te metody wymagają uzyskania rzeczywistych danych pikseli obrazu źródłowego, a następnie utworzenia na ich podstawie nowego obrazu. Jedynym interfejsem do tego jest kanwa.

Dlatego tak ważne jest, czy przetwarzanie ma odbywać się na procesorze z użyciem 2D canvas czy na GPU z użyciem WebGL.

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

Obszar roboczy 2D

To zdecydowanie najprostsza z tych 2 opcji. Najpierw rysujesz obraz na płótnie.

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 otrzymujesz 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 zmienną Uint8ClampedArray o długości width * height * 4. Każdy element tablicy to 1 bajt, a każde 4 elementy w tablicy odpowiadają kolorowi jednego piksela. Każdy z tych 4 elementów reprezentuje ilość czerwonego, zielonego, niebieskiego i alfa (przezroczystość) w takiej właśnie kolejności. Piksele są uporządkowane od lewego górnego rogu w kierunku 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 dowolnego piksela na podstawie jego współrzędnych, wystarczy użyć prostej formuły.

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 pozwoli Ci stosować dowolne efekty. Jednak ta tablica jest kopią rzeczywistych danych pikseli na płótnie. Aby przywrócić edytowaną wersję, musisz użyć metody putImageData, aby zapisać ją w lewym górnym rogu rysunku.

context.putImageData(imageData, 0, 0);

WebGL

WebGL to obszerny temat, zbyt obszerny, aby można go było przedstawić w jednym artykule. Jeśli chcesz dowiedzieć się więcej o WebGL, przeczytaj zalecane materiały na końcu tego artykułu.

Oto krótkie wprowadzenie do tego, co należy zrobić w przypadku manipulowania pojedynczym obrazem.

Pamiętaj, że WebGL nie jest interfejsem API grafiki 3D. W zasadzie WebGL (i OpenGL) jest dobry w jednym zadaniu – rysowaniu trójkątów. W aplikacji musisz opisać, co w rzeczywistości chcesz narysować za pomocą 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, aby ich przeciwprostokąty były w tym samym miejscu.

Podstawowy proces:

  • Wysyłanie danych do procesora graficznego, które opisują wierzchołki (punkty) trójkątów.
  • Wyślij obraz źródłowy do procesora graficznego jako teksturę (obraz).
  • Utwórz „shader” wierzchołkowy.
  • Utwórz „fragment shader”.
  • Ustaw kilka zmiennych shadera, zwanych „uniformami”.
  • Uruchom shadery.

Przyjrzyjmy się temu bliżej. Najpierw przydzielamy na karcie graficznej pamięć zwaną buforem wierzchołków. Przechowuje on dane opisujące każdy punkt każdego trójkąta. Możesz też ustawić niektóre zmienne, zwane jednorodnościami, które są wartościami globalnymi w obu shaderach.

Shader wierzchołka używa danych z bufora wierzchołków, aby obliczyć, gdzie na ekranie mają być narysowane 3 wierzchołki każdego trójkąta.

Teraz GPU wie, które piksele na płótnie należy narysować. Fragment shadera jest wywoływany raz na piksel i musi zwracać kolor, który ma być wyświetlany na ekranie. Fragment shadera może odczytać informacje z co najmniej 1 tekstury, aby określić kolor.

Podczas odczytu tekstury w fragment shadera określasz, który fragment obrazu chcesz odczytać, używając 2 współrzędnych zmiennoprzecinkowych w zakresie od 0 (po lewej lub u dołu) do 1 (po prawej lub u góry).

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

Pamiętaj, że pętla przesuwa 4 bajty naraz, ale zmienia tylko 3 wartości – dzieje się tak, ponieważ ta konkretna transformacja nie zmienia wartości alfa. Pamiętaj też, że Uint8ClampedArray zaokrągli wszystkie wartości do liczb całkowitych i ograniczy je do zakresu od 0 do 255.

Program fragmentu WebGL:

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

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

Niektóre z tych filtrów wykorzystują dodatkowe informacje, takie jak średnia luminancja całego obrazu, ale są to wartości, które można obliczyć raz dla całego obrazu.

Jednym ze sposobów zmiany kontrastu jest np. przesunięcie każdego piksela w kierunku lub w przeciwnym kierunku od pewnej wartości „szarości”, co powoduje odpowiednio obniżenie lub podwyższenie kontrastu. Wartość szarości jest zwykle wybierana jako szary kolor, którego luminancja jest średnią luminancją wszystkich pikseli na obrazie.

Wartość tę można obliczyć raz, gdy obraz zostanie załadowany, a następnie używać jej za każdym razem, gdy trzeba dostosować efekt obrazu.

Wielokrotne

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

W przypadku płótna 2D sposób działania jest nieco inny, ponieważ chcesz zachować oryginalne kolory obrazu. W poprzednim przykładzie piksele były aktualizowane na miejscu.

To nie jest trudne. Podczas tworzenia obiektu danych obrazu możesz utworzyć jego kopię.

const originalPixels = new Uint8Array(imageData.data);

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

Najczęstszą kategorią efektów wielopikselowych jest filtr konwolucyjny. Filtr konwolucyjny używa kilku pikseli z obrazu wejściowego do obliczenia koloru każdego piksela na tym obrazie. Poziom wpływu każdego piksela wejściowego na dane wyjściowe nazywa się wagą.

Wagi można przedstawić za pomocą macierzy zwanej rdzeniem, której wartość centralna odpowiada bieżącemu pikselowi. Oto na przykład kernel rozmycia gaussowskiego 3 x 3.

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

Załóżmy, że chcesz obliczyć wyjściowy kolor piksela o współrzędnych (23, 19). Weź 8 pikseli otaczających (23, 19) oraz sam 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, a potem podziel wynik przez 8, czyli sumę wag. Możesz zobaczyć, jak wynik będzie wyglądał w przypadku piksela, który jest w większości taki sam jak oryginał, ale z dodatkiem sąsiadujących pikseli.

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 podstawy, ale istnieją przewodniki, które zawierają znacznie więcej szczegółów i wymieniają inne przydatne jądra.

Cały obraz

Niektóre przekształcenia całego obrazu są proste. W przypadku płótna 2D przycinanie i skalowanie to tylko rysowanie części obrazu źródłowego na płótnie.

// 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 płótnie, 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 więcej, wiele transformacji 2D można zapisać jako macierze 2 x 3 i zastosować do kanwy za pomocą funkcji setTransform(). W tym przykładzie użyto macierzy, która łączy obrót i translację.

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 czy fale, wymagają zastosowania przesunięcia do każdej współrzędnej docelowej w celu obliczenia współrzędnych źródłowych pikseli. Na przykład, aby uzyskać efekt poziomej fali, możesz przesunąć źródłową współrzędną x piksela 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

Jeśli jako obraz źródłowy użyjesz elementu video, wszystkie pozostałe informacje w tym artykule będą działać również w przypadku filmów.

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

W tym przypadku zostanie użyta tylko bieżąca klatka filmu. Jeśli chcesz zastosować efekt do odtwarzanego filmu, musisz użyć funkcji drawImage/texImage2D w przypadku każdego kadru, aby pobrać nowy kadr filmu i przetworzyć go w każdym kadrze animacji przeglądarki.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Podczas pracy z filmami szybkie przetwarzanie staje się szczególnie ważne. W przypadku statycznych obrazów użytkownik może nie zauważyć opóźnienia 100 ms między kliknięciem przycisku a zastosowaniem efektu. Jednak w przypadku animacji opóźnienia rzędu zaledwie 16 ms mogą powodować widoczne zacięcia.

Prześlij opinię