Echtzeiteffekte für Bilder und Videos

Mattenwaagen

In vielen der beliebtesten Apps von heute können Sie Filter und Effekte auf Bilder oder Videos anwenden. In diesem Artikel erfahren Sie, wie Sie diese Funktionen im offenen Web implementieren können.

Der Prozess für Videos und Bilder ist im Grunde gleich, aber am Ende gehe ich auf einige wichtige Videoaspekte ein. Im gesamten Artikel können Sie davon ausgehen, dass „Bild“ für ein Bild oder einen einzelnen Frame eines Videos steht.

Pixeldaten für ein Bild ermitteln

Es gibt drei grundlegende Kategorien der Bildbearbeitung, die häufig vorkommen:

  • Pixel-Effekte wie Kontrast, Helligkeit, Wärme, Sepiaton und Sättigung.
  • Multipixel-Effekte, die als Faltungsfilter bezeichnet werden, wie Schärfe, Kantenerkennung, Unschärfe.
  • Verzerrungen des gesamten Bilds, z. B. Zuschnitt, Verzerrung, Streckung, Linseneffekte oder Wellen.

Dazu werden die tatsächlichen Pixeldaten des Quellbilds abgerufen und dann ein neues Bild daraus erstellt. Die einzige Oberfläche dafür ist ein Canvas.

Die wirklich wichtige Entscheidung ist also, ob die Verarbeitung auf der CPU mit einem 2D-Canvas oder auf der GPU mit WebGL erfolgen soll.

Sehen wir uns kurz die Unterschiede zwischen den beiden Ansätzen an.

2D-Canvas

Dies ist definitiv die einfachste der beiden Optionen. Zuerst zeichnen Sie das Bild auf den Canvas.

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

Dann erhalten Sie ein Array von Pixelwerten für den gesamten Canvas.

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

An dieser Stelle ist die Variable pixels ein Uint8ClampedArray mit einer Länge von width * height * 4. Jedes Array-Element besteht aus einem Byte und alle vier Elemente im Array stellen die Farbe eines Pixels dar. Jedes der vier Elemente stellt die Menge an Rot, Grün, Blau und Alpha (Transparenz) in dieser Reihenfolge dar. Die Pixel werden von der oberen linken Ecke aus von links nach rechts und von oben nach unten angeordnet.

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

Um den Index für ein bestimmtes Pixel anhand seiner Koordinaten zu ermitteln, gibt es eine einfache Formel.

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

Sie können diese Daten jetzt nach Belieben lesen und schreiben und beliebige Effekte anwenden. Dieses Array ist jedoch eine Kopie der tatsächlichen Pixeldaten für den Canvas. Wenn Sie die bearbeitete Version zurückschreiben möchten, müssen Sie die Methode putImageData verwenden, um dies in die linke obere Ecke des Canvas zu schreiben.

context.putImageData(imageData, 0, 0);

WebGL

WebGL ist ein großes Thema, sicherlich zu groß, um es in einem einzigen Artikel gerecht zu werden. Wenn Sie mehr über WebGL erfahren möchten, lesen Sie die Empfehlungen am Ende dieses Artikels.

Hier ist jedoch eine sehr kurze Einführung in die Vorgehensweise, wenn Sie ein einzelnes Bild bearbeiten.

Einer der wichtigsten Aspekte von WebGL ist, dass es sich nicht um eine 3D-Grafik-API handelt. Tatsächlich können WebGL (und OpenGL) genau eines – das Zeichnen von Dreiecken. In Ihrer Anwendung müssen Sie beschreiben, was Sie tatsächlich in Form von Dreiecken zeichnen möchten. Bei einem 2D-Bild ist das sehr einfach, da ein Rechteck aus zwei ähnlichen rechtwinkligen Dreiecken besteht, die so angeordnet sind, dass sich ihre Hypotenuseen an derselben Stelle befinden.

Der grundlegende Prozess sieht so aus:

  • Daten an die GPU senden, die die Eckpunkte (Punkte) der Dreiecke beschreibt.
  • Senden Sie Ihr Quell-Image als Textur (Bild) an die GPU.
  • Erstellen Sie einen Vertex-Shader.
  • Erstellen Sie einen "Fragment-Shader".
  • Legen Sie einige Shader-Variablen fest, die als „Uniformen“ bezeichnet werden.
  • Führen Sie die Shader aus.

Gehen wir ins Detail. Beginnen Sie mit der Zuweisung von Speicher auf der Grafikkarte, einem sogenannten Vertex-Zwischenspeicher. Sie speichern darin Daten, die jeden Punkt jedes Dreiecks beschreiben. Sie können auch einige Variablen, sogenannte Uniformen, festlegen, bei denen es sich um globale Werte in beiden Shadern handelt.

Ein Vertex-Shader berechnet anhand von Daten aus dem Vertex-Zwischenspeicher, wo auf dem Bildschirm die drei Punkte jedes Dreiecks gezeichnet werden.

Jetzt weiß die GPU, welche Pixel im Canvas zu zeichnen sind. Der Fragment-Shader wird einmal pro Pixel aufgerufen und muss die Farbe zurückgeben, die auf dem Bildschirm gezeichnet werden soll. Der Fragment-Shader kann Informationen aus einer oder mehreren Texturen lesen, um die Farbe zu bestimmen.

Beim Lesen einer Textur in einem Fragment-Shader geben Sie mit zwei Gleitkommakoordinaten zwischen 0 (links oder unten) und 1 (rechts oder oben) an, welchen Teil des Bildes Sie lesen möchten.

