Módulo 3: Comprensión de Documentos

4. Imágenes de Documentos

Descripción

Cuando un documento es un PDF escaneado o una imagen (foto de factura, captura de pantalla), necesitas convertir las páginas a imágenes y enviarlas a Vision APIs. En esta cápsula aprenderás a preparar imágenes de documentos para análisis: conversión con distintas librerías, optimización para reducir tokens y costo, envío a múltiples proveedores, y procesamiento inteligente de documentos con muchas páginas.

Por qué importa: Las Vision APIs requieren imágenes en formato específico (Base64, URL). Un PDF de 50 páginas enviado sin optimización puede costar 10x más de lo necesario. Dominar la preparación evita errores, optimiza costos y permite escalar a volúmenes reales de producción.

Conexión con el módulo: En la cápsula 02 aprendiste a extraer texto e imágenes de PDFs con PyMuPDF. En la cápsula 03 viste OCR tradicional vs Vision APIs. Aquí combinas ambos: conviertes páginas completas a imágenes y las envías a Vision para obtener comprensión total del documento — layout, tablas, imágenes y texto juntos.


Conceptos Clave

Formatos aceptados por Vision APIs

FormatoOpenAIAnthropicGoogle GeminiLímite típico
Base6420MB (OpenAI)
URL públicaMisma URL
PNG
JPEG
WebP
GIF

Límites por proveedor

ProveedorMax imágenes/requestMax tamañoDetalle
OpenAI1020 MB totaldetail=low usa 85 tokens fijos
Anthropic5 (Claude 3.5)5 MB por imagenBase64 + media_type obligatorio
Google16 (Gemini)20 MB totalAcepta bytes raw en SDK

Cómo las Vision APIs procesan imágenes

Las Vision APIs no reciben la imagen tal cual. Internamente la redimensionan y dividen en tiles. OpenAI con detail=high:

  1. Escala la imagen para que quepa en 2048×2048
  2. Escala para que el lado más corto sea 768px
  3. Divide en tiles de 512×512
  4. Cada tile = 170 tokens, más 85 tokens base

Una imagen de 1024×1024 consume ~765 tokens. Una de 4096×4096 consume lo mismo que una de 2048×2048 (por el paso 1).


Métodos de Conversión: PDF a Imágenes

Método 1: PyMuPDF (fitz) — pixmap directo

PyMuPDF genera pixmaps nativos sin dependencias externas de sistema. Es la opción más portátil.

import fitz
import io
import base64
from PIL import Image

def pdf_to_images_pymupdf(pdf_path: str, dpi: int = 150) -> list[Image.Image]:
    """Convierte PDF a lista de imágenes PIL usando PyMuPDF."""
    doc = fitz.open(pdf_path)
    images = []
    zoom = dpi / 72
    mat = fitz.Matrix(zoom, zoom)

    for page in doc:
        pix = page.get_pixmap(matrix=mat, alpha=False)
        img = Image.open(io.BytesIO(pix.tobytes("png")))
        images.append(img)

    doc.close()
    return images

Método 2: pdf2image (Poppler)

pdf2image usa Poppler como backend. Produce imágenes de alta calidad y ofrece opciones de renderizado avanzadas.

from pdf2image import convert_from_path

def pdf_to_images_poppler(pdf_path: str, dpi: int = 150) -> list[Image.Image]:
    """Convierte PDF a imágenes usando pdf2image (requiere Poppler)."""
    return convert_from_path(
        pdf_path,
        dpi=dpi,
        fmt="png",
        thread_count=4,
        grayscale=False,
        size=None  # None = usa DPI, o (width, height) para forzar tamaño
    )

def pdf_specific_pages(pdf_path: str, first: int, last: int, dpi: int = 150) -> list[Image.Image]:
    """Convierte solo un rango de páginas (1-indexed)."""
    return convert_from_path(pdf_path, dpi=dpi, first_page=first, last_page=last)

Manejo de diferentes tamaños de página

Los PDFs pueden tener páginas con tamaños mixtos (carta, oficio, A4). Es importante normalizar.

def normalize_page_sizes(images: list[Image.Image], target_width: int = 1024) -> list[Image.Image]:
    """Normaliza imágenes a un ancho consistente manteniendo proporciones."""
    normalized = []
    for img in images:
        w, h = img.size
        if w != target_width:
            ratio = target_width / w
            img = img.resize((target_width, int(h * ratio)), Image.Resampling.LANCZOS)
        normalized.append(img)
    return normalized

