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/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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd98239 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,68 @@ +# ───────────────────────────────────────────────────────────────── +# FastCrowdVision — Dockerfile +# 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 + +# 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 minimales pour OpenCV headless et torch +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# 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 . + +# 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-api.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" \ + HF_HOME=/app/.cache/huggingface + +WORKDIR /app + +# Libs runtime uniquement (pas d'outils de build) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgl1 \ + libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* + +# Récupérer le venv du stage builder +COPY --from=builder /opt/venv /opt/venv + +# Copier le code applicatif +# .dockerignore exclut : données, notebooks, scripts d'entraînement, .git +COPY . . + +# 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 + +EXPOSE 8000 + +CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"] 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 diff --git a/inference.py b/inference.py new file mode 100644 index 0000000..8668411 --- /dev/null +++ b/inference.py @@ -0,0 +1,105 @@ +# 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 +import time + +# 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. + """ + t0 = time.perf_counter() + 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 + + 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 new file mode 100644 index 0000000..e7b9051 --- /dev/null +++ b/kubernetes/deployment.yaml @@ -0,0 +1,85 @@ +# ───────────────────────────────────────────────────────────────── +# kubernetes/deployment.yaml +# ───────────────────────────────────────────────────────────────── +apiVersion: apps/v1 +kind: Deployment +metadata: + name: fastcrowdvision + labels: + app: fastcrowdvision +spec: + replicas: 1 + strategy: + type: Recreate + selector: + matchLabels: + app: fastcrowdvision + template: + metadata: + labels: + app: fastcrowdvision + spec: + securityContext: + runAsNonRoot: true + runAsUser: 1000 + fsGroup: 1000 + fsGroupChangePolicy: "OnRootMismatch" + + containers: + - name: fastcrowdvision + image: ghcr.io/josiepierr/fastcrowdvision:latest + imagePullPolicy: Always + ports: + - containerPort: 8000 + name: http + + env: + - name: HF_HOME + value: /app/.cache/huggingface + + resources: + requests: + cpu: "500m" + memory: "2Gi" + limits: + cpu: "4" + memory: "4Gi" + + # Attend que le modèle soit chargé (jusqu'à 10 min) + startupProbe: + httpGet: + path: /health + port: 8000 + failureThreshold: 60 + periodSeconds: 10 + timeoutSeconds: 5 + + livenessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 120 + periodSeconds: 30 + timeoutSeconds: 5 + + readinessProbe: + httpGet: + path: /health + port: 8000 + initialDelaySeconds: 20 + periodSeconds: 10 + timeoutSeconds: 5 + + volumeMounts: + - name: hf-cache + mountPath: /app/.cache/huggingface + - name: tmp-dir + mountPath: /tmp + + volumes: + - name: hf-cache + persistentVolumeClaim: + claimName: fastcrowdvision-hf-cache + - name: tmp-dir + emptyDir: + sizeLimit: 2Gi diff --git a/kubernetes/ingress.yaml b/kubernetes/ingress.yaml new file mode 100644 index 0000000..03aee4e --- /dev/null +++ b/kubernetes/ingress.yaml @@ -0,0 +1,36 @@ +# ────────────────────────────────────────────────────────────────────────────── +# kubernetes/ingress.yaml +# Expose l'API publiquement via l'Ingress du SSP Cloud (Traefik + cert-manager) +# URL finale sera : +# https://fastcrowdvision.lab.sspcloud.fr +# ────────────────────────────────────────────────────────────────────────────── +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: fastcrowdvision + annotations: + 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" + 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/websocket-services: "fastcrowdvision" +spec: + ingressClassName: nginx + tls: + - hosts: + - fastcrowdvision.lab.sspcloud.fr + rules: + - host: fastcrowdvision.lab.sspcloud.fr + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: fastcrowdvision + port: + number: 80 \ No newline at end of file diff --git a/kubernetes/pvc.yaml b/kubernetes/pvc.yaml new file mode 100644 index 0000000..49fdab8 --- /dev/null +++ b/kubernetes/pvc.yaml @@ -0,0 +1,15 @@ +# ────────────────────────────────────────────────────────────────────────────── +# kubernetes/pvc.yaml +# ────────────────────────────────────────────────────────────────────────────── +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: fastcrowdvision-hf-cache + labels: + app: fastcrowdvision +spec: + accessModes: + - ReadWriteOnce + 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/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/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 diff --git a/server.py b/server.py new file mode 100644 index 0000000..623ac37 --- /dev/null +++ b/server.py @@ -0,0 +1,219 @@ +# 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() +@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 +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) + + +MAX_VIDEO_DURATION_SEC=40 + +@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, + ) + max_frame=int(fps*MAX_VIDEO_DURATION_SEC) + + # 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 or frame_idx >=max_frame: + 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) ────────────────── + +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..21b29dd 100644 --- a/website/app.js +++ b/website/app.js @@ -1,197 +1,292 @@ -let model = null; -let detectionInterval = null; -let isDetecting = false; +// app.js — Frontend for FastCrowdVision. +// WebSocket communication to the Python backend (server-side SSD + norfair tracking). +// 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 ─────────────────────────────────────────────────────────── + +let sessionId = null; +let ws = null; +let selectedFile = null; +let videoMeta = null; +let currentVideoURL = 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; + + 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; + ws.send(JSON.stringify({ + session_id: sessionId, + score_thr: Number(scoreThresholdInput.value), + frame_skip: Number(frameSkipRange.value), + })); + }; + + let lastFrameTime = null; + + ws.onmessage = (event) => { + const now = performance.now(); + const data = JSON.parse(event.data); // ← un seul parse + + if (data.type === "metadata") { + videoMeta = data; + return; + } + + if (data.type === "detection") { + // 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); + currentCount.textContent = data.current_count; + totalUnique.textContent = data.total_unique; + + 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") { + 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; + } + + if (data.type === "error") { + detectionStatus.textContent = `Erreur : ${data.message}`; + return; + } + }; + + ws.onclose = () => { + if (detectionStatus.textContent !== "Terminé") { + detectionStatus.textContent = "Connexion fermée"; + startDetectionBtn.disabled = !sessionId; + } + 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. +// 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; - const videoURL = URL.createObjectURL(file); - video.src = videoURL; + selectedFile = file; - playPauseBtn.disabled = false; - startDetectionBtn.disabled = false; -}); + // 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; -video.addEventListener("loadedmetadata", () => { - resizeCanvases(); + // 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); + 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"; + } }); -video.addEventListener("play", () => { - resizeCanvases(); +video.addEventListener("loadedmetadata", () => { + 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 +294,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