Módulo 1: Introducción a IA Multimodal

2. Modalidad Visión

Descripción

La modalidad visión permite que los LLMs procesen imágenes como input. En lugar de solo texto, puedes enviar una foto, un diagrama, un documento escaneado, y el modelo "ve" el contenido para describirlo, extraer texto (OCR), clasificarlo o responder preguntas. En esta cápsula aprenderás las capacidades de los modelos con visión, sus limitaciones, y los patrones de uso más comunes con código ejecutable.

Por qué importa: La visión es la modalidad multimodal más madura y extendida. GPT-4 Vision, Claude 3 y Gemini la soportan con alta calidad. Dominar visión es el primer paso para análisis documental, image Q&A, y RAG multimodal — temas que cubrirás en los módulos 2, 3 y 6.

Conexión con el módulo: En la cápsula 01 viste las modalidades como concepto. Aquí trabajas con la primera de ellas: visión. Lo que aprendas aquí se profundiza en el Módulo 2 (Vision + LLMs) y se integra en el Clasificador multimodal de la cápsula 08.


Cómo Funciona la Visión en LLMs

El concepto

Un LLM con visión recibe dos tipos de input en un solo request:

  1. Texto: Tu prompt, instrucción o pregunta
  2. Imagen: Una foto, captura, documento, diagrama

El modelo procesa ambos inputs y genera una respuesta en texto. No genera imágenes (eso es DALL-E/Stable Diffusion, módulo 4). La visión es imagen → texto.

Input:  [imagen de un gato] + "¿Qué animal es?"
Output: "Es un gato doméstico de color naranja, probablemente de raza tabby."

Formatos de input de imagen

Los modelos aceptan imágenes de dos formas:

FormatoCómo funcionaCuándo usar
Base64Codificas la imagen como string y la envías en el bodyImágenes locales, archivos subidos por usuario
URLEnvías una URL pública de la imagenImágenes ya disponibles en web
# Formato 1: Base64 (imagen local)
import base64

with open("foto.jpg", "rb") as f:
    image_b64 = base64.b64encode(f.read()).decode()

content = {
    "type": "image_url",
    "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}
}

# Formato 2: URL (imagen en web)
content = {
    "type": "image_url",
    "image_url": {"url": "https://example.com/foto.jpg"}
}

Capacidades de los Modelos con Visión

1. Descripción de imágenes

El modelo puede describir lo que ve: objetos, personas, escenas, texto visible, colores, composición.

from openai import OpenAI
import base64

client = OpenAI()

def describe_image(image_path: str, detail: str = "auto") -> str:
    """Describe una imagen usando GPT-4o Vision."""
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": "Describe esta imagen en 2-3 oraciones. Sé específico sobre lo que ves."
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_data}",
                        "detail": detail  # "low", "high", "auto"
                    }
                }
            ]
        }],
        max_tokens=300
    )
    return response.choices[0].message.content

# Uso
result = describe_image("foto_oficina.jpg")
print(result)
# Output esperado: "La imagen muestra una oficina moderna con escritorios blancos,
# monitores duales y plantas decorativas. Hay 3 personas trabajando con laptops
# junto a una ventana con vista a la ciudad."

El parámetro detail controla la resolución del análisis:

  • "low": Más rápido y barato, suficiente para descripción general
  • "high": Más detallado, mejor para OCR y análisis fino
  • "auto": El modelo decide según la imagen

2. OCR (extracción de texto)

Los modelos con visión pueden "leer" texto en imágenes: facturas, capturas de pantalla, documentos escaneados, señales de tráfico, menús.

def extract_text_from_image(image_path: str) -> str:
    """Extrae todo el texto visible de una imagen."""
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        "Extrae TODO el texto visible en esta imagen. "
                        "Mantén la estructura original (párrafos, listas, tablas) "
                        "si es posible. Si hay texto que no puedes leer con "
                        "certeza, indícalo con [ilegible]."
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_data}",
                        "detail": "high"  # high para mejor OCR
                    }
                }
            ]
        }],
        max_tokens=1500
    )
    return response.choices[0].message.content

