""" Module pour l'extraction d'informations de chèques à partir d'images. Ce module fournit les fonctionnalités de base pour extraire des informations telles que le montant, la date, le bénéficiaire et le numéro de chèque. """ import fitz # PyMuPDF import re import os import cv2 import numpy as np import tempfile import logging import shutil # Utilisé mais non importé, ajouté import math from pathlib import Path from typing import Dict, Tuple, Optional, Any, Union, List # Configuration du logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger("cheque_scanner") def extraire_infos_cheque( chemin_image: str, methode: str = "hybride_avance", language: str = "fra", tessdata: Optional[str] = None ) -> Tuple[Dict[str, Any], str]: """ Extrait les informations d'un chèque à partir d'une image Args: chemin_image: Chemin vers l'image du chèque methode: "ocr", "cv", "micr", "hybride" ou "hybride_avance" - "ocr": Utilise PyMuPDF+Tesseract - "cv": Utilise OpenCV sans OCR - "micr": Extraction spécialisée des caractères MICR CMC-7 - "hybride": Combine MICR et OCR standard - "hybride_avance": Combine MICR, OCR et extraction du montant en lettres language: Code de langue pour OCR (fra par défaut pour les chèques français) tessdata: Chemin vers le dossier tessdata (optionnel) Returns: Tuple (infos, texte) où infos est un dictionnaire et texte le texte brut extrait """ if not os.path.exists(chemin_image): raise FileNotFoundError(f"L'image {chemin_image} n'existe pas") logger.info(f"Traitement de l'image: {chemin_image}") # Méthode hybride avancée (OCR + MICR + montant en lettres) if methode == "hybride_avance": try: logger.info("Utilisation de la méthode hybride avancée") # 1. Extraire les infos MICR (code banque, numéro de compte, etc.) infos_micr, texte_micr = extraire_par_micr_cmc7(chemin_image) # 2. Extraire les informations générales par OCR infos_ocr, texte_ocr = extraire_par_ocr(chemin_image, language, tessdata) # 3. Extraire spécifiquement le montant en lettres montant_lettres = extraire_montant_en_lettres(chemin_image) # 4. Fusionner les résultats infos = {**infos_ocr, **infos_micr} # Ajouter le montant en lettres if montant_lettres: infos["montant_lettres"] = montant_lettres # 5. Validation croisée montant en chiffres vs lettres if infos.get("montant") and montant_lettres: coherence = verifier_coherence_montants(infos["montant"], montant_lettres) infos["coherence_montants"] = coherence # Texte complet texte_complet = ( texte_ocr + "\n--- MONTANT EN LETTRES ---\n" + (montant_lettres or "Non détecté") + "\n--- MICR CMC-7 ---\n" + texte_micr ) # Ajouter la méthode utilisée infos["methode"] = "hybride_avance" return infos, texte_complet except Exception as e: logger.error(f"Erreur avec la méthode hybride avancée: {e}") logger.info("Fallback vers la méthode hybride standard...") try: return extraire_par_hybride(chemin_image, language, tessdata) except Exception as e2: logger.error(f"Erreur avec la méthode hybride: {e2}") logger.info("Fallback vers OCR standard...") return extraire_par_ocr(chemin_image, language, tessdata) # Méthode hybride standard (MICR + OCR) elif methode == "hybride": return extraire_par_hybride(chemin_image, language, tessdata) # Méthode MICR uniquement elif methode == "micr": return extraire_par_micr_cmc7(chemin_image) # Méthode OCR uniquement elif methode == "ocr": try: logger.info("Utilisation de la méthode OCR") return extraire_par_ocr(chemin_image, language, tessdata) except Exception as e: logger.error(f"Erreur avec OCR: {e}") logger.info("Tentative d'extraction par traitement d'image...") return extraire_par_cv(chemin_image) # Méthode CV uniquement else: logger.info("Utilisation de la méthode de traitement d'image sans OCR") return extraire_par_cv(chemin_image) def extraire_par_hybride( chemin_image: str, language: str = "fra", tessdata: Optional[str] = None ) -> Tuple[Dict[str, Any], str]: """ Extraction hybride combinant MICR CMC-7 et OCR standard Args: chemin_image: Chemin vers l'image du chèque language: Code de langue pour OCR tessdata: Chemin vers le dossier tessdata (optionnel) Returns: Tuple (infos, texte) où infos est un dictionnaire et texte le texte brut extrait """ logger.info(f"Extraction hybride pour: {chemin_image}") try: # 1. Extraire les infos MICR (code banque, numéro de compte, etc.) infos_micr, texte_micr = extraire_par_micr_cmc7(chemin_image) # 2. Extraire les informations générales par OCR infos_ocr, texte_ocr = extraire_par_ocr(chemin_image, language, tessdata) # 3. Fusionner les résultats, en privilégiant MICR pour les numéros infos = {**infos_ocr, **infos_micr} # Texte complet texte_complet = texte_ocr + "\n--- MICR CMC-7 ---\n" + texte_micr # Ajouter la méthode utilisée infos["methode"] = "hybride" return infos, texte_complet except Exception as e: logger.error(f"Erreur avec la méthode hybride: {e}") logger.info("Fallback vers OCR standard...") return extraire_par_ocr(chemin_image, language, tessdata) def extraire_par_ocr( chemin_image: str, language: str = "eng", tessdata: Optional[str] = None ) -> Tuple[Dict[str, Any], str]: """Extraction par OCR avec PyMuPDF et Tesseract""" # 1. Charger l'image avec PyMuPDF pixmap = fitz.Pixmap(chemin_image) # 2. S'assurer que l'image est au format approprié (RGB sans transparence) if pixmap.alpha: pixmap = fitz.Pixmap(fitz.csRGB, pixmap) # Convertir si nécessaire # 3. Améliorer la qualité de l'image pour l'OCR # Convertir en format OpenCV pour le prétraitement # Vérifier le nombre de canaux pour éviter les erreurs de reshape try: # Obtenir des informations détaillées sur le pixmap n_channels = pixmap.n logger.debug(f"Pixmap info: h={pixmap.h}, w={pixmap.w}, n={n_channels}, stride={pixmap.stride}") # Vérifier si les dimensions correspondent au nombre d'échantillons expected_size = pixmap.h * pixmap.w * n_channels actual_size = len(pixmap.samples) if expected_size != actual_size: logger.warning(f"Taille d'échantillon incorrecte: attendu {expected_size}, obtenu {actual_size}") raise ValueError("Dimension mismatch") # Tenter le reshape standard if n_channels in [1, 3, 4]: # Niveaux de gris, RGB ou RGBA img = np.frombuffer(pixmap.samples, dtype=np.uint8).reshape(pixmap.h, pixmap.w, n_channels) if n_channels == 4: # Si RGBA, convertir en RGB img = cv2.cvtColor(img, cv2.COLOR_RGBA2RGB) elif n_channels == 1: # Si grayscale, convertir en RGB pour traitement uniforme img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) else: raise ValueError(f"Nombre de canaux inattendu: {n_channels}") except Exception as e: logger.warning(f"Erreur lors de la conversion du pixmap: {e}") # Méthode alternative: sauvegarder et recharger l'image with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file: tmp_path = tmp_file.name pixmap.save(tmp_path) logger.info(f"Utilisation de la méthode alternative: chargement via OpenCV depuis {tmp_path}") img = cv2.imread(tmp_path) if img is None: # Si l'image ne peut pas être chargée, essayer de la charger en niveaux de gris img = cv2.imread(tmp_path, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(f"Impossible de charger l'image: {tmp_path}") # Convertir en RGB pour le traitement standard img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) # Conserver le fichier temporaire pour le débug si nécessaire debug_tmp_path = os.path.join(os.path.dirname(chemin_image), f"debug_tmp_{os.path.basename(chemin_image)}") shutil.copy(tmp_path, debug_tmp_path) logger.info(f"Image temporaire sauvegardée pour debug: {debug_tmp_path}") # Nettoyage du fichier temporaire original os.unlink(tmp_path) # Prétraitement avancé pour améliorer la détection de texte # 1. Conversion en niveaux de gris gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) # 2. Amélioration du contraste par égalisation d'histogramme clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) # 3. Réduction du bruit blurred = cv2.GaussianBlur(enhanced, (5, 5), 0) # 4. Binarisation adaptative pour mieux préserver les détails du texte thresh = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 11, 2) # 5. Opérations morphologiques pour nettoyer l'image kernel = np.ones((1, 1), np.uint8) thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) # Sauvegarder l'image prétraitée temporairement with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp_file: tmp_path = tmp_file.name cv2.imwrite(tmp_path, thresh) try: # Charger l'image prétraitée pixmap_processed = fitz.Pixmap(tmp_path) # Vérifier la validité du pixmap if pixmap_processed.width == 0 or pixmap_processed.height == 0: raise ValueError("Pixmap invalide après prétraitement") # Debug: sauvegarder une copie de l'image prétraitée pour inspection debug_path = os.path.join(os.path.dirname(chemin_image), f"debug_preprocess_{os.path.basename(chemin_image)}") pixmap_processed.save(debug_path) logger.info(f"Image prétraitée sauvegardée pour debug: {debug_path}") except Exception as e: logger.error(f"Erreur lors du chargement de l'image prétraitée: {e}") raise e try: # 4. Effectuer l'OCR et générer un PDF avec une couche de texte try: if tessdata: # Si un chemin vers tessdata est fourni logger.debug(f"Utilisation du dossier tessdata personnalisé: {tessdata}") pdf_ocr = pixmap_processed.pdfocr_tobytes(language=language, tessdata=tessdata) else: # Essai avec la configuration par défaut logger.debug(f"Utilisation du dossier tessdata par défaut") pdf_ocr = pixmap_processed.pdfocr_tobytes(language=language) except Exception as ocr_error: logger.warning(f"Erreur lors de l'OCR avec PyMuPDF: {ocr_error}") # Essai avec Tesseract directement via un fichier temporaire import pytesseract logger.info("Utilisation de pytesseract directement") # L'image prétraitée est déjà sauvegardée dans tmp_path texte = pytesseract.image_to_string(tmp_path, lang=language) # Simuler un document PDF pour maintenir la cohérence du code class MockPage: def get_text(self): return texte class MockDoc: def __init__(self): self.pages = [MockPage()] def __getitem__(self, idx): return self.pages[0] def close(self): pass doc = MockDoc() page = doc[0] else: # 5. Charger le PDF OCR et extraire le texte (seulement si la méthode PyMuPDF a réussi) doc = fitz.open("pdf", pdf_ocr) page = doc[0] texte = page.get_text() # 6. Analyser le texte pour trouver les informations pertinentes infos = { "montant": extraire_montant(texte), "date": extraire_date(texte), "beneficiaire": extraire_beneficiaire(texte), "numero_cheque": extraire_numero_cheque(texte) } # Ajouter des métadonnées sur la qualité de l'extraction infos["qualite_extraction"] = evaluer_qualite_extraction(infos) doc.close() # Nettoyage du fichier temporaire os.unlink(tmp_path) return infos, texte except Exception as e: # Nettoyage en cas d'erreur if os.path.exists(tmp_path): os.unlink(tmp_path) logger.error(f"Erreur pendant l'OCR: {str(e)}") raise e def extraire_par_cv(chemin_image: str) -> Tuple[Dict[str, Any], str]: """Extraction par traitement d'image avec OpenCV sans OCR""" logger.info(f"Extraction par traitement d'image pour: {chemin_image}") try: # Charger l'image avec différentes méthodes si nécessaire img = cv2.imread(chemin_image) if img is None: # Essayer de charger l'image en niveaux de gris logger.warning(f"Impossible de charger l'image en couleur, tentative en niveaux de gris") img = cv2.imread(chemin_image, cv2.IMREAD_GRAYSCALE) if img is None: # Dernière tentative: utiliser PIL/Pillow logger.warning("Tentative de chargement via PIL/Pillow") try: from PIL import Image import numpy as np pil_img = Image.open(chemin_image) img = np.array(pil_img) if len(img.shape) == 2: # Image en niveaux de gris img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) elif img.shape[2] == 4: # Image RGBA img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR) except Exception as pil_error: logger.error(f"Erreur avec PIL: {pil_error}") raise ValueError(f"Impossible de charger l'image: {chemin_image}") if img is None: raise ValueError(f"Impossible de charger l'image: {chemin_image} avec aucune méthode") # Enregistrer les dimensions de l'image pour le diagnostic height, width = img.shape[:2] logger.info(f"Dimensions de l'image: {width}x{height}") # Prétraitement avancé # Conversion en niveaux de gris si nécessaire if len(img.shape) == 3: # Image en couleur gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) else: # Déjà en niveaux de gris gray = img # Amélioration du contraste clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) # Réduction du bruit blurred = cv2.GaussianBlur(enhanced, (5, 5), 0) # Binarisation adaptative thresh = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 11, 2) # Opérations morphologiques pour améliorer la détection kernel = np.ones((3, 3), np.uint8) thresh = cv2.morphologyEx(thresh, cv2.MORPH_CLOSE, kernel) thresh = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel) # Enregistrer l'image prétraitée pour diagnostic preproc_path = f"pretraitement_{os.path.basename(chemin_image)}" cv2.imwrite(preproc_path, thresh) logger.info(f"Image prétraitée enregistrée: {preproc_path}") # Trouver les contours (rectangles potentiels des zones importantes) contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE) # Filtrer les contours pour ne garder que ceux qui pourraient être des zones d'intérêt filtered_contours = [] for contour in contours: area = cv2.contourArea(contour) # Ne garder que les contours d'une certaine taille if area > 500 and area < (width * height * 0.5): # Calculer le rectangle englobant x, y, w, h = cv2.boundingRect(contour) # Filtrer par ratio d'aspect pour éviter les lignes/bandes trop étroites aspect_ratio = float(w) / h if h > 0 else 0 if 0.1 < aspect_ratio < 10: filtered_contours.append(contour) logger.info(f"Nombre de contours détectés: {len(contours)}, filtrés: {len(filtered_contours)}") # Utiliser des contours détectés si possible, sinon utiliser des zones prédéfinies if len(filtered_contours) >= 3: # Trier les contours par position (haut vers bas) filtered_contours = sorted(filtered_contours, key=lambda c: cv2.boundingRect(c)[1]) # Utiliser des heuristiques pour déterminer les zones importantes # Pour la date (généralement en haut) top_contours = [c for c in filtered_contours if cv2.boundingRect(c)[1] < height * 0.3] if top_contours: # Préférer les contours en haut à droite pour la date top_right_contours = sorted(top_contours, key=lambda c: width - (cv2.boundingRect(c)[0] + cv2.boundingRect(c)[2])) if top_right_contours: x, y, w, h = cv2.boundingRect(top_right_contours[0]) zone_date = (x, y, x + w, y + h) else: x, y, w, h = cv2.boundingRect(top_contours[0]) zone_date = (x, y, x + w, y + h) else: # Zone par défaut pour la date zone_date = (int(width*0.7), int(height*0.1), int(width*0.95), int(height*0.25)) # Pour le bénéficiaire (généralement au milieu) middle_contours = [c for c in filtered_contours if height * 0.2 < cv2.boundingRect(c)[1] < height * 0.6] if middle_contours: # Trouver le plus grand contour dans la zone du milieu largest_middle = max(middle_contours, key=cv2.contourArea) x, y, w, h = cv2.boundingRect(largest_middle) zone_beneficiaire = (x, y, x + w, y + h) else: # Zone par défaut pour le bénéficiaire zone_beneficiaire = (int(width*0.2), int(height*0.25), int(width*0.8), int(height*0.4)) # Pour le montant (généralement en bas à droite) bottom_contours = [c for c in filtered_contours if cv2.boundingRect(c)[1] > height * 0.5] if bottom_contours: # Préférer les contours en bas à droite pour le montant bottom_right_contours = sorted(bottom_contours, key=lambda c: -cv2.boundingRect(c)[0]) if bottom_right_contours: x, y, w, h = cv2.boundingRect(bottom_right_contours[0]) zone_montant = (x, y, x + w, y + h) else: x, y, w, h = cv2.boundingRect(bottom_contours[-1]) zone_montant = (x, y, x + w, y + h) else: # Zone par défaut pour le montant zone_montant = (int(width*0.6), int(height*0.35), int(width*0.95), int(height*0.55)) logger.info("Zones détectées basées sur analyse des contours") else: # Estimation des zones d'intérêt basée sur la position relative logger.info("Utilisation des zones prédéfinies (pas assez de contours détectés)") # Zone approximative pour le montant (généralement en bas à droite) zone_montant = (int(width*0.6), int(height*0.35), int(width*0.95), int(height*0.55)) # Zone approximative pour la date (généralement en haut à droite) zone_date = (int(width*0.7), int(height*0.1), int(width*0.95), int(height*0.25)) # Zone approximative pour le bénéficiaire (généralement au milieu) zone_beneficiaire = (int(width*0.2), int(height*0.25), int(width*0.8), int(height*0.4)) # Créer une image avec les zones identifiées et les contours img_zones = img.copy() # Dessiner tous les contours filtrés en jaune cv2.drawContours(img_zones, filtered_contours, -1, (0, 255, 255), 1) # Dessiner les zones principales avec étiquettes cv2.rectangle(img_zones, (zone_montant[0], zone_montant[1]), (zone_montant[2], zone_montant[3]), (0, 255, 0), 2) cv2.putText(img_zones, "Montant", (zone_montant[0], zone_montant[1]-5), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.rectangle(img_zones, (zone_date[0], zone_date[1]), (zone_date[2], zone_date[3]), (255, 0, 0), 2) cv2.putText(img_zones, "Date", (zone_date[0], zone_date[1]-5), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 0, 0), 2) cv2.rectangle(img_zones, (zone_beneficiaire[0], zone_beneficiaire[1]), (zone_beneficiaire[2], zone_beneficiaire[3]), (0, 0, 255), 2) cv2.putText(img_zones, "Bénéficiaire", (zone_beneficiaire[0], zone_beneficiaire[1]-5), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) # Générer un nom de fichier unique pour l'image avec les zones identifiées image_result_path = f"zones_identifiees_{os.path.basename(chemin_image)}" cv2.imwrite(image_result_path, img_zones) logger.info(f"Image avec zones identifiées enregistrée sous: {image_result_path}") # Tenter d'extraire le texte des zones avec OCR si pytesseract est disponible try: import pytesseract logger.info("Tentative d'OCR des zones avec pytesseract") # Extraire les sous-images pour chaque zone roi_montant = gray[zone_montant[1]:zone_montant[3], zone_montant[0]:zone_montant[2]] roi_date = gray[zone_date[1]:zone_date[3], zone_date[0]:zone_date[2]] roi_beneficiaire = gray[zone_beneficiaire[1]:zone_beneficiaire[3], zone_beneficiaire[0]:zone_beneficiaire[2]] # OCR sur chaque zone texte_montant = pytesseract.image_to_string(roi_montant, lang='fra').strip() texte_date = pytesseract.image_to_string(roi_date, lang='fra').strip() texte_beneficiaire = pytesseract.image_to_string(roi_beneficiaire, lang='fra').strip() logger.info(f"OCR zones - Montant: '{texte_montant}', Date: '{texte_date}', Bénéficiaire: '{texte_beneficiaire}'") # Informations extraites avec OCR des zones infos = { "montant": texte_montant if texte_montant else f"Zone identifiée, voir {image_result_path} (vert)", "date": texte_date if texte_date else f"Zone identifiée, voir {image_result_path} (bleu)", "beneficiaire": texte_beneficiaire if texte_beneficiaire else f"Zone identifiée, voir {image_result_path} (rouge)", "numero_cheque": "Non détecté", "image_zones": image_result_path, "methode": "cv+ocr_zones" } texte_complet = f"Montant: {texte_montant}\nDate: {texte_date}\nBénéficiaire: {texte_beneficiaire}" return infos, texte_complet except Exception as ocr_error: logger.warning(f"Échec de l'OCR des zones: {ocr_error}") # Sans OCR, retourner les zones identifiées infos = { "montant": f"Zone identifiée, voir {image_result_path} (vert)", "date": f"Zone identifiée, voir {image_result_path} (bleu)", "beneficiaire": f"Zone identifiée, voir {image_result_path} (rouge)", "numero_cheque": "Non détecté sans OCR", "image_zones": image_result_path, "methode": "cv_only" } return infos, f"Texte non disponible sans OCR - visualisation des zones générée dans {image_result_path}" except Exception as e: logger.error(f"Erreur lors du traitement de l'image: {str(e)}") # Créer une image basique avec un message d'erreur error_img = np.ones((300, 600, 3), dtype=np.uint8) * 255 cv2.putText(error_img, f"ERREUR: {str(e)}", (50, 150), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 0, 255), 2) image_result_path = f"erreur_{os.path.basename(chemin_image)}" cv2.imwrite(image_result_path, error_img) # Informations minimales en cas d'erreur infos = { "montant": "Erreur de traitement", "date": "Erreur de traitement", "beneficiaire": "Erreur de traitement", "numero_cheque": "Erreur de traitement", "image_zones": image_result_path, "erreur": str(e) } return infos, f"Erreur lors du traitement de l'image: {str(e)}" # Fonctions d'extraction MICR et montant en lettres def extraire_par_micr_cmc7(chemin_image: str) -> Tuple[Dict[str, Any], str]: """ Extraction spécialisée pour les caractères CMC-7 au bas des chèques européens Args: chemin_image: Chemin vers l'image du chèque Returns: Tuple (infos, texte) où infos contient les informations bancaires structurées """ logger.info(f"Extraction MICR CMC-7 pour: {chemin_image}") try: # Chargement et validation de l'image img = cv2.imread(chemin_image) if img is None: raise ValueError(f"Impossible de charger l'image: {chemin_image}") # 1. Localisation de la bande MICR (tiers inférieur du chèque) bande_micr = localiser_bande_micr_cmc7(img) # Sauvegarder la bande MICR pour diagnostic debug_path = f"debug_micr_band_{os.path.basename(chemin_image)}" cv2.imwrite(debug_path, bande_micr) logger.info(f"Bande MICR sauvegardée pour debug: {debug_path}") # 2. Prétraitement spécifique pour CMC-7 bande_preprocessed = pretraiter_cmc7(bande_micr) # Sauvegarder l'image prétraitée debug_preproc_path = f"debug_micr_preproc_{os.path.basename(chemin_image)}" cv2.imwrite(debug_preproc_path, bande_preprocessed) # 3. Segmentation des caractères individuels caracteres = segmenter_caracteres_cmc7(bande_preprocessed) logger.info(f"Nombre de caractères CMC-7 segmentés: {len(caracteres)}") # 4. Reconnaissance des caractères CMC-7 sequence = reconnaitre_sequence_cmc7(caracteres) logger.info(f"Séquence MICR reconnue: {sequence}") # 5. Extraction des informations structurées infos = extraire_infos_cmc7(sequence) return infos, f"Séquence MICR: {sequence}" except Exception as e: logger.error(f"Erreur lors de l'extraction MICR: {str(e)}") return { "erreur_micr": str(e), "methode": "micr_cmc7_failed" }, f"Erreur lors de l'extraction MICR: {str(e)}" def localiser_bande_micr_cmc7(img): """ Localise la bande MICR en bas du chèque Le CMC-7 est généralement dans le tiers inférieur """ height, width = img.shape[:2] # Focalisation sur le tiers inférieur où se trouve typiquement la ligne MICR lower_third = img[int(height*0.65):height, 0:width] # Conversion en niveaux de gris si nécessaire gray = cv2.cvtColor(lower_third, cv2.COLOR_BGR2GRAY) if len(lower_third.shape) > 2 else lower_third # Binarisation adaptative pour mettre en évidence les caractères binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 19, 3) # Opérations morphologiques pour connecter les composants des caractères kernel = np.ones((3, 9), np.uint8) # Noyau horizontal pour connecter les barres du CMC-7 connected = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # Détection des composantes connexes (candidats potentiels) contours, _ = cv2.findContours(connected, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # Filtrage des contours par taille pour trouver la ligne MICR filtered_contours = [] for contour in contours: x, y, w, h = cv2.boundingRect(contour) aspect_ratio = w / h area = cv2.contourArea(contour) # Filtres pour identifier la ligne MICR: # - Largeur significative (une ligne de caractères) # - Hauteur dans une plage typique pour CMC-7 # - Ratio d'aspect élevé (c'est une ligne) if w > width*0.3 and 10 < h < 50 and aspect_ratio > 5: filtered_contours.append(contour) # Si aucun contour correspondant n'est trouvé, utiliser une approche heuristique if not filtered_contours: logger.warning("Bande MICR non détectée par analyse de contours, utilisation d'heuristique") y_start = int(gray.shape[0] * 0.5) # Milieu du tiers inférieur y_end = int(gray.shape[0] * 0.8) # 80% vers le bas du tiers inférieur return lower_third[y_start:y_end, 0:width] # Trier par position verticale (de haut en bas) filtered_contours = sorted(filtered_contours, key=lambda c: cv2.boundingRect(c)[1]) # Prendre le dernier contour (probablement la ligne MICR en bas) best_contour = filtered_contours[-1] x, y, w, h = cv2.boundingRect(best_contour) # Extraire la région avec une marge margin = int(h * 0.4) y_start = max(0, y - margin) y_end = min(lower_third.shape[0], y + h + margin) micr_line = lower_third[y_start:y_end, 0:width] return micr_line def pretraiter_cmc7(image): """ Prétraitement spécifique pour les caractères CMC-7 Optimisé pour faire ressortir les barres verticales caractéristiques """ # Conversion en niveaux de gris si nécessaire gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) > 2 else image # Amélioration du contraste clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) # Réduction du bruit denoised = cv2.fastNlMeansDenoising(enhanced, None, 13, 7, 21) # Binarisation adaptative adaptée au CMC-7 binary = cv2.adaptiveThreshold(denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 19, 5) # Opérations morphologiques pour nettoyer l'image kernel_v = np.ones((7, 1), np.uint8) # Noyau vertical pour renforcer les barres morphed = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel_v) morphed = cv2.morphologyEx(morphed, cv2.MORPH_OPEN, np.ones((1, 2), np.uint8)) return morphed def segmenter_caracteres_cmc7(image_preprocessed): """ Segmente les caractères individuels du CMC-7 Les caractères CMC-7 ont un espacement régulier et une largeur constante """ # Trouver les contours des caractères potentiels contours, _ = cv2.findContours(image_preprocessed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # Filtrer et trier les contours par position horizontale (gauche à droite) char_contours = [] for contour in contours: x, y, w, h = cv2.boundingRect(contour) # Filtrer par taille typique d'un caractère CMC-7 if 5 < w < 40 and h > 10: char_contours.append((x, y, w, h)) # Trier les contours de gauche à droite char_contours.sort(key=lambda c: c[0]) # Extraire chaque caractère caracteres = [] for x, y, w, h in char_contours: # Ajouter une marge autour du caractère margin_x = int(w * 0.1) margin_y = int(h * 0.1) # Calculer les coordonnées avec marge x1 = max(0, x - margin_x) y1 = max(0, y - margin_y) x2 = min(image_preprocessed.shape[1], x + w + margin_x) y2 = min(image_preprocessed.shape[0], y + h + margin_y) # Extraire le caractère char_img = image_preprocessed[y1:y2, x1:x2] # Normaliser la taille pour la reconnaissance char_img_resized = cv2.resize(char_img, (28, 40)) caracteres.append(char_img_resized) return caracteres def reconnaitre_sequence_cmc7(caracteres): """ Reconnaissance basique des caractères CMC-7 segmentés Dans cette version, nous utilisons une approche simplifiée qui identifie principalement les chiffres et retourne la séquence brute """ # Dans cette implémentation de base, nous simulons la reconnaissance # Une implémentation complète nécessiterait soit: # 1. Un ensemble de templates pour template matching # 2. Un classificateur ML entraîné pour les caractères CMC-7 sequence = "" separateurs = ["S1", "S2", "S3", "S4", "S5"] sep_count = 0 for i, char_img in enumerate(caracteres): # Calculer le nombre de pixels blancs (approximation de la densité) pixel_density = np.sum(char_img) / 255.0 / (char_img.shape[0] * char_img.shape[1]) # Dans l'idéal, nous aurions un classificateur précis ici # Pour cette implémentation de base, nous utilisons une heuristique simplifiée # Les caractères de séparation ont généralement une densité différente # Si la densité est dans une certaine plage, c'est probablement un séparateur if 0.4 < pixel_density < 0.6 and i % 7 == 6 and sep_count < 5: char = separateurs[sep_count] sep_count += 1 else: # Choisir un chiffre basé sur la densité (approximatif) # Cela est très basique et devrait être remplacé par une vraie reconnaissance digit_idx = min(9, int(pixel_density * 10)) char = str(digit_idx) sequence += char return sequence def extraire_infos_cmc7(sequence): """ Extrait les informations bancaires d'une séquence CMC-7 Format français: [Code banque][S1][Code guichet][S2][Numéro de compte][S3][Clé RIB][S4][Numéro de chèque][S5] """ # Recherche des séparateurs (S1 à S5) separateurs = ['S1', 'S2', 'S3', 'S4', 'S5'] # Initialiser les résultats infos = { "code_banque": None, "code_guichet": None, "numero_compte": None, "cle_rib": None, "numero_cheque": None, "sequence_micr": sequence, "format": "CMC-7" } # Si la séquence est trop courte, retourner des valeurs par défaut if len(sequence) < 10: logger.warning(f"Séquence MICR trop courte: {sequence}") return infos # Essayer de découper la séquence selon les séparateurs try: positions = [] current_pos = 0 # Chercher les positions des séparateurs for sep in separateurs: pos = sequence.find(sep, current_pos) if pos != -1: positions.append(pos) current_pos = pos + len(sep) else: positions.append(-1) # Si tous les séparateurs sont trouvés if all(pos != -1 for pos in positions): infos["code_banque"] = sequence[:positions[0]] infos["code_guichet"] = sequence[positions[0]+2:positions[1]] infos["numero_compte"] = sequence[positions[1]+2:positions[2]] infos["cle_rib"] = sequence[positions[2]+2:positions[3]] infos["numero_cheque"] = sequence[positions[3]+2:positions[4]] else: # Découpage approximatif basé sur les longueurs typiques # Code banque: 5 chiffres, Code guichet: 5 chiffres, N° compte: 11 chiffres, Clé RIB: 2 chiffres, N° chèque: 7 chiffres s = sequence.replace("S1", "").replace("S2", "").replace("S3", "").replace("S4", "").replace("S5", "") if len(s) >= 30: infos["code_banque"] = s[:5] infos["code_guichet"] = s[5:10] infos["numero_compte"] = s[10:21] infos["cle_rib"] = s[21:23] infos["numero_cheque"] = s[23:30] if len(s) >= 30 else s[23:] except Exception as e: logger.error(f"Erreur lors de l'extraction des infos MICR: {e}") # Mise à jour du numéro de chèque dans le format standard if infos["numero_cheque"]: infos["numero_cheque"] = infos["numero_cheque"].strip() return infos def extraire_montant_en_lettres(chemin_image: str) -> Optional[str]: """ Extraction spécialisée du montant en lettres sur un chèque, optimisée pour le texte manuscrit et les formulations françaises. Args: chemin_image: Chemin vers l'image du chèque Returns: Le montant en lettres extrait ou None si non détecté """ logger.info(f"Extraction du montant en lettres pour: {chemin_image}") try: # Chargement de l'image img = cv2.imread(chemin_image) if img is None: raise ValueError(f"Impossible de charger l'image: {chemin_image}") # 1. Localisation de la zone du montant en lettres zone_montant_lettres = localiser_zone_montant_lettres(img) # Sauvegarder la zone pour diagnostic debug_path = f"debug_montant_lettres_{os.path.basename(chemin_image)}" cv2.imwrite(debug_path, zone_montant_lettres) logger.info(f"Zone montant en lettres sauvegardée: {debug_path}") # 2. Prétraitement spécifique pour l'écriture manuscrite image_preprocessed = pretraiter_ecriture_manuscrite(zone_montant_lettres) # Vérifier que l'image prétraitée n'est pas vide avant de la sauvegarder if image_preprocessed is not None and not image_preprocessed.size == 0: # Sauvegarder l'image prétraitée debug_preproc_path = f"debug_montant_preproc_{os.path.basename(chemin_image)}" try: cv2.imwrite(debug_preproc_path, image_preprocessed) logger.info(f"Image prétraitée sauvegardée: {debug_preproc_path}") except Exception as e: logger.warning(f"Impossible de sauvegarder l'image prétraitée: {e}") else: logger.warning("L'image prétraitée est vide, impossible de la sauvegarder") # 3. OCR avec optimisations pour le texte manuscrit texte_brut = ocr_optimise_manuscrit(image_preprocessed) logger.info(f"Texte OCR brut: {texte_brut}") # 4. Extraction du montant à partir du texte montant_lettres = extraire_montant_depuis_texte(texte_brut) # 5. Validation linguistique du montant montant_valide = valider_montant_lettres(montant_lettres) logger.info(f"Montant en lettres extrait: {montant_valide}") return montant_valide except Exception as e: logger.error(f"Erreur lors de l'extraction du montant en lettres: {e}") return None def localiser_zone_montant_lettres(img): """ Localise la zone contenant le montant écrit en lettres sur un chèque français. Utilise une combinaison d'heuristiques de position et de détection de lignes. """ height, width = img.shape[:2] # La zone du montant en lettres est généralement dans le tiers supérieur du chèque # après la zone du bénéficiaire upper_mid = img[int(height*0.15):int(height*0.45), 0:width] # Conversion en niveaux de gris gray = cv2.cvtColor(upper_mid, cv2.COLOR_BGR2GRAY) if len(upper_mid.shape) > 2 else upper_mid # Amélioration du contraste pour faire ressortir l'écriture clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) # Détection des lignes horizontales (lignes d'écriture) edges = cv2.Canny(enhanced, 50, 150, apertureSize=3) lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=100, minLineLength=width*0.3, maxLineGap=20) # Filtrer et trier les lignes par position verticale horizontal_lines = [] if lines is not None: for line in lines: x1, y1, x2, y2 = line[0] # Calculer l'angle pour ne garder que les lignes presque horizontales angle = np.abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi) if angle < 5 or angle > 175: # Presque horizontal horizontal_lines.append((min(y1, y2), max(y1, y2))) # Si des lignes sont détectées, prendre celle qui correspond probablement # à la ligne du montant en lettres (souvent la 2ème ou 3ème ligne horizontale) if len(horizontal_lines) >= 3: # Trier les lignes par position verticale horizontal_lines.sort(key=lambda l: l[0]) # Prendre la ligne qui correspond généralement au montant en lettres # (après la ligne du bénéficiaire) line_idx = 1 # Souvent la deuxième ligne y_min, y_max = horizontal_lines[line_idx] # Ajouter une marge margin = int((y_max - y_min) * 1.0) y_start = max(0, y_min - margin) y_end = min(upper_mid.shape[0], y_max + margin) # S'assurer que y_end > y_start pour éviter une image vide if y_end <= y_start: y_end = min(y_start + 50, upper_mid.shape[0]) # Extraire la région zone_montant = upper_mid[y_start:y_end, 0:width] else: # Fallback: utiliser une position heuristique si les lignes ne sont pas détectées # Le montant en lettres est souvent entre 25% et 35% de la hauteur du chèque y_start = int(upper_mid.shape[0] * 0.3) y_end = int(upper_mid.shape[0] * 0.6) # S'assurer que y_end > y_start pour éviter une image vide if y_end <= y_start: y_end = min(y_start + 50, upper_mid.shape[0]) zone_montant = upper_mid[y_start:y_end, 0:width] # Vérifier que la zone extraite n'est pas vide if zone_montant.size == 0 or zone_montant.shape[0] == 0 or zone_montant.shape[1] == 0: logger.warning("Zone du montant en lettres vide, utilisation d'une zone par défaut") # Utiliser une zone par défaut si la zone extraite est vide y_start = int(height * 0.2) y_end = int(height * 0.3) zone_montant = img[y_start:y_end, 0:width] logger.info(f"Dimensions de la zone du montant en lettres: {zone_montant.shape}") return zone_montant def pretraiter_ecriture_manuscrite(image): """ Prétraitement optimisé pour l'écriture manuscrite sur les chèques """ # Vérifier que l'image n'est pas vide if image is None or image.size == 0 or image.shape[0] == 0 or image.shape[1] == 0: logger.warning("Image vide ou invalide fournie pour le prétraitement manuscrit") # Renvoyer une image noire minimale (1x1) pour éviter les erreurs return np.zeros((100, 100), dtype=np.uint8) try: # Vérifier que l'image a la bonne dimension et le bon type if len(image.shape) < 2: logger.warning(f"Image de forme invalide pour le prétraitement: {image.shape}") return np.zeros((100, 100), dtype=np.uint8) # Conversion en niveaux de gris gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) > 2 else image # Amélioration du contraste local pour faire ressortir l'écriture clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) enhanced = clahe.apply(gray) # Réduction du bruit tout en préservant les bords denoised = cv2.bilateralFilter(enhanced, 9, 75, 75) # Binarisation adaptative optimisée pour l'écriture manuscrite # Utiliser un seuil plus bas pour capturer les traits fins binary = cv2.adaptiveThreshold(denoised, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 15, 4) # Opérations morphologiques pour nettoyer et connecter les traits kernel = np.ones((2, 2), np.uint8) cleaned = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) # Inversion pour avoir le texte en noir sur fond blanc (format attendu par Tesseract) cleaned_inv = cv2.bitwise_not(cleaned) # Augmenter l'échelle de l'image (souvent aide l'OCR pour le manuscrit) scaled = cv2.resize(cleaned_inv, None, fx=1.5, fy=1.5, interpolation=cv2.INTER_CUBIC) return scaled except Exception as e: logger.error(f"Erreur lors du prétraitement de l'écriture manuscrite: {e}") # Renvoyer une image noire minimale en cas d'erreur return np.zeros((100, 100), dtype=np.uint8) def ocr_optimise_manuscrit(image_preprocessed): """ OCR spécifiquement configuré pour le texte manuscrit Utilise Tesseract avec des paramètres optimisés """ # Vérifier que l'image n'est pas vide ou invalide if image_preprocessed is None: logger.warning("Image vide fournie à l'OCR manuscrit") return "" # Vérifier les dimensions de l'image try: if len(image_preprocessed.shape) < 2 or image_preprocessed.size == 0: logger.warning(f"Image de forme invalide pour l'OCR: {image_preprocessed.shape if hasattr(image_preprocessed, 'shape') else 'sans forme'}") return "" except Exception as e: logger.error(f"Erreur lors de la vérification de l'image pour OCR: {e}") return "" try: import pytesseract # Configuration spécifique pour l'écriture manuscrite # --psm 6: Suppose un seul bloc de texte uniforme # --oem 1: Utiliser le moteur LSTM pour de meilleurs résultats sur l'écriture manuscrite custom_config = r'--oem 1 --psm 6 -l fra' # Ajouter des mots spécifiques au contexte des montants # pour aider la reconnaissance custom_config += r' -c tessedit_char_whitelist="ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789éèêëàâäôöùûüç -"' # Essayer de sauvegarder l'image pour debug avant OCR try: cv2.imwrite("debug_pre_ocr.png", image_preprocessed) logger.info("Image pré-OCR sauvegardée pour debug") except Exception as e: logger.warning(f"Impossible de sauvegarder l'image pré-OCR: {e}") # Effectuer l'OCR texte = pytesseract.image_to_string(image_preprocessed, config=custom_config) if not texte or texte.isspace(): logger.warning("OCR n'a extrait aucun texte") # Essayer avec une configuration alternative alt_config = r'--oem 0 --psm 3 -l fra' texte = pytesseract.image_to_string(image_preprocessed, config=alt_config) logger.info("OCR alternatif utilisé") return texte except Exception as e: logger.error(f"Erreur lors de l'OCR manuscrit: {e}") # Essayer de sauvegarder l'image en cas d'erreur pour diagnostic try: cv2.imwrite("debug_manuscrit_error.png", image_preprocessed) logger.info("Image d'erreur sauvegardée pour diagnostic") except Exception as e2: logger.warning(f"Impossible de sauvegarder l'image d'erreur: {e2}") return "" def extraire_montant_depuis_texte(texte): """ Extrait le montant en lettres à partir du texte OCR Utilise des patterns spécifiques aux formulations sur les chèques français """ # Nettoyage du texte texte = texte.lower().replace('\n', ' ').strip() # Patterns communs pour les montants sur les chèques français patterns = [ r"payez (?:contre ce chèque )?(?:à l'ordre de|à) .+? (?:la )?somme de (.+?)(?:euros|€|\n|$)", r"la somme de (.+?)(?:euros|€|\n|$)", r"montant: (.+?)(?:euros|€|\n|$)", r"([a-z]+ mille [a-z]+ cent [a-z]+ [a-z]+)", # ex: "deux mille trois cent cinquante euros" r"([a-z]+ mille [a-z]+ [a-z]+)", # ex: "deux mille cinquante euros" r"([a-z]+ cent [a-z]+ [a-z]+)", # ex: "trois cent cinquante euros" ] # Essayer chaque pattern for pattern in patterns: matches = re.search(pattern, texte) if matches: montant_texte = matches.group(1).strip() return montant_texte # Si aucun pattern ne correspond, retourner le texte entier # pour traitement manuel ou analyse plus poussée return texte def valider_montant_lettres(montant_texte): """ Valide et normalise le montant extrait en lettres Corrige les erreurs courantes d'OCR et normalise la formulation """ if not montant_texte: return None # Corrections courantes des erreurs d'OCR corrections = { "dcux": "deux", "trais": "trois", "quatrc": "quatre", "cinq": "cinq", "s1x": "six", "scpt": "sept", "hu1t": "huit", "neut": "neuf", "d1x": "dix", "vingr": "vingt", "trenre": "trente", "quarantc": "quarante", "cinquantc": "cinquante", "soixantc": "soixante", "miIIe": "mille", "milIe": "mille", "ccnt": "cent", "cen+": "cent" } # Appliquer les corrections for erreur, correction in corrections.items(): montant_texte = montant_texte.replace(erreur, correction) # Supprimer les termes non pertinents non_pertinents = ["euros", "euro", "€", "francs", "exactement", "et"] for terme in non_pertinents: montant_texte = montant_texte.replace(terme, "") # Normaliser les espaces montant_texte = " ".join(montant_texte.split()) return montant_texte def convertir_texte_en_nombre(texte_montant): """ Convertit un montant écrit en lettres en valeur numérique Gère les spécificités de la langue française """ # Dictionnaire de conversion français valeurs = { "zéro": 0, "zero": 0, "un": 1, "une": 1, "deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "six": 6, "sept": 7, "huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12, "treize": 13, "quatorze": 14, "quinze": 15, "seize": 16, "dix-sept": 17, "dix sept": 17, "dix-huit": 18, "dix huit": 18, "dix-neuf": 19, "dix neuf": 19, "vingt": 20, "trente": 30, "quarante": 40, "cinquante": 50, "soixante": 60, "soixante-dix": 70, "soixante dix": 70, "quatre-vingt": 80, "quatre vingt": 80, "quatre-vingt-dix": 90, "quatre vingt dix": 90, "cent": 100, "mille": 1000, "million": 1000000, "millions": 1000000, "milliard": 1000000000, "milliards": 1000000000 } if not texte_montant: return None try: # Prétraitement du texte texte = texte_montant.lower().replace('-', ' ').replace(' ', ' ').strip() texte = texte.replace('euros', '').replace('euro', '').replace('€', '').strip() # Gestion des décimales parties = texte.split('virgule') partie_entiere = parties[0].strip() partie_decimale = parties[1].strip() if len(parties) > 1 else None # Analyseur syntaxique simplifié pour la partie entière mots = partie_entiere.split() total = 0 sous_total = 0 for mot in mots: if mot in valeurs: valeur = valeurs[mot] if valeur in [100, 1000, 1000000, 1000000000]: # Multiplicateurs if sous_total == 0: sous_total = 1 sous_total *= valeur if valeur >= 1000: total += sous_total sous_total = 0 else: # Valeurs simples sous_total += valeur total += sous_total # Traiter la partie décimale si présente if partie_decimale: decimale = 0 mots_decimale = partie_decimale.split() # Cas spécial pour "cinquante centimes", etc. if "centime" in partie_decimale or "centimes" in partie_decimale: for mot in mots_decimale: if mot in valeurs and mot not in ["centime", "centimes"]: decimale = valeurs[mot] return total + (decimale / 100) # Sinon, conversion normale for mot in mots_decimale: if mot in valeurs: decimale = decimale * 10 + valeurs[mot] # Ajuster au format décimal (max 99 centimes) if decimale > 99: decimale = 99 return total + (decimale / 100) return total except Exception as e: logger.error(f"Erreur lors de la conversion du montant en lettres: {e}") return None def verifier_coherence_montants(montant_chiffres, montant_lettres): """ Vérifie la cohérence entre le montant en chiffres et en lettres Retourne un score de confiance et des détails sur la comparaison """ # Convertir le montant en chiffres en nombre if isinstance(montant_chiffres, str): # Nettoyer la chaîne (enlever symboles, espaces) montant_chiffres = montant_chiffres.replace('€', '').replace(' ', '').strip() # Remplacer la virgule par un point pour la conversion montant_chiffres = montant_chiffres.replace(',', '.') try: montant_chiffres_num = float(montant_chiffres) except ValueError: return {"coherent": False, "raison": "Montant en chiffres non numérique", "confiance": 0} else: montant_chiffres_num = float(montant_chiffres) # Convertir le montant en lettres en nombre montant_lettres_num = convertir_texte_en_nombre(montant_lettres) if montant_lettres_num is None: return {"coherent": False, "raison": "Montant en lettres non convertible", "confiance": 0} # Calculer la différence relative if montant_chiffres_num == 0 and montant_lettres_num == 0: diff_relative = 0 else: diff_relative = abs(montant_chiffres_num - montant_lettres_num) / max(montant_chiffres_num, montant_lettres_num) # Déterminer la cohérence if diff_relative == 0: return {"coherent": True, "raison": "Montants identiques", "confiance": 1.0} elif diff_relative < 0.01: return {"coherent": True, "raison": "Montants quasi-identiques", "confiance": 0.95} elif diff_relative < 0.1: return {"coherent": False, "raison": "Montants légèrement différents", "confiance": 0.5} else: return {"coherent": False, "raison": "Montants incohérents", "confiance": 0} # Fonctions d'extraction pour des champs spécifiques def extraire_montant(texte: str) -> Optional[str]: """Extrait le montant à partir du texte OCR""" # Recherche de motifs comme "€ 123,45" ou "123,45 €" ou "123 €" ou "123,45" ou "123 euros" motifs = [ r'(\d+[.,]\d{2})\s*€', # 123,45 € r'€\s*(\d+[.,]\d{2})', # € 123,45 r'(\d+)\s*€', # 123 € r'(\d+[.,]\d{2})', # 123,45 (sans symbole) r'(\d+[.,]\d{2})\s*[Ee][Uu][Rr]', # 123,45 EUR r'(\d+[.,]\d{2})\s*euros?', # 123,45 euros r'(\d+)\s*euros?' # 123 euros ] for motif in motifs: matches = re.findall(motif, texte) if matches: return matches[0] logger.warning("Aucun montant détecté dans le texte") return None def extraire_date(texte: str) -> Optional[str]: """Extrait la date à partir du texte OCR""" # Recherche de dates au format JJ/MM/AAAA ou JJ-MM-AAAA # Plusieurs formats de dates possibles formats_date = [ r'(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})', # 01/01/2023 ou 01-01-2023 r'(\d{1,2}\s+[a-zéûôA-Z]+\s+\d{2,4})', # 01 janvier 2023 r'([a-zéûôA-Z]+\s+\d{1,2}\s+\d{2,4})' # janvier 01 2023 ] for format_date in formats_date: match = re.search(format_date, texte, re.IGNORECASE) if match: return match.group(1) logger.warning("Aucune date détectée dans le texte") return None def extraire_beneficiaire(texte: str) -> Optional[str]: """Extrait le bénéficiaire à partir du texte OCR""" # Cette fonction est plus complexe car elle dépend du format du chèque # Recherche souvent après "Payez contre ce chèque à" ou similaire # Plusieurs formulations possibles pour le bénéficiaire motifs = [ r'(?:Payez|Payé)(?:\s+contre\s+ce\s+chèque)?\s+(?:à|au?)\s+([A-Z0-9\s\.,-]+)', r'(?:à l\'ordre de|ordre)(?:\s+:)?\s+([A-Z0-9\s\.,-]+)', r'(?:bénéficiaire|beneficiaire)(?:\s+:)?\s+([A-Z0-9\s\.,-]+)' ] for motif in motifs: match = re.search(motif, texte, re.IGNORECASE) if match: return match.group(1).strip() logger.warning("Aucun bénéficiaire détecté dans le texte") return None def extraire_numero_cheque(texte: str) -> Optional[str]: """Extrait le numéro de chèque à partir du texte OCR""" # Recherche d'un numéro de chèque (généralement 7 chiffres) # Recherche de formats de numéro de chèque motifs = [ r'N[o°]\s*(\d{7})', # N° 1234567 r'(?:chèque|cheque)\s*(?:n[o°]?)?\s*(\d{7})', # chèque n° 1234567 r'(? str: """ Évalue la qualité de l'extraction basée sur les champs extraits Prend en compte les informations MICR et le montant en lettres """ # Définir les champs importants par catégorie champs_classiques = ["montant", "date", "beneficiaire", "numero_cheque"] champs_micr = ["code_banque", "code_guichet", "numero_compte", "cle_rib"] champs_avances = ["montant_lettres", "coherence_montants"] # Compter les champs extraits dans chaque catégorie n_classiques = sum(1 for c in champs_classiques if infos.get(c) is not None) n_micr = sum(1 for c in champs_micr if infos.get(c) is not None) n_avances = sum(1 for c in champs_avances if infos.get(c) is not None) total_extraits = n_classiques + n_micr + n_avances methode = infos.get("methode", "") # Évaluation pour les méthodes avancées (hybride ou hybride_avance) if "hybride" in methode: # Méthode hybride_avance: évaluer sur tous les champs if methode == "hybride_avance": max_champs = len(champs_classiques) + len(champs_micr) + len(champs_avances) if total_extraits == 0: return "échec" elif total_extraits < max_champs * 0.3: # Moins de 30% return "faible" elif total_extraits < max_champs * 0.7: # Entre 30% et 70% return "moyenne" else: # Plus de 70% return "bonne" # Méthode hybride standard: évaluer sur les champs classiques et MICR else: max_champs = len(champs_classiques) + len(champs_micr) total_pertinent = n_classiques + n_micr if total_pertinent == 0: return "échec" elif total_pertinent < max_champs * 0.3: return "faible" elif total_pertinent < max_champs * 0.7: return "moyenne" else: return "bonne" # Évaluation pour la méthode MICR seule elif methode == "micr_cmc7": if n_micr == 0: return "échec" elif n_micr < 2: return "faible" elif n_micr < 4: return "moyenne" else: return "bonne" # Évaluation pour les méthodes classiques (ocr ou cv) else: if n_classiques == 0: return "échec" elif n_classiques < 2: return "faible" elif n_classiques < 4: return "moyenne" else: return "bonne" def get_tessdata_path() -> Optional[str]: """Trouve le chemin vers le dossier tessdata""" # Suggestions de chemins pour tessdata suggestions_tessdata = [ "/usr/share/tesseract-ocr/4.00/tessdata", "/usr/share/tesseract-ocr/tessdata", "/usr/local/share/tesseract-ocr/tessdata", "/usr/local/share/tessdata", os.path.expanduser("~/tessdata") ] # Chercher si un des chemins existe for path in suggestions_tessdata: if os.path.exists(path): logger.info(f"Dossier tessdata trouvé à: {path}") return path logger.warning("Aucun dossier tessdata trouvé dans les emplacements standards") return None