Módulo 3: Comprensión de Documentos

3. OCR + LLM

Descripción

Cuando un documento no tiene texto seleccionable (escaneado, fotografía, imagen), necesitas OCR (Optical Character Recognition). Hay dos enfoques principales: OCR tradicional (Tesseract) y Vision APIs (GPT-4 Vision, Claude 3). En esta cápsula aprenderás cuándo usar cada uno, cómo preprocesar imágenes para maximizar la calidad, y cómo combinar OCR con LLMs para extracción inteligente.

Por qué importa: La decisión OCR vs Vision afecta costo, calidad y latencia de tu pipeline documental. Tesseract es gratis, offline y rápido para volumen alto. Vision APIs ofrecen mejor calidad en documentos complejos pero tienen costo por uso. El enfoque híbrido (Tesseract + LLM para limpieza) es la opción más eficiente en muchos escenarios reales.

Conexión con el módulo: Esta cápsula se conecta con la cápsula 02 (PDFs) para documentos escaneados, con la 04 (imágenes de documentos) para fotografías, y con la 05 (extracción estructurada) donde el texto extraído se convierte en datos con schema. El Document Extractor (cápsula 08) usa estas técnicas como capa de entrada.


Conceptos Clave

OCR tradicional (Tesseract)

Tesseract es el motor OCR open-source más usado. Google lo mantiene desde 2006 y la versión 4+ usa redes LSTM.

  • Pros: Gratis, local, sin límites de API, rápido, funciona offline
  • Contras: Calidad variable en tablas, formularios, manuscritos; requiere preprocessing
  • Cuándo: Texto impreso claro, alto volumen, presupuesto limitado, requisitos de privacidad

Vision APIs (GPT-4V, Claude 3)

Los modelos de visión "leen" la imagen completa con comprensión semántica — entienden estructura, layout y contexto.

  • Pros: Alta calidad en tablas, formularios, layouts complejos; multi-idioma nativo
  • Contras: Costo por token, latencia de API, límites de rate, requieren conexión
  • Cuándo: Documentos con tablas/formularios, extracción estructurada, calidad crítica

Pipeline híbrido

Tesseract para extracción rápida + LLM de texto para limpiar y estructurar. Más barato que Vision, mejor que Tesseract solo.

  • Pros: Costo reducido vs Vision pura, mejor calidad que Tesseract solo
  • Contras: Dos pasos, latencia agregada del LLM
  • Cuándo: Volumen medio-alto donde Vision es muy caro pero Tesseract no alcanza

OCR con Tesseract

Instalación y paquetes de idioma

# Instalación del motor Tesseract
# macOS:   brew install tesseract
# Ubuntu:  sudo apt-get install tesseract-ocr
# Windows: https://github.com/UB-Mannheim/tesseract/wiki

# Paquetes de idioma (español)
# macOS:   brew install tesseract-lang
# Ubuntu:  sudo apt-get install tesseract-ocr-spa

# Wrapper de Python
# pip install pytesseract Pillow

import pytesseract

langs_disponibles = pytesseract.get_languages()
print(f"Idiomas instalados: {langs_disponibles}")

Uso básico

import pytesseract
from PIL import Image
import io


def ocr_with_tesseract(image_path: str, lang: str = "spa") -> str:
    """Extrae texto de una imagen usando Tesseract."""
    img = Image.open(image_path)
    text = pytesseract.image_to_string(img, lang=lang)
    return text.strip()


def ocr_from_bytes(image_bytes: bytes, lang: str = "spa") -> str:
    """Extrae texto desde bytes de imagen en memoria."""
    img = Image.open(io.BytesIO(image_bytes))
    return pytesseract.image_to_string(img, lang=lang).strip()

Modos PSM (Page Segmentation Mode)

El parámetro PSM controla cómo Tesseract segmenta la página. Elegir el modo correcto mejora drásticamente la calidad.

#  3 = Segmentación automática completa (DEFAULT)
#  4 = Asume una sola columna de texto
#  6 = Asume un bloque uniforme de texto
#  7 = Tratar imagen como una sola línea de texto
# 11 = Texto sparse sin orden particular
# 13 = Línea raw — sin preprocesamiento interno

def ocr_single_column(image_path: str, lang: str = "spa") -> str:
    """OCR optimizado para documentos de una sola columna."""
    img = Image.open(image_path)
    return pytesseract.image_to_string(img, lang=lang, config="--oem 3 --psm 4").strip()


