First commit

This commit is contained in:
2025-07-09 02:12:06 +02:00
parent 1a7946495c
commit a5e044d747
23 changed files with 1967 additions and 0 deletions

27
.env Normal file
View File

@@ -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

34
api/Dockerfile Normal file
View File

@@ -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"]

3
api/app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Package pour l'API Cheque Scanner
"""

69
api/app/config.py Normal file
View File

@@ -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)

45
api/app/dependencies.py Normal file
View File

@@ -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

11
api/app/entrypoint.sh Normal file
View File

@@ -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 "$@"

321
api/app/main.py Normal file
View File

@@ -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
)

77
api/app/schemas.py Normal file
View File

@@ -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")

228
api/app/tasks.py Normal file
View File

@@ -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)
}

14
api/requirements.txt Normal file
View File

@@ -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

95
docker-compose.yml Normal file
View File

@@ -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

View File

@@ -0,0 +1,212 @@
<?php
/**
* Exemple d'intégration PHP avec l'API Cheque Scanner
*
* Ce script démontre comment :
* 1. Envoyer une image de chèque à l'API
* 2. Vérifier périodiquement l'état du traitement
* 3. Récupérer les résultats une fois le traitement terminé
*/
// Configuration de l'API
$api_url = 'http://localhost:8000/api/v1'; // URL de l'API
$api_key = 'your-secret-api-key-change-me'; // Clé API (doit correspondre à celle définie dans .env)
/**
* Envoie une image de chèque à l'API et récupère l'ID de la tâche
*
* @param string $imagePath Chemin local vers l'image à envoyer
* @param bool $priority Définir à true pour un traitement prioritaire
* @return string L'identifiant de la tâche créée
* @throws Exception En cas d'erreur
*/
function uploadChequeImage($imagePath, $priority = false) {
global $api_url, $api_key;
// Vérifier que le fichier existe
if (!file_exists($imagePath)) {
throw new Exception("Le fichier n'existe pas: " . $imagePath);
}
// Préparer la requête
$curl = curl_init();
$postFields = [
'file' => 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";
}

View File

@@ -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'
]

View File

@@ -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'\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

33
worker/Dockerfile Normal file
View File

@@ -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"]

14
worker/app/__init__.py Normal file
View File

@@ -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é")

View File

@@ -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é")

View File

@@ -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é")

16
worker/app/entrypoint.sh Normal file
View File

@@ -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 "$@"

View File

@@ -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 "$@"

232
worker/app/tasks.py Normal file
View File

@@ -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)
}

142
worker/app/worker.py Normal file
View File

@@ -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)

8
worker/requirements.txt Normal file
View File

@@ -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