Last active 1 day ago

ocr_ai.py Raw
1#!/usr/bin/env python3
2"""
3ocr-ai.py — OCR inteligente con detección automática de contenido
4y post-procesado con IA (Ollama).
5
6Captura una región de la pantalla o procesa una imagen existente,
7detecta automáticamente el tipo de contenido (código, tabla, prosa),
8ajusta los parámetros de Tesseract, y limpia errores con Ollama.
9
10Uso:
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
16Dependencias:
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
24import argparse
25import json
26import os
27import re
28import subprocess
29import sys
30import tempfile
31from pathlib import Path
32
33import requests
34
35
36# ─── Configuración ─────────────────────────────────────────────────────────────
37
38OLLAMA_HOST = "http://localhost:11434"
39OLLAMA_MODEL = "llama3.2"
40OLLAMA_TIMEOUT = 60
41
42# Pipeline de ImageMagick por defecto
43DEFAULT_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)
52CODE_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)
63TABLE_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
75def 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
139def 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
149def 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
161def 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
173def 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
189def 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
198def 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
213def 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
227def 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
255def 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
325def 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="""\
331Ejemplos:
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
445if __name__ == "__main__":
446 main()