ocr_ai.py
· 19 KiB · Python
Bruto
#!/usr/bin/env python3
"""
ocr-ai.py — OCR inteligente con detección automática de contenido
y post-procesado con IA (Ollama).
Captura una región de la pantalla o procesa una imagen existente,
detecta automáticamente el tipo de contenido (código, tabla, prosa),
ajusta los parámetros de Tesseract, y limpia errores con Ollama.
Uso:
python3 ocr-ai.py # Captura + OCR + IA
python3 ocr-ai.py -f captura.png # Procesa imagen existente
python3 ocr-ai.py -f captura.png --raw # Solo OCR, sin IA
python3 ocr-ai.py -f captura.png --copy # OCR + IA + al portapapeles
Dependencias:
- Python 3.10+
- ImageMagick (magick)
- Tesseract (tesseract-ocr + tesseract-ocr-spa + tesseract-ocr-eng)
- wl-clipboard (Wayland) o xclip (X11) para --copy
- Ollama (ollama serve corriendo, modelo recomendado: llama3.2)
"""
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
from pathlib import Path
import requests
# ─── Configuración ─────────────────────────────────────────────────────────────
OLLAMA_HOST = "http://localhost:11434"
OLLAMA_MODEL = "llama3.2"
OLLAMA_TIMEOUT = 60
# Pipeline de ImageMagick por defecto
DEFAULT_PIPELINE = [
"-colorspace", "Gray",
"-normalize",
"-threshold", "60%",
"-deskew", "40%",
"-sharpen", "0x1",
]
# Pipeline más agresivo para código (más contraste, menos ruido)
CODE_PIPELINE = [
"-colorspace", "Gray",
"-normalize",
"-contrast-stretch", "2%",
"-threshold", "55%",
"-deskew", "40%",
"-sharpen", "0x2",
"-morphology", "Close", "Octagon",
]
# Pipeline para tablas (preserva líneas)
TABLE_PIPELINE = [
"-colorspace", "Gray",
"-normalize",
"-threshold", "50%",
"-deskew", "30%",
"-sharpen", "0x1",
"-morphology", "Erode", "Octagon",
]
# ─── Detección de tipo de contenido ───────────────────────────────────────────
def detectar_tipo(texto: str) -> str:
"""
Analiza el texto extraído por OCR y detecta el tipo de contenido.
Devuelve: 'codigo', 'tabla', 'prosa' o 'desconocido'.
"""
if not texto or len(texto.strip()) < 10:
return "desconocido"
lineas = texto.strip().split("\n")
num_lineas = len(lineas)
palabras = texto.split()
num_palabras = len(palabras)
# Heurísticas para código
patrones_codigo = [
r'\b(import|from|def |class |fn |let |var |const|function|int |float|void|return|if |else |for |while|switch|case |package|#include|using namespace|public |private |static)\b',
r'[{}()\[\];]',
r'==|!=|<=|>=|->|=>|\+\+|--',
r'^\s*[#//]\s',
r'\.\w+\(',
r'["\'].*["\']\s*[;,]',
]
puntuacion_codigo = 0
for patron in patrones_codigo:
if re.search(patron, texto, re.IGNORECASE | re.MULTILINE):
puntuacion_codigo += 2
# Más peso si hay indentación consistente (código suele estar indentado)
indentadas = sum(1 for l in lineas if l.startswith(" ") or l.startswith("\t"))
if indentadas > num_lineas * 0.3:
puntuacion_codigo += 3
# Puntuación por densidad de palabras (código tiene menos variedad léxica)
if num_palabras > 0 and len(set(palabras)) / num_palabras < 0.4:
puntuacion_codigo += 2
# Heurísticas para tabla (más restrictivas)
patrones_tabla = [
r'\|.*\|.*\|',
r'\+[-+]+\+',
r'(Nº|#|Item|Producto|Precio|Cantidad|Total|Unidades)\s*\||\|.*\|',
r'^\s*\d+\..*\d+[.,]\d+',
]
puntuacion_tabla = 0
for patron in patrones_tabla:
if re.search(patron, texto, re.IGNORECASE | re.MULTILINE):
puntuacion_tabla += 2
# Tablas suelen tener muchas líneas cortas con estructura repetitiva
lineas_cortas = sum(1 for l in lineas if 5 < len(l.strip()) < 40)
if num_lineas > 3 and lineas_cortas > num_lineas * 0.5:
puntuacion_tabla += 2
# Decisión
if puntuacion_codigo >= puntuacion_tabla and puntuacion_codigo >= 4:
return "codigo"
elif puntuacion_tabla > puntuacion_codigo and puntuacion_tabla >= 3:
return "tabla"
elif num_palabras > 20 and puntuacion_codigo < 4 and puntuacion_tabla < 3:
return "prosa"
else:
return "desconocido"
def psm_para_tipo(tipo: str) -> int:
"""Devuelve el PSM de Tesseract óptimo para cada tipo."""
return {
"codigo": 6, # Bloque de texto uniforme
"tabla": 6, # Bloque uniforme (mejor para TSV)
"prosa": 4, # Texto a una columna
"desconocido": 3, # Detección automática
}.get(tipo, 3)
def pipeline_para_tipo(tipo: str) -> list:
"""Devuelve el pipeline de ImageMagick óptimo para cada tipo."""
return {
"codigo": CODE_PIPELINE,
"tabla": TABLE_PIPELINE,
"prosa": DEFAULT_PIPELINE,
"desconocido": DEFAULT_PIPELINE,
}.get(tipo, DEFAULT_PIPELINE)
# ─── Herramientas del sistema ─────────────────────────────────────────────────
def check_deps():
"""Verifica que las dependencias del sistema están instaladas."""
faltan = []
for cmd, nombre in [("convert", "ImageMagick"), ("tesseract", "Tesseract")]:
if subprocess.run(["which", cmd], capture_output=True).returncode != 0:
faltan.append(nombre)
if faltan:
print(f"❌ Faltan dependencias: {', '.join(faltan)}", file=sys.stderr)
print(f" Instala: sudo apt install imagemagick tesseract-ocr tesseract-ocr-spa tesseract-ocr-eng", file=sys.stderr)
sys.exit(1)
def capturar_pantalla(output_path: str):
"""Captura una región de la pantalla con import (ImageMagick)."""
print("🔍 Selecciona una región con el ratón...", file=sys.stderr)
result = subprocess.run(
["import", output_path],
capture_output=True, text=True,
)
if result.returncode != 0:
print("❌ Captura cancelada o fallida", file=sys.stderr)
sys.exit(1)
if not Path(output_path).exists() or Path(output_path).stat().st_size == 0:
print("❌ No se generó la captura", file=sys.stderr)
sys.exit(1)
print(f" ✅ Captura guardada: {output_path}", file=sys.stderr)
def preprocesar(input_path: str, output_path: str, pipeline: list):
"""Aplica el pipeline de ImageMagick a la imagen."""
cmd = ["convert", input_path] + pipeline + [output_path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ Error en preprocesado: {result.stderr}", file=sys.stderr)
sys.exit(1)
def ocr(image_path: str, idiomas: str, psm: int) -> str:
"""Ejecuta Tesseract OCR y devuelve el texto extraído."""
cmd = [
"tesseract", image_path, "stdout",
"-l", idiomas,
"--psm", str(psm),
"--oem", "1",
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"❌ Error en OCR: {result.stderr}", file=sys.stderr)
return ""
return result.stdout.strip()
def copiar_portapapeles(texto: str):
"""Copia texto al portapapeles (Wayland o X11)."""
if subprocess.run(["which", "wl-copy"], capture_output=True).returncode == 0:
subprocess.run(["wl-copy"], input=texto, text=True)
elif subprocess.run(["which", "xclip"], capture_output=True).returncode == 0:
subprocess.run(["xclip", "-selection", "clipboard"], input=texto, text=True)
else:
print("⚠️ Ni wl-copy ni xclip disponibles. Texto solo por stdout.", file=sys.stderr)
return
print(" 📋 Copiado al portapapeles", file=sys.stderr)
# ─── Post-procesado con Ollama ────────────────────────────────────────────────
def llamar_ollama(system_prompt: str, user_prompt: str, model: str = OLLAMA_MODEL) -> str | None:
"""Llama a Ollama y devuelve el texto generado."""
payload = {
"model": model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
"options": {"temperature": 0.1, "num_predict": 2048},
}
try:
resp = requests.post(
f"{OLLAMA_HOST}/api/chat",
json=payload,
timeout=OLLAMA_TIMEOUT,
)
resp.raise_for_status()
return resp.json().get("message", {}).get("content", "").strip()
except requests.exceptions.ConnectionError:
print(" ⚠️ Ollama no está disponible. Omitiendo corrección con IA.", file=sys.stderr)
return None
except Exception as e:
print(f" ⚠️ Error en Ollama: {e}. Omitiendo corrección.", file=sys.stderr)
return None
def corregir_con_ia(texto: str, tipo: str, model: str = OLLAMA_MODEL) -> str:
"""Corrige errores de OCR usando Ollama, adaptado al tipo de contenido."""
prompts = {
"codigo": {
"system": (
"Eres un corrector de OCR especializado en código fuente. "
"Tu tarea es corregir errores de reconocimiento óptico en código.\n\n"
"REGLAS:\n"
"1. Corrige caracteres mal reconocidos (0 por O, l por 1, etc.)\n"
"2. Restaura indentación y formato del código original\n"
"3. NO cambies nombres de variables, funciones o lógica\n"
"4. NO inventes líneas de código que no están\n"
"5. Mantén comentarios exactamente como están\n"
"6. Preserva la sintaxis del lenguaje\n"
"7. Devuelve SOLO el código corregido, sin explicaciones"
),
"user": f"Corrige los errores de OCR en este código:\n\n```\n{texto}\n```",
},
"tabla": {
"system": (
"Eres un corrector de OCR especializado en tablas y datos numéricos. "
"Tu tarea es corregir errores de reconocimiento óptico en tablas.\n\n"
"REGLAS:\n"
"1. Corrige números mal reconocidos (1.000 por 1000, etc.)\n"
"2. Corrige símbolos monetarios (€ por EUR o similar)\n"
"3. Preserva la estructura de filas y columnas\n"
"4. NO inventes datos que no están en el original\n"
"5. NO rellenes celdas vacías con datos inventados\n"
"6. Si no estás seguro de un valor, déjalo como está\n"
"7. Devuelve SOLO la tabla corregida, sin explicaciones"
),
"user": f"Corrige los errores de OCR en esta tabla:\n\n```\n{texto}\n```",
},
"prosa": {
"system": (
"Eres un corrector de OCR especializado en texto en español. "
"Tu tarea es corregir errores de reconocimiento óptico.\n\n"
"REGLAS:\n"
"1. Corrige caracteres mal reconocidos\n"
"2. Corrige espacios perdidos o añadidos entre palabras\n"
"3. Corrige puntuación y acentos cuando sea evidente\n"
"4. NO cambies el significado del texto\n"
"5. NO añadas información que no está\n"
"6. Preserva el estilo y tono original\n"
"7. Si una palabra no la reconoces bien, déjala como está\n"
"8. Devuelve SOLO el texto corregido, sin explicaciones"
),
"user": f"Corrige los errores de OCR en este texto:\n\n```\n{texto}\n```",
},
"desconocido": {
"system": (
"Eres un corrector de OCR. Tu tarea es corregir errores "
"de reconocimiento óptico en el texto que se te pasa.\n\n"
"REGLAS:\n"
"1. Corrige solo errores evidentes de OCR\n"
"2. NO inventes contenido\n"
"3. NO cambies el formato o estructura\n"
"4. Devuelve SOLO el texto corregido, sin explicaciones"
),
"user": f"Corrige los errores de OCR en este texto:\n\n```\n{texto}\n```",
},
}
p = prompts.get(tipo, prompts["desconocido"])
resultado = llamar_ollama(p["system"], p["user"], model)
return resultado if resultado else texto
# ─── Main ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="ocr-ai: OCR inteligente con detección de contenido y corrección con IA",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""\
Ejemplos:
python3 ocr-ai.py # Captura + OCR + IA
python3 ocr-ai.py -f captura.png # Procesa imagen existente
python3 ocr-ai.py -f captura.png --raw # Solo OCR, sin IA
python3 ocr-ai.py -f captura.png --copy --lang eng # Código al portapapeles
""",
)
parser.add_argument("-f", "--file", help="Archivo de imagen a procesar (si no, captura interactiva)")
parser.add_argument("--lang", default="spa+eng", help="Idiomas para Tesseract (default: spa+eng)")
parser.add_argument("--psm", type=int, default=0, help="PSM de Tesseract (0 = auto-detect)")
parser.add_argument("--raw", action="store_true", help="OCR sin corrección con IA")
parser.add_argument("--copy", action="store_true", help="Copiar resultado al portapapeles")
parser.add_argument("--model", default=OLLAMA_MODEL, help=f"Modelo Ollama (default: {OLLAMA_MODEL})")
parser.add_argument("--detect-only", action="store_true", help="Solo detectar tipo de contenido y salir")
parser.add_argument("--debug", action="store_true", help="Modo verbose con información de depuración")
args = parser.parse_args()
# Verificar dependencias del sistema
check_deps()
# Configurar modelo Ollama
ollama_model = args.model
with tempfile.TemporaryDirectory(prefix="ocr-ai-") as tmpdir:
if args.file:
img_path = args.file
if not Path(img_path).exists():
print(f"❌ Archivo no encontrado: {img_path}", file=sys.stderr)
sys.exit(1)
print(f"📄 Procesando: {img_path}", file=sys.stderr)
else:
img_path = os.path.join(tmpdir, "captura.png")
capturar_pantalla(img_path)
# ─── 2. Preprocesar con pipeline por defecto ─────────────────────────
proc_path = os.path.join(tmpdir, "procesada.png")
print(f"🖼️ Preprocesando con ImageMagick...", file=sys.stderr)
# Primera OCR para detectar tipo (con pipeline por defecto)
first_ocr_path = os.path.join(tmpdir, "first_ocr.png")
preprocesar(img_path, first_ocr_path, DEFAULT_PIPELINE)
texto_bruto = ocr(first_ocr_path, args.lang, args.psm if args.psm > 0 else 3)
if not texto_bruto:
print("⚠️ No se detectó texto en la imagen", file=sys.stderr)
sys.exit(0)
# ─── 3. Detectar tipo de contenido ──────────────────────────────────
tipo = detectar_tipo(texto_bruto)
print(f" 📋 Tipo detectado: {tipo.upper()}", file=sys.stderr)
if args.detect_only:
print(f"\nTexto OCR (primeros 500 chars):\n{texto_bruto[:500]}")
return
# ─── 4. Reprocesar con pipeline óptimo para el tipo ─────────────────
if args.psm == 0:
psm_optimo = psm_para_tipo(tipo)
else:
psm_optimo = args.psm
pipeline_optimo = pipeline_para_tipo(tipo)
if args.debug:
print(f" 🔧 Pipeline: {' '.join(pipeline_optimo)}", file=sys.stderr)
print(f" 🔧 PSM: {psm_optimo}", file=sys.stderr)
# Solo reprocesar si no era el pipeline por defecto
if pipeline_optimo != DEFAULT_PIPELINE:
print(f" 🔄 Reprocesando con pipeline optimizado para {tipo}...", file=sys.stderr)
preprocesar(img_path, proc_path, pipeline_optimo)
else:
proc_path = first_ocr_path
texto_ocr = ocr(proc_path, args.lang, psm_optimo)
if not texto_ocr:
print("⚠️ No se detectó texto tras reprocesado", file=sys.stderr)
texto_ocr = texto_bruto # Fallback al primer intento
# ─── 5. Post-procesar con IA (opcional) ─────────────────────────────
if args.raw:
resultado = texto_ocr
else:
print(f" 🧠 Corrigiendo con {ollama_model}...", file=sys.stderr)
resultado = corregir_con_ia(texto_ocr, tipo, ollama_model)
if not resultado:
resultado = texto_ocr # Fallback si Ollama falla
# ─── 6. Mostrar resultado ───────────────────────────────────────────
print()
print("╔══════════════════════════════════════════════════════════════╗")
print(f"║ 📝 RESULTADO ({tipo.upper()}){' ' * (27 - len(tipo))}║")
print("╚══════════════════════════════════════════════════════════════╝")
print()
print(resultado)
print()
# ─── 7. Copiar al portapapeles ──────────────────────────────────────
if args.copy:
copiar_portapapeles(resultado)
# ─── 8. Estadísticas ────────────────────────────────────────────────
if args.debug:
print(f"📊 Estadísticas:", file=sys.stderr)
print(f" Tipo detectado: {tipo}", file=sys.stderr)
print(f" PSM usado: {psm_optimo}", file=sys.stderr)
print(f" Caracteres OCR: {len(texto_ocr)}", file=sys.stderr)
print(f" Caracteres tras IA: {len(resultado)}", file=sys.stderr)
if not args.raw and texto_ocr != resultado:
# Mostrar cambios principales
print(f" ✏️ Correcciones aplicadas por IA", file=sys.stderr)
if __name__ == "__main__":
main()
| 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | ocr-ai.py — OCR inteligente con detección automática de contenido |
| 4 | y post-procesado con IA (Ollama). |
| 5 | |
| 6 | Captura una región de la pantalla o procesa una imagen existente, |
| 7 | detecta automáticamente el tipo de contenido (código, tabla, prosa), |
| 8 | ajusta los parámetros de Tesseract, y limpia errores con Ollama. |
| 9 | |
| 10 | Uso: |
| 11 | python3 ocr-ai.py # Captura + OCR + IA |
| 12 | python3 ocr-ai.py -f captura.png # Procesa imagen existente |
| 13 | python3 ocr-ai.py -f captura.png --raw # Solo OCR, sin IA |
| 14 | python3 ocr-ai.py -f captura.png --copy # OCR + IA + al portapapeles |
| 15 | |
| 16 | Dependencias: |
| 17 | - Python 3.10+ |
| 18 | - ImageMagick (magick) |
| 19 | - Tesseract (tesseract-ocr + tesseract-ocr-spa + tesseract-ocr-eng) |
| 20 | - wl-clipboard (Wayland) o xclip (X11) para --copy |
| 21 | - Ollama (ollama serve corriendo, modelo recomendado: llama3.2) |
| 22 | """ |
| 23 | |
| 24 | import argparse |
| 25 | import json |
| 26 | import os |
| 27 | import re |
| 28 | import subprocess |
| 29 | import sys |
| 30 | import tempfile |
| 31 | from pathlib import Path |
| 32 | |
| 33 | import requests |
| 34 | |
| 35 | |
| 36 | # ─── Configuración ───────────────────────────────────────────────────────────── |
| 37 | |
| 38 | OLLAMA_HOST = "http://localhost:11434" |
| 39 | OLLAMA_MODEL = "llama3.2" |
| 40 | OLLAMA_TIMEOUT = 60 |
| 41 | |
| 42 | # Pipeline de ImageMagick por defecto |
| 43 | DEFAULT_PIPELINE = [ |
| 44 | "-colorspace", "Gray", |
| 45 | "-normalize", |
| 46 | "-threshold", "60%", |
| 47 | "-deskew", "40%", |
| 48 | "-sharpen", "0x1", |
| 49 | ] |
| 50 | |
| 51 | # Pipeline más agresivo para código (más contraste, menos ruido) |
| 52 | CODE_PIPELINE = [ |
| 53 | "-colorspace", "Gray", |
| 54 | "-normalize", |
| 55 | "-contrast-stretch", "2%", |
| 56 | "-threshold", "55%", |
| 57 | "-deskew", "40%", |
| 58 | "-sharpen", "0x2", |
| 59 | "-morphology", "Close", "Octagon", |
| 60 | ] |
| 61 | |
| 62 | # Pipeline para tablas (preserva líneas) |
| 63 | TABLE_PIPELINE = [ |
| 64 | "-colorspace", "Gray", |
| 65 | "-normalize", |
| 66 | "-threshold", "50%", |
| 67 | "-deskew", "30%", |
| 68 | "-sharpen", "0x1", |
| 69 | "-morphology", "Erode", "Octagon", |
| 70 | ] |
| 71 | |
| 72 | |
| 73 | # ─── Detección de tipo de contenido ─────────────────────────────────────────── |
| 74 | |
| 75 | def detectar_tipo(texto: str) -> str: |
| 76 | """ |
| 77 | Analiza el texto extraído por OCR y detecta el tipo de contenido. |
| 78 | Devuelve: 'codigo', 'tabla', 'prosa' o 'desconocido'. |
| 79 | """ |
| 80 | if not texto or len(texto.strip()) < 10: |
| 81 | return "desconocido" |
| 82 | |
| 83 | lineas = texto.strip().split("\n") |
| 84 | num_lineas = len(lineas) |
| 85 | palabras = texto.split() |
| 86 | num_palabras = len(palabras) |
| 87 | |
| 88 | # Heurísticas para código |
| 89 | patrones_codigo = [ |
| 90 | r'\b(import|from|def |class |fn |let |var |const|function|int |float|void|return|if |else |for |while|switch|case |package|#include|using namespace|public |private |static)\b', |
| 91 | r'[{}()\[\];]', |
| 92 | r'==|!=|<=|>=|->|=>|\+\+|--', |
| 93 | r'^\s*[#//]\s', |
| 94 | r'\.\w+\(', |
| 95 | r'["\'].*["\']\s*[;,]', |
| 96 | ] |
| 97 | puntuacion_codigo = 0 |
| 98 | for patron in patrones_codigo: |
| 99 | if re.search(patron, texto, re.IGNORECASE | re.MULTILINE): |
| 100 | puntuacion_codigo += 2 |
| 101 | |
| 102 | # Más peso si hay indentación consistente (código suele estar indentado) |
| 103 | indentadas = sum(1 for l in lineas if l.startswith(" ") or l.startswith("\t")) |
| 104 | if indentadas > num_lineas * 0.3: |
| 105 | puntuacion_codigo += 3 |
| 106 | |
| 107 | # Puntuación por densidad de palabras (código tiene menos variedad léxica) |
| 108 | if num_palabras > 0 and len(set(palabras)) / num_palabras < 0.4: |
| 109 | puntuacion_codigo += 2 |
| 110 | |
| 111 | # Heurísticas para tabla (más restrictivas) |
| 112 | patrones_tabla = [ |
| 113 | r'\|.*\|.*\|', |
| 114 | r'\+[-+]+\+', |
| 115 | r'(Nº|#|Item|Producto|Precio|Cantidad|Total|Unidades)\s*\||\|.*\|', |
| 116 | r'^\s*\d+\..*\d+[.,]\d+', |
| 117 | ] |
| 118 | puntuacion_tabla = 0 |
| 119 | for patron in patrones_tabla: |
| 120 | if re.search(patron, texto, re.IGNORECASE | re.MULTILINE): |
| 121 | puntuacion_tabla += 2 |
| 122 | |
| 123 | # Tablas suelen tener muchas líneas cortas con estructura repetitiva |
| 124 | lineas_cortas = sum(1 for l in lineas if 5 < len(l.strip()) < 40) |
| 125 | if num_lineas > 3 and lineas_cortas > num_lineas * 0.5: |
| 126 | puntuacion_tabla += 2 |
| 127 | |
| 128 | # Decisión |
| 129 | if puntuacion_codigo >= puntuacion_tabla and puntuacion_codigo >= 4: |
| 130 | return "codigo" |
| 131 | elif puntuacion_tabla > puntuacion_codigo and puntuacion_tabla >= 3: |
| 132 | return "tabla" |
| 133 | elif num_palabras > 20 and puntuacion_codigo < 4 and puntuacion_tabla < 3: |
| 134 | return "prosa" |
| 135 | else: |
| 136 | return "desconocido" |
| 137 | |
| 138 | |
| 139 | def psm_para_tipo(tipo: str) -> int: |
| 140 | """Devuelve el PSM de Tesseract óptimo para cada tipo.""" |
| 141 | return { |
| 142 | "codigo": 6, # Bloque de texto uniforme |
| 143 | "tabla": 6, # Bloque uniforme (mejor para TSV) |
| 144 | "prosa": 4, # Texto a una columna |
| 145 | "desconocido": 3, # Detección automática |
| 146 | }.get(tipo, 3) |
| 147 | |
| 148 | |
| 149 | def pipeline_para_tipo(tipo: str) -> list: |
| 150 | """Devuelve el pipeline de ImageMagick óptimo para cada tipo.""" |
| 151 | return { |
| 152 | "codigo": CODE_PIPELINE, |
| 153 | "tabla": TABLE_PIPELINE, |
| 154 | "prosa": DEFAULT_PIPELINE, |
| 155 | "desconocido": DEFAULT_PIPELINE, |
| 156 | }.get(tipo, DEFAULT_PIPELINE) |
| 157 | |
| 158 | |
| 159 | # ─── Herramientas del sistema ───────────────────────────────────────────────── |
| 160 | |
| 161 | def check_deps(): |
| 162 | """Verifica que las dependencias del sistema están instaladas.""" |
| 163 | faltan = [] |
| 164 | for cmd, nombre in [("convert", "ImageMagick"), ("tesseract", "Tesseract")]: |
| 165 | if subprocess.run(["which", cmd], capture_output=True).returncode != 0: |
| 166 | faltan.append(nombre) |
| 167 | if faltan: |
| 168 | print(f"❌ Faltan dependencias: {', '.join(faltan)}", file=sys.stderr) |
| 169 | print(f" Instala: sudo apt install imagemagick tesseract-ocr tesseract-ocr-spa tesseract-ocr-eng", file=sys.stderr) |
| 170 | sys.exit(1) |
| 171 | |
| 172 | |
| 173 | def capturar_pantalla(output_path: str): |
| 174 | """Captura una región de la pantalla con import (ImageMagick).""" |
| 175 | print("🔍 Selecciona una región con el ratón...", file=sys.stderr) |
| 176 | result = subprocess.run( |
| 177 | ["import", output_path], |
| 178 | capture_output=True, text=True, |
| 179 | ) |
| 180 | if result.returncode != 0: |
| 181 | print("❌ Captura cancelada o fallida", file=sys.stderr) |
| 182 | sys.exit(1) |
| 183 | if not Path(output_path).exists() or Path(output_path).stat().st_size == 0: |
| 184 | print("❌ No se generó la captura", file=sys.stderr) |
| 185 | sys.exit(1) |
| 186 | print(f" ✅ Captura guardada: {output_path}", file=sys.stderr) |
| 187 | |
| 188 | |
| 189 | def preprocesar(input_path: str, output_path: str, pipeline: list): |
| 190 | """Aplica el pipeline de ImageMagick a la imagen.""" |
| 191 | cmd = ["convert", input_path] + pipeline + [output_path] |
| 192 | result = subprocess.run(cmd, capture_output=True, text=True) |
| 193 | if result.returncode != 0: |
| 194 | print(f"❌ Error en preprocesado: {result.stderr}", file=sys.stderr) |
| 195 | sys.exit(1) |
| 196 | |
| 197 | |
| 198 | def ocr(image_path: str, idiomas: str, psm: int) -> str: |
| 199 | """Ejecuta Tesseract OCR y devuelve el texto extraído.""" |
| 200 | cmd = [ |
| 201 | "tesseract", image_path, "stdout", |
| 202 | "-l", idiomas, |
| 203 | "--psm", str(psm), |
| 204 | "--oem", "1", |
| 205 | ] |
| 206 | result = subprocess.run(cmd, capture_output=True, text=True) |
| 207 | if result.returncode != 0: |
| 208 | print(f"❌ Error en OCR: {result.stderr}", file=sys.stderr) |
| 209 | return "" |
| 210 | return result.stdout.strip() |
| 211 | |
| 212 | |
| 213 | def copiar_portapapeles(texto: str): |
| 214 | """Copia texto al portapapeles (Wayland o X11).""" |
| 215 | if subprocess.run(["which", "wl-copy"], capture_output=True).returncode == 0: |
| 216 | subprocess.run(["wl-copy"], input=texto, text=True) |
| 217 | elif subprocess.run(["which", "xclip"], capture_output=True).returncode == 0: |
| 218 | subprocess.run(["xclip", "-selection", "clipboard"], input=texto, text=True) |
| 219 | else: |
| 220 | print("⚠️ Ni wl-copy ni xclip disponibles. Texto solo por stdout.", file=sys.stderr) |
| 221 | return |
| 222 | print(" 📋 Copiado al portapapeles", file=sys.stderr) |
| 223 | |
| 224 | |
| 225 | # ─── Post-procesado con Ollama ──────────────────────────────────────────────── |
| 226 | |
| 227 | def llamar_ollama(system_prompt: str, user_prompt: str, model: str = OLLAMA_MODEL) -> str | None: |
| 228 | """Llama a Ollama y devuelve el texto generado.""" |
| 229 | payload = { |
| 230 | "model": model, |
| 231 | "messages": [ |
| 232 | {"role": "system", "content": system_prompt}, |
| 233 | {"role": "user", "content": user_prompt}, |
| 234 | ], |
| 235 | "stream": False, |
| 236 | "options": {"temperature": 0.1, "num_predict": 2048}, |
| 237 | } |
| 238 | |
| 239 | try: |
| 240 | resp = requests.post( |
| 241 | f"{OLLAMA_HOST}/api/chat", |
| 242 | json=payload, |
| 243 | timeout=OLLAMA_TIMEOUT, |
| 244 | ) |
| 245 | resp.raise_for_status() |
| 246 | return resp.json().get("message", {}).get("content", "").strip() |
| 247 | except requests.exceptions.ConnectionError: |
| 248 | print(" ⚠️ Ollama no está disponible. Omitiendo corrección con IA.", file=sys.stderr) |
| 249 | return None |
| 250 | except Exception as e: |
| 251 | print(f" ⚠️ Error en Ollama: {e}. Omitiendo corrección.", file=sys.stderr) |
| 252 | return None |
| 253 | |
| 254 | |
| 255 | def corregir_con_ia(texto: str, tipo: str, model: str = OLLAMA_MODEL) -> str: |
| 256 | """Corrige errores de OCR usando Ollama, adaptado al tipo de contenido.""" |
| 257 | prompts = { |
| 258 | "codigo": { |
| 259 | "system": ( |
| 260 | "Eres un corrector de OCR especializado en código fuente. " |
| 261 | "Tu tarea es corregir errores de reconocimiento óptico en código.\n\n" |
| 262 | "REGLAS:\n" |
| 263 | "1. Corrige caracteres mal reconocidos (0 por O, l por 1, etc.)\n" |
| 264 | "2. Restaura indentación y formato del código original\n" |
| 265 | "3. NO cambies nombres de variables, funciones o lógica\n" |
| 266 | "4. NO inventes líneas de código que no están\n" |
| 267 | "5. Mantén comentarios exactamente como están\n" |
| 268 | "6. Preserva la sintaxis del lenguaje\n" |
| 269 | "7. Devuelve SOLO el código corregido, sin explicaciones" |
| 270 | ), |
| 271 | "user": f"Corrige los errores de OCR en este código:\n\n```\n{texto}\n```", |
| 272 | }, |
| 273 | "tabla": { |
| 274 | "system": ( |
| 275 | "Eres un corrector de OCR especializado en tablas y datos numéricos. " |
| 276 | "Tu tarea es corregir errores de reconocimiento óptico en tablas.\n\n" |
| 277 | "REGLAS:\n" |
| 278 | "1. Corrige números mal reconocidos (1.000 por 1000, etc.)\n" |
| 279 | "2. Corrige símbolos monetarios (€ por EUR o similar)\n" |
| 280 | "3. Preserva la estructura de filas y columnas\n" |
| 281 | "4. NO inventes datos que no están en el original\n" |
| 282 | "5. NO rellenes celdas vacías con datos inventados\n" |
| 283 | "6. Si no estás seguro de un valor, déjalo como está\n" |
| 284 | "7. Devuelve SOLO la tabla corregida, sin explicaciones" |
| 285 | ), |
| 286 | "user": f"Corrige los errores de OCR en esta tabla:\n\n```\n{texto}\n```", |
| 287 | }, |
| 288 | "prosa": { |
| 289 | "system": ( |
| 290 | "Eres un corrector de OCR especializado en texto en español. " |
| 291 | "Tu tarea es corregir errores de reconocimiento óptico.\n\n" |
| 292 | "REGLAS:\n" |
| 293 | "1. Corrige caracteres mal reconocidos\n" |
| 294 | "2. Corrige espacios perdidos o añadidos entre palabras\n" |
| 295 | "3. Corrige puntuación y acentos cuando sea evidente\n" |
| 296 | "4. NO cambies el significado del texto\n" |
| 297 | "5. NO añadas información que no está\n" |
| 298 | "6. Preserva el estilo y tono original\n" |
| 299 | "7. Si una palabra no la reconoces bien, déjala como está\n" |
| 300 | "8. Devuelve SOLO el texto corregido, sin explicaciones" |
| 301 | ), |
| 302 | "user": f"Corrige los errores de OCR en este texto:\n\n```\n{texto}\n```", |
| 303 | }, |
| 304 | "desconocido": { |
| 305 | "system": ( |
| 306 | "Eres un corrector de OCR. Tu tarea es corregir errores " |
| 307 | "de reconocimiento óptico en el texto que se te pasa.\n\n" |
| 308 | "REGLAS:\n" |
| 309 | "1. Corrige solo errores evidentes de OCR\n" |
| 310 | "2. NO inventes contenido\n" |
| 311 | "3. NO cambies el formato o estructura\n" |
| 312 | "4. Devuelve SOLO el texto corregido, sin explicaciones" |
| 313 | ), |
| 314 | "user": f"Corrige los errores de OCR en este texto:\n\n```\n{texto}\n```", |
| 315 | }, |
| 316 | } |
| 317 | |
| 318 | p = prompts.get(tipo, prompts["desconocido"]) |
| 319 | resultado = llamar_ollama(p["system"], p["user"], model) |
| 320 | return resultado if resultado else texto |
| 321 | |
| 322 | |
| 323 | # ─── Main ───────────────────────────────────────────────────────────────────── |
| 324 | |
| 325 | def main(): |
| 326 | |
| 327 | parser = argparse.ArgumentParser( |
| 328 | description="ocr-ai: OCR inteligente con detección de contenido y corrección con IA", |
| 329 | formatter_class=argparse.RawDescriptionHelpFormatter, |
| 330 | epilog="""\ |
| 331 | Ejemplos: |
| 332 | python3 ocr-ai.py # Captura + OCR + IA |
| 333 | python3 ocr-ai.py -f captura.png # Procesa imagen existente |
| 334 | python3 ocr-ai.py -f captura.png --raw # Solo OCR, sin IA |
| 335 | python3 ocr-ai.py -f captura.png --copy --lang eng # Código al portapapeles |
| 336 | """, |
| 337 | ) |
| 338 | parser.add_argument("-f", "--file", help="Archivo de imagen a procesar (si no, captura interactiva)") |
| 339 | parser.add_argument("--lang", default="spa+eng", help="Idiomas para Tesseract (default: spa+eng)") |
| 340 | parser.add_argument("--psm", type=int, default=0, help="PSM de Tesseract (0 = auto-detect)") |
| 341 | parser.add_argument("--raw", action="store_true", help="OCR sin corrección con IA") |
| 342 | parser.add_argument("--copy", action="store_true", help="Copiar resultado al portapapeles") |
| 343 | parser.add_argument("--model", default=OLLAMA_MODEL, help=f"Modelo Ollama (default: {OLLAMA_MODEL})") |
| 344 | parser.add_argument("--detect-only", action="store_true", help="Solo detectar tipo de contenido y salir") |
| 345 | parser.add_argument("--debug", action="store_true", help="Modo verbose con información de depuración") |
| 346 | |
| 347 | args = parser.parse_args() |
| 348 | |
| 349 | # Verificar dependencias del sistema |
| 350 | check_deps() |
| 351 | |
| 352 | # Configurar modelo Ollama |
| 353 | ollama_model = args.model |
| 354 | |
| 355 | with tempfile.TemporaryDirectory(prefix="ocr-ai-") as tmpdir: |
| 356 | if args.file: |
| 357 | img_path = args.file |
| 358 | if not Path(img_path).exists(): |
| 359 | print(f"❌ Archivo no encontrado: {img_path}", file=sys.stderr) |
| 360 | sys.exit(1) |
| 361 | print(f"📄 Procesando: {img_path}", file=sys.stderr) |
| 362 | else: |
| 363 | img_path = os.path.join(tmpdir, "captura.png") |
| 364 | capturar_pantalla(img_path) |
| 365 | |
| 366 | # ─── 2. Preprocesar con pipeline por defecto ───────────────────────── |
| 367 | proc_path = os.path.join(tmpdir, "procesada.png") |
| 368 | print(f"🖼️ Preprocesando con ImageMagick...", file=sys.stderr) |
| 369 | |
| 370 | # Primera OCR para detectar tipo (con pipeline por defecto) |
| 371 | first_ocr_path = os.path.join(tmpdir, "first_ocr.png") |
| 372 | preprocesar(img_path, first_ocr_path, DEFAULT_PIPELINE) |
| 373 | texto_bruto = ocr(first_ocr_path, args.lang, args.psm if args.psm > 0 else 3) |
| 374 | |
| 375 | if not texto_bruto: |
| 376 | print("⚠️ No se detectó texto en la imagen", file=sys.stderr) |
| 377 | sys.exit(0) |
| 378 | |
| 379 | # ─── 3. Detectar tipo de contenido ────────────────────────────────── |
| 380 | tipo = detectar_tipo(texto_bruto) |
| 381 | print(f" 📋 Tipo detectado: {tipo.upper()}", file=sys.stderr) |
| 382 | |
| 383 | if args.detect_only: |
| 384 | print(f"\nTexto OCR (primeros 500 chars):\n{texto_bruto[:500]}") |
| 385 | return |
| 386 | |
| 387 | # ─── 4. Reprocesar con pipeline óptimo para el tipo ───────────────── |
| 388 | if args.psm == 0: |
| 389 | psm_optimo = psm_para_tipo(tipo) |
| 390 | else: |
| 391 | psm_optimo = args.psm |
| 392 | |
| 393 | pipeline_optimo = pipeline_para_tipo(tipo) |
| 394 | if args.debug: |
| 395 | print(f" 🔧 Pipeline: {' '.join(pipeline_optimo)}", file=sys.stderr) |
| 396 | print(f" 🔧 PSM: {psm_optimo}", file=sys.stderr) |
| 397 | |
| 398 | # Solo reprocesar si no era el pipeline por defecto |
| 399 | if pipeline_optimo != DEFAULT_PIPELINE: |
| 400 | print(f" 🔄 Reprocesando con pipeline optimizado para {tipo}...", file=sys.stderr) |
| 401 | preprocesar(img_path, proc_path, pipeline_optimo) |
| 402 | else: |
| 403 | proc_path = first_ocr_path |
| 404 | |
| 405 | texto_ocr = ocr(proc_path, args.lang, psm_optimo) |
| 406 | |
| 407 | if not texto_ocr: |
| 408 | print("⚠️ No se detectó texto tras reprocesado", file=sys.stderr) |
| 409 | texto_ocr = texto_bruto # Fallback al primer intento |
| 410 | |
| 411 | # ─── 5. Post-procesar con IA (opcional) ───────────────────────────── |
| 412 | if args.raw: |
| 413 | resultado = texto_ocr |
| 414 | else: |
| 415 | print(f" 🧠 Corrigiendo con {ollama_model}...", file=sys.stderr) |
| 416 | resultado = corregir_con_ia(texto_ocr, tipo, ollama_model) |
| 417 | if not resultado: |
| 418 | resultado = texto_ocr # Fallback si Ollama falla |
| 419 | |
| 420 | # ─── 6. Mostrar resultado ─────────────────────────────────────────── |
| 421 | print() |
| 422 | print("╔══════════════════════════════════════════════════════════════╗") |
| 423 | print(f"║ 📝 RESULTADO ({tipo.upper()}){' ' * (27 - len(tipo))}║") |
| 424 | print("╚══════════════════════════════════════════════════════════════╝") |
| 425 | print() |
| 426 | print(resultado) |
| 427 | print() |
| 428 | |
| 429 | # ─── 7. Copiar al portapapeles ────────────────────────────────────── |
| 430 | if args.copy: |
| 431 | copiar_portapapeles(resultado) |
| 432 | |
| 433 | # ─── 8. Estadísticas ──────────────────────────────────────────────── |
| 434 | if args.debug: |
| 435 | print(f"📊 Estadísticas:", file=sys.stderr) |
| 436 | print(f" Tipo detectado: {tipo}", file=sys.stderr) |
| 437 | print(f" PSM usado: {psm_optimo}", file=sys.stderr) |
| 438 | print(f" Caracteres OCR: {len(texto_ocr)}", file=sys.stderr) |
| 439 | print(f" Caracteres tras IA: {len(resultado)}", file=sys.stderr) |
| 440 | if not args.raw and texto_ocr != resultado: |
| 441 | # Mostrar cambios principales |
| 442 | print(f" ✏️ Correcciones aplicadas por IA", file=sys.stderr) |
| 443 | |
| 444 | |
| 445 | if __name__ == "__main__": |
| 446 | main() |