Mit vielen der beliebtesten Apps können Sie Bildern oder Videos Filter und Effekte hinzufügen. In diesem Artikel erfahren Sie, wie Sie diese Funktionen im offenen Web implementieren.
Der Prozess ist für Videos und Bilder im Grunde derselbe. Am Ende gehe ich jedoch auf einige wichtige Aspekte bei Videos ein. Im Folgenden wird unter „Bild“ ein Bild oder ein einzelner Frame eines Videos verstanden.
So rufen Sie die Pixeldaten für ein Bild ab
Es gibt drei gängige Kategorien der Bildmanipulation:
- Pixeleffekte wie Kontrast, Helligkeit, Wärme, Sepiaton, Sättigung
- Mehrere Pixeleffekte, sogenannte Faltungsfilter, wie Schärfen, Kantenerkennung und Weichzeichnen.
- Verzerrungen des gesamten Bildes, z. B. Zuschneiden, Verzerren, Dehnen, Linseneffekte oder Wellen
Bei allen diesen Methoden werden die Pixeldaten des Quellbilds abgerufen und dann ein neues Bild daraus erstellt. Die einzige Oberfläche, die dafür verwendet werden kann, ist ein Canvas.
Die wirklich wichtige Entscheidung besteht also darin, 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 mit Abstand 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;
Zu diesem Zeitpunkt ist die Variable pixels
eine Uint8ClampedArray
mit einer Länge von width * height * 4
.
Jedes Arrayelement ist ein Byte und alle vier Elemente im Array stehen für die Farbe eines Pixels. Jedes der vier Elemente steht in dieser Reihenfolge für die Menge an Rot, Grün, Blau und Alpha (Transparenz). 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 ...
Es gibt eine einfache Formel, mit der Sie den Index für ein beliebiges Pixel anhand seiner Koordinaten ermitteln können.
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 alle erdenklichen Effekte anwenden. Dieses Array ist jedoch eine Kopie der tatsächlichen Pixeldaten für das Canvas. Um die bearbeitete Version wieder zu schreiben, müssen Sie die Methode putImageData
verwenden, um sie oben links im Canvas wieder 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 behandeln. Weitere Informationen zu WebGL finden Sie in den weiterführenden Lesematerialien am Ende dieses Artikels.
Hier ist jedoch eine sehr kurze Einführung in die Schritte, die bei der Manipulation eines einzelnen Bildes erforderlich sind.
Eines der wichtigsten Dinge, die Sie über WebGL wissen sollten, ist, dass es sich nicht um eine 3D-Grafik-API handelt. Tatsächlich ist WebGL (und OpenGL) nur für eine Sache gut: 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 ganz einfach, da ein Rechteck aus zwei ähnlichen rechtwinkligen Dreiecken besteht, die so angeordnet sind, dass sich ihre Hypotenusen an derselben Stelle befinden.
Der grundlegende Ablauf ist:
- 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 „Uniformen“ bezeichnet werden.
- Shader ausführen.
Sehen wir uns das genauer an. Zuerst wird ein bestimmter Arbeitsspeicher auf der Grafikkarte zugewiesen, der als Vertex-Puffer bezeichnet wird. Sie speichern darin Daten, die jeden Punkt jedes Dreiecks beschreiben. Sie können auch einige Variablen festlegen, die als Uniforms bezeichnet werden und globale Werte über beide Shader sind.
Ein Vertex-Shader verwendet Daten aus dem Vertex-Buffer, um zu berechnen, wo auf dem Bildschirm die drei Punkte jedes Dreiecks gezeichnet werden sollen.
Jetzt weiß die GPU, welche Pixel innerhalb des 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 Fragmentshader kann Informationen aus einer oder mehreren Texturen lesen, um die Farbe zu bestimmen.
Wenn Sie eine Textur in einem Fragment-Shader lesen, geben Sie an, welchen Teil des Bilds Sie lesen möchten. Dazu verwenden Sie zwei Gleitkommakoordinaten zwischen 0 (links oder unten) und 1 (rechts oder oben).
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 die Umwandlung für jedes Pixel erfolgen kann.
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 weiterrückt, aber nur drei Werte ändert. Das liegt daran, dass bei dieser bestimmten Transformation der Alphawert nicht geändert wird. Denken Sie außerdem 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 spezielle Transformation nur der RGB-Teil der Ausgabefarbe multipliziert.
Einige dieser Filter erfassen zusätzliche Informationen, z. B. die durchschnittliche Leuchtdichte des gesamten Bildes, aber diese Werte können 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, um den Kontrast zu verringern, oder von einem Grauwert weg, um den Kontrast zu erhöhen. Der Grauwert wird in der Regel als Grau ausgewählt, dessen Leuchtkraft der Medianwert der Leuchtkraft aller Pixel im Bild ist.
Sie können diesen Wert einmal beim Laden des Bildes berechnen und dann jedes Mal verwenden, wenn Sie den Bildeffekt anpassen möchten.
Multi-Pixel
Bei einigen Effekten wird die Farbe der benachbarten Pixel für die Farbe des aktuellen Pixels verwendet.
Das ändert die Vorgehensweise im 2D-Canvas-Fall leicht, da Sie die ursprünglichen Farben des Bildes lesen können möchten. Im vorherigen Beispiel wurden die Pixel an Ort und Stelle aktualisiert.
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);
Im WebGL-Fall müssen Sie keine Änderungen vornehmen, da der Shader nicht in die Eingabetextur schreibt.
Die häufigste Kategorie von Multi-Pixel-Effekten wird als Faltungsfilter bezeichnet. Bei einem Convolutionsfilter werden mehrere Pixel aus dem Eingabebild verwendet, um die Farbe jedes Pixels im Eingabebild zu berechnen. Der Einfluss jedes Eingabepixels auf die Ausgabe wird als Gewicht bezeichnet.
Die Gewichtungen können durch eine Matrix dargestellt werden, die als Kernel bezeichnet wird, wobei der zentrale Wert dem aktuellen Pixel entspricht. Dies ist beispielsweise der Kernel für eine 3 × 3 Pixel große gaussianische Unschärfe.
| 0 1 0 | | 1 4 1 | | 0 1 0 |
Angenommen, Sie möchten die Ausgabefarbe des Pixels bei (23, 19) berechnen. Nehmen Sie die 8 Pixel um (23, 19) herum sowie das Pixel selbst und multiplizieren Sie die Farbwerte für jedes davon mit dem entsprechenden Gewicht.
(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 sie alle und teilen Sie dann das Ergebnis durch 8, d. h. die Summe der Gewichtungen. Sie können sehen, wie das Ergebnis ein Pixel sein wird, das größtenteils das Original ist, bei dem jedoch die Pixel in der Nähe durchscheinen.
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 Grundidee, aber es gibt Anleitungen, die viel detaillierter sind und viele andere nützliche Kernel auflisten.
Ganzes Bild
Einige Transformationen für das gesamte Bild sind einfach. In einem 2D-Canvas wird durch Zuschneiden und Skalieren einfach nur ein Teil des Quellbilds auf dem Canvas gezeichnet.
// 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 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 leistungsfähiger ist es jedoch, viele 2D-Transformationen als 2 × 3-Matrizen zu schreiben und mit setTransform()
auf den Canvas anzuwenden. In diesem Beispiel wird eine Matrix verwendet, die eine Drehung 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],
);
Bei komplexeren Effekten wie Verzeichnungen oder Wellen wird jeder Zielkoordinate ein bestimmter Offset zugewiesen, um die Pixelkoordinate der Quelle zu berechnen. Wenn Sie beispielsweise einen horizontalen Welleneffekt erzielen möchten, können Sie die X-Koordinate des Quellpixels um einen Wert verschieben, der von der Y-Koordinate abhängt.
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 abgespieltes Video anwenden möchten, müssen Sie mit drawImage
/texImage2D
für jeden Frame einen neuen Videoframe erfassen und ihn für jeden Browser-Animationsframe 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 fällt einer Nutzerin eine Verzögerung von 100 Millisekunden zwischen dem Klicken auf eine Schaltfläche und dem Anwenden eines Effekts möglicherweise nicht auf. In der Animation können Verzögerungen von nur 16 ms jedoch zu sichtbaren Rucklern führen.
Empfohlene Links
- WebGL Fundamentals: eine Website zum Thema WebGL
- Kernel (Bildverarbeitung): Wikipedia-Seite zu Faltungsfiltern
- Image Kernels Visually Explained: Beschreibungen einiger Kernel mit interaktiven Demos.
- Transformationen: MDN-Artikel zu 2D-Canvas-Transformationen