En este post de programación con JavaScript te enseñaré una herramienta para que puedas cambiar el tono de cualquier imagen. Vas a poder convertir la imagen a escala de grises, a blanco y negro o a blanco y negro con dithering, todo directamente desde la web usando un navegador.

Captura de pantalla de la herramienta online para convertir imágenes a blanco y negro, escala de grises y B&N con Dithering

Puedes probar el conversor de imágenes b/n dithering ya mismo, no necesitas conocimientos de programación para usarlo, simplemente elige tu imagen y el modo: Blanco y negro con dithering opcional o escala de grises.

El conversor de tonos de imagen permite seleccionar cualquier imagen en formato PNG, WEBP, AVIF y JPG, además de exportar dicha imagen en cualquiera de los 4 formatos incluso si el formato original no es el mismo.

A continuación te explicaré el código de JavaScript que permite aplicar Dithering a una imagen así como convertirla simplemente a blanco y negro o a escala de grises.

¿Para qué sirve Dithering?

El algoritmo Floyd-Steinberg Dithering permite convertir una imagen a blanco y negro pero conservando detalles de iluminación, haciendo que parezca casi como en escala de grises.

Yo descubrí que este algoritmo existía cuando estaba buscando cómo imprimir una imagen en una impresora térmica (donde solo hay puntos negros y blancos) manteniendo sus detalles visuales.

Ya he explicado cómo funciona este algoritmo, y también he demostrado las diferencias en mi post sobre Dithering en Golang

Obtener pixeles de imagen con JavaScript

Ya hemos tocado el tema de cómo recorrer los pixeles de una imagen con JavaScript, así que lo hacemos como lo indica el post.

JavaScript me va a devolver un ImageData que contiene la propiedad data. Esa data es un Uint8ClampedArray plano, no bidimensional. Cada elemento dentro del arreglo no es un pixel, es un nivel de color.

Por ejemplo, si nuestra imagen solo fuera de un pixel, se vería así:

[255,123,220,255]

Ya que es:

[R,G,B,A]

Si tuviera 2 pixeles:

[255,123,220,255,122,90,55,255]

Ya que sería:

[R,G,B,A,R,G,B,A]

Cada pixel ocupa 4 elementos dentro del arreglo. La siguiente función de JavaScript será invocada cuando se seleccione alguna imagen.

La parte relevante es cuando declaramos imagen accediendo al índice 0 (primer elemento) de la propiedad files del input de tipo file, la convertimos a bitmap con createImageBitmap y luego la pintamos tanto en el canvas original para mostrar una vista previa de la imagen original así como en el canvas donde vamos a hacer las modificaciones.


const CANTIDAD_NIVELES_POR_PIXEL = 4;
const generarImagenSegunOpcionesElegidas = async () => {
  const archivos = $imagen.files;
  if (archivos.length <= 0) {
    return;
  }
  const imagen = archivos[0];
  const imagenComoBitmap = await createImageBitmap(imagen);
  pintarImagenOriginal(imagenComoBitmap);
  $canvasResultado.width = imagenComoBitmap.width;
  $canvasResultado.height = imagenComoBitmap.height;
  const contexto = $canvasResultado.getContext("2d");
  contexto.drawImage(imagenComoBitmap, 0, 0);
  const imageData = contexto.getImageData(0, 0, imagenComoBitmap.width, imagenComoBitmap.height);
  let pixeles = [];
  if ($color.value === "grises") {
    pixeles = modificarParaEscalaDeGrises(imageData.data);
  } else if ($color.value === "bn") {
    pixeles = modificarBN(modificarParaEscalaDeGrises(imageData.data), imagenComoBitmap.width, imagenComoBitmap.height);
  } else if ($color.value === "bndithering") {
    // Dithering necesita que ya esté en escala de grises
    pixeles = modificarParaDithering(modificarParaEscalaDeGrises(imageData.data), imagenComoBitmap.width, imagenComoBitmap.height);
  }
  contexto.putImageData(new ImageData(pixeles, imagenComoBitmap.width, imagenComoBitmap.height), 0, 0);
}

Es necesario pintar la imagen en el canvas $canvasResultado porque justamente para obtener los pixeles con JS necesitamos acceder a:

$canvasResultado.getContext("2d").getImageData()

Cosa que ya hacemos con contexto.getImageData()

Y una vez que ya tenemos los pixeles en un Uint8ClampedArray se lo pasamos a las funciones que los van a modificar para que estén en escala de grises, blanco y negro o b/n con Dithering

Toma en cuenta que esas 3 funciones van a recibir y devolver el arreglo de pixeles.

4 niveles por pixel