Comparación de métodos

CriterioPyMuPDFpdf2image (Poppler)
Dependencia externaSolo pipPoppler (sistema)
VelocidadRápidoMás rápido en batch
CalidadBuenaExcelente
MultihiloManualthread_count nativo
Instalaciónpip install pymupdfpip install pdf2image + Poppler

DPI y su impacto

DPIResolución carta (8.5×11")Tamaño aprox.Uso recomendado
72612×792 px~100 KBPreview rápido
1501275×1650 px~400 KBBalance costo/calidad
2001700×2200 px~800 KBTexto pequeño, tablas
3002550×3300 px~2 MBExcesivo para Vision

Recomendación: 150 DPI es el punto óptimo para Vision APIs. Más de 200 DPI no mejora la comprensión del modelo y multiplica el costo en tokens.


Optimización de Imágenes para Vision

Redimensionar para uso óptimo de tokens

OpenAI redimensiona internamente a max 2048×2048. Enviar imágenes más grandes es desperdiciar ancho de banda sin beneficio.

def optimize_for_vision(
    img: Image.Image,
    max_side: int = 2048,
    jpeg_quality: int = 85
) -> bytes:
    """Redimensiona si excede max_side y comprime a JPEG."""
    w, h = img.size
    if max(w, h) > max_side:
        ratio = max_side / max(w, h)
        img = img.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS)

    if img.mode == "RGBA":
        img = img.convert("RGB")

    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=jpeg_quality, optimize=True)
    return buf.getvalue()

JPEG quality vs tamaño: el tradeoff

def compare_jpeg_quality(img: Image.Image) -> dict:
    """Muestra el impacto de quality en tamaño."""
    if img.mode == "RGBA":
        img = img.convert("RGB")
    results = {}
    for quality in [50, 65, 75, 85, 95]:
        buf = io.BytesIO()
        img.save(buf, format="JPEG", quality=quality, optimize=True)
        results[quality] = f"{len(buf.getvalue()) / 1024:.0f} KB"
    return results

# Resultado típico para una página de documento a 150 DPI:
# {50: "80 KB", 65: "120 KB", 75: "160 KB", 85: "250 KB", 95: "600 KB"}

Hallazgo clave: Quality 75-85 es el punto óptimo. Por debajo de 65 el texto se vuelve borroso. Por encima de 90 el tamaño crece exponencialmente sin mejora perceptible.

Calcular DPI óptimo según el documento

def calculate_optimal_dpi(
    page_width_inches: float,
    page_height_inches: float,
    target_pixels: int = 1500
) -> int:
    """Calcula el DPI que produce una imagen cercana al target en su lado mayor."""
    longer_side = max(page_width_inches, page_height_inches)
    ideal_dpi = int(target_pixels / longer_side)
    return max(100, min(ideal_dpi, 250))

# Carta (8.5×11"): 136 → 150 | A4 (8.27×11.69"): 128 → 150 | Oficio (8.5×14"): 107

Función completa de preparación

def prepare_page_for_vision(
    img: Image.Image,
    max_side: int = 2048,
    jpeg_quality: int = 80,
    max_bytes: int = 5 * 1024 * 1024
) -> dict:
    """Prepara una imagen de página con optimización progresiva."""
    img_bytes = optimize_for_vision(img, max_side, jpeg_quality)

    while len(img_bytes) > max_bytes and jpeg_quality > 40:
        jpeg_quality -= 10
        img_bytes = optimize_for_vision(img, max_side, jpeg_quality)

    if len(img_bytes) > max_bytes:
        max_side = int(max_side * 0.7)
        img_bytes = optimize_for_vision(img, max_side, jpeg_quality)

    b64 = base64.b64encode(img_bytes).decode()
    return {
        "base64": b64,
        "media_type": "image/jpeg",
        "size_bytes": len(img_bytes),
        "quality_used": jpeg_quality
    }

Envío a Múltiples Proveedores

Cada proveedor espera la imagen en un formato ligeramente diferente. Referencia: Módulo 1, cápsula 06.

OpenAI: data URI

from openai import OpenAI

def send_to_openai(pages: list[dict], prompt: str, detail: str = "high") -> str:
    """Envía imágenes de páginas a OpenAI Vision."""
    client = OpenAI()
    content = [{"type": "text", "text": prompt}]
    for page in pages[:10]:
        content.append({
            "type": "image_url",
            "image_url": {
                "url": f"data:{page['media_type']};base64,{page['base64']}",
                "detail": detail
            }
        })

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": content}],
        max_tokens=4000
    )
    return response.choices[0].message.content

