1508 lines
62 KiB
Python
1508 lines
62 KiB
Python
"""
|
|
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'(?<!\d)(\d{7})(?!\d)' # 7 chiffres isolés
|
|
]
|
|
|
|
for motif in motifs:
|
|
match = re.search(motif, texte, re.IGNORECASE)
|
|
if match:
|
|
return match.group(1)
|
|
|
|
logger.warning("Aucun numéro de chèque détecté dans le texte")
|
|
return None
|
|
|
|
|
|
def evaluer_qualite_extraction(infos: Dict[str, Any]) -> 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 |