def ocr_text_block(image_path: str, lang: str = "spa") -> str:
    """OCR optimizado para un bloque uniforme de texto."""
    img = Image.open(image_path)
    return pytesseract.image_to_string(img, lang=lang, config="--oem 3 --psm 6").strip()


def ocr_single_line(image_path: str, lang: str = "spa") -> str:
    """OCR para una sola línea (número de factura, fecha)."""
    img = Image.open(image_path)
    return pytesseract.image_to_string(img, lang=lang, config="--oem 3 --psm 7").strip()

Salida HOCR y coordenadas

HOCR es un formato que incluye las coordenadas de cada palabra. Útil para reconstruir layout o marcar regiones.

def ocr_word_boxes(image_path: str, lang: str = "spa") -> list[dict]:
    """Extrae cada palabra con su bounding box y confianza."""
    img = Image.open(image_path)
    data = pytesseract.image_to_data(img, lang=lang, output_type=pytesseract.Output.DICT)

    words = []
    for i in range(len(data["text"])):
        if data["text"][i].strip():
            words.append({
                "text": data["text"][i],
                "x": data["left"][i],
                "y": data["top"][i],
                "w": data["width"][i],
                "h": data["height"][i],
                "confidence": data["conf"][i],
            })
    return words

Preprocessing para Mejor OCR

La calidad del OCR con Tesseract depende directamente de la calidad de la imagen. Un pipeline de preprocessing transforma la imagen para maximizar la precisión.

Pipeline completo con Pillow

from PIL import Image, ImageEnhance, ImageFilter


def preprocess_for_ocr(
    image_path: str,
    binarize: bool = True,
    denoise: bool = True,
) -> Image.Image:
    """
    Pipeline: escala de grises → resize → contraste → binarización → denoising.
    """
    img = Image.open(image_path)

    img = img.convert("L")

    width, height = img.size
    if width < 1000:
        scale = 2.0
        img = img.resize((int(width * scale), int(height * scale)), Image.LANCZOS)

    enhancer = ImageEnhance.Contrast(img)
    img = enhancer.enhance(2.0)

    if binarize:
        img = img.point(lambda p: 255 if p > 128 else 0, "1")
        img = img.convert("L")

    if denoise:
        img = img.filter(ImageFilter.MedianFilter(size=3))

    return img

Comparar calidad antes y después

def compare_preprocessing(image_path: str) -> dict:
    """Compara OCR con y sin preprocessing."""
    img_raw = Image.open(image_path)
    img_processed = preprocess_for_ocr(image_path)

    text_raw = pytesseract.image_to_string(img_raw, lang="spa").strip()
    text_processed = pytesseract.image_to_string(img_processed, lang="spa").strip()

    return {
        "raw_chars": len(text_raw),
        "processed_chars": len(text_processed),
        "raw_preview": text_raw[:200],
        "processed_preview": text_processed[:200],
    }


resultado = compare_preprocessing("factura_baja_calidad.jpg")
print(f"Sin preprocessing: {resultado['raw_chars']} caracteres")
print(f"Con preprocessing: {resultado['processed_chars']} caracteres")

OCR con Vision API

OpenAI (GPT-4o)

from openai import OpenAI
import base64

client = OpenAI()


def ocr_with_vision(image_path: str, prompt: str = None) -> str:
    """Extrae texto de una imagen usando GPT-4 Vision."""
    with open(image_path, "rb") as f:
        b64 = base64.b64encode(f.read()).decode()

    default_prompt = """Extrae TODO el texto visible en esta imagen de documento.
Mantén la estructura original: párrafos, listas, tablas.
Si hay tablas, represéntalas en formato markdown.
No inventes ni interpretes — solo transcribe lo que ves.
Si algo es ilegible, marca [ilegible]."""

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[{
            "role": "user",
            "content": [
                {"type": "text", "text": prompt or default_prompt},
                {"type": "image_url", "image_url": {
                    "url": f"data:image/jpeg;base64,{b64}",
                    "detail": "high",
                }}
            ]
        }],
        max_tokens=4000,
    )
    return response.choices[0].message.content

Prompts especializados por tipo de documento