# Uso
text = extract_text_from_image("factura_escaneada.jpg")
print(text)
# Output esperado:
# FACTURA #12345
# Fecha: 2024-03-15
# Cliente: Empresa ABC
# ─────────────────────
# Servicio de consultoría   $2,500.00
# Licencia de software      $1,200.00
# ─────────────────────
# Total:                    $3,700.00

3. Clasificación de imágenes

Clasificar imágenes en categorías predefinidas es uno de los usos más directos.

def classify_image(
    image_path: str,
    categories: list[str],
    model: str = "gpt-4o-mini"
) -> dict:
    """Clasifica una imagen en una de las categorías dadas."""
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    cats = ", ".join(categories)
    response = client.chat.completions.create(
        model=model,
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        f"Clasifica esta imagen en UNA de estas categorías: {cats}. "
                        f"Responde con un JSON: "
                        f'{{"categoria": "...", "confianza": "alta/media/baja", '
                        f'"razon": "..."}}'
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}
                }
            ]
        }],
        max_tokens=150,
        temperature=0
    )

    import json
    return json.loads(response.choices[0].message.content)

# Uso
categories = ["factura", "recibo", "contrato", "identificación", "otro"]
result = classify_image("documento.jpg", categories)
print(result)
# Output esperado:
# {"categoria": "factura", "confianza": "alta", "razon": "El documento muestra
#  un formato de factura con número, fecha, items y total"}

4. Extracción estructurada

Extraer datos específicos de una imagen y devolverlos en formato estructurado (JSON).

def extract_structured_data(image_path: str, schema: dict) -> dict:
    """Extrae datos estructurados de una imagen según un schema."""
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    schema_str = "\n".join(
        f"- {k}: {v}" for k, v in schema.items()
    )

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        f"Extrae los siguientes campos de esta imagen:\n{schema_str}\n\n"
                        f"Responde ÚNICAMENTE con JSON válido. "
                        f"Si un campo no está visible, usa null."
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_data}",
                        "detail": "high"
                    }
                }
            ]
        }],
        max_tokens=500,
        temperature=0
    )

    import json
    return json.loads(response.choices[0].message.content)

# Uso: extraer datos de una tarjeta de visita
schema = {
    "nombre": "nombre completo de la persona",
    "empresa": "nombre de la empresa",
    "cargo": "cargo o puesto",
    "email": "dirección de email",
    "telefono": "número de teléfono"
}
data = extract_structured_data("tarjeta_visita.jpg", schema)
print(data)
# Output esperado:
# {"nombre": "María López", "empresa": "TechCorp",
#  "cargo": "CTO", "email": "maria@techcorp.com",
#  "telefono": "+52 55 1234 5678"}

Limitaciones de la Visión

LimitaciónDetalleImplicación
TamañoOpenAI: máx 20MB. Anthropic: 5MB (base64)Redimensionar imágenes grandes antes de enviar
FormatosPNG, JPEG, GIF, WebPConvertir otros formatos (BMP, TIFF) antes
CantidadHasta 10 imágenes por request (varía por modelo)Para lotes grandes, hacer múltiples requests
CostoMás caro que texto-only (tokens por imagen)Usar detail: "low" cuando alta resolución no es necesaria
Precisión OCRPuede fallar en texto pequeño o baja calidadPreprocesar imágenes: aumentar contraste, resolución
PrivacidadImágenes se envían al cloud del proveedorNo enviar documentos sensibles sin revisar políticas
AlucinacionesEl modelo puede "inventar" texto que no existeSiempre verificar resultados de OCR en casos críticos

¿Cuánto cuesta una imagen?

OpenAI cobra por tokens de imagen. El costo depende de la resolución:

ResoluciónTokens aproximadosCosto (gpt-4o)
512x512 (low)~85 tokens~$0.0002
1024x1024 (high)~765 tokens~$0.002
2048x2048 (high)~1105 tokens~$0.003

Consejo: Para clasificación y descripción general, detail: "low" es suficiente y 10x más barato. Usa detail: "high" solo para OCR o análisis de detalles finos.


