Phase 04 - Lesson 11

Stable Diffusion — Arquitectura y Fine-Tuning

Stable Diffusion es un DDPM que se ejecuta en el espacio latente de un VAE preentrenado, condicionado a texto mediante cross-attention, muestreado con un solver de EDO determinista y rápido, y guiado por classifier-free guidance.

Tipo: Aprender + Usar Lenguajes: Python Prerrequisitos: Fase 4 Lección 10 (Diffusion), Fase 7 Lección 02 (Self-Attention) Tiempo: ~75 minutos

Objetivos de aprendizaje

  • Recorrer las cinco piezas de un pipeline de Stable Diffusion: VAE, codificador de texto, U-Net, scheduler, safety checker — y lo que cada una de ellas realmente hace
  • Explicar la difusión latente y por qué entrenar en un espacio latente de 4x64x64 (en lugar de una imagen 3x512x512) reduce el cómputo en 48x sin pérdida de calidad
  • Usar diffusers para generar imágenes, ejecutar image-to-image, inpainting y generación guiada por ControlNet
  • Hacer fine-tuning de Stable Diffusion con LoRA en un pequeño dataset personalizado y cargar el adaptador LoRA en la inferencia

El Problema

Entrenar un DDPM directamente sobre imágenes RGB 512x512 es costoso. Cada paso de entrenamiento hace backprop a través de una U-Net que ve 3x512x512 = 786.432 valores de entrada, y el muestreo toma 50+ forward passes por esa misma U-Net. Al nivel de calidad de Stable Diffusion 1.5 (lanzado en 2022), la difusión en el espacio de píxeles requeriría aproximadamente 256 GPU-meses de entrenamiento y de 10 a 30 segundos por imagen en una GPU de consumo.

El truco que hizo viable el text-to-image de pesos abiertos fue la difusión latente (Rombach et al., CVPR 2022). Entrena un VAE que mapea una imagen 3x512x512 a un tensor latente 4x64x64 y de vuelta, y luego haz la difusión en ese espacio latente. El cómputo cae en (3*512*512)/(4*64*64) = 48x. El muestreo cae de decenas de segundos a menos de dos segundos en la misma GPU.

Casi todo modelo moderno de generación de imágenes — SDXL, SD3, FLUX, HunyuanDiT, Wan-Video — es un modelo de difusión latente con variaciones en el autoencoder, el denoiser (U-Net o DiT) y el condicionamiento de texto. Aprende Stable Diffusion y habrás aprendido la plantilla.

El Concepto

El pipeline

flowchart LR
    TXT["Prompt de texto"] --> TE["Codificador de texto<br/>(CLIP-L o T5)"]
    TE --> CT["Embedding<br/>de texto"]

    NOISE["Ruido<br/>4x64x64"] --> UNET["UNet<br/>(denoiser con<br/>cross-attention<br/>al texto)"]
    CT --> UNET

    UNET --> SCHED["Scheduler<br/>(DPM-Solver++,<br/>Euler)"]
    SCHED --> LATENT["Latente limpio<br/>4x64x64"]
    LATENT --> VAE["Decodificador del VAE"]
    VAE --> IMG["Imagen RGB<br/>512x512"]

    style TE fill:#dbeafe,stroke:#2563eb
    style UNET fill:#fef3c7,stroke:#d97706
    style SCHED fill:#fecaca,stroke:#dc2626
    style IMG fill:#dcfce7,stroke:#16a34a
  • VAE — autoencoder congelado. El codificador convierte la imagen en latentes (usado en img2img y entrenamiento). El decodificador convierte los latentes de vuelta en una imagen.
  • Codificador de texto — codificador de texto CLIP (SD 1.x/2.x), CLIP-L + CLIP-G (SDXL), o T5-XXL (SD3/FLUX). Produce una secuencia de embeddings de tokens.
  • U-Net — el denoiser. Tiene capas de cross-attention que atienden desde los latentes al embedding de texto en cada nivel de resolución.
  • Scheduler — el algoritmo de muestreo (DDIM, Euler, DPM-Solver++). Elige los sigmas y mezcla el ruido predicho de vuelta en el latente.
  • Safety checker — filtro opcional de contenido NSFW / ilegal en la imagen de salida.