OCR_PROMPTS = {
    "factura": """Extrae el texto de esta factura. Identifica y preserva:
- Encabezado (empresa, RFC, dirección)
- Datos del receptor
- Tabla de conceptos (descripción, cantidad, precio unitario, importe)
- Totales (subtotal, IVA, total)
- Folio y fecha
Usa formato markdown para tablas.""",

    "formulario": """Extrae los campos de este formulario.
Para cada campo: nombre_campo: valor
Checkboxes: [X] marcado o [ ] vacío.
Campos vacíos: campo: [vacío].""",

    "tabla": """Extrae la tabla en formato markdown estricto con |.
Preserva todos los valores numéricos exactos.
No redondees ni modifiques cifras.""",
}


def ocr_vision_typed(image_path: str, doc_type: str) -> str:
    """OCR con Vision API usando prompt especializado por tipo."""
    prompt = OCR_PROMPTS.get(doc_type)
    if not prompt:
        raise ValueError(f"Tipo no soportado. Usa: {list(OCR_PROMPTS.keys())}")
    return ocr_with_vision(image_path, prompt=prompt)

Anthropic (Claude 3)

import anthropic


def ocr_with_claude(image_path: str, prompt: str = None) -> str:
    """Extrae texto usando Claude 3 Vision."""
    import mimetypes
    mime_type = mimetypes.guess_type(image_path)[0] or "image/jpeg"

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

    ac = anthropic.Anthropic()
    response = ac.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4000,
        messages=[{
            "role": "user",
            "content": [
                {"type": "image", "source": {
                    "type": "base64", "media_type": mime_type, "data": b64,
                }},
                {"type": "text", "text": prompt or "Transcribe todo el texto visible. Usa markdown para tablas."},
            ]
        }],
    )
    return response.content[0].text

Comparación Detallada: Tesseract vs Vision API

CriterioTesseractVision API (GPT-4o / Claude 3)
Costo por imagenGratis~$0.01–0.04
Precisión texto impreso85-95% en docs limpios95-99%
Precisión texto manuscritoMuy baja (< 50%)Alta (80-95%)
Manejo de tablasPierde estructuraExcelente — preserva layout
Formularios con checkboxesNo detectaDetecta estado marcado/vacío
Velocidad< 1 segundo local2-8 segundos
Funciona offlineNo
PrivacidadTodo localDatos viajan al cloud
Idiomas100+ con traineddataAutomático, multi-idioma
Layout multi-columnaMezcla columnasRespeta orden de lectura
Complejidad de setupMotor + paquetes idiomaSolo API key
Batch / volumenIlimitadoRate limits (RPM, TPM)
Preservación de formatoSolo texto planoMarkdown, JSON, libre
Docs degradadosMuy sensible a calidadRobusto ante ruido

Resumen práctico:

  • Tesseract → alto volumen, bajo presupuesto, texto impreso limpio, privacidad, offline.
  • Vision API → calidad crítica, tablas, formularios, manuscritos, documentos complejos.
  • Híbrido → volumen medio, optimizar costo vs calidad.

Pipeline Híbrido: Tesseract + LLM

Tesseract extrae texto bruto (gratis y rápido), después un LLM de texto limpia y estructura el resultado. Significativamente más barato que Vision porque tokens de texto cuestan mucho menos que tokens de imagen.

def hybrid_ocr(image_path: str, lang: str = "spa") -> str:
    """Tesseract extrae texto bruto, LLM lo limpia y corrige."""
    img = preprocess_for_ocr(image_path)
    raw_text = pytesseract.image_to_string(img, lang=lang).strip()

    if len(raw_text) < 10:
        return ocr_with_vision(image_path)

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": """Eres un corrector de texto OCR.
Corrige errores evidentes (letras confundidas, espacios mal puestos).
Preserva la estructura. NO inventes contenido.
Si hay texto irrecuperable, marca [ilegible]."""},
            {"role": "user", "content": f"Texto OCR a corregir:\n\n{raw_text}"},
        ],
        temperature=0,
    )
    return response.choices[0].message.content

Extracción estructurada con pipeline híbrido

import json


def hybrid_structured_extraction(
    image_path: str,
    schema: dict[str, str],
    lang: str = "spa",
) -> dict:
    """Tesseract extrae texto → LLM estructura según schema. ~10x más barato que Vision."""
    img = preprocess_for_ocr(image_path)
    raw_text = pytesseract.image_to_string(img, lang=lang).strip()
    schema_desc = "\n".join(f"- {k}: {v}" for k, v in schema.items())

    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "Extraes datos estructurados de texto OCR."},
            {"role": "user", "content": f"""Extrae estos campos del texto OCR:
{schema_desc}

Texto OCR:
{raw_text}