Wenn Sie die Textur basierend auf Pixelkoordinaten lesen möchten, müssen Sie die Größe der Textur in Pixeln als einheitlichen Vektor übergeben, damit Sie die Konvertierung für jedes Pixel vornehmen können.

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

Die Schleife verschiebt jeweils 4 Byte, ändert aber nur drei Werte. Dies liegt daran, dass diese spezielle Transformation den Alphawert nicht ändert. Denken Sie auch daran, dass ein Uint8ClampedArray alle Werte auf Ganzzahlen rundet und Werte zwischen 0 und 255 begrenzt.

WebGL-Fragment-Shader:

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

In ähnlicher Weise wird für diese spezielle Transformation nur der RGB-Teil der Ausgabefarbe multipliziert.

Einige dieser Filter benötigen zusätzliche Informationen, z. B. die durchschnittliche Helligkeit des gesamten Bildes. Diese Werte können jedoch einmal für das gesamte Bild berechnet werden.

Eine Möglichkeit zum Ändern des Kontrasts besteht beispielsweise darin, jedes Pixel zu oder von einem „Grauwert“ zu bewegen, um einen niedrigeren bzw. höheren Kontrast zu erzielen. Als Grauwert wird in der Regel eine graue Farbe gewählt, deren Leuchtdichte dem Medianwert der Leuchtdichte aller Pixel im Bild entspricht.

Sie können diesen Wert beim Laden des Bildes einmal berechnen und dann jedes Mal verwenden, wenn Sie den Bildeffekt anpassen müssen.

Multi-Pixel

Bei einigen Effekten wird die Farbe benachbarter Pixel verwendet, um die Farbe des aktuellen Pixels zu bestimmen.

Dadurch ändert sich die Vorgehensweise im 2D-Canvas-Case geringfügig, da Sie die Originalfarben des Bildes lesen können möchten und im vorherigen Beispiel die Pixel an Ort und Stelle aktualisiert wurden.

Das ist aber ganz einfach. Wenn Sie das Bilddatenobjekt erstellen, können Sie die Daten kopieren.

const originalPixels = new Uint8Array(imageData.data);

Im WebGL-Fall müssen Sie keine Änderungen vornehmen, da der Shader nicht in die Eingabetextur schreibt.

Die häufigste Kategorie für Multipixel-Effekte ist ein Faltungsfilter. Ein Faltungsfilter verwendet mehrere Pixel aus dem Eingabebild, um die Farbe jedes Pixels im Eingabebild zu berechnen. Der Einfluss, den jedes Eingabepixel auf die Ausgabe hat, wird als Gewichtung bezeichnet.

Die Gewichtungen können durch eine Matrix, die als Kernel bezeichnet wird, dargestellt werden, wobei der zentrale Wert dem aktuellen Pixel entspricht. Dies ist zum Beispiel der Kernel für einen 3x3-Gaußschen Unschärfeeffekt.

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

Nehmen wir also an, Sie möchten die Ausgabefarbe des Pixels bei (23, 19) berechnen. Multiplizieren Sie für jeden der 8 Pixel (23, 19) sowie das Pixel selbst die Farbwerte mit der entsprechenden Gewichtung.

    (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

Addiere sie alle und teile das Ergebnis dann durch 8, was der Summe der Gewichtungen entspricht. Sie können sehen, dass das Ergebnis ein Pixel ist, das größtenteils dem Original entspricht, aber die Pixel in der Nähe einbluten.

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

Dies ist ein Grundgedanken. Es gibt aber auch Leitfäden, in denen Sie noch mehr ins Detail gehen und viele weitere nützliche Kernel auflisten können.

Gesamtes Bild

Einige Bildtransformationen sind einfach. Auf einem 2D-Canvas ist das Zuschneiden und Skalieren ein einfacher Fall, bei dem nur ein Teil des Quellbilds auf dem Canvas gezeichnet wird.

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

Drehung und Reflexion sind im 2D-Kontext direkt verfügbar. Bevor Sie das Bild auf den Canvas zeichnen, müssen Sie die Transformationen anpassen.

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

Viele 2D-Transformationen können jedoch als 2x3-Matrizen geschrieben und mit setTransform() auf den Canvas angewendet werden. In diesem Beispiel wird eine Matrix verwendet, die eine Rotation und eine Verschiebung kombiniert.

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

Zu komplizierteren Effekten wie Linsenverzerrung oder Wellenlinien gehört das Anwenden eines Versatzes auf jede Zielkoordinaten, um die Quellpixelkoordinaten zu berechnen. Für einen horizontalen Welleneffekt könnten Sie beispielsweise die x-Koordinate des Quellpixels um einen Wert auf der y-Koordinate verschieben.

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

Alles andere in diesem Artikel funktioniert bereits für Videos, wenn du ein video-Element als Quellbild verwendest.

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

Dabei wird jedoch nur der aktuelle Videoframe verwendet. Wenn Sie also einen Effekt auf ein wiedergegebenes Video anwenden möchten, müssen Sie drawImage/texImage2D auf jedem Frame verwenden, um einen neuen Videoframe abzurufen und für jeden Browseranimationsframe zu verarbeiten.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Bei der Arbeit mit Videos ist es besonders wichtig, dass die Verarbeitung schnell erfolgt. Bei einem Standbild bemerken Nutzer möglicherweise keine Verzögerung von 100 ms zwischen dem Klicken auf eine Schaltfläche und dem Anwenden eines Effekts. In animierten Fällen können Verzögerungen von nur 16 ms jedoch zu sichtbaren Rucklern führen.

Feedback