Efectos en tiempo real para imágenes y videos

Mat Scales

Muchas de las apps más populares de la actualidad te permiten aplicar filtros y efectos a imágenes o videos. En este artículo, se muestra cómo implementar estas funciones en la Web abierta.

El proceso es básicamente el mismo para videos e imágenes, pero, al final, abordaré algunas consideraciones importantes sobre los videos. En todo el artículo, puedes suponer que "imagen" significa "imagen o un solo fotograma de un video".

Cómo obtener los datos de píxeles de una imagen

Existen 3 categorías básicas de manipulación de imágenes que son comunes:

  • Efectos de píxeles, como contraste, brillo, calidez, tono sepia y saturación
  • Efectos de varios píxeles, llamados filtros convolucionales, como nitidez, detección de bordes y desenfoque
  • Distorsión de toda la imagen, como recortes, inclinaciones, estiramientos, efectos de lente y ondas

Todas estas operaciones implican obtener los datos de píxeles reales de la imagen de origen y, luego, crear una imagen nueva a partir de ella. La única interfaz para hacerlo es un lienzo.

Por lo tanto, la elección realmente importante es si realizar el procesamiento en la CPU, con un lienzo en 2D, o en la GPU, con WebGL.

Analicemos rápidamente las diferencias entre los dos enfoques.

Lienzo 2D

Esta es, sin duda, la más sencilla de las dos opciones. Primero, dibuja la imagen en el lienzo.

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

Luego, obtienes un array de valores de píxeles para todo el lienzo.

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

En este punto, la variable pixels es un Uint8ClampedArray con una longitud de width * height * 4. Cada elemento del array es de un byte, y cada cuatro elementos del array representan el color de un píxel. Cada uno de los cuatro elementos representa la cantidad de rojo, verde, azul y alfa (transparencia) en ese orden. Los píxeles se ordenan desde la esquina superior izquierda y se desplazan de izquierda a derecha y de arriba abajo.

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

Para encontrar el índice de cualquier píxel a partir de sus coordenadas, existe una fórmula sencilla.

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

Ahora puedes leer y escribir estos datos como quieras, lo que te permite aplicar cualquier efecto que se te ocurra. Sin embargo, este array es una copia de los datos de píxeles reales del lienzo. Para volver a escribir la versión editada, debes usar el método putImageData para escribirla en la esquina superior izquierda del lienzo.

context.putImageData(imageData, 0, 0);

WebGL

WebGL es un tema amplio, sin duda demasiado extenso para abordarlo en un solo artículo. Si quieres obtener más información sobre WebGL, consulta la lectura recomendada al final de este artículo.

Sin embargo, aquí tienes una breve introducción a lo que se debe hacer en el caso de manipular una sola imagen.

Uno de los aspectos más importantes que debes recordar sobre WebGL es que no es una API de gráficos 3D. De hecho, WebGL (y OpenGL) es bueno para una sola cosa: dibujar triángulos. En tu aplicación, debes describir lo que realmente quieres dibujar en términos de triángulos. En el caso de una imagen en 2D, es muy simple, ya que un rectángulo son dos triángulos rectángulos similares, dispuestos de modo que sus hipotenusas estén en el mismo lugar.

El proceso básico es el siguiente:

  • Envía datos a la GPU que describen los vértices (puntos) de los triángulos.
  • Envía tu imagen de origen a la GPU como una textura (imagen).
  • Crea un "sombreador de vértices".
  • Crea un "sombreador de fragmentos".
  • Establecer algunas variables de sombreador, llamadas "uniformes"
  • Ejecuta los sombreadores.

Analicemos esto en detalle. Para comenzar, asigna algo de memoria en la tarjeta gráfica, lo que se denomina búfer de vértices. En él, almacenas datos que describen cada punto de cada triángulo. También puedes establecer algunas variables, llamadas uniformes, que son valores globales en ambos sombreadores.

Un sombreador de vértices usa datos del búfer de vértices para calcular en qué parte de la pantalla se deben dibujar los tres puntos de cada triángulo.

Ahora, la GPU sabe qué píxeles dentro del lienzo se deben dibujar. El sombreador de fragmentos se llama una vez por píxel y debe devolver el color que se debe dibujar en la pantalla. El sombreador de fragmentos puede leer información de una o más texturas para determinar el color.

Cuando lees una textura en un sombreador de fragmentos, especificas qué parte de la imagen quieres leer con dos coordenadas de punto flotante entre 0 (izquierda o abajo) y 1 (derecha o arriba).

