
🎨 Proyecto MonteIA: Generación de Imágenes Local por Difusión con SD-Turbo en 1º y 2º ESO
1. Contexto Educativo y El Reto del Hardware
La generación de imágenes mediante modelos de difusión suele considerarse fuera del alcance de los centros educativos debido a sus altos requerimientos de hardware: entornos pesados de Python (PyTorch, CUDA) y tarjetas gráficas dedicadas con más de 6-8 GB de VRAM.
En el IES Monterroso, dentro del marco del proyecto MonteSTEAM impulsado con AIDARAC, nos propusimos superar esta barrera para que el alumnado experimente de primera mano cómo un algoritmo es capaz de “dibujar” a partir de texto.
La solución pasa por la eficiencia del software:
- Ejecución nativa en C++ (
stable-diffusion.cpp): Mismo concepto de cuantización y binarios ultraligeros que ya aplicamos con los modelos de lenguaje (LLMs) locales. - Modelos “Turbo” en 4 pasos: En lugar de los 30 o 50 pasos de una difusión tradicional, los modelos tipo SD-Turbo resuelven la imagen en tan solo 1 a 4 pasadas matemáticas.
- Servidor central en el aula: Un único ordenador atiende las peticiones HTTP de los portátiles del alumnado a través de una API con FastAPI servida en el puerto
:8066.
2. ¿Cómo funciona la Generación de Imágenes por Difusión?
Para que la actividad sea realmente formativa en 1º/2º ESO, es imprescindible que los estudiantes entiendan que la IA no “busca fotos en Google”, sino que realiza un proceso matemático inverso de eliminación de ruido.
El flujo de trabajo que experimentan en pantalla se compone de cuatro fases:
[Prompt de Texto] ──> (1. Text Encoder) ──> [Vectores Numéricos]
│
[Lienzo de Ruido Aleatorio (512x512)] <──────────┘
│
▼
(2. Desruido Progresivo en 4 Pasos con U-Net)
│
▼
(3. Decodificación VAE) ──> [Imagen PNG Final]
- Comprensión del Texto (Text Encoder): El prompt escrito por el alumno (por ejemplo, “an astronaut cat exploring Mars”) se traduce a una representación matemática de vectores de significado.
- Lienzo de Ruido (Gaussian Noise): El proceso no empieza con un lienzo en blanco, sino con una cuadrícula de $512 \times 512$ píxeles llena de “ruido estático” aleatorio (similar a la nieve de los televisores antiguos).
- Proceso de Desruido en 4 Pasos (Denoising): La red neuronal analiza el ruido y los vectores del texto, restando matemáticamente las frecuencias que no corresponden a la descripción. En cada paso, la imagen emerge desde la incertidumbre.
- Decodificación (VAE): El latente matemático procesado se convierte finalmente en un archivo de imagen en formato
.pngvisible para el usuario.
3. Guía de Instalación y Archivos Necesarios
La instalación se realiza en el equipo que actuará como servidor central de la red del centro. No requiere instalar PyTorch, Diffusers ni drivers CUDA.
Descargas Necesarias (Enlaces Directos):
- Ejecutable
stable-diffusion.cpppara CPU:- Descargar desde el repositorio oficial: GitHub Releases – leejet/stable-diffusion.cpp
- Archivo recomendado para Windows:
sd-master-XXXXXXX-bin-win-cpu-x64.zip(Descomprimir para obtenersd-cli.exe).
- Modelo Cuantizado SD-Turbo (GGUF Q4_0 – ~1.6 GB):
- Descargar desde Hugging Face: gpustack/stable-diffusion-v2-1-turbo-Q4_0.gguf
- Repositorio general del modelo: Página del modelo en Hugging Face
Estructura de la carpeta del proyecto:
Crea una carpeta única (por ejemplo, servidor-monteia) y organiza los archivos descargados de la siguiente forma:
Plaintext
servidor-monteia/
├── sd-cli.exe # Ejecutable de stable-diffusion.cpp
├── stable-diffusion-v2-1-turbo-Q4_0.gguf # Modelo cuantizado a 4-bits
├── outputs/ # Carpeta automática de imágenes
└── app_genima.py # Servidor web FastAPI


