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

Balances à tapis

Nombre des applications les plus populaires actuellement 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 globalement le même pour les vidéos et les images, mais je reviendrai à la fin sur quelques points importants à prendre en compte. Tout au long de cet article, vous pouvez supposer que le terme "image" signifie "image ou image unique d'une vidéo".

Obtenir les données de pixels 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 à convolution, comme l'accentuation, la détection des bords et le flou
  • Déformation de l'image entière (recadrage, inclinaison, étirement, effets d'objectif, ondulations, etc.).

Tout cela implique d'obtenir les données de pixels réelles de l'image source, puis de créer une nouvelle image à partir de celle-ci. La seule interface pour ce faire est un canevas.

Le choix le plus important consiste donc à effectuer le traitement sur le processeur, avec un canevas 2D, ou sur le GPU, avec WebGL.

Examinons rapidement les différences entre les deux approches.

Canevas 2D

C'est sans aucun doute la plus simple des deux options. Tout d'abord, vous dessinez l'image sur la toile.

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 de 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 une Uint8ClampedArray d'une longueur de width * height * 4. Chaque élément du tableau est d'un octet, et tous les quatre éléments du tableau représentent 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 triés en commençant par l'angle supérieur gauche et en partant 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'indice 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 maintenant lire et écrire ces données comme vous le souhaitez, ce qui vous permet d'appliquer tous les effets possibles. Cependant, ce tableau est une copie des données de pixels réelles pour le canevas. Pour réécrire la version modifiée, vous devez utiliser la méthode putImageData afin de la réécrire en haut à gauche du canevas

context.putImageData(imageData, 0, 0);

WebGL

WebGL est un sujet important, mais sans doute trop vaste pour qu'un seul article soit évoqué. Pour en savoir plus sur WebGL, consultez les recommandations à la fin de cet article.

Cependant, voici une très brève introduction de la procédure à suivre dans le cas de la manipulation d'une seule image.

L'un des points les plus importants à retenir à propos de WebGL est qu'il ne s'agit pas d'une API de graphisme 3D. En fait, WebGL (et OpenGL) est particulièrement efficace : 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 en 2D, c'est très simple, car un rectangle est constitué de deux triangles à angle droit similaires, disposés de manière à ce que leurs hypoténus soient au même endroit.

Le processus de base est le suivant:

  • Envoyez au GPU les données décrivant les sommets (points) des triangles.
  • Envoyez votre image source au GPU en tant que texture (image).
  • Créez un "nuanceur de sommets".
  • Créer un "nuanceur de fragments"
  • Définissez des variables de nuanceur, appelées "variables uniformes".
  • Exécutez les nuanceurs.

Entrons dans les détails. Commencez par allouer de la mémoire sur la carte graphique, appelée "tampon des sommets". Vous y stockez les données qui décrivent chaque point de chaque triangle. Vous pouvez également définir certaines variables, appelées variables 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 doivent être dessinés dans le canevas. Le nuanceur de fragments est appelé une fois par pixel et doit renvoyer la couleur à afficher à l'écran. Le nuanceur de fragments peut lire les informations d'une ou plusieurs textures pour déterminer la couleur.

Lorsque vous lisez une texture dans un nuanceur de fragments, 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 bas) et 1 (droite ou haut).

Si vous souhaitez lire la texture en fonction des coordonnées de pixels, vous devez transmettre la taille de la texture en pixels sous la forme d'un 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 se déplace de 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 valeur comprise entre 0 et 255.

Nuanceur de fragments 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 prennent des informations supplémentaires, telles que la luminance moyenne de l'image entière, mais ces éléments peuvent être calculés une seule fois pour l'image entière.

Une façon de modifier le contraste, par exemple, peut consister à éloigner chaque pixel d'une valeur de "gris", pour un contraste plus faible ou plus élevé, respectivement. La valeur de gris est généralement choisie en gris dont la luminance correspond à 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 d'image.

Multipixel

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

Cela modifie légèrement votre façon de faire les choses dans le boîtier Canvas 2D, car vous voulez pouvoir lire les couleurs originales de l'image. Dans l'exemple précédent, les pixels en place étaient mis à jour.

C'est cependant assez facile. Lorsque vous créez initialement votre objet de données d'image, vous pouvez créer une copie des données.

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'effet multipixel 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 cette image. Le niveau d'influence de chaque pixel d'entrée sur la sortie est appelé pondération.

Les pondérations peuvent être représentées 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 |

Supposons que vous souhaitiez calculer la couleur de sortie du pixel en (23, 19). Prenez les 8 pixels (23, 19) autour, ainsi que le pixel lui-même, et multipliez les valeurs de couleur de chacun d'eux par la pondération correspondante.

    (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, puis divisez le résultat par 8, qui correspond à la somme des pondérations. Vous pouvez voir que le résultat obtenu est un pixel qui est principalement l'original, mais avec des pixels voisins qui apparaissent à l'écran.

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 l'idée de base, mais il existe des guides qui détaillent de nombreux autres noyaux utiles.

Image entière

Certaines transformations d'images complètes sont simples. Dans un canevas 2D, le recadrage et la mise à l'échelle sont un cas simple qui consiste à 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 dans le 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 surtout, 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, tels que la distorsion de l'objectif ou les ondulations, consistent à appliquer un décalage à chaque coordonnée de destination pour calculer la coordonnée en pixels source. Par exemple, pour obtenir un effet d'onde horizontale, vous pouvez décaler la coordonnée X du pixel source d'une valeur basée sur 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 la vidéo si vous utilisez un élément video comme image source.

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

Toutefois, seule l'image vidéo actuelle sera utilisée. Par conséquent, si vous souhaitez appliquer un effet à une vidéo en cours de lecture, vous devez utiliser drawImage/texImage2D sur chaque image afin de récupérer une nouvelle image vidéo et de 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 une vidéo, il est particulièrement important que le traitement soit rapide. Avec une image fixe, l'utilisateur peut ne pas remarquer un délai de 100 ms entre un clic sur un bouton et l'application d'un effet. Toutefois, avec l'animation, un délai de seulement 16 ms peut provoquer des saccades visibles.

Commentaires