Si quieres leer la textura según las coordenadas de píxeles, debes pasar el tamaño de la textura en píxeles como un vector uniforme para poder realizar la conversión de cada píxel.

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

Ten en cuenta que el bucle se mueve 4 bytes a la vez, pero solo cambia tres valores. Esto se debe a que esta transformación en particular no cambia el valor alfa. También recuerda que un Uint8ClampedArray redondeará todos los valores a números enteros y los limitará para que estén entre 0 y 255.

Sombreador de fragmentos de WebGL:

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

Del mismo modo, solo se multiplica la parte RGB del color de salida para esta transformación en particular.

Algunos de estos filtros toman información adicional, como la luminancia promedio de toda la imagen, pero estos son aspectos que se pueden calcular una vez para toda la imagen.

Una forma de cambiar el contraste, por ejemplo, puede ser mover cada píxel hacia o desde algún valor "gris", para un contraste más bajo o más alto, respectivamente. Por lo general, el valor de gris se elige para que sea un color gris cuya luminancia sea la mediana de la luminancia de todos los píxeles de la imagen.

Puedes calcular este valor una vez que se cargue la imagen y, luego, usarlo cada vez que necesites ajustar el efecto de la imagen.

Multipíxel

Algunos efectos usan el color de los píxeles vecinos para decidir el color del píxel actual.

Esto cambia un poco la forma en que haces las cosas en el caso del lienzo 2D, ya que quieres poder leer los colores originales de la imagen, y el ejemplo anterior actualizaba los píxeles en su lugar.

Sin embargo, esto es bastante fácil. Cuando creas inicialmente tu objeto de datos de imagen, puedes hacer una copia de los datos.

const originalPixels = new Uint8Array(imageData.data);

En el caso de WebGL, no es necesario que realices ningún cambio, ya que el sombreador no escribe en la textura de entrada.

La categoría más común de efecto de varios píxeles se denomina filtro de convolución. Un filtro convolucional usa varios píxeles de la imagen de entrada para calcular el color de cada píxel en la imagen de entrada. El nivel de influencia que tiene cada píxel de entrada en el resultado se denomina peso.

Los pesos se pueden representar con una matriz, llamada kernel, con el valor central correspondiente al píxel actual. Por ejemplo, este es el kernel para un desenfoque gaussiano de 3 x 3.

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

Supongamos que quieres calcular el color de salida del píxel en (23, 19). Toma los 8 píxeles que rodean a (23, 19) y el píxel en sí, y multiplica los valores de color de cada uno de ellos por el peso correspondiente.

    (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

Súmalos todos y, luego, divide el resultado por 8, que es la suma de los pesos. Puedes ver cómo el resultado será un píxel que es, en su mayoría, el original, pero con los píxeles cercanos que se filtran.

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

Esto te da una idea básica, pero hay guías que entrarán en mucho más detalle y enumerarán muchos otros kernels útiles.

Imagen completa

Algunas transformaciones de imágenes completas son sencillas. En un lienzo en 2D, recortar y escalar es un caso simple de dibujar solo una parte de la imagen de origen en el lienzo.

// 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 rotación y el reflejo están disponibles directamente en el contexto 2D. Antes de dibujar la imagen en el lienzo, cambia las distintas transformaciones.

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

Sin embargo, lo más importante es que muchas transformaciones en 2D se pueden escribir como matrices de 2 x 3 y aplicarse al lienzo con setTransform(). En este ejemplo, se usa una matriz que combina una rotación y una traslación.

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

Los efectos más complejos, como la distorsión de la lente o las ondas, implican aplicar un desplazamiento a cada coordenada de destino para calcular la coordenada de píxel de origen. Por ejemplo, para tener un efecto de onda horizontal, podrías compensar la coordenada X del píxel de origen con un valor basado en la coordenada 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];
  }
}

Video

Todo lo demás en el artículo ya funciona para el video si usas un elemento video como imagen de origen.

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

Sin embargo, esto solo usará el fotograma actual. Por lo tanto, si deseas aplicar un efecto a un video en reproducción, debes usar drawImage/texImage2D en cada fotograma para capturar un nuevo fotograma de video y procesarlo en cada fotograma de animación del navegador.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Cuando se trabaja con videos, es especialmente importante que el procesamiento sea rápido. Con una imagen fija, es posible que el usuario no note un retraso de 100 ms entre el clic en un botón y la aplicación de un efecto. Sin embargo, cuando se trata de animaciones, las demoras de solo 16 ms pueden causar tirones visibles.

Comentarios