Efectos en tiempo real para imágenes y videos

Escalas para colchonetas

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 abordaremos algunas consideraciones importantes sobre los videos al final. Durante el artículo, puedes suponer que “imagen” significa “imagen o un solo fotograma de un video”.

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

Hay 3 categorías básicas comunes de manipulación de imágenes:

  • Efectos de píxeles como contraste, brillo, calidez, tono sepia y saturación
  • Efectos de varios píxeles, llamados filtros de convolución, como nitidez, detección de bordes y desenfoque.
  • Es la distorsión completa de la imagen, como recortes, distorsión, estiramiento, efectos de lente y ondas.

Todas estas 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 decisión realmente importante es realizar el procesamiento en la CPU, con un lienzo 2D, o en la GPU, con WebGL.

Veamos rápidamente las diferencias entre ambos enfoques.

Lienzo 2D

En términos generales, esta es la más simple 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 representa 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 a partir de la esquina superior izquierda, avanzando de izquierda a derecha y de arriba hacia 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 un píxel determinado a partir de sus coordenadas, hay 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 extenso, sin dudas demasiado grande para que sea justamente 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í hay una introducción muy breve de lo que se debe hacer en 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) son buenos en exactamente una 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 las imágenes en 2D, eso es muy simple, porque un rectángulo es dos triángulos rectos similares, organizados de modo que sus hipotenas estén en el mismo lugar.

El proceso básico es el siguiente:

  • Envía datos a la GPU que describa los vértices (puntos) de los triángulos.
  • Envía la imagen de origen a la GPU como una textura (imagen).
  • Crea un "sombreador de vértices".
  • Crea un "sombreador de fragmentos".
  • Configura algunas variables del sombreador llamadas 'uniforms'.
  • Ejecuta los sombreadores.

Veamos los detalles. Comienza por asignar algo de memoria en la tarjeta gráfica denominada búfer de vértices. Almacenas en él datos que describen cada punto de cada triángulo. También puedes establecer algunas variables, denominadas 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 en qué lugar de la pantalla dibujar los tres puntos de cada triángulo.

Ahora, la GPU sabe qué píxeles del lienzo deben dibujarse. 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 coordenadas de píxeles, debes pasar el tamaño de la textura en píxeles como un vector uniforme para que puedas hacer 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. Recuerda también que un Uint8ClampedArray redondea todos los valores a números enteros y que los valores de restricción deben estar 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 la imagen completa, pero estos son factores 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 o desde algún valor "gris", para obtener 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 en la imagen.

Puedes calcular este valor cuando la imagen está cargada 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 realizas las tareas en el caso del lienzo 2D, ya que quieres poder leer los colores originales de la imagen y en el ejemplo anterior se actualizaron los píxeles establecidos.

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 de convolución utiliza varios píxeles de la imagen de entrada para calcular el color de cada píxel en esa imagen. El nivel de influencia que tiene cada píxel de entrada sobre la salida 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 3x3.

    | 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) y el píxel en sí, y multiplica los valores de color de cada uno por el grosor 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 y divide el resultado por 8, que es la suma de los pesos. Puedes ver que el resultado será un píxel que será mayormente el original, pero con los píxeles cercanos 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 da una idea básica, pero existen guías que brindarán mucho más detalle y enumerarán muchos otros kernels útiles.

Imagen completa

Algunas transformaciones de imágenes completas son simples. En un lienzo 2D, el recorte y escalamiento es un caso simple en el que solo se dibuja 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 la reflexión están directamente disponibles en el contexto de 2D. Antes de dibujar la imagen en el lienzo, 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, de manera más poderosa, muchas transformaciones 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 complicados, como la distorsión o las ondas de la lente, implican aplicar un desplazamiento a cada coordenada de destino para calcular la coordenada de píxeles de origen. Por ejemplo, para lograr un efecto de onda horizontal, podrías desplazar la coordenada x del píxel de origen por algún 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 los videos si usas un elemento video como imagen de origen.

Lienzo 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, se usará solo el fotograma actual de video. 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 y procesarlo en cada fotograma de animación del navegador.

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

  context.drawImage(video, 0, 0);

  // ...image processing goes here
};

Cuando trabajas con videos, es importante que el procesamiento sea rápido. Con una imagen estática, 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 animan, las demoras de solo 16 ms pueden causar una inclinación visible.

Comentarios