From c5ded9d77c3e18d40c63a2db91453fa30484c48f Mon Sep 17 00:00:00 2001 From: aayrapet Date: Thu, 16 Apr 2026 12:18:48 +0000 Subject: [PATCH 01/13] added websocket api for model inference + sort simple tracker --- inference.py | 100 +++++++++++++++ main.ipynb | 22 +++- server.py | 215 +++++++++++++++++++++++++++++++ website/app.js | 309 +++++++++++++++++++++++++++------------------ website/index.html | 56 ++++---- website/style.css | 15 ++- 6 files changed, 558 insertions(+), 159 deletions(-) create mode 100644 inference.py create mode 100644 server.py diff --git a/inference.py b/inference.py new file mode 100644 index 0000000..a758f78 --- /dev/null +++ b/inference.py @@ -0,0 +1,100 @@ +# inference.py — Import-safe model loading and per-frame detection for the server. +# Separates model setup from CLI/argparse concerns in SsdFastCrowdVision.py. + +import torch +import numpy as np +from huggingface_hub import hf_hub_download +from mobilenetv3 import MobileNetV3Large +from ssd import SSDLite +from transforms import test_val_transform +from eval import load_model +from torchvision.transforms import v2 +import os +import yaml + +# project root = folder containing this file +project_root = os.path.dirname(os.path.abspath(__file__)) + + +def load_ssd_model(device): + """Build SSDLite + MobileNetV3 backbone, download weights from HuggingFace, + return (model, config_dict, transform) ready for inference.""" + + # build MobileNetV3Large backbone without the final avg-pool + classifier + mn = MobileNetV3Large(0.1, 1000, 1280) + backbone = mn.features[:-1] + + # build SSDLite on top — use absolute path for the YAML config + # so the server works regardless of working directory + model = SSDLite( + backbone_config_path=os.path.join(project_root, "config", "ssdlite_mobilenetv3large.yaml"), + backbone=backbone, + c4_name="7.features.1.features.2", + nb_classes=4, + phase="test", + alpha=1.0, + prob_thr=0.01, + nms_thr=0.45, + top_k=200, + variances=[0.1, 0.2], + N_epochs=50, + device=device, + ).to(device) + + # dummy optimizer — load_model() requires one to restore checkpoint state + optimizer = torch.optim.Adam(model.parameters(), lr=0.001, weight_decay=0.0005) + + # download weights from HuggingFace (cached locally after first download) + weights_path = hf_hub_download( + repo_id="aayrapet/SsdFastCrowdVision", + filename="SSD_FastCrowdVision_v1.pth", + ) + + # load trained weights into the model + model, _, _, max_map, _ = load_model(weights_path, device, model, optimizer) + model.eval() + model.phase = "test" + print(f"Model loaded — mAP on WiderPeople: {max_map}") + + # load class names (1: pedestrians, 2: riders, 3: partially-visible persons) + wider_yaml = os.path.join(project_root, "datasets", "WiderPeople", "widerpeople.yaml") + with open(wider_yaml) as f: + config = yaml.safe_load(f)["names"] + + # test transform: resize to 300x300, convert to float, normalize with ImageNet stats + transform = v2.Compose(test_val_transform) + + return model, config, transform + + +def detect_frame(model, pil_image, transform, device, score_thr=0.25): + """Run SSD on a single PIL image. + + Returns np.ndarray of shape (N, 6) — columns: [x1, y1, x2, y2, score, class]. + Coordinates are in pixels (original image size). + Returns empty (0, 6) array if no detections pass the threshold. + """ + W, H = pil_image.size + + # transform PIL image to 300x300 normalized tensor, add batch dim + x = transform(pil_image).unsqueeze(0).to(device) + + # SSD forward pass + NMS (no gradients needed at inference) + with torch.no_grad(): + _, _, topk = model(x) + + # remove batch dimension: (1, top_k, 6) → (top_k, 6) + topk = topk.squeeze(0) + + # keep only detections above the score threshold + mask = topk[:, 4] > score_thr + if not mask.any(): + return np.empty((0, 6)) + + topk = topk[mask] + + # scale normalized [0,1] box coordinates to pixel coordinates + topk[:, [0, 2]] *= W + topk[:, [1, 3]] *= H + + return topk.cpu().numpy() diff --git a/main.ipynb b/main.ipynb index 9160b2c..31562bb 100644 --- a/main.ipynb +++ b/main.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 3, + "execution_count": 2, "id": "9a7fa25c", "metadata": {}, "outputs": [ @@ -11,9 +11,8 @@ "output_type": "stream", "text": [ " Model number of params: 3,686,736 \n", - "Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.\n", "map on WiderPeople dataset: 0.2562450170516968\n", - "~4.99 FPS (100 iters, 300x300, cpu)\n" + "~4.51 FPS (100 iters, 300x300, cpu)\n" ] } ], @@ -23,7 +22,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": 1, "id": "6d0f80c4", "metadata": {}, "outputs": [ @@ -32,14 +31,23 @@ "output_type": "stream", "text": [ " Model number of params: 3,686,736 \n", - "Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads.\n", "map on WiderPeople dataset: 0.2562450170516968\n", - "~4.70 FPS (100 iters, 300x300, cpu)\n" + "~4.47 FPS (100 iters, 300x300, cpu)\n" ] } ], "source": [ - "!python SsdFastCrowdVision.py \"/home/onyxia/work/FastCrowdVision/datasets/voc/val/images/000019.jpg\"" + "!python SsdFastCrowdVision.py \"/home/onyxia/work/FastCrowdVision/datasets/WiderPeople/test/images/image000047.jpg\"" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "26216075", + "metadata": {}, + "outputs": [], + "source": [ + "uvicorn server:app --reload" ] } ], diff --git a/server.py b/server.py new file mode 100644 index 0000000..9787af2 --- /dev/null +++ b/server.py @@ -0,0 +1,215 @@ +# server.py — FastAPI backend that processes uploaded videos frame-by-frame, +# runs SSD detection + norfair tracking, and pushes results to the browser +# via WebSocket (Option A: server-paced, video waits for each detection). +# +# Start with: uvicorn server:app --reload +# Then open: http://localhost:8000 + +import os +import uuid +import asyncio +import tempfile + +import cv2 +import numpy as np +import torch +from PIL import Image +from fastapi import FastAPI, WebSocket, UploadFile, File +from fastapi.staticfiles import StaticFiles +from norfair import Tracker, Detection + +from inference import load_ssd_model, detect_frame + +app = FastAPI() + +# --- globals: loaded once at startup, shared across all requests --- +model = None +config = None +transform = None +device = None + +# maps session_id → path of the uploaded temp video file +video_sessions: dict[str, str] = {} + +project_root = os.path.dirname(os.path.abspath(__file__)) + + +@app.on_event("startup") +def startup(): + """Called once when uvicorn starts. Loads the SSD model into memory.""" + global model, config, transform, device + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + model, config, transform = load_ssd_model(device) + print("Server ready — model loaded on", device) + + +# ── Upload endpoint ────────────────────────────────────────────────── + +@app.post("/upload") +async def upload_video(file: UploadFile = File(...)): + """Receive a video file from the browser, save it to a temp file, + return a session_id the browser will use to start detection.""" + session_id = str(uuid.uuid4()) + + suffix = os.path.splitext(file.filename or ".mp4")[1] + tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix) + content = await file.read() + tmp.write(content) + tmp.close() + + video_sessions[session_id] = tmp.name + return {"session_id": session_id} + + +# ── WebSocket detection endpoint ───────────────────────────────────── + +@app.websocket("/ws/detect") +async def detect_ws(websocket: WebSocket): + """Server-paced detection loop (Option A). + 1. Browser sends config {session_id, score_thr, frame_skip} + 2. Server reads frames with OpenCV, runs SSD + tracker, sends JSON per frame + 3. Browser receives JSON → seeks video → draws boxes + 4. Server sends {type: "done"} when finished + """ + await websocket.accept() + + try: + # wait for the browser to send session config + init_msg = await websocket.receive_json() + session_id = init_msg["session_id"] + score_thr = init_msg.get("score_thr", 0.25) + frame_skip = init_msg.get("frame_skip", 0) + + video_path = video_sessions.get(session_id) + if not video_path: + await websocket.send_json({"type": "error", "message": "Invalid session"}) + await websocket.close() + return + + # open video with OpenCV + cap = cv2.VideoCapture(video_path) + fps = cap.get(cv2.CAP_PROP_FPS) or 30.0 + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + + # tell the browser the video metadata so it can seek correctly + await websocket.send_json({ + "type": "metadata", + "fps": fps, + "total_frames": total_frames, + }) + + # create a fresh tracker for this video + tracker = Tracker( + distance_function="iou", + distance_threshold=0.7, + hit_counter_max=15, + initialization_delay=3, + ) + + # keep track of every unique ID seen across all frames + all_track_ids: set[int] = set() + frame_idx = 0 + + while True: + ret, frame_bgr = cap.read() + if not ret: + break + + # skip frames if requested (frame_skip=1 means process every 2nd frame) + if frame_idx % (frame_skip + 1) != 0: + frame_idx += 1 + continue + + # convert OpenCV BGR numpy array → PIL RGB image + frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB) + pil_image = Image.fromarray(frame_rgb) + + # run SSD detection on this frame + detections_np = detect_frame(model, pil_image, transform, device, score_thr) + + # convert SSD detections to norfair Detection objects + norfair_dets = [] + for det in detections_np: + x1, y1, x2, y2, score, cls = det + norfair_dets.append( + Detection( + points=np.array([[x1, y1], [x2, y2]]), + scores=np.array([score, score]), + data={"class": int(cls), "score": float(score)}, + ) + ) + + # update tracker with this frame's detections + tracked_objects = tracker.update(detections=norfair_dets) + + # build JSON response for this frame + boxes = [] + track_ids = [] + scores_list = [] + classes_list = [] + + for obj in tracked_objects: + if obj.estimate is not None: + box = obj.estimate.flatten().tolist() + boxes.append(box) + track_ids.append(obj.id) + all_track_ids.add(obj.id) + + if obj.last_detection and obj.last_detection.data: + scores_list.append(obj.last_detection.data["score"]) + cls_id = obj.last_detection.data["class"] + classes_list.append(config.get(cls_id, f"class_{cls_id}")) + else: + scores_list.append(0.0) + classes_list.append("unknown") + + # send this frame's results to the browser + await websocket.send_json({ + "type": "detection", + "frame": frame_idx, + "time": frame_idx / fps, + "boxes": boxes, + "track_ids": track_ids, + "scores": scores_list, + "classes": classes_list, + "current_count": len(tracked_objects), + "total_unique": len(all_track_ids), + }) + + frame_idx += 1 + + # yield control so the WebSocket message actually gets sent + await asyncio.sleep(0) + + cap.release() + + # tell the browser we're done + await websocket.send_json({ + "type": "done", + "total_unique": len(all_track_ids), + "total_frames_processed": frame_idx, + }) + + # clean up the temp video file + try: + os.unlink(video_path) + except OSError: + pass + video_sessions.pop(session_id, None) + + except Exception as e: + try: + await websocket.send_json({"type": "error", "message": str(e)}) + except Exception: + pass + finally: + try: + await websocket.close() + except Exception: + pass + + +# ── Serve the website static files (HTML, CSS, JS) ────────────────── +# This is mounted LAST so that /upload and /ws/detect are matched first. +# html=True makes it serve index.html when you visit http://localhost:8000/ +app.mount("/", StaticFiles(directory=os.path.join(project_root, "website"), html=True), name="website") diff --git a/website/app.js b/website/app.js index 6bac61f..8bbcacd 100644 --- a/website/app.js +++ b/website/app.js @@ -1,197 +1,271 @@ -let model = null; -let detectionInterval = null; -let isDetecting = false; +// app.js — Frontend for FastCrowdVision. +// +// REWRITTEN: replaced TF.js COCO-SSD (client-side detection) with +// WebSocket communication to the Python backend (server-side SSD + norfair). +// Uses Option A (server-paced): the server processes one frame at a time, +// sends results, and the browser seeks the video to match. + +// ── DOM elements ──────────────────────────────────────────────────── const videoUpload = document.getElementById("videoUpload"); const video = document.getElementById("video"); const overlay = document.getElementById("overlay"); const overlayCtx = overlay.getContext("2d"); -const hiddenCanvas = document.getElementById("hiddenCanvas"); -const hiddenCtx = hiddenCanvas.getContext("2d"); - -const playPauseBtn = document.getElementById("playPauseBtn"); const startDetectionBtn = document.getElementById("startDetectionBtn"); const stopDetectionBtn = document.getElementById("stopDetectionBtn"); -const modelStatus = document.getElementById("modelStatus"); +const serverStatus = document.getElementById("serverStatus"); const detectionStatus = document.getElementById("detectionStatus"); -const personCount = document.getElementById("personCount"); +const currentCount = document.getElementById("currentCount"); +const totalUnique = document.getElementById("totalUnique"); +const progressInfo = document.getElementById("progressInfo"); const scoreThresholdInput = document.getElementById("scoreThreshold"); const scoreThresholdValue = document.getElementById("scoreThresholdValue"); +const frameSkipRange = document.getElementById("frameSkipRange"); +const frameSkipValue = document.getElementById("frameSkipValue"); + +// ── State ─────────────────────────────────────────────────────────── + +// ADDED: session_id received from server after uploading video +let sessionId = null; +// ADDED: WebSocket connection to the backend +let ws = null; +// ADDED: video file selected by the user (needed for both local preview and upload) +let selectedFile = null; +// ADDED: video metadata from server (fps, total frames) +let videoMeta = null; -const intervalRange = document.getElementById("intervalRange"); -const intervalValue = document.getElementById("intervalValue"); +// ── Slider listeners ──────────────────────────────────────────────── scoreThresholdInput.addEventListener("input", () => { scoreThresholdValue.textContent = Number(scoreThresholdInput.value).toFixed(2); }); -intervalRange.addEventListener("input", () => { - intervalValue.textContent = intervalRange.value; - if (isDetecting) { - restartDetection(); - } +frameSkipRange.addEventListener("input", () => { + frameSkipValue.textContent = frameSkipRange.value; }); -async function loadModel() { - try { - modelStatus.textContent = "Chargement du modèle..."; - model = await cocoSsd.load({ - base: "lite_mobilenet_v2" - }); - modelStatus.textContent = "Modèle chargé"; - } catch (error) { - console.error("Erreur lors du chargement du modèle :", error); - modelStatus.textContent = "Erreur de chargement"; - } -} +// ── Canvas helpers ────────────────────────────────────────────────── -function resizeCanvases() { +function resizeOverlay() { const rect = video.getBoundingClientRect(); - overlay.width = rect.width; overlay.height = rect.height; overlay.style.width = `${rect.width}px`; overlay.style.height = `${rect.height}px`; - - hiddenCanvas.width = video.videoWidth; - hiddenCanvas.height = video.videoHeight; } function clearOverlay() { overlayCtx.clearRect(0, 0, overlay.width, overlay.height); } -function drawPredictions(predictions) { - clearOverlay(); +// ── Drawing ───────────────────────────────────────────────────────── - const scaleX = overlay.width / video.videoWidth; - const scaleY = overlay.height / video.videoHeight; +// CHANGED: now receives server JSON data with track_ids instead of +// TF.js prediction objects. Draws boxes with track ID labels. - overlayCtx.lineWidth = 2; - overlayCtx.font = "16px Arial"; +// assign a stable color to each track ID so the same person keeps the same color +const trackColors = {}; +const colorPalette = [ + "#00FF7F", "#FF6347", "#1E90FF", "#FFD700", "#FF69B4", + "#00CED1", "#FF8C00", "#8A2BE2", "#32CD32", "#DC143C", +]; - predictions.forEach((pred) => { - const [x, y, width, height] = pred.bbox; +function getTrackColor(trackId) { + if (!(trackId in trackColors)) { + trackColors[trackId] = colorPalette[Object.keys(trackColors).length % colorPalette.length]; + } + return trackColors[trackId]; +} - const drawX = x * scaleX; - const drawY = y * scaleY; - const drawW = width * scaleX; - const drawH = height * scaleY; +function drawDetections(data) { + clearOverlay(); - overlayCtx.strokeStyle = "#00FF7F"; - overlayCtx.fillStyle = "#00FF7F"; + // scale factor: server sends pixel coords for the original video resolution, + // but the canvas may be displayed at a different size + const scaleX = overlay.width / video.videoWidth; + const scaleY = overlay.height / video.videoHeight; + overlayCtx.lineWidth = 2; + overlayCtx.font = "14px Arial"; + + for (let i = 0; i < data.boxes.length; i++) { + const [x1, y1, x2, y2] = data.boxes[i]; + const trackId = data.track_ids[i]; + const score = data.scores[i]; + const cls = data.classes[i]; + const color = getTrackColor(trackId); + + const drawX = x1 * scaleX; + const drawY = y1 * scaleY; + const drawW = (x2 - x1) * scaleX; + const drawH = (y2 - y1) * scaleY; + + // draw bounding box + overlayCtx.strokeStyle = color; overlayCtx.strokeRect(drawX, drawY, drawW, drawH); - const text = `person ${(pred.score * 100).toFixed(1)}%`; + // draw label background + text with track ID, class name, and score + const text = `#${trackId} ${cls} ${(score * 100).toFixed(0)}%`; const textWidth = overlayCtx.measureText(text).width; - const textHeight = 20; + const textHeight = 18; - overlayCtx.fillRect(drawX, Math.max(0, drawY - textHeight), textWidth + 10, textHeight); + overlayCtx.fillStyle = color; + overlayCtx.fillRect(drawX, Math.max(0, drawY - textHeight), textWidth + 8, textHeight); overlayCtx.fillStyle = "#000000"; - overlayCtx.fillText(text, drawX + 5, Math.max(15, drawY - 5)); - }); -} - -async function detectFrame() { - if (!model || video.paused || video.ended || video.readyState < 2) { - return; + overlayCtx.fillText(text, drawX + 4, Math.max(14, drawY - 4)); } +} - try { - hiddenCtx.drawImage(video, 0, 0, hiddenCanvas.width, hiddenCanvas.height); +// ── Upload ────────────────────────────────────────────────────────── - const predictions = await model.detect(hiddenCanvas); +// ADDED: upload the video file to the server via POST /upload - const threshold = Number(scoreThresholdInput.value); +async function uploadVideo(file) { + serverStatus.textContent = "Envoi de la vidéo..."; - const personPredictions = predictions.filter( - (pred) => pred.class === "person" && pred.score >= threshold - ); + const formData = new FormData(); + formData.append("file", file); - personCount.textContent = personPredictions.length.toString(); - drawPredictions(personPredictions); - } catch (error) { - console.error("Erreur pendant la détection :", error); + const resp = await fetch("/upload", { method: "POST", body: formData }); + if (!resp.ok) { + serverStatus.textContent = "Erreur d'envoi"; + throw new Error("Upload failed"); } -} -function startDetection() { - if (!model || isDetecting) return; - - isDetecting = true; - detectionStatus.textContent = "Active"; + const result = await resp.json(); + serverStatus.textContent = "Vidéo reçue par le serveur"; + return result.session_id; +} - const intervalMs = Number(intervalRange.value); +// ── WebSocket detection ───────────────────────────────────────────── - detectionInterval = setInterval(() => { - detectFrame(); - }, intervalMs); +// ADDED: open a WebSocket to /ws/detect, send config, receive frame-by-frame results - startDetectionBtn.disabled = true; - stopDetectionBtn.disabled = false; +function startDetection() { + if (!sessionId) return; + + // build WebSocket URL from the current page location + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + const wsUrl = `${protocol}//${window.location.host}/ws/detect`; + ws = new WebSocket(wsUrl); + + ws.onopen = () => { + detectionStatus.textContent = "Active — traitement en cours..."; + startDetectionBtn.disabled = true; + stopDetectionBtn.disabled = false; + + // send session config to the server to start processing + ws.send(JSON.stringify({ + session_id: sessionId, + score_thr: Number(scoreThresholdInput.value), + frame_skip: Number(frameSkipRange.value), + })); + }; + + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type === "metadata") { + // server tells us the video FPS and total frames + videoMeta = data; + return; + } + + if (data.type === "detection") { + // server processed a frame — seek the video to that time and draw boxes + if (videoMeta) { + video.currentTime = data.time; + } + + drawDetections(data); + + // update counters + currentCount.textContent = data.current_count; + totalUnique.textContent = data.total_unique; + + // update progress + if (videoMeta && videoMeta.total_frames > 0) { + const pct = Math.round((data.frame / videoMeta.total_frames) * 100); + progressInfo.textContent = `Image ${data.frame} / ${videoMeta.total_frames} (${pct}%)`; + } + return; + } + + if (data.type === "done") { + // server finished processing the entire video + detectionStatus.textContent = "Terminé"; + totalUnique.textContent = data.total_unique; + progressInfo.textContent = "Terminé"; + stopDetectionBtn.disabled = true; + return; + } + + if (data.type === "error") { + detectionStatus.textContent = `Erreur : ${data.message}`; + return; + } + }; + + ws.onclose = () => { + detectionStatus.textContent = detectionStatus.textContent === "Terminé" + ? "Terminé" : "Connexion fermée"; + startDetectionBtn.disabled = false; + stopDetectionBtn.disabled = true; + }; + + ws.onerror = () => { + detectionStatus.textContent = "Erreur WebSocket"; + }; } function stopDetection() { - isDetecting = false; - detectionStatus.textContent = "Inactive"; - - if (detectionInterval) { - clearInterval(detectionInterval); - detectionInterval = null; + if (ws) { + ws.close(); + ws = null; } - + detectionStatus.textContent = "Arrêtée"; clearOverlay(); - personCount.textContent = "0"; - + currentCount.textContent = "0"; startDetectionBtn.disabled = false; stopDetectionBtn.disabled = true; } -function restartDetection() { - stopDetection(); - startDetection(); -} +// ── Event listeners ───────────────────────────────────────────────── -videoUpload.addEventListener("change", (event) => { +// CHANGED: on file select, show local preview AND upload to server +videoUpload.addEventListener("change", async (event) => { const file = event.target.files[0]; if (!file) return; + selectedFile = file; + + // show local video preview so the user sees the video const videoURL = URL.createObjectURL(file); video.src = videoURL; - playPauseBtn.disabled = false; - startDetectionBtn.disabled = false; + // upload to server in the background + try { + sessionId = await uploadVideo(file); + startDetectionBtn.disabled = false; + } catch (err) { + console.error("Upload error:", err); + } }); video.addEventListener("loadedmetadata", () => { - resizeCanvases(); -}); - -video.addEventListener("play", () => { - resizeCanvases(); + resizeOverlay(); }); window.addEventListener("resize", () => { if (video.videoWidth > 0) { - resizeCanvases(); + resizeOverlay(); clearOverlay(); } }); -playPauseBtn.addEventListener("click", () => { - if (video.paused) { - video.play(); - playPauseBtn.textContent = "Pause"; - } else { - video.pause(); - playPauseBtn.textContent = "Lecture"; - } -}); - startDetectionBtn.addEventListener("click", () => { startDetection(); }); @@ -199,14 +273,3 @@ startDetectionBtn.addEventListener("click", () => { stopDetectionBtn.addEventListener("click", () => { stopDetection(); }); - -video.addEventListener("pause", () => { - playPauseBtn.textContent = "Lecture"; -}); - -video.addEventListener("ended", () => { - playPauseBtn.textContent = "Lecture"; - stopDetection(); -}); - -loadModel(); \ No newline at end of file diff --git a/website/index.html b/website/index.html index 78e85d1..f869ce9 100644 --- a/website/index.html +++ b/website/index.html @@ -3,27 +3,26 @@ - Détection de personnes sur vidéo + FastCrowdVision — Détection et suivi de personnes - - - - - +
-

