Convirtiendo el input de una webcam en ASCII art

26 de abril de 2022

Night lights by Benjamin Wong
Photo by Benjamin Wong on Unsplash

Cada cierto tiempo, y como modo de “relajación”, tengo la oportunidad de trabajar en retos aleatorios de tecnología utilizando código. En esta ocasión, el reto era bastante interesante y bsucaba convertir una entrada de vídeo, proveniente de la webcam, en un ASCII art.

El arte ASCII es:

Arte ASCII (pronunciado arte áski), es un medio artístico que utiliza recursos computarizados fundamentados en los caracteres de impresión del Código Estándar Estadounidense de Intercambio de Información.

Es decir, y en palabras mucho más simples, es la forma en que podemos crear un medio artístico a partir de solo caracteres de texto.

El modelo inicial lo realicé con Python y utilizando librerías como pygame, numpy, cv2 y otras, pero para este post me interesé en hacer lo mismo utilizando solamente JavaScript.

#.Los conceptos preliminares: ¿qué es una imagen y como se representan los colores de un pixel?

Empecemos por el concepto detrás de esta idea. Cada imagen o fotografía está compuesta, en realidad, por un grupo de pixeles. De hecho, por si no lo sabían, “pixel” viene de picture element”.

Cada uno de esos pixeles está asociado a un color, que a su vez se representa en la paleta RGB (Red Green Blue / Rojo Verde Azul) con un código de tres números. Cada uno de esos números puede ir de 0 a 255 (es decir, pueden ser 256 valores).

Un código RGB se podría ver, en notación decimal, de la siguiente forma: 0 0 255.

  • Los primeros 3 números representarían el color Rojo (Red)
  • Los segundos 3 números representarían el color Verde (Green)
  • Los últimos 3 números representarían el color Azul (Blue)

Por lo tanto, el código mostrado anteriormente (0 0 255) representaría un pixel de color completamente azul. Si todos los números son 0, quiere decir que es negro. Si todos son 255, quiere decir que es blanco. Estos colores también se pueden representar en hexadecimal y esos son los que vemos como #ffffff (hexadecimal), que quiere decir 255 255 255 (en decimal), pero eso es otra historia que no vamos a contar ahora mismo.

Lo importante de esto es: entre más grande es la serie de colores, más brillante es el pixel. Esto es importante, ya que vamos a utilizarlo como base muy pronto.

Tomando esto en cuenta, una forma muy pero muy simple para obtener el brillo de un pixel es calculando el promedio de esa serie de 3 números decimales.

Es decir, si tenemos:

255 200 200

Podemos calcular el promedio de:

(255 + 200 + 200) / 3 = 218.33333333

Y ese valor, 218.33333333, podríamos usarlo para representar el brillo de ese pixel. Como está muy cerca de 255, es un color bastante brillante, de hecho se ve como el fondo de este texto.

#.Contrastes, el brillo y referencias a un caracter

Bien. Ya sabemos como podemos sacar el brillo de un pixel, por ende podemos sacar el brillo de cada elemento de la imagen (recuerden una imagen = muchos pixeles). Ahora, ¿cómo lo volvemos texto?.

Ahora es importante el siguiente concepto. Cualquier elemento lo podríamos representar con dos colores básicos: el blanco y el negro, donde negro es nada de brillo y blanco es muchísimo brillo.

Gracias a esto podemos jugar con los contrastes, específicamente contrastes de luminosidad, que es la diferencia relativa entre dos puntos (o pixeles) en una imagen utilizando de referencia el valor del brillo.

Si hablamos en blanco y negro, todo puede ser en dos tonalidades (o es blanco o es negro). Si hablamos ahora de escalas de grises, el contraste puede ir de nada (0% de contraste, como cuando dos elementos son del mismo color) a completo (100% de contraste, como cuando un elemento es totalmente blanco y el otro totalmente negro).

De esta forma, si tenemos dos caracteres:

  • Caracter #1: █ (un bloque completamente negro)
  • Caracter #2: _ (un espacio en blanco)

Uno podría representar un pixel totalmente negro (o sin brillo/luminosidad) y el otro podría representar un pixel totalmente en blanco (o con brillo/luminosidad a tope). Y si los ponemos de forma contigua, tendríamos contraste completo entre ambos.

Por ende, lo que debemos intentar hacer es asignarle un caracter a cada posible valor numérico de ese valor de brillo, para así generar el contraste entre los caracteres y así representar el contraste de la imagen.

De hecho, aquí me encontré con una serie de caracteres que lo hacía bastante bien y se ve como lo siguiente:

'Ñ@#W$9876543210?!abc;:+=-,._ '

Siendo el primero valor, Ñ, aquel que representaría nada brillo y el último valor, un espacio en blanco, representaría el brillo total o completo.