Classifier-free guidance (CFG)

El condicionamiento de texto puro aprende epsilon_theta(x_t, t, c) para cada prompt c. El CFG entrena la misma red con c descartado el 10% de las veces (reemplazado por un embedding vacío), produciendo un único modelo que predice tanto el ruido condicional como el incondicional. En la inferencia:

eps = eps_uncond + w * (eps_cond - eps_uncond)

w es la escala de guidance. w=0 es incondicional, w=1 es condicional puro, w>1 empuja la salida hacia ser "más condicionada al prompt" al costo de la diversidad. El valor por defecto de SD es w=7.5.

El CFG es la razón por la que el text-to-image funciona con calidad de producción. Sin él, los prompts sesgan la salida débilmente; con él, los prompts dominan.

Geometría del espacio latente

El latente de 4 canales del VAE no es solo una imagen comprimida. Es una variedad (manifold) donde la aritmética corresponde aproximadamente a ediciones semánticas (prompt engineering + interpolación viven ambos aquí), y donde la U-Net de difusión ha sido entrenada para gastar todo su presupuesto de modelado. Decodificar un latente aleatorio 4x64x64 no produce una imagen de apariencia aleatoria — produce basura, porque solo una submanifold específica de latentes decodifica a imágenes válidas.

Dos consecuencias:

  1. Img2img = codificar la imagen a latente, agregar ruido parcial, ejecutar el denoiser, decodificar. La estructura de la imagen sobrevive porque la codificación es casi invertible; el contenido cambia según el prompt.
  2. Inpainting = igual que img2img pero el denoiser solo actualiza las regiones enmascaradas; las regiones no enmascaradas se mantienen en el latente codificado.

La arquitectura de la U-Net

La U-Net de SD es una versión grande de la TinyUNet de la Lección 10 con tres adiciones:

  • Bloques de transformer en cada resolución espacial, conteniendo self-attention + cross-attention al embedding de texto.
  • Time embedding mediante MLP sobre una codificación sinusoidal.
  • Skip connections entre el codificador y el decodificador en resoluciones coincidentes.

Total de parámetros en SD 1.5: ~860M. SDXL: ~2.6B. FLUX: ~12B. El salto en parámetros está principalmente en las capas de atención.

Fine-tuning con LoRA

El fine-tuning completo de Stable Diffusion necesita 20+ GB de VRAM y actualiza 860M de parámetros. LoRA (Low-Rank Adaptation) mantiene el modelo base congelado e inyecta pequeñas matrices de descomposición de bajo rango en las capas de atención. Un adaptador LoRA para SD pesa típicamente de 10 a 50 MB, se entrena en 10 a 60 minutos en una sola GPU de consumo, y se carga en el momento de la inferencia como una modificación plug-in.

Original: W_q : (d_in, d_out)   frozen
LoRA:     W_q + alpha * (A @ B)   where A : (d_in, r), B : (r, d_out)

r is typically 4-32.

LoRA es la forma en que casi todo fine-tune de la comunidad se distribuye. CivitAI y Hugging Face alojan millones de ellos.

Schedulers que verás

  • DDIM — determinista, ~50 pasos, simple.
  • Euler ancestral — estocástico, 30-50 pasos, muestras ligeramente más creativas.
  • DPM-Solver++ 2M Karras — determinista, 20-30 pasos, valor por defecto de producción.
  • LCM / TCD / Turbo — consistency models y variantes destiladas; 1-4 pasos al costo de algo de calidad.

Cambiar de scheduler es un cambio de una línea en diffusers y a veces corrige problemas de muestreo sin ningún reentrenamiento.

