diff --git a/edge_model_serving/onnx_serving/Dockerfile b/edge_model_serving/onnx_serving/Dockerfile new file mode 100644 index 00000000..0e4d5c45 --- /dev/null +++ b/edge_model_serving/onnx_serving/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.8-slim + +WORKDIR /app + +# Install dependencies +RUN apt-get update && apt-get upgrade -y && apt-get install gcc -y + + +COPY src /app/src +COPY pyproject.toml /app/ +COPY models/onnx /models/onnx + + +# dependances +RUN pip install --upgrade pip +RUN pip install -e "/app[linux]" + + +EXPOSE 8000 + +ENV PYTHONPATH=/app/src +ENTRYPOINT ["uvicorn"] +CMD ["onnx_server:app", "--reload", "--host", "0.0.0.0", "--port", "8000"] diff --git a/edge_model_serving/onnx_serving/models/onnx/object_couting_video/yolo11n.onnx b/edge_model_serving/onnx_serving/models/onnx/object_couting_video/yolo11n.onnx new file mode 100644 index 00000000..eb14728d Binary files /dev/null and b/edge_model_serving/onnx_serving/models/onnx/object_couting_video/yolo11n.onnx differ diff --git a/edge_model_serving/onnx_serving/pyproject.toml b/edge_model_serving/onnx_serving/pyproject.toml new file mode 100644 index 00000000..f8b09bba --- /dev/null +++ b/edge_model_serving/onnx_serving/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=66", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" + +[project] +name = "onnx_serving" +version = "1.0.0" +dependencies = [ + "fastapi==0.92.0", + "numpy==1.24.2", + "Pillow==9.4.0", + "uvicorn==0.20.0", + "onnxruntime==1.16.0", + "opencv-python-headless==4.7.0.72", + "anyio==3.7.1" +] +requires-python = ">=3.8" + +[tool.setuptools.packages.find] +where = ["src/"] + +[project.optional-dependencies] +dev = [ + "black==23.3.0", + "isort==5.13.2", + "flake8==7.1.1", + "autoflake==2.3.1", + "pytest==7.2.2", + "pytest-cov==4.0.0", + "requests==2.26.0" +] + +[tool.pytest.ini_options] +min_version = "6.0" +testpaths = "tests/" + +[tool.flake8] +exclude = "venv*" +max-complexity = 10 +max-line-length = 120 + +[tool.isort] +profile = "black" diff --git a/edge_model_serving/onnx_serving/src/__init__.py b/edge_model_serving/onnx_serving/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edge_model_serving/onnx_serving/src/api_routes.py b/edge_model_serving/onnx_serving/src/api_routes.py new file mode 100644 index 00000000..b94f6cae --- /dev/null +++ b/edge_model_serving/onnx_serving/src/api_routes.py @@ -0,0 +1,148 @@ +import logging +from typing import Any, AnyStr, Dict, List, Union, Tuple +import numpy as np +from fastapi import APIRouter, HTTPException, Request +import time +import os +from PIL import Image +from pathlib import Path + + +from utils.yolo11n_postprocessing import ( + compute_severities, + non_max_suppression, + yolo_extract_boxes_information, +) + +JSONObject = Dict[AnyStr, Any] +JSONArray = List[Any] +JSONStructure = Union[JSONArray, JSONObject] + +api_router = APIRouter() + + +# Page initiale +@api_router.get("/") +async def info(): + return """Welcome to the onnx-server for VIO !!""" + + +# Lister les modèles ONNX chargés +@api_router.get("/models") +async def get_models(request: Request) -> List[str]: + return list( + request.app.state.model_interpreters.keys() + ) # Retourne les modèles disponibles + + +# Récupérer les métadonnées d’un modèle +@api_router.get("/models/{model_name}/versions/{model_version}/resolution") +async def get_model_metadata( + model_name: str, model_version: str, request: Request +) -> Dict[str, Tuple]: + session = request.app.state.model_interpreters[model_name] + input_details = session.get_inputs() + return {"inputs_shape": input_details[0].shape} + + +# Faire une prédictionPrediction + + +def load_test_image(path: str) -> np.ndarray: + if not os.path.exists(path): + raise FileNotFoundError(f"Image de test introuvable : {path}") + img = Image.open(path).convert("RGB") + img = img.resize((640, 640)) + arr = np.array(img, dtype=np.float32) / 255.0 # Normalization : [640,640,3] + arr = arr.transpose(2, 0, 1) # --> [3,640,640] + arr = np.expand_dims(arr, axis=0) # --> [1,3,640,640] : format accepté par YOLO + return arr + + +# Modifier l'endpoint pour que l'utilisateur puisse envoyer une image par la requete +@api_router.post("/models/{model_name}/versions/{model_version}:predict") +async def predict_test_image(model_name: str, model_version: str, request: Request): + HERE = Path(__file__).resolve().parent + + # verification modèle + if model_name not in request.app.state.model_interpreters: + raise HTTPException(status_code=404, detail="Modèle non trouvé") + session = request.app.state.model_interpreters[ + model_name + ] # recuperer la session d'inference du modèle + + # charger l'image + try: + test_img_path = HERE / "data" / "test_img.jpg" + input_array = load_test_image(test_img_path) + except FileNotFoundError as e: + raise HTTPException(status_code=500, detail=str(e)) + + logging.info(f"Chargé image de test, forme finale {input_array.shape}") + + # inférence + try: + input_details = session.get_inputs() + ort_inputs = {input_details[0].name: input_array} + outputs = session.run(None, ort_inputs) + except Exception: + raise HTTPException(status_code=500, detail="Erreur d'inférence ONNX") + + # Post‐processing + try: + outputs = outputs[0][0] + boxes, scores, class_ids = yolo_extract_boxes_information(outputs) + boxes, scores, class_ids = non_max_suppression(boxes, scores, class_ids) + severities = compute_severities(input_array[0], boxes) + + prediction = { + "outputs": { + "detection_boxes": [boxes.tolist()], + "detection_classes": [class_ids.tolist()], + "detection_scores": [scores.tolist()], + "severities": [severities], + } + } + return prediction + except Exception: + raise HTTPException(status_code=500, detail="Erreur postprocessing") + + +@api_router.post("/models/{model_name}/performance") +async def model_performance(model_name: str, request: Request): + + # Verif exsitence modele + if model_name not in request.app.state.model_interpreters: + raise HTTPException(status_code=404, detail="Modèle non trouvé") + session = request.app.state.model_interpreters[model_name] + input_details = session.get_inputs() + + # get img + HERE = Path(__file__).resolve().parent + test_img_path = HERE / "data" / "test_img.jpg" + try: + input_array = load_test_image(test_img_path) + except FileNotFoundError as e: + raise HTTPException(status_code=500, detail=str(e)) + + # Verif format attendu par YOLO + if input_array.shape != (1, 3, 640, 640): + raise HTTPException( + status_code=400, + detail=f"Les dimensions de l'input doivent être [1,3,640,640], got {input_array.shape}", + ) + + # Inférence + mesure du temps + try: + ort_inputs = {input_details[0].name: input_array} + start = time.time() + _ = session.run(None, ort_inputs) + exec_time = time.time() - start + except Exception: + raise HTTPException(status_code=500, detail="Erreur d'inférence ONNX") + + return { + "model_name": model_name, + "input_shape": input_array.shape, + "inference_time_sec": round(exec_time, 4), + } diff --git a/edge_model_serving/onnx_serving/src/data/test_img.jpg b/edge_model_serving/onnx_serving/src/data/test_img.jpg new file mode 100644 index 00000000..916e11ad Binary files /dev/null and b/edge_model_serving/onnx_serving/src/data/test_img.jpg differ diff --git a/edge_model_serving/onnx_serving/src/onnx_interpreter.py b/edge_model_serving/onnx_serving/src/onnx_interpreter.py new file mode 100644 index 00000000..56c3a3f6 --- /dev/null +++ b/edge_model_serving/onnx_serving/src/onnx_interpreter.py @@ -0,0 +1,44 @@ +import onnxruntime as ort +import os +from pathlib import Path + +MODELS_PATH = Path(os.getenv("MODELS_PATH", "/models")) + +# chercehr tous les modèles .onnx +model_files = list(MODELS_PATH.rglob("*.onnx")) + +if not model_files: + raise FileNotFoundError(f"Aucun modèle ONNX trouvé dans {MODELS_PATH}") + +print(f"Modèles trouvés : {[str(m) for m in model_files]}") + +# choisir par défaut le premier modèle onnx disponible +MODEL_PATH = str(model_files[0]) + +class ONNXModel: + def __init__(self): + """Initialiser ONNX Runtime avec tous les modèles trouvés""" + self.models = {} + model_files = list(MODELS_PATH.rglob("*.onnx")) + + if not model_files: + raise FileNotFoundError(f"Aucun modèle ONNX trouvé dans {MODELS_PATH}") + + for model_path in model_files: + model_name = model_path.stem # Nom du modele sans extension + self.models[model_name] = ort.InferenceSession(str(model_path), providers=["CPUExecutionProvider"]) # Création d'une session d'inference par modèle + print(f"Model {model_name} loaded from {model_path}") + + + + + """" + def predict(self, model_name, input_array): + if model_name not in self.models: + raise ValueError(f"Model {model_name} not loaded") + + session = self.models[model_name] + input_name = session.get_inputs()[0].name + outputs = session.run(None, {input_name: input_array}) + return outputs + """ diff --git a/edge_model_serving/onnx_serving/src/onnx_server.py b/edge_model_serving/onnx_serving/src/onnx_server.py new file mode 100644 index 00000000..5179ea78 --- /dev/null +++ b/edge_model_serving/onnx_serving/src/onnx_server.py @@ -0,0 +1,21 @@ +from fastapi import FastAPI +import logging +from api_routes import api_router +from onnx_interpreter import ONNXModel + +app = FastAPI() + +# Charger les modèles ONNX au démarrage du serveur +@app.on_event("startup") +async def load_model(): + logging.info("Chargement des modèles ONNX...") + model = ONNXModel() # Charge tous les modèles + app.state.model_interpreters = model.models # stocke tous les modèles + logging.info(f"Modèles chargés : {list(model.models.keys())}") + +app.include_router(api_router) + +# health check du serveur ONNX +@app.get("/") +async def root(): + return {"message": "ONNX Model Serving is running!", "models": list(app.state.model_interpreters.keys())} diff --git a/edge_model_serving/onnx_serving/src/utils/__init__.py b/edge_model_serving/onnx_serving/src/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/edge_model_serving/onnx_serving/src/utils/yolo11n_postprocessing.py b/edge_model_serving/onnx_serving/src/utils/yolo11n_postprocessing.py new file mode 100644 index 00000000..cd89cf31 --- /dev/null +++ b/edge_model_serving/onnx_serving/src/utils/yolo11n_postprocessing.py @@ -0,0 +1,70 @@ +import numpy as np +import cv2 + +# fct pour calculer score IOU +def compute_iou(box1, box2): + x1, y1, x2, y2 = box1 + x1g, y1g, x2g, y2g = box2 + + xi1, yi1 = max(x1, x1g), max(y1, y1g) + xi2, yi2 = min(x2, x2g), min(y2, y2g) + + inter_width = max(0, xi2 - xi1) + inter_height = max(0, yi2 - yi1) + inter_area = inter_width * inter_height + + box1_area = (x2 - x1) * (y2 - y1) + box2_area = (x2g - x1g) * (y2g - y1g) + + union_area = box1_area + box2_area - inter_area + + return inter_area / union_area if union_area > 0 else 0 + + +# fct pour extraire les boîtes, scores et classes depuis les sorties YOLO ONNX +def yolo_extract_boxes_information(outputs, confidence_threshold=0.5): + print(f"📌 Debug - outputs.shape: {np.array(outputs).shape}") # ✅ Affiche la forme des outputs + + boxes = [] + scores = [] + class_ids = [] + + for output in outputs: + print(f"📌 Debug - output: {output}") # ✅ Vérifie la structure de chaque ligne + + # Assurer que chaque sortie a bien 6 valeurs (x1, y1, x2, y2, conf, class_id) + if len(output) < 6: + print(f"⚠️ Warning : Une sortie YOLO ne contient que {len(output)} valeurs : {output}") + continue # Ignore cette sortie + + x1, y1, x2, y2, conf, class_id = output[:6] # ✅ Prendre seulement les 6 premières valeurs + + if conf > confidence_threshold: + boxes.append([int(x1), int(y1), int(x2), int(y2)]) + scores.append(float(conf)) + class_ids.append(int(class_id)) + + return np.array(boxes), np.array(scores), np.array(class_ids) + + +# fct pour appliquer la suppression non maximale (NMS) aux boîtes détectées +def non_max_suppression(boxes, scores, class_ids, iou_threshold=0.3): + indices = cv2.dnn.NMSBoxes(boxes.tolist(), scores.tolist(), 0.5, iou_threshold) + filtered_boxes = [] + filtered_scores = [] + filtered_class_ids = [] + + for i in indices.flatten(): + filtered_boxes.append(boxes[i]) + filtered_scores.append(scores[i]) + filtered_class_ids.append(class_ids[i]) + + return np.array(filtered_boxes), np.array(filtered_scores), np.array(filtered_class_ids) + +# 📌 Fonction pour calculer les "severities" (peut être modifié selon besoin) +def compute_severities(frame, boxes): + severities = [] + for box in boxes: + severity = np.random.uniform(0, 1) # Exemple : générer un score aléatoire + severities.append(severity) + return severities diff --git a/edge_model_serving/onnx_serving/tests/data/test_img.jpg b/edge_model_serving/onnx_serving/tests/data/test_img.jpg new file mode 100644 index 00000000..916e11ad Binary files /dev/null and b/edge_model_serving/onnx_serving/tests/data/test_img.jpg differ diff --git a/edge_model_serving/onnx_serving/tests/test_onnx_serving.py b/edge_model_serving/onnx_serving/tests/test_onnx_serving.py new file mode 100644 index 00000000..d0bc63d5 --- /dev/null +++ b/edge_model_serving/onnx_serving/tests/test_onnx_serving.py @@ -0,0 +1,104 @@ +import os +import sys +import asyncio + +import pytest +import numpy as np +from PIL import Image +from fastapi.testclient import TestClient + +# Configurer environment et paths +os.environ["MODELS_PATH"] = "../models" +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../src"))) + +from onnx_server import app + + +@pytest.fixture(scope="session", autouse=True) +def startup_and_shutdown(): + """Force l'exécution de l'événement `startup` dans les tests""" + loop = asyncio.get_event_loop() + loop.run_until_complete(app.router.startup()) + + +@pytest.fixture(scope="session") +def client(): + """Créer un client de test qui sera réutilisé pour tous les tests""" + return TestClient(app) + + +class TestOnnxServing: + """Tests pour l'API ONNX Serving""" + + def test_homepage(self, client): + """Test de la page d'accueil""" + # Given + # Le serveur est démarré (géré par la fixture startup_and_shutdown) + + # When + response = client.get("/") + + # Then + assert response.status_code == 200 + assert "Welcome to the onnx-server" in response.text + + def test_get_models(self, client): + """Test pour récupérer la liste des modèles chargés""" + # Given + # Les modèles sont chargés (géré par la fixture startup_and_shutdown) + + # When + response = client.get("/models") + + # Then + assert response.status_code == 200 + models_list = response.json() + assert isinstance(models_list, list) + assert "yolo11n" in models_list # Vérifie la présence du modèle attendu + + def test_get_model_metadata(self, client): + """Test pour récupérer la résolution d’un modèle""" + response = client.get("/models/yolo11n/versions/1/resolution") + assert response.status_code == 200 + assert ( + "inputs_shape" in response.json() + ) # Verifie que la réponse contient la forme d'entrée + + def test_predict(self, client): + """Test d'inférence sur une image avec le modèle YOLOv11n""" + # Given + image_path = os.path.abspath( + os.path.join(os.path.dirname(__file__), "data/test_img.jpg") + ) + assert os.path.exists( + image_path + ), f"L'image de test {image_path} n'existe pas !" + + # Préparation de l'image + image = Image.open(image_path).convert("RGB") + image = image.resize((640, 640)) + image_array = np.array(image).astype(np.float32) / 255.0 + image_array = np.transpose(image_array, (2, 0, 1)) + image_array = np.expand_dims(image_array, axis=0) + payload = {"inputs": image_array.tolist(), "model_type": "yolo"} + + # When + response = client.post("/models/yolo11n/versions/1:predict", json=payload) + + # Then + assert ( + response.status_code == 200 + ), f"Erreur {response.status_code}: {response.text}" + response_json = response.json() + + # Vérification des résultats de détection + assert "outputs" in response_json, "Pas de clé 'outputs' dans la réponse" + assert ( + "detection_boxes" in response_json["outputs"] + ), "Pas de détection de boîtes" + assert ( + "detection_classes" in response_json["outputs"] + ), "Pas de classes détectées" + assert "detection_scores" in response_json["outputs"], "Pas de scores détectés" + + print(f"Inférence réussie ! Résultat : {response_json['outputs']}")