Comparación: Vision API vs OCR Tradicional

CriterioVision API (GPT-4V, Claude 3)OCR Tradicional (Tesseract)
CalidadAlta en documentos variadosVariable, mejor en texto claro
CostoPor token (puede ser alto)Gratis, local
IdiomasMuchos, automáticoConfigurable por idioma
Docs complejosExcelente (tablas, formularios, diagramas)Puede fallar con layouts complejos
LatenciaAPI call (1-5 segundos)Local (milisegundos)
PrivacidadDatos en cloudTodo local
RazonamientoPuede interpretar, resumir, clasificarSolo extrae texto crudo
SetupAPI key + pip installInstalación de Tesseract + configuración

¿Cuándo usar cada uno?

  • Vision API: Documentos complejos, tablas, formularios, necesitas interpretación (no solo texto crudo), volumen bajo-medio
  • Tesseract OCR: Volumen alto de documentos simples, costo cero, privacidad total, solo necesitas texto crudo

Patrones Comunes de Uso

Patrón 1: Describe + actúa

Pides al modelo que describa la imagen Y tome una acción basada en lo que ve.

prompt = """Analiza esta imagen de un producto:
1. Describe el producto (nombre, color, estado)
2. Estima un rango de precio en USD
3. Sugiere 3 palabras clave para catalogarlo

Responde en JSON."""

Patrón 2: Extracción con schema

Defines exactamente qué campos necesitas. El modelo extrae solo eso.

prompt = """Extrae de esta factura:
- fecha (formato YYYY-MM-DD)
- total (número con 2 decimales)
- proveedor (string)
- moneda (código ISO: EUR, USD, MXN)

Responde ÚNICAMENTE con JSON válido."""

Patrón 3: Comparación de imágenes

Envías 2+ imágenes y pides comparación.

prompt = """Compara estas dos imágenes de dashboards.
- ¿Qué métricas muestra cada uno?
- ¿Cuál tiene mejor diseño visual?
- Lista 3 diferencias específicas."""

Patrón 4: Q&A contextual

El usuario hace preguntas sobre una imagen específica.

prompt = """Dado este diagrama de arquitectura:
1. ¿Cuántos microservicios hay?
2. ¿Qué base de datos se usa?
3. ¿Hay un load balancer? ¿Dónde?"""

Troubleshooting

Problema 1: "Invalid image format"

Causa: Formato no soportado (BMP, TIFF) o archivo corrupto.

Solución:

from PIL import Image

def ensure_supported_format(image_path: str) -> str:
    """Convierte imagen a JPEG si no es formato soportado."""
    supported = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
    from pathlib import Path
    ext = Path(image_path).suffix.lower()
    if ext in supported:
        return image_path
    output_path = image_path.rsplit(".", 1)[0] + ".jpg"
    Image.open(image_path).convert("RGB").save(output_path, "JPEG")
    return output_path

Problema 2: Imagen muy grande (>20MB)

Causa: Foto de alta resolución o documento escaneado a 600 DPI.

Solución:

from PIL import Image

def resize_if_needed(image_path: str, max_size_mb: float = 15.0) -> str:
    """Redimensiona imagen si excede el tamaño máximo."""
    import os
    size_mb = os.path.getsize(image_path) / (1024 * 1024)
    if size_mb <= max_size_mb:
        return image_path

    img = Image.open(image_path)
    # Reducir resolución manteniendo aspect ratio
    factor = (max_size_mb / size_mb) ** 0.5
    new_size = (int(img.width * factor), int(img.height * factor))
    img = img.resize(new_size, Image.LANCZOS)

    output = image_path.rsplit(".", 1)[0] + "_resized.jpg"
    img.save(output, "JPEG", quality=85)
    return output

Problema 3: OCR inexacto en documentos

Causa: Imagen de baja resolución, texto pequeño, fondo ruidoso.

Solución:

  • Escanear a mínimo 300 DPI
  • Usar detail: "high" en la API
  • Preprocesar: aumentar contraste, convertir a escala de grises
  • Para documentos críticos, verificar manualmente los resultados