Es importante explicar lo de los niveles por pixel porque luego cuando accedamos a cada nivel haremos lo siguiente:

for (let indice = 0; indice < pixeles.length; indice += CANTIDAD_NIVELES_POR_PIXEL) {
    const rojo = pixeles[indice];
    const verde = pixeles[indice + 1];
    const azul = pixeles[indice + 2];
}

El nivel alfa estaría en indice + 3 pero no lo necesitamos.

Escala de grises con JS

Comenzamos con la función más importante: convertir una imagen en color a una imagen en escala de grises con JavaScript.

Esta es la primera función porque la conversión a blanco y negro así como la aplicación del algoritmo Floyd-Steinberg Dithering necesitan que los pixeles ya estén en escala de grises para saber si un pixel se debe convertir a negro o a blanco.


const modificarParaEscalaDeGrises = (pixeles) => {
  for (let indice = 0; indice < pixeles.length; indice += CANTIDAD_NIVELES_POR_PIXEL) {
    const rojo = pixeles[indice];
    const verde = pixeles[indice + 1];
    const azul = pixeles[indice + 2];
    const gris = 0.3 * rojo + 0.59 * verde + 0.11 * azul;
    pixeles[indice] = gris;
    pixeles[indice + 1] = gris;
    pixeles[indice + 2] = gris;
  }
  return pixeles;
}

Aplicamos la fórmula de luminancia para calcular el nivel de gris en ese pixel:

nivel_gris = 0.3 * rojo + 0.59 * verde + 0.11 * azul

Y luego modificamos los pixeles para que en lugar del RGB originales tengan dicho nivel de gris en los 3 niveles:

pixeles[indice] = gris;
pixeles[indice + 1] = gris;
pixeles[indice + 2] = gris;

Algoritmo blanco y negro en JavaScript

Ahora que ya hemos convertido una imagen a escala de grises con JavaScript veamos cómo convertir ese montón de pixeles a blanco y negro. Aquí el proceso se simplifica pues una vez que tenemos el nivel de gris evaluamos si está más cerca del negro o más cerca del blanco.

El algoritmo que yo uso es:

  • Si el nivel de gris es mayor a 128 entonces está más cerca del blanco que es 255, así que convierto ese pixel a blanco

Por otro lado, si el nivel de gris es menor o igual que 128 evalúo también el nivel alfa:

  • Si el nivel alfa es menor a 128 el pixel también será blanco
  • De lo contrario, el pixel será negro

Así que en resumen el pixel será negro solo si el nivel de gris es menor o igual a 128 y el nivel alfa es mayor o igual que 128.

const modificarBN = (pixeles) => {
  for (let indice = 0; indice < pixeles.length; indice += CANTIDAD_NIVELES_POR_PIXEL) {
    // Hasta este punto los niveles RGB ya están igualados y da igual a cuál de ellos accedamos, todos tienen el mismo nivel de gris
    const gris = pixeles[indice];
    const alfa = pixeles[indice + 3];
    if (gris > 128) {
      // Todo es blanco
      pixeles[indice] = 255;
      pixeles[indice + 1] = 255;
      pixeles[indice + 2] = 255;
    } else {
      if (alfa < 128) {
        // Todo es blanco
        pixeles[indice] = 255;
        pixeles[indice + 1] = 255;
        pixeles[indice + 2] = 255;
      } else {
        // Todo es negro
        pixeles[indice] = 0;
        pixeles[indice + 1] = 0;
        pixeles[indice + 2] = 0;
      }
    }
  }
  return pixeles;
}

Toma en cuenta que este algoritmo es el que yo uso tomando en cuenta el nivel alfa que se traduce a la transparencia de dicho pixel. Puedes modificarlo como desees.

Dithering con JavaScript

Finalmente para aplicar el algoritmo Floyd-Steinberg Dithering en JavaScript del lado del cliente necesitamos igualmente que los pixeles ya estén en escala de grises y aplicamos el mismo algoritmo que teníamos en Go

Primero veamos la función que transforma los pixeles en escala de grises a blanco y negro con dithering:


const modificarParaDithering = (pixelesEscalaGrises, ancho, alto) => {

  for (let indice = 0; indice < pixelesEscalaGrises.length; indice += CANTIDAD_NIVELES_POR_PIXEL) {
    // Hasta este punto los niveles RGB ya están igualados y da igual a cuál de ellos accedamos, todos tienen el mismo nivel de gris
    const gris = pixelesEscalaGrises[indice];
    const alfa = pixelesEscalaGrises[indice + 3];
    let nivel = 255;
    if (gris < 128 && alfa > 128) {
      nivel = 0;
    }
    pixelesEscalaGrises[indice] = nivel;
    pixelesEscalaGrises[indice + 1] = nivel;
    pixelesEscalaGrises[indice + 2] = nivel;
    const errorDeCuantificacion = gris - nivel;
    propagarError(pixelesEscalaGrises, indice, errorDeCuantificacion, ancho, alto)
  }
  return pixelesEscalaGrises;
}

