Cómo implementar Stable Diffusion
Tras ver cómo funciona Stable Diffusion teóricamente ahora toca implementarlo en Python.
Introducción
En un artículo anterior, vimos como funciona Stable Diffusion entrando en detalle pero sin utilizar ni una sola línea de código. Vimos como se entrena un modelo (difusión hacia delante) para después utilizarlo en el proceso de inferencia y generar espectaculares imágenes con Inteligencia Artificial (difusión inversa). Si aún no has leído ese artículo, te recomiendo que lo hagas antes de continuar con este.
En este artículo vamos a implementar cada parte del proceso de inferencia mediante código en Python, para así comprender de manera más técnica su funcionamiento. Es probable que no entiendas a la perfección todas las líneas de código de este artículo. No te preocupes, no tiene importancia. La idea es ver de forma general cómo funciona, cuáles son sus componentes y cómo interactúan entre ellos.
Vamos a utilizar la misma versión que en el artículo anterior (Stable Diffusion 1.5) y un condicionamiento de tipo texto-a-imagen (txt2img).
Empecemos recordando los pasos que sigue el proceso de inferencia utilizando la imagen final del anterior artículo:
Estructura del modelo
Antes de comenzar viene bien familiarizarse con su estructura. Recordemos que Stable Diffusion contiene varios componentes en su interior:
- Tokenizer: convierte texto en tokens.
- Transformer: transforma los embeddings mediante mecanismos de atención.
- Variational autoencoder (VAE): convierte imágenes en tensores dentro del espacio latente y viceversa.
- U-Net: predice el ruido de un tensor.
- Scheduler: guía al predictor de ruido y muestrea (sampling) imágenes con menos ruido en cada paso.
- Otros modelos, como el filtro NSFW.
Estos modelos se distribuyen en varios formatos, siendo los más comunes ckpt
y safetensors
(ya que agregan todos los componentes en único archivo). Gracias a Hugging Face los modelos también están disponibles en formato diffusers
, para utilizarlos fácilmente con su librería que lleva el mismo nombre. Este formato consiste en varias carpetas con todas los componentes por separado, perfecto para entender su composición.
Si navegamos por el repositorio de Stable Diffusion 1.5 encontraremos la siguiente estructura:
feature_extractor
- preprocessor_config.json
safety_checker
- config.json
- model.fp16.safetensors
- model.safetensors
- pytorch_model.bin
- pytorch_model.fp16.bin
scheduler
- scheduler_config.json
text_encoder
- config.json
- model.fp16.safetensors
- model.safetensors
- pytorch_model.bin
- pytorch_model.fp16.bin
tokenizer
- merges.txt
- special_tokens_map.json
- tokenizer_config.json
- vocab.json
unet
- config.json
- diffusion_pytorch_model.bin
- diffusion_pytorch_model.fp16.bin
- diffusion_pytorch_model.fp16.safetensors
- diffusion_pytorch_model.non_ema.bin
- diffusion_pytorch_model.non_ema.safetensors
- diffusion_pytorch_model.safetensors
vae
- config.json
- diffusion_pytorch_model.bin
- diffusion_pytorch_model.fp16.bin
- diffusion_pytorch_model.fp16.safetensors
- diffusion_pytorch_model.safetensors
- .gitattributes
- README.md
- model_index.json
- v1-5-pruned-emaonly.ckpt
- v1-5-pruned-emaonly.safetensors
- v1-5-pruned.ckpt
- v1-5-pruned.safetensors
- v1-inference.yaml
Puedes ver qué scheduler, tokenizer, transformer, U-Net o VAE utiliza Stable Diffusion 1.5 simplemente explorando los archivos .json
que encontrarás dentro de estas carpetas.
Aquí encontrarás los modelos individuales en formato .bin
o .safetensors
. Ambos con variante fp16
que, a diferencia de fp32
, utiliza la mitad de espacio en disco y memoria gracias a una bajada en la precisión de los números decimales, sin que apenas afecte al resultado final.
Instalación de librerías
Antes de nada, asegúrate de tener Python 3.10.
Además, si tienes una gráfica NVIDIA y vas a utilizar CUDA para acelerar el proceso (en este artículo lo utilizaré), necesitarás instalar CUDA Toolkit. Puedes seguir estos pasos para su instalación.
Ahora sí, creamos un entorno virtual e instalamos las librerías necesarias:
Proceso de inferencia
Ya podemos crear un archivo (por ejemplo inference.py
), donde escribiremos el código de nuestra aplicación.
Si quieres copiar y pegar el código entero, recuerda que lo tienes disponible en articles/how-to-implement-stable-diffusion/inference.py.
En el repositorio del blog en GitHub encontrarás todo el contenido asociado con este y otros artículos.
Importar lo necesario
Lo primero es importar las librerías y métodos que vamos a utilizar:
Más adelante entenderás para qué es cada cosa.
Instanciar modelos
Vamos a instanciar los modelos necesarios para así tenerlos disponibles en toda la aplicación.
Necesitamos el tokenizador, el transformer (text encoder), el predictor de ruido (U-Net), el scheduler y el variational autoencoder (VAE). Todos los modelos los obtenemos del repositorio en Hugging Face de Stable Diffusion 1.5.
Cada modelo se extrae de una carpeta específica (subfolder) y hacemos uso del formato .safetensors
cuando esté disponible. Además, los modelos parametrizados los movemos a la tarjeta gráfica mediante to('cuda')
, para acelerar los cálculos.
Stable Diffusion 1.5 utiliza el scheduler PLMS
(también llamado PNDM
), pero nosotros vamos a utilizar Euler
para mostrar lo fácil que es sustituirlo (ya lo hemos hecho).
Inicializar parámetros
A continuación, definimos los parámetros que necesitamos para la generación de imágenes.
Vamos a definir nuestro prompt. Utilizamos una lista por si quisiéramos generar varias imágenes al mismo tiempo utilizando diferentes prompts (['prompt1', 'prompt2', '...']
) o varias imágenes del mismo prompt utilizando una semilla aleatoria (['prompt'] * 4
). De momento no nos vamos a complicar, utilizamos un único prompt:
Para hacer el código más legible, almacenamos cuántas imágenes vamos a generar al mismo tiempo:
Para el proceso de muestreo, especificamos que vamos a utilizar 30 pasos (steps o sampling steps). Es decir, se eliminará ruido de la imagen 30 veces.
Para obtener un resultado reproducible especificamos una semilla en vez de ser aleatoria. Este número se utilizará más adelante para generar un tensor con ruido desde el que iremos limpiando la imagen hasta obtener el resultado. Si partimos del mismo ruido, siempre obtendremos el mismo resultado.
Y por último, guardamos también en unas variables el valor de CFG, así como el tamaño de la imagen que queremos generar:
Condicionamiento
Empecemos generando el tensor que contiene la información para guiar al predictor de ruido hacia la imagen que esperamos obtener.
Tokenizer
Como los ordenadores no entienden de letras, la primera tarea es utilizar un tokenizador (tokenizer) para convertir cada palabra en un número llamado símbolo (token).
Vamos a convertir un prompt de prueba en tokens:
Nos ha devuelto un diccionario donde input_ids
es una lista con los siguientes tokens: 49406, 1929, 530, 7223, 7235, 49407
.
Si abres el archivo vocab.json que encontrarás en la carpeta tokenizer
, hallarás un diccionario que asigna tokens a todos los términos posibles (recuerda, no tienen por qué ser siempre palabras).
Así pues, podemos observar como se ha tokenizado este prompt:
Fácil, ¿verdad?
[...] los tokens se guardan en un vector que tiene un tamaño de 77 tokens (1x77).
Como vimos en el anterior artículo, el vector tiene que tener un tamaño de 77 tokens. Si se supera este límite se pueden eliminar o solventar con técnicas de concatenación y retroalimentación para utilizar todos los tokens. En este ejemplo solo tenemos 6 y en nuestro prompt real tampoco tenemos 77. ¿De dónde sacamos el resto? Demos la bienvenida al padding y truncation.
Padding es la técnica que inserta un token especial para rellenar los elementos que faltan. Truncation, por otro lado, es una técnica que elimina tokens cuando se sobrepasa la cantidad deseada.
Vamos pues a tokenizar nuestro prompt de la siguiente manera:
Ahora sí, podemos ver como input_ids
contiene 77 elementos. El token 49407
se ha repetido todas las veces que ha sido necesario. Además, ahora input_ids
es un tensor gracias al argumento return_tensors='pt'
.
Embedding
Cabe destacar que cada token contendrá 768 dimensiones. Es decir, si utilizamos la palabra coche
en nuestro prompt, ese token se convertirá en un vector de 768 dimensiones. Una vez se realiza esto con todos los tokens tendremos un embedding de tamaño 1x77x768.
Tarea sencilla para nosotros. Llamamos a la función text_encoder()
, pasándole como argumento la propiedad input_ids
del tensor y quedándonos con el primer elemento que devuelve.
Como estamos utilizando CUDA tenemos que mandar el tensor guardado en cond_input.input_ids
a la tarjeta gráfica mediante to('cuda')
.
La línea with torch.no_grad()
desactiva el cálculo automático del gradiente. Sin entrar en detalle, es algo que no necesitamos para el proceso de inferencia y evitamos utilizar memoria innecesariamente. Entraremos en este contexto cada vez que hagamos uso de un modelo parametrizado.
Ya tenemos nuestro embedding listo.
Transformer
Este es el último paso del condicionamiento. En esta pieza se procesan los embeddings mediante un modelo transformer de CLIP.
No te sientas engañado, pero el text_encoder()
de CLIP ya se encarga de aplicar los mecanismos de atención a la hora de crear el embedding, así que no hay que hacer nada más.
Con esto terminamos el condicionamiento.
Incondicionamiento
A Stable Diffusion tambien hay que proveerle de un embedding no condicionado. Se hace de la misma manera pero el prompt es una cadena vacía tantas veces como imágenes estemos generando a la vez.
Unimos estos embeddings, tanto condicionados como no condicionados, en un único tensor:
Prompt negativo
¿Quieres añadir un prompt negativo? ¡Se trata del embedding no condicionado!
Cuando utilizamos un prompt positivo estamos guiando al predictor de ruido en esa dirección. Si le decimos que queremos un ramo de rosas (bouquet of roses
), el predictor de ruido irá en esa dirección.
El prompt no condicionado aleja al predictor de ruido de esos tokens. Si no lo utilizasemos, la calidad se vería gravemente afectada ya que no sabría de donde alejarse.
Al utilizar un prompt no condicionado vacío le estamos dando ruido extra. Es como decirle que se aleje del ruido. ¿Y qué es lo contrario? Una imagen de calidad.
Si en vez de ruido utilizamos el embedding no condicionado para añadir palabras (prompt negativo), seremos aún más específicos a la hora de alejar al predictor de ruido. Si utilizamos el prompt negativo red, pink
, lo que le estamos diciendo es que se aleje del color rojo y rosa, así que lo más probable es que nos genere una imagen con un ramo de rosas blancas, azules o amarillas.
En este artículo no vamos a utilizar prompt negativo pero te recomiendo que siempre añadas uno para obtener mucha mayor calidad en el resultado. Si utilizas un prompt como bad quality, deformed, oversaturated
, le estarás alejando de todo eso y el modelo buscará lo contrario.
Generar un tensor con ruido
Al principio del proceso, en vez de generar una imagen llena de ruido se genera ruido latente y se guarda en un tensor.
Para generar ruido instanciamos un generador mediante torch.Generator y le asignamos la semilla desde la que comenzaremos:
Después, utilizamos la función torch.randn para obtener un tensor con ruido. El parámetro que recibe es una secuencia de enteros que define la forma del tensor:
Le estamos pasando como valor (1, 4, 64, 64)
. Estos números provienen de:
1
: El valor debatch_size
. Es decir, cuántas imágenes generamos a la vez.4
: La cantidad de canales de entrada que tiene la red neuronal del predictor de ruido (U-Net).64
/64
: El tamaño de la imagen en el espacio latente (height / width). Nuestra imagen es de tamaño512x512
pero en el espacio latente ocupa 8 veces menos. Este divisor viene especificado en la arquitectura de Stable Diffusion 1.5.
Recuerda que al utilizar CUDA, lo hemos especificado en ambas funciones mediante el argumento device='cuda'
.
Ahora latents
es un tensor con ruido sobre el que ya podemos trabajar como si de un lienzo se tratase.
Limpiar el ruido del tensor
El predictor de ruido estima cuánto ruido hay en la imagen. Tras esto, el algoritmo llamado sampler genera una imagen con esa cantidad de rudio y se resta de la imagen original. Este proceso se repite la cantidad de veces especificada por los pasos (steps o sampling steps).
Vamos a configurar el scheduler para indicarle en cuántos pasos queremos limpiar el tensor:
Podemos comprobar cómo funciona internamente imprimiendo la siguiente propiedad:
Como son 30 pasos, se han generado 30 elementos separados por la misma distancia (34.4483
unidades).
Algunos schedulers como DPM2 Karras
o Euler
, necesitan que desde el primer paso los valores del tensor ya estén multiplicados por la desviación estándar de la distribución de ruido inicial. Los schedulers que no lo necesitan simplemente multiplicarán por 1
. El caso... que hay que añadir la siguiente operación:
Ya tenemos el tensor con ruido, los condicionamientos y el scheduler. Con esto ya podemos generar el bucle que limpiará el tensor a lo largo de 30 vueltas. Utilizamos la librería tqdm para mostrar una barra de progreso. Explicaré cada línea directamente en el código.
Tras finalizar este bucle, ya tenemos nuestro tensor libre de ruido y listo para ser convertido en una espectacular imagen. O quizás en un churro, ahora saldremos de dudas.
Si a pesar de estar utilizando la misma semilla no consigues obtener la misma imagen como resultado, este problema de reproducibilidad seguramente se deba a que estás utilizando un sampler ancestral o estocástico.
Esto ocurre porque los samplers ancestrales añaden ruido extra en cada paso y los samplers estocásticos utilizan información del paso anterior. Por lo tanto, necesitan tener acceso al generador para ofrecer esta variabilidad en cada paso. Solo hay que añadirlo a esta línea:
Convertir el tensor en una imagen
Un variational autoencoder (VAE) es un tipo de red neuronal que convierte una imagen en un tensor en el espacio latente (encoder) o un tensor del espacio latente en una imagen (decoder).
Ya queda poco. Hay que tener en cuenta el factor de escala (vae.config.scale_factor
) del propio VAE, un valor fijado en 0.18215
. Una vez normalizado el tensor ya podemos decodificarlo mediante vae.decode()
para sacarlo del espacio latente e introducirlo en el espacio de imagen.
Seguimos teniendo un tensor, pero ya no está dentro de la Matrix. Este tensor tiene valores que van desde -1
a 1
, así que primero lo normalizamos a un rango desde 0
hasta 1
.
Podríamos hacer cálculos para convertir estos valores en valores RGB pero dejemos que torchvision se encargue de ello. Utilizamos su transformación ToPILImage para convertir el tensor de torch
en una imagen de pillow
(o varias, dependiendo del batch_size
). El método save()
de Pillow se encargará de guardar las imágenes en el disco.
Ejecuta python inference.py
... ¡y ya podemos visualizar nuesta magnífica y perfectísima imagen!
Conclusión
Hemos visto de qué components se compone un modelo de Stable Diffusion y también qué resultado producen para así poder conectarlas entre ellas. Gracias a la librería diffusers
hemos podido abstraer el código lo suficiente como para no tener que reinventar la rueda desde cero, pero tampoco quedarnos en la superficie sin entender nada.
En diffusers
tenemos pipelines como la de Stable Diffusion para ejecutar el proceso de inferencia en un par de líneas:
Podría decirse que hemos implementado nuestra propia pipeline, en la que podemos intercambiar componentes como por ejemplo el scheduler o el VAE.
Espero que no se te haya hecho muy complicado y que este artículo te haya sido de ayuda para entender cómo funciona el proceso de inferencia de Stable Diffusion.
Puedes apoyarme para que pueda dedicar aún más tiempo a escribir artículos y tener recursos para crear nuevos proyectos. ¡Gracias!