Manipulación de píxeles usando Canvas | Programar Plus

Los navegadores modernos admiten la reproducción de videos a través de <video> elemento. La mayoría de los navegadores también tienen acceso a cámaras web a través del MediaDevices.getUserMedia() API. Pero incluso con esas dos cosas combinadas, realmente no podemos acceder y manipular esos píxeles directamente.

Afortunadamente, los navegadores tienen una API de Canvas que nos permite dibujar gráficos usando JavaScript. De hecho, podemos dibujar imágenes en el <canvas> del propio vídeo, lo que nos da la posibilidad de manipular y jugar con esos píxeles.

Todo lo que aprenda aquí sobre cómo manipular píxeles le brindará una base para trabajar con imágenes y videos de cualquier tipo o fuente, no solo lienzos.

Agregar una imagen al lienzo

Antes de comenzar a jugar con el video, veamos cómo agregar una imagen al lienzo.

<img id="SourceImage" src="https://css-tricks.com/manipulating-pixels-using-canvas/image.jpg">
<div class="video-container"></div>

Creamos un elemento de imagen que representa la imagen que se va a dibujar en el lienzo. Alternativamente, podríamos usar el Image objeto en JavaScript.

var canvas;
var context;

function init() {
  var image = document.getElementById('SourceImage');
  canvas = document.getElementById('Canvas');
  context = canvas.getContext('2d');

  drawImage(image);
  // Or
  // var image = new Image();
  // image.onload = function () {
  //    drawImage(image);
  // }
  // image.src="https://css-tricks.com/manipulating-pixels-using-canvas/image.jpg";
}

function drawImage(image) {
  // Set the canvas the same width and height of the image
  canvas.width = image.width;
  canvas.height = image.height;

  context.drawImage(image, 0, 0);
}

window.addEventListener('load', init);

El código anterior dibuja la imagen completa en el lienzo.

¡Ahora podemos empezar a jugar con esos píxeles!

Actualización de los datos de la imagen

Los datos de la imagen en el lienzo nos permiten manipular y cambiar los píxeles.

La propiedad de datos es una ImageData objeto con tres propiedades: el width, height y data/ todos los cuales representan esas cosas basadas en la imagen original. Todas estas propiedades son readonly. El que nos importa es data, n arreglo unidimensional representado por un Uint8ClampedArray objeto, que contiene los datos de cada píxel en formato RGBA.

Aunque el data la propiedad es readonly, no significa que no podamos cambiar su valor. Significa que no podemos asignar otra matriz a esta propiedad.

// Get the canvas image data
var imageData = context.getImageData(0, 0, canvas.width, canvas.height);

image.data = new Uint8ClampedArray(); // WRONG
image.data[1] = 0; // CORRECT

¿Qué valores tiene el Uint8ClampedArray objeto representan, usted puede pedir. Aquí está la descripción de MDN:

El Uint8ClampedArray matriz tipada representa una matriz de enteros sin signo de 8 bits sujetos a 0-255; si especificó un valor que está fuera del rango de [0,255], se establecerá 0 o 255 en su lugar; si especifica un valor no entero, se establecerá el entero más cercano. Los contenidos se inicializan a 0. Una vez establecido, puede hacer referencia a elementos en la matriz usando los métodos del objeto o usando la sintaxis de índice de matriz estándar (es decir, usando la notación de corchetes)

En resumen, esta matriz almacena valores que van de 0 a 255 en cada posición, lo que la convierte en la solución perfecta para el formato RGBA, ya que cada parte está representada por valores de 0 a 255.

colores RGBA

Los colores se pueden representar en formato RGBA, que es una combinación de rojo, verde y azul. El A representa el valor alfa que es la opacidad del color.

Cada posición en la matriz representa un valor de canal de color (píxel).

  • La primera posición es el valor rojo
  • La segunda posición es el valor verde
  • La tercera posición es el valor azul
  • La cuarta posición es el valor Alfa
  • La quinta posición es el siguiente valor de píxel rojo
  • La sexta posición es el siguiente valor de píxel verde
  • La séptima posición es el siguiente valor de píxel azul
  • La octava posición es el siguiente valor alfa de píxel
  • Y así…

Si tiene una imagen de 2 × 2, entonces tenemos una matriz de 16 posiciones (valor de 2 × 2 píxeles × 4 cada uno).

La imagen 2×2 ampliada de cerca

La matriz se representará como se muestra a continuación:

// RED                 GREEN                BLUE                 WHITE
[ 255, 0, 0, 255,      0, 255, 0, 255,      0, 0, 255, 255,      255, 255, 255, 255]

Cambiar los datos de píxeles

Una de las cosas más rápidas que podemos hacer es establecer todos los píxeles en blanco cambiando todos los valores RGBA a 255.

// Use a button to trigger the "effect"
var button = document.getElementById('Button');

button.addEventListener('click', onClick);

function changeToWhite(data) {
  for (var i = 0; i < data.length; i++) {
    data[i] = 255;
  }
}

function onClick() {
  var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
  
  changeToWhite(imageData.data);

  // Update the canvas with the new data
  context.putImageData(imageData, 0, 0);
}

El data se pasará como referencia, lo que significa que cualquier modificación que le hagamos cambiará el valor del argumento pasado.

Invertir colores

Un buen efecto que no requiere mucho cálculo es invertir los colores de una imagen.

La inversión de un valor de color se puede hacer usando el operador XOR (^) o esta fórmula 255 - value (el valor debe estar entre 0-255).

function invertColors(data) {
  for (var i = 0; i < data.length; i+= 4) {
    data[i] = data[i] ^ 255; // Invert Red
    data[i+1] = data[i+1] ^ 255; // Invert Green
    data[i+2] = data[i+2] ^ 255; // Invert Blue
  }
}

function onClick() {
  var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
  
  invertColors(imageData.data);

  // Update the canvas with the new data
  context.putImageData(imageData, 0, 0);
}

Estamos incrementando el ciclo en 4 en lugar de 1 como lo hicimos antes, por lo que podemos de píxel a píxel que cada uno llene 4 elementos en la matriz.

El valor alfa no tiene efecto en la inversión de colores, por lo que lo omitimos.

Brillo y contraste

El ajuste del brillo de una imagen se puede hacer usando la siguiente fórmula: newValue = currentValue + 255 * (brightness / 100).

  • brightness debe estar entre -100 y 100
  • currentValue es el valor de luz actual de rojo, verde o azul.
  • newValue es el resultado de la luz de color actual más brightness

El ajuste del contraste de una imagen se puede hacer con esta fórmula:

factor = (259 * (contrast + 255)) / (255 * (259 - contrast))
color = GetPixelColor(x, y)
newRed   = Truncate(factor * (Red(color)   - 128) + 128)
newGreen = Truncate(factor * (Green(color) - 128) + 128)
newBlue  = Truncate(factor * (Blue(color)  - 128) + 128)

El cálculo principal es obtener el factor de contraste que se aplicará a cada valor de color. Truncate es una función que asegura que el valor permanezca entre 0 y 255.

Escribamos estas funciones en JavaScript:

function applyBrightness(data, brightness) {
  for (var i = 0; i < data.length; i+= 4) {
    data[i] += 255 * (brightness / 100);
    data[i+1] += 255 * (brightness / 100);
    data[i+2] += 255 * (brightness / 100);
  }
}

function truncateColor(value) {
  if (value < 0) {
    value = 0;
  } else if (value > 255) {
    value = 255;
  }

  return value;
}

function applyContrast(data, contrast) {
  var factor = (259.0 * (contrast + 255.0)) / (255.0 * (259.0 - contrast));

  for (var i = 0; i < data.length; i+= 4) {
    data[i] = truncateColor(factor * (data[i] - 128.0) + 128.0);
    data[i+1] = truncateColor(factor * (data[i+1] - 128.0) + 128.0);
    data[i+2] = truncateColor(factor * (data[i+2] - 128.0) + 128.0);
  }
}

En este caso no necesita el truncateColor funcionan como Uint8ClampedArray truncará estos valores, pero por el bien de traducir el algoritmo, lo agregamos.

Una cosa a tener en cuenta es que, si aplica brillo o contraste, no hay forma de volver al estado anterior ya que los datos de la imagen se sobrescriben. Los datos de la imagen original deben almacenarse por separado como referencia si queremos restablecer el estado original. Será útil mantener la variable de imagen accesible para otras funciones, ya que puede usar esa imagen para volver a dibujar el lienzo con la imagen original.

var image = document.getElementById('SourceImage');

function redrawImage() {
  context.drawImage(image, 0, 0);
}

usando videos

Para que funcione con videos, vamos a tomar nuestro script de imagen inicial y código HTML y realizar algunos pequeños cambios.

HTML

Cambie el elemento Imagen con un elemento de video reemplazando esta línea:

<img id="SourceImage" src="https://css-tricks.com/manipulating-pixels-using-canvas/image.jpg">

