GitHub Actions
deploy: Dog Breed Classifier API
7a2733c
Raw
History Blame Contribute Delete
5.35 kB
"""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)
@asynccontextmanager
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]
@app.get("/health", response_model=HealthResponse, summary="Statut de l'API")
def health():
return {"status": "ok", "model_loaded": model is not None}
@app.get("/breeds", response_model=BreedsResponse, summary="Liste des races reconnues")
def breeds():
return {"breeds": BREEDS, "count": len(BREEDS)}
@app.post("/predict", response_model=PredictionResponse, summary="Prédiction de race")
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)
@app.get(
"/drift/summary",
response_model=DriftSummaryResponse,
summary="Dernier résumé de drift (cache)",
)
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
@app.post(
"/drift/run",
response_model=DriftSummaryResponse,
summary="Déclenche une analyse de drift",
)
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