Peleándome un poco con los canvas de JavaScript, estuve viendo que era posible extraer la información de color de cada pixel. Por lo que pensé ¿y si convierto esa información de color en un carácter? Pues dicho y hecho, en este artículo vamos a ver el código de un proyecto de ejemplo (del que podéis ver todo su código en Github), y que es capaz de transformar una fotografía en un conjunto de caracteres.

Por ejemplo, gracias a él he podido convertir una foto con muchos años en la que salimos mi hermana y yo, en lo siguiente:

Además, es posible escoger un conjunto personalizado de caracteres, así como el número de caracteres utilizado como ancho y como alto de la imagen convertida. Pero, dejo de enrollarme y pasamos a ver cómo funciona todo esto.

Dentro del código

El código está escrito en TypeScript, pero vamos, cualquier persona que sólo conozca JavaScript no creo que tenga problemas para entenderlo.

Comenzaremos por la parte de más bajo nivel. Lo primero que necesitamos es una de las varias fórmulas que hay para convertir los colores RGB a un tono de gris, para ello tenemos la función siguiente:

function rgbToGray(red: number, green: number, blue: number): number {
  // Fórmula extraída de: https://en.wikipedia.org/wiki/Grayscale#Converting_color_to_grayscale
  return 0.299 * red + 0.587 * green + 0.114 * blue;
}

Esta función la utilizaremos en otra en la que obtendremos el tono de gris de un pixel en concreto dentro de un canvas:

function pixelToGrayValue(context: CanvasRenderingContext2D, x: number, y: number): number {
  const pixelInformation = context.getImageData(x, y, 1, 1).data;
  return rgbToGray(pixelInformation[0], pixelInformation[1], pixelInformation[2]);
}

Seguimos subiendo otro nivel, y ahora lo que vamos a ver es la función que hace el trabajo de convertir todos los píxeles de un canvas a letras. Para ello incluyo comentarios en el código que os puedan ayudar a la comprensión:

// Representa el número de tonos de gris posibles por cada porción RGB.
const GRAY_RANGE: number = 256;

function canvasToString(canvas: HTMLCanvasElement, characters: string[]): string {
  // Lo primero que necesitamos del canvas es su contexto para poder leer la información, así
  // como su ancho y alto, que utilizaremos para hacer el recorrido pixel a pixel.
  const context: CanvasRenderingContext2D = canvas.getContext("2d");
  const { width, height } = canvas;

  // En la variable range, almacenaremos el número de tonos grises que albergará cada carácter.
  const range: number = GRAY_RANGE / characters.length;

  let text: string = "";

  // Un recorrido sencillo línea a línea y luego columna a columna. Seguramente estaréis pensando
  // que tener dos niveles de indentación en una función no mola. Pero al final esto es un ejemplo :P
  for (let y = 0; y < height; ++y) {
    for (let x = 0; x < width; ++x) {
      // Obtenermos el tono de gris que corresponde al pixel actual.
      const grayValue: number = pixelToGrayValue(context, x, y);

      // Aquí buscamos el carácter que tocaría mostrar según el tono de gris.
      text += characters[Math.floor(grayValue / range)];
    }

    // Al final de cada línea añadimos una nueva.
    text += "\n";
  }

  return text;
}

La función anterior es la que realiza todo el trabajo, y espero haber incluido comentarios descriptivos que os hayan podido ayudar a comprenderla. De todas formas, para cualquier duda podéis escribir en los comentarios.

Para terminar tenemos otras tres funciones que se encargan de obtener el canvas que usaremos para pintar la imagen temporalmente antes de convertirla:

export function imageToString(
	imageElement: HTMLImageElement, columns: number, rows: number,
	charactersList: string[] = DEFAULT_CHARACTERS
): string {
  // Creamos un canvas donde guardaremos la imagen.
  const canvas: HTMLCanvasElement = createCanvas(columns, rows);
	
  // "Pintamos" la imagen en el canvas.
  loadImageOnCanvas(canvas, imageElement);

  // Convertimos los pixels del canvas en caracters (como vimos antes).
  const asciiImage: string = canvasToString(canvas, charactersList)

  // Es importante borrar la basurilla que vamos creando.
  canvas.remove();

  return asciiImage;
}

// Esta función no tiene mucho misterior, pues simplemente crea un canvas con las dimensiones
// especificadas.
function createCanvas(width: number, height: number): HTMLCanvasElement {
  const canvas: HTMLCanvasElement = document.createElement("canvas");

  canvas.width = width;
  canvas.height = height;

  return canvas;
}

// Carga un elemento <img /> en el canvas.
function loadImageOnCanvas(canvas: HTMLCanvasElement, imageElement: HTMLImageElement): void {
  const context = canvas.getContext("2d");
  context.drawImage(imageElement, 0, 0, canvas.width, canvas.height);
}

Resumiendo, lo que hemos hecho primero es volcar una imagen cualquiera a un elemento canvas. Y dicho elemento lo hemos aprovechado para leer cada pixel y así convertirlo primero a un tono de gris, y de ahí, luego lo hemos convertido a un carácter.

Como conjunto de caracteres base, la función usa los siguientes si no se define ninguno:

const DEFAULT_CHARACTERS: string[] = ["@", "M", "#", "A", "3", "?", "*", "-", " "];

Estos vendrán ordenados del que represente tonos más oscuros, a tonos más claros.

De regalo...

Como extra, parte del código anterior podría servirnos para hacer una copia en blanco y negro de una imagen. Para ello, podemos crear un canvas con las mismas dimensiones y por cada pixel podríamos hacer lo siguiente:

function canvasToString(canvas: HTMLCanvasElement, characters: string[]): string {
...
  // Si volvemos a la parte en la que obteníamos el tono de gris:
  const grayValue: number = pixelToGrayValue(context, x, y);
  // Podemos ahora convertir a un hexadecimal el tono:
  const hexColor = toHexadecimalString(grayValue);
  // Para finalmente pintar el píxel en el contexto del canvas "copia":
  drawPixel(contextCopy, x, y, "#" + hexColor + hexColor + hexColor);
  ...
}

// Este método tan sólo pinta un píxel en un canvas.
function drawPixel(context, x, y, color) {
  context.beginPath();
  context.fillStyle = color;
  context.fillRect(x, y, 1, 1);
  context.fill();
}

// Este método convierte el tono de gris (puede tener decimales de ahí el Math.floor), a
// un valor hexadecimal. Si queda con unidades le añade el "0" de decenas.
function toHexadecimalString(value) {
  const roundedValue = Math.floor(value);
  const stringValue = roundedValue.toString(16);

  if (stringValue.length === 1) {
    return "0" + stringValue;
  }

  return stringValue;
}

Terminando...

Espero que este código os haya servido como curiosidad, y estáis invitados a sugerir cualquier mejora, avisar de cualquier error, trastear con el código, probar vuestros propios ajustes de caracteres, etc. Y cualquier comentario siempre será bienvenido. ¡Hasta la próxima! 🖖

Comparte este artículo con quien quieras
async y await: la magia del código asíncrono en JavaScript
Curiosidades del objeto global console

Leave a Comment

Your email address will not be published. Required fields are marked *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.