Introducción
Buscando y buscando librerías para ocultar mensajes en imágenes utilizando Python encontré algunas, pero ninguna funcionó en mi máquina. Ya fuera al momento de instalarla o al momento de utilizarla.
Así que decidí hacerlo a mano, y aprender un poco del proceso. Al final, pude ocultar texto para más tarde recuperarlo. A esta técnica se le llama esteganografía.
No utilicé ninguna librería, sólo PIL para obtener los pixeles de una imagen. Los métodos explicados aquí puede que sean redundantes, pero son bastante explicativos.
Antes de continuar, recomiendo y casi obligo a leer cómo es que modificamos el LSB de cada byte de cada nivel de color en un pixel.
Recuerda tener instalado Python y pip.
Nota: si quieres ver el código completo míralo en GitHub.
Ocultar mensaje en imágenes con Python
Vamos a empezar ocultando información. Veremos paso por paso.
Obtener pixeles de una imagen
Primeramente necesitamos los pixeles como un arreglo de dos dimensiones. El código es sencillo, con la altura y anchura de la imagen hacemos un ciclo dentro de otro. Luego, accedemos a los pixeles en la posición x, y. Y los vamos imprimiendo.
from PIL import Image
imagen = Image.open("back_to_the_future.png") # Aquí puedes cambiar el nombre de la imagen
pixeles = imagen.load()
tamaño = imagen.size
anchura = tamaño[0]
altura = tamaño[1]
print("La anchura de la imagen es {}".format(anchura))
print("La altura de la imagen es {}".format(altura))
for x in range(anchura):
for y in range(altura):
pixel = pixeles[x, y]
print(pixel)
Nota: para evitar que salgan miles de líneas en la terminal, puedes presionar Ctrl + C unos segundos después de ejecutar el script, ya que se imprimirán muchas de ellas.
Como se observa, cada pixel se compone de R, G, B.
Obtener nivel de color por cada pixel
Sólo tenemos que modificar un poco el código anterior. Quedaría así:
from PIL import Image
imagen = Image.open("back_to_the_future.png") # Aquí puedes cambiar el nombre de la imagen
pixeles = imagen.load()
tamaño = imagen.size
anchura = tamaño[0]
altura = tamaño[1]
print("La anchura de la imagen es {}".format(anchura))
print("La altura de la imagen es {}".format(altura))
for x in range(anchura):
for y in range(altura):
pixel = pixeles[x, y]
rojo = pixel[0]
verde = pixel[1]
azul = pixel[2]
print("Rojo: {}".format(rojo))
print("Verde: {}".format(verde))
print("Azul: {}".format(azul))
Y ahora ya imprime el valor de cada color, por cada pixel.
Ya vimos cómo obtener el nivel de cada color, ahora podemos modificarlo. Pero antes, veamos cómo obtener los bits que escribiremos.
Obtener lista de bits de una palabra
Sé que este método no será el mejor ni el más óptimo, pero repito que todo es con fin de explicar las cosas adecuadamente.
Necesitamos escribir una función que, dada una cadena, devuelva una lista de bits. Por ejemplo, si escribo “Hola” debe devolver [‘0’, ‘1’, ‘0’, ‘0’, ‘1’, ‘0’, ‘0’, ‘0’, ‘0’, ‘1’, ‘1’, ‘0’, ‘1’, ‘1’, ‘1’, ‘1’, ‘0’, ‘1’, ‘1’, ‘0’, ‘1’, ‘1’, ‘0’, ‘0’, ‘0’, ‘1’, ‘1’, ‘0’, ‘0’, ‘0’, ‘0’, ‘1’].
Se ve complicado al inicio, pero no lo es. Primero, convierte cada letra al número que le corresponde en el código ascii. Luego, cada código es convertido a binario (un byte) y más tarde, por cada bit en ese byte creamos una lista:
def obtener_representacion_ascii(caracter):
return ord(caracter)
def obtener_representacion_binaria(numero):
return bin(numero)[2:].zfill(8)
def obtener_lista_de_bits(texto):
lista = []
for letra in texto:
representacion_ascii = obtener_representacion_ascii(letra)
representacion_binaria = obtener_representacion_binaria(representacion_ascii)
for bit in representacion_binaria:
lista.append(bit)
return lista
print("Hola en bits es")
print(obtener_lista_de_bits("Hola"))
Al ejecutarlo, se ve esto:
Pero ahora que lo pienso, necesitamos poner unos bits de terminación. Es decir, si tenemos una imagen muy grande y sólo queremos ocultar “Hola”, ¿qué pasa con los demás pixeles? no vamos a recorrer cada pixel sin escribirle nada, y tampoco lo vamos a llenar de ceros.
Mejor ponemos un carácter de terminación; ya que cuando leamos el mensaje oculto tendremos que leer únicamente hasta dicho símbolo. Entonces necesitamos modificar nuestra lista de bits para que regrese los bits de la palabra, y también unos bits de terminación:
caracter_terminacion = [1, 1, 1, 1, 1, 1, 1, 1]
def obtener_representacion_ascii(caracter):
return ord(caracter)
def obtener_representacion_binaria(numero):
return bin(numero)[2:].zfill(8)
def obtener_lista_de_bits(texto):
lista = []
for letra in texto:
representacion_ascii = obtener_representacion_ascii(letra)
representacion_binaria = obtener_representacion_binaria(representacion_ascii)
for bit in representacion_binaria:
lista.append(bit)
for bit in caracter_terminacion:
lista.append(bit)
return lista
print("Hola en bits es")
print(obtener_lista_de_bits("Hola"))
Al ejecutarlo, se ve lo mismo que antes. Pero con la diferencia de que ahora, al final, está nuestro carácter de terminación.
Ahora que ya aprendimos esto, vamos a ir modificando un color por cada bit que haya en nuestra lista.
Modificar colores
Antes de juntar todo esto, vamos a ver cómo modificar un color. Recordemos que un pixel tiene 3 colores: Rojo, Verde y Azul. Y cada nivel tiene un valor entre 0 y 255.
Supongamos que nuestro color rojo es 200, y nuestro bit que vamos a ocultar es 1. El código quedaría así:
def cambiar_ultimo_bit(byte, nuevo_bit):
return byte[:-1] + str(nuevo_bit)
def binario_a_decimal(binario):
return int(binario, 2)
def obtener_representacion_binaria(numero):
return bin(numero)[2:].zfill(8)
def modificar_color(color_original, bit):
color_binario = obtener_representacion_binaria(color_original)
color_modificado = cambiar_ultimo_bit(color_binario, bit)
return binario_a_decimal(color_modificado)
color_original = 200
color_modificado = modificar_color(color_original, 1) # Cambiar el LSB de 200 por un 1
print("Original: {}".format(color_original))
print("Modificado: {}".format(color_modificado))
Al ejecutarlo…
Juntando todo
Ya que vimos cómo hacer cada cosa, vamos a juntar todo y hacerlo funcional. Por cierto, para modificar un pixel hacemos esto:
pixeles[x, y] = (rojo_modificado, verde_modificado, azul_modificado)
Es decir, le pasamos una tupla. Y para guardar todo el arreglo de pixeles (es decir, la imagen con el texto oculto) hacemos esto:
imagen.save(ruta_imagen_salida)
Finalmente, quiero explicar que hay un contador para saber si ya terminamos de escribir el mensaje… si ya terminamos, entonces terminamos el ciclo. También sirve para saber si pudimos escribir o no el mensaje completo.
He aquí el código
"""
Ocultar mensaje en una imagen utilizando esteganografía, ocultando un bit en cada nivel de color
Nota: recuerda instalar Pillow:
pip install Pillow
@author parzibyte
@date 06-04-2018
@web parzibyte.me/blog
"""
from PIL import Image
import math #Utilizado sólo para redondear hacia abajo
caracter_terminacion = [1, 1, 1, 1, 1, 1, 1, 1]
def obtener_representacion_ascii(caracter):
return ord(caracter)
def obtener_representacion_binaria(numero):
return bin(numero)[2:].zfill(8)
def cambiar_ultimo_bit(byte, nuevo_bit):
return byte[:-1] + str(nuevo_bit)
def binario_a_decimal(binario):
return int(binario, 2)
def modificar_color(color_original, bit):
color_binario = obtener_representacion_binaria(color_original)
color_modificado = cambiar_ultimo_bit(color_binario, bit)
return binario_a_decimal(color_modificado)
def obtener_lista_de_bits(texto):
lista = []
for letra in texto:
representacion_ascii = obtener_representacion_ascii(letra)
representacion_binaria = obtener_representacion_binaria(representacion_ascii)
for bit in representacion_binaria:
lista.append(bit)
for bit in caracter_terminacion:
lista.append(bit)
return lista
def ocultar_texto(mensaje, ruta_imagen_original, ruta_imagen_salida="salida.png"):
print("Ocultando mensaje...".format(mensaje))
imagen = Image.open(ruta_imagen_original)
pixeles = imagen.load()
tamaño = imagen.size
anchura = tamaño[0]
altura = tamaño[1]
lista = obtener_lista_de_bits(mensaje)
contador = 0
longitud = len(lista)
for x in range(anchura):
for y in range(altura):
if contador < longitud:
pixel = pixeles[x, y]
rojo = pixel[0]
verde = pixel[1]
azul = pixel[2]
if contador < longitud:
rojo_modificado = modificar_color(rojo, lista[contador])
contador += 1
else:
rojo_modificado = rojo
if contador < longitud:
verde_modificado = modificar_color(verde, lista[contador])
contador += 1
else:
verde_modificado = verde
if contador < longitud:
azul_modificado = modificar_color(azul, lista[contador])
contador += 1
else:
azul_modificado = azul
pixeles[x, y] = (rojo_modificado, verde_modificado, azul_modificado)
else:
break
else:
continue
break
if contador >= longitud:
print("Mensaje escrito correctamente")
else:
print("Advertencia: no se pudo escribir todo el mensaje, sobraron {} caracteres".format( math.floor((longitud - contador) / 8) ))
imagen.save(ruta_imagen_salida)
ocultar_texto("Hola, mundo. Esto es un mensaje oculto desde parzibyte.me/blog", "oveja.png")
Cuando lo ejecuto, sale esto:
En mi caso, la imagen es la de una oveja. Originalmente es esta:
La que tiene el mensaje oculto es esta: https://github.com/parzibyte/esteganografia-python/blob/master/salida.png
No he probado con imágenes JPG, pero no lo recomiendo, ya que (creo) comprimen los pixeles o algo así, perdiendo nuestros bits ocultos. Por favor prueba sólo con imágenes PNG.
Ya hemos terminado de ocultar. Hora de leer.
Leer mensaje oculto en imágenes
Esto es un poco más sencillo. Ahora no modificamos ningún color, sólo leemos el LSB de cada nivel. Concatenamos todo en un byte; cuando el byte es un byte (es decir, cuando la cadena mide 8) obtenemos su carácter y ese lo concatenamos en una variable llamada mensaje.
Por ejemplo, si mi byte es “01000000”, entonces su representación decimal es 64, y eso, según el código ascii es el arroba @.
Ese arroba lo concatenamos en otra variable, y así vamos formando nuestro mensaje final.
Ahh, otra cosa. Si encontramos el carácter de terminación, nos damos por bien servidos y dejamos de leer los pixeles.
He aquí el código:
"""
Leer mensaje en una imagen utilizando esteganografía, leyendo un bit en cada nivel de color
Nota: recuerda instalar Pillow:
pip install Pillow
@author parzibyte
@date 06-04-2018
@web parzibyte.me/blog
"""
from PIL import Image
caracter_terminacion = "11111111"
def obtener_lsb(byte):
return byte[-1]
def obtener_representacion_binaria(numero):
return bin(numero)[2:].zfill(8)
def binario_a_decimal(binario):
return int(binario, 2)
def caracter_desde_codigo_ascii(numero):
return chr(numero)
def leer(ruta_imagen):
imagen = Image.open(ruta_imagen)
pixeles = imagen.load()
tamaño = imagen.size
anchura = tamaño[0]
altura = tamaño[1]
byte = ""
mensaje = ""
for x in range(anchura):
for y in range(altura):
pixel = pixeles[x, y]
rojo = pixel[0]
verde = pixel[1]
azul = pixel[2]
byte += obtener_lsb(obtener_representacion_binaria(rojo))
if len(byte) >= 8:
if byte == caracter_terminacion:
break
mensaje += caracter_desde_codigo_ascii(binario_a_decimal(byte))
byte = ""
byte += obtener_lsb(obtener_representacion_binaria(verde))
if len(byte) >= 8:
if byte == caracter_terminacion:
break
mensaje += caracter_desde_codigo_ascii(binario_a_decimal(byte))
byte = ""
byte += obtener_lsb(obtener_representacion_binaria(azul))
if len(byte) >= 8:
if byte == caracter_terminacion:
break
mensaje += caracter_desde_codigo_ascii(binario_a_decimal(byte))
byte = ""
else:
continue
break
return mensaje
mensaje = leer("salida.png")
print("El mensaje oculto es:")
print(mensaje)
En mi caso, la imagen se llama salida.png. Si quieres, descarga la imagen de la oveja modificada, pon el nombre que indico, ejecuta el script y verás que sale el mensaje:
Con eso terminamos. Ya veremos más tarde cómo podemos regar el mensaje, cifrarlo, etcétera.
Hola, necesitaría saber si se puede obtener
alguna relación entre píxeles y fila/columnas.-
Para ejemplificar, tengo un widget TEXT y necesito relacionar
el contenido en fila/columnas para establecer las dimensiones
de este y la ventana.-
Hola. Gracias por sus comentarios. Si tiene alguna consulta, solicitud de creación de un programa o solicitud de cambio de software estoy para servirle en https://parzibyte.me/#contacto
Saludos!
muy buen aporte amigo, pero como puedo ver ambas imagenes al mismo tiempo
como para saber cual es la original y la codificada, aunque no sería mucha la diferencia entre ambas.
Puede poner otro nombre a la imagen de salida.
Saludos 🙂