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 ten wygląda tak samo w przypadku filmów i obrazów, ale na końcu przypomnę sobie kilka ważnych kwestii. W całym artykule możesz założyć, że „obraz” oznacza „obraz lub pojedynczy kadr filmu”.

Jak znaleźć 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łcenia całego obrazu, takie jak przycinanie, wykrzywienie, rozciąganie, efekty soczewkowe czy zmarszczki.

Wszystkie te czynności obejmują uzyskanie rzeczywistych danych pikselowych obrazu źródłowego, a następnie utworzenie na jego podstawie nowego obrazu. Jedynym interfejsem do tego celu jest obiekt canvas.

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 trzeba narysować obraz na kanwie.

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 zmienną Uint8ClampedArray o długości width * height * 4. Każdy element tablicy ma rozmiar 1 bajta, a każde 4 elementy tablicy reprezentuje kolor 1 piksela. Każdy z tych elementów reprezentuje ilość czerwonego, zielonego, niebieskiego i alfa (przezroczystości) w tej 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, dzięki czemu możesz 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.

Jedną z najważniejszych kwestii w technologii WebGL jest to, że nie jest to interfejs API grafiki 3D. W zasadzie WebGL (i OpenGL) jest dobry w jednym zadaniu – rysowaniu trójkątów. W aplikacji musisz opisać, co faktycznie chcesz narysować w postaci 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ątne były w tym samym miejscu.

Podstawowy proces przebiega w ten sposób:

  • Wyślij do GPU dane opisujące wierzchołki 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 cieniowanie.

Przejdźmy do szczegółów. Najpierw przydziel część pamięci na karcie graficznej nazywany buforem wierzchołków. Przechowujemy w niej dane opisujące każdy punkt każdego trójkąta. Oba zmienne możesz też skonfigurować jako wartości globalne.

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

Teraz GPU wie, które piksele na płótnie należy narysować. Moduł cieniowania fragmentów jest wywoływany raz na piksel i musi zwrócić kolor, który powinien zostać wyświetlony na ekranie. Fragment shadera może odczytać informacje z co najmniej 1 tekstury, aby określić kolor.

Odczytując teksturę w cieniowaniu fragmentów, określasz, która część obrazu ma być odczytywana, za pomocą dwóch współrzędnych zmiennoprzecinkowych z zakresu od 0 (lewa lub u dołu) do 1 (prawa lub góra).

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 dyrektywa Uint8ClampedSlate zaokrągla wszystkie wartości do liczb całkowitych, a wartości ograniczenia zakresu z zakresu od 0 do 255.

Program fragmentu WebGL:

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

Podobnie dla tego konkretnego przekształcenia mnożona jest 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 może być na przykład przesunięcie każdego piksela do określonej wartości szarej lub od niej oddaloną, co odpowiednio spowoduje zmniejszenie lub zwiększenie 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 powinno być 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, bo program do cieniowania nie zapisuje tekstur wejściowych.

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

Sumuj je wszystkie, a następnie 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 kanwie 2D przycinanie i skalowanie to prosty przypadek: wystarczy narysować na nim 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 na 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ć w postaci macierzy 2 x 3 i zastosować je do obszaru roboczego 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ć 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 więc chcesz zastosować efekt do odtwarzanego filmu, musisz użyć funkcji drawImage/texImage2D w każdej klatce, aby zarejestrować nową klatkę filmu i przetworzyć ją przy każdej klatce animacji w przeglądarce.

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 nieruchomego obrazu użytkownik może nie zauważyć 100 ms opóźnienia między kliknięciem przycisku a zastosowaniem efektu. Jeśli jednak film jest animowany z opóźnieniem wynoszącym 16 ms, może to spowodować widoczny skok obrazu.

Prześlij opinię