mirc detection
This commit is contained in:
@@ -39,12 +39,20 @@ class Settings(BaseSettings):
|
|||||||
UPLOAD_FOLDER: str = os.getenv("UPLOAD_FOLDER", "/app/data/uploads")
|
UPLOAD_FOLDER: str = os.getenv("UPLOAD_FOLDER", "/app/data/uploads")
|
||||||
RESULT_FOLDER: str = os.getenv("RESULT_FOLDER", "/app/data/results")
|
RESULT_FOLDER: str = os.getenv("RESULT_FOLDER", "/app/data/results")
|
||||||
TEMP_FOLDER: str = os.getenv("TEMP_FOLDER", "/app/data/tmp")
|
TEMP_FOLDER: str = os.getenv("TEMP_FOLDER", "/app/data/tmp")
|
||||||
|
CONVERTED_FOLDER: str = os.getenv("CONVERTED_FOLDER", "/app/data/converted")
|
||||||
MAX_CONTENT_LENGTH: int = int(os.getenv("MAX_CONTENT_LENGTH", "16777216")) # 16MB
|
MAX_CONTENT_LENGTH: int = int(os.getenv("MAX_CONTENT_LENGTH", "16777216")) # 16MB
|
||||||
ALLOWED_EXTENSIONS: set = {"png", "jpg", "jpeg", "gif", "tiff", "pdf"}
|
ALLOWED_EXTENSIONS: set = {"png", "jpg", "jpeg", "gif", "tiff", "pdf"}
|
||||||
|
|
||||||
|
# Formats modernes (nécessitant conversion)
|
||||||
|
MODERN_EXTENSIONS: set = {"heic", "heif", "webp", "dng", "cr2", "arw", "nef", "raw"}
|
||||||
|
|
||||||
|
# Configuration de conversion
|
||||||
|
CONVERSION_QUALITY: int = int(os.getenv("CONVERSION_QUALITY", "95")) # Qualité JPEG/PNG
|
||||||
|
DEFAULT_OUTPUT_FORMAT: str = os.getenv("DEFAULT_OUTPUT_FORMAT", "jpg")
|
||||||
|
|
||||||
# Configuration du traitement d'image
|
# Configuration du traitement d'image
|
||||||
DEFAULT_OCR_LANGUAGE: str = os.getenv("DEFAULT_OCR_LANGUAGE", "eng")
|
DEFAULT_OCR_LANGUAGE: str = os.getenv("DEFAULT_OCR_LANGUAGE", "fra")
|
||||||
ALTERNATIVE_OCR_LANGUAGE: str = os.getenv("ALTERNATIVE_OCR_LANGUAGE", "fra")
|
ALTERNATIVE_OCR_LANGUAGE: str = os.getenv("ALTERNATIVE_OCR_LANGUAGE", "eng")
|
||||||
TESSERACT_DATA_PATH: str = os.getenv("TESSERACT_DATA_PATH", "/usr/share/tesseract-ocr/4.00/tessdata")
|
TESSERACT_DATA_PATH: str = os.getenv("TESSERACT_DATA_PATH", "/usr/share/tesseract-ocr/4.00/tessdata")
|
||||||
|
|
||||||
# Délais et timeouts
|
# Délais et timeouts
|
||||||
@@ -67,3 +75,4 @@ settings = Settings()
|
|||||||
os.makedirs(settings.UPLOAD_FOLDER, exist_ok=True)
|
os.makedirs(settings.UPLOAD_FOLDER, exist_ok=True)
|
||||||
os.makedirs(settings.RESULT_FOLDER, exist_ok=True)
|
os.makedirs(settings.RESULT_FOLDER, exist_ok=True)
|
||||||
os.makedirs(settings.TEMP_FOLDER, exist_ok=True)
|
os.makedirs(settings.TEMP_FOLDER, exist_ok=True)
|
||||||
|
os.makedirs(settings.CONVERTED_FOLDER, exist_ok=True)
|
||||||
238
api/app/conversion.py
Normal file
238
api/app/conversion.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""
|
||||||
|
Module de conversion pour les formats d'images modernes
|
||||||
|
Supporte les formats courants des appareils Android et iOS
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
|
||||||
|
# Configuration du logging
|
||||||
|
logger = logging.getLogger("cheque_scanner_api.conversion")
|
||||||
|
|
||||||
|
# Formats modernes pris en charge
|
||||||
|
MODERN_FORMATS = {
|
||||||
|
# Formats iPhone
|
||||||
|
"heic": "HEIC (High Efficiency Image Format) - iPhone",
|
||||||
|
"heif": "HEIF (High Efficiency Image Format) - iPhone",
|
||||||
|
|
||||||
|
# Formats Android haute qualité
|
||||||
|
"webp": "WebP - Android",
|
||||||
|
"dng": "DNG (Digital Negative) - Android RAW",
|
||||||
|
|
||||||
|
# Formats communs haute résolution
|
||||||
|
"cr2": "Canon RAW Format",
|
||||||
|
"arw": "Sony RAW Format",
|
||||||
|
"nef": "Nikon RAW Format",
|
||||||
|
"raw": "Format RAW générique",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mapping des extensions aux formats de sortie recommandés
|
||||||
|
FORMAT_MAPPING = {
|
||||||
|
"heic": "jpg",
|
||||||
|
"heif": "jpg",
|
||||||
|
"webp": "png",
|
||||||
|
"dng": "jpg",
|
||||||
|
"cr2": "jpg",
|
||||||
|
"arw": "jpg",
|
||||||
|
"nef": "jpg",
|
||||||
|
"raw": "jpg",
|
||||||
|
}
|
||||||
|
|
||||||
|
def is_modern_format(filename: str) -> bool:
|
||||||
|
"""
|
||||||
|
Vérifie si le fichier est dans un format moderne qui nécessite une conversion
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename (str): Nom du fichier à vérifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True si le fichier est dans un format moderne, False sinon
|
||||||
|
"""
|
||||||
|
ext = Path(filename).suffix.lower().lstrip(".")
|
||||||
|
return ext in MODERN_FORMATS
|
||||||
|
|
||||||
|
def get_supported_input_formats() -> List[str]:
|
||||||
|
"""
|
||||||
|
Renvoie la liste des formats d'entrée pris en charge
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List[str]: Liste des extensions de fichiers pris en charge
|
||||||
|
"""
|
||||||
|
return list(MODERN_FORMATS.keys()) + list(settings.ALLOWED_EXTENSIONS)
|
||||||
|
|
||||||
|
def convert_heic_to_jpg(input_path: str, output_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Convertit un fichier HEIC en JPG en utilisant pillow-heif
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path (str): Chemin du fichier HEIC
|
||||||
|
output_path (str): Chemin de sortie pour le fichier JPG
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True si la conversion a réussi, False sinon
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import pillow_heif
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# Enregistrer le décodeur HEIF
|
||||||
|
pillow_heif.register_heif_opener()
|
||||||
|
|
||||||
|
# Ouvrir et convertir l'image
|
||||||
|
img = Image.open(input_path)
|
||||||
|
img.convert("RGB").save(output_path, format="JPEG", quality=95)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("pillow-heif n'est pas installé, utilisation de la méthode alternative")
|
||||||
|
return _convert_with_imagemagick(input_path, output_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la conversion HEIC: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert_webp_to_png(input_path: str, output_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Convertit un fichier WebP en PNG
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path (str): Chemin du fichier WebP
|
||||||
|
output_path (str): Chemin de sortie pour le fichier PNG
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True si la conversion a réussi, False sinon
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
img = Image.open(input_path)
|
||||||
|
img.save(output_path, format="PNG")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la conversion WebP: {str(e)}")
|
||||||
|
return _convert_with_imagemagick(input_path, output_path)
|
||||||
|
|
||||||
|
def _convert_with_imagemagick(input_path: str, output_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Méthode alternative utilisant ImageMagick pour la conversion
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path (str): Chemin du fichier d'entrée
|
||||||
|
output_path (str): Chemin du fichier de sortie
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True si la conversion a réussi, False sinon
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Vérifier si ImageMagick est installé
|
||||||
|
subprocess.run(["which", "convert"], check=True, capture_output=True)
|
||||||
|
|
||||||
|
# Conversion avec ImageMagick
|
||||||
|
result = subprocess.run(
|
||||||
|
["convert", input_path, output_path],
|
||||||
|
check=True,
|
||||||
|
capture_output=True
|
||||||
|
)
|
||||||
|
return result.returncode == 0
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Erreur ImageMagick: {e.stderr.decode() if e.stderr else str(e)}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la conversion avec ImageMagick: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def convert_raw_to_jpg(input_path: str, output_path: str) -> bool:
|
||||||
|
"""
|
||||||
|
Convertit un fichier RAW (DNG, CR2, ARW, NEF, etc.) en JPG
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path (str): Chemin du fichier RAW
|
||||||
|
output_path (str): Chemin de sortie pour le fichier JPG
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True si la conversion a réussi, False sinon
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Essayer d'abord avec rawpy
|
||||||
|
import rawpy
|
||||||
|
import imageio
|
||||||
|
|
||||||
|
with rawpy.imread(input_path) as raw:
|
||||||
|
rgb = raw.postprocess(use_camera_wb=True, half_size=False, no_auto_bright=False)
|
||||||
|
imageio.imsave(output_path, rgb)
|
||||||
|
return True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("rawpy n'est pas installé, utilisation de la méthode alternative")
|
||||||
|
return _convert_with_imagemagick(input_path, output_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la conversion RAW: {str(e)}")
|
||||||
|
return _convert_with_imagemagick(input_path, output_path)
|
||||||
|
|
||||||
|
def convert_file(input_path: str, output_dir: str = None) -> Tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
"""
|
||||||
|
Convertit un fichier d'un format moderne vers un format standard
|
||||||
|
|
||||||
|
Args:
|
||||||
|
input_path (str): Chemin du fichier à convertir
|
||||||
|
output_dir (str, optional): Répertoire de sortie pour le fichier converti
|
||||||
|
Si non spécifié, utilise le même répertoire que le fichier d'entrée
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple[bool, Optional[str], Optional[str]]:
|
||||||
|
- Succès de la conversion (bool)
|
||||||
|
- Chemin du fichier converti (str ou None si échec)
|
||||||
|
- Message d'erreur (str ou None si succès)
|
||||||
|
"""
|
||||||
|
input_path = Path(input_path)
|
||||||
|
|
||||||
|
# Vérifier si le fichier existe
|
||||||
|
if not input_path.exists():
|
||||||
|
return False, None, f"Le fichier {input_path} n'existe pas"
|
||||||
|
|
||||||
|
# Obtenir l'extension du fichier
|
||||||
|
ext = input_path.suffix.lower().lstrip(".")
|
||||||
|
|
||||||
|
# Si l'extension est déjà dans les formats autorisés, pas besoin de conversion
|
||||||
|
if ext in settings.ALLOWED_EXTENSIONS:
|
||||||
|
return True, str(input_path), None
|
||||||
|
|
||||||
|
# Vérifier si le format est pris en charge
|
||||||
|
if ext not in MODERN_FORMATS:
|
||||||
|
return False, None, f"Format non pris en charge: {ext}"
|
||||||
|
|
||||||
|
# Déterminer le format de sortie
|
||||||
|
output_ext = FORMAT_MAPPING.get(ext, "jpg")
|
||||||
|
|
||||||
|
# Déterminer le chemin de sortie
|
||||||
|
if output_dir:
|
||||||
|
output_dir = Path(output_dir)
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
output_path = output_dir / f"{input_path.stem}.{output_ext}"
|
||||||
|
else:
|
||||||
|
output_path = input_path.with_suffix(f".{output_ext}")
|
||||||
|
|
||||||
|
# Effectuer la conversion en fonction du format
|
||||||
|
success = False
|
||||||
|
|
||||||
|
if ext in ["heic", "heif"]:
|
||||||
|
success = convert_heic_to_jpg(str(input_path), str(output_path))
|
||||||
|
elif ext == "webp":
|
||||||
|
success = convert_webp_to_png(str(input_path), str(output_path))
|
||||||
|
elif ext in ["dng", "cr2", "arw", "nef", "raw"]:
|
||||||
|
success = convert_raw_to_jpg(str(input_path), str(output_path))
|
||||||
|
else:
|
||||||
|
# Fallback sur ImageMagick pour les autres formats
|
||||||
|
success = _convert_with_imagemagick(str(input_path), str(output_path))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return True, str(output_path), None
|
||||||
|
else:
|
||||||
|
return False, None, f"Échec de la conversion du fichier {input_path}"
|
||||||
264
api/app/main.py
264
api/app/main.py
@@ -6,12 +6,14 @@ import os
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
import shutil
|
import shutil
|
||||||
|
import json
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Header, BackgroundTasks, Query, Request
|
from fastapi import FastAPI, File, UploadFile, HTTPException, Depends, Header, BackgroundTasks, Query, Request, Path
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path as FilePath
|
||||||
import redis
|
import redis
|
||||||
from rq import Queue
|
from rq import Queue
|
||||||
from rq.job import Job
|
from rq.job import Job
|
||||||
@@ -21,8 +23,10 @@ import logging
|
|||||||
from .config import settings
|
from .config import settings
|
||||||
from .schemas import (
|
from .schemas import (
|
||||||
UploadResponse, JobStatusResponse, JobResult,
|
UploadResponse, JobStatusResponse, JobResult,
|
||||||
ExtractionResult, HealthCheck, ErrorResponse, JobStatus
|
ExtractionResult, HealthCheck, ErrorResponse, JobStatus,
|
||||||
|
ConversionResponse, ConversionStatus
|
||||||
)
|
)
|
||||||
|
from .conversion import is_modern_format, convert_file, get_supported_input_formats
|
||||||
from .dependencies import verify_api_key, get_redis_connection
|
from .dependencies import verify_api_key, get_redis_connection
|
||||||
from .tasks import process_cheque_image
|
from .tasks import process_cheque_image
|
||||||
|
|
||||||
@@ -52,8 +56,9 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configuration des fichiers statiques pour accéder aux images de résultats
|
# Configuration des fichiers statiques pour accéder aux images de résultats et fichiers convertis
|
||||||
app.mount("/static", StaticFiles(directory=settings.RESULT_FOLDER), name="static")
|
app.mount("/static", StaticFiles(directory=settings.RESULT_FOLDER), name="static")
|
||||||
|
app.mount("/static/converted", StaticFiles(directory=settings.CONVERTED_FOLDER), name="converted_files")
|
||||||
|
|
||||||
# Variable pour stocker le temps de démarrage
|
# Variable pour stocker le temps de démarrage
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -183,6 +188,255 @@ async def upload_image(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
f"{settings.API_PREFIX}/convert",
|
||||||
|
response_model=ConversionResponse,
|
||||||
|
tags=["Conversion"],
|
||||||
|
summary="Convertit un fichier d'un format moderne vers un format standard"
|
||||||
|
)
|
||||||
|
async def convert_image(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
target_format: Optional[str] = Query(None, description="Format cible (jpg, png)"),
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Convertit une image d'un format moderne (HEIC, WebP, RAW, etc.) vers un format standard (JPG, PNG)
|
||||||
|
|
||||||
|
Cette API prend en charge les formats suivants:
|
||||||
|
- Formats iPhone: HEIC, HEIF
|
||||||
|
- Formats Android haute qualité: WebP, DNG
|
||||||
|
- Formats RAW: CR2, ARW, NEF, RAW
|
||||||
|
|
||||||
|
La conversion est effectuée côté serveur et le fichier converti est renvoyé.
|
||||||
|
"""
|
||||||
|
# Vérifier si le format d'entrée nécessite une conversion
|
||||||
|
filename = file.filename
|
||||||
|
file_ext = FilePath(filename).suffix.lower().lstrip(".")
|
||||||
|
|
||||||
|
# Si le format est déjà pris en charge, renvoyer une erreur
|
||||||
|
if file_ext in settings.ALLOWED_EXTENSIONS and not target_format:
|
||||||
|
return ConversionResponse(
|
||||||
|
original_filename=filename,
|
||||||
|
original_format=file_ext,
|
||||||
|
status=ConversionStatus.FAILED,
|
||||||
|
message="Le fichier est déjà dans un format pris en charge, aucune conversion nécessaire"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Si le format n'est pas pris en charge du tout
|
||||||
|
if file_ext not in settings.ALLOWED_EXTENSIONS and file_ext not in settings.MODERN_EXTENSIONS:
|
||||||
|
return ConversionResponse(
|
||||||
|
original_filename=filename,
|
||||||
|
original_format=file_ext,
|
||||||
|
status=ConversionStatus.FAILED,
|
||||||
|
message=f"Format non pris en charge: {file_ext}. Formats supportés: {', '.join(get_supported_input_formats())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sauvegarder le fichier temporairement
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
temp_filename = f"{timestamp}_{FilePath(filename).stem}.{file_ext}"
|
||||||
|
temp_path = FilePath(settings.TEMP_FOLDER) / temp_filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(temp_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la sauvegarde du fichier temporaire: {str(e)}")
|
||||||
|
return ConversionResponse(
|
||||||
|
original_filename=filename,
|
||||||
|
original_format=file_ext,
|
||||||
|
status=ConversionStatus.FAILED,
|
||||||
|
message=f"Erreur lors de la sauvegarde du fichier: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Déterminer le format de sortie si spécifié
|
||||||
|
output_format = target_format if target_format else None
|
||||||
|
|
||||||
|
# Effectuer la conversion
|
||||||
|
success, converted_path, error_message = convert_file(
|
||||||
|
str(temp_path),
|
||||||
|
settings.CONVERTED_FOLDER
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nettoyer le fichier temporaire
|
||||||
|
if FilePath(temp_path).exists():
|
||||||
|
FilePath(temp_path).unlink()
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
return ConversionResponse(
|
||||||
|
original_filename=filename,
|
||||||
|
original_format=file_ext,
|
||||||
|
status=ConversionStatus.FAILED,
|
||||||
|
message=error_message if error_message else "Échec de la conversion pour une raison inconnue"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Préparer le chemin relatif pour l'URL
|
||||||
|
converted_filename = FilePath(converted_path).name
|
||||||
|
converted_format = FilePath(converted_path).suffix.lower().lstrip(".")
|
||||||
|
|
||||||
|
# Construire l'URL pour accéder au fichier converti
|
||||||
|
converted_url = f"/static/converted/{converted_filename}"
|
||||||
|
|
||||||
|
return ConversionResponse(
|
||||||
|
original_filename=filename,
|
||||||
|
original_format=file_ext,
|
||||||
|
converted_filename=converted_filename,
|
||||||
|
converted_format=converted_format,
|
||||||
|
converted_path=converted_url,
|
||||||
|
status=ConversionStatus.SUCCESS,
|
||||||
|
message=f"Conversion réussie de {file_ext} vers {converted_format}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post(
|
||||||
|
f"{settings.API_PREFIX}/convert-and-process",
|
||||||
|
response_model=UploadResponse,
|
||||||
|
tags=["Conversion"],
|
||||||
|
status_code=202,
|
||||||
|
summary="Convertit un fichier et lance son traitement"
|
||||||
|
)
|
||||||
|
async def convert_and_process_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)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Convertit une image d'un format moderne vers un format standard, puis lance son traitement d'extraction
|
||||||
|
|
||||||
|
Cette API combine la conversion et le traitement en une seule étape pour les fichiers provenant
|
||||||
|
d'appareils modernes (iPhone, Android haut de gamme).
|
||||||
|
"""
|
||||||
|
# Vérifier le format du fichier
|
||||||
|
filename = file.filename
|
||||||
|
file_ext = FilePath(filename).suffix.lower().lstrip(".")
|
||||||
|
|
||||||
|
# Créer un identifiant unique pour la tâche
|
||||||
|
job_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
# Si le format est déjà pris en charge, procéder directement au traitement
|
||||||
|
if file_ext in settings.ALLOWED_EXTENSIONS:
|
||||||
|
# Créer le chemin de fichier
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
new_filename = f"{timestamp}_{job_id}.{file_ext}"
|
||||||
|
file_path = os.path.join(settings.UPLOAD_FOLDER, new_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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Si le format nécessite une conversion
|
||||||
|
elif file_ext in settings.MODERN_EXTENSIONS:
|
||||||
|
# Sauvegarder le fichier temporairement
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
temp_filename = f"{timestamp}_{job_id}.{file_ext}"
|
||||||
|
temp_path = FilePath(settings.TEMP_FOLDER) / temp_filename
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(temp_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Erreur lors de la sauvegarde du fichier temporaire: {str(e)}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Erreur lors de la sauvegarde de l'image: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Effectuer la conversion
|
||||||
|
success, converted_path, error_message = convert_file(
|
||||||
|
str(temp_path),
|
||||||
|
settings.UPLOAD_FOLDER
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nettoyer le fichier temporaire
|
||||||
|
if FilePath(temp_path).exists():
|
||||||
|
FilePath(temp_path).unlink()
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Erreur lors de la conversion: {error_message if error_message else 'Raison inconnue'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Utiliser le fichier converti
|
||||||
|
file_path = converted_path
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Format non pris en charge
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Format de fichier non pris en charge. Formats acceptés: " +
|
||||||
|
f"{', '.join(settings.ALLOWED_EXTENSIONS)} et {', '.join(settings.MODERN_EXTENSIONS)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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": filename,
|
||||||
|
"priority": str(priority).lower(),
|
||||||
|
"converted": "true" if file_ext in settings.MODERN_EXTENSIONS else "false"
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"Tâche créée: {job_id} - Fichier: {filename} (converti: {file_ext in settings.MODERN_EXTENSIONS})")
|
||||||
|
|
||||||
|
return UploadResponse(
|
||||||
|
job_id=job_id,
|
||||||
|
status=JobStatus.PENDING,
|
||||||
|
message=f"Image {'convertie et ' if file_ext in settings.MODERN_EXTENSIONS else ''}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}/supported-formats",
|
||||||
|
response_model=List[str],
|
||||||
|
tags=["Conversion"],
|
||||||
|
summary="Liste tous les formats de fichiers pris en charge"
|
||||||
|
)
|
||||||
|
async def list_supported_formats(
|
||||||
|
api_key: str = Depends(verify_api_key)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Renvoie la liste de tous les formats de fichiers pris en charge par l'API
|
||||||
|
|
||||||
|
Cela inclut à la fois les formats standards et les formats modernes qui nécessitent une conversion.
|
||||||
|
"""
|
||||||
|
return get_supported_input_formats()
|
||||||
|
|
||||||
|
|
||||||
@app.get(
|
@app.get(
|
||||||
f"{settings.API_PREFIX}/status/{{job_id}}",
|
f"{settings.API_PREFIX}/status/{{job_id}}",
|
||||||
response_model=JobStatusResponse,
|
response_model=JobStatusResponse,
|
||||||
@@ -279,7 +533,7 @@ async def get_job_result(
|
|||||||
# Charger les résultats depuis Redis
|
# Charger les résultats depuis Redis
|
||||||
result_data = job_data.get("result")
|
result_data = job_data.get("result")
|
||||||
if result_data:
|
if result_data:
|
||||||
result_dict = eval(result_data) # Attention: eval n'est pas sécurisé en production
|
result_dict = json.loads(result_data) # Utiliser json.loads au lieu de eval
|
||||||
result = ExtractionResult(**result_dict)
|
result = ExtractionResult(**result_dict)
|
||||||
|
|
||||||
texte_brut = job_data.get("texte_brut")
|
texte_brut = job_data.get("texte_brut")
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ from enum import Enum
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionStatus(str, Enum):
|
||||||
|
"""Statuts possibles pour une conversion de fichier"""
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionResponse(BaseModel):
|
||||||
|
"""Réponse à une demande de conversion de fichier"""
|
||||||
|
original_filename: str = Field(..., description="Nom du fichier original")
|
||||||
|
original_format: str = Field(..., description="Format du fichier original")
|
||||||
|
converted_filename: Optional[str] = Field(None, description="Nom du fichier converti")
|
||||||
|
converted_format: Optional[str] = Field(None, description="Format du fichier converti")
|
||||||
|
converted_path: Optional[str] = Field(None, description="Chemin vers le fichier converti")
|
||||||
|
status: ConversionStatus = Field(..., description="Statut de la conversion")
|
||||||
|
message: str = Field(..., description="Message d'information ou d'erreur")
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(str, Enum):
|
class JobStatus(str, Enum):
|
||||||
"""Statuts possibles pour une tâche d'extraction"""
|
"""Statuts possibles pour une tâche d'extraction"""
|
||||||
PENDING = "pending"
|
PENDING = "pending"
|
||||||
@@ -37,13 +54,25 @@ class JobStatusResponse(BaseModel):
|
|||||||
|
|
||||||
class ExtractionResult(BaseModel):
|
class ExtractionResult(BaseModel):
|
||||||
"""Résultat de l'extraction d'informations d'un chèque"""
|
"""Résultat de l'extraction d'informations d'un chèque"""
|
||||||
montant: Optional[str] = Field(None, description="Montant du chèque")
|
# Champs standards
|
||||||
|
montant: Optional[str] = Field(None, description="Montant du chèque en chiffres")
|
||||||
date: Optional[str] = Field(None, description="Date 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")
|
beneficiaire: Optional[str] = Field(None, description="Bénéficiaire du chèque")
|
||||||
numero_cheque: Optional[str] = Field(None, description="Numéro 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)")
|
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")
|
image_zones: Optional[str] = Field(None, description="Chemin vers l'image avec les zones identifiées")
|
||||||
|
|
||||||
|
# Champs MICR (CMC-7)
|
||||||
|
code_banque: Optional[str] = Field(None, description="Code banque (MICR CMC-7)")
|
||||||
|
code_guichet: Optional[str] = Field(None, description="Code guichet (MICR CMC-7)")
|
||||||
|
numero_compte: Optional[str] = Field(None, description="Numéro de compte (MICR CMC-7)")
|
||||||
|
cle_rib: Optional[str] = Field(None, description="Clé RIB (MICR CMC-7)")
|
||||||
|
sequence_micr: Optional[str] = Field(None, description="Séquence MICR complète")
|
||||||
|
|
||||||
|
# Champs avancés
|
||||||
|
montant_lettres: Optional[str] = Field(None, description="Montant du chèque en lettres")
|
||||||
|
coherence_montants: Optional[bool] = Field(None, description="Cohérence entre montant en chiffres et en lettres")
|
||||||
|
|
||||||
|
|
||||||
class JobResult(BaseModel):
|
class JobResult(BaseModel):
|
||||||
"""Résultat complet d'une tâche d'extraction"""
|
"""Résultat complet d'une tâche d'extraction"""
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- cheque-scanner-network
|
- cheque-scanner-network
|
||||||
|
|
||||||
worker:
|
worker1:
|
||||||
build: ./worker
|
build: ./worker
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
@@ -38,11 +38,35 @@ services:
|
|||||||
- TEMP_FOLDER=/app/data/tmp
|
- TEMP_FOLDER=/app/data/tmp
|
||||||
- QUEUE_NAME=cheque_processing
|
- QUEUE_NAME=cheque_processing
|
||||||
- HIGH_PRIORITY_QUEUE_NAME=cheque_processing_high
|
- HIGH_PRIORITY_QUEUE_NAME=cheque_processing_high
|
||||||
- TESSERACT_DATA_PATH=/usr/share/tesseract-ocr/4.00/tessdata
|
- TESSERACT_DATA_PATH=/usr/share/tesseract-ocr/5/tessdata
|
||||||
- DEFAULT_OCR_LANGUAGE=eng
|
- DEFAULT_OCR_LANGUAGE=fra
|
||||||
- ALTERNATIVE_OCR_LANGUAGE=fra
|
- ALTERNATIVE_OCR_LANGUAGE=eng
|
||||||
deploy:
|
- WORKER_NAME=worker-1
|
||||||
replicas: ${WORKER_REPLICAS:-2}
|
command: ["python", "worker.py", "--queues", "cheque_processing", "--name", "worker-1"]
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- cheque-scanner-network
|
||||||
|
|
||||||
|
worker2:
|
||||||
|
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/5/tessdata
|
||||||
|
- DEFAULT_OCR_LANGUAGE=fra
|
||||||
|
- ALTERNATIVE_OCR_LANGUAGE=eng
|
||||||
|
- WORKER_NAME=worker-2
|
||||||
|
command: ["python", "worker.py", "--queues", "cheque_processing", "--name", "worker-2"]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- cheque-scanner-network
|
- cheque-scanner-network
|
||||||
@@ -62,9 +86,9 @@ services:
|
|||||||
- RESULT_FOLDER=/app/data/results
|
- RESULT_FOLDER=/app/data/results
|
||||||
- TEMP_FOLDER=/app/data/tmp
|
- TEMP_FOLDER=/app/data/tmp
|
||||||
- QUEUE_NAME=cheque_processing_high
|
- QUEUE_NAME=cheque_processing_high
|
||||||
- TESSERACT_DATA_PATH=/usr/share/tesseract-ocr/4.00/tessdata
|
- TESSERACT_DATA_PATH=/usr/share/tesseract-ocr/5/tessdata
|
||||||
- DEFAULT_OCR_LANGUAGE=eng
|
- DEFAULT_OCR_LANGUAGE=fra
|
||||||
- ALTERNATIVE_OCR_LANGUAGE=fra
|
- ALTERNATIVE_OCR_LANGUAGE=eng
|
||||||
- WORKER_NAME=priority-worker
|
- WORKER_NAME=priority-worker
|
||||||
entrypoint: ["/priority_entrypoint.sh"]
|
entrypoint: ["/priority_entrypoint.sh"]
|
||||||
command: ["python", "worker.py", "--queues", "cheque_processing_high", "--name", "priority-worker"]
|
command: ["python", "worker.py", "--queues", "cheque_processing_high", "--name", "priority-worker"]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ import traceback
|
|||||||
import redis
|
import redis
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from rq import get_current_job
|
from rq import get_current_job
|
||||||
|
from json import JSONEncoder
|
||||||
|
|
||||||
# Ajouter le module d'extraction au path
|
# Ajouter le module d'extraction au path
|
||||||
sys.path.append('/app/shared')
|
sys.path.append('/app/shared')
|
||||||
@@ -56,7 +57,8 @@ def update_job_status(job_id, status, message=None, progress=None, result=None,
|
|||||||
update_data["progress"] = str(progress)
|
update_data["progress"] = str(progress)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
update_data["result"] = str(result)
|
# Utiliser json.dumps pour une sérialisation correcte
|
||||||
|
update_data["result"] = json.dumps(result)
|
||||||
|
|
||||||
if texte_brut:
|
if texte_brut:
|
||||||
update_data["texte_brut"] = texte_brut
|
update_data["texte_brut"] = texte_brut
|
||||||
@@ -82,13 +84,14 @@ def update_job_status(job_id, status, message=None, progress=None, result=None,
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def process_cheque_image(file_path, job_id):
|
def process_cheque_image(job_id, file_path, **kwargs):
|
||||||
"""
|
"""
|
||||||
Traite une image de chèque pour en extraire les informations
|
Traite une image de chèque pour en extraire les informations
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_path (str): Chemin vers l'image à traiter
|
|
||||||
job_id (str): Identifiant de la tâche
|
job_id (str): Identifiant de la tâche
|
||||||
|
file_path (str): Chemin vers l'image à traiter
|
||||||
|
**kwargs: Paramètres supplémentaires (ignorés, pour compatibilité avec RQ)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict: Résultat de l'extraction
|
dict: Résultat de l'extraction
|
||||||
@@ -135,12 +138,12 @@ def process_cheque_image(file_path, job_id):
|
|||||||
|
|
||||||
infos, texte = extraire_infos_cheque(
|
infos, texte = extraire_infos_cheque(
|
||||||
chemin_image=file_path,
|
chemin_image=file_path,
|
||||||
methode="ocr",
|
methode="hybride_avance",
|
||||||
language=DEFAULT_OCR_LANGUAGE,
|
language=DEFAULT_OCR_LANGUAGE,
|
||||||
tessdata=tessdata_path
|
tessdata=tessdata_path
|
||||||
)
|
)
|
||||||
|
|
||||||
methode = "ocr"
|
methode = "hybride_avance"
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Échec de la première tentative: {str(e)}")
|
logger.warning(f"Échec de la première tentative: {str(e)}")
|
||||||
@@ -157,31 +160,50 @@ def process_cheque_image(file_path, job_id):
|
|||||||
|
|
||||||
infos, texte = extraire_infos_cheque(
|
infos, texte = extraire_infos_cheque(
|
||||||
chemin_image=file_path,
|
chemin_image=file_path,
|
||||||
methode="ocr",
|
methode="hybride_avance",
|
||||||
language=ALTERNATIVE_OCR_LANGUAGE,
|
language=ALTERNATIVE_OCR_LANGUAGE,
|
||||||
tessdata=tessdata_path
|
tessdata=tessdata_path
|
||||||
)
|
)
|
||||||
|
|
||||||
methode = "ocr"
|
methode = "hybride_avance"
|
||||||
|
|
||||||
except Exception as e2:
|
except Exception as e2:
|
||||||
logger.warning(f"Échec de la deuxième tentative: {str(e2)}")
|
logger.warning(f"Échec de la deuxième tentative: {str(e2)}")
|
||||||
|
|
||||||
# Troisième tentative avec la méthode CV
|
# Troisième tentative avec la méthode hybride (combinant MICR et extraction du montant en lettres)
|
||||||
logger.info("Tentative d'extraction avec la méthode CV (sans OCR)")
|
logger.info("Tentative d'extraction avec la méthode hybride (MICR + montant en lettres)")
|
||||||
update_job_status(
|
update_job_status(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
status="processing",
|
status="processing",
|
||||||
message="Extraction par détection de zones (sans OCR)",
|
message="Extraction MICR et montant en lettres",
|
||||||
progress=70
|
progress=70
|
||||||
)
|
)
|
||||||
|
|
||||||
infos, texte = extraire_infos_cheque(
|
infos, texte = extraire_infos_cheque(
|
||||||
chemin_image=file_path,
|
chemin_image=file_path,
|
||||||
methode="cv"
|
methode="hybride",
|
||||||
|
language=DEFAULT_OCR_LANGUAGE,
|
||||||
|
tessdata=tessdata_path
|
||||||
)
|
)
|
||||||
|
|
||||||
methode = "cv"
|
methode = "hybride"
|
||||||
|
|
||||||
|
# Si échec, dernière tentative avec la méthode MICR seule
|
||||||
|
if not infos.get("montant") and not infos.get("code_banque"):
|
||||||
|
logger.info("Tentative d'extraction avec la méthode MICR uniquement")
|
||||||
|
update_job_status(
|
||||||
|
job_id=job_id,
|
||||||
|
status="processing",
|
||||||
|
message="Extraction MICR uniquement",
|
||||||
|
progress=80
|
||||||
|
)
|
||||||
|
|
||||||
|
infos, texte = extraire_infos_cheque(
|
||||||
|
chemin_image=file_path,
|
||||||
|
methode="micr"
|
||||||
|
)
|
||||||
|
|
||||||
|
methode = "micr"
|
||||||
|
|
||||||
# Mise à jour finale
|
# Mise à jour finale
|
||||||
update_job_status(
|
update_job_status(
|
||||||
|
|||||||
Reference in New Issue
Block a user