Efekty w czasie rzeczywistym w obrazach i filmach

Wagi mat

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

Zasadniczo wygląda to tak samo w przypadku filmów i obrazów, ale na końcu omówię kilka ważnych kwestii związanych z filmami. W artykule możesz zakładać, że „obraz” to „obraz lub pojedynczy kadr filmu”,

Uzyskiwanie informacji o pikselach obrazu

Istnieją 3 podstawowe kategorie manipulacji obrazami:

  • Efekty Pixela, takie jak kontrast, jasność, ciepło, odcień sepii i nasycenie.
  • Efekty wielopikselowe, zwane filtrami splotów, takie jak wyostrzanie, wykrywanie krawędzi czy rozmycie.
  • Całe zniekształcenia obrazu takie jak przycinanie, przekrzywienie, rozciąganie, efekty obiektywu, zmarszczki.

Wszystko to wymaga uzyskania rzeczywistych danych pikselowych obrazu źródłowego i utworzenia z niego nowego obrazu. Jedynym interfejsem jest płótno.

Najważniejszym wyborem jest właśnie to, czy przetwarzanie chcesz przeprowadzić na procesorze, na kanwie 2D, czy na GPU, korzystając z WebGL.

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

Płótno 2D

Zdecydowanie jest to najprostsza opcja. Najpierw narysuj 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 otrzymasz tablicę wartości pikseli dla całego obszaru roboczego.

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

Obecnie zmienna pixels ma postać typu Uint8ClampedArray o długości width * height * 4. Każdy element tablicy ma 1 bajt i każde 4 elementy tablicy reprezentują kolor 1 piksela. Każdy z 4 elementów reprezentuje ilość czerwonego, zielonego, niebieskiego i alfa (przejrzystości) w tej kolejności. Piksele są uporządkowane od lewego górnego rogu do lewej do prawej oraz 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, mamy do czynienia z prostą formułą.

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

Teraz możesz odczytywać i zapisywać te dane w dowolny sposób, co pozwala zastosować dowolne efekty. Jest ona jednak kopią rzeczywistych danych piksela dla obszaru roboczego. Aby zapisać poprawioną wersję, użyj metody putImageData, aby zapisać ją w lewym górnym rogu obszaru roboczego.

context.putImageData(imageData, 0, 0);

WebGL

WebGL to duży temat, ale na pewno za duży, aby opisywać je w jednym artykule. Aby dowiedzieć się więcej o WebGL, przeczytaj zalecaną lekturę na końcu tego artykułu.

ale to tylko krótkie wprowadzenie do tego, co trzeba zrobić w przypadku manipulowania pojedynczym obrazem.

Jedną z najważniejszych rzeczy, o których należy pamiętać o WebGL, jest to, że nie jest to interfejs API grafiki 3D. WebGL (i OpenGL) dobrze sprawdza się w jednym aspekcie – rysowaniu trójkątów. W aplikacji musisz opisać, co faktycznie chcesz narysować pod kątem trójkątów. W przypadku obrazu 2D jest to bardzo proste, ponieważ prostokąt to dwa podobne trójkąty prostokątne rozmieszczone tak, aby ich przeciwprostokątne znajdowały się w tym samym miejscu.

Podstawowy proces to:

  • Wyślij do GPU dane opisujące wierzchołki (punkty) trójkątów.
  • Wyślij obraz źródłowy do GPU jako teksturę (obraz).
  • Utwórz tzw. cienia wierzchołków.
  • Utworzenie „cienia fragmentów”.
  • Ustaw zmienne cieniowania, nazywane „uniformami”.
  • Uruchom cieniowanie.

Przejdźmy do szczegółów. Zacznij od przypisania części pamięci w karcie graficznej zwanej buforem wierzchołków. Przechowujesz w niej dane opisujące każdy punkt każdego trójkąta. Możesz też ustawić pewne zmienne (tzw. uniformy) o wartościach globalnych za pomocą obu programów do cieniowania.

Szkic wierzchołków korzysta z danych z bufora wierzchołków, aby obliczyć miejsce na ekranie do narysowania 3 punktów każdego trójkąta.

Teraz GPU wie, które piksele w obszarze roboczym należy narysować. Moduł do cieniowania fragmentów jest wywoływany raz na piksel i musi zwracać kolor, który ma być narysowany na ekranie. Aby określić kolor, może odczytywać informacje z jednej lub kilku tekstur.

Odczytując teksturę w cieniowaniu fragmentów, za pomocą 2 współrzędnych zmiennoprzecinkowych z zakresu od 0 (lewo lub u dołu) do 1 (prawa lub góra) określasz, którą część obrazu chcesz odczytać.

