ocr-layoutxlm-backend / src /ocr /post_processor.py
Goubaa
feat: backend FastAPI OCR+LayoutXLM — model loaded from HF Hub
63874d7
Raw
History Blame Contribute Delete
31.6 kB
"""
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