Como puedes ver es muy parecido al de la conversión a blanco y negro, con el detalle de que ahora propagamos el error de cuantificación:


const propagarError = (pixelesEscalaGrises, indice, errorDeCuantificacion, ancho, alto) => {
  /*
          pixels[x + 1][y    ] := pixels[x + 1][y    ] + quant_error × 7 / 16
          pixels[x - 1][y + 1] := pixels[x - 1][y + 1] + quant_error × 3 / 16
          pixels[x    ][y + 1] := pixels[x    ][y + 1] + quant_error × 5 / 16
          pixels[x + 1][y + 1] := pixels[x + 1][y + 1] + quant_error × 1 / 16
      */
  //pixels[x + 1][y    ] := pixels[x + 1][y    ] + quant_error × 7 / 16
  const [x, y] = convertirIndiceDePixelACoordenadas(indice, ancho);
  if (x < ancho - 1) {
    for (let i = 0; i < 3; i++) {
      pixelesEscalaGrises[indice + CANTIDAD_NIVELES_POR_PIXEL + i] += errorDeCuantificacion * 7 / 16
    }
  }

  //pixels[x - 1][y + 1] := pixels[x - 1][y + 1] + quant_error × 3 / 16
  if (x > 0) {
    for (let i = 0; i < 3; i++) {
      pixelesEscalaGrises[indice - CANTIDAD_NIVELES_POR_PIXEL + (ancho * CANTIDAD_NIVELES_POR_PIXEL) + i] += errorDeCuantificacion * 3 / 16
    }
  }
  //pixels[x    ][y + 1] := pixels[x    ][y + 1] + quant_error × 5 / 16
  if (y < alto - 1) {
    for (let i = 0; i < 3; i++) {
      pixelesEscalaGrises[indice + (ancho * CANTIDAD_NIVELES_POR_PIXEL) + i] += errorDeCuantificacion * 5 / 16
    }
  }

  //pixels[x + 1][y + 1] := pixels[x + 1][y + 1] + quant_error × 1 / 16
  if (y < alto - 1 && x < ancho - 1) {
    for (let i = 0; i < 3; i++) {
      pixelesEscalaGrises[indice + (ancho * CANTIDAD_NIVELES_POR_PIXEL) + CANTIDAD_NIVELES_POR_PIXEL + i] += errorDeCuantificacion * 1 / 16
    }
  }
}

Aquí me hizo falta convertir el índice del pixel en coordenadas, pues así se me hizo más fácil. Los ciclos for que puedes ver son para establecer el mismo valor en los niveles RGB.

Colocando pixeles después

Sin importar la conversión que hayamos aplicado a la imagen, debemos colocar el resultado en el canvas en donde se muestra la imagen transformada.

Para ello una vez que tenemos los pixeles (B/N, grises o con dithering) simplemente invocamos a putImageData creando un nuevo ImageData a partir de los pixeles:

contexto.putImageData(new ImageData(pixeles, imagenComoBitmap.width, imagenComoBitmap.height), 0, 0);

Eso hará que el usuario vea cómo se transformó la imagen original, además de que nos sirve a nosotros como programadores para, más adelante, poder descargar dicho canvas como imagen JPEG, PNG, AVIF o WEBP.

Descargando resultado

Simplemente convertimos el canvas de resultado a base64, lo colocamos en un enlace y hacemos clic en él.

Para esto invocamos a toDataURL que recibe en primer lugar el tipo (aquí es donde especificamos el formato jpg, png, webp, avif) y en segundo lugar la calidad, misma que siempre es 1 indicando la máxima calidad.


$descargar.onclick = () => {
  let enlace = document.createElement('a');
  // El título
  enlace.download = obtenerNombreParaDescargar();
  // Convertir la imagen a Base64 y ponerlo en el enlace
  enlace.href = $canvasResultado.toDataURL($formato.value, 1);
  // Hacer click en él
  enlace.click();
}

El código completo está en GitHub y la demostración en el apartado de convertir imagen a grises, b&n y dithering con JS

Si el post ha sido de tu agrado te invito a que me sigas para saber cuando haya escrito un nuevo post, haya actualizado algún sistema o publicado un nuevo software. Facebook | X | Instagram | Telegram | También estoy a tus órdenes para cualquier contratación en mi página de contacto