Anthropic: Base64 + media_type explícito

import anthropic

def send_to_anthropic(pages: list[dict], prompt: str) -> str:
    """Envía imágenes de páginas a Claude Vision."""
    client = anthropic.Anthropic()
    content = []
    for page in pages[:5]:
        content.append({
            "type": "image",
            "source": {
                "type": "base64",
                "media_type": page["media_type"],
                "data": page["base64"]
            }
        })
    content.append({"type": "text", "text": prompt})

    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4000,
        messages=[{"role": "user", "content": content}]
    )
    return response.content[0].text

Google Gemini: objetos PIL directos

import google.generativeai as genai

def send_to_gemini(page_images: list[Image.Image], prompt: str) -> str:
    """Envía imágenes a Gemini — acepta PIL directamente."""
    model = genai.GenerativeModel("gemini-2.0-flash")
    parts = [prompt] + page_images[:16]
    response = model.generate_content(parts)
    return response.text

Función unificada

def send_to_provider(pages: list[dict], images: list[Image.Image], prompt: str, provider: str = "openai") -> str:
    providers = {
        "openai": lambda: send_to_openai(pages, prompt),
        "anthropic": lambda: send_to_anthropic(pages, prompt),
        "gemini": lambda: send_to_gemini(images, prompt),
    }
    if provider not in providers:
        raise ValueError(f"Proveedor no soportado: {provider}")
    return providers[provider]()

Procesamiento Multi-Página

Estrategia 1: Secuencial

Procesa cada página por separado. Útil cuando cada página es independiente.

def process_sequential(images: list[Image.Image], prompt: str) -> list[str]:
    """Procesa cada página individualmente."""
    results = []
    for i, img in enumerate(images):
        page_data = prepare_page_for_vision(img)
        result = send_to_openai([page_data], f"Página {i + 1}. {prompt}")
        results.append(result)
    return results

Estrategia 2: Batch

Agrupa páginas en un solo request. Mejor contexto entre páginas y menos requests.

def process_batch(images: list[Image.Image], prompt: str, batch_size: int = 5) -> list[str]:
    """Procesa páginas en grupos para reducir requests."""
    results = []
    for i in range(0, len(images), batch_size):
        batch = images[i:i + batch_size]
        pages_data = [prepare_page_for_vision(img) for img in batch]
        batch_prompt = f"{prompt}\n\nPáginas {i + 1} a {i + len(batch)} del documento."
        result = send_to_openai(pages_data, batch_prompt)
        results.append(result)
    return results

Estrategia 3: Selectiva (inteligente)

Solo envía a Vision las páginas que lo necesitan. Las de solo texto se procesan con OCR local.

import numpy as np

def classify_page(img: Image.Image) -> str:
    """Clasifica página como 'text_only' o 'complex' usando variación de grises."""
    small = img.resize((100, 100)).convert("L")
    arr = np.array(small)
    return "text_only" if len(np.unique(arr)) < 20 else "complex"


def process_selective(images: list[Image.Image], prompt: str) -> list[dict]:
    """Envía solo páginas complejas a Vision. Páginas simples → OCR local."""
    import pytesseract
    results = []
    for i, img in enumerate(images):
        page_type = classify_page(img)
        if page_type == "complex":
            page_data = prepare_page_for_vision(img)
            text = send_to_openai([page_data], prompt)
            method = "vision"
        else:
            text = pytesseract.image_to_string(img, lang="spa")
            method = "ocr"
        results.append({"page": i + 1, "type": page_type, "method": method, "text": text})
    return results

Cuándo usar cada estrategia

EstrategiaRequests APICostoContexto entre páginasCaso de uso
Secuencial1 por páginaAltoNingunoPáginas independientes
Batch1 por grupoMedioDentro del grupoDocumentos coherentes
SelectivaSolo complejasBajoNingunoDocumentos largos, presupuesto limitado

Pipeline Completo: PDF → Imágenes → Vision → Texto

import json
from dataclasses import dataclass, field

@dataclass
class PageResult:
    page_number: int
    text: str
    method: str
    tokens_estimated: int = 0

@dataclass
class DocumentResult:
    source: str
    total_pages: int
    pages: list[PageResult] = field(default_factory=list)
    combined_text: str = ""
    total_cost: float = 0.0

def estimate_image_tokens(size_bytes: int, detail: str = "high") -> int:
    if detail == "low":
        return 85
    if size_bytes < 200_000:
        return 765
    elif size_bytes < 500_000:
        return 1105
    return 1105 + (size_bytes - 500_000) // 100_000 * 170


def full_pipeline(
    pdf_path: str,
    prompt: str = "Extrae todo el texto visible. Mantén la estructura.",
    dpi: int = 150,
    batch_size: int = 5,
    detail: str = "high",
    selective: bool = True
) -> DocumentResult:
    """Pipeline completo: PDF → Imágenes → Vision API → Texto combinado."""
    images = pdf_to_images_pymupdf(pdf_path, dpi=dpi)
    images = normalize_page_sizes(images)
    doc_result = DocumentResult(source=pdf_path, total_pages=len(images))
    import pytesseract

    for batch_start in range(0, len(images), batch_size):
        batch_imgs = images[batch_start:batch_start + batch_size]
        pages_data = []

        for i, img in enumerate(batch_imgs):
            page_num = batch_start + i + 1
            if selective and classify_page(img) == "text_only":
                text = pytesseract.image_to_string(img, lang="spa").strip()
                doc_result.pages.append(PageResult(page_num, text, "ocr_local"))
                continue
            page_data = prepare_page_for_vision(img)
            page_data["page_number"] = page_num
            pages_data.append(page_data)

        if pages_data:
            page_nums = [p["page_number"] for p in pages_data]
            batch_prompt = (
                f"{prompt}\n\nPáginas: {page_nums}. "
                f"Separa con '--- Página N ---'."
            )
            result_text = send_to_openai(pages_data, batch_prompt, detail)
            sections = result_text.split("--- Página")
            for j, pd in enumerate(pages_data):
                section = sections[j + 1] if j + 1 < len(sections) else result_text
                tokens = estimate_image_tokens(pd["size_bytes"], detail)
                doc_result.pages.append(PageResult(pd["page_number"], section.strip(), "vision_api", tokens))

    doc_result.pages.sort(key=lambda p: p.page_number)
    doc_result.combined_text = "\n\n".join(p.text for p in doc_result.pages)
    doc_result.total_cost = sum(p.tokens_estimated * 0.0000025 for p in doc_result.pages)
    return doc_result

Uso del pipeline

result = full_pipeline("contrato_servicio.pdf", selective=True)
print(f"Páginas: {result.total_pages} | Costo: ${result.total_cost:.4f}")
for p in result.pages:
    print(f"  Pág {p.page_number}: {p.method} ({p.tokens_estimated} tokens)")

Costo y Optimización

Calculando costo para documentos multi-página

def estimate_document_cost(num_pages: int, detail: str = "high", model: str = "gpt-4o") -> dict:
    """Estima el costo de procesar un documento completo."""
    prices = {
        "gpt-4o": {"input": 2.50, "output": 10.00},
        "gpt-4o-mini": {"input": 0.15, "output": 0.60},
    }
    price = prices.get(model, prices["gpt-4o"])
    tokens_per_image = 85 if detail == "low" else 765
    input_tokens = num_pages * tokens_per_image + 100
    output_tokens = num_pages * 500

    input_cost = (input_tokens / 1_000_000) * price["input"]
    output_cost = (output_tokens / 1_000_000) * price["output"]
    return {
        "model": model, "detail": detail,
        "input_tokens": input_tokens, "output_tokens": output_tokens,
        "total_cost": f"${input_cost + output_cost:.4f}"
    }

Costos comparativos

PáginasModeloDetailCosto estimado
10gpt-4ohigh~$0.069
10gpt-4olow~$0.052
10gpt-4o-minihigh~$0.004
50gpt-4ohigh~$0.346
50gpt-4o-minilow~$0.002

6 estrategias para reducir costo

#EstrategiaAhorro estimadoTrade-off
1detail=low en OpenAI85 tokens fijos vs ~765Pierde detalle fino, malo para tablas
2gpt-4o-mini en vez de gpt-4o~15x más baratoMenor capacidad en layouts complejos
3DPI bajo (100-120)Menos bytes, más rápidoTexto pequeño puede ser ilegible
4JPEG quality 65-75~40% menos bytesLigera pérdida en bordes finos
5Selectiva: OCR local para texto simple0 tokens para esas páginasNecesita clasificador de página
6Batch de páginasMenos overhead de promptLímite de imágenes por request

Función de presupuesto

