Spaces:
Runtime error
Runtime error
| """API FastAPI — classification de races de chiens.""" | |
| import csv | |
| import io | |
| import json | |
| import os | |
| from contextlib import asynccontextmanager | |
| import numpy as np | |
| import yaml | |
| from fastapi import FastAPI, File, HTTPException, UploadFile | |
| from loguru import logger | |
| from PIL import Image | |
| from pydantic import BaseModel | |
| from dog_breed_classifier.config import PROJ_ROOT, REPORTS_DIR | |
| from dog_breed_classifier.features import extract_visual_features | |
| from dog_breed_classifier.modeling.predict import load_model, preprocess | |
| from dog_breed_classifier.monitoring import DRIFT_REPORTS_DIR, run_drift_report | |
| PRODUCTION_FEATURES_CSV = REPORTS_DIR / "production_features.csv" | |
| with open(PROJ_ROOT / "params.yaml") as f: | |
| _params = yaml.safe_load(f) | |
| BREEDS: list[str] = sorted(_params["data"]["races"]) | |
| IMG_SIZE: tuple[int, int] = tuple(_params["model"]["img_size"]) | |
| model = None | |
| def _log_production_features(features: dict) -> None: | |
| """Ajoute les features de l'image reçue dans production_features.csv.""" | |
| REPORTS_DIR.mkdir(parents=True, exist_ok=True) | |
| write_header = not PRODUCTION_FEATURES_CSV.exists() | |
| with open(PRODUCTION_FEATURES_CSV, "a", newline="") as f: | |
| writer = csv.DictWriter(f, fieldnames=list(features.keys())) | |
| if write_header: | |
| writer.writeheader() | |
| writer.writerow(features) | |
| async def lifespan(app: FastAPI): | |
| global model | |
| model = None | |
| try: | |
| model = load_model() | |
| except Exception as e: | |
| logger.warning(f"Modèle non chargé au démarrage : {e}") | |
| yield | |
| app = FastAPI( | |
| title="Dog Breed Classifier API", | |
| description="API de classification de races de chiens — Stanford Dogs Dataset (10 races).", | |
| version="1.0.0", | |
| lifespan=lifespan, | |
| ) | |
| class PredictionResponse(BaseModel): | |
| breed: str | |
| confidence: float | |
| top_3: list[dict] | |
| class HealthResponse(BaseModel): | |
| status: str | |
| model_loaded: bool | |
| class BreedsResponse(BaseModel): | |
| breeds: list[str] | |
| count: int | |
| class FeatureDriftStats(BaseModel): | |
| drift_detected: bool | |
| stattest: str | |
| p_value: float | |
| class DriftSummaryResponse(BaseModel): | |
| timestamp: str | |
| production_rows: int | |
| dataset_drift_detected: bool | |
| drifted_features: int | |
| total_features: int | |
| per_feature: dict[str, FeatureDriftStats] | |
| def health(): | |
| return {"status": "ok", "model_loaded": model is not None} | |
| def breeds(): | |
| return {"breeds": BREEDS, "count": len(BREEDS)} | |
| async def predict(file: UploadFile = File(...)): | |
| if file.content_type not in ("image/jpeg", "image/png"): | |
| raise HTTPException(status_code=400, detail="Format non supporté. Envoyez un JPEG ou PNG.") | |
| if model is None: | |
| raise HTTPException(status_code=503, detail="Modèle non disponible.") | |
| try: | |
| contents = await file.read() | |
| image = Image.open(io.BytesIO(contents)) | |
| except Exception: | |
| raise HTTPException(status_code=400, detail="Image invalide ou corrompue.") | |
| arr = preprocess(image, IMG_SIZE) | |
| preds = model.predict(arr, verbose=0)[0] | |
| top_indices = np.argsort(preds)[::-1][:3] | |
| top_3 = [{"breed": BREEDS[i], "confidence": round(float(preds[i]), 4)} for i in top_indices] | |
| features = extract_visual_features(image) | |
| _log_production_features(features) | |
| return { | |
| "breed": top_3[0]["breed"], | |
| "confidence": top_3[0]["confidence"], | |
| "top_3": top_3, | |
| } | |
| def _latest_drift_summary() -> dict | None: | |
| """Retourne le contenu du dernier fichier drift_summary_*.json, ou None.""" | |
| if not DRIFT_REPORTS_DIR.exists(): | |
| return None | |
| summaries = sorted(DRIFT_REPORTS_DIR.glob("drift_summary_*.json")) | |
| if not summaries: | |
| return None | |
| with open(summaries[-1]) as f: | |
| return json.load(f) | |
| def drift_summary(): | |
| """Retourne le dernier rapport de drift calculé sans relancer l'analyse.""" | |
| summary = _latest_drift_summary() | |
| if summary is None: | |
| raise HTTPException( | |
| status_code=404, | |
| detail="Aucun rapport de drift disponible. Appelle POST /drift/run d'abord.", | |
| ) | |
| return summary | |
| def drift_run(min_rows: int = 10): | |
| """Lance une analyse Evidently et retourne le résumé. | |
| - **min_rows** : nombre minimum de requêtes de production requises (défaut : 10). | |
| """ | |
| try: | |
| run_drift_report(min_production_rows=min_rows, log_to_mlflow=False) | |
| except FileNotFoundError as e: | |
| raise HTTPException(status_code=422, detail=str(e)) | |
| except Exception as e: | |
| logger.error(f"Erreur lors du calcul du drift : {e}") | |
| raise HTTPException(status_code=500, detail=f"Erreur analyse drift : {e}") | |
| summary = _latest_drift_summary() | |
| if summary is None: | |
| raise HTTPException(status_code=500, detail="Rapport généré mais introuvable.") | |
| return summary | |