Détection de personnes sur vidéo

+

FastCrowdVision — Détection et suivi de personnes

- Téléverse une vidéo et lance la détection des personnes pendant la lecture. + Téléverse une vidéo, le serveur détecte et suit les personnes image par image.

+ - +
@@ -32,49 +31,54 @@

Détection de personnes sur vidéo

+
-
-

Statut modèle : Chargement...

+

Statut serveur : En attente

Statut détection : Inactive

-

Nombre de personnes : 0

+ +

Personnes visibles : 0

+

Total personnes uniques : 0

+ +

Progression :

- +
- - + - \ No newline at end of file + diff --git a/website/style.css b/website/style.css index 9b86bb3..e476b39 100644 --- a/website/style.css +++ b/website/style.css @@ -86,10 +86,21 @@ input[type="file"] { width: 100%; } +/* ADDED: highlight the total unique count so it stands out */ .status-panel p { margin: 6px 0; } +.status-panel #totalUnique { + font-size: 1.3em; + font-weight: 700; + color: #2563eb; +} + +.status-panel #currentCount { + font-weight: 600; +} + .video-wrapper { position: relative; width: 100%; @@ -113,6 +124,4 @@ input[type="file"] { pointer-events: none; } -.hidden-canvas { - display: none; -} \ No newline at end of file +/* REMOVED: .hidden-canvas — no longer needed (server reads frames via OpenCV) */ \ No newline at end of file From d40afb5986777ef04b19378923da58f8a9da98cd Mon Sep 17 00:00:00 2001 From: Artur Ayrapetyan Date: Thu, 16 Apr 2026 16:05:28 +0200 Subject: [PATCH 02/13] bug fixed : multiple videos sequentially --- server.py | 8 +++---- website/app.js | 60 ++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 47 insertions(+), 21 deletions(-) diff --git a/server.py b/server.py index 9787af2..4b8b8d8 100644 --- a/server.py +++ b/server.py @@ -43,7 +43,7 @@ def startup(): print("Server ready — model loaded on", device) -# ── Upload endpoint ────────────────────────────────────────────────── +MAX_VIDEO_DURATION_SEC=40 @app.post("/upload") async def upload_video(file: UploadFile = File(...)): @@ -105,6 +105,7 @@ async def detect_ws(websocket: WebSocket): hit_counter_max=15, initialization_delay=3, ) + max_frame=int(fps*MAX_VIDEO_DURATION_SEC) # keep track of every unique ID seen across all frames all_track_ids: set[int] = set() @@ -112,7 +113,7 @@ async def detect_ws(websocket: WebSocket): while True: ret, frame_bgr = cap.read() - if not ret: + if not ret or frame_idx >=max_frame: break # skip frames if requested (frame_skip=1 means process every 2nd frame) @@ -210,6 +211,5 @@ async def detect_ws(websocket: WebSocket): # ── Serve the website static files (HTML, CSS, JS) ────────────────── -# This is mounted LAST so that /upload and /ws/detect are matched first. -# html=True makes it serve index.html when you visit http://localhost:8000/ + app.mount("/", StaticFiles(directory=os.path.join(project_root, "website"), html=True), name="website") diff --git a/website/app.js b/website/app.js index 8bbcacd..a82d732 100644 --- a/website/app.js +++ b/website/app.js @@ -1,8 +1,5 @@ // app.js — Frontend for FastCrowdVision. -// -// REWRITTEN: replaced TF.js COCO-SSD (client-side detection) with -// WebSocket communication to the Python backend (server-side SSD + norfair). -// Uses Option A (server-paced): the server processes one frame at a time, +// WebSocket communication to the Python backend (server-side SSD + norfair tracking). // sends results, and the browser seeks the video to match. // ── DOM elements ──────────────────────────────────────────────────── @@ -28,14 +25,11 @@ const frameSkipValue = document.getElementById("frameSkipValue"); // ── State ─────────────────────────────────────────────────────────── -// ADDED: session_id received from server after uploading video let sessionId = null; -// ADDED: WebSocket connection to the backend let ws = null; -// ADDED: video file selected by the user (needed for both local preview and upload) let selectedFile = null; -// ADDED: video metadata from server (fps, total frames) let videoMeta = null; +let currentVideoURL = null; // ── Slider listeners ──────────────────────────────────────────────── @@ -195,11 +189,16 @@ function startDetection() { } if (data.type === "done") { - // server finished processing the entire video + // server finished processing the entire video — reset UI so user can + // upload a new video without reloading the page detectionStatus.textContent = "Terminé"; totalUnique.textContent = data.total_unique; progressInfo.textContent = "Terminé"; stopDetectionBtn.disabled = true; + startDetectionBtn.disabled = true; + ws = null; + sessionId = null; + serverStatus.textContent = "Prêt — choisissez une nouvelle vidéo"; return; } @@ -210,9 +209,12 @@ function startDetection() { }; ws.onclose = () => { - detectionStatus.textContent = detectionStatus.textContent === "Terminé" - ? "Terminé" : "Connexion fermée"; - startDetectionBtn.disabled = false; + // CHANGED: don't overwrite "Terminé" status, and don't re-enable start + // button — user needs to upload a new video first + if (detectionStatus.textContent !== "Terminé") { + detectionStatus.textContent = "Connexion fermée"; + startDetectionBtn.disabled = !sessionId; + } stopDetectionBtn.disabled = true; }; @@ -235,23 +237,47 @@ function stopDetection() { // ── Event listeners ───────────────────────────────────────────────── -// CHANGED: on file select, show local preview AND upload to server +// CHANGED: on file select, show local preview AND upload to server. +// Also resets all state from the previous video so the user doesn't +// need to reload the page. videoUpload.addEventListener("change", async (event) => { const file = event.target.files[0]; if (!file) return; selectedFile = file; - // show local video preview so the user sees the video - const videoURL = URL.createObjectURL(file); - video.src = videoURL; + // reset all state from the previous video + if (ws) { ws.close(); ws = null; } + sessionId = null; + videoMeta = null; + for (const key in trackColors) delete trackColors[key]; + clearOverlay(); + currentCount.textContent = "0"; + totalUnique.textContent = "0"; + progressInfo.textContent = "—"; + detectionStatus.textContent = "Envoi en cours…"; + startDetectionBtn.disabled = true; + stopDetectionBtn.disabled = true; + + // revoke old blob URL to free memory, then create a new one + if (currentVideoURL) URL.revokeObjectURL(currentVideoURL); + currentVideoURL = URL.createObjectURL(file); + video.src = currentVideoURL; + video.load(); + + // reset file input so the same file can be re-selected later + event.target.value = ""; // upload to server in the background try { sessionId = await uploadVideo(file); - startDetectionBtn.disabled = false; + if (sessionId) { + startDetectionBtn.disabled = false; + detectionStatus.textContent = "Prêt — lancez la détection"; + } } catch (err) { console.error("Upload error:", err); + detectionStatus.textContent = "Erreur lors de l'envoi"; } }); From 02a03f792d7c813a2711d9505cde213d1191c793 Mon Sep 17 00:00:00 2001 From: Artur Ayrapetyan Date: Thu, 16 Apr 2026 20:42:28 +0200 Subject: [PATCH 03/13] bug fixed : multiple videos sequentially --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 5aba6ba..1473d50 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,10 @@ # FastCrowdVision Implementing vision algorithms for human detection on mobile devices. + + +for inference uvicorn server:app --reload + ## Setup ``` cp .env.example .env From d7f83e81871f29234978100e5b738b17c2362b8d Mon Sep 17 00:00:00 2001 From: josiepierr Date: Sat, 18 Apr 2026 13:52:14 +0200 Subject: [PATCH 04/13] =?UTF-8?q?fichiers=20n=C3=A9cessaires=20pour=20l'AP?= =?UTF-8?q?I?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 51 +++++++++++++++++ .github/docker-deploy.yml | 109 +++++++++++++++++++++++++++++++++++++ Dockerfile | 75 +++++++++++++++++++++++++ kubernetes/deployment.yaml | 96 ++++++++++++++++++++++++++++++++ kubernetes/ingress.yaml | 41 ++++++++++++++ kubernetes/pvc.yaml | 18 ++++++ kubernetes/service.yaml | 19 +++++++ server.py | 4 ++ 8 files changed, 413 insertions(+) create mode 100644 .dockerignore create mode 100644 .github/docker-deploy.yml create mode 100644 Dockerfile create mode 100644 kubernetes/deployment.yaml create mode 100644 kubernetes/ingress.yaml create mode 100644 kubernetes/pvc.yaml create mode 100644 kubernetes/service.yaml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a61c160 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# .dockerignore — exclut les fichiers inutiles de l'image Docker + +# Git +.git/ +.gitignore + +# Python +__pycache__/ +*.py[cod] +*.pyo +.pytest_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ + +# Environnements virtuels +.venv/ +venv/ +env/ +my_env/ + +# Données & modèles locaux (téléchargés au runtime via HuggingFace / S3) +data/ +*.pth +*.pt +datasets/WiderPeople/images/ +datasets/voc/images/ +train/ +val/ +test/ + +# Notebooks & scripts d'entraînement (pas nécessaires dans l'API) +main.ipynb +SsdTrainingPipelineVOC2007.py +*.ipynb + +# Secrets +.env +*.env + +# CI/CD & outils +.github/ +mlruns/ +wandb/ + +# Divers +*.log +*.zip +*.tar.gz +.DS_Store diff --git a/.github/docker-deploy.yml b/.github/docker-deploy.yml new file mode 100644 index 0000000..5967cfc --- /dev/null +++ b/.github/docker-deploy.yml @@ -0,0 +1,109 @@ +# ────────────────────────────────────────────────────────────────────────────── +# .github/workflows/docker-deploy.yml +# Pipeline CI/CD : build → push image → deploy sur SSP Cloud +# +# Secrets GitHub à configurer (Settings → Secrets and variables → Actions) : +# REGISTRY_URL : ex. harbor.lab.sspcloud.fr +# REGISTRY_USER : votre identifiant Harbor +# REGISTRY_PASSWORD : votre mot de passe / token Harbor +# ────────────────────────────────────────────────────────────────────────────── +name: Build & Deploy FastCrowdVision + +on: + push: + branches: + - main + tags: + - "v*" + +env: + IMAGE_NAME: fastcrowdvision + +jobs: + # ── 1. Tests rapides ───────────────────────────────────────────────────── + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install test dependencies + run: pip install pytest torch torchvision --quiet + + - name: Run unit tests + run: pytest tests/ -v --tb=short + + # ── 2. Build & Push image Docker ───────────────────────────────────────── + build-push: + needs: test + runs-on: ubuntu-latest + outputs: + image_tag: ${{ steps.meta.outputs.tags }} + + steps: + - uses: actions/checkout@v4 + + - name: Docker meta (tags & labels) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.REGISTRY_URL }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix=sha-,format=short + + - name: Log in to Harbor registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_URL }} + username: ${{ secrets.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:cache + cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:cache,mode=max + + # ── 3. Déploiement sur SSP Cloud (kubectl apply) ───────────────────────── + deploy: + needs: build-push + runs-on: ubuntu-latest + # Ne déployer que depuis main ou un tag de release + if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') + + steps: + - uses: actions/checkout@v4 + + - name: Set image tag in deployment manifest + run: | + SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) + IMAGE="${{ secrets.REGISTRY_URL }}/fastcrowdvision:sha-${SHORT_SHA}" + sed -i "s|/fastcrowdvision:|${IMAGE}|g" kubernetes/deployment.yaml + + - name: Configure kubectl (kubeconfig SSP Cloud) + run: | + mkdir -p $HOME/.kube + echo "${{ secrets.KUBECONFIG_SSP }}" | base64 -d > $HOME/.kube/config + + - name: Apply Kubernetes manifests + run: | + kubectl apply -f kubernetes/pvc.yaml + kubectl apply -f kubernetes/deployment.yaml + kubectl apply -f kubernetes/service.yaml + kubectl apply -f kubernetes/ingress.yaml + + - name: Wait for rollout + run: kubectl rollout status deployment/fastcrowdvision --timeout=180s diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e56a5fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# ────────────────────────────────────────────────────────────────────────────── +# FastCrowdVision — Dockerfile +# Build: docker build -t fastcrowdvision:latest . +# Run: docker run -p 8000:8000 fastcrowdvision:latest +# ────────────────────────────────────────────────────────────────────────────── + +# --- Stage 1 : builder (installe les dépendances) ---------------------------- +FROM python:3.11-slim AS builder + +# Variables pour éviter les .pyc et le buffering +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Dépendances système pour OpenCV et torch (libGL, libgthread...) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + libgomp1 \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copie uniquement le fichier de dépendances en premier (layer cache) +COPY requirements.txt . + +# Installation des dépendances Python dans un venv dédié +RUN python -m venv /opt/venv && \ + /opt/venv/bin/pip install --upgrade pip && \ + /opt/venv/bin/pip install --no-cache-dir -r requirements.txt + +# --- Stage 2 : image finale (allégée) ---------------------------------------- +FROM python:3.11-slim AS runtime + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PATH="/opt/venv/bin:$PATH" \ + # Répertoire de cache HuggingFace — monté via PVC en prod + HF_HOME=/app/.cache/huggingface + +WORKDIR /app + +# Libs runtime seulement (pas de git/build tools) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +# Récupération du venv construit à l'étape précédente +COPY --from=builder /opt/venv /opt/venv + +# Copie du code applicatif +COPY . . + +# Création d'un utilisateur non-root (bonne pratique sécurité) +RUN useradd -m -u 1000 appuser && \ + mkdir -p /app/.cache/huggingface && \ + chown -R appuser:appuser /app + +USER appuser + +# Port exposé par uvicorn +EXPOSE 8000 + +# Healthcheck basique +HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +# Démarrage de l'API avec uvicorn +CMD ["uvicorn", "server:app", \ + "--host", "0.0.0.0", \ + "--port", "8000", \ + "--workers", "1", \ + "--timeout-keep-alive", "75"] diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml new file mode 100644 index 0000000..4f474b6 --- /dev/null +++ b/kubernetes/deployment.yaml @@ -0,0 +1,96 @@ +# ────────────────────────────────────────────────────────────────────────────── +# kubernetes/deployment.yaml +# Déploiement FastCrowdVision sur le SSP Cloud (Onyxia / Kubernetes) +# +# Remplacer par votre registry (ex: harbor.lab.sspcloud.fr/votreuser) +# Remplacer par le tag de votre image (ex: latest, v1.0.0) +# ────────────────────────────────────────────────────────────────────────────── +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fastcrowdvision + labels: + app: fastcrowdvision + version: "1.0" +spec: + replicas: 1 + selector: + matchLabels: + app: fastcrowdvision + template: + metadata: + labels: + app: fastcrowdvision + spec: + # Sécurité : pas de root, filesystem read-only (sauf /tmp et cache HF) + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + + containers: + - name: fastcrowdvision + image: /fastcrowdvision: + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + + env: + # Le cache HuggingFace est stocké dans le volume persistant + - name: HF_HOME + value: /cache/huggingface + # Désactive la télémétrie HuggingFace + - name: TRANSFORMERS_OFFLINE + value: "0" + + # Ressources : ajuster selon les nœuds disponibles sur le SSP Cloud + resources: + requests: + cpu: "500m" + memory: "2Gi" + limits: + cpu: "2" + memory: "4Gi" + + # Sonde de démarrage : attend que le modèle soit chargé (jusqu'à 3 min) + startupProbe: + httpGet: + path: /health + port: 8000 + failureThreshold: 18 + periodSeconds: 10 + + # Sonde de vie : redémarre le pod si l'API ne répond plus + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 10 + periodSeconds: 30 + failureThreshold: 3 + + # Sonde de disponibilité : retire le pod du Service si pas prêt + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 5 + periodSeconds: 10 + + volumeMounts: + # Cache HuggingFace persistant (évite de re-télécharger le modèle) + - name: hf-cache + mountPath: /cache/huggingface + # /tmp en mémoire pour les fichiers vidéo temporaires + - name: tmp-dir + mountPath: /tmp + + volumes: + - name: hf-cache + persistentVolumeClaim: + claimName: fastcrowdvision-hf-cache + - name: tmp-dir + emptyDir: + medium: Memory + sizeLimit: 2Gi diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml new file mode 100644 index 0000000..9338db8 --- /dev/null +++ b/kubernetes/ingress.yaml @@ -0,0 +1,41 @@ +# ────────────────────────────────────────────────────────────────────────────── +# kubernetes/ingress.yaml +# Expose l'API publiquement via l'Ingress du SSP Cloud +# +# Remplacer user-pmakamwe-ensae par votre namespace Onyxia (ex: user-votreuser) +# L'URL finale sera : https://fastcrowdvision.user-pmakamwe-ensae.lab.sspcloud.fr +# ────────────────────────────────────────────────────────────────────────────── +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fastcrowdvision + annotations: + # TLS géré automatiquement par cert-manager (déjà configuré sur SSP Cloud) + kubernetes.io/ingress.class: nginx + cert-manager.io/cluster-issuer: letsencrypt-prod + # Taille max de l'upload (vidéos pouvant être volumineuses) + nginx.ingress.kubernetes.io/proxy-body-size: "500m" + # Timeout élevé pour le WebSocket de détection long + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + # Activation du support WebSocket + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" + nginx.ingress.kubernetes.io/configuration-snippet: | + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; +spec: + tls: + - hosts: + - fastcrowdvision.user-pmakamwe-ensae.lab.sspcloud.fr + secretName: fastcrowdvision-tls + rules: + - host: fastcrowdvision.user-pmakamwe-ensae.lab.sspcloud.fr + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: fastcrowdvision + port: + name: http diff --git a/kubernetes/pvc.yaml b/kubernetes/pvc.yaml new file mode 100644 index 0000000..e3b6137 --- /dev/null +++ b/kubernetes/pvc.yaml @@ -0,0 +1,18 @@ +# ────────────────────────────────────────────────────────────────────────────── +# kubernetes/pvc.yaml +# Volume persistant pour le cache HuggingFace +# (évite de re-télécharger les poids du modèle à chaque redémarrage du pod) +# ────────────────────────────────────────────────────────────────────────────── +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: fastcrowdvision-hf-cache + labels: + app: fastcrowdvision +spec: + accessModes: + - ReadWriteOnce + storageClassName: longhorn # StorageClass disponible sur le SSP Cloud + resources: + requests: + storage: 5Gi # ~1.5 Go pour les poids + marge diff --git a/kubernetes/service.yaml b/kubernetes/service.yaml new file mode 100644 index 0000000..2ef8990 --- /dev/null +++ b/kubernetes/service.yaml @@ -0,0 +1,19 @@ +# ────────────────────────────────────────────────────────────────────────────── +# kubernetes/service.yaml +# Expose l'API FastCrowdVision en interne au cluster +# ────────────────────────────────────────────────────────────────────────────── +apiVersion: v1 +kind: Service +metadata: + name: fastcrowdvision + labels: + app: fastcrowdvision +spec: + selector: + app: fastcrowdvision + ports: + - name: http + protocol: TCP + port: 80 + targetPort: 8000 + type: ClusterIP diff --git a/server.py b/server.py index 4b8b8d8..623ac37 100644 --- a/server.py +++ b/server.py @@ -21,6 +21,10 @@ from inference import load_ssd_model, detect_frame app = FastAPI() +@app.get("/health") +def health(): + """Endpoint utilisé par le HEALTHCHECK Docker et les sondes Kubernetes.""" + return {"status": "ok", "model_loaded": model is not None} # --- globals: loaded once at startup, shared across all requests --- model = None From 391c91915430fe859ad4104e68ad976d800ea3a9 Mon Sep 17 00:00:00 2001 From: josiepierr Date: Sat, 18 Apr 2026 23:29:02 +0200 Subject: [PATCH 05/13] bonnes versions de fichiers --- .github/docker-deploy.yml | 78 ++++++++++++++------------------------ Dockerfile | 63 ++++++++++++++---------------- kubernetes/deployment.yaml | 32 ++++------------ kubernetes/ingress.yaml | 34 ++++++++--------- kubernetes/pvc.yaml | 6 ++- requirements-api.txt | 26 +++++++++++++ 6 files changed, 112 insertions(+), 127 deletions(-) create mode 100644 requirements-api.txt diff --git a/.github/docker-deploy.yml b/.github/docker-deploy.yml index 5967cfc..0973440 100644 --- a/.github/docker-deploy.yml +++ b/.github/docker-deploy.yml @@ -1,12 +1,16 @@ -# ────────────────────────────────────────────────────────────────────────────── +# ───────────────────────────────────────────────────────────────── # .github/workflows/docker-deploy.yml -# Pipeline CI/CD : build → push image → deploy sur SSP Cloud +# Pipeline CI/CD — conforme au cours ENSAE "Mise en production" # -# Secrets GitHub à configurer (Settings → Secrets and variables → Actions) : -# REGISTRY_URL : ex. harbor.lab.sspcloud.fr -# REGISTRY_USER : votre identifiant Harbor -# REGISTRY_PASSWORD : votre mot de passe / token Harbor -# ────────────────────────────────────────────────────────────────────────────── +# Reproduit exactement le schéma du cours : +# push sur main → build image Docker → push sur Docker Hub +# +# Secrets GitHub à configurer : +# DOCKERHUB_USERNAME : votre identifiant Docker Hub +# DOCKERHUB_TOKEN : Access Token Docker Hub +# (hub.docker.com → Account Settings → Security) +# KUBECONFIG_SSP : kubeconfig SSP Cloud encodé en base64 +# ───────────────────────────────────────────────────────────────── name: Build & Deploy FastCrowdVision on: @@ -16,56 +20,34 @@ on: tags: - "v*" -env: - IMAGE_NAME: fastcrowdvision - jobs: - # ── 1. Tests rapides ───────────────────────────────────────────────────── - test: + docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python 3.11 - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install test dependencies - run: pip install pytest torch torchvision --quiet + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 - - name: Run unit tests - run: pytest tests/ -v --tb=short - - # ── 2. Build & Push image Docker ───────────────────────────────────────── - build-push: - needs: test - runs-on: ubuntu-latest - outputs: - image_tag: ${{ steps.meta.outputs.tags }} - - steps: - - uses: actions/checkout@v4 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 - - name: Docker meta (tags & labels) + - name: Docker meta id: meta uses: docker/metadata-action@v5 with: - images: ${{ secrets.REGISTRY_URL }}/${{ env.IMAGE_NAME }} + images: ${{ secrets.DOCKERHUB_USERNAME }}/fastcrowdvision tags: | type=ref,event=branch type=semver,pattern={{version}} type=sha,prefix=sha-,format=short + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - name: Log in to Harbor registry + - name: Login to Docker Hub uses: docker/login-action@v3 with: - registry: ${{ secrets.REGISTRY_URL }} - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_PASSWORD }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push uses: docker/build-push-action@v5 @@ -74,26 +56,22 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - cache-from: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:cache - cache-to: type=registry,ref=${{ secrets.REGISTRY_URL }}/${{ env.IMAGE_NAME }}:cache,mode=max - # ── 3. Déploiement sur SSP Cloud (kubectl apply) ───────────────────────── deploy: - needs: build-push + needs: docker runs-on: ubuntu-latest - # Ne déployer que depuis main ou un tag de release if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') - steps: - uses: actions/checkout@v4 - - name: Set image tag in deployment manifest + - name: Set image in deployment manifest run: | SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - IMAGE="${{ secrets.REGISTRY_URL }}/fastcrowdvision:sha-${SHORT_SHA}" - sed -i "s|/fastcrowdvision:|${IMAGE}|g" kubernetes/deployment.yaml + IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/fastcrowdvision:sha-${SHORT_SHA}" + sed -i "s|/fastcrowdvision:|${IMAGE}|g" \ + kubernetes/deployment.yaml - - name: Configure kubectl (kubeconfig SSP Cloud) + - name: Configure kubectl run: | mkdir -p $HOME/.kube echo "${{ secrets.KUBECONFIG_SSP }}" | base64 -d > $HOME/.kube/config diff --git a/Dockerfile b/Dockerfile index e56a5fa..fd98239 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,75 +1,68 @@ -# ────────────────────────────────────────────────────────────────────────────── +# ───────────────────────────────────────────────────────────────── # FastCrowdVision — Dockerfile -# Build: docker build -t fastcrowdvision:latest . -# Run: docker run -p 8000:8000 fastcrowdvision:latest -# ────────────────────────────────────────────────────────────────────────────── - -# --- Stage 1 : builder (installe les dépendances) ---------------------------- +# Conforme aux bonnes pratiques du cours ENSAE "Mise en production" +# +# Points clés : +# - Image de base slim → image légère (~600 Mo vs ~3 Go) +# - uv pour installer les dépendances (recommandé dans le cours) +# - Séparation build / runtime (multi-stage) +# - Dépendances minimales : uniquement ce dont l'API a besoin +# - Utilisateur non-root +# ───────────────────────────────────────────────────────────────── + +# ── Stage 1 : installation des dépendances ──────────────────────── FROM python:3.11-slim AS builder -# Variables pour éviter les .pyc et le buffering -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 +# Copier uv depuis son image officielle (plus rapide et fiable que pip install uv) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv WORKDIR /app -# Dépendances système pour OpenCV et torch (libGL, libgthread...) +# Dépendances système minimales pour OpenCV headless et torch RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1 \ libglib2.0-0 \ - libgomp1 \ - git \ && rm -rf /var/lib/apt/lists/* -# Copie uniquement le fichier de dépendances en premier (layer cache) -COPY requirements.txt . +# Copier UNIQUEMENT le fichier de dépendances en premier +# → Docker met en cache cette couche et ne réinstalle pas si le code change +COPY requirements-api.txt . -# Installation des dépendances Python dans un venv dédié +# Créer un venv et installer les dépendances avec pip RUN python -m venv /opt/venv && \ /opt/venv/bin/pip install --upgrade pip && \ - /opt/venv/bin/pip install --no-cache-dir -r requirements.txt + /opt/venv/bin/pip install --no-cache-dir -r requirements-api.txt + -# --- Stage 2 : image finale (allégée) ---------------------------------------- +# ── Stage 2 : image finale allégée ─────────────────────────────── FROM python:3.11-slim AS runtime ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PATH="/opt/venv/bin:$PATH" \ - # Répertoire de cache HuggingFace — monté via PVC en prod HF_HOME=/app/.cache/huggingface WORKDIR /app -# Libs runtime seulement (pas de git/build tools) +# Libs runtime uniquement (pas d'outils de build) RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1 \ libglib2.0-0 \ - libgomp1 \ && rm -rf /var/lib/apt/lists/* -# Récupération du venv construit à l'étape précédente +# Récupérer le venv du stage builder COPY --from=builder /opt/venv /opt/venv -# Copie du code applicatif +# Copier le code applicatif +# .dockerignore exclut : données, notebooks, scripts d'entraînement, .git COPY . . -# Création d'un utilisateur non-root (bonne pratique sécurité) +# Utilisateur non-root (bonne pratique sécurité Kubernetes) RUN useradd -m -u 1000 appuser && \ mkdir -p /app/.cache/huggingface && \ chown -R appuser:appuser /app - USER appuser -# Port exposé par uvicorn EXPOSE 8000 -# Healthcheck basique -HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 - -# Démarrage de l'API avec uvicorn -CMD ["uvicorn", "server:app", \ - "--host", "0.0.0.0", \ - "--port", "8000", \ - "--workers", "1", \ - "--timeout-keep-alive", "75"] +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 4f474b6..c4e81ea 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -1,17 +1,14 @@ -# ────────────────────────────────────────────────────────────────────────────── +# ───────────────────────────────────────────────────────────────── # kubernetes/deployment.yaml -# Déploiement FastCrowdVision sur le SSP Cloud (Onyxia / Kubernetes) -# -# Remplacer par votre registry (ex: harbor.lab.sspcloud.fr/votreuser) -# Remplacer par le tag de votre image (ex: latest, v1.0.0) -# ────────────────────────────────────────────────────────────────────────────── +# ⚠️ Remplacer par votre compte Docker Hub +# Remplacer par le tag voulu (ex: latest) +# ───────────────────────────────────────────────────────────────── apiVersion: apps/v1 kind: Deployment metadata: name: fastcrowdvision labels: app: fastcrowdvision - version: "1.0" spec: replicas: 1 selector: @@ -22,29 +19,22 @@ spec: labels: app: fastcrowdvision spec: - # Sécurité : pas de root, filesystem read-only (sauf /tmp et cache HF) securityContext: runAsNonRoot: true runAsUser: 1000 - fsGroup: 1000 containers: - name: fastcrowdvision - image: /fastcrowdvision: + image: /fastcrowdvision: imagePullPolicy: Always ports: - containerPort: 8000 name: http env: - # Le cache HuggingFace est stocké dans le volume persistant - name: HF_HOME - value: /cache/huggingface - # Désactive la télémétrie HuggingFace - - name: TRANSFORMERS_OFFLINE - value: "0" + value: /app/.cache/huggingface - # Ressources : ajuster selon les nœuds disponibles sur le SSP Cloud resources: requests: cpu: "500m" @@ -53,7 +43,7 @@ spec: cpu: "2" memory: "4Gi" - # Sonde de démarrage : attend que le modèle soit chargé (jusqu'à 3 min) + # Attend que le modèle soit chargé (jusqu'à 3 min) startupProbe: httpGet: path: /health @@ -61,16 +51,13 @@ spec: failureThreshold: 18 periodSeconds: 10 - # Sonde de vie : redémarre le pod si l'API ne répond plus livenessProbe: httpGet: path: /health port: 8000 initialDelaySeconds: 10 periodSeconds: 30 - failureThreshold: 3 - # Sonde de disponibilité : retire le pod du Service si pas prêt readinessProbe: httpGet: path: /health @@ -79,10 +66,8 @@ spec: periodSeconds: 10 volumeMounts: - # Cache HuggingFace persistant (évite de re-télécharger le modèle) - name: hf-cache - mountPath: /cache/huggingface - # /tmp en mémoire pour les fichiers vidéo temporaires + mountPath: /app/.cache/huggingface - name: tmp-dir mountPath: /tmp @@ -92,5 +77,4 @@ spec: claimName: fastcrowdvision-hf-cache - name: tmp-dir emptyDir: - medium: Memory sizeLimit: 2Gi diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml index 9338db8..c264393 100644 --- a/kubernetes/ingress.yaml +++ b/kubernetes/ingress.yaml @@ -1,35 +1,35 @@ # ────────────────────────────────────────────────────────────────────────────── # kubernetes/ingress.yaml -# Expose l'API publiquement via l'Ingress du SSP Cloud +# Expose l'API publiquement via l'Ingress du SSP Cloud (Traefik + cert-manager) # -# Remplacer user-pmakamwe-ensae par votre namespace Onyxia (ex: user-votreuser) -# L'URL finale sera : https://fastcrowdvision.user-pmakamwe-ensae.lab.sspcloud.fr +# ⚠️ Remplacer par votre identifiant Onyxia. +# Votre namespace est toujours de la forme : user- +# Votre URL finale sera : +# https://fastcrowdvision.user-.lab.sspcloud.fr +# +# Comment trouver votre : +# datalab.sspcloud.fr → Mon compte → le namespace affiché dans la section +# Kubernetes, ou visible dans le kubeconfig téléchargé. # ────────────────────────────────────────────────────────────────────────────── apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: fastcrowdvision annotations: - # TLS géré automatiquement par cert-manager (déjà configuré sur SSP Cloud) - kubernetes.io/ingress.class: nginx + # SSP Cloud utilise Traefik comme ingress controller (pas nginx) + kubernetes.io/ingress.class: traefik + # TLS Let's Encrypt géré par cert-manager (déjà installé sur le cluster) cert-manager.io/cluster-issuer: letsencrypt-prod - # Taille max de l'upload (vidéos pouvant être volumineuses) - nginx.ingress.kubernetes.io/proxy-body-size: "500m" - # Timeout élevé pour le WebSocket de détection long - nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" - # Activation du support WebSocket - nginx.ingress.kubernetes.io/proxy-http-version: "1.1" - nginx.ingress.kubernetes.io/configuration-snippet: | - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + # Support WebSocket (nécessaire pour /ws/detect) + traefik.ingress.kubernetes.io/router.entrypoints: websecure + traefik.ingress.kubernetes.io/router.tls: "true" spec: tls: - hosts: - - fastcrowdvision.user-pmakamwe-ensae.lab.sspcloud.fr + - fastcrowdvision.user-.lab.sspcloud.fr secretName: fastcrowdvision-tls rules: - - host: fastcrowdvision.user-pmakamwe-ensae.lab.sspcloud.fr + - host: fastcrowdvision.user-.lab.sspcloud.fr http: paths: - path: / diff --git a/kubernetes/pvc.yaml b/kubernetes/pvc.yaml index e3b6137..5eace1a 100644 --- a/kubernetes/pvc.yaml +++ b/kubernetes/pvc.yaml @@ -2,6 +2,9 @@ # kubernetes/pvc.yaml # Volume persistant pour le cache HuggingFace # (évite de re-télécharger les poids du modèle à chaque redémarrage du pod) +# +# Sur le SSP Cloud, la StorageClass disponible est "onyxia" (par défaut). +# Pour vérifier : kubectl get storageclass # ────────────────────────────────────────────────────────────────────────────── apiVersion: v1 kind: PersistentVolumeClaim @@ -12,7 +15,8 @@ metadata: spec: accessModes: - ReadWriteOnce - storageClassName: longhorn # StorageClass disponible sur le SSP Cloud + # Laisser vide pour utiliser la StorageClass par défaut du cluster SSP Cloud + # ou spécifier "onyxia" si kubectl get storageclass la retourne resources: requests: storage: 5Gi # ~1.5 Go pour les poids + marge diff --git a/requirements-api.txt b/requirements-api.txt new file mode 100644 index 0000000..5126335 --- /dev/null +++ b/requirements-api.txt @@ -0,0 +1,26 @@ +# ───────────────────────────────────────────────────────────────── +# Dépendances UNIQUEMENT pour l'API (inférence + serveur). +# Ne PAS inclure : wandb, kagglehub, s3fs, duckdb, pyarrow, +# torchaudio, torchmetrics, faster-coco-eval (entraînement seul). +# ───────────────────────────────────────────────────────────────── + +# Deep learning — inférence seulement (pas torchaudio) +torch +torchvision + +# Vision & traitement image +pillow +numpy>=1.26,<2.2 +opencv-python-headless + +# Modèle & poids +huggingface_hub +pyyaml + +# API +fastapi +uvicorn[standard] +python-multipart + +# Tracking +norfair From 2735d93d9fc9444fd84ec69dbb743d42453e3b63 Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Sat, 18 Apr 2026 22:57:36 +0000 Subject: [PATCH 06/13] changes in .yaml files --- kubernetes/deployment.yaml | 4 +--- kubernetes/ingress.yaml | 14 ++++---------- kubernetes/pvc.yaml | 7 ------- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index c4e81ea..245e4af 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -1,7 +1,5 @@ # ───────────────────────────────────────────────────────────────── # kubernetes/deployment.yaml -# ⚠️ Remplacer par votre compte Docker Hub -# Remplacer par le tag voulu (ex: latest) # ───────────────────────────────────────────────────────────────── apiVersion: apps/v1 kind: Deployment @@ -25,7 +23,7 @@ spec: containers: - name: fastcrowdvision - image: /fastcrowdvision: + image: ghcr.io/josiepierr/fastcrowdvision:latest imagePullPolicy: Always ports: - containerPort: 8000 diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml index c264393..ea4775d 100644 --- a/kubernetes/ingress.yaml +++ b/kubernetes/ingress.yaml @@ -1,15 +1,9 @@ # ────────────────────────────────────────────────────────────────────────────── # kubernetes/ingress.yaml # Expose l'API publiquement via l'Ingress du SSP Cloud (Traefik + cert-manager) +# URL finale sera : +# https://fastcrowdvision.user-pmakamwe.lab.sspcloud.fr # -# ⚠️ Remplacer par votre identifiant Onyxia. -# Votre namespace est toujours de la forme : user- -# Votre URL finale sera : -# https://fastcrowdvision.user-.lab.sspcloud.fr -# -# Comment trouver votre : -# datalab.sspcloud.fr → Mon compte → le namespace affiché dans la section -# Kubernetes, ou visible dans le kubeconfig téléchargé. # ────────────────────────────────────────────────────────────────────────────── apiVersion: networking.k8s.io/v1 kind: Ingress @@ -26,10 +20,10 @@ metadata: spec: tls: - hosts: - - fastcrowdvision.user-.lab.sspcloud.fr + - fastcrowdvision.user-pmakamwe.lab.sspcloud.fr secretName: fastcrowdvision-tls rules: - - host: fastcrowdvision.user-.lab.sspcloud.fr + - host: fastcrowdvision.user-pmakamwe.lab.sspcloud.fr http: paths: - path: / diff --git a/kubernetes/pvc.yaml b/kubernetes/pvc.yaml index 5eace1a..49fdab8 100644 --- a/kubernetes/pvc.yaml +++ b/kubernetes/pvc.yaml @@ -1,10 +1,5 @@ # ────────────────────────────────────────────────────────────────────────────── # kubernetes/pvc.yaml -# Volume persistant pour le cache HuggingFace -# (évite de re-télécharger les poids du modèle à chaque redémarrage du pod) -# -# Sur le SSP Cloud, la StorageClass disponible est "onyxia" (par défaut). -# Pour vérifier : kubectl get storageclass # ────────────────────────────────────────────────────────────────────────────── apiVersion: v1 kind: PersistentVolumeClaim @@ -15,8 +10,6 @@ metadata: spec: accessModes: - ReadWriteOnce - # Laisser vide pour utiliser la StorageClass par défaut du cluster SSP Cloud - # ou spécifier "onyxia" si kubectl get storageclass la retourne resources: requests: storage: 5Gi # ~1.5 Go pour les poids + marge From e167eb4ac36d780986cd7af8e170c7ccc07d7e85 Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Sun, 19 Apr 2026 22:37:38 +0000 Subject: [PATCH 07/13] valid ingress --- kubernetes/deployment.yaml | 15 +++++++++++---- kubernetes/ingress.yaml | 26 +++++++++++++------------- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index 245e4af..a6cdb49 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -9,6 +9,8 @@ metadata: app: fastcrowdvision spec: replicas: 1 + strategy: + type: Recreate selector: matchLabels: app: fastcrowdvision @@ -20,6 +22,8 @@ spec: securityContext: runAsNonRoot: true runAsUser: 1000 + fsGroup: 1000 + fsGroupChangePolicy: "OnRootMismatch" containers: - name: fastcrowdvision @@ -41,27 +45,30 @@ spec: cpu: "2" memory: "4Gi" - # Attend que le modèle soit chargé (jusqu'à 3 min) + # Attend que le modèle soit chargé (jusqu'à 10 min) startupProbe: httpGet: path: /health port: 8000 - failureThreshold: 18 + failureThreshold: 60 periodSeconds: 10 + timeoutSeconds: 5 livenessProbe: httpGet: path: /health port: 8000 - initialDelaySeconds: 10 + initialDelaySeconds: 120 periodSeconds: 30 + timeoutSeconds: 5 readinessProbe: httpGet: path: /health port: 8000 - initialDelaySeconds: 5 + initialDelaySeconds: 20 periodSeconds: 10 + timeoutSeconds: 5 volumeMounts: - name: hf-cache diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml index ea4775d..3528dba 100644 --- a/kubernetes/ingress.yaml +++ b/kubernetes/ingress.yaml @@ -2,28 +2,28 @@ # kubernetes/ingress.yaml # Expose l'API publiquement via l'Ingress du SSP Cloud (Traefik + cert-manager) # URL finale sera : -# https://fastcrowdvision.user-pmakamwe.lab.sspcloud.fr -# +# https://fastcrowdvision.lab.sspcloud.fr # ────────────────────────────────────────────────────────────────────────────── apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: fastcrowdvision annotations: - # SSP Cloud utilise Traefik comme ingress controller (pas nginx) - kubernetes.io/ingress.class: traefik - # TLS Let's Encrypt géré par cert-manager (déjà installé sur le cluster) - cert-manager.io/cluster-issuer: letsencrypt-prod - # Support WebSocket (nécessaire pour /ws/detect) - traefik.ingress.kubernetes.io/router.entrypoints: websecure - traefik.ingress.kubernetes.io/router.tls: "true" + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-origin: "*" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "Authorization, Content-Type" + nginx.ingress.kubernetes.io/cors-allow-credentials: "true" + # Timeout élevé pour les WebSockets de détection longue + nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" spec: + ingressClassName: nginx tls: - hosts: - - fastcrowdvision.user-pmakamwe.lab.sspcloud.fr - secretName: fastcrowdvision-tls + - fastcrowdvision.lab.sspcloud.fr rules: - - host: fastcrowdvision.user-pmakamwe.lab.sspcloud.fr + - host: fastcrowdvision.lab.sspcloud.fr http: paths: - path: / @@ -32,4 +32,4 @@ spec: service: name: fastcrowdvision port: - name: http + number: 80 \ No newline at end of file From ff8b0bc73bb97df1d9c566e2f7f5efb53aca183a Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Mon, 20 Apr 2026 11:28:51 +0000 Subject: [PATCH 08/13] feat: add inference timing + cpu limit increase --- inference.py | 5 +++++ kubernetes/deployment.yaml | 2 +- website/app.js | 9 +++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/inference.py b/inference.py index a758f78..8668411 100644 --- a/inference.py +++ b/inference.py @@ -11,6 +11,7 @@ from torchvision.transforms import v2 import os import yaml +import time # project root = folder containing this file project_root = os.path.dirname(os.path.abspath(__file__)) @@ -74,6 +75,7 @@ def detect_frame(model, pil_image, transform, device, score_thr=0.25): Coordinates are in pixels (original image size). Returns empty (0, 6) array if no detections pass the threshold. """ + t0 = time.perf_counter() W, H = pil_image.size # transform PIL image to 300x300 normalized tensor, add batch dim @@ -97,4 +99,7 @@ def detect_frame(model, pil_image, transform, device, score_thr=0.25): topk[:, [0, 2]] *= W topk[:, [1, 3]] *= H + elapsed = time.perf_counter() - t0 + print(f"[inference] {elapsed*1000:.1f} ms") + return topk.cpu().numpy() diff --git a/kubernetes/deployment.yaml b/kubernetes/deployment.yaml index a6cdb49..e7b9051 100644 --- a/kubernetes/deployment.yaml +++ b/kubernetes/deployment.yaml @@ -42,7 +42,7 @@ spec: cpu: "500m" memory: "2Gi" limits: - cpu: "2" + cpu: "4" memory: "4Gi" # Attend que le modèle soit chargé (jusqu'à 10 min) diff --git a/website/app.js b/website/app.js index a82d732..3945b61 100644 --- a/website/app.js +++ b/website/app.js @@ -159,7 +159,16 @@ function startDetection() { })); }; + let lastFrameTime = null; ws.onmessage = (event) => { + const now = performance.now(); + const data = JSON.parse(event.data); + if (data.type === "detection") { + if (lastFrameTime) { + const fps = 1000 / (now - lastFrameTime); + console.log(`Throughput: ${fps.toFixed(1)} frames/s`); + } + lastFrameTime = now; const data = JSON.parse(event.data); if (data.type === "metadata") { From 169fa1d962edff92e7d13332eec53b7262d31e85 Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Mon, 20 Apr 2026 11:48:15 +0000 Subject: [PATCH 09/13] Updates de ingress pour les connexions WebSocket --- kubernetes/ingress.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml index 3528dba..939c044 100644 --- a/kubernetes/ingress.yaml +++ b/kubernetes/ingress.yaml @@ -17,6 +17,10 @@ metadata: # Timeout élevé pour les WebSockets de détection longue nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" + nginx.ingress.kubernetes.io/proxy-http-version: "1.1" + nginx.ingress.kubernetes.io/configuration-snippet: | + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; spec: ingressClassName: nginx tls: From ebc925248f9b82cb6379835ebeb99538bc08cf2e Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Mon, 20 Apr 2026 12:33:16 +0000 Subject: [PATCH 10/13] fix: remove duplicate data parsing in WebSocket onmessage --- website/app.js | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/website/app.js b/website/app.js index 3945b61..21b29dd 100644 --- a/website/app.js +++ b/website/app.js @@ -141,7 +141,6 @@ async function uploadVideo(file) { function startDetection() { if (!sessionId) return; - // build WebSocket URL from the current page location const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/ws/detect`; ws = new WebSocket(wsUrl); @@ -150,8 +149,6 @@ function startDetection() { detectionStatus.textContent = "Active — traitement en cours..."; startDetectionBtn.disabled = true; stopDetectionBtn.disabled = false; - - // send session config to the server to start processing ws.send(JSON.stringify({ session_id: sessionId, score_thr: Number(scoreThresholdInput.value), @@ -160,36 +157,29 @@ function startDetection() { }; let lastFrameTime = null; + ws.onmessage = (event) => { const now = performance.now(); - const data = JSON.parse(event.data); - if (data.type === "detection") { - if (lastFrameTime) { - const fps = 1000 / (now - lastFrameTime); - console.log(`Throughput: ${fps.toFixed(1)} frames/s`); - } - lastFrameTime = now; - const data = JSON.parse(event.data); + const data = JSON.parse(event.data); // ← un seul parse if (data.type === "metadata") { - // server tells us the video FPS and total frames videoMeta = data; return; } if (data.type === "detection") { - // server processed a frame — seek the video to that time and draw boxes - if (videoMeta) { - video.currentTime = data.time; + // mesure du throughput + if (lastFrameTime) { + const fps = 1000 / (now - lastFrameTime); + console.log(`Throughput: ${fps.toFixed(1)} frames/s`); } + lastFrameTime = now; + if (videoMeta) video.currentTime = data.time; drawDetections(data); - - // update counters currentCount.textContent = data.current_count; totalUnique.textContent = data.total_unique; - // update progress if (videoMeta && videoMeta.total_frames > 0) { const pct = Math.round((data.frame / videoMeta.total_frames) * 100); progressInfo.textContent = `Image ${data.frame} / ${videoMeta.total_frames} (${pct}%)`; @@ -198,8 +188,6 @@ function startDetection() { } if (data.type === "done") { - // server finished processing the entire video — reset UI so user can - // upload a new video without reloading the page detectionStatus.textContent = "Terminé"; totalUnique.textContent = data.total_unique; progressInfo.textContent = "Terminé"; @@ -218,8 +206,6 @@ function startDetection() { }; ws.onclose = () => { - // CHANGED: don't overwrite "Terminé" status, and don't re-enable start - // button — user needs to upload a new video first if (detectionStatus.textContent !== "Terminé") { detectionStatus.textContent = "Connexion fermée"; startDetectionBtn.disabled = !sessionId; From 81310578d3a85a89e55820d72ffc4b921608eaf0 Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Mon, 20 Apr 2026 14:23:51 +0000 Subject: [PATCH 11/13] fix: CI sur tous les pushs + correction double parsing WebSocket --- .github/workflows/docker-deploy.yml | 54 +++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 .github/workflows/docker-deploy.yml diff --git a/.github/workflows/docker-deploy.yml b/.github/workflows/docker-deploy.yml new file mode 100644 index 0000000..ea763e0 --- /dev/null +++ b/.github/workflows/docker-deploy.yml @@ -0,0 +1,54 @@ +# ───────────────────────────────────────────────────────────────── +# .github/workflows/docker-deploy.yml +# Pipeline CI/CD — conforme au cours ENSAE "Mise en production" +# +# Reproduit exactement le schéma du cours : +# push sur une branche → build image Docker → push sur Docker Hub +# +# Secrets GitHub à configurer : +# DOCKERHUB_USERNAME : votre identifiant Docker Hub +# DOCKERHUB_TOKEN : Access Token Docker Hub +# (hub.docker.com → Account Settings → Security) +# ───────────────────────────────────────────────────────────────── +name: Build & Deploy FastCrowdVision + +on: + push: + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ secrets.DOCKERHUB_USERNAME }}/fastcrowdvision + tags: | + type=ref,event=branch + type=semver,pattern={{version}} + type=sha,prefix=sha-,format=short + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + \ No newline at end of file From f110146c2561c4c2bc9adc916cd9f5d1c5291c90 Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Mon, 20 Apr 2026 14:56:44 +0000 Subject: [PATCH 12/13] =?UTF-8?q?configuration=20snippet=20chang=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/docker-deploy.yml | 87 --------------------------------------- kubernetes/ingress.yaml | 4 +- 2 files changed, 1 insertion(+), 90 deletions(-) delete mode 100644 .github/docker-deploy.yml diff --git a/.github/docker-deploy.yml b/.github/docker-deploy.yml deleted file mode 100644 index 0973440..0000000 --- a/.github/docker-deploy.yml +++ /dev/null @@ -1,87 +0,0 @@ -# ───────────────────────────────────────────────────────────────── -# .github/workflows/docker-deploy.yml -# Pipeline CI/CD — conforme au cours ENSAE "Mise en production" -# -# Reproduit exactement le schéma du cours : -# push sur main → build image Docker → push sur Docker Hub -# -# Secrets GitHub à configurer : -# DOCKERHUB_USERNAME : votre identifiant Docker Hub -# DOCKERHUB_TOKEN : Access Token Docker Hub -# (hub.docker.com → Account Settings → Security) -# KUBECONFIG_SSP : kubeconfig SSP Cloud encodé en base64 -# ───────────────────────────────────────────────────────────────── -name: Build & Deploy FastCrowdVision - -on: - push: - branches: - - main - tags: - - "v*" - -jobs: - docker: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Docker meta - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ secrets.DOCKERHUB_USERNAME }}/fastcrowdvision - tags: | - type=ref,event=branch - type=semver,pattern={{version}} - type=sha,prefix=sha-,format=short - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Login to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - deploy: - needs: docker - runs-on: ubuntu-latest - if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') - steps: - - uses: actions/checkout@v4 - - - name: Set image in deployment manifest - run: | - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - IMAGE="${{ secrets.DOCKERHUB_USERNAME }}/fastcrowdvision:sha-${SHORT_SHA}" - sed -i "s|/fastcrowdvision:|${IMAGE}|g" \ - kubernetes/deployment.yaml - - - name: Configure kubectl - run: | - mkdir -p $HOME/.kube - echo "${{ secrets.KUBECONFIG_SSP }}" | base64 -d > $HOME/.kube/config - - - name: Apply Kubernetes manifests - run: | - kubectl apply -f kubernetes/pvc.yaml - kubectl apply -f kubernetes/deployment.yaml - kubectl apply -f kubernetes/service.yaml - kubectl apply -f kubernetes/ingress.yaml - - - name: Wait for rollout - run: kubectl rollout status deployment/fastcrowdvision --timeout=180s diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml index 939c044..90af00a 100644 --- a/kubernetes/ingress.yaml +++ b/kubernetes/ingress.yaml @@ -18,9 +18,7 @@ metadata: nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-http-version: "1.1" - nginx.ingress.kubernetes.io/configuration-snippet: | - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + nginx.ingress.kubernetes.io/websocket-services: "fastcrowdvision" spec: ingressClassName: nginx tls: From 0bc1f2d53fc5d6a631c285e0be96baca7f1aba7b Mon Sep 17 00:00:00 2001 From: pmakamwe Date: Mon, 20 Apr 2026 17:17:34 +0000 Subject: [PATCH 13/13] =?UTF-8?q?derni=C3=A8re=20retouche?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kubernetes/ingress.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml index 90af00a..03aee4e 100644 --- a/kubernetes/ingress.yaml +++ b/kubernetes/ingress.yaml @@ -14,7 +14,6 @@ metadata: nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS" nginx.ingress.kubernetes.io/cors-allow-headers: "Authorization, Content-Type" nginx.ingress.kubernetes.io/cors-allow-credentials: "true" - # Timeout élevé pour les WebSockets de détection longue nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-http-version: "1.1"