…con este:

<video id="SourceVideo" src="https://css-tricks.com/manipulating-pixels-using-canvas/video.mp4" width="300" height="150"></video>

JavaScript

Reemplace esta línea:

var image = document.getElementById('SourceImage');

…con este:

var video = document.getElementById('SourceVideo');

Para comenzar a trabajar con el video, tenemos que esperar hasta que se pueda reproducir el video.

video.addEventListener('canplay', function () {
    // Set the canvas the same width and height of the video
    canvas.width = video.videoWidth;
    canvas.height = video.videoHeight;    
    
    // Play the video
    video.play();

    // start drawing the frames  
    drawFrame(video);
});

El evento canplay se activa cuando hay suficientes datos disponibles para que los medios se puedan reproducir, al menos durante un par de fotogramas.

No podemos ver ninguno de los videos que se muestran en el lienzo porque solo estamos mostrando el primer cuadro. debemos ejecutar drawFrame cada n milisegundos para mantenerse al día con la tasa de cuadros de video.

Dentro drawFrame nosotros llamamos drawFrame de nuevo cada 10 ms.

function drawFrame(video) {
  context.drawImage(video, 0, 0);
  
  setTimeout(function () {
    drawFrame(video);
  }, 10);
}

Después de ejecutar drawFrame, creamos un bucle ejecutando drawFrame cada 10 ms: tiempo suficiente para mantener el video sincronizado en el lienzo.

Agregar el efecto al video

Podemos usar la misma función que creamos antes para invertir colores:

function invertColors(data) {
  for (var i = 0; i < data.length; i+= 4) {
    data[i] = data[i] ^ 255; // Invert Red
    data[i+1] = data[i+1] ^ 255; // Invert Green
    data[i+2] = data[i+2] ^ 255; // Invert Blue
  }
}

Y añádelo a la drawFrame función:

function drawFrame(video) {
  context.drawImage(video, 0, 0);
  
  var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
  invertColors(imageData.data);
  context.putImageData(imageData, 0, 0);
  
  setTimeout(function () {
    drawFrame(video);
  }, 10);
}

Podemos agregar un botón y alternar los efectos:

function drawFrame(video) {
  context.drawImage(video, 0, 0);
  
  if (applyEffect) {
    var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
    invertColors(imageData.data);
    context.putImageData(imageData, 0, 0);
  }
  
  setTimeout(function () {
    drawFrame(video);
  }, 10);
}

usando la cámara

Vamos a mantener el mismo código que usamos para el video con la única diferencia de que vamos a cambiar la transmisión de video de un archivo a la transmisión de la cámara usando MediaDevices.getUserMedia

Dispositivos multimedia.getUserMedia es la nueva API, en desuso de la API anterior Navegador.getUserMedia. Todavía hay soporte de navegador para la versión anterior y algunos navegadores no son compatibles con la nueva versión y tenemos que recurrir a polyfill para asegurarnos de que el navegador sea compatible con uno de ellos.

Primero, quite el src atributo del elemento de video:

<video id="SourceVideo" width="300" height="150"></video>
// Set the source of the video to the camera stream
function initCamera(stream) {
  video.srcObject = stream;
}

if (navigator.mediaDevices.getUserMedia) {
  navigator.mediaDevices.getUserMedia({video: true, audio: false})
    .then(initCamera)
    .catch(console.error)
  );
}

Demo en vivo

Efectos

Todo lo que hemos cubierto hasta ahora es la base que necesitamos para crear diferentes efectos en un video o una imagen. Hay un montón de efectos diferentes que podemos usar transformando cada color de forma independiente.

escala de grises

La conversión de un color a una escala de grises se puede hacer de diferentes maneras utilizando diferentes fórmulas/técnicas, para evitar profundizar demasiado en el tema, le mostraré cinco de las fórmulas basadas en la herramienta de desaturación de GIMP y Luma:

Gray = 0.21R + 0.72G + 0.07B // Luminosity
Gray = (R + G + B) ÷ 3 // Average Brightness
Gray = 0.299R + 0.587G + 0.114B // rec601 standard
Gray = 0.2126R + 0.7152G + 0.0722B // ITU-R BT.709 standard
Gray = 0.2627R + 0.6780G + 0.0593B // ITU-R BT.2100 standard

Lo que queremos encontrar usando estas fórmulas es el nivel de intensidad de brillo de cada color de píxel. El valor oscilará entre 0 (negro) y 255 (blanco). Estos valores crearán un efecto de escala de grises (blanco y negro).

