From a5e044d747977e410b368eccf86a5dc44a8fb3f7 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 9 Jul 2025 02:12:06 +0200 Subject: [PATCH] First commit --- .env | 27 +++ api/Dockerfile | 34 ++++ api/app/__init__.py | 3 + api/app/config.py | 69 +++++++ api/app/dependencies.py | 45 +++++ api/app/entrypoint.sh | 11 + api/app/main.py | 321 ++++++++++++++++++++++++++++++ api/app/schemas.py | 77 +++++++ api/app/tasks.py | 228 +++++++++++++++++++++ api/requirements.txt | 14 ++ docker-compose.yml | 95 +++++++++ examples/php-integration.php | 212 ++++++++++++++++++++ shared/extraction/__init__.py | 25 +++ shared/extraction/scanner.py | 294 +++++++++++++++++++++++++++ worker/Dockerfile | 33 +++ worker/app/__init__.py | 14 ++ worker/app/app/__init__.py | 14 ++ worker/app/app/tasks/__init__.py | 37 ++++ worker/app/entrypoint.sh | 16 ++ worker/app/priority_entrypoint.sh | 16 ++ worker/app/tasks.py | 232 +++++++++++++++++++++ worker/app/worker.py | 142 +++++++++++++ worker/requirements.txt | 8 + 23 files changed, 1967 insertions(+) create mode 100644 .env create mode 100644 api/Dockerfile create mode 100644 api/app/__init__.py create mode 100644 api/app/config.py create mode 100644 api/app/dependencies.py create mode 100644 api/app/entrypoint.sh create mode 100644 api/app/main.py create mode 100644 api/app/schemas.py create mode 100644 api/app/tasks.py create mode 100644 api/requirements.txt create mode 100644 docker-compose.yml create mode 100644 examples/php-integration.php create mode 100644 shared/extraction/__init__.py create mode 100644 shared/extraction/scanner.py create mode 100644 worker/Dockerfile create mode 100644 worker/app/__init__.py create mode 100644 worker/app/app/__init__.py create mode 100644 worker/app/app/tasks/__init__.py create mode 100644 worker/app/entrypoint.sh create mode 100755 worker/app/priority_entrypoint.sh create mode 100644 worker/app/tasks.py create mode 100644 worker/app/worker.py create mode 100644 worker/requirements.txt diff --git a/.env b/.env new file mode 100644 index 0000000..e3b16a3 --- /dev/null +++ b/.env @@ -0,0 +1,27 @@ +# Configuration de l'API Cheque Scanner +API_KEY=your-secret-api-key-change-me +DEBUG=false + +# Configuration Redis +REDIS_URL=redis://redis:6379/0 + +# Configuration des workers +WORKER_REPLICAS=2 + +# Configuration OCR +DEFAULT_OCR_LANGUAGE=fra +ALTERNATIVE_OCR_LANGUAGE=eng + +# Configuration des files d'attente +QUEUE_NAME=cheque_processing +HIGH_PRIORITY_QUEUE_NAME=cheque_processing_high + +# Chemins de stockage +UPLOAD_FOLDER=/app/data/uploads +RESULT_FOLDER=/app/data/results +TEMP_FOLDER=/app/data/tmp + +# Limites et timeouts +MAX_CONTENT_LENGTH=16777216 # 16MB +JOB_TIMEOUT=300 # 5 minutes +RESULT_TTL=86400 # 24 heures \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..ce9bc91 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.10-slim + +WORKDIR /app + +# Installation des dépendances système +RUN apt-get update && apt-get install -y \ + gcc \ + python3-dev \ + tesseract-ocr \ + tesseract-ocr-fra \ + tesseract-ocr-eng \ + libgl1-mesa-glx \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# Copie et installation des dépendances Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copie du code de l'application +COPY ./app /app/app +COPY ./app/entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +# Variables d'environnement +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Exposition du port +EXPOSE 8000 + +# Commande d'exécution +ENTRYPOINT ["/app/entrypoint.sh"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] \ No newline at end of file diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..f6e2929 --- /dev/null +++ b/api/app/__init__.py @@ -0,0 +1,3 @@ +""" +Package pour l'API Cheque Scanner +""" \ No newline at end of file diff --git a/api/app/config.py b/api/app/config.py new file mode 100644 index 0000000..c4828bb --- /dev/null +++ b/api/app/config.py @@ -0,0 +1,69 @@ +""" +Configuration de l'API Cheque Scanner +""" + +import os +from pydantic_settings import BaseSettings +from dotenv import load_dotenv + +# Charger les variables d'environnement depuis le fichier .env +load_dotenv() + +class Settings(BaseSettings): + """Configuration de l'application""" + + # Informations de base + APP_NAME: str = "Cheque Scanner API" + API_VERSION: str = "v1" + API_PREFIX: str = f"/api/{API_VERSION}" + DEBUG: bool = os.getenv("DEBUG", "False").lower() == "true" + + # Sécurité + API_KEY: str = os.getenv("API_KEY", "your-secret-api-key") + + # Configuration du serveur + HOST: str = os.getenv("HOST", "0.0.0.0") + PORT: int = int(os.getenv("PORT", "8000")) + + # Configuration Redis + REDIS_HOST: str = os.getenv("REDIS_HOST", "redis") + REDIS_PORT: int = int(os.getenv("REDIS_PORT", "6379")) + REDIS_DB: int = int(os.getenv("REDIS_DB", "0")) + REDIS_URL: str = os.getenv("REDIS_URL", f"redis://{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}") + + # Configuration des files d'attente + QUEUE_NAME: str = os.getenv("QUEUE_NAME", "cheque_processing") + HIGH_PRIORITY_QUEUE_NAME: str = os.getenv("HIGH_PRIORITY_QUEUE_NAME", "cheque_processing_high") + + # Configuration du stockage + UPLOAD_FOLDER: str = os.getenv("UPLOAD_FOLDER", "/app/data/uploads") + RESULT_FOLDER: str = os.getenv("RESULT_FOLDER", "/app/data/results") + TEMP_FOLDER: str = os.getenv("TEMP_FOLDER", "/app/data/tmp") + MAX_CONTENT_LENGTH: int = int(os.getenv("MAX_CONTENT_LENGTH", "16777216")) # 16MB + ALLOWED_EXTENSIONS: set = {"png", "jpg", "jpeg", "gif", "tiff", "pdf"} + + # Configuration du traitement d'image + DEFAULT_OCR_LANGUAGE: str = os.getenv("DEFAULT_OCR_LANGUAGE", "eng") + ALTERNATIVE_OCR_LANGUAGE: str = os.getenv("ALTERNATIVE_OCR_LANGUAGE", "fra") + TESSERACT_DATA_PATH: str = os.getenv("TESSERACT_DATA_PATH", "/usr/share/tesseract-ocr/4.00/tessdata") + + # Délais et timeouts + JOB_TIMEOUT: int = int(os.getenv("JOB_TIMEOUT", "300")) # 5 minutes + RESULT_TTL: int = int(os.getenv("RESULT_TTL", "86400")) # 24 heures + + # Limites + MAX_WORKERS: int = int(os.getenv("MAX_WORKERS", "3")) + RATE_LIMIT: int = int(os.getenv("RATE_LIMIT", "100")) # requêtes par heure + + class Config: + env_file = ".env" + case_sensitive = True + + +# Instance de configuration globale +settings = Settings() + +# Créer les dossiers nécessaires s'ils n'existent pas +os.makedirs(settings.UPLOAD_FOLDER, exist_ok=True) +os.makedirs(settings.RESULT_FOLDER, exist_ok=True) +os.makedirs(settings.TEMP_FOLDER, exist_ok=True) \ No newline at end of file diff --git a/api/app/dependencies.py b/api/app/dependencies.py new file mode 100644 index 0000000..2d3881c --- /dev/null +++ b/api/app/dependencies.py @@ -0,0 +1,45 @@ +""" +Dépendances pour l'API FastAPI +""" + +import redis +from fastapi import Depends, HTTPException, Header +from typing import Optional + +from .config import settings + + +def get_redis_connection(): + """ + Crée et retourne une connexion à Redis + """ + try: + conn = redis.Redis.from_url(settings.REDIS_URL) + yield conn + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Erreur de connexion à Redis: {str(e)}" + ) + finally: + # Fermer la connexion + pass # La connexion est fermée automatiquement + + +async def verify_api_key(x_api_key: Optional[str] = Header(None)): + """ + Vérifie que la clé API fournie est valide + """ + if not x_api_key: + raise HTTPException( + status_code=401, + detail="Clé API manquante" + ) + + if x_api_key != settings.API_KEY: + raise HTTPException( + status_code=403, + detail="Clé API invalide" + ) + + return x_api_key \ No newline at end of file diff --git a/api/app/entrypoint.sh b/api/app/entrypoint.sh new file mode 100644 index 0000000..b984bd7 --- /dev/null +++ b/api/app/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -e + +# Attendre que Redis soit disponible +echo "Démarrage de l'API..." +# Ajouter un petit délai pour s'assurer que Redis a eu le temps de démarrer +sleep 5 +echo "Redis devrait être disponible maintenant!" + +# Exécuter la commande spécifiée +exec "$@" \ No newline at end of file diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..a280e27 --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,321 @@ +""" +API principale pour le service d'extraction d'informations de chèques +""" + +import os +import time +import uuid +import shutil +from typing import List, Optional +from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Header, BackgroundTasks, Query, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +from datetime import datetime, timedelta +import redis +from rq import Queue +from rq.job import Job +from rq.registry import StartedJobRegistry, FinishedJobRegistry, FailedJobRegistry +import logging + +from .config import settings +from .schemas import ( + UploadResponse, JobStatusResponse, JobResult, + ExtractionResult, HealthCheck, ErrorResponse, JobStatus +) +from .dependencies import verify_api_key, get_redis_connection +from .tasks import process_cheque_image + +# Configuration du logging +logging.basicConfig( + level=logging.INFO if not settings.DEBUG else logging.DEBUG, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("cheque_scanner_api") + +# Création de l'application FastAPI +app = FastAPI( + title=settings.APP_NAME, + description="API pour l'extraction d'informations de chèques à partir d'images", + version=settings.API_VERSION, + docs_url=f"{settings.API_PREFIX}/docs", + redoc_url=f"{settings.API_PREFIX}/redoc", + openapi_url=f"{settings.API_PREFIX}/openapi.json" +) + +# Middleware CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # À ajuster en production + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Configuration des fichiers statiques pour accéder aux images de résultats +app.mount("/static", StaticFiles(directory=settings.RESULT_FOLDER), name="static") + +# Variable pour stocker le temps de démarrage +start_time = time.time() + + +@app.get(f"{settings.API_PREFIX}/health", response_model=HealthCheck, tags=["Système"]) +async def health_check(redis_conn: redis.Redis = Depends(get_redis_connection)): + """ + Vérifie l'état de santé de l'API et des services associés + """ + # Vérifier la connexion Redis + try: + redis_conn.ping() + redis_status = "ok" + except Exception as e: + logger.error(f"Erreur Redis: {str(e)}") + redis_status = f"error: {str(e)}" + + # Obtenir le nombre de workers actifs + registry = StartedJobRegistry(settings.QUEUE_NAME, connection=redis_conn) + worker_count = len(registry.get_job_ids()) + + # Obtenir la taille de la file d'attente + queue = Queue(settings.QUEUE_NAME, connection=redis_conn) + queue_size = len(queue.get_job_ids()) + + # Calculer le temps de fonctionnement + uptime_seconds = time.time() - start_time + days, remainder = divmod(uptime_seconds, 86400) + hours, remainder = divmod(remainder, 3600) + minutes, seconds = divmod(remainder, 60) + uptime = f"{int(days)}d {int(hours)}h {int(minutes)}m {int(seconds)}s" + + return HealthCheck( + status="ok", + version=settings.API_VERSION, + redis_status=redis_status, + worker_count=worker_count, + queue_size=queue_size, + uptime=uptime + ) + + +@app.post( + f"{settings.API_PREFIX}/upload", + response_model=UploadResponse, + tags=["Extraction"], + status_code=202 +) +async def upload_image( + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + priority: bool = Query(False, description="Traiter avec une priorité élevée"), + api_key: str = Depends(verify_api_key), + redis_conn: redis.Redis = Depends(get_redis_connection) +): + """ + Télécharge une image de chèque et lance son traitement + """ + # Vérifier l'extension du fichier + file_ext = os.path.splitext(file.filename)[1].lower().lstrip(".") + if file_ext not in settings.ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=400, + detail=f"Format de fichier non pris en charge. Formats acceptés: {', '.join(settings.ALLOWED_EXTENSIONS)}" + ) + + # Créer un identifiant unique pour la tâche + job_id = str(uuid.uuid4()) + + # Créer le chemin de fichier + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"{timestamp}_{job_id}.{file_ext}" + file_path = os.path.join(settings.UPLOAD_FOLDER, filename) + + # Sauvegarder le fichier + try: + with open(file_path, "wb") as buffer: + shutil.copyfileobj(file.file, buffer) + except Exception as e: + logger.error(f"Erreur lors de la sauvegarde du fichier: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Erreur lors de la sauvegarde de l'image: {str(e)}" + ) + + # Déterminer la file d'attente en fonction de la priorité + queue_name = settings.HIGH_PRIORITY_QUEUE_NAME if priority else settings.QUEUE_NAME + queue = Queue(queue_name, connection=redis_conn) + + # Créer une tâche RQ + try: + # Utiliser directement le nom complet du module dans le worker + queue_job = queue.enqueue( + 'tasks.process_cheque_image', + job_id, + file_path, + result_ttl=settings.RESULT_TTL, + timeout=settings.JOB_TIMEOUT + ) + + # Stocker les métadonnées dans Redis + redis_conn.hset(f"job:{job_id}", mapping={ + "status": JobStatus.PENDING.value, + "created_at": datetime.now().isoformat(), + "file_path": file_path, + "filename": file.filename, + "priority": str(priority).lower() + }) + + logger.info(f"Tâche créée: {job_id} - Fichier: {file.filename}") + + return UploadResponse( + job_id=job_id, + status=JobStatus.PENDING, + message=f"Image en file d'attente pour traitement (file {'prioritaire' if priority else 'standard'})" + ) + + except Exception as e: + logger.error(f"Erreur lors de la création de la tâche: {str(e)}") + # Supprimer le fichier en cas d'erreur + if os.path.exists(file_path): + os.remove(file_path) + raise HTTPException( + status_code=500, + detail=f"Erreur lors de la création de la tâche: {str(e)}" + ) + + +@app.get( + f"{settings.API_PREFIX}/status/{{job_id}}", + response_model=JobStatusResponse, + tags=["Extraction"] +) +async def get_job_status( + job_id: str, + api_key: str = Depends(verify_api_key), + redis_conn: redis.Redis = Depends(get_redis_connection) +): + """ + Vérifie l'état d'une tâche d'extraction + """ + # Vérifier si la tâche existe + if not redis_conn.exists(f"job:{job_id}"): + raise HTTPException( + status_code=404, + detail=f"Tâche non trouvée: {job_id}" + ) + + # Récupérer les métadonnées de la tâche + job_data = {k.decode(): v.decode() for k, v in redis_conn.hgetall(f"job:{job_id}").items()} + + # Récupérer l'état de la tâche RQ + queue_name = settings.HIGH_PRIORITY_QUEUE_NAME if job_data.get("priority") == "true" else settings.QUEUE_NAME + queue = Queue(queue_name, connection=redis_conn) + + status = job_data.get("status", JobStatus.PENDING.value) + message = job_data.get("message", "En attente de traitement") + + # Vérifier si la tâche est dans différentes files d'attente + progress = None + queue_position = None + + # Si la tâche est en attente, déterminer sa position dans la file + if status == JobStatus.PENDING.value: + job_ids = queue.get_job_ids() + if job_id in job_ids: + queue_position = job_ids.index(job_id) + 1 + + # Si la tâche est en cours, récupérer la progression + elif status == JobStatus.PROCESSING.value: + progress = job_data.get("progress") + if progress: + progress = int(progress) + + return JobStatusResponse( + job_id=job_id, + status=JobStatus(status), + message=message, + created_at=datetime.fromisoformat(job_data.get("created_at")), + updated_at=datetime.fromisoformat(job_data.get("updated_at")) if "updated_at" in job_data else None, + progress=progress, + queue_position=queue_position + ) + + +@app.get( + f"{settings.API_PREFIX}/result/{{job_id}}", + response_model=JobResult, + tags=["Extraction"] +) +async def get_job_result( + job_id: str, + api_key: str = Depends(verify_api_key), + redis_conn: redis.Redis = Depends(get_redis_connection) +): + """ + Récupère les résultats d'une tâche d'extraction terminée + """ + # Vérifier si la tâche existe + if not redis_conn.exists(f"job:{job_id}"): + raise HTTPException( + status_code=404, + detail=f"Tâche non trouvée: {job_id}" + ) + + # Récupérer les métadonnées de la tâche + job_data = {k.decode(): v.decode() for k, v in redis_conn.hgetall(f"job:{job_id}").items()} + + # Vérifier si la tâche est terminée + status = JobStatus(job_data.get("status", JobStatus.PENDING.value)) + if status != JobStatus.COMPLETED and status != JobStatus.FAILED: + raise HTTPException( + status_code=400, + detail=f"La tâche n'est pas encore terminée. Statut actuel: {status.value}" + ) + + # Récupérer les résultats d'extraction si disponibles + result = None + texte_brut = None + + if status == JobStatus.COMPLETED: + # Charger les résultats depuis Redis + result_data = job_data.get("result") + if result_data: + result_dict = eval(result_data) # Attention: eval n'est pas sécurisé en production + result = ExtractionResult(**result_dict) + + texte_brut = job_data.get("texte_brut") + + return JobResult( + job_id=job_id, + status=status, + created_at=datetime.fromisoformat(job_data.get("created_at")), + completed_at=datetime.fromisoformat(job_data.get("completed_at")) if "completed_at" in job_data else None, + image_path=job_data.get("file_path"), + result=result, + texte_brut=texte_brut, + methode=job_data.get("methode", "inconnu"), + erreur=job_data.get("erreur") if status == JobStatus.FAILED else None + ) + + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """Gestionnaire d'exceptions global""" + logger.error(f"Exception non gérée: {str(exc)}") + return JSONResponse( + status_code=500, + content=ErrorResponse( + message="Erreur interne du serveur", + error_code="INTERNAL_SERVER_ERROR", + details={"error": str(exc)} + ).dict() + ) + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG + ) \ No newline at end of file diff --git a/api/app/schemas.py b/api/app/schemas.py new file mode 100644 index 0000000..c94f726 --- /dev/null +++ b/api/app/schemas.py @@ -0,0 +1,77 @@ +""" +Schémas Pydantic pour la validation et la sérialisation des données +""" + +from typing import Optional, Dict, Any, List +from pydantic import BaseModel, Field +from enum import Enum +from datetime import datetime + + +class JobStatus(str, Enum): + """Statuts possibles pour une tâche d'extraction""" + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + + +class UploadResponse(BaseModel): + """Réponse à une demande d'upload d'image""" + job_id: str = Field(..., description="Identifiant unique de la tâche") + status: JobStatus = Field(default=JobStatus.PENDING, description="Statut de la tâche") + message: str = Field(default="Image en file d'attente pour traitement", description="Message d'information") + created_at: datetime = Field(default_factory=datetime.now, description="Date de création de la tâche") + + +class JobStatusResponse(BaseModel): + """Réponse à une demande de statut de tâche""" + job_id: str = Field(..., description="Identifiant unique de la tâche") + status: JobStatus = Field(..., description="Statut de la tâche") + message: str = Field(..., description="Message d'information") + created_at: datetime = Field(..., description="Date de création de la tâche") + updated_at: Optional[datetime] = Field(None, description="Date de dernière mise à jour") + progress: Optional[int] = Field(None, description="Progression en pourcentage (0-100)") + queue_position: Optional[int] = Field(None, description="Position dans la file d'attente") + + +class ExtractionResult(BaseModel): + """Résultat de l'extraction d'informations d'un chèque""" + montant: Optional[str] = Field(None, description="Montant du chèque") + date: Optional[str] = Field(None, description="Date du chèque") + beneficiaire: Optional[str] = Field(None, description="Bénéficiaire du chèque") + numero_cheque: Optional[str] = Field(None, description="Numéro du chèque") + qualite_extraction: Optional[str] = Field(None, description="Qualité de l'extraction (échec, faible, moyenne, bonne)") + image_zones: Optional[str] = Field(None, description="Chemin vers l'image avec les zones identifiées") + + +class JobResult(BaseModel): + """Résultat complet d'une tâche d'extraction""" + job_id: str = Field(..., description="Identifiant unique de la tâche") + status: JobStatus = Field(..., description="Statut de la tâche") + created_at: datetime = Field(..., description="Date de création de la tâche") + completed_at: Optional[datetime] = Field(None, description="Date de complétion de la tâche") + image_path: str = Field(..., description="Chemin de l'image originale") + result: Optional[ExtractionResult] = Field(None, description="Résultats de l'extraction") + texte_brut: Optional[str] = Field(None, description="Texte brut extrait (si disponible)") + methode: str = Field(..., description="Méthode utilisée (ocr ou cv)") + erreur: Optional[str] = Field(None, description="Message d'erreur (si échec)") + + +class HealthCheck(BaseModel): + """Réponse du health check""" + status: str = Field(default="ok", description="Statut du service") + version: str = Field(..., description="Version de l'API") + timestamp: datetime = Field(default_factory=datetime.now, description="Horodatage de la vérification") + redis_status: str = Field(..., description="Statut de la connexion Redis") + worker_count: int = Field(..., description="Nombre de workers disponibles") + queue_size: int = Field(..., description="Nombre de tâches en attente") + uptime: str = Field(..., description="Temps de fonctionnement du service") + + +class ErrorResponse(BaseModel): + """Réponse en cas d'erreur""" + status: str = Field(default="error", description="Statut de la réponse") + message: str = Field(..., description="Message d'erreur") + error_code: Optional[str] = Field(None, description="Code d'erreur") + details: Optional[Dict[str, Any]] = Field(None, description="Détails supplémentaires") \ No newline at end of file diff --git a/api/app/tasks.py b/api/app/tasks.py new file mode 100644 index 0000000..8a3350d --- /dev/null +++ b/api/app/tasks.py @@ -0,0 +1,228 @@ +""" +Tâches de traitement pour l'API Cheque Scanner +""" + +import os +import sys +import time +import json +import logging +import traceback +from datetime import datetime +import redis +from rq import get_current_job + +# Ajouter le module d'extraction au path +sys.path.append('/app/shared') + +# Importer les fonctions d'extraction +from extraction import ( + extraire_infos_cheque, + get_tessdata_path +) + +from .config import settings + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("cheque_scanner_tasks") + + +def update_job_status(job_id, status, message=None, progress=None, result=None, texte_brut=None, erreur=None, methode=None): + """ + Met à jour le statut d'une tâche dans Redis + """ + try: + # Connexion à Redis + redis_conn = redis.Redis.from_url(settings.REDIS_URL) + + # Préparer les données à mettre à jour + update_data = { + "status": status, + "updated_at": datetime.now().isoformat() + } + + if message: + update_data["message"] = message + + if progress: + update_data["progress"] = str(progress) + + if result: + update_data["result"] = str(result) + + if texte_brut: + update_data["texte_brut"] = texte_brut + + if erreur: + update_data["erreur"] = erreur + + if methode: + update_data["methode"] = methode + + if status == "completed": + update_data["completed_at"] = datetime.now().isoformat() + + # Mettre à jour les données dans Redis + redis_conn.hset(f"job:{job_id}", mapping=update_data) + + logger.info(f"Statut de la tâche {job_id} mis à jour: {status}") + + return True + + except Exception as e: + logger.error(f"Erreur lors de la mise à jour du statut de la tâche {job_id}: {str(e)}") + return False + + +def process_cheque_image(job_id, file_path): + """ + Traite une image de chèque pour en extraire les informations + + Args: + job_id (str): Identifiant de la tâche + file_path (str): Chemin vers l'image à traiter + + Returns: + dict: Résultat de l'extraction + """ + job = get_current_job() + logger.info(f"Début du traitement de l'image: {file_path} (Tâche: {job_id})") + + # Mettre à jour le statut + update_job_status( + job_id=job_id, + status="processing", + message="Traitement en cours", + progress=10 + ) + + try: + # Vérifier que le fichier existe + if not os.path.exists(file_path): + raise FileNotFoundError(f"L'image {file_path} n'existe pas") + + # Récupérer le chemin vers tessdata + tessdata_path = settings.TESSERACT_DATA_PATH + if not os.path.exists(tessdata_path): + # Essayer de trouver automatiquement + tessdata_path = get_tessdata_path() + + # Mise à jour intermédiaire + update_job_status( + job_id=job_id, + status="processing", + message="Extraction des informations en cours", + progress=30 + ) + + # Première tentative avec la langue par défaut + try: + logger.info(f"Tentative d'extraction avec la langue: {settings.DEFAULT_OCR_LANGUAGE}") + update_job_status( + job_id=job_id, + status="processing", + message=f"Extraction avec langue {settings.DEFAULT_OCR_LANGUAGE}", + progress=50 + ) + + infos, texte = extraire_infos_cheque( + chemin_image=file_path, + methode="ocr", + language=settings.DEFAULT_OCR_LANGUAGE, + tessdata=tessdata_path + ) + + methode = "ocr" + + except Exception as e: + logger.warning(f"Échec de la première tentative: {str(e)}") + + # Deuxième tentative avec la langue alternative + try: + logger.info(f"Tentative d'extraction avec la langue: {settings.ALTERNATIVE_OCR_LANGUAGE}") + update_job_status( + job_id=job_id, + status="processing", + message=f"Extraction avec langue {settings.ALTERNATIVE_OCR_LANGUAGE}", + progress=60 + ) + + infos, texte = extraire_infos_cheque( + chemin_image=file_path, + methode="ocr", + language=settings.ALTERNATIVE_OCR_LANGUAGE, + tessdata=tessdata_path + ) + + methode = "ocr" + + except Exception as e2: + logger.warning(f"Échec de la deuxième tentative: {str(e2)}") + + # Troisième tentative avec la méthode CV + logger.info("Tentative d'extraction avec la méthode CV (sans OCR)") + update_job_status( + job_id=job_id, + status="processing", + message="Extraction par détection de zones (sans OCR)", + progress=70 + ) + + infos, texte = extraire_infos_cheque( + chemin_image=file_path, + methode="cv" + ) + + methode = "cv" + + # Mise à jour finale + update_job_status( + job_id=job_id, + status="processing", + message="Finalisation des résultats", + progress=90 + ) + + # Sauvegarder les résultats dans Redis + update_job_status( + job_id=job_id, + status="completed", + message="Extraction terminée avec succès", + progress=100, + result=infos, + texte_brut=texte, + methode=methode + ) + + logger.info(f"Traitement terminé pour la tâche {job_id}") + + return { + "job_id": job_id, + "status": "completed", + "result": infos, + "methode": methode + } + + except Exception as e: + # Capturer l'erreur + error_trace = traceback.format_exc() + logger.error(f"Erreur lors du traitement de l'image: {str(e)}\n{error_trace}") + + # Mettre à jour le statut avec l'erreur + update_job_status( + job_id=job_id, + status="failed", + message="Échec du traitement", + erreur=str(e) + ) + + # Retourner l'erreur + return { + "job_id": job_id, + "status": "failed", + "error": str(e) + } \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..8eb2b01 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.100.0 +uvicorn==0.23.2 +python-multipart==0.0.6 +rq==1.15.1 +redis==4.6.0 +pydantic==2.3.0 +pydantic-settings==2.0.3 +python-dotenv==1.0.0 +httpx==0.24.1 +PyMuPDF==1.22.5 +opencv-python==4.8.0.74 +numpy==1.24.3 +pytesseract==0.3.10 +Pillow==10.0.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3c135b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,95 @@ +version: '3.8' + +services: + api: + build: ./api + container_name: cheque-scanner-api + ports: + - "8001:8000" + depends_on: + - redis + volumes: + - shared_data:/app/data + - ./shared:/app/shared + environment: + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_FOLDER=/app/data/uploads + - RESULT_FOLDER=/app/data/results + - TEMP_FOLDER=/app/data/tmp + - DEBUG=false + - API_KEY=${API_KEY:-your-secret-api-key} + - MAX_WORKERS=3 + restart: unless-stopped + networks: + - cheque-scanner-network + + worker: + build: ./worker + depends_on: + - redis + volumes: + - shared_data:/app/data + - ./shared:/app/shared + - ./api/app:/app/api_app + environment: + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_FOLDER=/app/data/uploads + - RESULT_FOLDER=/app/data/results + - TEMP_FOLDER=/app/data/tmp + - QUEUE_NAME=cheque_processing + - HIGH_PRIORITY_QUEUE_NAME=cheque_processing_high + - TESSERACT_DATA_PATH=/usr/share/tesseract-ocr/4.00/tessdata + - DEFAULT_OCR_LANGUAGE=eng + - ALTERNATIVE_OCR_LANGUAGE=fra + deploy: + replicas: ${WORKER_REPLICAS:-2} + restart: unless-stopped + networks: + - cheque-scanner-network + + # Service supplémentaire pour traiter les requêtes prioritaires + priority-worker: + build: ./worker + depends_on: + - redis + volumes: + - shared_data:/app/data + - ./shared:/app/shared + - ./api/app:/app/api_app + environment: + - REDIS_URL=redis://redis:6379/0 + - UPLOAD_FOLDER=/app/data/uploads + - RESULT_FOLDER=/app/data/results + - TEMP_FOLDER=/app/data/tmp + - QUEUE_NAME=cheque_processing_high + - TESSERACT_DATA_PATH=/usr/share/tesseract-ocr/4.00/tessdata + - DEFAULT_OCR_LANGUAGE=eng + - ALTERNATIVE_OCR_LANGUAGE=fra + - WORKER_NAME=priority-worker + entrypoint: ["/priority_entrypoint.sh"] + command: ["python", "worker.py", "--queues", "cheque_processing_high", "--name", "priority-worker"] + restart: unless-stopped + networks: + - cheque-scanner-network + + redis: + image: redis:alpine + container_name: cheque-scanner-redis + ports: + - "6380:6379" + volumes: + - redis_data:/data + command: ["redis-server", "--appendonly", "yes"] + restart: unless-stopped + networks: + - cheque-scanner-network + +volumes: + shared_data: + name: cheque-scanner-shared-data + redis_data: + name: cheque-scanner-redis-data + +networks: + cheque-scanner-network: + name: cheque-scanner-network \ No newline at end of file diff --git a/examples/php-integration.php b/examples/php-integration.php new file mode 100644 index 0000000..938d456 --- /dev/null +++ b/examples/php-integration.php @@ -0,0 +1,212 @@ + new CURLFile($imagePath), + 'priority' => $priority ? 'true' : 'false' + ]; + + curl_setopt_array($curl, [ + CURLOPT_URL => $api_url . '/upload', + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $postFields, + CURLOPT_HTTPHEADER => [ + 'X-API-Key: ' . $api_key + ] + ]); + + // Exécuter la requête + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + // Vérifier la réponse + if ($status != 202) { + throw new Exception("Erreur lors de l'upload: " . $response); + } + + // Décoder la réponse JSON + $result = json_decode($response, true); + + if (!isset($result['job_id'])) { + throw new Exception("Réponse invalide de l'API: " . $response); + } + + return $result['job_id']; +} + +/** + * Vérifie l'état de traitement d'une tâche + * + * @param string $jobId Identifiant de la tâche + * @return array Informations sur l'état de la tâche + * @throws Exception En cas d'erreur + */ +function checkJobStatus($jobId) { + global $api_url, $api_key; + + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $api_url . '/status/' . $jobId, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'X-API-Key: ' . $api_key + ] + ]); + + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($status != 200) { + throw new Exception("Erreur lors de la vérification du statut: " . $response); + } + + return json_decode($response, true); +} + +/** + * Récupère les résultats d'une tâche terminée + * + * @param string $jobId Identifiant de la tâche + * @return array Résultats de l'extraction + * @throws Exception En cas d'erreur + */ +function getJobResult($jobId) { + global $api_url, $api_key; + + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $api_url . '/result/' . $jobId, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => [ + 'X-API-Key: ' . $api_key + ] + ]); + + $response = curl_exec($curl); + $status = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); + + if ($status != 200) { + throw new Exception("Erreur lors de la récupération des résultats: " . $response); + } + + return json_decode($response, true); +} + +/** + * Attend que le traitement d'une tâche soit terminé + * + * @param string $jobId Identifiant de la tâche + * @param int $maxWaitTime Temps d'attente maximum en secondes + * @param int $pollInterval Intervalle de vérification en secondes + * @return array État final de la tâche + * @throws Exception Si le temps d'attente est dépassé ou en cas d'erreur + */ +function waitForJobCompletion($jobId, $maxWaitTime = 300, $pollInterval = 2) { + $startTime = time(); + $status = ['status' => 'pending']; + + while (($status['status'] == 'pending' || $status['status'] == 'processing') && + (time() - $startTime < $maxWaitTime)) { + + // Attendre avant de vérifier à nouveau + sleep($pollInterval); + + // Vérifier l'état + $status = checkJobStatus($jobId); + + // Afficher la progression + if (isset($status['progress'])) { + echo "Progression: " . $status['progress'] . "% - " . $status['message'] . "\n"; + } else { + echo "État: " . $status['status'] . " - " . $status['message'] . "\n"; + } + } + + if ($status['status'] != 'completed' && $status['status'] != 'failed') { + throw new Exception("Délai d'attente dépassé pour la tâche " . $jobId); + } + + return $status; +} + +// Exemple d'utilisation +try { + // Chemin de l'image à traiter + $imagePath = '/home/alex/Téléchargements/pymupdf/test_qheque-PXL_2025.jpg'; + + echo "=== EXTRACTION D'INFORMATIONS DE CHÈQUE ===\n"; + + // 1. Envoyer l'image + echo "Envoi de l'image: " . $imagePath . "\n"; + $jobId = uploadChequeImage($imagePath, true); // true pour un traitement prioritaire + echo "Image envoyée, ID de la tâche: " . $jobId . "\n\n"; + + // 2. Attendre la fin du traitement + echo "Attente du traitement...\n"; + $finalStatus = waitForJobCompletion($jobId); + + // 3. Si le traitement est terminé avec succès, récupérer les résultats + if ($finalStatus['status'] == 'completed') { + echo "\nTraitement terminé avec succès!\n"; + + // Récupérer les résultats détaillés + $results = getJobResult($jobId); + + echo "\n=== RÉSULTATS DE L'EXTRACTION ===\n"; + if (isset($results['result'])) { + echo "Montant: " . ($results['result']['montant'] ?? 'Non détecté') . "\n"; + echo "Date: " . ($results['result']['date'] ?? 'Non détectée') . "\n"; + echo "Bénéficiaire: " . ($results['result']['beneficiaire'] ?? 'Non détecté') . "\n"; + echo "Numéro de chèque: " . ($results['result']['numero_cheque'] ?? 'Non détecté') . "\n"; + echo "Qualité de l'extraction: " . ($results['result']['qualite_extraction'] ?? 'Inconnue') . "\n"; + + if (isset($results['result']['image_zones'])) { + echo "Image avec zones identifiées: " . $results['result']['image_zones'] . "\n"; + } + } + + echo "\nMéthode utilisée: " . $results['methode'] . "\n"; + + if (isset($results['texte_brut']) && !empty($results['texte_brut'])) { + echo "\n=== TEXTE BRUT EXTRAIT ===\n"; + echo $results['texte_brut'] . "\n"; + } + } else { + echo "\nÉchec du traitement: " . ($results['erreur'] ?? 'Erreur inconnue') . "\n"; + } +} catch (Exception $e) { + echo "ERREUR: " . $e->getMessage() . "\n"; +} \ No newline at end of file diff --git a/shared/extraction/__init__.py b/shared/extraction/__init__.py new file mode 100644 index 0000000..35d6eac --- /dev/null +++ b/shared/extraction/__init__.py @@ -0,0 +1,25 @@ +""" +Package pour l'extraction d'informations à partir d'images de chèques. +""" + +from .scanner import ( + extraire_infos_cheque, + extraire_par_ocr, + extraire_par_cv, + extraire_montant, + extraire_date, + extraire_beneficiaire, + extraire_numero_cheque, + get_tessdata_path +) + +__all__ = [ + 'extraire_infos_cheque', + 'extraire_par_ocr', + 'extraire_par_cv', + 'extraire_montant', + 'extraire_date', + 'extraire_beneficiaire', + 'extraire_numero_cheque', + 'get_tessdata_path' +] \ No newline at end of file diff --git a/shared/extraction/scanner.py b/shared/extraction/scanner.py new file mode 100644 index 0000000..f0d222d --- /dev/null +++ b/shared/extraction/scanner.py @@ -0,0 +1,294 @@ +""" +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 +from pathlib import Path +from typing import Dict, Tuple, Optional, Any, Union + +# 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 = "ocr", + language: str = "eng", + 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" (utilise PyMuPDF+Tesseract) ou "cv" (utilise OpenCV sans OCR) + language: Code de langue pour OCR (eng par défaut, utiliser fra pour français si disponible) + 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}") + + if 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) + else: + logger.info("Utilisation de la méthode de traitement d'image sans OCR") + return extraire_par_cv(chemin_image) + + +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 + img = np.frombuffer(pixmap.samples, dtype=np.uint8).reshape(pixmap.h, pixmap.w, 3) + + # Prétraitement pour améliorer la détection de texte + gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + blurred = cv2.GaussianBlur(gray, (5, 5), 0) + _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + # 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) + + # Charger l'image prétraitée + pixmap_processed = fitz.Pixmap(tmp_path) + + try: + # 4. Effectuer l'OCR et générer un PDF avec une couche de texte + 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) + + # 5. Charger le PDF OCR et extraire le texte + 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""" + + # Charger l'image + img = cv2.imread(chemin_image) + + if img is None: + raise ValueError(f"Impossible de charger l'image: {chemin_image}") + + # Prétraitement + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + blurred = cv2.GaussianBlur(gray, (5, 5), 0) + thresh = cv2.adaptiveThreshold(blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, + cv2.THRESH_BINARY_INV, 11, 2) + + # Trouver les contours (rectangles potentiels des zones importantes) + contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Trier les contours par taille (du plus grand au plus petit) + contours = sorted(contours, key=cv2.contourArea, reverse=True) + + # Estimation des zones d'intérêt basée sur la position relative + # (ceci est approximatif et dépend du format exact du chèque) + height, width = img.shape[:2] + + # 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)) + + # Pour la démonstration, créer une image avec les zones identifiées + img_zones = img.copy() + cv2.rectangle(img_zones, (zone_montant[0], zone_montant[1]), + (zone_montant[2], zone_montant[3]), (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.rectangle(img_zones, (zone_beneficiaire[0], zone_beneficiaire[1]), + (zone_beneficiaire[2], zone_beneficiaire[3]), (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}") + + # Sans OCR, nous ne pouvons pas extraire le texte directement, + # mais nous pouvons indiquer les zones où se trouvent les informations + 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 + } + + return infos, f"Texte non disponible sans OCR - visualisation des zones générée dans {image_result_path}" + + +# 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 €" + motifs = [ + r'(\d+[.,]\d{2})\s*€', # 123,45 € + r'€\s*(\d+[.,]\d{2})', # € 123,45 + r'(\d+)\s*€' # 123 € + ] + + 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 + match = re.search(r'(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})', texte) + 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 + match = re.search(r'(?:Payez|Payé)(?:\s+contre\s+ce\s+chèque)?\s+(?:à|au?)\s+([A-Z\s]+)', + 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) + match = re.search(r'N°\s*(\d{7})', texte) + if match: + return match.group(1) + + # Autre format possible + match = re.search(r'(?:chèque|cheque)\s*(?:n[o°]?)?\s*(\d{7})', 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""" + + # Compter le nombre de champs extraits avec succès + champs_extraits = sum(1 for v in infos.values() if v is not None) + total_champs = 4 # montant, date, beneficiaire, numero_cheque + + if champs_extraits == 0: + return "échec" + elif champs_extraits < 2: + return "faible" + elif champs_extraits < 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 \ No newline at end of file diff --git a/worker/Dockerfile b/worker/Dockerfile new file mode 100644 index 0000000..f4a51c2 --- /dev/null +++ b/worker/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.10-slim + +WORKDIR /app + +# Installation des dépendances système et Tesseract OCR +RUN apt-get update && apt-get install -y \ + gcc \ + python3-dev \ + tesseract-ocr \ + tesseract-ocr-fra \ + tesseract-ocr-eng \ + libgl1-mesa-glx \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# Copie et installation des dépendances Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copie du code de l'application +COPY ./app . +COPY ./app/entrypoint.sh /entrypoint.sh +COPY ./app/priority_entrypoint.sh /priority_entrypoint.sh +RUN chmod +x /entrypoint.sh /priority_entrypoint.sh + +# Variables d'environnement +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV TESSERACT_DATA_PATH=/usr/share/tesseract-ocr/4.00/tessdata + +# Commande d'exécution +ENTRYPOINT ["/entrypoint.sh"] +CMD ["python", "worker.py", "--queues", "cheque_processing"] \ No newline at end of file diff --git a/worker/app/__init__.py b/worker/app/__init__.py new file mode 100644 index 0000000..a8acdb2 --- /dev/null +++ b/worker/app/__init__.py @@ -0,0 +1,14 @@ +""" +Module d'initialisation du worker +""" + +import logging + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("cheque_scanner_worker_module") + +logger.info("Module worker initialisé") \ No newline at end of file diff --git a/worker/app/app/__init__.py b/worker/app/app/__init__.py new file mode 100644 index 0000000..fe18c8a --- /dev/null +++ b/worker/app/app/__init__.py @@ -0,0 +1,14 @@ +""" +Module proxy pour l'API +""" + +import logging + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("app.proxy") + +logger.info("Module proxy app initialisé") \ No newline at end of file diff --git a/worker/app/app/tasks/__init__.py b/worker/app/app/tasks/__init__.py new file mode 100644 index 0000000..4cf4921 --- /dev/null +++ b/worker/app/app/tasks/__init__.py @@ -0,0 +1,37 @@ +""" +Module proxy pour importer process_cheque_image depuis le module tasks parent +""" + +import sys +import os +import logging + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("app.tasks.proxy") + +# Importer la fonction process_cheque_image depuis le module tasks parent +logger.info("Initialisation du module proxy app.tasks") + +# Ajouter le répertoire parent au path pour pouvoir importer tasks +parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../')) +sys.path.insert(0, parent_dir) + +try: + # Importer depuis le module tasks parent + from tasks import process_cheque_image + logger.info("Fonction process_cheque_image importée avec succès") +except ImportError as e: + logger.error(f"Erreur lors de l'importation de process_cheque_image: {str(e)}") + # Créer une fonction de secours qui enregistre l'erreur + def process_cheque_image(job_id, file_path): + logger.error(f"Appel à la fonction de secours process_cheque_image: {job_id}, {file_path}") + raise RuntimeError(f"Impossible d'importer la véritable fonction process_cheque_image: {str(e)}") + +# Exporter la fonction pour qu'elle soit accessible via app.tasks.process_cheque_image +__all__ = ['process_cheque_image'] + +logger.info("Module proxy app.tasks initialisé") \ No newline at end of file diff --git a/worker/app/entrypoint.sh b/worker/app/entrypoint.sh new file mode 100644 index 0000000..c939da0 --- /dev/null +++ b/worker/app/entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +# Suppression de la vérification Redis qui nécessite redis-cli +echo "Démarrage du worker..." + +# Créer les dossiers nécessaires s'ils n'existent pas +mkdir -p /app/data/uploads +mkdir -p /app/data/results +mkdir -p /app/data/tmp + +# Définir les permissions +chmod -R 755 /app/data + +# Exécuter la commande spécifiée +exec "$@" \ No newline at end of file diff --git a/worker/app/priority_entrypoint.sh b/worker/app/priority_entrypoint.sh new file mode 100755 index 0000000..4ce0883 --- /dev/null +++ b/worker/app/priority_entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +# Suppression de la vérification Redis qui nécessite redis-cli +echo "Démarrage du worker prioritaire..." + +# Créer les dossiers nécessaires s'ils n'existent pas +mkdir -p /app/data/uploads +mkdir -p /app/data/results +mkdir -p /app/data/tmp + +# Définir les permissions +chmod -R 755 /app/data + +# Exécuter la commande spécifiée +exec "$@" \ No newline at end of file diff --git a/worker/app/tasks.py b/worker/app/tasks.py new file mode 100644 index 0000000..957bfb5 --- /dev/null +++ b/worker/app/tasks.py @@ -0,0 +1,232 @@ +""" +Tâches de traitement pour le worker Cheque Scanner +""" + +import os +import sys +import time +import json +import logging +import traceback +import redis +from datetime import datetime +from rq import get_current_job + +# Ajouter le module d'extraction au path +sys.path.append('/app/shared') + +# Importer les fonctions d'extraction +from extraction import ( + extraire_infos_cheque, + get_tessdata_path +) + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("cheque_scanner_tasks") + +# Variables d'environnement +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +DEFAULT_OCR_LANGUAGE = os.getenv("DEFAULT_OCR_LANGUAGE", "eng") +ALTERNATIVE_OCR_LANGUAGE = os.getenv("ALTERNATIVE_OCR_LANGUAGE", "fra") +TESSERACT_DATA_PATH = os.getenv("TESSERACT_DATA_PATH", "/usr/share/tesseract-ocr/4.00/tessdata") + + +def update_job_status(job_id, status, message=None, progress=None, result=None, texte_brut=None, erreur=None, methode=None): + """ + Met à jour le statut d'une tâche dans Redis + """ + try: + # Connexion à Redis + redis_conn = redis.Redis.from_url(REDIS_URL) + + # Préparer les données à mettre à jour + update_data = { + "status": status, + "updated_at": datetime.now().isoformat() + } + + if message: + update_data["message"] = message + + if progress: + update_data["progress"] = str(progress) + + if result: + update_data["result"] = str(result) + + if texte_brut: + update_data["texte_brut"] = texte_brut + + if erreur: + update_data["erreur"] = erreur + + if methode: + update_data["methode"] = methode + + if status == "completed": + update_data["completed_at"] = datetime.now().isoformat() + + # Mettre à jour les données dans Redis + redis_conn.hset(f"job:{job_id}", mapping=update_data) + + logger.info(f"Statut de la tâche {job_id} mis à jour: {status}") + + return True + + except Exception as e: + logger.error(f"Erreur lors de la mise à jour du statut de la tâche {job_id}: {str(e)}") + return False + + +def process_cheque_image(file_path, job_id): + """ + Traite une image de chèque pour en extraire les informations + + Args: + file_path (str): Chemin vers l'image à traiter + job_id (str): Identifiant de la tâche + + Returns: + dict: Résultat de l'extraction + """ + job = get_current_job() + logger.info(f"Début du traitement de l'image: {file_path} (Tâche: {job_id})") + + # Mettre à jour le statut + update_job_status( + job_id=job_id, + status="processing", + message="Traitement en cours", + progress=10 + ) + + try: + # Vérifier que le fichier existe + if not os.path.exists(file_path): + raise FileNotFoundError(f"L'image {file_path} n'existe pas") + + # Récupérer le chemin vers tessdata + tessdata_path = TESSERACT_DATA_PATH + if not os.path.exists(tessdata_path): + # Essayer de trouver automatiquement + tessdata_path = get_tessdata_path() + + # Mise à jour intermédiaire + update_job_status( + job_id=job_id, + status="processing", + message="Extraction des informations en cours", + progress=30 + ) + + # Première tentative avec la langue par défaut + try: + logger.info(f"Tentative d'extraction avec la langue: {DEFAULT_OCR_LANGUAGE}") + update_job_status( + job_id=job_id, + status="processing", + message=f"Extraction avec langue {DEFAULT_OCR_LANGUAGE}", + progress=50 + ) + + infos, texte = extraire_infos_cheque( + chemin_image=file_path, + methode="ocr", + language=DEFAULT_OCR_LANGUAGE, + tessdata=tessdata_path + ) + + methode = "ocr" + + except Exception as e: + logger.warning(f"Échec de la première tentative: {str(e)}") + + # Deuxième tentative avec la langue alternative + try: + logger.info(f"Tentative d'extraction avec la langue: {ALTERNATIVE_OCR_LANGUAGE}") + update_job_status( + job_id=job_id, + status="processing", + message=f"Extraction avec langue {ALTERNATIVE_OCR_LANGUAGE}", + progress=60 + ) + + infos, texte = extraire_infos_cheque( + chemin_image=file_path, + methode="ocr", + language=ALTERNATIVE_OCR_LANGUAGE, + tessdata=tessdata_path + ) + + methode = "ocr" + + except Exception as e2: + logger.warning(f"Échec de la deuxième tentative: {str(e2)}") + + # Troisième tentative avec la méthode CV + logger.info("Tentative d'extraction avec la méthode CV (sans OCR)") + update_job_status( + job_id=job_id, + status="processing", + message="Extraction par détection de zones (sans OCR)", + progress=70 + ) + + infos, texte = extraire_infos_cheque( + chemin_image=file_path, + methode="cv" + ) + + methode = "cv" + + # Mise à jour finale + update_job_status( + job_id=job_id, + status="processing", + message="Finalisation des résultats", + progress=90 + ) + + # Sauvegarder les résultats dans Redis + update_job_status( + job_id=job_id, + status="completed", + message="Extraction terminée avec succès", + progress=100, + result=infos, + texte_brut=texte, + methode=methode + ) + + logger.info(f"Traitement terminé pour la tâche {job_id}") + + return { + "job_id": job_id, + "status": "completed", + "result": infos, + "methode": methode + } + + except Exception as e: + # Capturer l'erreur + error_trace = traceback.format_exc() + logger.error(f"Erreur lors du traitement de l'image: {str(e)}\n{error_trace}") + + # Mettre à jour le statut avec l'erreur + update_job_status( + job_id=job_id, + status="failed", + message="Échec du traitement", + erreur=str(e) + ) + + # Retourner l'erreur + return { + "job_id": job_id, + "status": "failed", + "error": str(e) + } \ No newline at end of file diff --git a/worker/app/worker.py b/worker/app/worker.py new file mode 100644 index 0000000..abf4f6f --- /dev/null +++ b/worker/app/worker.py @@ -0,0 +1,142 @@ +""" +Worker principal pour le traitement des images de chèques +""" + +import os +import sys +import time +import redis +import logging +import signal +import argparse +import importlib +from rq import Connection, Worker, Queue +from rq.job import Job +from rq.worker import SimpleWorker +from dotenv import load_dotenv + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("cheque_scanner_worker") + +# Ajouter les modules au path +sys.path.append('/app/shared') +sys.path.append('/app/api_app') +sys.path.append('/app') + +# Importer notre module tasks local +try: + from tasks import process_cheque_image + logger.info("Fonction process_cheque_image importée avec succès") +except ImportError as e: + logger.error(f"Erreur lors de l'importation directe de process_cheque_image: {str(e)}") + +# Charger les variables d'environnement +load_dotenv() + +# Classe Worker personnalisée qui remplace l'importation de fonction +class CustomWorker(SimpleWorker): + def perform_job(self, job, queue): + """ + Remplace l'importation de app.tasks.process_cheque_image par notre fonction locale + """ + # Si la tâche est app.tasks.process_cheque_image, remplacer par notre fonction locale + if job.func_name == 'app.tasks.process_cheque_image' or job.func_name == 'process_cheque_image' or job.func_name == 'tasks.process_cheque_image': + job.func_name = 'tasks.process_cheque_image' + job._func = process_cheque_image + logger.info(f"Fonction remplacée pour la tâche {job.id}") + + return super().perform_job(job, queue) + +# Variables d'environnement +REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0") +QUEUE_NAME = os.getenv("QUEUE_NAME", "cheque_processing") +HIGH_PRIORITY_QUEUE_NAME = os.getenv("HIGH_PRIORITY_QUEUE_NAME", "cheque_processing_high") +WORKER_NAME = os.getenv("WORKER_NAME", f"worker-{os.getpid()}") + +# Créer les dossiers nécessaires +UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "/app/data/uploads") +RESULT_FOLDER = os.getenv("RESULT_FOLDER", "/app/data/results") +TEMP_FOLDER = os.getenv("TEMP_FOLDER", "/app/data/tmp") + +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(RESULT_FOLDER, exist_ok=True) +os.makedirs(TEMP_FOLDER, exist_ok=True) + + +def signal_handler(signum, frame): + """ + Gestionnaire de signal pour arrêter proprement le worker + """ + logger.info(f"Signal {signum} reçu, arrêt du worker...") + sys.exit(0) + + +def start_worker(queues, worker_name=None): + """ + Démarre un worker pour traiter les tâches des files d'attente spécifiées + + Args: + queues (list): Liste des noms de files d'attente à traiter + worker_name (str, optional): Nom du worker. Si None, un nom sera généré + """ + if not worker_name: + worker_name = WORKER_NAME + + logger.info(f"Démarrage du worker '{worker_name}' sur les files: {', '.join(queues)}") + + # Connexion à Redis + redis_conn = redis.Redis.from_url(REDIS_URL) + + try: + # Vérifier la connexion à Redis + redis_conn.ping() + logger.info("Connexion à Redis établie") + except redis.exceptions.ConnectionError: + logger.error(f"Impossible de se connecter à Redis: {REDIS_URL}") + sys.exit(1) + + # Configurer le gestionnaire de signal + signal.signal(signal.SIGTERM, signal_handler) + signal.signal(signal.SIGINT, signal_handler) + + # Vérifier la connexion à Redis + logger.info("Vérification de la connexion à Redis") + + # Démarrer le worker avec notre classe personnalisée + with Connection(redis_conn): + # Créer les queues + worker_queues = [Queue(name, connection=redis_conn) for name in queues] + + # Créer le worker personnalisé + worker = CustomWorker( + worker_queues, + name=worker_name + ) + + logger.info(f"Worker '{worker_name}' prêt à traiter les tâches") + worker.work(with_scheduler=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Worker pour le traitement des images de chèques") + parser.add_argument( + "--queues", + nargs="+", + default=[HIGH_PRIORITY_QUEUE_NAME, QUEUE_NAME], + help="Liste des files d'attente à traiter (par ordre de priorité)" + ) + parser.add_argument( + "--name", + type=str, + default=None, + help="Nom du worker" + ) + + args = parser.parse_args() + + # Démarrer le worker + start_worker(args.queues, args.name) \ No newline at end of file diff --git a/worker/requirements.txt b/worker/requirements.txt new file mode 100644 index 0000000..bc17982 --- /dev/null +++ b/worker/requirements.txt @@ -0,0 +1,8 @@ +rq==1.15.1 +redis==4.6.0 +PyMuPDF==1.22.5 +opencv-python==4.8.0.74 +numpy==1.24.3 +python-dotenv==1.0.0 +pytesseract==0.3.10 +Pillow==10.0.0 \ No newline at end of file