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.

ModeloVisionContext WindowCosto Input (1M tokens)Costo Output (1M tokens)Mejor uso
gpt-4o128K tokens$2.50$10.00Análisis detallado, OCR complejo, extracción estructurada
gpt-4o-mini128K tokens$0.15$0.60Clasificació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 tener content como 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. Usa 0 para tareas determinísticas (OCR, clasificación, extracción). Usa 0.71.0 para 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

CriterioBase64URL
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
LatenciaMá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 entre low y high según el tamaño de la imagen.

Costo por nivel

DetailTokens de imagenCosto aprox (gpt-4o)Costo aprox (gpt-4o-mini)
low85$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

EscenarioModelDetailInput tokens (aprox)Output tokensCosto estimado
Clasificación simplegpt-4o-minilow~120~10$0.000024
Descripción cortagpt-4olow~120~150$0.001800
OCR documento 1 páginagpt-4ohigh~900~800$0.010250
Extracción JSON productogpt-4ohigh~900~200$0.004250
Batch 1000 clasificacionesgpt-4o-minilow~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 content con objetos text e image_url.
  • gpt-4o para calidad máxima, gpt-4o-mini para volumen y bajo costo.
  • Imágenes se envían como Base64 (data URI) o URL pública.
  • El parámetro detail controla resolución y costo: low (85 tokens), high (85 + 170×tiles).
  • temperature=0 y response_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.usage para monitorear consumo real.

Recursos Adicionales

  1. OpenAI Vision Guide
  2. API Reference: Chat Completions
  3. OpenAI Pricing
  4. OpenAI Cookbook: Vision