En ese código también noté que el autor lo llamó “densidad”, lo cual es un nombre interesante, ya que podríamos decir que la misión de cada caracter es utilizar la mayor cantidad de espacio posible para representar esa área. Es decir, más densidad = menos brillo.

En palabras más sencillas y juntando la sección anterior con esta:

Si el valor promedio del brillo es 0, utilizaríamos la Ñ.

Si el valor promedio del brillo es 255, utilizaríamos el espacio en blanco. Cualquier valor entre 0 y 255 utilizaría un valor entre ellos dos.

#.Ok, ok, mucha plática. ¿Cómo se come esto?

Ya dije que vamos a utilizar JavaScript, porque podemos utilizar la librería p5.js. Esta librería es:

una librería para programación creativa, con enfoque para hacer la programación accesible e inclusiva para artistas, diseñadores, educadores, aprendices y cualquier otra persona (…)

(…) puedes utilizar elementos de HTML5 como texto, input, video, webcam y sonido.

La parte importante es que tenemos acceso a utilizar las características disponibles de vídeo y sonido de forma simple y fácil.

Vamos a comenzar con una página HTML básica y vamos a incluir p5.js.

#.Paso 1: Configurando el proyecto y desplegando una imagen

Primero, configuremos el proyecto. Para eso, vamos a crear una carpeta y vamos a la página de p5.js y descargamos la librería completa. Luego pasamos los archivos index.html, sketch.js y p5.min.js (la versión minificada) a nuestra carpeta:

mkdir video-to-ascii
cd video-to-ascii
cp ~/Downloads/p5/index.html .
cp ~/Downloads/p5/sketch.js .
cp ~/Downloads/p5/p5.min.js .

Lo primero es entender como funciona p5.js, pero para eso puedes leer esta guía. La librería espera tener dos funcines: setup y draw, por lo cual debemos tener ambas funciones en nuestro sketch.js.

Ahora vamos a hacer algo simple, utilizando todos los conceptos de los puntos anteriores: vamos a pasar una imagen plana a ASCII art. Vamos a separar la imagen en cuadrantes, representados por { x, y } o {i, j}, como ya veremos (donde i representa la posición en el plano x y j en el plano y).

Iterando entre las filas y columnas y calculando sus valores de RGB y Brillo

Por cada una de esas posiciones, calcularemos el valor RGB y además calcularemos ese valor de brillo o luminosidad. Es decir, cada cuadrante tendrá 6 valores:

  1. i: posición de la fila
  2. j: posición de la columna
  3. r: valor del rojo de ese pixel
  4. g: valor del verde de ese pixel
  5. b: valor del azul de ese pixel
  6. a (de aveage / promedio): valor del brillo / luminosidad de ese pixel

Nota: Te preguntarás ¿qué tiene que ver una imagen con un vídeo? Pues bien, el vídeo es simplemente una serie de imagenes (fotogramas) en secuencia. Lo que tendremos que hacer es analizar cada fotograma de forma independiente.

Para la prueba, nos vamos a Unsplash y descargamos cualquier foto, por ejemplo esta foto de un doggy tomada por Victor Grabarczyk.

Para poder cargar estos recursos en Chrome, podemos utilizar Web Server for Chrome o simplemente configurar un servidor con Express.

Utilizando p5.js, ahora podemos pre-cargar esa imagen utilizando la función preload y la función loadImage().

Ya luego podemos simplemente mostrar la imagen utilizando algo de código:

let img;
function preload() {
  img = loadImage("./dog.jpg");
}

function setup() {
  createCanvas(800, 533);
}

function draw() {
  background(220);
  image(img, 0, 0, width, height);
}

El resultado será bastante simple, por ahora:

Ahora nos propondremos transformar esta imagen a un arte con ASCII. Para que sea más cómodo, vamos a redimensionar la imagen del perrito a 50 pixeles de ancho.

Tenemos que:

  1. Cargar la imagen y los pixeles. Voy a explicar un poco sobre esto en breve.
  2. Iterar entre las columnas / filas.
  3. Obtener el indice (el valor {i, j}) y los otros 4 valores mencionados anteriormente (r, g, b, a).
  4. Determinar qué caracter le corresponde, en base a su brillo.

El código en cuestión quedaría así:

const density = "Ñ@#W$9876543210?!abc;:+=-,._ ";
let img;

function preload() {
  img = loadImage("./dog copy.jpg");
}

