Módulo 4: Generación de Imágenes
6. Edición y Variaciones
Descripción
Generar imágenes desde cero es solo el primer paso. En producción, la mayoría del trabajo con imágenes es edición: reemplazar el fondo de un producto, añadir un elemento que falta, expandir una imagen para un formato diferente, generar variaciones de un concepto aprobado. Estas operaciones — inpainting, outpainting, variaciones, img2img — son las que convierten la generación de imágenes en una herramienta práctica.
Por qué importa: Un e-commerce no genera cada foto de producto desde cero. Genera una base y luego edita: cambia el fondo para la temporada, reemplaza el color del producto, expande la imagen para banners. Una agencia de marketing no crea 10 conceptos desde cero — genera uno bueno y produce variaciones. Dominar la edición reduce costos, mejora la consistencia y acelera los flujos.
Conexión con el módulo: En las cápsulas 02 y 03 usaste las APIs de generación. En la cápsula 05 optimizaste los prompts. Ahora vas a aprender a editar lo que ya generaste. Estas técnicas se integran directamente en los pipelines de la cápsula 07 y en el proyecto final (cápsula 08).
Conceptos Clave
Inpainting
Reemplazar una zona específica de una imagen usando una máscara. La máscara define qué área se modifica (transparente) y qué se conserva (opaca). El modelo genera contenido nuevo solo en la zona enmascarada.
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Imagen base │ + │ Máscara │ → │ Resultado │
│ [gato en sofá]│ │ [sofá transp]│ │ [gato en │
│ │ │ │ │ sillón cuero]│
└──────────────┘ └──────────────┘ └──────────────┘
Outpainting
Expandir la imagen más allá de sus bordes originales. Útil para adaptar imágenes a formatos diferentes (cuadrada → panorámica).
Variaciones
Generar imágenes nuevas que mantienen la esencia visual de una referencia. No son copias — son reinterpretaciones del mismo concepto.
img2img (Image-to-Image)
Tomar una imagen existente y transformarla según un prompt. El parámetro strength controla cuánto se desvía del original (0.0 = idéntica, 1.0 = ignora la original).
Creación de Máscaras con Pillow
Máscara rectangular
from PIL import Image, ImageDraw
def create_rectangular_mask(
width: int, height: int, x1: int, y1: int, x2: int, y2: int,
output_path: str = "mask.png",
) -> str:
mask = Image.new("RGBA", (width, height), (0, 0, 0, 255))
draw = ImageDraw.Draw(mask)
draw.rectangle([x1, y1, x2, y2], fill=(0, 0, 0, 0))
mask.save(output_path)
return output_path
create_rectangular_mask(1024, 1024, 200, 200, 800, 600)
Máscara circular
def create_circular_mask(
width: int, height: int, center_x: int, center_y: int, radius: int,
output_path: str = "mask_circle.png",
) -> str:
mask = Image.new("RGBA", (width, height), (0, 0, 0, 255))
draw = ImageDraw.Draw(mask)
draw.ellipse(
[center_x - radius, center_y - radius, center_x + radius, center_y + radius],
fill=(0, 0, 0, 0),
)
mask.save(output_path)
return output_path
Máscara desde zona de color
Para enmascarar zonas basándose en su color (ej. fondo blanco de una foto de producto):
import numpy as np
from PIL import Image
def create_mask_from_color(
image_path: str,
target_color: tuple[int, int, int],
tolerance: int = 30,
output_path: str = "mask_color.png",
) -> str:
img = Image.open(image_path).convert("RGB")
img_array = np.array(img)
target = np.array(target_color)
distance = np.sqrt(np.sum((img_array.astype(float) - target.astype(float)) ** 2, axis=2))
mask_array = np.ones((*img_array.shape[:2], 4), dtype=np.uint8) * 255
mask_array[distance < tolerance] = [0, 0, 0, 0]
Image.fromarray(mask_array, "RGBA").save(output_path)
return output_path
Máscara con borde suave (feathering)
Bordes duros producen transiciones abruptas. El feathering suaviza para un resultado natural:
from PIL import Image, ImageDraw, ImageFilter
def create_feathered_mask(
width: int, height: int, x1: int, y1: int, x2: int, y2: int,
feather_radius: int = 20, output_path: str = "mask_feathered.png",
) -> str:
alpha = Image.new("L", (width, height), 255)
draw = ImageDraw.Draw(alpha)
draw.rectangle([x1, y1, x2, y2], fill=0)
alpha = alpha.filter(ImageFilter.GaussianBlur(radius=feather_radius))
mask = Image.new("RGBA", (width, height))
mask.putalpha(alpha)
mask.save(output_path)
return output_path
Inpainting con DALL-E 2 (OpenAI)
Requisitos de la API
- Imagen: PNG, cuadrada, menos de 4 MB
- Máscara: PNG con canal alfa, mismas dimensiones
- Zonas transparentes = zonas a editar
- Zonas opacas = zonas a preservar
Implementación
from openai import OpenAI
from pathlib import Path
client = OpenAI()
def inpaint_dalle(
image_path: str, mask_path: str, prompt: str,
size: str = "1024x1024", n: int = 1,
) -> list[str]:
with open(image_path, "rb") as f_img, open(mask_path, "rb") as f_mask:
response = client.images.edit(
image=f_img, mask=f_mask, prompt=prompt, n=n, size=size,
)
return [item.url for item in response.data]
urls = inpaint_dalle(
"room_photo.png", "furniture_mask.png",
"a modern minimalist wooden desk with a laptop and a small plant", n=2,
)
for i, url in enumerate(urls):
print(f"Variación {i + 1}: {url}")
Preparar imagen para la API
from PIL import Image
def prepare_image_for_edit(image_path: str, target_size: int = 1024) -> str:
output_path = str(Path(image_path).with_suffix(".prepared.png"))
img = Image.open(image_path).convert("RGBA")
w, h = img.size
side = max(w, h)
square = Image.new("RGBA", (side, side), (0, 0, 0, 0))
square.paste(img, ((side - w) // 2, (side - h) // 2))
square = square.resize((target_size, target_size), Image.LANCZOS)
square.save(output_path, "PNG")
return output_path
Variaciones con DALL-E 2
Las variaciones generan imágenes similares sin necesidad de prompt:
from openai import OpenAI
import urllib.request
client = OpenAI()
def create_variations(image_path: str, n: int = 3, size: str = "1024x1024") -> list[dict]:
with open(image_path, "rb") as f:
response = client.images.create_variation(image=f, n=n, size=size)
return [{"index": i, "url": item.url} for i, item in enumerate(response.data)]
def download_image(url: str, output_path: str) -> str:
urllib.request.urlretrieve(url, output_path)
return output_path
Variaciones con evaluación automática
import json
import base64
import urllib.request
from openai import OpenAI
client = OpenAI()
def best_variation(image_path: str, criteria: str, n_variations: int = 3) -> dict:
variations = create_variations(image_path, n=n_variations)
scored = []
for var in variations:
img_data = urllib.request.urlopen(var["url"]).read()
b64 = base64.b64encode(img_data).decode("utf-8")
eval_resp = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": f"Evalúa según: '{criteria}'. JSON: {{\"score\": 0-1, \"reason\": \"...\"}}"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
],
}],
max_tokens=100,
)
try:
evaluation = json.loads(eval_resp.choices[0].message.content)
except json.JSONDecodeError:
evaluation = {"score": 0.5, "reason": "Parse error"}
scored.append({**var, **evaluation})
scored.sort(key=lambda x: x.get("score", 0), reverse=True)
return {"best": scored[0], "all_scores": [(s["index"], s.get("score", 0)) for s in scored]}
img2img con Stable Diffusion (Replicate)
prompt_strength controla la transformación: 0.0 = idéntica, 0.5 = mantiene estructura, cambia estilo, 1.0 = genera desde cero.
import replicate
import base64
def img2img_sd(
image_path: str, prompt: str,
negative_prompt: str = "blurry, low quality, distorted",
prompt_strength: float = 0.6,
guidance_scale: float = 7.5,
) -> str:
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode("utf-8")
output = replicate.run(
"stability-ai/sdxl:39a52a2a03a4faf0651640ac8a542059c52f2d04fc26c8b83e22b0a957ffedd3",
input={
"image": f"data:image/png;base64,{b64}",
"prompt": prompt,
"negative_prompt": negative_prompt,
"prompt_strength": prompt_strength,
"guidance_scale": guidance_scale,
},
)
return output[0] if isinstance(output, list) else str(output)
url = img2img_sd(
"sketch.png",
"detailed digital illustration, fantasy landscape, vivid colors, professional concept art",
prompt_strength=0.7,
)
print(f"Resultado: {url}")
Explorar niveles de transformación
def explore_strength_levels(
image_path: str, prompt: str,
strengths: list[float] = None,
) -> list[dict]:
if strengths is None:
strengths = [0.3, 0.5, 0.7, 0.9]
results = []
for strength in strengths:
url = img2img_sd(image_path, prompt, prompt_strength=strength)
label = "sutil" if strength < 0.4 else "moderado" if strength < 0.7 else "fuerte"
results.append({"strength": strength, "url": url, "description": label})
return results
Inpainting con Stable Diffusion (Replicate)
import replicate
import base64
def inpaint_sd(
image_path: str, mask_path: str, prompt: str,
negative_prompt: str = "blurry, low quality, distorted, artifacts",
guidance_scale: float = 7.5,
num_inference_steps: int = 30,
) -> str:
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode("utf-8")
with open(mask_path, "rb") as f:
mask_b64 = base64.b64encode(f.read()).decode("utf-8")
output = replicate.run(
"stability-ai/stable-diffusion-inpainting:95b7223104132402a9ae91cc677285bc5eb997834bd2349fa486f53910fd68b3",
input={
"image": f"data:image/png;base64,{img_b64}",
"mask": f"data:image/png;base64,{mask_b64}",
"prompt": prompt,
"negative_prompt": negative_prompt,
"guidance_scale": guidance_scale,
"num_inference_steps": num_inference_steps,
},
)
return output[0] if isinstance(output, list) else str(output)
Outpainting: Expandir Imágenes
Outpainting = crear canvas más grande + colocar imagen original + enmascarar zonas vacías + inpainting.
from PIL import Image
def prepare_outpaint(
image_path: str,
expand_left: int = 0, expand_right: int = 0,
expand_top: int = 0, expand_bottom: int = 0,
output_prefix: str = "outpaint",
) -> dict:
img = Image.open(image_path).convert("RGBA")
orig_w, orig_h = img.size
new_w = orig_w + expand_left + expand_right
new_h = orig_h + expand_top + expand_bottom
canvas = Image.new("RGBA", (new_w, new_h), (255, 255, 255, 255))
canvas.paste(img, (expand_left, expand_top))
mask = Image.new("RGBA", (new_w, new_h), (0, 0, 0, 0))
opaque = Image.new("RGBA", (orig_w, orig_h), (0, 0, 0, 255))
mask.paste(opaque, (expand_left, expand_top))
canvas_path = f"{output_prefix}_canvas.png"
mask_path = f"{output_prefix}_mask.png"
canvas.save(canvas_path)
mask.save(mask_path)
return {"canvas_path": canvas_path, "mask_path": mask_path, "new_size": (new_w, new_h)}
outpaint_data = prepare_outpaint("portrait_square.png", expand_left=256, expand_right=256)
result_url = inpaint_sd(
outpaint_data["canvas_path"], outpaint_data["mask_path"],
"continuation of the scene, matching style and lighting, seamless extension",
)
Casos de Uso: Background Replacement
def replace_product_background(
product_image: str, new_background_prompt: str, provider: str = "dalle",
) -> dict:
mask_path = create_mask_from_color(product_image, target_color=(255, 255, 255), tolerance=40)
if provider == "dalle":
prepared = prepare_image_for_edit(product_image)
urls = inpaint_dalle(prepared, mask_path, new_background_prompt)
return {"url": urls[0], "provider": "dalle"}
else:
url = inpaint_sd(product_image, mask_path, new_background_prompt)
return {"url": url, "provider": "sd"}
backgrounds = [
"modern kitchen counter, marble surface, soft natural light",
"outdoor garden table with flowers, sunny day",
"minimalist studio, gradient gray background",
]
for bg in backgrounds:
result = replace_product_background("product_white_bg.png", bg)
print(f" {bg[:40]}... → {result['url']}")
Comparación de Capacidades por Proveedor
| Capacidad | DALL-E 2 (OpenAI) | DALL-E 3 (OpenAI) | Stable Diffusion (Replicate) |
|---|---|---|---|
| Inpainting | Sí (edit endpoint) | No | Sí (modelo dedicado) |
| Outpainting | Manual con masks | No | Sí (con preparación) |
| Variaciones | Sí (variations endpoint) | No | Via img2img (strength bajo) |
| img2img | No | No | Sí (parámetro nativo) |
| Negative prompts | No | No | Sí |
| Seed control | No | No | Sí |
| Costo por edit | ~$0.020 | N/A | ~$0.003-0.010 |
Troubleshooting
Máscara no funciona con DALL-E
Síntoma: El edit endpoint ignora la máscara o edita toda la imagen.
Causa: Máscara sin canal alfa correcto o dimensiones diferentes.
from PIL import Image
def validate_mask(image_path: str, mask_path: str) -> dict:
img = Image.open(image_path)
mask = Image.open(mask_path)
issues = []
if img.size != mask.size:
issues.append(f"Dimensiones: imagen={img.size}, máscara={mask.size}")
if mask.mode != "RGBA":
issues.append(f"Máscara no es RGBA (es {mask.mode})")
if mask.mode == "RGBA":
alpha_values = set(mask.split()[3].getdata())
if 0 not in alpha_values:
issues.append("Sin zonas transparentes (nada que editar)")
return {"valid": len(issues) == 0, "issues": issues}
img2img produce resultados muy diferentes
Causa: prompt_strength > 0.8 o guidance_scale muy alto.
Solución: Reducir prompt_strength a 0.3-0.5 y guidance_scale a 5-7.
Bordes visibles en inpainting
Causa: Máscara con bordes duros o diferencia de iluminación.
Solución: Usar feathered masks y prompts que describan la iluminación coherente:
def fix_seams(image_path: str, mask_bbox: tuple, prompt: str) -> str:
w, h = Image.open(image_path).size
feathered = create_feathered_mask(w, h, *mask_bbox, feather_radius=40)
enhanced = f"{prompt}, matching lighting and color temperature, seamless blend"
return inpaint_sd(image_path, feathered, enhanced, num_inference_steps=50)
Ejercicios
Ejercicio 1: Editor de producto multi-fondo
Crea un sistema que tome una foto de producto con fondo blanco y genere 3 versiones con fondos diferentes (estudio, exterior, lifestyle). Debe crear la máscara automáticamente y retornar las URLs con metadata.
Ver solución
import numpy as np
from PIL import Image
from openai import OpenAI
client = OpenAI()
BACKGROUND_SCENES = {
"studio": "clean studio, soft gradient background, professional product lighting",
"outdoor": "natural outdoor, wooden table, soft bokeh green garden, golden hour",
"lifestyle": "modern living room, design furniture, warm ambient light",
}
def auto_mask_white_bg(image_path: str, tolerance: int = 35) -> str:
img = Image.open(image_path).convert("RGB")
arr = np.array(img)
white = np.array([255, 255, 255])
distance = np.sqrt(np.sum((arr.astype(float) - white.astype(float)) ** 2, axis=2))
mask_arr = np.ones((*arr.shape[:2], 4), dtype=np.uint8) * 255
mask_arr[distance < tolerance] = [0, 0, 0, 0]
mask = Image.fromarray(mask_arr, "RGBA")
mask.save("auto_mask.png")
return "auto_mask.png"
def multi_background_product(image_path: str) -> list[dict]:
mask_path = auto_mask_white_bg(image_path)
prepared = prepare_image_for_edit(image_path)
results = []
for scene_name, scene_prompt in BACKGROUND_SCENES.items():
try:
urls = inpaint_dalle(prepared, mask_path, f"product on {scene_prompt}", n=1)
results.append({"scene": scene_name, "url": urls[0], "success": True})
except Exception as e:
results.append({"scene": scene_name, "url": None, "success": False, "error": str(e)})
return results
product_results = multi_background_product("headphones_white_bg.png")
for r in product_results:
print(f" {r['scene']}: {'OK' if r['success'] else r.get('error', '')}")
Ejercicio 2: Pipeline de variaciones con ranking
Genera 4 variaciones de una imagen con DALL-E 2, evalúa cada una con GPT-4o Vision según un criterio dado, ordena por score y retorna el ranking.
Ver solución
import json
import base64
import urllib.request
from openai import OpenAI
client = OpenAI()
def ranked_variations(image_path: str, criteria: str, n: int = 4) -> dict:
with open(image_path, "rb") as f:
response = client.images.create_variation(image=f, n=n, size="1024x1024")
variations = [{"index": i, "url": item.url} for i, item in enumerate(response.data)]
evaluated = []
for var in variations:
img_bytes = urllib.request.urlopen(var["url"]).read()
b64 = base64.b64encode(img_bytes).decode("utf-8")
ev = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": (
f"Evalúa según: '{criteria}'\n"
'JSON: {{"score": 0-1, "strengths": [...], "weaknesses": [...]}}'
)},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}},
],
}],
max_tokens=200,
)
try:
scores = json.loads(ev.choices[0].message.content)
except json.JSONDecodeError:
scores = {"score": 0.5, "strengths": [], "weaknesses": ["Parse error"]}
evaluated.append({**var, **scores})
evaluated.sort(key=lambda x: x.get("score", 0), reverse=True)
return {
"ranking": [
{"rank": i + 1, "index": e["index"], "score": e.get("score", 0),
"strengths": e.get("strengths", []), "url": e["url"]}
for i, e in enumerate(evaluated)
],
"best_url": evaluated[0]["url"],
}
ranking = ranked_variations("brand_concept.png", "profesionalismo para sitio corporativo")
for item in ranking["ranking"]:
print(f" #{item['rank']}: score={item['score']}, strengths={item['strengths']}")
Ejercicio 3: Style transfer con img2img y comparación
Dada una imagen y varios estilos artísticos, genera versiones img2img con SD, evalúa cuál mantiene mejor la composición original con Vision API.
Ver solución
import json
import base64
import urllib.request
from openai import OpenAI
client = OpenAI()
STYLES = {
"watercolor": "watercolor painting, soft washes, paper texture, translucent colors",
"oil_painting": "classical oil painting, visible brushstrokes, rich colors, canvas texture",
"anime": "anime illustration, vibrant colors, clean lines, Studio Ghibli inspired",
"pencil": "detailed pencil sketch, graphite on paper, cross-hatching",
}
def style_transfer_comparison(image_path: str, strength: float = 0.65) -> dict:
with open(image_path, "rb") as f:
original_b64 = base64.b64encode(f.read()).decode("utf-8")
results = []
for style_name, style_prompt in STYLES.items():
url = img2img_sd(image_path, style_prompt, prompt_strength=strength)
styled_bytes = urllib.request.urlopen(url).read()
styled_b64 = base64.b64encode(styled_bytes).decode("utf-8")
ev = client.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": (
"Compara original (1ra) vs estilizada (2da).\n"
'JSON: {{"composition_preserved": 0-1, "style_quality": 0-1, "overall": 0-1}}'
)},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{original_b64}"}},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{styled_b64}"}},
],
}],
max_tokens=100,
)
try:
scores = json.loads(ev.choices[0].message.content)
except json.JSONDecodeError:
scores = {"composition_preserved": 0.5, "style_quality": 0.5, "overall": 0.5}
results.append({"style": style_name, "url": url, "scores": scores})
results.sort(key=lambda x: x["scores"].get("overall", 0), reverse=True)
return {"results": results, "best_style": results[0]["style"]}
comp = style_transfer_comparison("landscape_photo.png")
print(f"Mejor estilo: {comp['best_style']}")
for r in comp["results"]:
s = r["scores"]
print(f" {r['style']}: comp={s.get('composition_preserved')}, "
f"style={s.get('style_quality')}, overall={s.get('overall')}")
Recursos Adicionales
- OpenAI Image Edits — Endpoint de edición DALL-E 2
- OpenAI Variations — Endpoint de variaciones
- Replicate Image Editing — Modelos de edición
- Pillow Documentation — Manipulación de imágenes
- Stable Diffusion img2img — Guía de img2img