Responde ÚNICAMENTE con JSON válido. Usa null si no encuentras un campo."""},
        ],
        response_format={"type": "json_object"},
        temperature=0,
    )
    return json.loads(response.choices[0].message.content)

Comparación de costos

Costo estimado por documento:
┌───────────────────────┬────────────────┬───────────────┐
│ Método                │ Costo aprox.   │ Calidad       │
├───────────────────────┼────────────────┼───────────────┤
│ Tesseract solo        │ $0.00          │ ★★★☆☆        │
│ Híbrido (Tess + Mini) │ $0.001–0.005   │ ★★★★☆        │
│ Vision API directa    │ $0.01–0.04     │ ★★★★★        │
└───────────────────────┴────────────────┴───────────────┘

Para 10,000 documentos/mes:
  Tesseract solo:   $0
  Híbrido:          $10–50
  Vision directa:   $100–400

Árbol de Decisión: ¿Qué Método Usar?

¿El documento tiene texto seleccionable (PDF digital)?
├── SÍ → Usa PyMuPDF (cápsula 02). No necesitas OCR.
│
└── NO → Es imagen o PDF escaneado.
         │
         ├── ¿Tienes presupuesto para Vision API?
         │   ├── NO → Tesseract
         │   │        ├── ¿Texto impreso y limpio? → Tesseract directo (PSM 3/6)
         │   │        ├── ¿Imagen de baja calidad? → Preprocessing + Tesseract
         │   │        └── ¿Necesitas datos estructurados? → Híbrido (Tess + LLM)
         │   │
         │   └── SÍ → ¿Cuántos documentos/día?
         │       ├── < 100     → Vision API directa (máxima calidad)
         │       ├── 100–1000  → Híbrido mayoría + Vision docs complejos
         │       └── > 1000    → Tesseract + LLM + Vision como fallback
         │
         └── ¿Tiene tablas, formularios o manuscritos?
             ├── SÍ → Vision API o híbrido con prompt especializado
             └── NO → Tesseract con preprocessing suele bastar

OCR de Documentos Complejos

Tablas

Tesseract pierde estructura de columnas. Usa image_to_data para coordenadas y reconstruye por posición.

import pandas as pd


def ocr_table_from_image(image_path: str, lang: str = "spa") -> pd.DataFrame:
    """Reconstruye tabla desde OCR con coordenadas (agrupando por fila Y y columna X)."""
    img = preprocess_for_ocr(image_path, binarize=True)
    data = pytesseract.image_to_data(img, lang=lang, output_type=pytesseract.Output.DICT)

    words = []
    for i in range(len(data["text"])):
        text = data["text"][i].strip()
        if text and int(data["conf"][i]) > 30:
            words.append({"text": text, "x": data["left"][i], "y": data["top"][i]})

    if not words:
        return pd.DataFrame()

    rows, current_row = [], [words[0]]
    for word in words[1:]:
        if abs(word["y"] - current_row[0]["y"]) < 15:
            current_row.append(word)
        else:
            rows.append(sorted(current_row, key=lambda w: w["x"]))
            current_row = [word]
    rows.append(sorted(current_row, key=lambda w: w["x"]))

    table_data = [[w["text"] for w in row] for row in rows]
    max_cols = max(len(r) for r in table_data)
    for row in table_data:
        row.extend([""] * (max_cols - len(row)))

    return pd.DataFrame(table_data[1:], columns=table_data[0] if table_data else None)

Layouts multi-columna

Divide la imagen en columnas y aplica OCR por separado para preservar orden de lectura.

def ocr_multicolumn(image_path: str, num_columns: int = 2) -> list[str]:
    """OCR columna por columna para documentos tipo periódico/revista."""
    img = Image.open(image_path)
    width, height = img.size
    col_width = width // num_columns

    columns_text = []
    for i in range(num_columns):
        left = i * col_width
        right = (i + 1) * col_width if i < num_columns - 1 else width
        col_img = img.crop((left, 0, right, height))
        text = pytesseract.image_to_string(col_img, lang="spa", config="--psm 4")
        columns_text.append(text.strip())
    return columns_text

Formularios y texto manuscrito

def ocr_form_with_vision(image_path: str) -> str:
    """Para formularios con checkboxes, Vision API es la opción más confiable."""
    return ocr_with_vision(image_path, prompt=OCR_PROMPTS["formulario"])


def ocr_handwritten(image_path: str) -> dict:
    """Para manuscritos, siempre Vision API. Retorna texto + confianza."""
    prompt = """Esta imagen contiene texto escrito a mano.