Jeśli chcesz odczytywać teksturę na podstawie współrzędnych pikseli, musisz przekazać jej rozmiar w pikselach jako jednolity wektor, aby móc dokonać konwersji 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 przenosi 4 bajty naraz, ale zmienia tylko 3 wartości, ponieważ to konkretne przekształcenie nie zmienia wartości alfa. Pamiętaj też, że funkcja Uint8ClampedArray zaokrągli wszystkie wartości do liczb całkowitych i ograniczy wartości z zakresu od 0 do 255.

cieniowanie fragmentów WebGL:

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

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

Niektóre z tych filtrów wymagają dodatkowych informacji, takich jak średnia luminacja całego obrazu. Takie wartości można jednak obliczyć tylko raz dla całego zdjęcia.

Jednym ze sposobów zmiany kontrastu może być na przykład przesunięcie każdego piksela w kierunku wartości „szary” lub odsunięcie go od niego (odpowiednio niższy lub wyższy kontrast). Jako wartość szarości wybiera się zwykle kolor szary, którego luminancja to mediana luminancji wszystkich pikseli na obrazie.

Możesz obliczyć tę wartość raz podczas wczytywania obrazu i używać jej za każdym razem, gdy chcesz poprawić efekt graficzny.

Wiele pikseli

Niektóre efekty przy określaniu koloru bieżącego piksela używają koloru sąsiadujących pikseli.

To trochę zmienia sposób działania w przypadku kanwy 2D, ponieważ chcemy mieć możliwość odczytywania oryginalnych kolorów obrazu, a w poprzednim przykładzie było to zaktualizowanie pikseli na miejscu.

To jednak dość proste. Podczas początkowego tworzenia obiektu danych obrazu możesz utworzyć ich kopię.

const originalPixels = new Uint8Array(imageData.data);

W przypadku WebGL nie trzeba wprowadzać żadnych zmian, ponieważ cieniowanie nie zapisuje tekstury wejściowej.

Najpowszechniejszą kategorią efektu wielopikselowego jest filtr splotu. Filtr Splot korzysta z kilku pikseli z obrazu wejściowego, aby obliczyć kolor każdego piksela na obrazie. Poziom wpływu każdego piksela wejściowego na dane wyjściowe nazywa się wagą.

Wagi mogą być reprezentowane przez macierz, tzw. jądro, gdzie wartość centralna odpowiada bieżącemu pikselowi. Jest to na przykład jądro do rozmycia Gaussa 3 x 3.

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

Załóżmy, że chcesz obliczyć kolor wyjściowego piksela w punkcie (23, 19). Oblicz otaczający Cię 8 pikseli (23, 19) i sam piksel, a potem pomnóż wartości kolorów dla 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 przez 8, czyli sumę wag. Jak będzie w rezultacie piksel, w którym obraz będzie w większości oryginał, ale w pobliżu rozjaśnią się sąsiednie piksele.

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 daje podstawowy obraz. Dostępne są jednak przewodniki, które zawierają znacznie bardziej szczegółowe informacje i zawierają wiele innych przydatnych jąder.

Cały obraz

Niektóre przekształcenia obrazów są proste. Na kanwie 2D przycinanie i skalowanie to prosty przypadek, w którym rysujemy tylko część obrazu źródłowego.

// 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 w kanwie, 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 przekształceń 2D można zapisać jako macierze 2 x 3 i zastosować do płótna za pomocą funkcji setTransform(). W tym przykładzie użyto macierzy łączącej 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 złożone efekty, np. zniekształcenia lub zmarszczenia obiektywu, obejmują przesunięcie względem każdej współrzędnej miejsca docelowego w celu obliczenia współrzędnych w pikselu źródłowym. Aby np. uzyskać efekt fali poziomej, możesz przesunąć współrzędną x w pikselu źródłowym o pewną wartość na podstawie współrzędnych 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

Pozostałe wartości w artykule działają w przypadku filmów, jeśli używasz elementu video jako obrazu źródłowego.

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 wideo. Jeśli więc chcesz zastosować efekt do odtwarzanego filmu, musisz używać drawImage/texImage2D na każdej klatce, by pobrać nową klatkę wideo i przetworzyć ją w każdej ramce animacji w przeglądarce.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

W przypadku materiałów wideo szczególnie ważne staje się szybkie przetwarzanie. W przypadku nieruchomego obrazu użytkownik może nie zauważyć opóźnienia między kliknięciem przycisku a zastosowaniem efektu o 100 ms. Jednak w przypadku animacji opóźnienie wynoszące tylko 16 ms może powodować widoczne drgania.

Prześlij opinię