OCR-NER-Facturas / ocr_processor.py
Lucas Gagneten
Added I-COMPROBANTE_NUMERO
0c2f23e
import os
import uuid
from PIL import Image, ImageDraw
import numpy as np
import cv2
from gemini_ner_client import get_gemini_ner_tags
# Módulos de Doctr
from doctr.models import ocr_predictor
# Módulos locales
# Asegúrate de tener este archivo 'error_handler.py' en tu entorno
# from error_handler import ErrorHandler
# --- Configuración de Directorios ---
DATASET_BASE_DIR = "dataset"
IMAGES_DIR = os.path.join(DATASET_BASE_DIR, "imagenes")
# --- Inicialización del Modelo Doctr ---
# Se carga el modelo una sola vez al inicio del módulo
try:
# Usaremos el modelo por defecto, que es robusto.
OCR_MODEL = ocr_predictor(pretrained=True)
print("✅ Modelo Doctr cargado con éxito.")
except Exception as e:
print(f"FATAL: Error al cargar el modelo Doctr. Asegúrate de instalar doctr[full]. Error: {e}")
OCR_MODEL = None
# --- Función Dummy para ErrorHandler ---
# Se asume que ErrorHandler.show_error existe. Si no, usa print o logging.
class ErrorHandler:
@staticmethod
def show_error(title, message=""):
print(f"[{title}] ERROR: {message}")
# --- Preprocesamiento de Imagen (Activado) ---
def preprocess_image_for_ocr(image: Image.Image) -> Image.Image:
"""
Aplica preprocesamiento con OpenCV (conversión a escala de grises y umbral adaptativo)
para mejorar la calidad del OCR.
"""
"""
try:
# Convertir PIL Image a array NumPy RGB
img_np = np.array(image.convert('RGB'))
# Convertir a escala de grises
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
# Aplicar umbral adaptativo (útil para imágenes con iluminación variable)
thresh = cv2.adaptiveThreshold(
gray,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
11, # Tamaño del bloque (debe ser impar y > 1)
2 # Constante a restar de la media
)
# Devolver el array umbralizado como una imagen PIL
return Image.fromarray(thresh).convert('RGB')
except Exception as e:
ErrorHandler.show_error("Error de Preprocesamiento", f"Fallo al aplicar OpenCV: {e}")
# En caso de fallo, devolvemos la imagen original
return image
"""
return image
# --- Función Principal de OCR con Doctr ---
def get_ocr_data_doctr(image: Image.Image):
"""
Ejecuta Doctr en la imagen, opcionalmente preprocesada, y devuelve
tokens y bboxes normalizados para LayoutXLM.
"""
if image is None:
raise ValueError("No se proporcionó ninguna imagen.")
if OCR_MODEL is None:
ErrorHandler.show_error("Error de Modelo",
"El motor Doctr no está disponible. Revise el log de inicio.")
return None, [], None
W, H = image.size
tokens_data = []
# 💡 PASO CLAVE: Aplicar Preprocesamiento
# Almacenamos la imagen original ANTES del preprocesamiento
image_orig_unprocessed = image.copy()
processed_image = preprocess_image_for_ocr(image)
try:
# Doctr espera la imagen como un array numpy RGB
img_np = np.array(processed_image.convert('RGB'))
# 1. Ejecutar la predicción
result = OCR_MODEL([img_np]) # El modelo espera una lista de imágenes
# 2. Parsear el resultado a nivel de 'Word'
for page in result.pages:
for block in page.blocks:
for line in block.lines:
for word in line.words:
token_text = word.value.strip()
if not token_text:
continue
# Las coordenadas de Doctr son normalizadas a 0-1 (fracciones)
# word.geometry: [[x_min, y_min], [x_max, y_max]]
bbox_frac = word.geometry
# BBox original (en píxeles de la imagen original)
x_min, y_min, x_max, y_max = [
int(bbox_frac[0][0] * W),
int(bbox_frac[0][1] * H),
int(bbox_frac[1][0] * W),
int(bbox_frac[1][1] * H)
]
bbox_original = [x_min, y_min, x_max, y_max]
# BBox normalizado a 0-1000 (para LayoutXLM)
# Nota: Se corrigen los denominadores. LayoutXLM normaliza X e Y
# con respecto al tamaño completo (W o H).
bbox_normalized = [
int(x_min * 1000 / W),
int(y_min * 1000 / H),
int(x_max * 1000 / W),
int(y_max * 1000 / H)
]
tokens_data.append({
'token': token_text,
'bbox_norm': bbox_normalized,
'bbox_orig': bbox_original,
'ner_tag': 'O'
})
# 💡 Importante: Devolvemos la imagen original sin procesar, ya que
# esta es la que se guarda y se usa para el dibujo en la interfaz.
return image_orig_unprocessed, tokens_data, None
except Exception as e:
ErrorHandler.show_error("Error de Procesamiento OCR", e)
return None, [], None
# --- Funciones de Visualización y Guardado ---
def draw_boxes(image: Image.Image, tokens_data: list, highlight_index: int = -1):
"""Dibuja un resaltado en la imagen para el bounding box seleccionado."""
if image is None or not tokens_data:
return None
img_copy = image.copy()
draw = ImageDraw.Draw(img_copy)
if highlight_index >= 0 and highlight_index < len(tokens_data):
# Usamos bbox_orig para dibujar en la imagen a tamaño completo
bbox = tokens_data[highlight_index]['bbox_orig']
x1, y1, x2, y2 = bbox
outline_color = (255, 0, 0)
draw.rectangle([x1, y1, x2, y2], outline=outline_color, width=4)
return img_copy
def save_image_to_dataset(image: Image.Image) -> str:
"""
Genera un nombre único y guarda la imagen en el directorio de dataset.
Retorna el nombre único del archivo.
"""
# 1. Crear el nombre único (UUID + extensión)
unique_filename = f"{uuid.uuid4()}.jpeg"
save_path = os.path.join(IMAGES_DIR, unique_filename)
# 2. Asegurar que el directorio exista (es importante en un entorno efímero)
os.makedirs(IMAGES_DIR, exist_ok=True)
# 3. Guardar la imagen
image.save(save_path, format="JPEG")
print(f"Imagen guardada: {save_path}")
return unique_filename
# --- Función de Flujo Principal ---
def process_and_setup(image_file, api_key: str): # 💡 ACEPTA LA LLAVE API
"""
Función inicial: OCR con Doctr, **NER asistido por Gemini**,
configuración del estado y guardar la imagen.
"""
if image_file is None:
empty_df = {'token': [], 'ner_tag': []}
return None, [], None, empty_df, None, None
# 💡 Llama a la función de OCR basada en Doctr
image_orig, tokens_data, _ = get_ocr_data_doctr(image_file)
if image_orig is None:
empty_df = {'token': [], 'ner_tag': []}
return None, [], None, empty_df, "Error fatal al procesar el OCR con Doctr. Revise el log.", None
# 💡 PASO NUEVO: NER Asistido por Gemini (CONDICIONAL)
msg_ner_assist = ""
if api_key:
# Llama a la función que SÓLO se ejecuta si api_key no es None/vacío
tokens_data = get_gemini_ner_tags(api_key, tokens_data)
msg_ner_assist = " (NER asistido)"
# --- Guardar la Imagen Original ---
image_filename = save_image_to_dataset(image_orig)
if not tokens_data:
empty_df = {'token': [], 'ner_tag': []}
msg = "OCR completado. No se detectaron tokens válidos."
ErrorHandler.show_error(msg)
return image_orig, [], None, empty_df, msg, image_filename
# Crear el DataFrame inicial para la edición en Gradio
df_data = {
'token': [item['token'] for item in tokens_data],
'ner_tag': [item['ner_tag'] for item in tokens_data]
}
# La imagen inicial no tiene resaltado
highlighted_image = image_orig.copy()
msg = f"OCR de Doctr completado. Tokens detectados: {len(tokens_data)}.{msg_ner_assist}"
print(msg)
return image_orig, tokens_data, highlighted_image, df_data, msg, image_filename