Problema 4: Costo alto por volumen

Causa: Enviar muchas imágenes con detail: "high".

Solución:

  • Usar gpt-4o-mini para clasificación y tareas simples
  • Usar detail: "low" cuando no necesitas OCR preciso
  • Implementar cache: si la misma imagen se envía dos veces, reutilizar resultado
  • Procesar en batch: agrupar imágenes similares

Ejercicios

Ejercicio 1: Descripción con restricciones (Fácil)

Modifica la función describe_image para que el modelo responda en máximo 50 palabras y mencione solo objetos (no personas).

Ver solución
def describe_objects_only(image_path: str) -> str:
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        "Describe esta imagen en MÁXIMO 50 palabras. "
                        "Menciona SOLO objetos y elementos visibles. "
                        "NO describas personas. Sé conciso y específico."
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}
                }
            ]
        }],
        max_tokens=100
    )
    return response.choices[0].message.content

Explicación: La restricción de 50 palabras se pone en el prompt, no en max_tokens. max_tokens limita tokens (no palabras), así que se pone un valor holgado. La instrucción de "solo objetos" va explícita en el prompt.

Ejercicio 2: Extracción de factura a JSON (Fácil)

Escribe una función que reciba una imagen de factura y devuelva un diccionario con: fecha, total, proveedor, moneda.

Ver solución
import json

def extract_invoice(image_path: str) -> dict:
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        "Extrae de esta factura los siguientes campos:\n"
                        "- fecha (formato YYYY-MM-DD)\n"
                        "- total (número con decimales)\n"
                        "- proveedor (nombre de la empresa)\n"
                        "- moneda (código ISO: EUR, USD, MXN)\n\n"
                        "Responde ÚNICAMENTE con JSON válido."
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {
                        "url": f"data:image/jpeg;base64,{image_data}",
                        "detail": "high"
                    }
                }
            ]
        }],
        max_tokens=200,
        temperature=0
    )
    return json.loads(response.choices[0].message.content)

Explicación: temperature=0 asegura respuestas deterministas. detail: "high" mejora la lectura de texto en la imagen. El JSON se parsea con json.loads().

Ejercicio 3: Clasificador multi-etiqueta (Medio)

Diseña una función que clasifique una imagen en múltiples categorías (no solo una). Debe devolver las categorías que apliquen con nivel de confianza.

Ver solución
import json

def classify_multi_label(
    image_path: str,
    categories: list[str]
) -> list[dict]:
    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    cats = ", ".join(categories)
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        f"Clasifica esta imagen. Puede pertenecer a VARIAS categorías.\n"
                        f"Categorías disponibles: {cats}\n\n"
                        f"Responde con JSON: una lista de objetos con "
                        f'"categoria" y "confianza" (alta/media/baja).\n'
                        f"Solo incluye categorías que apliquen."
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{image_data}"}
                }
            ]
        }],
        max_tokens=300,
        temperature=0
    )
    return json.loads(response.choices[0].message.content)

# Uso
categories = ["naturaleza", "urbano", "interior", "personas",
              "objetos", "texto", "documento", "comida"]
result = classify_multi_label("foto.jpg", categories)
# Output esperado:
# [{"categoria": "urbano", "confianza": "alta"},
#  {"categoria": "personas", "confianza": "media"}]

Explicación: La diferencia con clasificación single-label es que el prompt indica "puede pertenecer a VARIAS categorías" y el output es una lista (no un solo objeto).

Ejercicio 4: Wrapper con retry y validación (Medio)

Crea una función safe_vision_call que: (a) valide que el archivo existe y es imagen, (b) redimensione si es mayor a 15MB, (c) haga retry hasta 3 veces si la API falla.

Ver solución
import os
import time
from pathlib import Path
from PIL import Image

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}

