En este post de programación con Go te voy a enseñar cómo aplicar el algoritmo Floyd-Steinberg Dithering a cualquier imagen, de modo que puedas convertir una imagen de color completo a una en blanco y negro respetando la iluminación.
Para que tengas una idea de lo que hace este algoritmo, mira la siguiente comparación donde aparece la imagen a color, en blanco y negro sin dithering y finalmente en blanco y negro pero con dithering:
Como puedes ver, al aplicarle el dithering aparecen más detalles en la imagen, dando la ilusión de iluminación. De hecho pareciera que está en escala de grises, pero no, en este caso el dithering solo convierte la imagen a blanco y negro, nada más.
Veamos entonces cómo implementar este algoritmo Floyd-Steinberg Dithering con Golang a cualquier imagen ya sea PNG o JPG.
Explicación del algoritmo
El funcionamiento del dithering es convertir la imagen a otro nivel de colores, por ejemplo, imagen en color a imagen en blanco y negro. El tramado o dithering no solo hace la conversión, también propaga la diferencia de conversión entre el pixel original y el nuevo.
Veamos un ejemplo a continuación, y después veremos la implementación en el lenguaje de programación Go. Toma en cuenta que en este ejemplo vamos a convertir una imagen a color, a una imagen en blanco y negro, pero el tramado sirve para más paletas de colores.
Convertir a escala de grises
Primero necesitamos convertir la imagen en color a imagen en escala de grises. Necesitamos hacerlo con la imagen completa, ya que necesitamos que todos sus pixeles estén en un nivel de gris.
Para convertir a escala de grises supongamos que tenemos un pixel con el valor RGB en 200, 150, 168. Primero lo convertimos a grises, así que aplicamos la ecuación de la luminancia y queda en: 0.299*r+0.587*g+0.114*b
Lo que da como resultado: 59.8+88.05+19.152=167.002
, ahora aplicamos ese valor para los 3 niveles, por lo que el RGB que previamente era 200,150,168
se convierte en 167,167,167
, pues ya está en un gris.
Hacemos lo mismo por cada pixel existente en la imagen de modo que toda la imagen quede en escala de grises y cada pixel tenga un RGB igualado.
Convertir a blanco y negro
Una vez que tenemos el valor en gris de un pixel, vamos a convertirlo a blanco o negro. Para ello hacemos una simple conversión: si el gris es más blanco, entonces el pixel resultante será blanco (255 en RGB). Si no, negro (0 en RGB). Y para ello definimos un umbral que está a la mitad.
Debido a que RGB tiene valores del 0 al 255, podemos definir el umbral como 127. Si el valor del pixel es menor a 127, lo convertimos en negro. De lo contrario, lo convertimos en blanco.
Nota importante: en este ejemplo estamos convirtiendo una imagen de color completo a blanco y negro. El Dithering sirve para más paletas de colores, no solo para blanco y negro.
Calcular error de cuantificación
Siguiendo con nuestro ejemplo, teníamos el RGB en 200, 150, 168
. Se convirtió a 167,167,167
en grises y después, debido a que el 167 es mayor que 127, se convirtió en un pixel blanco (255). Si seguimos ese proceso por cada pixel, tendríamos la imagen en blanco y negro sin más; pero no todo termina ahí.
Justamente en este punto es donde aplicamos el Dithering, mismo que nos dice que debemos obtener la diferencia también llamada error de cuantificación, específicamente de la conversión entre el nivel de gris original y el nuevo nivel de blanco y negro.
Volvamos a los números de nuevo, teníamos el RGB 200, 150, 168
. Lo convertimos a 167, 167, 167
y luego a un 255, 255, 255
. El error de cuantificación es:
errorDeCuantificacion := nivelDeGrisOriginal - nivelBlancoONegro
Por lo que en este caso sería 167-255
, que da como resultado -88
.
Propagar error de cuantificación
Ese -88
es el error de cuantificación que debemos propagar a los pixeles vecinos siguiendo la siguiente distribución:
- imagen[x + 1][y ] := imagen[x + 1][y ] + error de cuantificación * 7 / 16
- imagen[x – 1][y + 1] := imagen[x – 1][y + 1] + error de cuantificación * 3 / 16
- imagen[x ][y + 1] := imagen[x ][y + 1] + error de cuantificación * 5 / 16
- imagen[x + 1][y + 1] := imagen[x + 1][y + 1] + error de cuantificación * 1 / 16
Por ejemplo, supongamos que estamos en el pixel 5,6 (x con valor de 5, y con valor de 6) y tenemos nuestro error de cuantificación en -88. Queda así:
- imagen[6][6] := imagen[6][6 ] + (-88 * 7 / 16)
- imagen[4][7] := imagen[4][7] + (-88 * 3 / 16)
- imagen[5 ][7] := imagen[5][7] + (-88 * 5 / 16)
- imagen[6][7] := imagen6][7] + (-88 * 1 / 16)
Es importante que recuerdes que en este caso la imagen ya debe estar en escala de grises porque vamos a sumar ese error de cuantificación a cada valor RGB, y se supone que para este punto cada pixel va a tener los mismos valores para R,G y B que va a ser un gris. Calculando tenemos:
- imagen[6][6] := imagen[6][6 ] + (-38.5)
- imagen[4][7] := imagen[4][7] + (-16.5)
- imagen[5 ][7] := imagen[5][7] + (-27.5)
- imagen[6][7] := imagen6][7] + (-5.5)
Supongamos que los valores de cada pixel vecino son los siguientes:
- imagen[6][6] = 20,20,20
- imagen[4][7] = 52,52,52
- imagen[5 ][7] = 96,96,96
- imagen[6][7] = 28,28,28
Al aplicar la fórmula queda así:
- imagen[6][6] := 20 + (-38.5)
- imagen[4][7] := 52 + (-16.5)
- imagen[5 ][7] :=96 + (-27.5)
- imagen[6][7] := 28 + (-5.5)
Y finalmente así:
- imagen[6][6] = -18.5, -18.5, -18.5,
- imagen[4][7] = 35.5, 35.5, 35.5,
- imagen[5 ][7] = 68.5, 68.5, 68.5,
- imagen[6][7] = 22.5, 22.5, 22.5
Obviamente no podemos tener pixeles con valores negativos o decimales, hay que hacer las revisiones correspondientes para convertir el 35.5 a 35, por ejemplo, y convertir el -18.5 a 0. Lo que quiero que notes es cómo cambiaron los valores. Estaban así:
- imagen[6][6] = 20,20,20
- imagen[4][7] = 52,52,52
- imagen[5 ][7] = 96,96,96
- imagen[6][7] = 28,28,28
Y ahora así:
- imagen[6][6] = 0,0,0
- imagen[4][7] = 35,35,35
- imagen[5 ][7] = 68,68,68
- imagen[6][7] = 22,22,22
Fíjate en que, debido a que nuestro pixel original tenía un valor de 167 (más blanco), el error de cuantificación fue de -88, lo que hizo que sus pixeles vecinos sean más oscuros de lo que eran.
Repetimos ese proceso por cada pixel y gracias al error de propagación se genera un efecto de suavizado e iluminación en la imagen resultante, ya que cuando le toque el turno de ser procesado al que fue un pixel vecino, su valor original habrá cambiado gracias al error de propagación del pixel previo.
Código fuente
Ya te expliqué el algoritmo, ahora veamos el código de Golang. La función del algoritmo Floyd-Steinberg Dithering en Go queda así:
func floydSteinbergDithering(imagenOriginal image.Image) *image.RGBA {
ancho, alto := imagenOriginal.Bounds().Max.X, imagenOriginal.Bounds().Max.Y
imagenConDithering := image.NewRGBA(image.Rect(0, 0, ancho, alto))
for y := 0; y < alto; y++ {
for x := 0; x < ancho; x++ {
nivelDeGris := convertirAEscalaDeGrises(imagenOriginal.At(x, y))
nivelDeGrisUint8 := uint32AUint8(nivelDeGris)
imagenConDithering.Set(x, y, color.RGBA{
R: nivelDeGrisUint8,
G: nivelDeGrisUint8,
B: nivelDeGrisUint8,
A: NivelAlfaTotalmenteOpacoPara8Bits,
})
}
}
for y := 0; y < alto; y++ {
for x := 0; x < ancho; x++ {
// En este punto la imagen ya es gris, podemos
// acceder a cualquier nivel RGB para obtener
// su nivel de gris, pues los 3 son iguales.
// Yo accedo al nivel R
nivelDeGrisOriginal, _, _, _ := imagenConDithering.At(x, y).RGBA()
nivelBlancoONegro := colorMasCercanoSegunNivelDeGris(nivelDeGrisOriginal)
nivelBlancoONegroUint8 := uint32AUint8(nivelBlancoONegro)
imagenConDithering.Set(x, y, color.RGBA{
R: nivelBlancoONegroUint8,
G: nivelBlancoONegroUint8,
B: nivelBlancoONegroUint8,
A: NivelAlfaTotalmenteOpacoPara8Bits,
})
errorDeCuantificacion := nivelDeGrisOriginal - nivelBlancoONegro
propagarError(imagenConDithering, x, y, errorDeCuantificacion)
}
}
return imagenConDithering
}
El recorrido de la imagen se hace desde 0,0, avanzando fila por fila. En este caso estoy convirtiendo la imagen a escala de grises en primer lugar, y después ya estoy aplicando el dithering obteniendo el nivel de gris original, convirtiendo dicho nivel a un nivel blanco o negro, calculando el error de cuantificación y propagando el error.
Todo el procesamiento se está haciendo con Golang sobre una image.Image. La siguiente función se encarga de convertir un pixel a escala de grises:
func convertirAEscalaDeGrises(c color.Color) uint32 {
r, g, b, _ := c.RGBA()
return uint32(0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b))
}
Por cierto, es buen momento para aclarar que en Golang un RGBA tiene como máximo valor 65535, es decir, 65536 posibles combinaciones, aunque cada valor de RGBA es un uint32. Por lo tanto no podemos trabajar con cada nivel de color suponiendo que sus valores van del 0 al 255; hay que hacer las conversiones necesarias. Esta conversión solo aplica en este lenguaje, pero el algoritmo sigue siendo el mismo.
Aquí está la función que propaga el error de cuantificación para aplicar el Floyd-Steinberg Dithering:
func propagarError(imagenResultante *image.RGBA, x int, y int, errorDeCuantificacion uint32) {
/*
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
*/
establecerNivelDeColorEnImagen(imagenResultante, x+1, y, calcular716(errorDeCuantificacion, imagenResultante.At(x+1, y)))
establecerNivelDeColorEnImagen(imagenResultante, x-1, y+1, calcular316(errorDeCuantificacion, imagenResultante.At(x-1, y+1)))
establecerNivelDeColorEnImagen(imagenResultante, x, y+1, calcular516(errorDeCuantificacion, imagenResultante.At(x, y+1)))
establecerNivelDeColorEnImagen(imagenResultante, x+1, y+1, calcular116(errorDeCuantificacion, imagenResultante.At(x+1, y+1)))
}
Poniendo todo junto
Te acabo de mostrar las funciones más importantes para aplicar el tramado a una imagen con Go, pero el código completo lo voy a dejar en GitHub. Fíjate que solo debes invocar a la función demostrarDithering
así:
func main() {
nombreImagenEntrada := "lagartija.jpg"
nombreImagenSalida := "convertida.png"
log.Printf("Error al convertir: %v", demostrarDithering(nombreImagenEntrada, nombreImagenSalida))
}
Una vez que tengas el código fuente, configura el nombre de la imagen de entrada y salida, compila con go build
, ejecuta el programa recién compilado y verás los resultados de aplicar el tramado a una imagen con Go. Un ejemplo de imagen ya lo has visto al inicio del post.
También te dejo con un vídeo explicando todo este algoritmo y probando con algunas imágenes.
He estado investigando esta técnica porque la voy a aplicar en mi plugin para impresoras térmicas para mejorar la calidad de las imágenes, aunque técnicamente hablando no es mejorar la calidad, es aplicar un efecto de iluminación a las imágenes en blanco y negro.