Effets en temps réel pour les images et les vidéos

Mat Scales

Bon nombre des applications les plus populaires du moment vous permettent d'appliquer des filtres et des effets à des images ou à des vidéos. Cet article explique comment implémenter ces fonctionnalités sur le Web ouvert.

Le processus est essentiellement le même pour les vidéos et les images, mais je vous parlerai des points importants à prendre en compte à la fin. Tout au long de l'article, vous pouvez considérer que "image" signifie "image ou un seul frame d'une vidéo".

Accéder aux données de pixel d'une image

Trois catégories de base de manipulation d'images sont courantes:

  • Effets Pixel comme le contraste, la luminosité, la chaleur, le ton sépia et la saturation.
  • Effets multipixels, appelés filtres de convolution, comme l'accentuation, la détection des bords et le floutage.
  • Distorsion de l'image entière, comme le recadrage, le gauchissement, l'étirement, les effets d'objectif et les ondulations

Toutes ces opérations impliquent d'accéder aux données de pixel réelles de l'image source, puis de créer une nouvelle image à partir de celle-ci. La seule interface permettant de le faire est un canevas.

Le choix le plus important est donc de procéder au traitement sur le processeur, avec un canevas 2D, ou sur le GPU, avec WebGL.

Voyons rapidement les différences entre les deux approches.

Canevas 2D

C'est sans aucun doute la plus simple des deux options. Vous devez d'abord dessiner l'image sur le canevas.

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

Vous obtenez ensuite un tableau de valeurs en pixels pour l'ensemble du canevas.

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

À ce stade, la variable pixels est un Uint8ClampedArray d'une longueur de width * height * 4. Chaque élément du tableau est un octet, et chaque groupe de quatre éléments représente la couleur d'un pixel. Chacun des quatre éléments représente la quantité de rouge, de vert, de bleu et d'alpha (transparence) dans cet ordre. Les pixels sont ordonnés du coin supérieur gauche et allant de gauche à droite et de haut en bas.

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

Pour trouver l'index d'un pixel donné à partir de ses coordonnées, il existe une formule simple.

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

Vous pouvez désormais lire et écrire ces données comme vous le souhaitez, ce qui vous permet d'appliquer tous les effets possibles. Toutefois, ce tableau est une copie des données de pixel réelles du canevas. Pour réécrire la version modifiée, vous devez utiliser la méthode putImageData pour la réécrire en haut à gauche du canevas.

context.putImageData(imageData, 0, 0);

WebGL

WebGL est un sujet vaste, certainement trop vaste pour être traité dans un seul article. Pour en savoir plus sur WebGL, consultez la documentation recommandée à la fin de cet article.

Cependant, voici une brève présentation de la procédure à suivre pour manipuler une seule image.

L'un des points les plus importants à retenir concernant WebGL est qu'il n'est pas une API graphique 3D. En fait, WebGL (et OpenGL) permettent exactement de dessiner des triangles. Dans votre application, vous devez décrire ce que vous voulez réellement dessiner en termes de triangles. Dans le cas d'une image 2D, c'est très simple, car un rectangle est constitué de deux triangles similaires à angle droit, disposés de sorte que leurs hypoténus se trouvent au même endroit.

Le processus de base est le suivant:

  • Envoyez au GPU des données qui décrivent les sommets (points) des triangles.
  • Envoyez votre image source au GPU sous forme de texture (image).
  • Créez un "vertex shader".
  • Créez un "fragment shader".
  • Définissez des variables de nuanceur, appelées "uniformes".
  • Exécutez les nuanceurs.

Voyons cela plus en détail. Commencez par allouer de la mémoire sur la carte graphique appelée tampon de sommets. Vous y stockez des données qui décrivent chaque point de chaque triangle. Vous pouvez également définir certaines variables, appelées "uniformes", qui sont des valeurs globales via les deux nuanceurs.

Un nuanceur de sommets utilise les données du tampon de sommets pour calculer l'emplacement à l'écran où dessiner les trois points de chaque triangle.

Le GPU sait maintenant quels pixels du canevas doivent être dessinés. Le nuanceur de fragment est appelé une fois par pixel et doit renvoyer la couleur à dessiner à l'écran. Le nuanceur de fragment peut lire des informations d'une ou de plusieurs textures pour déterminer la couleur.

Lorsque vous lisez une texture dans un nuanceur de fragment, vous spécifiez la partie de l'image que vous souhaitez lire à l'aide de deux coordonnées à virgule flottante comprises entre 0 (à gauche ou en bas) et 1 (à droite ou en haut).

Si vous souhaitez lire la texture en fonction des coordonnées en pixels, vous devez transmettre la taille de la texture en pixels en tant que vecteur uniforme afin de pouvoir effectuer la conversion pour chaque pixel.

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