Transcribe todo lo que puedas leer. Si algo es ilegible, marca [ilegible].
Al final, indica tu confianza: ALTO, MEDIO o BAJO."""

    result = ocr_with_vision(image_path, prompt=prompt)
    confidence = "BAJO"
    for level in ["ALTO", "MEDIO", "BAJO"]:
        if level in result.upper():
            confidence = level
            break
    return {"text": result, "confidence": confidence}

Ejercicios

Ejercicio 1: Pipeline de preprocessing configurable

Crea una función preprocess_pipeline que reciba una imagen y una lista de pasos (["grayscale", "contrast", "binarize", "denoise", "resize"]). Cada paso es opcional. Después aplica OCR con Tesseract y retorna el texto.

Ver solución
def preprocess_pipeline(
    image_path: str,
    steps: list[str],
    contrast_factor: float = 2.0,
    threshold: int = 128,
) -> str:
    """Pipeline configurable de preprocessing + OCR."""
    img = Image.open(image_path)

    for step in steps:
        if step == "grayscale":
            img = img.convert("L")
        elif step == "contrast":
            if img.mode != "L":
                img = img.convert("L")
            img = ImageEnhance.Contrast(img).enhance(contrast_factor)
        elif step == "binarize":
            if img.mode != "L":
                img = img.convert("L")
            img = img.point(lambda p: 255 if p > threshold else 0, "1").convert("L")
        elif step == "denoise":
            img = img.filter(ImageFilter.MedianFilter(size=3))
        elif step == "resize":
            w, h = img.size
            img = img.resize((w * 2, h * 2), Image.LANCZOS)

    return pytesseract.image_to_string(img, lang="spa").strip()


texto = preprocess_pipeline(
    "factura_borrosa.jpg",
    steps=["grayscale", "resize", "contrast", "binarize", "denoise"],
)

Ejercicio 2: OCR híbrido con fallback inteligente

Implementa una función que: (1) intente Tesseract, (2) evalúe la calidad del resultado (proporción de caracteres alfabéticos vs basura), (3) si la calidad es baja, reintente con Vision API. Retorna resultado y método usado.

Ver solución
def evaluate_ocr_quality(text: str) -> float:
    """Puntúa calidad de texto OCR entre 0.0 y 1.0."""
    if not text:
        return 0.0
    alpha_ratio = sum(c.isalpha() or c.isspace() for c in text) / len(text)
    has_words = len(text.split()) > 3
    no_garbage = not any(c in text for c in ["\x00", "□", "■"])
    return min(alpha_ratio * 0.6 + (0.2 if has_words else 0) + (0.2 if no_garbage else 0), 1.0)


def ocr_smart_fallback(image_path: str, quality_threshold: float = 0.5) -> dict:
    """OCR con fallback inteligente basado en calidad."""
    img = preprocess_for_ocr(image_path)
    tesseract_text = pytesseract.image_to_string(img, lang="spa").strip()
    quality = evaluate_ocr_quality(tesseract_text)

    if quality >= quality_threshold:
        return {"text": tesseract_text, "method": "tesseract", "quality": quality}

    vision_text = ocr_with_vision(image_path)
    return {"text": vision_text, "method": "vision_fallback", "quality": quality}

Ejercicio 3: Batch OCR con reporte de costo

Procesa una lista de imágenes usando el pipeline híbrido. Lleva la cuenta de cuántas usaron Tesseract solo vs cuántas necesitaron LLM/Vision. Genera un reporte de costo estimado.

Ver solución
def batch_ocr_with_report(
    image_paths: list[str],
    quality_threshold: float = 0.5,
    cost_per_vision: float = 0.02,
    cost_per_llm: float = 0.003,
) -> dict:
    """Batch OCR con reporte de costos."""
    results = []
    stats = {"tesseract_only": 0, "hybrid": 0, "vision_fallback": 0}

    for path in image_paths:
        img = preprocess_for_ocr(path)
        raw = pytesseract.image_to_string(img, lang="spa").strip()
        quality = evaluate_ocr_quality(raw)

        if quality >= quality_threshold:
            results.append({"path": path, "text": raw, "method": "tesseract"})
            stats["tesseract_only"] += 1
        elif quality >= 0.3:
            cleaned = hybrid_ocr(path)
            results.append({"path": path, "text": cleaned, "method": "hybrid"})
            stats["hybrid"] += 1
        else:
            vision = ocr_with_vision(path)
            results.append({"path": path, "text": vision, "method": "vision"})
            stats["vision_fallback"] += 1

    total_cost = stats["hybrid"] * cost_per_llm + stats["vision_fallback"] * cost_per_vision
    return {
        "results": results,
        "stats": stats,
        "total_cost_usd": round(total_cost, 4),
        "cost_if_all_vision": round(len(image_paths) * cost_per_vision, 4),
    }

Ejercicio 4: Extractor de tabla con Vision + validación

Usa Vision API para extraer una tabla como CSV. Conviértela a DataFrame de pandas. Valida que las columnas numéricas contengan números reales.

Ver solución
import io


def extract_table_vision(image_path: str) -> pd.DataFrame:
    """Extrae tabla de imagen con Vision API → DataFrame con validación numérica."""
    prompt = """Extrae la tabla en formato CSV.
Primera línea = encabezados. Coma como separador.
No añadas texto antes ni después. Preserva valores numéricos exactos."""

    csv_text = ocr_with_vision(image_path, prompt=prompt).strip()
    if csv_text.startswith("```"):
        lines = csv_text.split("\n")
        csv_text = "\n".join(lines[1:-1])

    df = pd.read_csv(io.StringIO(csv_text))

    for col in df.columns:
        try:
            df[col] = pd.to_numeric(df[col])
        except (ValueError, TypeError):
            pass

    numeric_cols = df.select_dtypes(include=["number"]).columns
    print(f"Tabla: {len(df)} filas, {len(df.columns)} cols ({len(numeric_cols)} numéricas)")
    return df

Troubleshooting

Problema: Tesseract no encuentra idioma "spa"

Causa: Datos de idioma no instalados.

Solución: brew install tesseract-lang (macOS) o sudo apt-get install tesseract-ocr-spa (Ubuntu). Verificar con tesseract --list-langs. Alternativa manual: descargar spa.traineddata de tesseract-ocr/tessdata.

Problema: Vision API devuelve texto inventado

Causa: Prompt no suficientemente restrictivo o imagen ambigua.

Solución: Usar prompt defensivo con temperatura 0:

prompt_defensivo = """Transcribe ÚNICAMENTE el texto visible.
NO interpretes, NO completes, NO inventes.
Si algo es ilegible: [ilegible]. Si no hay texto: SIN TEXTO VISIBLE."""

Problema: Tesseract devuelve basura en imágenes de baja calidad

Causa: Bajo contraste, ruido, resolución insuficiente.

Solución: Aplicar pipeline de preprocessing. Asegurar ancho > 1000px (~300 DPI para documento carta). Si el preprocessing no ayuda, usar pipeline híbrido.

Problema: OCR de tabla pierde estructura de columnas

Causa: Tesseract trata la tabla como texto continuo.

Solución: Usar --psm 6 para bloques, o dividir la imagen en celdas individuales. Para tablas complejas, usar Vision API con OCR_PROMPTS["tabla"].

Problema: PDF escaneado con muchas páginas es costoso

Causa: Cada página con Vision escala linealmente en costo.

Solución: Pipeline selectivo — Tesseract primero, Vision solo para páginas que fallan:

def smart_pdf_ocr(page_images: list[Image.Image]) -> list[str]:
    """Tesseract primero, Vision como fallback por página."""
    results = []
    for i, img in enumerate(page_images):
        text = pytesseract.image_to_string(img, lang="spa").strip()
        if evaluate_ocr_quality(text) < 0.4:
            img.save(f"/tmp/page_{i}.jpg")
            text = ocr_with_vision(f"/tmp/page_{i}.jpg")
        results.append(text)
    return results

Problema: Timeout o rate limit con Vision API en batch

Causa: Demasiadas requests simultáneas.

Solución: Retry con backoff exponencial:

import time


def ocr_with_retry(image_path: str, max_retries: int = 3) -> str:
    """Vision OCR con retry y backoff exponencial."""
    for attempt in range(max_retries):
        try:
            return ocr_with_vision(image_path)
        except Exception as e:
            if "rate_limit" in str(e).lower() or "429" in str(e):
                wait = 2 ** attempt
                time.sleep(wait)
            else:
                raise
    raise RuntimeError(f"Falló después de {max_retries} intentos")

Recursos adicionales

  1. Tesseract Documentation
  2. pytesseract — Python wrapper
  3. OpenAI Vision Guide
  4. Anthropic Vision (Claude)
  5. Pillow ImageEnhance
  6. Tesseract — Improve Quality