Constrúyelo

Esta lección usa diffusers de extremo a extremo en lugar de reconstruir Stable Diffusion desde cero. Las piezas que necesitarías reconstruir (VAE, codificador de texto, U-Net, scheduler) son temas de sus propias lecciones; aquí el objetivo es fluidez con la API de producción.

Paso 1: Text-to-image

import torch
from diffusers import StableDiffusionPipeline

pipe = StableDiffusionPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    torch_dtype=torch.float16,
).to("cuda")

image = pipe(
    prompt="a dog riding a skateboard in tokyo, studio ghibli style",
    guidance_scale=7.5,
    num_inference_steps=25,
    generator=torch.Generator("cuda").manual_seed(42),
).images[0]
image.save("dog.png")

float16 reduce la VRAM a la mitad sin pérdida de calidad visible. num_inference_steps=25 con el DPM-Solver++ por defecto equivale a num_inference_steps=50 con DDIM.

Paso 2: Cambiar el scheduler

from diffusers import DPMSolverMultistepScheduler, EulerAncestralDiscreteScheduler

pipe.scheduler = DPMSolverMultistepScheduler.from_config(pipe.scheduler.config)
pipe.scheduler = EulerAncestralDiscreteScheduler.from_config(pipe.scheduler.config)

El estado del scheduler está desacoplado de los pesos de la U-Net. Puedes entrenar con DDPM y muestrear con cualquier scheduler.

Paso 3: Image-to-image

from diffusers import StableDiffusionImg2ImgPipeline
from PIL import Image

img2img = StableDiffusionImg2ImgPipeline.from_pretrained(
    "runwayml/stable-diffusion-v1-5",
    torch_dtype=torch.float16,
).to("cuda")

init_image = Image.open("dog.png").convert("RGB").resize((512, 512))
out = img2img(
    prompt="a dog riding a skateboard, oil painting",
    image=init_image,
    strength=0.6,
    guidance_scale=7.5,
).images[0]

strength es cuánto ruido agregar antes del denoising (0.0 = sin cambios, 1.0 = regeneración completa). 0.5-0.7 es el rango estándar para transferencia de estilo.

Paso 4: Inpainting

from diffusers import StableDiffusionInpaintPipeline

inpaint = StableDiffusionInpaintPipeline.from_pretrained(
    "runwayml/stable-diffusion-inpainting",
    torch_dtype=torch.float16,
).to("cuda")

image = Image.open("dog.png").convert("RGB").resize((512, 512))
mask = Image.open("dog_mask.png").convert("L").resize((512, 512))

out = inpaint(
    prompt="a cat",
    image=image,
    mask_image=mask,
    guidance_scale=7.5,
).images[0]

Los píxeles blancos en la máscara son el área a regenerar. Los píxeles negros se preservan.

Paso 5: Carga de LoRA

pipe.load_lora_weights("sayakpaul/sd-lora-ghibli")
pipe.fuse_lora(lora_scale=0.8)

image = pipe(prompt="a village square in ghibli style").images[0]

lora_scale controla la fuerza; 0.0 = ningún efecto, 1.0 = efecto total. fuse_lora integra el adaptador en los pesos in situ para ganar velocidad, pero impide el intercambio. Llama a pipe.unfuse_lora() antes de cargar un adaptador diferente.

Paso 6: Entrenamiento de LoRA (esbozo)

El entrenamiento real de LoRA vive en peft o diffusers.training. El esquema:

# Pseudocode
for step, batch in enumerate(dataloader):
    images, prompts = batch
    latents = vae.encode(images).latent_dist.sample() * 0.18215

    t = torch.randint(0, num_train_timesteps, (batch_size,))
    noise = torch.randn_like(latents)
    noisy_latents = scheduler.add_noise(latents, noise, t)

    text_emb = text_encoder(tokenizer(prompts))

    pred_noise = unet(noisy_latents, t, text_emb)  # LoRA weights injected here

    loss = F.mse_loss(pred_noise, noise)
    loss.backward()
    optimizer.step()

