Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions .github/workflows/docker-deploy.yml
Original file line number Diff line number Diff line change
@@ -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 }}

68 changes: 68 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
105 changes: 105 additions & 0 deletions inference.py
Original file line number Diff line number Diff line change
@@ -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()
85 changes: 85 additions & 0 deletions kubernetes/deployment.yaml
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading