atareao revised this gist 1 day ago. Go to revision
1 file changed, 446 insertions
ocr_ai.py(file created)
| @@ -0,0 +1,446 @@ | |||
| 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() | |
Newer
Older