Solo las matrices LoRA reciben gradiente; la U-Net base, el VAE y el codificador de texto quedan congelados. Con un batch size de 1 y gradient checkpointing esto cabe en 8 GB de VRAM.

Úsalo

En producción, las decisiones que realmente tomas:

  • Familia de modelo: SD 1.5 para fine-tunes open-source de la comunidad, SDXL para mayor fidelidad, SD3 / FLUX para el estado del arte y requisitos estrictos de licenciamiento.
  • Scheduler: DPM-Solver++ 2M Karras para 20-30 pasos, LCM-LoRA cuando la latencia está por debajo de 1s.
  • Precisión: float16 en 4080/4090, bfloat16 en A100 y más nuevas, int8 (mediante bitsandbytes o compel) cuando la VRAM está ajustada.
  • Condicionamiento: el texto puro funciona; para un control más fuerte, agrega ControlNet (canny, depth, pose) sobre el pipeline base.

Para generación por lotes, AUTO1111 / ComfyUI son las herramientas de la comunidad; para APIs de producción, diffusers + accelerate u optimum-nvidia con compilación TensorRT.

Entrégalo

Esta lección produce:

  • outputs/prompt-sd-pipeline-planner.md — un prompt que elige SD 1.5 / SDXL / SD3 / FLUX más scheduler y precisión dados un presupuesto de latencia, un objetivo de fidelidad y una restricción de licenciamiento.
  • outputs/skill-lora-training-setup.md — una skill que escribe una config completa de entrenamiento de LoRA para un dataset personalizado, incluyendo captions, rank, batch size y learning rate.

Ejercicios

  1. (Fácil) Genera el mismo prompt con guidance_scale en [1, 3, 5, 7.5, 10, 15]. Describe cómo cambia la imagen. ¿A qué valor de guidance aparecen los artefactos?
  2. (Medio) Toma cualquier fotografía real, pásala por StableDiffusionImg2ImgPipeline con strength en [0.2, 0.4, 0.6, 0.8, 1.0]. ¿Qué strength preserva la composición mientras cambia el estilo? ¿Por qué 1.0 ignora la entrada por completo?
  3. (Difícil) Entrena un LoRA con 10-20 imágenes de un único sujeto (una mascota, un logo, un personaje) y genera escenas inéditas con ese sujeto en ellas. Reporta el rank del LoRA y los pasos de entrenamiento que produjeron la mejor preservación de identidad sin overfitting en las imágenes de entrada.

Términos clave

Término Lo que dice la gente Lo que realmente significa
Difusión latente "Difundir en latentes" Ejecutar todo el DDPM en el espacio latente del VAE (4x64x64) en lugar del espacio de píxeles (3x512x512); ahorro de 48x en cómputo
Factor de escala del VAE "0.18215" Constante que reescala el latente crudo del VAE a varianza aproximadamente unitaria; hardcoded en todo pipeline SD
Classifier-free guidance "CFG" Mezclar predicciones de ruido condicional e incondicional; la perilla de inferencia más impactante de todas
Scheduler "Sampler" El algoritmo que convierte ruido + predicciones del modelo en una trayectoria de latente denoised
LoRA "Adaptador de bajo rango" Pequeñas matrices de descomposición de bajo rango que hacen fine-tune a las capas de atención sin tocar los pesos base
Cross-attention "Atención texto-imagen" Atención de los tokens latentes a los tokens de texto; inyecta información del prompt en cada nivel de la U-Net
ControlNet "Condicionamiento de estructura" Un adaptador entrenado por separado que guía a SD con una entrada extra (canny, depth, pose, segmentación)
DPM-Solver++ "El scheduler por defecto" Solver de EDO determinista de segundo orden; mejor calidad con pocos pasos (20-30) en 2026

Lectura adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).