Spaces:
Sleeping
Sleeping
| """ | |
| post_processor.py — Post-traitement OCR spécialisé documents métier. | |
| Complémentaire à ocr_cleaner.py (qui gère arabic_norm, fuzzy FR/AR). | |
| Ce module cible : | |
| - Vocabulaire factures / devis / bons de commande (FR + EN) | |
| - Confusions caractères typiques petits fonts : l↔1, O↔0, rn↔m, etc. | |
| - Mots fusionnés : 'Nameand' → 'Name and', 'TotalAmount' → 'Total Amount' | |
| - Patterns numériques : montants, dates, numéros de facture | |
| - Reconstruction de lignes adaptative (seuil dynamique / colonnes) | |
| Coordonnées attendues : format LayoutXLM [x1, y1, x2, y2] normalisé 0-1000. | |
| """ | |
| import re | |
| import logging | |
| from typing import List, Dict, Tuple, Optional | |
| import numpy as np | |
| logger = logging.getLogger(__name__) | |
| try: | |
| from rapidfuzz import process as _rfuzz, fuzz as _fuzz | |
| _HAS_RAPIDFUZZ = True | |
| except ImportError: | |
| _HAS_RAPIDFUZZ = False | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Vocabulaire domaine (FR + EN) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| INVOICE_VOCAB: Dict[str, str] = { | |
| # ── Titres document ───────────────────────────────────────────────────── | |
| "Involce": "Invoice", "lnvoice": "Invoice", "Inv0ice": "Invoice", | |
| "Invoce": "Invoice", "Invo1ce": "Invoice", "Invo ice": "Invoice", | |
| "INVOLCE": "INVOICE", "LNVOICE": "INVOICE", | |
| "Fachre": "Facture", "Factnre": "Facture", "Facure": "Facture", | |
| "Rec eipt": "Receipt", "Rece1pt": "Receipt", "Recei pt": "Receipt", | |
| "Rec3ipt": "Receipt", "Rec3pt": "Receipt", | |
| "Quotat1on": "Quotation", "Quotat ion": "Quotation", | |
| "Pu rchase": "Purchase", "Purchas3": "Purchase", | |
| # ── Champs courants ───────────────────────────────────────────────────── | |
| "Dat3": "Date", "Oate": "Date", "Date:": "Date:", | |
| "Nurnber": "Number", "Num8er": "Number", "Nurnbers": "Numbers", | |
| "Arnount": "Amount", "Arnounts": "Amounts", "Am0unt": "Amount", | |
| "Ouantity": "Quantity", "Qnantity": "Quantity", "Qty.": "Qty.", | |
| "Descnption": "Description", "Descr1ption": "Description", | |
| "Payrnent": "Payment", "Payement": "Payment", "Paym3nt": "Payment", | |
| "T0tal": "Total", "T0TAL": "TOTAL", "Tota1": "Total", | |
| "Subtota1": "Subtotal", "Sub-tota1": "Subtotal", "Sub total": "Subtotal", | |
| "Custorner": "Customer", "Cust0mer": "Customer", | |
| "Supplrer": "Supplier", "Supp1ier": "Supplier", | |
| "Conpany": "Company", "Cornpany": "Company", "Compamy": "Company", | |
| "Adress": "Address", "Adrress": "Address", "Addres": "Address", | |
| "Teleph0ne": "Telephone", "Te1ephone": "Telephone", | |
| "Ernail": "Email", "Erna1l": "Email", | |
| "Ternis": "Terms", "Teirms": "Terms", | |
| "Disc0unt": "Discount", "D1scount": "Discount", | |
| "Sh1pping": "Shipping", "Sh ipping": "Shipping", | |
| "Acco unt": "Account", "Acc0unt": "Account", | |
| "ltern": "Item", "ltems": "Items", | |
| "Pr1ce": "Price", "Un1t": "Unit", | |
| "De1ivery": "Delivery", "Del1very": "Delivery", | |
| "lncludes": "Includes", "lncluding": "Including", | |
| "S1gnature": "Signature", | |
| # ── Abréviations / tokens courts ──────────────────────────────────────── | |
| "lnv": "Inv", "Ref.": "Ref.", | |
| "T.V.A": "T.V.A", "V.A.T": "V.A.T", | |
| "H.T": "H.T", "T.T.C": "T.T.C", | |
| "N/A": "N/A", "n/a": "n/a", | |
| # ── Telecom / Utilities (Vodacom, MTN, Orange…) ────────────────────────── | |
| "Ivoice": "Invoice", "lvoice": "Invoice", "Invoi ce": "Invoice", | |
| "Celphone": "Cellphone", "Ce1lphone": "Cellphone", "Cell phone":"Cellphone", | |
| "Moblle": "Mobile", "Mob1le": "Mobile", "M0bile": "Mobile", | |
| "Serv1ce": "Service", "Serv ice": "Service", "Serv!ce": "Service", | |
| "Subscr1ption":"Subscription", "Subscript1on":"Subscription", | |
| "Roam1ng": "Roaming", "R0aming": "Roaming", | |
| "Bund1e": "Bundle", "Bund le": "Bundle", | |
| "Bal ance": "Balance", "Ba1ance": "Balance", "Ba1ance:": "Balance:", | |
| "0utstanding":"Outstanding","Outstand1ng":"Outstanding", | |
| "Curr ent": "Current", "C0nnection": "Connection", | |
| "Contr act": "Contract", "Contra ct": "Contract", | |
| "Acc0unt": "Account", "Accc ount": "Account", | |
| "lnclusive": "Inclusive", "lnc1usive": "Inclusive", | |
| "Vaild": "Valid", "Va1id": "Valid", "Va|id": "Valid", | |
| "Unt1l": "Until", "Unti1": "Until", | |
| "Voda com": "Vodacom", "V0dacom": "Vodacom", "Vodac0m": "Vodacom", | |
| "0range": "Orange", "0rance": "Orange", | |
| "Oper ator": "Operator", "0perator": "Operator", | |
| "Plan": "Plan", "Ta riff": "Tariff", "Tar1ff": "Tariff", | |
| "Usag e": "Usage", "Us age": "Usage", | |
| "lnternet": "Internet", "lntemet": "Internet", | |
| "Ca11": "Call", "Ca1l": "Call", | |
| "SM S": "SMS", "MMS": "MMS", | |
| "G8": "GB", "M8": "MB", "K8": "KB", | |
| } | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Prefixes connus pour la détection de mots fusionnés | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| _KNOWN_PREFIXES = sorted([ | |
| "Total", "Subtotal", "Sub", "Tax", "Net", "Gross", "Unit", "Item", "Line", | |
| "Date", "Due", "Pay", "Ship", "Bill", "Name", "Last", "First", "Order", | |
| "Invoice", "Facture", "Reference", "Number", "Amount", "Price", "Quantity", | |
| "Description", "Delivery", "Account", "Customer", "Supplier", "Company", | |
| "Discount", "Signature", "Payment", "Address", "Email", "Phone", | |
| "From", "To", "And", "Or", "Per", "For", "With", | |
| ], key=len, reverse=True) | |
| _PREFIX_RE = re.compile( | |
| r'(' + '|'.join(re.escape(p) for p in _KNOWN_PREFIXES) + r')([A-Z][a-z])', | |
| re.IGNORECASE, | |
| ) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Patterns regex pour documents métier | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| _REGEX_PATTERNS: List[Tuple] = [ | |
| # Dates : 01/0l/2024 ou 0l.01.2024 → corriger l→1, O→0 | |
| (re.compile(r'\b(\d{1,2})[./]([Ol\d]{2})[./](\d{2,4})\b'), | |
| lambda m: m.group(0).replace('l', '1').replace('O', '0')), | |
| # Numéros de facture : INV-OO1 → INV-001 | |
| (re.compile(r'\b(INV|FAC|BON|DEV|REF|NO|N°)[.\-/]?([A-Z0-9Ol\-]+)\b', re.I), | |
| lambda m: m.group(0).replace('O', '0').replace('l', '1')), | |
| # Montants : suppression espace intrusif (1 2.34 → 12.34) dans contexte numérique | |
| (re.compile(r'\b(\d+)\s(\d{2,3})[,.](\d{2})\b'), | |
| r'\1\2.\3'), | |
| # Pourcentages : l8% → 18%, O% → 0% | |
| (re.compile(r'\b([Ol\d]+)\s*%'), | |
| lambda m: m.group(0).replace('O', '0').replace('l', '1')), | |
| # TVA / VAT : TVA l9% → TVA 19% | |
| (re.compile(r'(TVA|VAT|TPS|GST|TAX)\s*:?\s*([Ol\d,. ]+%)', re.I), | |
| lambda m: m.group(0).replace('O', '0').replace('l', '1')), | |
| # Ligatures typographiques | |
| (re.compile('fi'), 'fi'), | |
| (re.compile('fl'), 'fl'), | |
| (re.compile('ff'), 'ff'), | |
| (re.compile('\u00a0'), ' '), # espace insécable → espace normal | |
| ] | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Correction confusions G/O dans contexte numérique (police Vodacom et autres) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Caractères alpha fréquemment confondus avec des chiffres par l'OCR | |
| # sur les polices télécom à empattement (Vodacom, MTN…). | |
| _NUMERIC_CHAR_SUBS: Dict[str, str] = { | |
| 'O': '0', # Lettre O → zéro (confusion la plus fréquente) | |
| 'G': '0', # Lettre G → zéro (spécifique polices Vodacom) | |
| 'l': '1', # lettre l minuscule → un | |
| 'I': '1', # lettre I majuscule → un | |
| 'Z': '2', # Z → 2 | |
| 'S': '5', # S → 5 | |
| 'B': '8', # B → 8 | |
| } | |
| # Ensemble de tous les caractères considérés "digit-like" | |
| _DIGIT_LIKE: frozenset = frozenset('0123456789' + ''.join(_NUMERIC_CHAR_SUBS.keys())) | |
| # Pattern pour détecter les tokens ressemblant à un numéro de téléphone SA | |
| # (avant ou après normalisation G/O) — utilisé pour activer la validation. | |
| _PHONE_RE = re.compile( | |
| r'^[\+]?[\d OGlI]{9,14}$' # 9 à 14 chars, tous digit-like ou + | |
| ) | |
| def _fix_numeric_token(text: str) -> str: | |
| """ | |
| Corrige les confusions G/O/l/I/Z/S/B → 0/0/1/1/2/5/8 dans les tokens | |
| à dominante numérique. | |
| Heuristique : si ≥ 60 % des caractères sont des chiffres ou leurs | |
| homophones visuels (_DIGIT_LIKE), le token est traité comme numérique | |
| et les substitutions sont appliquées. | |
| Conservateur par conception : les mots purement alphabétiques (ratio < 0.6) | |
| ne sont jamais modifiés, ce qui évite de corrompre du texte légitime. | |
| """ | |
| if len(text) < 2: | |
| return text | |
| digit_like_count = sum(1 for c in text if c in _DIGIT_LIKE) | |
| if digit_like_count / len(text) < 0.60: | |
| return text # Pas assez numérique — on ne touche pas | |
| return ''.join(_NUMERIC_CHAR_SUBS.get(c, c) for c in text) | |
| def _validate_phone_number(text: str) -> Optional[str]: | |
| """ | |
| Valide et normalise un numéro de téléphone au format Vodacom/SA. | |
| Appliquer APRÈS _fix_numeric_token (les G/O sont déjà remplacés). | |
| Formats en entrée acceptés : | |
| - 0XXXXXXXXX (10 chiffres, préfixe 06/07/08) → inchangé | |
| - 27XXXXXXXXX (11 chiffres sans +) → +27XXXXXXXXX | |
| - +27XXXXXXXXX (12 chars avec +) → inchangé | |
| - Avec séparateurs espaces / tirets / points → nettoyés | |
| Retourne : | |
| - La chaîne normalisée si le pattern est valide. | |
| - None si le token ne ressemble pas à un numéro de téléphone. | |
| """ | |
| # Supprimer séparateurs courants | |
| clean = re.sub(r'[\s\-.]', '', text) | |
| # Format international : +27 ou 27 suivi de 9 chiffres (06x-08x) | |
| m = re.match(r'^\+?(27)([6-8]\d{8})$', clean) | |
| if m: | |
| return f"+27{m.group(2)}" | |
| # Format local : 0[6-8] + 8 chiffres = 10 chiffres | |
| m = re.match(r'^(0[6-8]\d{8})$', clean) | |
| if m: | |
| return m.group(1) | |
| return None # Token non reconnu comme numéro de téléphone | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Correction et reconstruction des adresses e-mail | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Extensions de domaine les plus communes pour détecter les fragments TLD | |
| _KNOWN_TLDS: frozenset = frozenset({ | |
| 'com', 'org', 'net', 'fr', 'co', 'za', 'uk', 'de', 'eu', | |
| 'io', 'gov', 'edu', 'biz', 'info', 'ma', 'tn', 'dz', 'cm', | |
| 'us', 'ca', 'it', 'es', 'nl', 'be', 'ch', 'at', 'rw', 'ng', | |
| }) | |
| def _fix_email_text(text: str) -> str: | |
| """ | |
| Corrige les erreurs OCR courantes dans un token contenant '@'. | |
| Confusions ciblées : | |
| - Espaces autour de @ : 'user @ domain' → 'user@domain' | |
| - Virgule à la place du point : 'name,com' → 'name.com' | |
| - Points doublés : 'domain..com' → 'domain.com' | |
| - Fusion rn → m dans le domaine : 'grnail.com' → 'gmail.com' | |
| - '@' mal reconnu (0, α, a) : 'user0domain' → 'user@domain' | |
| (uniquement si le pattern user + séparateur + domain.tld est trouvé) | |
| Les emails étant insensibles à la casse, la sortie est entièrement | |
| mise en minuscules. | |
| """ | |
| # Supprimer les espaces autour de @ | |
| text = re.sub(r'\s*@\s*', '@', text) | |
| if '@' not in text: | |
| return text.lower() | |
| local, _, domain = text.partition('@') | |
| # Corrections dans la partie domaine uniquement | |
| domain = domain.replace('rn', 'm') # rn → m (ex: grnail → gmail) | |
| domain = re.sub(r',([a-zA-Z])', r'.\1', domain) # virgule → point | |
| domain = re.sub(r'\.{2,}', '.', domain) # double point → simple | |
| domain = domain.strip('.,') | |
| return (local + '@' + domain).lower() | |
| def _merge_split_emails(words: List[Dict]) -> List[Dict]: | |
| """ | |
| Reconstruit les adresses e-mail fragmentées sur plusieurs tokens OCR. | |
| docTR sépare fréquemment une adresse en plusieurs tokens selon les | |
| caractères spéciaux (@, .) qu'il traite comme séparateurs de mots : | |
| 'user' '@' 'domain.com' → 'user@domain.com' | |
| 'user@domain' '.' 'com' → 'user@domain.com' | |
| 'user' '@' 'domain' '.com' → 'user@domain.com' | |
| 'user' '@' 'domain' '.' 'com' → 'user@domain.com' | |
| Stratégie : | |
| Cas A — '@' isolé : fusionner le token gauche + '@' + token(s) droit(s) | |
| Cas B — token contient '@' : absorber les fragments TLD à droite | |
| ('. ', ',com', 'fr', '.org', etc.) | |
| La bounding box résultante enveloppe tous les tokens absorbés. | |
| """ | |
| if not words: | |
| return words | |
| result: List[Dict] = [] | |
| i = 0 | |
| while i < len(words): | |
| text = words[i].get('text', '').strip() | |
| # ── Cas A : '@' isolé entre deux tokens ────────────────────────────── | |
| if text == '@' and result and i + 1 < len(words): | |
| prev_w = result[-1] | |
| next_w = words[i + 1] | |
| merged = prev_w['text'].rstrip() + '@' + next_w['text'].lstrip() | |
| # Absorber les fragments TLD contigus à droite | |
| j = i + 2 | |
| while j < len(words) and j < i + 5: | |
| nxt = words[j].get('text', '').strip().lower().lstrip(',.') | |
| if nxt in _KNOWN_TLDS or re.match(r'^[,.]?[a-z]{2,6}$', words[j].get('text', '')): | |
| merged += '.' + nxt | |
| j += 1 | |
| else: | |
| break | |
| last_absorbed = words[j - 1] | |
| merged_w = dict(prev_w) | |
| merged_w['original_text'] = prev_w.get('original_text', prev_w['text']) | |
| merged_w['text'] = _fix_email_text(merged) | |
| merged_w['corrections'] = prev_w.get('corrections', []) + ['email_merged'] | |
| merged_w['box'] = [ | |
| prev_w['box'][0], | |
| min(prev_w['box'][1], last_absorbed['box'][1]), | |
| last_absorbed['box'][2], | |
| max(prev_w['box'][3], last_absorbed['box'][3]), | |
| ] | |
| result[-1] = merged_w | |
| i = j | |
| continue | |
| # ── Cas B : token contient '@' → absorber fragments TLD à droite ───── | |
| if '@' in text: | |
| merged = text | |
| j = i + 1 | |
| while j < len(words) and j < i + 4: | |
| nxt = words[j].get('text', '').strip() | |
| nxt_low = nxt.lower().lstrip(',.') | |
| if (nxt in ('.', ',') | |
| or nxt_low in _KNOWN_TLDS | |
| or re.match(r'^[,.]?[a-z]{2,6}$', nxt)): | |
| merged += '.' + nxt_low | |
| j += 1 | |
| else: | |
| break | |
| merged_w = dict(words[i]) | |
| orig = merged_w.get('original_text', text) | |
| fixed = _fix_email_text(merged) | |
| if fixed != text or j > i + 1: | |
| merged_w['original_text'] = orig | |
| merged_w['text'] = fixed | |
| corr = merged_w.get('corrections', []) | |
| tag = 'email_merged' if j > i + 1 else 'email_fixed' | |
| merged_w['corrections'] = corr + [tag] | |
| if j > i + 1: | |
| last = words[j - 1] | |
| merged_w['box'] = [ | |
| words[i]['box'][0], | |
| min(words[i]['box'][1], last['box'][1]), | |
| last['box'][2], | |
| max(words[i]['box'][3], last['box'][3]), | |
| ] | |
| result.append(merged_w) | |
| i = j | |
| continue | |
| result.append(words[i]) | |
| i += 1 | |
| return result | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Fonctions internes de correction | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def _split_merged_words(text: str) -> str: | |
| """ | |
| Sépare les mots fusionnés courants dans les documents OCR. | |
| 'Nameand' → 'Name and' | |
| 'TotalAmount' → 'Total Amount' | |
| 'InvoiceDate' → 'Invoice Date' | |
| """ | |
| # Cas 1 : deux mots connus collés (préfixe connu + Majuscule) | |
| result = _PREFIX_RE.sub(r'\1 \2', text) | |
| # Cas 2 : mot très long avec transition minuscule→Majuscule | |
| if len(result) > 14 and not result.isupper(): | |
| result = re.sub(r'([a-z])([A-Z])', r'\1 \2', result) | |
| return result | |
| def _detect_context(word: str) -> str: | |
| """Retourne 'numeric' | 'alpha' selon le type dominant du token.""" | |
| digits = sum(c.isdigit() for c in word) | |
| letters = sum(c.isalpha() for c in word) | |
| return 'numeric' if digits > letters else 'alpha' | |
| def _fix_char_confusions(text: str, context: str) -> str: | |
| """ | |
| Corrige les confusions caractère typiques petits fonts selon le contexte. | |
| Contexte 'numeric' : O→0, l→1 entre chiffres ou en position numérique. | |
| Contexte 'alpha' : 0→O en position alphabétique (rare, conservateur). | |
| """ | |
| if context == 'numeric': | |
| # O isolé entre chiffres ou en position finale numérique | |
| text = re.sub(r'(?<=\d)O(?=\d)', '0', text) | |
| text = re.sub(r'(?<=\d)l(?=\d)', '1', text) | |
| text = re.sub(r'\bO\b', '0', text) # O seul = probablement 0 | |
| return text | |
| def _apply_vocab(text: str) -> Tuple[str, str]: | |
| """Correction via dictionnaire métier. Retourne (texte, type_correction).""" | |
| if text in INVOICE_VOCAB: | |
| return INVOICE_VOCAB[text], 'vocab_exact' | |
| lower = text.lower() | |
| for k, v in INVOICE_VOCAB.items(): | |
| if k.lower() == lower: | |
| if text.isupper(): | |
| corrected = v.upper() | |
| elif text and text[0].isupper(): | |
| corrected = v.capitalize() | |
| else: | |
| corrected = v.lower() | |
| return corrected, 'vocab_case' | |
| return text, '' | |
| def _apply_regex_patterns(text: str) -> Tuple[str, str]: | |
| """Application des patterns regex. Retourne (texte, type_correction).""" | |
| for pattern, replacement in _REGEX_PATTERNS: | |
| try: | |
| new_text = pattern.sub(replacement, text) | |
| except (TypeError, AttributeError): | |
| continue | |
| if new_text != text: | |
| return new_text, 'regex_pattern' | |
| return text, '' | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Fonction principale de post-traitement | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def clean_invoice_ocr( | |
| words: List[Dict], | |
| split_merged: bool = True, | |
| apply_vocab: bool = True, | |
| apply_regex: bool = True, | |
| confidence_threshold: float = 0.65, | |
| fuzzy_threshold: int = 90, | |
| ) -> List[Dict]: | |
| """ | |
| Pipeline de post-traitement OCR pour documents métier (factures, devis…). | |
| S'exécute APRÈS ocr_cleaner.clean_ocr_output() (qui gère arabic_norm, | |
| fuzzy FR/AR). Ce module cible l'anglais métier et les patterns numériques. | |
| Args: | |
| words : liste de dicts {text, box, confidence, ...} | |
| split_merged : sépare les mots fusionnés | |
| apply_vocab : vocabulaire exact + insensible casse | |
| apply_regex : patterns dates / montants / numéros | |
| confidence_threshold : seuil en dessous duquel on applique toutes corrections | |
| fuzzy_threshold : seuil rapidfuzz pour corrections approximatives | |
| Returns: | |
| Liste enrichie avec 'original_text' et 'corrections' (liste des types). | |
| """ | |
| vocab_keys = list(INVOICE_VOCAB.keys()) | |
| cleaned = [] | |
| # Pré-étape : reconstruction des emails fragmentés (multi-token → 1 token) | |
| # Doit s'exécuter SUR LA LISTE complète avant la boucle mot par mot. | |
| words = _merge_split_emails(list(words)) | |
| for w in words: | |
| word = dict(w) | |
| text = word.get("text", "") | |
| conf = word.get("confidence", 1.0) | |
| corrections: List[str] = [] | |
| original = text | |
| if not text: | |
| cleaned.append(word) | |
| continue | |
| # 1. Vocabulaire exact (priorité maximale, toujours appliqué) | |
| if apply_vocab: | |
| text, ctype = _apply_vocab(text) | |
| if ctype: | |
| corrections.append(ctype) | |
| # 2. Patterns regex (montants, dates, numéros — toujours appliqués) | |
| if apply_regex: | |
| text, ctype = _apply_regex_patterns(text) | |
| if ctype: | |
| corrections.append(ctype) | |
| # 2b. Correction confusion G/O dans tokens numériques (systématique) | |
| # Appliqué inconditionnellement car la confusion est liée à la police | |
| # (pas à la confiance du modèle OCR). | |
| fixed_num = _fix_numeric_token(text) | |
| if fixed_num != text: | |
| text = fixed_num | |
| corrections.append('numeric_char_fix') | |
| # 2c. Correction token email simple (déjà complet, mais erreurs OCR internes) | |
| # ex : 'user@grnail,com' → 'user@gmail.com' | |
| # Appliqué inconditionnellement si '@' présent et non déjà traité. | |
| if '@' in text and 'email_merged' not in corrections and 'email_fixed' not in corrections: | |
| fixed_email = _fix_email_text(text) | |
| if fixed_email != text: | |
| text = fixed_email | |
| corrections.append('email_fixed') | |
| # 2e. Validation / normalisation numéros de téléphone SA | |
| # Activée uniquement si le token ressemble à un numéro (longueur + chars). | |
| if _PHONE_RE.match(text.replace(' ', '').replace('-', '')): | |
| normed = _validate_phone_number(text) | |
| if normed and normed != text: | |
| text = normed | |
| corrections.append('phone_normalized') | |
| # 3. Corrections sur mots à faible confiance | |
| if conf < confidence_threshold: | |
| # 3a. Séparation mots fusionnés (mots longs uniquement) | |
| if split_merged and len(text) > 8: | |
| new_text = _split_merged_words(text) | |
| if new_text != text: | |
| text = new_text | |
| corrections.append('split_merged') | |
| # 3b. Confusion caractères selon contexte | |
| if len(text) <= 10: | |
| ctx = _detect_context(text) | |
| new_text = _fix_char_confusions(text, ctx) | |
| if new_text != text: | |
| text = new_text | |
| corrections.append(f'char_fix_{ctx}') | |
| # 3c. Fuzzy matching vocabulaire (mots courts non encore corrigés) | |
| if _HAS_RAPIDFUZZ and not corrections and 3 <= len(text) <= 15: | |
| match = _rfuzz.extractOne( | |
| text, vocab_keys, scorer=_fuzz.ratio, | |
| score_cutoff=fuzzy_threshold, | |
| ) | |
| if match: | |
| text = INVOICE_VOCAB[match[0]] | |
| corrections.append(f'fuzzy_{match[1]}') | |
| # Mise à jour du mot | |
| if text != original: | |
| word["original_text"] = original | |
| word["text"] = text | |
| else: | |
| word.setdefault("original_text", original) | |
| word["corrections"] = corrections | |
| cleaned.append(word) | |
| changed = sum(1 for w in cleaned if w.get("corrections")) | |
| logger.info("post_processor : %d correction(s) sur %d mots.", changed, len(cleaned)) | |
| return cleaned | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Reconstruction de lignes adaptative (conscience des colonnes) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def reconstruct_lines_table_aware(words: List[Dict]) -> List[List[Dict]]: | |
| """ | |
| Regroupe les mots en lignes avec un seuil de hauteur dynamique. | |
| Avantage vs seuil fixe : | |
| - Adapté à toute taille de police (petits tableaux, gros titres) | |
| - Utilise le y_center (milieu vertical) plutôt que y_top | |
| - Threshold = 60 % de la hauteur médiane des mots | |
| Coordonnées attendues : [x1, y1, x2, y2] normalisées 0-1000. | |
| Returns: | |
| Liste de lignes, chaque ligne = liste de mots triés par x_min. | |
| """ | |
| if not words: | |
| return [] | |
| # Hauteur médiane des mots | |
| heights = [max(1, w["box"][3] - w["box"][1]) for w in words] | |
| median_h = float(np.median(heights)) | |
| threshold = median_h * 0.60 | |
| # Y-center de chaque mot | |
| for w in words: | |
| w["_yc"] = (w["box"][1] + w["box"][3]) / 2 | |
| sorted_words = sorted(words, key=lambda w: w["_yc"]) | |
| lines: List[List[Dict]] = [] | |
| current_line = [sorted_words[0]] | |
| current_y_avg = sorted_words[0]["_yc"] | |
| for w in sorted_words[1:]: | |
| if abs(w["_yc"] - current_y_avg) <= threshold: | |
| current_line.append(w) | |
| current_y_avg = float(np.mean([x["_yc"] for x in current_line])) | |
| else: | |
| lines.append(sorted(current_line, key=lambda x: x["box"][0])) | |
| current_line = [w] | |
| current_y_avg = w["_yc"] | |
| if current_line: | |
| lines.append(sorted(current_line, key=lambda x: x["box"][0])) | |
| # Nettoyer l'attribut temporaire | |
| for w in words: | |
| w.pop("_yc", None) | |
| return lines | |
| def detect_column_boundaries(words: List[Dict], n_bins: int = 60, | |
| min_gap_ratio: float = 0.025) -> List[float]: | |
| """ | |
| Détecte les séparations de colonnes par histogramme des x_min. | |
| Returns: | |
| Liste de positions x (0-1000) séparant les colonnes. | |
| Vide si document mono-colonne. | |
| """ | |
| if len(words) < 8: | |
| return [] | |
| x_starts = [w["box"][0] for w in words] | |
| max_x = max(w["box"][2] for w in words) | |
| hist, edges = np.histogram(x_starts, bins=n_bins, range=(0, max_x)) | |
| min_gap_px = max_x * min_gap_ratio | |
| gaps = [] | |
| in_gap = False | |
| gap_start = 0.0 | |
| for i, count in enumerate(hist): | |
| x_pos = edges[i] | |
| if count == 0 and not in_gap: | |
| in_gap = True | |
| gap_start = x_pos | |
| elif count > 0 and in_gap: | |
| in_gap = False | |
| gap_end = x_pos | |
| if gap_end - gap_start >= min_gap_px: | |
| gaps.append((gap_start + gap_end) / 2) | |
| return gaps | |
| def lines_to_text(lines: List[List[Dict]], | |
| column_boundaries: Optional[List[float]] = None) -> str: | |
| """ | |
| Convertit les lignes reconstruites en texte final. | |
| Si column_boundaries fourni : insère une tabulation entre colonnes | |
| (utile pour rendre les tableaux lisibles dans le texte brut). | |
| """ | |
| text_lines = [] | |
| for line in lines: | |
| if column_boundaries: | |
| n_cols = len(column_boundaries) + 1 | |
| cols: Dict[int, List[str]] = {i: [] for i in range(n_cols)} | |
| for w in line: | |
| col_idx = sum(1 for b in column_boundaries if w["box"][0] > b) | |
| cols[col_idx].append(w["text"]) | |
| text_lines.append( | |
| "\t".join(" ".join(cols.get(i, [])) for i in range(n_cols)).rstrip() | |
| ) | |
| else: | |
| text_lines.append(" ".join(w["text"] for w in line)) | |
| return "\n".join(text_lines) | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| # Rapport des corrections post-processor | |
| # ───────────────────────────────────────────────────────────────────────────── | |
| def correction_report_invoice(words: List[Dict]) -> List[Dict]: | |
| """ | |
| Génère le rapport des corrections appliquées par clean_invoice_ocr(). | |
| Compatible avec le format DataFrame attendu par process_document.py. | |
| """ | |
| report = [] | |
| for w in words: | |
| orig = w.get("original_text", w["text"]) | |
| if orig != w["text"]: | |
| report.append({ | |
| "original_text": orig, | |
| "text": w["text"], | |
| "correction": ", ".join(w.get("corrections", ["post"])), | |
| "confidence": round(w.get("confidence", 1.0), 3), | |
| }) | |
| return report | |