def process_within_budget(pdf_path: str, prompt: str, max_budget: float = 0.10) -> DocumentResult:
    """Procesa un documento ajustando configuración para respetar presupuesto."""
    images = pdf_to_images_pymupdf(pdf_path, dpi=150)
    n = len(images)

    configs = [
        {"detail": "high", "model": "gpt-4o"},
        {"detail": "low", "model": "gpt-4o"},
        {"detail": "high", "model": "gpt-4o-mini"},
        {"detail": "low", "model": "gpt-4o-mini"},
    ]

    for cfg in configs:
        cost = estimate_document_cost(n, cfg["detail"], cfg["model"])
        total = float(cost["total_cost"].replace("$", ""))
        if total <= max_budget:
            print(f"Config: {cfg['model']}, detail={cfg['detail']}, costo≈${total:.4f}")
            return full_pipeline(pdf_path, prompt=prompt, selective=True, detail=cfg["detail"])

    print(f"Presupuesto muy bajo para {n} páginas. Usando config mínima.")
    return full_pipeline(pdf_path, prompt=prompt, selective=True, detail="low")

Caso de Uso: Factura Multi-Página

Una factura de 4 páginas: portada con datos del emisor, detalle de líneas (tabla), impuestos/totales, y términos.

def process_invoice(pdf_path: str) -> dict:
    """Procesa una factura multi-página y extrae datos estructurados."""
    extraction_prompt = """Analiza esta factura y extrae en JSON:
{
    "emisor": {"nombre": "", "rfc": "", "direccion": ""},
    "receptor": {"nombre": "", "rfc": "", "direccion": ""},
    "folio": "", "fecha": "",
    "lineas": [{"descripcion": "", "cantidad": 0, "precio_unitario": 0, "total": 0}],
    "subtotal": 0, "iva": 0, "total": 0, "moneda": "", "metodo_pago": ""
}
Responde ÚNICAMENTE con JSON válido. Usa null para campos no encontrados.
Extrae TODAS las líneas de producto de la tabla."""

    images = pdf_to_images_pymupdf(pdf_path, dpi=200)
    pages_data = [prepare_page_for_vision(img) for img in images]

    client = OpenAI()
    content = [{"type": "text", "text": extraction_prompt}]
    for page in pages_data:
        content.append({
            "type": "image_url",
            "image_url": {"url": f"data:{page['media_type']};base64,{page['base64']}", "detail": "high"}
        })

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{"role": "user", "content": content}],
        response_format={"type": "json_object"},
        temperature=0,
        max_tokens=4000
    )
    invoice = json.loads(response.choices[0].message.content)

    if invoice.get("lineas"):
        calc = sum(l.get("total", 0) for l in invoice["lineas"] if l.get("total"))
        reported = invoice.get("subtotal", 0)
        if reported and abs(calc - reported) > 1:
            invoice["_warning"] = f"Subtotal calculado ({calc}) ≠ reportado ({reported})"

    return invoice

Uso

invoice = process_invoice("factura_servicios_2024.pdf")
print(f"Emisor: {invoice['emisor']['nombre']} | Folio: {invoice['folio']}")
for line in invoice.get("lineas", []):
    print(f"  {line['descripcion']}: {line['cantidad']} × ${line['precio_unitario']}")
print(f"Total: ${invoice['total']} {invoice['moneda']}")

Troubleshooting

Problema 1: "Poppler not found" al usar pdf2image

Causa: pdf2image necesita Poppler instalado a nivel de sistema.

Solución:

# macOS
brew install poppler
# Ubuntu/Debian
sudo apt-get install poppler-utils

Si no puedes instalar Poppler, usa PyMuPDF como alternativa (no requiere dependencias de sistema).

Problema 2: MemoryError con PDFs grandes

Causa: Convertir todas las páginas a imágenes de alta resolución al mismo tiempo agota la memoria.

Solución: Procesar página por página con un generador.

def process_large_pdf(pdf_path: str, dpi: int = 150):
    """Procesa PDFs grandes sin agotar memoria."""
    doc = fitz.open(pdf_path)
    mat = fitz.Matrix(dpi / 72, dpi / 72)
    for i in range(len(doc)):
        pix = doc[i].get_pixmap(matrix=mat, alpha=False)
        yield i + 1, Image.open(io.BytesIO(pix.tobytes("png")))
    doc.close()

Problema 3: Imágenes borrosas en la conversión

Causa: DPI demasiado bajo para el contenido (tablas con texto pequeño).

Solución: Incrementar DPI a 200. Si solo ciertas áreas necesitan detalle, recortar la región:

def extract_region(img: Image.Image, bbox: tuple) -> Image.Image:
    """Recorta una región específica para enviarla en alta resolución."""
    return img.crop(bbox)  # bbox = (x1, y1, x2, y2)

Problema 4: Exceder límites de imágenes por request

Causa: Más de 10 imágenes a OpenAI o más de 5 a Anthropic.

Solución: Dividir en chunks respetando el límite de cada proveedor.

PROVIDER_LIMITS = {"openai": 10, "anthropic": 5, "gemini": 16}

def chunk_pages(pages: list, provider: str = "openai") -> list[list]:
    limit = PROVIDER_LIMITS.get(provider, 5)
    return [pages[i:i + limit] for i in range(0, len(pages), limit)]

Problema 5: Timeout en documentos con muchas páginas

Causa: Un request con muchas imágenes excede el timeout del cliente HTTP.

Solución: client = OpenAI(timeout=120.0, max_retries=3)


Ejercicios

Ejercicio 1: Comparar calidad por DPI

Escribe una función que convierta la primera página de un PDF con tres DPI distintos (100, 150, 200) y retorne el tamaño en KB de cada una en PNG y JPEG.

Ver solución
def compare_dpi_quality(pdf_path: str) -> dict:
    doc = fitz.open(pdf_path)
    page = doc[0]
    results = {}
    for dpi in [100, 150, 200]:
        mat = fitz.Matrix(dpi / 72, dpi / 72)
        pix = page.get_pixmap(matrix=mat, alpha=False)
        img = Image.open(io.BytesIO(pix.tobytes("png")))

        buf_png = io.BytesIO()
        img.save(buf_png, format="PNG")
        buf_jpg = io.BytesIO()
        img.convert("RGB").save(buf_jpg, format="JPEG", quality=80)

        results[dpi] = {
            "resolution": f"{img.size[0]}×{img.size[1]}",
            "png_kb": round(len(buf_png.getvalue()) / 1024),
            "jpeg_kb": round(len(buf_jpg.getvalue()) / 1024)
        }
    doc.close()
    return results

Ejercicio 2: Enviar misma página a dos proveedores

Crea una función que tome una imagen PIL, la prepare, y la envíe a OpenAI y Anthropic con el mismo prompt. Retorna ambas respuestas para comparar.

Ver solución
def compare_providers(img: Image.Image, prompt: str) -> dict:
    page_data = prepare_page_for_vision(img)
    return {
        "openai": send_to_openai([page_data], prompt),
        "anthropic": send_to_anthropic([page_data], prompt),
    }

Ejercicio 3: Pipeline selectivo con reporte

Implementa una función que procese un PDF con estrategia selectiva y genere un reporte: cuántas páginas con Vision, cuántas con OCR, y costo estimado.

Ver solución
def selective_with_report(pdf_path: str, prompt: str) -> dict:
    images = pdf_to_images_pymupdf(pdf_path, dpi=150)
    results = process_selective(images, prompt)

    vision_pages = [r for r in results if r["method"] == "vision"]
    ocr_pages = [r for r in results if r["method"] == "ocr"]
    cost = len(vision_pages) * 0.003

    return {
        "total_pages": len(results),
        "vision_pages": len(vision_pages),
        "ocr_pages": len(ocr_pages),
        "vision_nums": [r["page"] for r in vision_pages],
        "estimated_cost": f"${cost:.4f}",
        "savings": f"{(1 - len(vision_pages)/max(len(results),1))*100:.0f}%",
        "pages": results
    }

Ejercicio 4: Validar y comprimir antes de enviar

Escribe una función que reciba imágenes, verifique que el tamaño total no exceda el límite del proveedor, y si excede, comprima progresivamente hasta que quepa.

Ver solución
def validate_and_compress(images: list[Image.Image], max_total_mb: float = 20.0) -> list[dict]:
    max_bytes = max_total_mb * 1024 * 1024
    quality = 85

    while quality >= 40:
        pages_data = [prepare_page_for_vision(img, jpeg_quality=quality) for img in images]
        total = sum(p["size_bytes"] for p in pages_data)
        if total <= max_bytes:
            return pages_data
        quality -= 10

    return [prepare_page_for_vision(img, max_side=1024, jpeg_quality=40) for img in images]

Recursos adicionales

  1. OpenAI Vision — Image limits and pricing
  2. Anthropic Vision — Image format requirements
  3. PyMuPDF (fitz) — Pixmap documentation
  4. pdf2image — GitHub repository
  5. PIL Image.resize