import argparse import cv2 import numpy as np import pytesseract import pandas as pd import os import json import glob import shutil import re import string import unicodedata import subprocess import tempfile import sys from pathlib import Path from collections import defaultdict from datetime import datetime # Vérification et import d'EasyOCR # Ajoute les répertoires utilisateur et système à PYTHONPATH user_site = os.path.expanduser("~/.local/lib/python3.6/site-packages") sys_site = "/usr/local/lib/python3.6/dist-packages" for path in [user_site, sys_site]: if os.path.exists(path) and path not in sys.path: sys.path.append(path) # Vérification de easyocr try: import easyocr EASYOCR_AVAILABLE = True print("✅ EasyOCR disponible (version {})".format(easyocr.__version__)) except ImportError as e: easyocr = None EASYOCR_AVAILABLE = False print("❌ EasyOCR non trouvé. Erreur :", e) print(" Vérifiez l'installation avec : pip3 install --user easyocr") print(" Essayez peut être : pip3 install --user --upgrade easyocr pillow") print(" ou pip3 install --user --force-reinstall python-bidi==0.21.0") # Vérification de torchfree-ocr try: import torchfree_ocr TORCHFREE_AVAILABLE = True print("✅ TorchFree OCR disponible (version {})".format(torchfree_ocr.__version__)) except ImportError as e: torchfreeocr = None TORCHFREE_AVAILABLE = False print("❌ TorchFree OCR non trouvé. Erreur :", e) print(" Vérifiez l'installation avec : pip3 install --user torchfree-ocr") print(" Si erreur de compilation : pip3 install --user --upgrade onnxruntime opencv-python") # Dictionnaire des moteurs OCR OCR_ENGINES = { "easyocr": { "module": easyocr, "reader": None, # Sera initialisé à la première utilisation "default_lang": ["fr"] }, "torchfree": { "module": torchfree_ocr, "reader": None, "default_lang": ["fr"] } } # Fonction helper def _ocr_text_extraction(image, reader, detail=0): """Traitement générique pour tous les moteurs OCR.""" if isinstance(image, np.ndarray): img = image.copy() elif isinstance(image, str) and os.path.exists(image): img = cv2.imread(image) if img is None: raise ValueError(f"Impossible de charger {image}") else: raise TypeError("L'image doit être un chemin (str) ou un tableau numpy.") results = reader.readtext(img, detail=0, batch_size=4) if detail == 1: return results texts = [] for res in results: if isinstance(res, (list, tuple)) and len(res) >= 3 and res[2] > 0.1: texts.append(res[1]) elif isinstance(res, str): texts.append(res) return " ".join(texts).strip() if texts else "" # ============================================================================= # ALGORITHME PHONEX (pour la comparaison phonétique des noms) # ============================================================================= IGNORE = "HW~!@#$%^&*()_+=-`[]\|;:'/?.,<>\" \t\f\v" source = string.ascii_uppercase # 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' destination = '01230120224550126230120200' TABLE = str.maketrans(source, destination) def phonex_fr(strval): """Retourne la valeur Phonex pour une chaîne (français).""" if strval is None: return "Z000" # 1. Remplacer les y par des i et normaliser les accents r = strval.upper().strip() r = r.replace('Y', 'I') r = r.replace(u'É', 'Y').replace(u'È', 'Y').replace(u'Ê', 'Y') # Normalisation Unicode r = unicodedata.normalize('NFKD', r).encode('ASCII', 'ignore').decode('ASCII') if not r: return "Z000" # 2. Supprimer les h non précédés de C, S ou P r = re.sub(r'([^P|C|S])H', r'\1', r) # 3. Remplacer PH par F r = r.replace('PH', 'F') # 4. Remplacer les groupes de lettres r = re.sub(r'G(AI?[N|M])', r'K\1', r) # 5. Remplacer les occurrences suivies de a, e, i, o, u r = re.sub(r'[A|E]I[N|M]([A|E|I|O|U])', r'YN\1', r) # 6. Remplacer les groupes de 3 lettres (sons 'o', 'oua', 'ein') r = r.replace('EAU', 'O') r = r.replace('OUA', '2') r = r.replace('EIN', '4') r = r.replace('AIN', '4') r = r.replace('EIM', '4') r = r.replace('AIM', '4') # 7. Remplacer le son É r = r.replace('AI', 'Y') r = r.replace('EI', 'Y') r = r.replace('ER', 'YR') r = r.replace('ESS', 'YS') r = r.replace('ET', 'YT') r = r.replace('EZ', 'YZ') # 8. Remplacer les groupes AN/ON/AM/EN/EM/IN (sauf suivis de a,e,i,o,u ou 1-4) r = re.sub(r'AN([^A|E|I|O|U|1|2|3|4])', r'1\1', r) r = re.sub(r'ON([^A|E|I|O|U|1|2|3|4])', r'1\1', r) r = re.sub(r'AM([^A|E|I|O|U|1|2|3|4])', r'1\1', r) r = re.sub(r'EN([^A|E|I|O|U|1|2|3|4])', r'1\1', r) r = re.sub(r'EM([^A|E|I|O|U|1|2|3|4])', r'1\1', r) r = re.sub(r'IN([^A|E|I|O|U|1|2|3|4])', r'4\1', r) # 9. Remplacer les S entre voyelles par Z r = re.sub(r'([A|E|I|O|U|Y|1|2|3|4])S([A|E|I|O|U|Y|1|2|3|4])', r'\1Z\2', r) # 10. Remplacer les groupes de 2 lettres r = r.replace('OE', 'E') r = r.replace('EU', 'E') r = r.replace('AU', 'O') r = r.replace('OI', '2') r = r.replace('OY', '2') r = r.replace('OU', '3') # 11. Remplacer les groupes de lettres (CH, SCH, SH, etc.) r = r.replace('CH', '5') r = r.replace('SCH', '5') r = r.replace('SH', '5') r = r.replace('SS', 'S') r = r.replace('SC', 'S') # 12. Remplacer C par S s'il est suivi de E ou I r = re.sub(r'C([E|I])', r'S\1', r) # 13. Remplacer les lettres ou groupes r = r.replace('C', 'K') r = r.replace('Q', 'K') r = r.replace('QU', 'K') r = r.replace('GU', 'K') r = r.replace('GA', 'KA') r = r.replace('GO', 'KO') r = r.replace('GY', 'KY') # 14. Remplacer les lettres r = r.replace('A', 'O') r = r.replace('D', 'T') r = r.replace('P', 'T') r = r.replace('J', 'G') r = r.replace('B', 'F') r = r.replace('V', 'F') r = r.replace('M', 'N') # 15. Supprimer les lettres dupliquées oldc = '#' newr = '' for c in r: if oldc != c: newr += c oldc = c r = newr # 16. Supprimer les terminaisons T, X r = re.sub(r'(.*)[T|X]$', r'\1', r) # 17. Appliquer la table de traduction str2 = r[0] if r else '' r = r.translate(TABLE) if not r: return "Z000" # 18. Supprimer les doublons consécutifs (sauf 0) prev = r[0] for character in r[1:]: if character != prev and character != "0": str2 += character prev = character # 19. Compléter avec des zéros str2 = str2 + "0000" return str2[:4] def compare_phonex(str1, str2): """Compare deux chaînes phonétiquement (1 si similaires, 0 sinon).""" return phonex_fr(str1) == phonex_fr(str2) def tesseract_ocr_raw(image, lang="fra", psm=6, oem=1, whitelist=None, digits_only=False): """Appel direct à Tesseract avec OEM/PSM configurables.""" if isinstance(image, np.ndarray): with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp: tmp_path = tmp.name cv2.imwrite(tmp_path, image) else: tmp_path = image cmd = [ "tesseract", tmp_path, "stdout", "-l", lang.replace(",", "+"), "--psm", str(psm), "--oem", str(oem), # <-- Ajout de OEM ] if whitelist: clean_whitelist = "".join(c for c in whitelist if c.isalnum() or c in ".,-") cmd.extend(["-c", f"tessedit_char_whitelist={clean_whitelist}"]) if digits_only: cmd.extend(["-c", "tessedit_char_whitelist=0123456789"]) result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) if result.returncode != 0: print(f"❌ Erreur Tesseract (code {result.returncode}): {result.stderr.strip()}") return "" return result.stdout.strip() def _ocr_text_extraction(image, reader, detail=0): """Traitement générique pour EasyOCR et TorchFree OCR.""" # Gestion de l'entrée (identique dans les deux fonctions) if isinstance(image, np.ndarray): img = image.copy() elif isinstance(image, str) and os.path.exists(image): img = cv2.imread(image) if img is None: raise ValueError(f"Impossible de charger {image}") else: raise TypeError("L'image doit être un chemin (str) ou un tableau numpy.") # Extraction du texte results = reader.readtext(img, detail=0, batch_size=4) if detail == 1: return results # Traitement des résultats (identique) texts = [] for res in results: if isinstance(res, (list, tuple)) and len(res) >= 3 and res[2] > 0.1: texts.append(res[1]) elif isinstance(res, str): texts.append(res) return " ".join(texts).strip() if texts else "" def easyocr_text_extraction(image, languages=["fr"], detail=0, **kwargs): if not EASYOCR_AVAILABLE: raise RuntimeError("EasyOCR non disponible") engine = OCR_ENGINES["easyocr"] if engine["reader"] is None: engine["reader"] = engine["module"].Reader(languages, **kwargs) return _ocr_text_extraction(image, engine["reader"], detail) def torchfreeocr_text_extraction(image, lang=["fr"], detail=0, **kwargs): if not TORCHFREE_AVAILABLE: raise RuntimeError("TorchFree OCR non disponible") engine = OCR_ENGINES["torchfree"] if engine["reader"] is None: engine["reader"] = engine["module"].Reader(lang, **kwargs) return _ocr_text_extraction(image, engine["reader"], detail) # ============================================================================= # Dictionnaire de correction phonétique (chargé depuis un fichier JSON) # ============================================================================= DEFAULT_DICT_PATH = "dictionnaire_noms.json" # Chemin par défaut def charger_dictionnaire_noms(path=None): """ Charge un dictionnaire de noms depuis un fichier JSON. Si le fichier n'existe pas, retourne un dictionnaire vide. { "Danguyon": ["Danguyon", "Danguyon", "Danguyon"], "Ordonneau": ["Ordonneau", "Ordonneau", "Ordonnau"], "Acker": ["Acker", "Acker", "Aker", "Accker"], "Albejard": ["Albejard", "Albejart", "Albejard"], "Barthélemy": ["Barthélemy", "Barthelemy", "Barthélémy"], "Aubert": ["Aubert", "Aubert", "Aubèrt"], "Boeuf": ["Boeuf", "Boeuf", "Bœuf"], "Chabert": ["Chabert", "Chabert", "Chabèrt"], "Dubois": ["Dubois", "Dubois", "Du Bois", "DuBois"], "Martin": ["Martin", "Martain", "Martain"], "Moreau": ["Moreau", "Moreau", "Morau"], "Roux": ["Roux", "Roux", "Rou"], "Simon": ["Simon", "Simond", "Simond"] } """ if path is None: path = DEFAULT_DICT_PATH if not os.path.exists(path): print(f"⚠️ Fichier de dictionnaire introuvable : {path}") print(" Un dictionnaire vide sera utilisé.") return {} try: with open(path, "r", encoding="utf-8") as f: return json.load(f) except json.JSONDecodeError as e: print(f"❌ Erreur dans le fichier {path} : {e}") return {} except Exception as e: print(f"❌ Impossible de charger {path} : {e}") return {} def sauvegader_dictionnaire_noms(dictionnaire, path=None): """ Sauvegarde un dictionnaire de noms dans un fichier JSON. """ if path is None: path = DEFAULT_DICT_PATH try: with open(path, "w", encoding="utf-8") as f: json.dump(dictionnaire, f, indent=4, ensure_ascii=False) print(f"✅ Dictionnaire sauvegardé dans {path}") except Exception as e: print(f"❌ Impossible de sauvegarder {path} : {e}") # Charger le dictionnaire au démarrage CORRECTIONS_PHONETIQUES = charger_dictionnaire_noms() def corriger_nom(nom, dictionnaire=None): """Corrige un nom en utilisant Phonex et un dictionnaire de variantes.""" if dictionnaire is None: dictionnaire = CORRECTIONS_PHONETIQUES if not nom: return nom nom = nom.strip().upper() # Normaliser # Vérifier si le nom est déjà canonique ou dans les variantes for canonique, variantes in dictionnaire.items(): if nom == canonique or nom in variantes: return canonique # Chercher une correspondance phonétique for canonique, variantes in dictionnaire.items(): if compare_phonex(nom, canonique): return canonique for variante in variantes: if compare_phonex(nom, variante): return canonique return nom # Retourner le nom original si aucune correspondance # ============================================================================= # FONCTION POUR LISTER LES PROFILS # ============================================================================= def list_profiles(): """Affiche la liste des profils avec leurs descriptions et paramètres.""" print("\n" + "=" * 70) print("📋 PROFILS DISPONIBLES (utilisez --profile )".center(70)) print("=" * 70) for name, profile in DEFAULT_PROFILES.items(): print(f"\n🔹 {name}") print(f" └─ Description: {profile['name']}") print(f" └─ Moteur OCR: {profile.get('ocr_engine', 'pytesseract')}") # Afficher OEM/PSM si présent if 'oem' in profile: oem_map = {0: "Legacy only", 1: "LSTM only", 2: "Legacy+LSTM", 3: "Défaut"} print(f" └─ OEM: {profile['oem']} ({oem_map.get(profile['oem'], '?')})") if 'psm' in profile: psm_map = { 0: "OSD only", 1: "Auto+OSD", 3: "Auto (défaut)", 4: "Colonne unique", 5: "Bloc vertical", 6: "Bloc uniforme", 7: "Ligne unique", 8: "Mot unique", 11: "Texte épars", 13: "Ligne brute" } print(f" └─ PSM: {profile['psm']} ({psm_map.get(profile['psm'], '?')})") if 'tesseract_lang' in profile: print(f" └─ Langues: {profile['tesseract_lang']}") print("\n" + "=" * 70) print("Exemple: python script.py --profile manuscrit_ancien --input image.jpg") print("=" * 70 + "\n") sys.exit(0) # --- 1. Gestion des arguments en ligne de commande --- parser = argparse.ArgumentParser(description="Extraction OCR de tableaux avec configuration avancée") parser.add_argument("--list-profiles", action="store_true", help="Affiche la liste des profils disponibles et leurs paramètres" ) parser.add_argument("--profile", type=str, default="default", help="Profil de configuration à utiliser (ex: ancien_manuscrit)") parser.add_argument("--input", type=str, default="A.JPG", help="Image ou dossier d'images à traiter (ex: images/*.jpg)") parser.add_argument("--output", type=str, default="output", help="Dossier de sortie (défaut: output/)") parser.add_argument("--preview", action="store_true", help="Générer une page HTML de prévisualisation") parser.add_argument("--auto-columns", action="store_true", help="Détecter automatiquement les colonnes") parser.add_argument("--dictionnaire", type=str, default=None, help="Chemin vers le fichier JSON du dictionnaire de noms (défaut: dictionnaire_noms.json)" ) parser.add_argument("--update-dict", action="store_true", help="Mode édition interactive du dictionnaire de noms" ) parser.add_argument("--raw-tesseract", action="store_true", help="Utiliser l'appel direct à `tesseract` (au lieu de pytesseract)" ) parser.add_argument("--easyocr", action="store_true", help="Forcer l'utilisation d'EasyOCR (ignore la config du profil)" ) parser.add_argument("--torchfree", action="store_true", help="Utiliser torchfree-ocr (sans PyTorch)") args = parser.parse_args() def select_profile_interactively(): """Affiche un menu pour choisir un profil.""" print("\n📋 Sélectionnez un scénario (ou tapez le nom) :") for i, (name, profile) in enumerate(DEFAULT_PROFILES.items(), 1): print(f"{i}. {profile['name']} ({name})") choice = input("\nChoix (numéro ou nom) : ").strip() try: # Si l'utilisateur tape un numéro idx = int(choice) - 1 return list(DEFAULT_PROFILES.keys())[idx] except ValueError: # Si l'utilisateur tape un nom if choice in DEFAULT_PROFILES: return choice else: print(f"⚠️ Scénario '{choice}' invalide. Utilisation de 'default'.") return "default" # Profils prédéfinis DEFAULT_PROFILES = { # --- Scénarios pour Tesseract (pytesseract) --- "document_imprime": { "name": "Document imprimé propre (PDF, livres)", "ocr_engine": "pytesseract", "oem": 1, # LSTM only (meilleur pour la plupart des cas) "psm": 6, # Bloc de texte uniforme "tesseract_lang": "fra+eng", "whitelist_text": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÂÄÇÉÈÊËÎÏÔÖÙÛÜÑàâäçéèêëîïôöùûüñ.,-", "whitelist_digits": "0123456789", "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] }, "manuscrit_ancien": { "name": "Manuscrit ancien (bruit, fond non uniforme)", "ocr_engine": "pytesseract", "oem": 1, # LSTM only "psm": 11, # Texte épars (meilleur pour les manuscrits) "tesseract_lang": "fra", "whitelist_text": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÂÄÇÉÈÊËÎÏÔÖÙÛÜÑàâäçéèêëîïôöùûüñ.,-", "whitelist_digits": "0123456789IOO°¶-", "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] }, "tableau_colonnes": { "name": "Tableau avec colonnes (noms + folios)", "ocr_engine": "pytesseract", "oem": 1, "psm": 6, # Bloc de texte (idéal pour les colonnes) "tesseract_lang": "fra", "whitelist_text": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÂÄÇÉÈÊËÎÏÔÖÙÛÜÑàâäçéèêëîïôöùûüñ.,-", "whitelist_digits": "0123456789", "columns": [ {"type": "text", "is_name": True}, {"type": "digits", "is_name": False}, {"type": "text", "is_name": True}, {"type": "digits", "is_name": False} ] }, "numeros_purs": { "name": "Extraction de numéros uniquement (folios, dates)", "ocr_engine": "pytesseract", "oem": 1, "psm": 8, # Mot unique "tesseract_lang": "fra", "whitelist_digits": "0123456789", "columns": [{"type": "digits", "is_name": False}] }, # --- Scénarios pour EasyOCR --- "easyocr_manuscrit": { "name": "Manuscrit avec EasyOCR", "ocr_engine": "easyocr", "easyocr_languages": ["fr"], "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] }, # --- Scénarios pour TorchFree --- "torchfree_tableau": { "name": "Tableau avec TorchFree OCR", "ocr_engine": "torchfree", "torchfree_lang": ["fr"], "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] } } # 1. Vérifier --list-profiles en premier if args.list_profiles: list_profiles() # 2. Vérifier si on doit afficher le menu interactif if args.profile == "default" and len(sys.argv) == 1: args.profile = select_profile_interactively() # 3. Vérifier que le profil existe if args.profile not in DEFAULT_PROFILES: print(f"❌ Profil '{args.profile}' introuvable !") print(f" Profils disponibles: {', '.join(DEFAULT_PROFILES.keys())}") sys.exit(1) # --- 2. Charger la configuration --- CONFIG_DIR = "profiles" os.makedirs(CONFIG_DIR, exist_ok=True) os.makedirs(args.output, exist_ok=True) # Profils prédéfinis DEFAULT_PROFILES = { # --- Scénarios pour Tesseract (pytesseract) --- "document_imprime": { "name": "Document imprimé propre (PDF, livres)", "ocr_engine": "pytesseract", "oem": 1, # LSTM only (meilleur pour la plupart des cas) "psm": 6, # Bloc de texte uniforme "tesseract_lang": "fra+eng", "whitelist_text": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÂÄÇÉÈÊËÎÏÔÖÙÛÜÑàâäçéèêëîïôöùûüñ.,-", "whitelist_digits": "0123456789", "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] }, "manuscrit_ancien": { "name": "Manuscrit ancien (bruit, fond non uniforme)", "ocr_engine": "pytesseract", "oem": 1, # LSTM only "psm": 11, # Texte épars (meilleur pour les manuscrits) "tesseract_lang": "fra", "whitelist_text": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÂÄÇÉÈÊËÎÏÔÖÙÛÜÑàâäçéèêëîïôöùûüñ.,-", "whitelist_digits": "0123456789IOO°¶-", "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] }, "tableau_colonnes": { "name": "Tableau avec colonnes (noms + folios)", "ocr_engine": "pytesseract", "oem": 1, "psm": 6, # Bloc de texte (idéal pour les colonnes) "tesseract_lang": "fra", "whitelist_text": "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÂÄÇÉÈÊËÎÏÔÖÙÛÜÑàâäçéèêëîïôöùûüñ.,-", "whitelist_digits": "0123456789", "columns": [ {"type": "text", "is_name": True}, {"type": "digits", "is_name": False}, {"type": "text", "is_name": True}, {"type": "digits", "is_name": False} ] }, "numeros_purs": { "name": "Extraction de numéros uniquement (folios, dates)", "ocr_engine": "pytesseract", "oem": 1, "psm": 8, # Mot unique "tesseract_lang": "fra", "whitelist_digits": "0123456789", "columns": [{"type": "digits", "is_name": False}] }, # --- Scénarios pour EasyOCR --- "easyocr_manuscrit": { "name": "Manuscrit avec EasyOCR", "ocr_engine": "easyocr", "easyocr_languages": ["fr"], "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] }, # --- Scénarios pour TorchFree --- "torchfree_tableau": { "name": "Tableau avec TorchFree OCR", "ocr_engine": "torchfree", "torchfree_lang": ["fr"], "columns": [{"type": "text", "is_name": True}, {"type": "digits", "is_name": False}] } } # Charger ou créer le profil def load_profile(profile_name): profile_path = os.path.join(CONFIG_DIR, f"{profile_name}.json") if os.path.exists(profile_path): with open(profile_path, "r") as f: return json.load(f) elif profile_name in DEFAULT_PROFILES: return DEFAULT_PROFILES[profile_name].copy() else: print(f"⚠️ Profil '{profile_name}' introuvable. Utilisation du profil 'default'.") return DEFAULT_PROFILES["default"].copy() # Afficher les profils si demandé if args.list_profiles: list_profiles() if args.profile not in DEFAULT_PROFILES: print(f"❌ Profil '{args.profile}' introuvable !") print(f" Profils disponibles: {', '.join(DEFAULT_PROFILES.keys())}") print(" Utilisez --list-profiles pour voir les détails.\n") sys.exit(1) config = load_profile(args.profile) # --- 3. Détection automatique des colonnes (optionnelle) --- def detect_columns(img): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) edges = cv2.Canny(gray, 50, 150, apertureSize=3) lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=100, minLineLength=50, maxLineGap=10) if lines is None: return [] vertical_lines = [] for line in lines: # Vérifier que line[0] est un tableau de 4 éléments if line is None or len(line) == 0: continue try: x1, y1, x2, y2 = line[0] except (TypeError, ValueError): # Si line[0] n'est pas itérable ou n'a pas 4 éléments, ignorer continue if abs(x1 - x2) < 20: # Ligne verticale (tolérance de 20 pixels) vertical_lines.append((x1 + x2) // 2) vertical_lines = sorted(list(set(vertical_lines))) return vertical_lines # --- 4. Traitement des images --- def process_image(image_path, config, output_dir): original_img = cv2.imread(image_path) if original_img is None: print(f"❌ Impossible de charger {image_path}") return img_height, img_width = original_img.shape[:2] filename = os.path.splitext(os.path.basename(image_path))[0] # Créer un dossier de débogage pour cette image debug_dir = Path(output_dir) / f"debug_{Path(image_path).stem}" debug_dir.mkdir(parents=True, exist_ok=True) # Détecter ou utiliser les colonnes fixes if args.auto_columns: col_x_positions = detect_columns(original_img) if len(col_x_positions) < 2: print(f"⚠️ Impossible de détecter les colonnes pour {image_path}. Utilisation de 6 colonnes fixes.") col_x_positions = [i * img_width // 6 for i in range(1, 6)] col_x_positions = [0] + col_x_positions + [img_width] else: num_cols = max(1, len(config["columns"])) # Au moins 1 colonne col_x_positions = [i * img_width // num_cols for i in range(num_cols + 1)] # Sauvegarder l'image originale cv2.imwrite(os.path.join(debug_dir, "0_original.jpg"), original_img) use_raw_tesseract = args.raw_tesseract # Détermine le moteur OCR (priorité : CLI > config) if args.easyocr: ocr_engine = "easyocr" elif args.torchfree: ocr_engine = "torchfree" else: ocr_engine = config.get("ocr_engine", "pytesseract") # Prétraitement def preprocess_col(img, col_type): gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) inverted_image = cv2.bitwise_not(gray) return inverted_image # Extraire le texte de chaque colonne col_texts = [] for i in range(len(col_x_positions) - 1): start, end = col_x_positions[i], col_x_positions[i + 1] col_img = original_img[:, start:end] cv2.imwrite(os.path.join(debug_dir, f"col_{i+1}.jpg"), col_img) col_type = config["columns"][i]["type"] if i < len(config["columns"]) else "text" processed_col = preprocess_col(col_img, col_type) cv2.imwrite(os.path.join(debug_dir, f"col_{i+1}_processed.jpg"), processed_col) # Config Tesseract use_raw_tesseract = False # $ tesseract --help-oem # OCR Engine modes: (see https://tesseract-ocr.github.io/tessdoc/#40-with-lstm) # 0 Legacy engine only. # 1 Neural nets LSTM engine only. # 2 Legacy + LSTM engines. # 3 Default, based on what is available. tesseract_config = ( '--oem 2 ' # Moteur LSTM (obligatoire pour les manuscrits) '--psm 6 ' # Bloc de texte '-l fra+eng ' '-c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyzÀÂÄÇÉÈÊËÎÏÔÖÙÛÜÑàâäçéèêëîïôöùûüñ' '-c tessedit_unreject_ambig=true ' # Améliore la détection des caractères ambigus ) # Utiliser le moteur OCR sélectionné text = "" if ocr_engine == "easyocr" and EASYOCR_AVAILABLE: try: text = easyocr_text_extraction(processed_col, languages=config.get("easyocr_languages", ["fr"])) if col_type == "digits": text = re.sub(r"[^0-9]", "", text) print(f"[DEBUG EasyOCR] Colonne {i+1}: {text[:50]}...") except Exception as e: print(f"❌ Erreur EasyOCR: {e}") text = "" elif ocr_engine == "torchfree" and TORCHFREE_AVAILABLE: try: text = torchfreeocr_text_extraction(processed_col, lang=config.get("torchfree_lang", ["fr"])) if col_type == "digits": text = re.sub(r"[^0-9]", "", text) print(f"[DEBUG TorchFree] Colonne {i+1}: {text[:50]}...") except Exception as e: print(f"❌ Erreur TorchFree: {e}") text = "" elif use_raw_tesseract: # Récupérer OEM/PSM depuis le profil oem = config.get("oem", 1) # Défaut: LSTM only psm = config.get("psm", 6) # Défaut: Bloc de texte if col_type == "digits": text = tesseract_ocr_raw( processed_col, lang=config["tesseract_lang"], psm=psm, oem=oem, digits_only=True ) else: text = tesseract_ocr_raw( processed_col, lang=config["tesseract_lang"], psm=psm, oem=oem, whitelist=config.get("whitelist_text") ) else: text = pytesseract.image_to_string(processed_col, config=tesseract_config).strip() col_texts.append(text) # Nettoyer les résultats aberrants for i in range(len(col_texts)): if i % 2 == 1: # Colonnes de folios (2, 4, 6) col_texts[i] = re.sub(r"[^0-9]", "", col_texts[i]) # Garde UNIQUEMENT les chiffres else: # Colonnes de noms col_texts[i] = re.sub(r"[^A-Za-zÀ-ÿ0-9\s-]", "", col_texts[i]) # Garde lettres, chiffres, espaces, - # Structurer les données lines = [] for text in col_texts: lines.append([line.strip() for line in text.split('\n') if line.strip()]) max_lines = max(len(col_lines) for col_lines in lines) structured_data = [] headers = [f"Colonne {i+1}" for i in range(len(col_texts))] for i in range(max_lines): row_data = {} for j, header in enumerate(headers): if i < len(lines[j]): # Vérifier si c'est une colonne de nom ET si on a une config pour cette colonne is_name_col = False if j < len(config["columns"]): is_name_col = config["columns"][j].get("is_name", True) if is_name_col: row_data[header] = corriger_nom(lines[j][i], CORRECTIONS_PHONETIQUES) else: row_data[header] = lines[j][i] # Texte brut pour les non-noms else: row_data[header] = "" # Cellule vide structured_data.append(row_data) # Exporter en CSV csv_path = os.path.join(output_dir, f"{filename}.csv") pd.DataFrame(structured_data).to_csv(csv_path, index=False, encoding="utf-8-sig") # Afficher les codes Phonex pour vérification --- print("\n🔍 Codes Phonex pour les noms (pour vérification) :") for row in structured_data[:5]: # Affiche les 5 premières lignes for header in headers[::2]: # Colonnes de noms (1, 3, 5) nom = row[header] if nom: print(f"{nom}: {phonex_fr(nom)}") # Sauvegarder la config utilisée with open(os.path.join(output_dir, f"{filename}_config.json"), "w") as f: json.dump(config, f, indent=4) # Générer une prévisualisation HTML si demandé if args.preview: generate_preview_html(debug_dir, config, structured_data, os.path.join(output_dir, f"{filename}_preview.html")) print(f"✅ {image_path} → {csv_path} ({len(structured_data)} lignes)") # Debug # # $ tesseract --help-psm # Page segmentation modes: # 0 Orientation and script detection (OSD) only. # 1 Automatic page segmentation with OSD. # 2 Automatic page segmentation, but no OSD, or OCR. (not implemented) # 3 Fully automatic page segmentation, but no OSD. (Default) # 4 Assume a single column of text of variable sizes. # 5 Assume a single uniform block of vertically aligned text. # 6 Assume a single uniform block of text. # 7 Treat the image as a single text line. # 8 Treat the image as a single word. # 9 Treat the image as a single word in a circle. # 10 Treat the image as a single character. # 11 Sparse text. Find as much text as possible in no particular order. # 12 Sparse text with OSD. # 13 Raw line. Treat the image as a single text line bypassing hacks that are Tesseract-specific. text_pytesseract = pytesseract.image_to_string(processed_col, config=tesseract_config).strip() text_raw = tesseract_ocr_raw(original_img, lang="eng+fra+osd", psm=11, whitelist=None) print(f"[DEBUG] pytesseract:\n{text_pytesseract}") print(f"[DEBUG] raw tesseract:\n{text_raw}") return structured_data # --- 5. Générer la prévisualisation HTML --- def generate_preview_html(debug_dir, config, results, html_path): html = f""" Prévisualisation OCR - {datetime.now().strftime("%Y-%m-%d %H:%M")}

Prévisualisation OCR

Configuration utilisée

{json.dumps(config, indent=2)}

Images intermédiaires

""" for img_file in sorted(os.listdir(debug_dir)): if img_file.endswith(('.jpg', '.png')): html += f'

{img_file}

' html += """

Résultats extraits

""" for header in (results[0].keys() if results else []): html += f"" html += "" for row in results: html += "" for value in row.values(): html += f"" html += "" html += """
{header}
{value}
""" # Copier les images dans le dossier de sortie pour la prévisualisation for img_file in os.listdir(debug_dir): if img_file.endswith(('.jpg', '.png')): shutil.copy(os.path.join(debug_dir, img_file), os.path.join(os.path.dirname(html_path), img_file)) with open(html_path, "w") as f: f.write(html) # --- 6. Traitement des images --- if os.path.isdir(args.input): # Traiter un dossier d'images image_paths = glob.glob(os.path.join(args.input, "*.jpg")) + glob.glob(os.path.join(args.input, "*.png")) for img_path in image_paths: process_image(img_path, config, args.output) else: # Traiter une seule image process_image(args.input, config, args.output) print("\n✅ Traitement terminé !") print(f"📁 Résultats sauvegardés dans: {args.output}") if args.preview: print("🌐 Ouvre les fichiers *_preview.html pour voir les résultats.") # ============================================================================= # COMMANDE POUR METTRE À JOUR LE DICTIONNAIRE # ============================================================================= if args.update_dict: print("\n📝 Mode édition du dictionnaire de noms") print(" Appuie sur Ctrl+C pour quitter.") # Charger le dictionnaire actuel current_dict = charger_dictionnaire_noms(args.dictionnaire) while True: print("\nOptions:") print("1. Ajouter un nom") print("2. Supprimer un nom") print("3. Afficher le dictionnaire") print("4. Quitter") choice = input("Choix (1-4): ").strip() if choice == "1": canonique = input("Nom canonique (ex: Danguyon): ").strip() if not canonique: print("❌ Nom vide ignoré.") continue variantes = input("Variantes (séparées par des virgules, ex: Danguyon,Danguyon): ").strip() variantes = [v.strip() for v in variantes.split(",") if v.strip()] variantes = list(set(variantes + [canonique])) # Inclure le canonique current_dict[canonique] = variantes sauvegader_dictionnaire_noms(current_dict, args.dictionnaire) elif choice == "2": nom = input("Nom à supprimer: ").strip() if nom in current_dict: del current_dict[nom] sauvegader_dictionnaire_noms(current_dict, args.dictionnaire) else: print(f"❌ '{nom}' non trouvé dans le dictionnaire.") elif choice == "3": print("\nDictionnaire actuel:") for canonique, variantes in current_dict.items(): print(f" {canonique}: {variantes}") elif choice == "4": break else: print("❌ Choix invalide.")