Esto significa que el color más brillante será el más cercano a 255 y el color más oscuro será el más cercano a 0.

Demo en vivo

Duotonos

La diferencia entre el efecto de duotono y el efecto de escala de grises son los dos colores que se utilizan. En escala de grises, tiene un degradado de negro a blanco, mientras que en duotono puede tener un degradado de cualquier color a cualquier otro color, azul a rosa como ejemplo.

Usando el valor de intensidad de la escala de grises, podemos reemplazar esto de los valores de gradiente.

Necesitamos crear un gradiente de ColorA a ColorB.

function createGradient(colorA, colorB) {   
  // Values of the gradient from colorA to colorB
  var gradient = [];
  // the maximum color value is 255
  var maxValue = 255;
  // Convert the hex color values to RGB object
  var from = getRGBColor(colorA);
  var to = getRGBColor(colorB);
  
  // Creates 256 colors from Color A to Color B
  for (var i = 0; i <= maxValue; i++) {
    // IntensityB will go from 0 to 255
    // IntensityA will go from 255 to 0
    // IntensityA will decrease intensity while instensityB will increase
    // What this means is that ColorA will start solid and slowly transform into ColorB
    // If you look at it in other way the transparency of color A will increase and the transparency of color B will decrease
    var intensityB = i;
    var intensityA = maxValue - intensityB;
    
    // The formula below combines the two color based on their intensity
    // (IntensityA * ColorA + IntensityB * ColorB) / maxValue
    gradient[i] = {
      r: (intensityA*from.r + intensityB*to.r) / maxValue,
      g: (intensityA*from.g + intensityB*to.g) / maxValue,
      b: (intensityA*from.b + intensityB*to.b) / maxValue
    };
  }

  return gradient;
}

// Helper function to convert 6digit hex values to a RGB color object
function getRGBColor(hex)
{
  var colorValue;

  if (hex[0] === '#') {
    hex = hex.substr(1);
  }
  
  colorValue = parseInt(hex, 16);
  
  return {
    r: colorValue >> 16,
    g: (colorValue >> 8) & 255,
    b: colorValue & 255
  }
}

En resumen, estamos creando una matriz de valores de color desde el Color A, disminuyendo la intensidad mientras vamos al Color B y aumentando su intensidad.

Desde #0096ff a #ff00f0
Representación ampliada de la transición de color

var gradients = [
  {r: 32, g: 144, b: 254},
  {r: 41, g: 125, b: 253},
  {r: 65, g: 112, b: 251},
  {r: 91, g: 96, b: 250},
  {r: 118, g: 81, b: 248},
  {r: 145, g: 65, b: 246},
  {r: 172, g: 49, b: 245},
  {r: 197, g: 34, b: 244},
  {r: 220, g: 21, b: 242},
  {r: 241, g: 22, b: 242},
];

Arriba hay un ejemplo de un degradado de 10 valores de colores de #0096ff a #ff00f0.

Representación en escala de grises de la transición de color.

Ahora que tenemos la representación en escala de grises de la imagen, podemos usarla para asignarla a los valores de gradiente de duotono.

El degradado de duotono tiene 256 colores, mientras que la escala de grises también tiene 256 colores que van desde el negro (0) hasta el blanco (255). Eso significa que un valor de color en escala de grises se asignará a un índice de elemento degradado.

var gradientColors = createGradient('#0096ff', '#ff00f0');
var imageData = context.getImageData(0, 0, canvas.width, canvas.height);
applyGradient(imageData.data);

for (var i = 0; i < data.length; i += 4) {
  // Get the each channel color value
  var redValue = data[i];
  var greenValue = data[i+1];
  var blueValue = data[i+2];

  // Mapping the color values to the gradient index
  // Replacing the grayscale color value with a color for the duotone gradient
  data[i] = gradientColors[redValue].r;
  data[i+1] = gradientColors[greenValue].g;
  data[i+2] = gradientColors[blueValue].b;
  data[i+3] = 255;
}

Demo en vivo

Conclusión

Este tema puede profundizar más o explicar más efectos. La tarea para usted es encontrar diferentes algoritmos que pueda aplicar a estos ejemplos de esqueleto.

Saber cómo se estructuran los píxeles en un lienzo le permitirá crear una cantidad ilimitada de efectos, como sepia, combinación de colores, un efecto de pantalla verde, parpadeo/fallo de la imagen, etc.

Incluso puede crear efectos sobre la marcha sin usar una imagen o un video: