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 los videos y las imágenes, pero al final, hablaré sobre algunas consideraciones importantes para los videos. A lo largo del artículo, puedes suponer que "imagen" significa "imagen o un solo fotograma de un video".

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 de convolución, como el enfoque, la detección de bordes y la desenfoque
  • Distorsión de toda la imagen, como recorte, inclinación, estiramiento, efectos de lente o ondulaciones

Todo esto implica obtener los datos de píxeles reales de la imagen de origen y, luego, crear una imagen nueva a partir de ella, y la única interfaz para hacerlo es un lienzo.

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

Veamos rápidamente las diferencias entre los dos enfoques.

Lienzo 2D

Esta es, sin duda, la más sencilla de las dos opciones. Primero, dibujas 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 un byte y cada cuatro elementos del array representan el color de un pixel. 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 trabajan 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 determinado a partir de sus coordenadas, existe una fórmula 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];

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 para el lienzo. Para volver a escribir la versión editada, debes usar el método putImageData para volver a escribirla en la esquina superior izquierda del lienzo.

context.putImageData(imageData, 0, 0);

WebGL

WebGL es un tema muy amplio, demasiado grande para hacerlo justicia en un solo artículo. Si deseas 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.

Una de las cosas más importantes que debes recordar sobre WebGL es que no es una API de gráficos en 3D. De hecho, WebGL (y OpenGL) funciona bien para 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 2D, es muy simple, ya que un rectángulo es dos triángulos similares con ángulos rectos, 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 describan los vértices (puntos) de los triángulos.
  • Envía tu imagen de origen a la GPU como una textura (imagen).
  • Crea un "shader de vértices".
  • Crea un “sombreador de fragmentos”.
  • Establece algunas variables de sombreador, llamadas "uniformes".
  • Ejecuta los sombreadores.

Veamos los detalles. Comienza por asignar memoria en la tarjeta gráfica llamada búfer de vértices. En ella, almacenas datos que describen cada punto de cada triángulo. También puedes establecer algunas variables, llamadas uniformes, que son valores globales a través de ambos sombreadores.

Un sombreador de vértices usa datos del búfer de vértices para calcular dónde dibujar los tres puntos de cada triángulo en la pantalla.

Ahora, la GPU sabe qué píxeles dentro del lienzo se deben dibujar. Se llama al sombreador de fragmentos una vez por píxel y debe mostrar 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 inferior) y 1 (derecha o superior).

Si deseas leer la textura en función de las coordenadas de píxeles, debes pasar el tamaño de la textura en píxeles como un vector uniforme para que puedas realizar la conversión para 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 mueve 4 bytes a la vez, pero solo cambia tres valores, ya 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á entre 0 y 255.

Sombreador de fragmentos de WebGL:

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

De manera similar, para esta transformación en particular, solo se multiplica la parte RGB del color de salida.

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

Por ejemplo, una forma de cambiar el contraste puede ser mover cada píxel hacia un valor “gris” o alejarlo de él, para obtener un contraste más bajo o más alto, respectivamente. Por lo general, el valor de gris es un color gris cuya luminancia es la mediana de luminancia de todos los píxeles de la imagen.

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

Varios píxeles

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

Esto cambia ligeramente la forma en que haces las cosas en el caso del lienzo 2D porque 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 por primera vez 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 necesitas realizar 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 de convolución 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 cada píxel de entrada tiene 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 de un desenfoque Gaussiano de 3 × 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 (23, 19), así como el píxel en sí, y multiplica los valores de color de cada uno 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

Suma todos los valores 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 extienden.

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 la idea básica, pero hay guías que profundizan mucho más en el tema y enumeran muchos otros kernels útiles.

Imagen completa

Algunas transformaciones de imágenes completas son simples. En un lienzo 2D, el recorte y el escalamiento son un caso simple de solo dibujar 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 de las 2D. Antes de dibujar la imagen en el canvas, cambia las diversas 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);

Pero, lo más potente, es que muchas transformaciones 2D se pueden escribir como matrices de 2 × 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 complicados, como la distorsión del lente o las ondulaciones, implican aplicar un desplazamiento a cada coordenada de destino para calcular la coordenada de píxeles de origen. Por ejemplo, para tener un efecto de onda horizontal, puedes compensar la coordenada x del píxel de origen en función de 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 del artículo ya funciona para los videos si usas un elemento video como imagen fuente.

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, solo se usará el fotograma de video 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 de especial importancia que el procesamiento sea rápido. Con una imagen fija, es posible que un usuario no note una demora de 100 ms entre hacer clic en un botón y aplicar un efecto. Sin embargo, cuando se anima, las demoras de solo 16 ms pueden causar movimientos bruscos visibles.

Comentarios