Notez que la boucle déplace 4 octets à la fois, mais ne modifie que trois valeurs, car cette transformation particulière ne modifie pas la valeur alpha. N'oubliez pas non plus qu'un Uint8ClampedArray arrondit toutes les valeurs à des entiers et limite les valeurs à une plage comprise entre 0 et 255.

nuanceur de fragment WebGL:

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

De même, seule la partie RVB de la couleur de sortie est multipliée pour cette transformation particulière.

Certains de ces filtres utilisent des informations supplémentaires, telles que la luminance moyenne de l'ensemble de l'image, mais il s'agit d'éléments qui peuvent être calculés une seule fois pour l'ensemble de l'image.

Par exemple, pour modifier le contraste, vous pouvez déplacer chaque pixel vers ou loin d'une valeur "gris", pour un contraste plus faible ou plus élevé, respectivement. La valeur de gris est généralement choisie comme une couleur grise dont la luminance est la luminance médiane de tous les pixels de l'image.

Vous pouvez calculer cette valeur une fois lorsque l'image est chargée, puis l'utiliser chaque fois que vous devez ajuster l'effet de l'image.

Multipixel

Certains effets utilisent la couleur des pixels voisins pour déterminer la couleur du pixel actuel.

Cela modifie légèrement la façon de procéder dans le cas du canevas 2D, car vous devez pouvoir lire les couleurs d'origine de l'image, alors que l'exemple précédent modifiait les pixels en place.

Cependant, c’est assez facile. Lorsque vous créez initialement votre objet de données d'image, vous pouvez en créer une copie.

const originalPixels = new Uint8Array(imageData.data);

Pour le cas WebGL, vous n'avez pas besoin d'apporter de modifications, car le nuanceur n'écrit pas dans la texture d'entrée.

La catégorie d'effets multipixels la plus courante est appelée filtre de convolution. Un filtre de convolution utilise plusieurs pixels de l'image d'entrée pour calculer la couleur de chaque pixel de l'image d'entrée. Le niveau d'influence de chaque pixel d'entrée sur la sortie est appelé pondération.

Les poids peuvent être représentés par une matrice, appelée noyau, dont la valeur centrale correspond au pixel actuel. Par exemple, il s'agit du noyau d'un flou gaussien 3x3.

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

Disons que vous souhaitez calculer la couleur de sortie du pixel en position (23, 19). Prenez les huit pixels entourant (23, 19) ainsi que le pixel lui-même, et multipliez les valeurs de couleur de chacun d'eux par le poids correspondant.

    (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

Additionnez-les tous, puis divisez le résultat par 8, qui correspond à la somme des pondérations. Vous pouvez voir que le résultat est un pixel qui est principalement l'original, mais avec les pixels à proximité qui s'estompent.

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

Cela donne une idée de base, mais il existe des guides qui vont beaucoup plus en détail et qui énumèrent de nombreux autres noyaux utiles.

Image entière

Certaines transformations d'images entières sont simples. Dans un canevas 2D, le recadrage et le redimensionnement consistent simplement à ne dessiner qu'une partie de l'image source sur le canevas.

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

La rotation et la réflexion sont directement disponibles en contexte 2D. Avant de dessiner l'image dans le canevas, modifiez les différentes transformations.

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

Mais plus efficacement, de nombreuses transformations 2D peuvent être écrites sous forme de matrices 2x3 et appliquées au canevas avec setTransform(). Cet exemple utilise une matrice qui combine une rotation et une translation.

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

Les effets plus complexes, comme la distorsion de l'objectif ou les ondulations, impliquent d'appliquer un décalage à chaque coordonnée de destination pour calculer la coordonnée de pixel source. Par exemple, pour obtenir un effet d'onde horizontale, vous pouvez décaler la coordonnée X du pixel source d'une valeur en fonction de la coordonnée 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];
  }
}

Vidéo

Tout le reste de l'article fonctionne déjà pour les vidéos si vous utilisez un élément video comme image source.

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

Toutefois, seule l'image vidéo actuelle est utilisée. Ainsi, si vous souhaitez appliquer un effet à une vidéo en cours de lecture, vous devez utiliser drawImage/texImage2D sur chaque image pour saisir une nouvelle image vidéo et la traiter sur chaque image d'animation du navigateur.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Lorsque vous travaillez avec des vidéos, il est particulièrement important que le traitement soit rapide. Avec une image fixe, un utilisateur peut ne pas remarquer un délai de 100 ms entre le clic sur un bouton et l'application d'un effet. Cependant, une fois animées, des retards de seulement 16 ms peuvent entraîner des saccades visibles.

Commentaires