function setup() {
  noCanvas();
  img.loadPixels();

  // Analizamos cada columna
  for (let j = 0; j < img.height; j++) {
    let line = "";

    // Analizamos cada fila de esa columna
    for (let i = 0; i < img.width; i++) {
      // Es importante recalcar que, img.pixels va a regresar un array, mejor
      // descrito en la siguiente dirección:
      // https://p5js.org/reference/#/p5/pixels
      //
      // Donde:
      // 1. La densidad será x4, por eso debemos multiplicar *4
      // 2. Va a regresarse los valores de RGB (en la posición 0, 1 y 2)
      const index = (i + j * img.width) * 4;

      // Valores RGB y A (promedio / brillo / luminosidad)
      const r = img.pixels[index];
      const g = img.pixels[index+ 1];
      const b = img.pixels[index + 2];
      const a = (r + g + b) / 3;

      // Floor: Devuelve el máximo entero menor o igual a un número.
      // El `map` aquí va a hacer:
      // Tomar el valor del brillo (`a`), el cual debe tener un valor entre
      // 0 y 255 (atributo 1 y 2) y lo va a mapear esto a un valor entre la cantidad
      // de valores entre mi densidad y 0.
      const charIndex = floor(map(a, 0, 255, density.length, 0));
      const charToShow = density.split("")[charIndex];

      // no breaking space (espacio vacio)
      // esto es para que quede un cuadrado perfecto, ya que en HTML el espacio
      // vacío se representará como un eso, un espacio vacío.
      line += (charToShow === " " || charToShow === undefined) ? "&nbsp;" : charToShow;
    }

    createDiv(line);
  }
}

Si corremos este código, nos vamos a encontrar con este resultado, algo desalentador:

Metiéndole un poco de cabeza (mentira, luego de buscar como 30 minutos en Google 🤣), entendí que el problema es que la fuente por defecto no es del tipo monospaced fn-1, por lo que cada letra ocupa un espacio distinto y esto lo podemos corregir al utilizar una fuente de tipo monospaced, como “Courier” o, en mi caso “Fira Code” fn-2. Si no tienes “Fira Code”, simplemente utiliza “Courier” y problema resuelto.

Si además utilizamos un line-height menor al tamaño de la fuente, podemos hacer que los caracteres estén aún más pegados verticalmente, por lo que podemos incluir un CSS similar al siguiente:

html, body {
  background-color: #000;
  color: #fff;
  font-family: 'Fira Code';
  font-size: 1em;
  line-height: 0.8em;
}

canvas {
  display: block;
}

Y nuestro resultado quedará así:

Ahora sí se parece a nuestro amiguito de la foto 🐶🐶🐶.

#.Paso 2: Haciendo lo mismo, pero con la webcam como dispositivo de entrada

De hecho, esta parte es aún más simple. Debemos hacer lo mismo, pero analizando cada fotograma del vídeo por separado y tenemos que desplegar todo como un único contenido (con sus saltos de línea):

const density = "Ñ@#W$9876543210?!abc;:+=-,._ ";
let video;
let container;

function setup() {
  noCanvas();
  video = createCapture(VIDEO);
  video.size(80, 80);
  container = createDiv();
}

function draw() {
  video.loadPixels();

  // Ahora vamos a tener que desplegar todo como un único contenido
  let art = "";

  // Analizamos cada columna
  for (let j = 0; j < video.height; j++) {

    // Analizamos cada fila de esa columna
    for (let i = 0; i < video.width; i++) {
      // Es importante recalcar que, video.pixels va a regresar un array, mejor
      // descrito en la siguiente dirección:
      // https://p5js.org/reference/#/p5/pixels
      //
      // Donde:
      // 1. La densidad será x4, por eso debemos multiplicar *4
      // 2. Va a regresarse los valores de RGB (en la posición 0, 1 y 2)
      const index = (i + j * video.width) * 4;

      // Valores RGB y A (promedio / brillo / luminosidad)
      const r = video.pixels[index];
      const g = video.pixels[index+ 1];
      const b = video.pixels[index + 2];
      const a = (r + g + b) / 3;

      // Floor: Devuelve el máximo entero menor o igual a un número.
      // El `map` aquí va a hacer:
      // Tomar el valor del brillo (`a`), el cual debe tener un valor entre
      // 0 y 255 (atributo 1 y 2) y lo va a mapear esto a un valor entre la cantidad
      // de valores entre mi densidad y 0.
      const charIndex = floor(map(a, 0, 255, density.length, 0));
      const charToShow = density.split("")[charIndex];

      // no breaking space (espacio vacio)
      // esto es para que quede un cuadrado perfecto, ya que en HTML el espacio
      // vacío se representará como un eso, un espacio vacío.
      art += (charToShow === " " || charToShow === undefined) ? "&nbsp;" : charToShow;
    }

    // Y despues de cada línea, simplemente agregamos un salto de linea
    art += "<br />";
  }

  container.html(art);
}

Y el resultado se vería así, luego de cambiar el texto a color #64d86b:


  1. Las fuentes monospaced son fuentes en donde todos los caracteres ocupan el mismo espacio.
  2. Descargar Fira Code.

Profile picture

Escrito por Demóstenes García G. (@demogar) Ingeniero de Software y desarrollador web y móvil, basado en Ciudad de Panamá.