Módulo 2: Vision + LLMs
2. GPT-4 Vision (OpenAI)
Descripción
GPT-4 Vision es la capacidad de los modelos gpt-4o y gpt-4o-mini de OpenAI para recibir imágenes junto con texto en la API de chat completions. No es un modelo separado — es el mismo modelo de lenguaje, pero con un encoder visual que transforma píxeles en tokens que el transformer puede procesar junto al texto.
En la cápsula anterior viste el panorama general de LLMs con visión y los tres proveedores principales. Esta cápsula se enfoca exclusivamente en OpenAI: la estructura exacta del request, cómo se codifican las imágenes, cómo controlar calidad y costo con el parámetro detail, y patrones prácticos que vas a reutilizar en el resto del módulo.
Si vienes del Módulo 1 (cápsula 06 — Formatos y APIs), ya conoces Base64, data URIs y tipos MIME. Aquí vas a aplicar todo eso directamente contra la API de OpenAI.
Lo que vas a construir: Cinco ejemplos funcionales — descripción, OCR, clasificación, extracción JSON y uso de system prompts — más una función para calcular costos antes de hacer la llamada.
Modelos con Vision
OpenAI ofrece dos modelos con capacidad visual. La diferencia principal es calidad vs. costo.
| Modelo | Vision | Context Window | Costo Input (1M tokens) | Costo Output (1M tokens) | Mejor uso |
|---|---|---|---|---|---|
gpt-4o | Sí | 128K tokens | $2.50 | $10.00 | Análisis detallado, OCR complejo, extracción estructurada |
gpt-4o-mini | Sí | 128K tokens | $0.15 | $0.60 | Clasificación, descripción rápida, alto volumen |
Cuándo elegir cada uno
Usa gpt-4o cuando necesitas precisión: extraer texto de documentos escaneados, analizar diagramas técnicos, o generar descripciones detalladas donde un error tiene costo. La diferencia de calidad es notable en tareas que requieren razonamiento sobre detalles finos de la imagen.
Usa gpt-4o-mini cuando procesas volumen: clasificar miles de imágenes en categorías, filtrar contenido, o hacer un primer pase antes de enviar casos difíciles a gpt-4o. A $0.15/1M tokens de input, puedes procesar ~6,600 imágenes simples por un dólar.
Una estrategia común en producción es un pipeline de dos etapas: gpt-4o-mini clasifica y filtra, gpt-4o analiza en profundidad solo lo que lo necesita.
Estructura del Request
La API de chat completions acepta imágenes dentro del array content de un mensaje. En lugar de enviar un string como contenido, envías un array de objetos con tipo text o image_url.
Request con texto e imagen
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "¿Qué hay en esta imagen?"
},
{
"type": "image_url",
"image_url": {
"url": "https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"
}
}
]
}
],
max_tokens=512
)
print(response.choices[0].message.content)
Request solo con imagen
Puedes omitir el bloque text del array content y enviar solo image_url. El modelo genera una descripción por defecto, pero los resultados son más predecibles cuando incluyes un prompt explícito. Siempre prefiere incluir texto.
Parámetros clave
model:"gpt-4o"o"gpt-4o-mini". Ambos soportan visión.messages: Array de mensajes. Cada mensaje puede tenercontentcomo string (solo texto) o como array (multimodal).max_tokens: Límite de tokens en la respuesta. OpenAI no aplica un default bajo para vision — conviene fijarlo explícitamente para controlar costos.temperature: Controla aleatoriedad. Usa0para tareas determinísticas (OCR, clasificación, extracción). Usa0.7–1.0para descripciones creativas.
Input: Base64 vs URL
Hay dos formas de enviar una imagen a la API: como URL pública o codificada en Base64 dentro de un data URI.
Base64 con detección de MIME
En el Módulo 1 (cápsula 06) viste cómo codificar archivos a Base64. Aquí lo aplicamos con detección automática del tipo MIME, que es necesaria para construir el data URI correctamente.
import base64
import mimetypes
from pathlib import Path
from openai import OpenAI
client = OpenAI()
def load_image_as_data_uri(image_path: str) -> str:
path = Path(image_path)
if not path.exists():
raise FileNotFoundError(f"No se encontró: {image_path}")
mime_type, _ = mimetypes.guess_type(str(path))
if mime_type is None:
mime_type = "image/jpeg"
with open(path, "rb") as f:
encoded = base64.b64encode(f.read()).decode("utf-8")
return f"data:{mime_type};base64,{encoded}"
def analyze_local_image(image_path: str, prompt: str) -> str:
data_uri = load_image_as_data_uri(image_path)
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{"type": "image_url", "image_url": {"url": data_uri}}
]
}
],
max_tokens=1024
)
return response.choices[0].message.content
result = analyze_local_image("producto.jpg", "Describe este producto para un catálogo online.")
print(result)
URL pública
Para imágenes ya accesibles en la web, pasa la URL directamente sin codificar:
content = [
{"type": "text", "text": "¿Qué muestra esta imagen?"},
{
"type": "image_url",
"image_url": {"url": "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png"}
}
]
La URL debe ser pública. URLs con autenticación, tokens temporales expirados o IPs privadas fallan silenciosamente.
Cuándo usar cada uno
| Criterio | Base64 | URL |
|---|---|---|
| Imagen local | ✅ Único método | ❌ No aplica |
| Imagen en servidor propio | ✅ Evita exponer URLs | ✅ Si es pública |
| Imágenes grandes (>10 MB) | ⚠️ Payload pesado | ✅ Más eficiente |
| Latencia | Más lenta (envías bytes) | Más rápida (OpenAI descarga) |
| Seguridad | ✅ No expone ubicación | ⚠️ URL debe ser accesible |
La regla práctica: Base64 para archivos locales y datos sensibles, URL para imágenes ya públicas en la web.
Parámetro detail: low vs high vs auto
El parámetro detail controla cuánto procesamiento visual aplica el modelo. Afecta calidad del análisis y costo en tokens.
Los tres niveles
low: La imagen se redimensiona a 512×512. El modelo recibe una versión comprimida. Cuesta 85 tokens fijos sin importar el tamaño original.high: La imagen se procesa en su resolución original (hasta 2048×2048). Se divide en tiles de 512×512 y cada tile cuesta 170 tokens, más 85 tokens base. Total:85 + 170 × num_tiles.auto(default): OpenAI decide entrelowyhighsegún el tamaño de la imagen.
Costo por nivel
| Detail | Tokens de imagen | Costo aprox (gpt-4o) | Costo aprox (gpt-4o-mini) |
|---|---|---|---|
low | 85 | $0.000213 | $0.0000128 |
high (1024×1024, 4 tiles) | 765 | $0.001913 | $0.000115 |
high (2048×2048, 16 tiles) | 2,805 | $0.007013 | $0.000421 |
Cómo configurarlo
from openai import OpenAI
client = OpenAI()
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "¿Qué texto aparece en este documento?"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/document.png",
"detail": "high"
}
}
]
}
],
max_tokens=2048,
temperature=0
)
print(response.choices[0].message.content)
Cuándo usar cada nivel
low: Clasificación binaria (sí/no), detección de categoría general, verificar si una imagen contiene texto. Cualquier tarea donde los detalles finos no importan.high: OCR, lectura de documentos, análisis de diagramas, inspección de defectos, cualquier tarea donde necesitas leer texto pequeño o detectar detalles.auto: Cuando no controlas el tipo de imagen y prefieres que OpenAI decida. Útil en aplicaciones genéricas.
Ejemplo 1: Descripción de Imagen
from openai import OpenAI
client = OpenAI()
def describe_image(image_url: str, max_words: int = 100) -> str:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
f"Describe esta imagen en español, en máximo {max_words} palabras. "
"Incluye: sujeto principal, entorno, colores dominantes y estado de ánimo general."
)
},
{
"type": "image_url",
"image_url": {"url": image_url, "detail": "low"}
}
]
}
],
max_tokens=300,
temperature=0.7
)
return response.choices[0].message.content
description = describe_image(
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"
)
print(description)
El prompt pide idioma, longitud máxima y ejes de descripción — esto produce resultados consistentes. detail="low" porque no necesitamos resolución alta para descripción general, y temperature=0.7 para texto natural.
Ejemplo 2: OCR — Extracción de Texto
from openai import OpenAI
client = OpenAI()
def extract_text_from_image(image_url: str) -> str:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Extrae TODO el texto visible en esta imagen. "
"Preserva la estructura original: si hay una tabla, "
"represéntala como tabla markdown. Si hay párrafos, "
"mantén los saltos de línea. Si hay encabezados, "
"usa formato markdown con #. "
"No agregues interpretaciones, solo el texto tal como aparece."
)
},
{
"type": "image_url",
"image_url": {"url": image_url, "detail": "high"}
}
]
}
],
max_tokens=4096,
temperature=0
)
return response.choices[0].message.content
text = extract_text_from_image("https://example.com/invoice-scan.png")
print(text)
detail="high" es obligatorio para OCR — con low se pierden caracteres pequeños. temperature=0 para reproducción exacta. max_tokens=4096 porque un documento de una página puede generar 500–1500 tokens. El prompt pide formato markdown para tablas explícitamente; sin esto, el modelo lineariza las tablas perdiendo estructura columnar.
Ejemplo 3: Clasificación con Categorías Fijas
from openai import OpenAI
client = OpenAI()
CATEGORIES = ["producto", "persona", "paisaje", "documento", "comida", "otro"]
def classify_image(image_url: str) -> str:
categories_str = ", ".join(CATEGORIES)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
f"Clasifica esta imagen en exactamente UNA de estas categorías: {categories_str}. "
"Responde ÚNICAMENTE con el nombre de la categoría, sin puntuación ni explicación."
)
},
{
"type": "image_url",
"image_url": {"url": image_url, "detail": "low"}
}
]
}
],
max_tokens=20,
temperature=0
)
result = response.choices[0].message.content.strip().lower()
if result not in CATEGORIES:
return "otro"
return result
category = classify_image("https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg")
print(f"Categoría: {category}")
Usamos gpt-4o-mini porque clasificación es tarea simple. detail="low" y max_tokens=20 porque la respuesta es una sola palabra. La validación final asegura que siempre devolvemos una categoría válida — respuestas inesperadas caen a "otro".
Ejemplo 4: Extracción Estructurada (JSON)
import json
from openai import OpenAI
client = OpenAI()
def extract_product_info(image_url: str) -> dict:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Analiza esta imagen de producto y devuelve un JSON con esta estructura exacta:\n"
"{\n"
' "nombre": "nombre del producto",\n'
' "categoria": "electrónica|ropa|alimentos|hogar|otro",\n'
' "color_principal": "color dominante",\n'
' "texto_visible": ["lista", "de", "textos"],\n'
' "estado": "nuevo|usado|no_determinado"\n'
"}\n"
"Responde SOLO con el JSON válido."
)
},
{
"type": "image_url",
"image_url": {"url": image_url, "detail": "high"}
}
]
}
],
response_format={"type": "json_object"},
max_tokens=500,
temperature=0
)
raw = response.choices[0].message.content
try:
data = json.loads(raw)
except json.JSONDecodeError:
data = {"error": "JSON inválido", "raw_response": raw}
return data
info = extract_product_info("https://example.com/product-photo.jpg")
print(json.dumps(info, indent=2, ensure_ascii=False))
response_format={"type": "json_object"} fuerza JSON válido — sin él, el modelo a veces envuelve el JSON en markdown. El try/except es red de seguridad: con response_format activo es raro que falle, pero en producción siempre maneja el caso.
System Prompt para Vision
El mensaje de sistema (role: "system") establece contexto y personalidad antes de que el modelo vea la imagen. Es especialmente útil para tareas de dominio específico.
from openai import OpenAI
client = OpenAI()
def ecommerce_product_analysis(image_url: str) -> str:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": (
"Eres un experto en fotografía de producto para e-commerce. "
"Cuando analizas una imagen de producto, evalúas: calidad de iluminación, "
"composición, fondo, ángulo, y si la imagen cumple estándares de marketplace "
"(Amazon, MercadoLibre). Respondes en español con recomendaciones accionables."
)
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "Evalúa esta foto de producto y dame recomendaciones para mejorarla."
},
{
"type": "image_url",
"image_url": {"url": image_url, "detail": "high"}
}
]
}
],
max_tokens=1024,
temperature=0.3
)
return response.choices[0].message.content
feedback = ecommerce_product_analysis("https://example.com/my-product.jpg")
print(feedback)
Otros system prompts útiles: radiólogo para imágenes médicas ("Describes hallazgos usando terminología BIRADS..."), generador de alt text para accesibilidad web ("Generas texto alternativo siguiendo WCAG 2.1..."), inspector de calidad industrial ("Identificas defectos visuales en piezas manufacturadas...").
El system prompt solo consume tokens de texto, no de imagen. Es la forma más económica de mejorar la calidad sin cambiar de modelo.
Tokens y Costos
Cada respuesta incluye usage con el conteo exacto de tokens. Úsalo para monitorear consumo real:
usage = response.usage
print(f"Tokens de input: {usage.prompt_tokens}")
print(f"Tokens de output: {usage.completion_tokens}")
print(f"Tokens totales: {usage.total_tokens}")
Calcular costo
def calculate_cost(usage, model: str = "gpt-4o") -> dict:
pricing = {
"gpt-4o": {"input": 2.50, "output": 10.00},
"gpt-4o-mini": {"input": 0.15, "output": 0.60},
}
rates = pricing[model]
input_cost = (usage.prompt_tokens / 1_000_000) * rates["input"]
output_cost = (usage.completion_tokens / 1_000_000) * rates["output"]
return {
"input_cost": round(input_cost, 6),
"output_cost": round(output_cost, 6),
"total_cost": round(input_cost + output_cost, 6),
"model": model
}
cost = calculate_cost(response.usage, "gpt-4o")
print(f"Costo total: ${cost['total_cost']:.6f}")
Costos típicos por escenario
| Escenario | Model | Detail | Input tokens (aprox) | Output tokens | Costo estimado |
|---|---|---|---|---|---|
| Clasificación simple | gpt-4o-mini | low | ~120 | ~10 | $0.000024 |
| Descripción corta | gpt-4o | low | ~120 | ~150 | $0.001800 |
| OCR documento 1 página | gpt-4o | high | ~900 | ~800 | $0.010250 |
| Extracción JSON producto | gpt-4o | high | ~900 | ~200 | $0.004250 |
| Batch 1000 clasificaciones | gpt-4o-mini | low | ~120K | ~10K | $0.024000 |
Troubleshooting
1. Invalid image o imagen rechazada
Causa: Formato no soportado o Base64 corrupto. OpenAI acepta PNG, JPEG, GIF y WebP. Formatos como BMP, TIFF o SVG se rechazan.
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
def validate_image_format(path: str) -> bool:
ext = Path(path).suffix.lower()
if ext not in SUPPORTED_FORMATS:
raise ValueError(f"Formato {ext} no soportado. Usa: {SUPPORTED_FORMATS}")
return True
2. Request too large (413)
Causa: La imagen codificada en Base64 excede el límite del payload. OpenAI acepta imágenes hasta 20 MB, pero el payload total del request tiene límites prácticos.
from PIL import Image
def resize_if_needed(image_path: str, max_dimension: int = 2048) -> str:
img = Image.open(image_path)
if max(img.size) > max_dimension:
img.thumbnail((max_dimension, max_dimension))
resized_path = f"resized_{Path(image_path).name}"
img.save(resized_path)
return resized_path
return image_path
3. Respuesta vacía o genérica
Causa: Prompt demasiado vago o max_tokens muy bajo. El modelo trunca la respuesta y puede quedar incompleta.
Solución: Sé específico en el prompt (qué quieres, en qué formato, qué longitud) y asigna max_tokens suficientes. Para OCR de documentos, usa al menos 2048.
4. Rate limit (429)
Causa: Demasiadas requests por minuto. Las imágenes consumen más capacity que texto puro.
import time
def analyze_with_retry(func, *args, max_retries: int = 3):
for attempt in range(max_retries):
try:
return func(*args)
except Exception as e:
if "rate_limit" in str(e).lower() and attempt < max_retries - 1:
time.sleep(2 ** attempt)
else:
raise
5. El modelo "alucina" texto que no existe en la imagen
Causa: El modelo infiere texto basándose en contexto visual en lugar de leerlo. Es más frecuente con detail="low".
Solución: Usa detail="high", temperature=0, y agrega al prompt: "Extrae SOLO el texto que puedas leer con certeza. Si no puedes leer un fragmento, indícalo como [ilegible].".
Ejercicios
Ejercicio 1 (Fácil): Análisis de imagen con JSON
Escribe una función analyze_image_json que reciba una URL de imagen y devuelva un diccionario con dos campos: description (descripción en español, máximo 50 palabras) y detected_objects (lista de objetos detectados en la imagen).
Ver solución
import json
from openai import OpenAI
client = OpenAI()
def analyze_image_json(image_url: str) -> dict:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Analiza esta imagen y responde en JSON con esta estructura:\n"
"{\n"
' "description": "descripción en español, máximo 50 palabras",\n'
' "detected_objects": ["objeto1", "objeto2", "..."]\n'
"}\n"
"Solo JSON válido."
)
},
{
"type": "image_url",
"image_url": {"url": image_url, "detail": "low"}
}
]
}
],
response_format={"type": "json_object"},
max_tokens=300,
temperature=0
)
return json.loads(response.choices[0].message.content)
result = analyze_image_json(
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg"
)
print(f"Descripción: {result['description']}")
print(f"Objetos: {result['detected_objects']}")
Ejercicio 2 (Medio): Comparar dos imágenes
Escribe una función compare_images que reciba dos URLs de imágenes y devuelva un diccionario con similarity_score (entero del 1 al 10) y justification (texto explicando la puntuación). Ambas imágenes van en el mismo request.
Ver solución
import json
from openai import OpenAI
client = OpenAI()
def compare_images(image_url_1: str, image_url_2: str) -> dict:
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": (
"Compara estas dos imágenes. Responde en JSON:\n"
"{\n"
' "similarity_score": <entero del 1 al 10, donde 1 es completamente diferente y 10 es idéntica>,\n'
' "justification": "explicación en español de por qué asignaste ese puntaje"\n'
"}\n"
"Solo JSON válido."
)
},
{
"type": "image_url",
"image_url": {"url": image_url_1, "detail": "low"}
},
{
"type": "image_url",
"image_url": {"url": image_url_2, "detail": "low"}
}
]
}
],
response_format={"type": "json_object"},
max_tokens=300,
temperature=0
)
return json.loads(response.choices[0].message.content)
result = compare_images(
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat03.jpg/1200px-Cat03.jpg",
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/4d/Cat_November_2010-1a.jpg/1200px-Cat_November_2010-1a.jpg"
)
print(f"Similitud: {result['similarity_score']}/10")
print(f"Justificación: {result['justification']}")
Ejercicio 3 (Medio): Calculadora de costos pre-llamada
Escribe una función estimate_vision_cost que reciba la ruta de una imagen local, el modelo ("gpt-4o" o "gpt-4o-mini") y el nivel de detail ("low" o "high"). La función debe calcular los tokens de imagen estimados y el costo aproximado de input sin hacer la llamada a la API. Para high, calcula los tiles basándote en las dimensiones reales de la imagen.
Ver solución
import math
from PIL import Image
def estimate_vision_cost(
image_path: str,
model: str = "gpt-4o",
detail: str = "low"
) -> dict:
pricing = {
"gpt-4o": {"input": 2.50},
"gpt-4o-mini": {"input": 0.15},
}
if detail == "low":
image_tokens = 85
else:
img = Image.open(image_path)
width, height = img.size
max_dim = 2048
if max(width, height) > max_dim:
scale = max_dim / max(width, height)
width = int(width * scale)
height = int(height * scale)
min_side = 768
if min(width, height) > min_side:
scale = min_side / min(width, height)
width = int(width * scale)
height = int(height * scale)
tiles_x = math.ceil(width / 512)
tiles_y = math.ceil(height / 512)
num_tiles = tiles_x * tiles_y
image_tokens = 85 + 170 * num_tiles
rate = pricing[model]["input"]
estimated_cost = (image_tokens / 1_000_000) * rate
return {
"image_tokens": image_tokens,
"estimated_input_cost": round(estimated_cost, 8),
"model": model,
"detail": detail
}
estimate = estimate_vision_cost("foto_grande.jpg", "gpt-4o", "high")
print(f"Tokens de imagen: {estimate['image_tokens']}")
print(f"Costo estimado de input: ${estimate['estimated_input_cost']:.8f}")
Resumen
- La API de GPT-4 Vision usa el array
contentcon objetostexteimage_url. gpt-4opara calidad máxima,gpt-4o-minipara volumen y bajo costo.- Imágenes se envían como Base64 (data URI) o URL pública.
- El parámetro
detailcontrola resolución y costo:low(85 tokens),high(85 + 170×tiles). temperature=0yresponse_format={"type": "json_object"}para tareas determinísticas y estructuradas.- System prompts especializan al modelo para dominios específicos sin costo extra en tokens de imagen.
- Siempre lee
response.usagepara monitorear consumo real.