4. Código Fuente Completo del Servidor Web (app_genima.py)
Guardamos el siguiente código como app_genima.py. Incluye la interfaz adaptada con la identidad visual de MonteSTEAM / IES Monterroso, selector de prompts de ejemplo, barra de progreso animada durante los 4 pasos de cálculo y pestaña de explicación teórica.
Python
import asyncio
import os
import uuid
from fastapi import FastAPI, HTTPException
from fastapi.responses import HTMLResponse, FileResponse
from pydantic import BaseModel
app = FastAPI(title="MonteIA - Generación de Imágenes Local")
# Rutas absolutas del sistema
SD_BIN = os.path.abspath("./sd-cli.exe")
MODEL_PATH = os.path.abspath("./stable-diffusion-v2-1-turbo-Q4_0.gguf")
OUTPUT_DIR = os.path.abspath("./outputs")
os.makedirs(OUTPUT_DIR, exist_ok=True)
class ImageRequest(BaseModel):
prompt: str
HTML_CONTENT = """
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PROYECTO MONTEIA - Generador de Imágenes Local</title>
<style>
:root {
--bg-page: #f1f5f9;
--nav-bg: #0f2b48;
--banner-bg: #2d0a16;
--primary-blue: #0284c7;
--primary-red: #e11d48;
--text-main: #0f172a;
--border-color: #e2e8f0;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-page); color: var(--text-main); }
/* Cabecera Superior estilo MonteSTEAM */
header {
background: var(--nav-bg);
color: white;
padding: 12px 24px;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header-left { display: flex; align-items: center; gap: 12px; }
.brand-logo { background: white; color: var(--nav-bg); font-weight: 900; padding: 6px 10px; border-radius: 6px; font-size: 18px; }
.header-titles h1 { font-size: 18px; font-weight: 800; letter-spacing: 0.5px; display: flex; align-items: center; gap: 8px; }
.header-pills { display: flex; gap: 6px; font-size: 11px; margin-bottom: 2px; }
.pill { padding: 2px 8px; border-radius: 4px; font-weight: 600; text-transform: uppercase; }
.pill-gray { background: rgba(255,255,255,0.15); color: #e2e8f0; }
.pill-red { background: var(--primary-red); color: white; }
.header-status { background: rgba(225, 29, 72, 0.2); border: 1px solid var(--primary-red); color: #fecdd3; padding: 6px 12px; border-radius: 20px; font-size: 13px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
.status-dot { width: 8px; height: 8px; background: #22c55e; border-radius: 50%; display: inline-block; }
/* Contenedor Principal */
.container { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
/* Banner Burdeos */
.banner {
background: var(--banner-bg);
color: white;
border-radius: 12px;
padding: 20px 24px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
}
.banner-content { display: flex; align-items: center; gap: 16px; }
.banner-icon { background: rgba(255,255,255,0.1); padding: 12px; border-radius: 50%; font-size: 24px; }
.banner-text h2 { font-size: 13px; text-transform: uppercase; color: #f43f5e; font-weight: 700; letter-spacing: 0.5px; }
.banner-text p { font-size: 18px; font-weight: 700; margin-top: 2px; }
.banner-quote { background: rgba(0,0,0,0.25); padding: 10px 16px; border-radius: 8px; font-style: italic; font-size: 13px; color: #cbd5e1; max-width: 320px; border-left: 3px solid var(--primary-red); }
/* Grid de Trabajo */
.main-grid { display: grid; grid-template-columns: 1fr 1.2fr; gap: 24px; }
.card { background: white; border-radius: 12px; border: 1px solid var(--border-color); padding: 20px; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
.card-title { font-size: 14px; font-weight: 800; text-transform: uppercase; color: #475569; margin-bottom: 16px; display: flex; align-items: center; gap: 8px; }
label { font-size: 13px; font-weight: 600; color: #334155; display: block; margin-bottom: 6px; }
select, textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 14px; margin-bottom: 14px; font-family: inherit; }
textarea { height: 90px; resize: none; }
textarea:focus, select:focus { outline: 2px solid var(--primary-blue); border-color: transparent; }
.btn-primary { width: 100%; padding: 12px; background: var(--primary-blue); color: white; border: none; border-radius: 8px; font-size: 15px; font-weight: 700; cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 8px; transition: background 0.2s; }
.btn-primary:hover { background: #0284c7; }
.btn-primary:disabled { background: #94a3b8; cursor: not-allowed; }
/* Barra de Progreso */
.progress-box { margin-top: 16px; display: none; }
.progress-header { display: flex; justify-content: space-between; font-size: 12px; font-weight: 600; color: #475569; margin-bottom: 6px; }
.progress-bar-bg { width: 100%; background: #e2e8f0; height: 10px; border-radius: 5px; overflow: hidden; }
.progress-bar-fill { width: 0%; height: 100%; background: linear-gradient(90deg, var(--primary-blue), var(--primary-red)); transition: width 0.4s ease; }
/* Pestañas */
.tabs { display: flex; border-bottom: 2px solid var(--border-color); margin-bottom: 16px; }
.tab-btn { padding: 8px 16px; border: none; background: none; font-weight: 700; font-size: 14px; color: #64748b; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; }
.tab-btn.active { color: var(--primary-blue); border-bottom-color: var(--primary-blue); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.img-placeholder { border: 2px dashed var(--border-color); border-radius: 8px; min-height: 320px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #94a3b8; font-size: 14px; gap: 8px; }
.result-img { width: 100%; max-height: 400px; object-fit: contain; border-radius: 8px; display: none; border: 1px solid var(--border-color); }
.explain-step { background: #f8fafc; border-left: 3px solid var(--primary-blue); padding: 10px 14px; border-radius: 0 6px 6px 0; margin-bottom: 10px; font-size: 13px; }
.explain-step strong { color: var(--nav-bg); }
</style>
</head>
<body>
<header>
<div class="header-left">
<div class="brand-logo">JB</div>
<div class="header-titles">
<div class="header-pills">
<span class="pill pill-gray">IES MONTERROSO — NIVEL 1º/2º ESO</span>
<span class="pill pill-red">Puerto :8066</span>
</div>
<h1>PROYECTO MONTEIA <span class="pill pill-red" style="font-size:10px;">GENERADOR SD-TURBO LOCAL</span></h1>
</div>
</div>
<div class="header-status">
<span class="status-dot"></span> app_genima.py Activo (:8066)
</div>
</header>
<div class="container">
<div class="banner">
<div class="banner-content">
<div class="banner-icon">🎨</div>
<div class="banner-text">
<h2>TALLER DE GENERACIÓN DE IMÁGENES CON IA (1º/2º ESO)</h2>
<p>Escribe tu prompt y descubre cómo la IA crea arte desde el ruido matemático</p>
</div>
</div>
<div class="banner-quote">
"De la descripción en texto al renderizado píxel a píxel en CPU local."
</div>
</div>
<div class="main-grid">
<div class="card">
<div class="card-title">
<span style="color:var(--primary-red);">🔴</span> 1. CREA TU PROMPT
</div>
<label for="presetSelect">Ejemplos de sugerencia:</label>
<select id="presetSelect">
<option value="">-- Selecciona una idea rápida --</option>
<option value="a sports red car driving in a futuristic city at sunset, 8k">Coche deportivo rojo en ciudad futurista</option>
<option value="an astronaut cat exploring Mars surface, detailed digital art">Gato astronauta explorando Marte</option>
<option value="a medieval castle on top of a snowy mountain, high quality">Castillo medieval en montaña nevada</option>
</select>
<label for="promptInput">Prompt en inglés (instrucción gráfica):</label>
<textarea id="promptInput" placeholder="Ej: A cute robot sitting in a forest with flowers, digital art..."></textarea>
<button class="btn-primary" id="generateBtn">
<span>✨</span> Generar Imagen con IA
</button>
<div class="progress-box" id="progressBox">
<div class="progress-header">
<span id="progressStep">Iniciando modelo SD-Turbo...</span>
<span id="progressPercent">0%</span>
</div>
<div class="progress-bar-bg">
<div class="progress-bar-fill" id="progressBarFill"></div>
</div>
</div>
</div>
<div class="card">
<div class="tabs">
<button class="tab-btn active" onclick="switchTab('tabResult', this)">🖼️ Resultado</button>
<button class="tab-btn" onclick="switchTab('tabExplain', this)">💡 ¿Cómo funciona la Difusión?</button>
</div>
<div id="tabResult" class="tab-content active">
<div class="img-placeholder" id="imgPlaceholder">
<span style="font-size:32px;">🖼️</span>
<span>Escribe un prompt y haz clic en 'Generar Imagen'</span>
</div>
<img id="resultImg" class="result-img" alt="Imagen generada">
</div>
<div id="tabExplain" class="tab-content">
<div class="explain-step">
<strong>1. Comprensión del Texto (Text Encoder):</strong>
La IA convierte las palabras del prompt en una serie de valores numéricos o vectores.
</div>
<div class="explain-step">
<strong>2. Lienzo de Ruido Aleatorio:</strong>
El modelo empieza con un lienzo de 512x512 lleno de ruido estático.
</div>
<div class="explain-step">
<strong>3. Desruido en 4 Pasos (SD-Turbo):</strong>
Mediante un proceso matemático (U-Net), el modelo elimina progresivamente el ruido reconociendo las formas solicitadas.
</div>
<div class="explain-step">
<strong>4. Decodificación VAE:</strong>
Traduce la matriz matemática limpia en el archivo de imagen .png visible.
</div>
<p style="font-size: 11px; color: #94a3b8; margin-top: 12px; font-style: italic;">
Fuente: Servidor local ejecutable sd-cli.exe (Modelo GGUF cuantizado a 4-bits)
</p>
</div>
</div>
</div>
</div>
<script>
function switchTab(tabId, btn) {
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
document.querySelectorAll('.tab-btn').forEach(el => el.classList.remove('active'));
document.getElementById(tabId).classList.add('active');
btn.classList.add('active');
}
document.getElementById('presetSelect').addEventListener('change', (e) => {
if (e.target.value) {
document.getElementById('promptInput').value = e.target.value;
}
});
document.getElementById('generateBtn').addEventListener('click', async () => {
const prompt = document.getElementById('promptInput').value.trim();
if (!prompt) {
alert('Por favor, escribe un prompt antes de generar.');
return;
}
const btn = document.getElementById('generateBtn');
const progressBox = document.getElementById('progressBox');
const progressBarFill = document.getElementById('progressBarFill');
const progressPercent = document.getElementById('progressPercent');
const progressStep = document.getElementById('progressStep');
const imgPlaceholder = document.getElementById('imgPlaceholder');
const resultImg = document.getElementById('resultImg');
btn.disabled = true;
progressBox.style.display = 'block';
imgPlaceholder.style.display = 'flex';
resultImg.style.display = 'none';
let progress = 0;
progressBarFill.style.width = '0%';
progressPercent.innerText = '0%';
progressStep.innerText = 'Paso 1/4: Generando ruido Gaussiano...';
const timer = setInterval(() => {
if (progress < 90) {
progress += Math.random() * 2 + 1;
const rounded = Math.round(progress);
progressBarFill.style.width = rounded + '%';
progressPercent.innerText = rounded + '%';
if (progress > 25 && progress <= 50) {
progressStep.innerText = 'Paso 2/4: Eliminando ruido de fondo...';
} else if (progress > 50 && progress <= 75) {
progressStep.innerText = 'Paso 3/4: Definiendo formas y estructuras...';
} else if (progress > 75) {
progressStep.innerText = 'Paso 4/4: Decodificando VAE y renderizando...';
}
}
}, 800);
try {
const response = await fetch('/v1/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: prompt })
});
if (!response.ok) throw new Error('Error al generar la imagen');
const blob = await response.blob();
clearInterval(timer);
progressBarFill.style.width = '100%';
progressPercent.innerText = '100%';
progressStep.innerText = '¡Imagen completada!';
resultImg.src = URL.createObjectURL(blob);
imgPlaceholder.style.display = 'none';
resultImg.style.display = 'block';
switchTab('tabResult', document.querySelectorAll('.tab-btn')[0]);
} catch (err) {
clearInterval(timer);
alert('Ocurrió un error en el servidor durante la generación.');
} finally {
btn.disabled = false;
}
});
</script>
</body>
</html>
"""
@app.get("/", response_class=HTMLResponse)
async def serve_index():
return HTML_CONTENT
@app.post("/v1/generate")
async def generate_image(request: ImageRequest):
if not os.path.exists(SD_BIN) or not os.path.exists(MODEL_PATH):
raise HTTPException(status_code=500, detail="Ejecutable o modelo no encontrado.")
image_filename = f"{uuid.uuid4()}.png"
output_path = os.path.join(OUTPUT_DIR, image_filename)
cmd = [
SD_BIN,
"-m", MODEL_PATH,
"-p", request.prompt,
"--steps", "4",
"-o", output_path,
"-W", "512",
"-H", "512"
]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if proc.returncode != 0 or not os.path.exists(output_path):
raise HTTPException(status_code=500, detail="Falló la ejecución de sd-cli.exe")
return FileResponse(output_path, media_type="image/png")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
5. Puesta en Marcha y Dinámica en el Aula
- Instalar dependencias ligeras en Python:PowerShell
pip install fastapi uvicorn - Ejecutar el servidor en la red del instituto:PowerShell
uvicorn app_genima:app --host 0.0.0.0 --port 8066 - Uso por parte de los alumnos:
- Cada alumno abre el navegador e introduce la IP local del equipo servidor:
http://<IP_SERVIDOR>:8066. - Introducen un prompt en inglés o eligen un ejemplo del desplegable.
- Mientras la imagen se procesa en el servidor central (aprox. 1 minuto por petición en CPU), observan las fases de desruido en la barra de progreso y consultan la pestaña ¿Cómo funciona la Difusión? para debatir sobre cómo la computadora interpreta los conceptos visuales.
- Cada alumno abre el navegador e introduce la IP local del equipo servidor:
Etiqueta:1º ESO, 2º ESO, AIDARAC, Educación, fastapi, generación de imágenes, gguf, ia local, ies monterroso, montesteam, sd-turbo, Stable Diffusion