def safe_vision_call(
    image_path: str,
    prompt: str,
    max_retries: int = 3
) -> str:
    path = Path(image_path)
    if not path.exists():
        raise FileNotFoundError(f"Imagen no encontrada: {image_path}")

    if path.suffix.lower() not in SUPPORTED_FORMATS:
        raise ValueError(
            f"Formato no soportado: {path.suffix}. "
            f"Usa: {SUPPORTED_FORMATS}"
        )

    # Redimensionar si es necesario
    size_mb = os.path.getsize(image_path) / (1024 * 1024)
    if size_mb > 15:
        img = Image.open(image_path)
        factor = (15 / size_mb) ** 0.5
        new_size = (int(img.width * factor), int(img.height * factor))
        img = img.resize(new_size, Image.LANCZOS)
        resized_path = str(path.with_suffix("")) + "_resized.jpg"
        img.save(resized_path, "JPEG", quality=85)
        image_path = resized_path

    with open(image_path, "rb") as f:
        image_data = base64.b64encode(f.read()).decode()

    for attempt in range(max_retries):
        try:
            response = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[{
                    "role": "user",
                    "content": [
                        {"type": "text", "text": prompt},
                        {"type": "image_url", "image_url": {
                            "url": f"data:image/jpeg;base64,{image_data}"
                        }}
                    ]
                }],
                max_tokens=500
            )
            return response.choices[0].message.content
        except Exception as e:
            if attempt < max_retries - 1:
                wait = 2 ** attempt  # exponential backoff
                time.sleep(wait)
            else:
                raise RuntimeError(
                    f"Vision API falló después de {max_retries} intentos: {e}"
                )

Explicación: Este patrón es fundamental para producción: valida input, maneja tamaño, y tiene retry con exponential backoff. Lo reutilizarás en módulos posteriores.

Ejercicio 5: Comparar dos imágenes (Difícil)

Crea una función que reciba dos imágenes y devuelva un análisis comparativo en JSON con: similitudes, diferencias, y conclusion.

Ver solución
import json

def compare_images(image_path_1: str, image_path_2: str) -> dict:
    images = []
    for path in [image_path_1, image_path_2]:
        with open(path, "rb") as f:
            b64 = base64.b64encode(f.read()).decode()
        images.append(b64)

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {
                    "type": "text",
                    "text": (
                        "Compara estas dos imágenes.\n"
                        "Responde con JSON:\n"
                        '{"similitudes": ["..."], '
                        '"diferencias": ["..."], '
                        '"conclusion": "..."}'
                    )
                },
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{images[0]}"}
                },
                {
                    "type": "image_url",
                    "image_url": {"url": f"data:image/jpeg;base64,{images[1]}"}
                }
            ]
        }],
        max_tokens=500,
        temperature=0
    )
    return json.loads(response.choices[0].message.content)

Explicación: Puedes enviar múltiples imágenes en un solo request. El modelo las procesa como "imagen 1" e "imagen 2" en orden. Esto es útil para detectar cambios, comparar productos, o verificar diferencias entre versiones.


Resumen

En esta cápsula aprendiste:

  • La modalidad visión permite que LLMs procesen imágenes: descripción, OCR, clasificación, extracción estructurada
  • Los formatos de input son Base64 (imágenes locales) y URL (imágenes en web)
  • El parámetro detail (low/high/auto) controla resolución y costo
  • Las limitaciones incluyen: tamaño máximo, formatos soportados, costo por token, precisión de OCR
  • Vision API vs Tesseract OCR: vision para complejidad e interpretación, Tesseract para volumen y costo cero
  • Los patrones comunes son: describe+actúa, extracción con schema, comparación, Q&A contextual
  • Para producción necesitas: validación de input, redimensionamiento, retry, manejo de errores

Próxima cápsula: Modalidad audio — transcripción con Whisper y síntesis de voz con TTS.


Recursos Adicionales

  1. OpenAI Vision Guide — Documentación oficial de GPT-4 Vision
  2. GPT-4o Vision Capabilities — Anuncio y capacidades de GPT-4o
  3. Anthropic Vision Docs — Claude 3 con imágenes
  4. Tesseract OCR — OCR open-source como alternativa local
  5. Pillow Documentation — Procesamiento de imágenes en Python
  6. OpenAI Cookbook: Vision — Ejemplos avanzados con vision