In vielen der beliebtesten Apps können Sie Filter und Effekte auf Bilder oder Videos anwenden. In diesem Artikel wird beschrieben, wie Sie diese Funktionen im offenen Web implementieren.
Der Prozess ist für Videos und Bilder im Wesentlichen derselbe, aber am Ende gehe ich auf einige wichtige Überlegungen zu Videos ein. In diesem Artikel können Sie davon ausgehen, dass „Bild“ „Bild oder ein einzelner Frame eines Videos“ bedeutet.
So rufen Sie die Pixeldaten für ein Bild ab
Es gibt drei grundlegende Kategorien der Bildmanipulation, die häufig vorkommen:
- Pixel-Effekte wie Kontrast, Helligkeit, Wärme, Sepia-Ton, Sättigung.
- Multi-Pixel-Effekte, sogenannte Faltungsfilter, wie Scharfzeichnen, Kantenerkennung, Weichzeichnen.
- Verzerrung des gesamten Bildes, z. B. Zuschneiden, Neigen, Strecken, Objektiveffekte, Wellen.
Bei allen diesen Vorgängen werden die tatsächlichen Pixeldaten des Quellbilds abgerufen und daraus ein neues Bild erstellt. Die einzige Schnittstelle 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 die Unterschiede zwischen den beiden Ansätzen genauer an.
2D-Canvas
Dies ist mit Abstand die einfachere der beiden Optionen. Zuerst zeichnen Sie das Bild auf dem 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 Arrayelement ist ein Byte groß und alle vier Elemente im Array stellen die Farbe eines Pixels dar. Jedes der vier Elemente steht für den Anteil von Rot, Grün, Blau und Alpha (Transparenz) in dieser Reihenfolge. Die Pixel werden von der oberen linken Ecke aus von links nach rechts und von oben nach unten sortiert.
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 ...
Es gibt eine einfache Formel, um den Index für ein bestimmtes Pixel anhand seiner Koordinaten zu ermitteln.
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 beliebig lesen und schreiben und so alle erdenklichen Effekte anwenden. Dieses Array ist jedoch eine Kopie der tatsächlichen Pixeldaten für das Canvas. Wenn Sie die bearbeitete Version zurückschreiben möchten, müssen Sie die Methode putImageData verwenden, um sie in die obere linke Ecke des Canvas zu schreiben.
context.putImageData(imageData, 0, 0);
WebGL
WebGL ist ein großes Thema, das sich in einem einzigen Artikel nicht umfassend behandeln lässt. Wenn Sie mehr über WebGL erfahren möchten, finden Sie am Ende dieses Artikels empfohlene Lesematerialien.
Hier ist jedoch eine sehr kurze Einführung in die Schritte, die bei der Bearbeitung eines einzelnen Bildes erforderlich sind.
Einer der wichtigsten Punkte, die Sie bei WebGL beachten sollten, ist, dass es sich nicht um eine 3D-Grafik-API handelt. Tatsächlich sind WebGL (und OpenGL) nur in einer Sache gut: Dreiecke zeichnen. 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 Hypotenusen an derselben Stelle befinden.
Der grundlegende Prozess sieht so aus:
- Daten an die GPU senden, die die Eckpunkte (Punkte) der Dreiecke beschreiben.
- Senden Sie Ihr Quellbild als Textur (Bild) an die GPU.
- Erstellen Sie einen „Vertex-Shader“.
- Erstellen Sie einen „Fragment-Shader“.
- Legen Sie einige Shader-Variablen fest, die als „Uniforms“ bezeichnet werden.
- Führen Sie die Shader aus.
Sehen wir uns das genauer an. Zuerst wird auf der Grafikkarte ein Speicherbereich zugewiesen, der als Vertex-Zwischenspeicher bezeichnet wird. Darin speichern Sie Daten, die jeden Punkt jedes Dreiecks beschreiben. Sie können auch einige Variablen festlegen, die als Uniforms bezeichnet werden. Das sind globale Werte, die für beide Shader gelten.
Ein Vertex-Shader verwendet Daten aus dem Vertex-Zwischenspeicher, um zu berechnen, wo auf dem Bildschirm die drei Punkte jedes Dreiecks gezeichnet werden sollen.
Jetzt weiß die GPU, welche Pixel im Canvas gezeichnet werden müssen. Der Fragment-Shader wird einmal pro Pixel aufgerufen und muss die Farbe zurückgeben, die auf dem Bildschirm dargestellt werden soll. Der Fragment-Shader kann Informationen aus einer oder mehreren Texturen lesen, um die Farbe zu bestimmen.
Wenn Sie eine Textur in einem Fragment-Shader lesen, geben Sie mit zwei Gleitkomma-Koordinaten zwischen 0 (links oder unten) und 1 (rechts oder oben) an, welchen Teil des Bildes Sie lesen möchten.
Wenn Sie die Textur anhand von 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 durchführen 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;
}
Beachten Sie, dass die Schleife jeweils 4 Byte weitergeht, aber nur drei Werte ändert. Das liegt daran, dass diese 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;
Ebenso wird für diese Transformation nur der RGB-Teil der Ausgabefarbe multipliziert.
Einige dieser Filter benötigen zusätzliche Informationen, z. B. die durchschnittliche Luminanz des gesamten Bildes. Diese können jedoch einmal für das gesamte Bild berechnet werden.
Eine Möglichkeit, den Kontrast zu ändern, besteht beispielsweise darin, jedes Pixel in Richtung eines „Grauwerts“ zu verschieben oder davon wegzubewegen, um einen niedrigeren oder höheren Kontrast zu erzielen. Der Grauwert wird üblicherweise als Graufarbe gewählt, deren Luminanz der Medianluminanz aller Pixel im Bild entspricht.
Sie können diesen Wert einmal berechnen, wenn das Bild geladen wird, und ihn dann jedes Mal verwenden, wenn Sie den Bildeffekt anpassen müssen.
Multi-Pixel
Bei einigen Effekten wird die Farbe der benachbarten Pixel verwendet, um die Farbe des aktuellen Pixels zu bestimmen.
Das ändert die Vorgehensweise beim 2D-Canvas-Fall leicht, da Sie die Originalfarben des Bildes lesen können möchten und im vorherigen Beispiel die Pixel direkt aktualisiert wurden.
Das ist aber ganz einfach. Wenn Sie das Bilddatenobjekt zum ersten Mal erstellen, können Sie eine Kopie der Daten erstellen.
const originalPixels = new Uint8Array(imageData.data);
Für WebGL müssen Sie keine Änderungen vornehmen, da der Shader nicht in die Eingabetextur schreibt.
Die gängigste Kategorie von Multi-Pixel-Effekten wird als Faltungsfilter bezeichnet. 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, den sogenannten Kernel, dargestellt werden, wobei der zentrale Wert dem aktuellen Pixel entspricht. Dies ist beispielsweise der Kernel für einen 3x3-Gaußschen Weichzeichner.
| 0 1 0 |
| 1 4 1 |
| 0 1 0 |
Angenommen, Sie möchten die Ausgabefarbe des Pixels bei (23, 19) berechnen. Nehmen Sie die acht Pixel um (23, 19) sowie das Pixel selbst und multiplizieren Sie die Farbwerte für jedes Pixel 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
Addieren Sie alle Werte und teilen Sie das Ergebnis durch 8, also die Summe der Gewichtungen. Das Ergebnis ist ein Pixel, das größtenteils dem Original entspricht, aber mit den Pixeln in der Nähe verschmilzt.
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;
}
}
Das ist die grundlegende Idee, aber es gibt Anleitungen, die viel detaillierter sind und viele andere nützliche Kernel auflisten.
Gesamtes Bild
Einige Transformationen des gesamten Bildes sind einfach. In einem 2D-Canvas ist das Zuschneiden und Skalieren ganz einfach: Sie zeichnen nur einen Teil des Quellbilds auf den Canvas.
// 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);
Rotation und Spiegelung sind direkt im 2D-Kontext verfügbar. Bevor Sie das Bild in den Canvas zeichnen, ändern Sie die verschiedenen Transformationen.
// 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);
Noch besser ist, dass viele 2D-Transformationen als 2x3-Matrizen geschrieben und mit setTransform() auf den Canvas angewendet werden können. In diesem Beispiel wird eine Matrix verwendet, die eine Rotation und eine Translation 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],
);
Bei komplexeren Effekten wie Linsenverzerrung oder Wellen wird ein Versatz auf jede Zielkoordinate angewendet, um die Quellpixelkoordinate zu berechnen. Wenn Sie beispielsweise einen horizontalen Welleneffekt erzielen möchten, können Sie die X-Koordinate des Quellpixels um einen Wert basierend 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 im Artikel funktioniert bereits für Videos, wenn Sie ein video-Element als Quellbild verwenden.
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 für jeden Frame verwenden, um einen neuen Videoframe zu erfassen und ihn 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 bemerkt ein Nutzer möglicherweise keine Verzögerung von 100 ms zwischen dem Klicken auf eine Schaltfläche und dem Anwenden eines Effekts. Bei Animationen können jedoch schon Verzögerungen von nur 16 ms zu sichtbarem Ruckeln führen.
Empfohlene Links
- WebGL Fundamentals: eine Website, auf der WebGL erklärt wird
- Kernel (Bildverarbeitung): Wikipedia-Seite, auf der Faltungsfilter erklärt werden
- Image Kernels Explained Visually: Beschreibungen einiger Kernel mit interaktiven Demos.
- Transformationen: MDN-Artikel zu 2D-Canvas-Transformationen