Computer Vision avec PyTorch : entraîner un modèle de détection d’objets

Niveau du tutoriel : Expert

Computer Vision et Deep Learning

1. Introduction à la détection d’objets

La détection d’objets représente l’un des domaines les plus passionnants et les plus actifs de l’intelligence artificielle contemporaine. Contrairement à la simple classification d’images — qui consiste à attribuer une étiquette unique à une image entière — la détection d’objets combine deux tâches fondamentales : la localisation (où se trouve l’objet dans l’image ?) et la classification (de quel type d’objet s’agit-il ?). Cette dualité en fait un problème nettement plus complexe, mais aussi infiniment plus utile dans les applications du monde réel.

Les applications de la détection d’objets sont omniprésentes : véhicules autonomes qui doivent identifier piétons, panneaux et autres véhicules en temps réel ; systèmes de surveillance intelligente capables de détecter des comportements anormaux ; contrôle qualité industriel sur les lignes de production ; imagerie médicale pour la détection de tumeurs ou d’anomalies ; agriculture de précision pour surveiller l’état des cultures ; et bien d’autres domaines encore. Le marché mondial de la vision par ordinateur devrait atteindre 41,11 milliards de dollars d’ici 2030, ce qui témoigne de l’importance croissante de ces technologies.

PyTorch, développé par Meta AI Research, s’est imposé comme le framework de référence pour la recherche et le développement en deep learning. Sa conception basée sur le calcul dynamique (define-by-run) le rend particulièrement intuitif pour le prototypage et le débogage. L’écosystème PyTorch offre également torchvision, une bibliothèque dédiée à la vision par ordinateur qui fournit des modèles pré-entraînés, des transformations d’images et des utilitaires pour les datasets les plus courants.

ℹ️ Prérequis
Cet article s’adresse aux développeurs ayant une connaissance intermédiaire de Python et des bases en machine learning. Une familiarité avec les tenseurs PyTorch et les concepts fondamentaux du deep learning (rétropropagation, descente de gradient) est recommandée. Un GPU NVIDIA avec au moins 8 Go de VRAM est fortement conseillé pour les entraînements présentés.

Dans cet article approfondi, nous allons parcourir l’ensemble du pipeline de détection d’objets avec PyTorch : de la théorie fondamentale des CNN aux architectures les plus récentes comme YOLO v8 et v9, en passant par la préparation des données, l’augmentation, le transfer learning, l’entraînement complet, l’évaluation et le déploiement en production. Chaque section sera accompagnée de code fonctionnel que vous pourrez reproduire directement dans vos projets.

2. Théorie des réseaux convolutifs (CNN)

Les réseaux de neurones convolutifs (Convolutional Neural Networks, ou CNN) constituent la pierre angulaire de toute approche moderne en vision par ordinateur. Pour comprendre la détection d’objets, il est essentiel de maîtriser les mécanismes fondamentaux qui permettent à ces réseaux d’extraire des caractéristiques visuelles pertinentes à partir d’images brutes.

2.1 L’opération de convolution

L’opération de convolution consiste à faire glisser un filtre (ou kernel) de petite taille — typiquement 3×3 ou 5×5 pixels — sur l’ensemble de l’image d’entrée. À chaque position, le filtre effectue une multiplication élément par élément suivie d’une somme, produisant une valeur unique dans la carte de caractéristiques (feature map) de sortie. Ce mécanisme permet au réseau de détecter des motifs locaux comme des bords, des textures ou des formes, indépendamment de leur position dans l’image — c’est ce qu’on appelle l’invariance par translation.

Un CNN typique empile plusieurs couches de convolution. Les premières couches capturent des caractéristiques de bas niveau (bords horizontaux, verticaux, diagonaux), tandis que les couches profondes combinent ces primitives pour former des représentations de plus haut niveau (yeux, roues, visages). Cette hiérarchie de caractéristiques est ce qui rend les CNN si puissants pour comprendre le contenu visuel.

2.2 Pooling et sous-échantillonnage

Entre les couches de convolution, on insère généralement des couches de pooling (le plus souvent du max pooling 2×2) qui réduisent la résolution spatiale des feature maps par un facteur 2. Cette opération a plusieurs avantages : elle réduit le nombre de paramètres et le coût computationnel, elle introduit une forme de robustesse aux petites translations, et elle augmente le champ réceptif effectif des couches suivantes.

2.3 Architecture pour la détection

Dans le contexte de la détection d’objets, l’architecture CNN est généralement décomposée en deux parties distinctes. Le backbone est la partie qui extrait les caractéristiques visuelles — c’est typiquement un réseau de classification pré-entraîné comme ResNet, EfficientNet ou VGG dont on retire les couches finales de classification. Le neck est une structure intermédiaire (comme le Feature Pyramid Network, FPN) qui combine les feature maps de différentes résolutions pour détecter des objets de tailles variées. Enfin, la head est la partie qui produit les prédictions finales : coordonnées des boîtes englobantes, scores de confiance et probabilités de classe.

import torch
import torch.nn as nn

class SimpleConvBlock(nn.Module):
    """Bloc convolutif basique avec BatchNorm et ReLU."""
    def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1):
        super().__init__()
        self.conv = nn.Conv2d(in_channels, out_channels, kernel_size, stride, padding, bias=False)
        self.bn = nn.BatchNorm2d(out_channels)
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        return self.relu(self.bn(self.conv(x)))

class FeatureExtractor(nn.Module):
    """Extracteur de caractéristiques multi-échelle simplifié."""
    def __init__(self):
        super().__init__()
        self.stage1 = nn.Sequential(
            SimpleConvBlock(3, 64),
            SimpleConvBlock(64, 64),
            nn.MaxPool2d(2, 2)
        )
        self.stage2 = nn.Sequential(
            SimpleConvBlock(64, 128),
            SimpleConvBlock(128, 128),
            nn.MaxPool2d(2, 2)
        )
        self.stage3 = nn.Sequential(
            SimpleConvBlock(128, 256),
            SimpleConvBlock(256, 256),
            SimpleConvBlock(256, 256),
            nn.MaxPool2d(2, 2)
        )

    def forward(self, x):
        c1 = self.stage1(x)   # Résolution / 2
        c2 = self.stage2(c1)  # Résolution / 4
        c3 = self.stage3(c2)  # Résolution / 8
        return c1, c2, c3     # Feature maps multi-échelle

« Le véritable pouvoir des CNN réside dans leur capacité à apprendre automatiquement les représentations visuelles pertinentes, sans ingénierie manuelle de caractéristiques. C’est ce qui a rendu obsolètes des décennies de travail en feature engineering. »

— Yann LeCun, Chief AI Scientist chez Meta

2.4 Approches one-stage vs two-stage

Les détecteurs d’objets se divisent historiquement en deux familles. Les détecteurs two-stage (comme Faster R-CNN et Mask R-CNN) génèrent d’abord des propositions de régions d’intérêt (Region Proposal Network), puis classifient et affinent chaque proposition individuellement. Ils sont généralement plus précis mais plus lents. Les détecteurs one-stage (comme YOLO, SSD, RetinaNet) prédisent directement les boîtes englobantes et les classes en une seule passe sur l’image. Ils sont beaucoup plus rapides et particulièrement adaptés aux applications temps réel, au prix d’une légère perte de précision — un écart qui s’est considérablement réduit avec les architectures récentes.

3. Datasets : COCO, Pascal VOC et au-delà

La qualité d’un modèle de détection d’objets dépend fondamentalement de la qualité et de la diversité des données d’entraînement. Deux datasets ont joué un rôle central dans l’évolution du domaine et servent encore aujourd’hui de références incontournables pour l’évaluation comparative des modèles.

3.1 MS COCO (Common Objects in Context)

Le dataset COCO, créé par Microsoft Research, est le standard de facto pour l’évaluation des modèles de détection. Il contient plus de 330 000 images avec plus de 1,5 million d’instances d’objets annotées dans 80 catégories différentes. Les annotations incluent non seulement les boîtes englobantes mais aussi les masques de segmentation d’instance, les points clés pour la pose humaine et les descriptions textuelles. La richesse et la complexité des scènes COCO — avec de nombreux objets par image, des occlusions fréquentes et des tailles d’objets très variées — en font un benchmark exigeant qui reflète bien les défis du monde réel.

3.2 Pascal VOC (Visual Object Classes)

Pascal VOC, plus ancien et plus petit que COCO (environ 11 500 images pour 20 catégories), reste largement utilisé pour le prototypage rapide et l’enseignement. Son format d’annotation XML est devenu un standard repris par de nombreux outils. Le challenge VOC a été organisé annuellement de 2005 à 2012 et a catalysé les progrès en détection d’objets, segmentation sémantique et classification d’actions.

3.3 Charger les données avec PyTorch

import torchvision
from torchvision import transforms
from torch.utils.data import DataLoader

# Charger COCO avec torchvision
transform = transforms.Compose([
    transforms.Resize((640, 640)),
    transforms.ToTensor(),
    transforms.Normalize(mean=[0.485, 0.456, 0.406],
                         std=[0.229, 0.224, 0.225])
])

# Dataset COCO
coco_dataset = torchvision.datasets.CocoDetection(
    root='./data/coco/train2017',
    annFile='./data/coco/annotations/instances_train2017.json',
    transform=transform
)

# Dataset Pascal VOC
voc_dataset = torchvision.datasets.VOCDetection(
    root='./data',
    year='2012',
    image_set='trainval',
    download=True,
    transform=transform
)

# DataLoader avec collate_fn personnalisé pour la détection
def collate_fn(batch):
    """Gère les images avec un nombre variable d'annotations."""
    images = [item[0] for item in batch]
    targets = [item[1] for item in batch]
    images = torch.stack(images, dim=0)
    return images, targets

train_loader = DataLoader(
    coco_dataset,
    batch_size=16,
    shuffle=True,
    num_workers=4,
    collate_fn=collate_fn,
    pin_memory=True
)

print(f"Nombre d'images d'entraînement : {len(coco_dataset)}")
print(f"Nombre de batches : {len(train_loader)}")

3.4 Datasets spécialisés et personnalisés

Au-delà de COCO et VOC, de nombreux datasets spécialisés existent pour des domaines spécifiques : Open Images V7 de Google (9 millions d’images, 600 catégories), Objects365 (365 catégories, 2 millions d’images), LVIS (1 200+ catégories avec distribution long-tail). Pour les projets industriels, vous aurez souvent besoin de créer vos propres datasets annotés. Des outils comme Label Studio, CVAT ou Roboflow facilitent grandement ce processus d’annotation.

⚠️ Attention
La qualité des annotations est critique. Un dataset mal annoté (boîtes imprécises, classes erronées, objets oubliés) produira un modèle médiocre, quelle que soit la sophistication de l’architecture utilisée. Prévoyez un processus de relecture et de validation de vos annotations. Un taux d’erreur d’annotation supérieur à 5% peut sérieusement dégrader les performances.

4. Data Augmentation avec Albumentations

L’augmentation de données est une technique essentielle pour améliorer la robustesse et la capacité de généralisation des modèles de détection. Elle consiste à appliquer des transformations aléatoires aux images d’entraînement pour simuler la variabilité des conditions réelles : changements d’éclairage, rotations, occlusions partielles, variations de contraste, etc. La bibliothèque Albumentations s’est imposée comme la référence pour l’augmentation en vision par ordinateur grâce à sa rapidité (implémentation en C++ via OpenCV), sa richesse fonctionnelle et sa gestion native des boîtes englobantes et des masques de segmentation.

import albumentations as A
from albumentations.pytorch import ToTensorV2
import cv2
import numpy as np

# Pipeline d'augmentation pour la détection d'objets
train_transform = A.Compose([
    # Redimensionnement avec préservation du ratio
    A.LongestMaxSize(max_size=640),
    A.PadIfNeeded(min_height=640, min_width=640,
                  border_mode=cv2.BORDER_CONSTANT, value=0),

    # Transformations géométriques
    A.HorizontalFlip(p=0.5),
    A.RandomRotate90(p=0.2),
    A.ShiftScaleRotate(
        shift_limit=0.1,
        scale_limit=0.2,
        rotate_limit=15,
        border_mode=cv2.BORDER_CONSTANT,
        p=0.5
    ),

    # Transformations colorimétriques
    A.OneOf([
        A.RandomBrightnessContrast(
            brightness_limit=0.3,
            contrast_limit=0.3,
            p=1.0
        ),
        A.HueSaturationValue(
            hue_shift_limit=20,
            sat_shift_limit=30,
            val_shift_limit=20,
            p=1.0
        ),
        A.CLAHE(clip_limit=4.0, p=1.0),
    ], p=0.7),

    # Bruit et flou
    A.OneOf([
        A.GaussNoise(var_limit=(10, 50), p=1.0),
        A.GaussianBlur(blur_limit=(3, 7), p=1.0),
        A.MotionBlur(blur_limit=7, p=1.0),
    ], p=0.3),

    # Cutout / occlusion simulée
    A.CoarseDropout(
        max_holes=8, max_height=64, max_width=64,
        min_holes=1, min_height=16, min_width=16,
        fill_value=0, p=0.3
    ),

    # Normalisation et conversion
    A.Normalize(mean=[0.485, 0.456, 0.406],
                std=[0.229, 0.224, 0.225]),
    ToTensorV2()
], bbox_params=A.BboxParams(
    format='pascal_voc',       # [x_min, y_min, x_max, y_max]
    label_fields=['class_labels'],
    min_area=256,              # Ignorer les boîtes trop petites
    min_visibility=0.3         # Boîte visible à au moins 30%
))

# Transformation de validation (sans augmentation aléatoire)
val_transform = A.Compose([
    A.LongestMaxSize(max_size=640),
    A.PadIfNeeded(min_height=640, min_width=640,
                  border_mode=cv2.BORDER_CONSTANT, value=0),
    A.Normalize(mean=[0.485, 0.456, 0.406],
                std=[0.229, 0.224, 0.225]),
    ToTensorV2()
], bbox_params=A.BboxParams(
    format='pascal_voc',
    label_fields=['class_labels']
))

# Exemple d'utilisation
image = cv2.imread('image.jpg')
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
bboxes = [[50, 30, 200, 180], [300, 100, 450, 350]]
class_labels = [0, 1]  # IDs de classe

augmented = train_transform(
    image=image,
    bboxes=bboxes,
    class_labels=class_labels
)

aug_image = augmented['image']        # Tensor PyTorch
aug_bboxes = augmented['bboxes']      # Boîtes transformées
aug_labels = augmented['class_labels'] # Labels préservés

Le pipeline ci-dessus combine des transformations géométriques (flip, rotation, scale) qui modifient la position spatiale des objets — et donc leurs boîtes englobantes — avec des transformations colorimétriques (luminosité, contraste, teinte) qui n’affectent que l’apparence sans modifier les annotations. L’utilisation de A.OneOf permet de sélectionner aléatoirement une seule transformation parmi un groupe, évitant ainsi la sur-augmentation qui pourrait rendre les images irréalistes.

La technique de Mosaic augmentation, popularisée par YOLO v4, consiste à assembler 4 images d’entraînement en une seule mosaïque. Elle est particulièrement efficace pour la détection d’objets car elle augmente artificiellement le nombre d’objets par image et expose le modèle à des contextes visuels plus variés. La MixUp augmentation superpose deux images avec transparence, encourageant le modèle à apprendre des représentations plus robustes.

5. Transfer Learning : ResNet et EfficientNet

Le transfer learning est sans doute la technique la plus impactante en deep learning appliqué. Plutôt que d’entraîner un réseau de neurones from scratch sur votre dataset — ce qui nécessiterait des millions d’images et des semaines de calcul GPU — le transfer learning consiste à partir d’un modèle déjà entraîné sur un large dataset (typiquement ImageNet avec ses 14 millions d’images et 1 000 catégories) et à l’adapter à votre tâche spécifique. Les couches profondes du réseau ont déjà appris à reconnaître des caractéristiques visuelles universelles (bords, textures, formes, parties d’objets) qui sont transférables à pratiquement n’importe quelle tâche de vision.

5.1 ResNet comme backbone

ResNet (Residual Network), introduit par Kaiming He et al. en 2015, a révolutionné le deep learning avec son concept de connexions résiduelles (skip connections). Ces connexions permettent au gradient de circuler directement à travers les couches, résolvant le problème de la dégradation du gradient dans les réseaux très profonds. ResNet-50 (25,6 millions de paramètres) et ResNet-101 (44,5 millions) offrent un excellent compromis entre performance et coût computationnel pour servir de backbone de détection.

import torchvision.models as models
import torch.nn as nn

def create_resnet_backbone(pretrained=True, frozen_stages=2):
    """
    Crée un backbone ResNet-50 pré-entraîné pour la détection.

    Args:
        pretrained: Utiliser les poids ImageNet
        frozen_stages: Nombre de stages à geler (0-4)
    """
    # Charger ResNet-50 pré-entraîné
    resnet = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V2)

    # Supprimer les couches de classification
    # On garde: conv1, bn1, relu, maxpool, layer1-4
    backbone_modules = list(resnet.children())[:-2]  # Retire avgpool et fc

    # Geler les premiers stages pour stabiliser l'entraînement
    stages = [
        nn.Sequential(*backbone_modules[:4]),   # Stage 0: conv1 + bn + relu + maxpool
        backbone_modules[4],                     # Stage 1: layer1 (256 channels)
        backbone_modules[5],                     # Stage 2: layer2 (512 channels)
        backbone_modules[6],                     # Stage 3: layer3 (1024 channels)
        backbone_modules[7],                     # Stage 4: layer4 (2048 channels)
    ]

    for i in range(frozen_stages + 1):
        for param in stages[i].parameters():
            param.requires_grad = False
        print(f"Stage {i} gelé ({sum(p.numel() for p in stages[i].parameters()):,} params)")

    backbone = nn.Sequential(*stages)
    return backbone

backbone = create_resnet_backbone(pretrained=True, frozen_stages=1)
# Test avec une image aléatoire
dummy = torch.randn(1, 3, 640, 640)
features = backbone(dummy)
print(f"Shape des features: {features.shape}")  # [1, 2048, 20, 20]

5.2 EfficientNet : l’efficacité optimisée

EfficientNet, proposé par Tan et Le (Google Brain, 2019), utilise un compound scaling qui optimise simultanément la profondeur, la largeur et la résolution du réseau via un coefficient unique. EfficientNet-B0 à B7 offrent une gamme complète de modèles, du plus léger au plus puissant. EfficientNet-B4 et B5 sont particulièrement populaires comme backbones de détection, offrant des performances supérieures à ResNet avec moins de paramètres et de FLOPs.

# EfficientNet comme backbone
efficientnet = models.efficientnet_b4(
    weights=models.EfficientNet_B4_Weights.IMAGENET1K_V1
)

# Extraire les features intermédiaires pour le FPN
class EfficientNetBackbone(nn.Module):
    def __init__(self, model):
        super().__init__()
        self.features = model.features

    def forward(self, x):
        outputs = []
        for i, block in enumerate(self.features):
            x = block(x)
            # Capturer les sorties aux résolutions clés
            if i in [2, 4, 6, 8]:  # Stages P2-P5
                outputs.append(x)
        return outputs

eff_backbone = EfficientNetBackbone(efficientnet)
dummy = torch.randn(1, 3, 640, 640)
multi_scale_features = eff_backbone(dummy)
for i, feat in enumerate(multi_scale_features):
    print(f"Feature P{i+2}: {feat.shape}")

« En pratique, le transfer learning réduit le temps d’entraînement d’un facteur 10 à 100 et permet d’atteindre des performances compétitives avec seulement quelques centaines d’images annotées, là où un entraînement from scratch nécessiterait des dizaines de milliers d’exemples. »

— Andrew Ng, fondateur de DeepLearning.AI

6. YOLO v8 et v9 : architectures modernes

YOLO (You Only Look Once) est la famille de détecteurs d’objets la plus populaire pour les applications temps réel. Depuis sa création par Joseph Redmon en 2016, l’architecture a connu de nombreuses évolutions. Les versions 8 et 9, développées respectivement par Ultralytics et le groupe de Chien-Yao Wang, représentent l’état de l’art actuel en termes de compromis vitesse-précision.

6.1 YOLOv8 : la maturité de l’écosystème

YOLOv8 (janvier 2023, Ultralytics) introduit plusieurs innovations majeures. L’architecture adopte un design anchor-free, abandonnant les boîtes d’ancrage prédéfinies au profit d’une prédiction directe des centres et dimensions des objets. Le backbone CSPDarknet est optimisé avec des blocs C2f (Cross Stage Partial avec Fusion), et la head utilise des branches séparées (decoupled head) pour la classification et la régression. YOLOv8 est disponible en cinq tailles : Nano (3,2M params), Small (11,2M), Medium (25,9M), Large (43,7M) et XLarge (68,2M).

from ultralytics import YOLO

# Charger un modèle pré-entraîné YOLOv8
model = YOLO('yolov8m.pt')  # Medium - bon compromis

# Entraîner sur un dataset personnalisé
results = model.train(
    data='dataset.yaml',      # Configuration du dataset
    epochs=100,
    imgsz=640,
    batch=16,
    lr0=0.01,                 # Learning rate initial
    lrf=0.01,                 # Learning rate final (fraction de lr0)
    momentum=0.937,
    weight_decay=0.0005,
    warmup_epochs=3,
    warmup_momentum=0.8,
    warmup_bias_lr=0.1,
    box=7.5,                  # Poids de la loss de boîte
    cls=0.5,                  # Poids de la loss de classification
    dfl=1.5,                  # Poids de la Distribution Focal Loss
    mosaic=1.0,               # Augmentation mosaïque
    mixup=0.15,               # Augmentation MixUp
    copy_paste=0.1,           # Augmentation Copy-Paste
    device='0',               # GPU 0
    workers=8,
    project='runs/detect',
    name='yolov8m_custom',
    patience=20,              # Early stopping
    save_period=10,           # Sauvegarder tous les 10 epochs
    amp=True,                 # Mixed precision training
    close_mosaic=10,          # Désactiver mosaïque les 10 derniers epochs
    resume=False
)

Le fichier de configuration dataset.yaml définit la structure du dataset avec les chemins vers les images et labels, ainsi que les classes à détecter. Voici un exemple type :

# dataset.yaml - Configuration du dataset
path: /data/custom_dataset
train: images/train
val: images/val
test: images/test

# Classes (indices commençant à 0)
names:
  0: personne
  1: voiture
  2: velo
  3: moto
  4: bus
  5: camion

# Nombre de classes
nc: 6

6.2 YOLOv9 : Programmable Gradient Information

YOLOv9 (février 2024) introduit le concept révolutionnaire de Programmable Gradient Information (PGI), conçu pour résoudre le problème de la perte d’information (information bottleneck) dans les réseaux profonds. Le PGI utilise un réseau auxiliaire réversible qui préserve l’intégralité des informations du gradient pendant l’entraînement. Combiné avec l’architecture GELAN (Generalized Efficient Layer Aggregation Network), YOLOv9 atteint des performances supérieures à YOLOv8 sur COCO avec un nombre comparable de paramètres.

# YOLOv9 avec Ultralytics
model_v9 = YOLO('yolov9c.pt')  # YOLOv9 Compact

# Entraînement YOLOv9
results_v9 = model_v9.train(
    data='dataset.yaml',
    epochs=150,
    imgsz=640,
    batch=8,                  # Batch plus petit (modèle plus lourd)
    optimizer='AdamW',
    lr0=0.001,
    cos_lr=True,              # Cosine annealing LR scheduler
    label_smoothing=0.1,
    device='0',
    amp=True,
    cache='ram',              # Mettre le dataset en RAM pour accélérer
    multi_scale=True,         # Entraînement multi-résolution
)
ℹ️ Comparaison rapide YOLOv8 vs YOLOv9
Sur COCO val2017 : YOLOv8-M atteint 50,2% mAP@0.5:0.95 à 234 FPS (T4), tandis que YOLOv9-C atteint 53,0% mAP avec un nombre similaire de paramètres. YOLOv9 est donc plus précis, mais YOLOv8 bénéficie d’un écosystème plus mature et d’une meilleure compatibilité d’export.

7. Code complet d’entraînement

Voici un pipeline d’entraînement complet et production-ready pour un détecteur Faster R-CNN avec PyTorch natif. Ce code couvre la gestion du dataset, l’entraînement avec mixed precision, la validation, le logging et la sauvegarde des checkpoints.

import torch
import torch.optim as optim
from torch.utils.data import Dataset, DataLoader
from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor
from torch.cuda.amp import GradScaler, autocast
import json
import os
import time
from pathlib import Path

class CustomDetectionDataset(Dataset):
    """Dataset personnalisé pour la détection d'objets."""

    def __init__(self, root_dir, annotations_file, transforms=None):
        self.root_dir = Path(root_dir)
        self.transforms = transforms

        with open(annotations_file, 'r') as f:
            self.annotations = json.load(f)

        self.image_ids = list(self.annotations.keys())

    def __len__(self):
        return len(self.image_ids)

    def __getitem__(self, idx):
        img_id = self.image_ids[idx]
        img_info = self.annotations[img_id]

        # Charger l'image
        img_path = self.root_dir / img_info['filename']
        image = cv2.imread(str(img_path))
        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)

        # Préparer les annotations
        boxes = torch.tensor(img_info['boxes'], dtype=torch.float32)
        labels = torch.tensor(img_info['labels'], dtype=torch.int64)
        area = (boxes[:, 3] - boxes[:, 1]) * (boxes[:, 2] - boxes[:, 0])
        iscrowd = torch.zeros(len(boxes), dtype=torch.int64)

        target = {
            'boxes': boxes,
            'labels': labels,
            'image_id': torch.tensor([idx]),
            'area': area,
            'iscrowd': iscrowd
        }

        if self.transforms:
            augmented = self.transforms(
                image=image,
                bboxes=boxes.numpy().tolist(),
                class_labels=labels.numpy().tolist()
            )
            image = augmented['image']
            if len(augmented['bboxes']) > 0:
                target['boxes'] = torch.tensor(augmented['bboxes'], dtype=torch.float32)
                target['labels'] = torch.tensor(augmented['class_labels'], dtype=torch.int64)

        return image, target


def create_model(num_classes, pretrained=True):
    """Crée un Faster R-CNN avec backbone ResNet50-FPN v2."""
    model = fasterrcnn_resnet50_fpn_v2(
        weights='DEFAULT' if pretrained else None,
        trainable_backbone_layers=3
    )

    # Remplacer la tête de classification
    in_features = model.roi_heads.box_predictor.cls_score.in_features
    model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)

    return model


def train_one_epoch(model, optimizer, data_loader, device, scaler, epoch):
    """Entraîne le modèle pour une epoch complète."""
    model.train()
    total_loss = 0
    num_batches = 0

    for batch_idx, (images, targets) in enumerate(data_loader):
        images = [img.to(device) for img in images]
        targets = [{k: v.to(device) for k, v in t.items()} for t in targets]

        optimizer.zero_grad()

        with autocast():
            loss_dict = model(images, targets)
            losses = sum(loss for loss in loss_dict.values())

        scaler.scale(losses).backward()
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=10.0)
        scaler.step(optimizer)
        scaler.update()

        total_loss += losses.item()
        num_batches += 1

        if batch_idx % 50 == 0:
            lr = optimizer.param_groups[0]['lr']
            print(f"  Epoch {epoch} [{batch_idx}/{len(data_loader)}] "
                  f"Loss: {losses.item():.4f} | LR: {lr:.6f}")

    return total_loss / num_batches


@torch.no_grad()
def evaluate(model, data_loader, device):
    """Évalue le modèle sur le jeu de validation."""
    model.eval()
    all_predictions = []
    all_targets = []

    for images, targets in data_loader:
        images = [img.to(device) for img in images]
        predictions = model(images)

        for pred, target in zip(predictions, targets):
            all_predictions.append({
                k: v.cpu() for k, v in pred.items()
            })
            all_targets.append(target)

    return all_predictions, all_targets


def main():
    """Pipeline d'entraînement principal."""
    # Configuration
    config = {
        'num_classes': 7,       # 6 classes + background
        'batch_size': 8,
        'num_epochs': 50,
        'lr': 0.005,
        'momentum': 0.9,
        'weight_decay': 0.0005,
        'lr_step_size': 15,
        'lr_gamma': 0.1,
        'save_dir': './checkpoints',
        'device': 'cuda' if torch.cuda.is_available() else 'cpu'
    }

    device = torch.device(config['device'])
    os.makedirs(config['save_dir'], exist_ok=True)

    # Créer le modèle
    model = create_model(config['num_classes'], pretrained=True)
    model.to(device)
    print(f"Paramètres totaux: {sum(p.numel() for p in model.parameters()):,}")
    print(f"Paramètres entraînables: "
          f"{sum(p.numel() for p in model.parameters() if p.requires_grad):,}")

    # Optimiseur SGD avec momentum
    params = [p for p in model.parameters() if p.requires_grad]
    optimizer = optim.SGD(
        params,
        lr=config['lr'],
        momentum=config['momentum'],
        weight_decay=config['weight_decay']
    )

    # Scheduler : réduction du LR
    scheduler = optim.lr_scheduler.CosineAnnealingWarmRestarts(
        optimizer, T_0=10, T_mult=2, eta_min=1e-6
    )

    # Mixed precision scaler
    scaler = GradScaler()

    # Boucle d'entraînement
    best_map = 0.0
    for epoch in range(1, config['num_epochs'] + 1):
        start = time.time()

        train_loss = train_one_epoch(
            model, optimizer, train_loader, device, scaler, epoch
        )
        scheduler.step()

        elapsed = time.time() - start
        print(f"Epoch {epoch}/{config['num_epochs']} terminée en {elapsed:.1f}s "
              f"| Loss: {train_loss:.4f}")

        # Sauvegarde du meilleur modèle
        if epoch % 5 == 0:
            checkpoint = {
                'epoch': epoch,
                'model_state_dict': model.state_dict(),
                'optimizer_state_dict': optimizer.state_dict(),
                'scheduler_state_dict': scheduler.state_dict(),
                'train_loss': train_loss,
                'config': config
            }
            path = f"{config['save_dir']}/checkpoint_epoch{epoch}.pth"
            torch.save(checkpoint, path)
            print(f"Checkpoint sauvegardé : {path}")

if __name__ == '__main__':
    main()

8. Métriques : mAP et IoU

L’évaluation rigoureuse des modèles de détection nécessite des métriques spécifiques qui capturent à la fois la qualité de la localisation et la précision de la classification. Les deux métriques fondamentales sont l’IoU (Intersection over Union) et le mAP (mean Average Precision).

8.1 IoU (Intersection over Union)

L’IoU, aussi appelé indice de Jaccard, mesure le degré de chevauchement entre la boîte prédite et la boîte de vérité terrain (ground truth). Elle se calcule comme le rapport entre l’aire de l’intersection des deux boîtes et l’aire de leur union. Un IoU de 1,0 signifie une correspondance parfaite, tandis qu’un IoU de 0 indique aucun chevauchement. Conventionnellement, un seuil d’IoU de 0,5 est utilisé pour déterminer si une prédiction est un vrai positif (True Positive) ou un faux positif (False Positive).

def compute_iou(box1, box2):
    """
    Calcule l'IoU entre deux boîtes englobantes.
    Format: [x_min, y_min, x_max, y_max]
    """
    # Coordonnées de l'intersection
    x1 = max(box1[0], box2[0])
    y1 = max(box1[1], box2[1])
    x2 = min(box1[2], box2[2])
    y2 = min(box1[3], box2[3])

    # Aire de l'intersection
    intersection = max(0, x2 - x1) * max(0, y2 - y1)

    # Aires des boîtes individuelles
    area1 = (box1[2] - box1[0]) * (box1[3] - box1[1])
    area2 = (box2[2] - box2[0]) * (box2[3] - box2[1])

    # IoU = Intersection / Union
    union = area1 + area2 - intersection
    iou = intersection / union if union > 0 else 0

    return iou

def compute_iou_batch(boxes1, boxes2):
    """Calcul vectorisé de l'IoU pour des tenseurs de boîtes."""
    # boxes1: [N, 4], boxes2: [M, 4] -> output: [N, M]
    x1 = torch.max(boxes1[:, None, 0], boxes2[None, :, 0])
    y1 = torch.max(boxes1[:, None, 1], boxes2[None, :, 1])
    x2 = torch.min(boxes1[:, None, 2], boxes2[None, :, 2])
    y2 = torch.min(boxes1[:, None, 3], boxes2[None, :, 3])

    intersection = torch.clamp(x2 - x1, min=0) * torch.clamp(y2 - y1, min=0)

    area1 = (boxes1[:, 2] - boxes1[:, 0]) * (boxes1[:, 3] - boxes1[:, 1])
    area2 = (boxes2[:, 2] - boxes2[:, 0]) * (boxes2[:, 3] - boxes2[:, 1])

    union = area1[:, None] + area2[None, :] - intersection
    return intersection / (union + 1e-6)

8.2 mAP (mean Average Precision)

Le mAP est la métrique standard pour évaluer les détecteurs d’objets. Pour chaque classe, on calcule la courbe Precision-Recall en faisant varier le seuil de confiance. L’Average Precision (AP) est l’aire sous cette courbe, calculée par interpolation. Le mAP est la moyenne des AP sur toutes les classes. Sur COCO, on rapporte généralement le mAP@0.5 (IoU ≥ 0.5) et le mAP@0.5:0.95 (moyenne sur 10 seuils d’IoU de 0.5 à 0.95 par pas de 0.05), cette dernière étant la plus exigeante et la plus représentative des performances réelles.

from torchmetrics.detection.mean_ap import MeanAveragePrecision

# Calcul du mAP avec torchmetrics
metric = MeanAveragePrecision(
    iou_type='bbox',
    iou_thresholds=[0.5, 0.75],  # mAP@50 et mAP@75
    class_metrics=True             # AP par classe
)

# Prédictions et ground truth
preds = [{
    'boxes': torch.tensor([[100, 50, 300, 200], [350, 100, 500, 400]]),
    'scores': torch.tensor([0.95, 0.87]),
    'labels': torch.tensor([1, 2])
}]
targets = [{
    'boxes': torch.tensor([[105, 48, 295, 205], [340, 90, 510, 410]]),
    'labels': torch.tensor([1, 2])
}]

metric.update(preds, targets)
results = metric.compute()

print(f"mAP@0.5: {results['map_50']:.4f}")
print(f"mAP@0.75: {results['map_75']:.4f}")
print(f"mAP@0.5:0.95: {results['map']:.4f}")
print(f"AP par classe: {results['map_per_class']}")

9. Inférence en temps réel

L’inférence en temps réel est l’objectif ultime pour de nombreuses applications de détection d’objets : surveillance vidéo, conduite autonome, robotique industrielle, réalité augmentée. Atteindre des performances temps réel (typiquement ≥ 30 FPS) nécessite une optimisation minutieuse à plusieurs niveaux : choix du modèle, précision numérique, batching, et pipeline de pré/post-traitement.

import cv2
import torch
import time
from ultralytics import YOLO

class RealtimeDetector:
    """Détecteur d'objets temps réel avec YOLOv8."""

    def __init__(self, model_path, conf_threshold=0.5, iou_threshold=0.45):
        self.model = YOLO(model_path)
        self.conf_threshold = conf_threshold
        self.iou_threshold = iou_threshold
        self.device = 'cuda' if torch.cuda.is_available() else 'cpu'

        # Warmup du modèle
        dummy = torch.randn(1, 3, 640, 640).to(self.device)
        for _ in range(3):
            self.model.predict(dummy, verbose=False)
        print(f"Modèle chargé sur {self.device}, warmup terminé.")

    def process_video(self, source=0, save_output=None):
        """
        Détection en temps réel sur un flux vidéo.
        source: 0 pour webcam, ou chemin vers un fichier vidéo
        """
        cap = cv2.VideoCapture(source)
        if not cap.isOpened():
            raise RuntimeError(f"Impossible d'ouvrir la source: {source}")

        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = cap.get(cv2.CAP_PROP_FPS) or 30

        writer = None
        if save_output:
            fourcc = cv2.VideoWriter_fourcc(*'mp4v')
            writer = cv2.VideoWriter(save_output, fourcc, fps, (width, height))

        frame_count = 0
        total_time = 0

        try:
            while True:
                ret, frame = cap.read()
                if not ret:
                    break

                start = time.perf_counter()

                # Inférence
                results = self.model.predict(
                    frame,
                    conf=self.conf_threshold,
                    iou=self.iou_threshold,
                    device=self.device,
                    half=True,       # FP16 pour accélérer
                    verbose=False
                )

                # Dessiner les détections
                annotated = results[0].plot(
                    line_width=2,
                    font_size=12,
                    pil=False
                )

                elapsed = time.perf_counter() - start
                total_time += elapsed
                frame_count += 1
                current_fps = 1.0 / elapsed if elapsed > 0 else 0

                # Afficher les stats
                cv2.putText(annotated, f"FPS: {current_fps:.1f}",
                           (10, 30), cv2.FONT_HERSHEY_SIMPLEX,
                           1, (0, 255, 0), 2)

                num_detections = len(results[0].boxes)
                cv2.putText(annotated, f"Objets: {num_detections}",
                           (10, 70), cv2.FONT_HERSHEY_SIMPLEX,
                           1, (0, 255, 0), 2)

                if writer:
                    writer.write(annotated)

                cv2.imshow('Detection', annotated)
                if cv2.waitKey(1) & 0xFF == ord('q'):
                    break

        finally:
            cap.release()
            if writer:
                writer.release()
            cv2.destroyAllWindows()

            avg_fps = frame_count / total_time if total_time > 0 else 0
            print(f"\nStats: {frame_count} frames, FPS moyen: {avg_fps:.1f}")

# Utilisation
detector = RealtimeDetector('yolov8m.pt', conf_threshold=0.4)
detector.process_video(source='video.mp4', save_output='output.mp4')

10. Export ONNX et déploiement

Pour déployer un modèle en production, il est souvent nécessaire de l’exporter dans un format optimisé, indépendant du framework d’entraînement. ONNX (Open Neural Network Exchange) est le standard industriel qui permet d’exécuter un modèle PyTorch dans n’importe quel runtime compatible : ONNX Runtime, TensorRT, OpenVINO, CoreML, etc. L’export ONNX est une étape critique qui peut multiplier les performances d’inférence par un facteur 2 à 10 selon la plateforme cible.

# Export ONNX avec YOLOv8
from ultralytics import YOLO

model = YOLO('runs/detect/yolov8m_custom/weights/best.pt')

# Export vers ONNX
model.export(
    format='onnx',
    imgsz=640,
    half=False,          # FP32 pour compatibilité maximale
    simplify=True,       # Simplifier le graphe ONNX
    opset=17,            # Version de l'opset ONNX
    dynamic=True,        # Axes dynamiques (batch size variable)
    batch=1
)

# Export vers TensorRT (GPU NVIDIA)
model.export(
    format='engine',
    imgsz=640,
    half=True,           # FP16 pour TensorRT
    device='0',
    workspace=4          # Go de mémoire GPU pour l'optimisation
)

# Export vers CoreML (Apple Silicon)
model.export(
    format='coreml',
    imgsz=640,
    half=True,           # FP16
    nms=True             # Inclure NMS dans le modèle
)
# Inférence avec ONNX Runtime
import onnxruntime as ort
import numpy as np

class ONNXDetector:
    """Détecteur utilisant ONNX Runtime pour l'inférence."""

    def __init__(self, model_path):
        # Configurer les providers (GPU si disponible)
        providers = ['CUDAExecutionProvider', 'CPUExecutionProvider']
        self.session = ort.InferenceSession(model_path, providers=providers)

        self.input_name = self.session.get_inputs()[0].name
        self.input_shape = self.session.get_inputs()[0].shape
        print(f"Modèle ONNX chargé. Input: {self.input_name} {self.input_shape}")
        print(f"Provider actif: {self.session.get_providers()[0]}")

    def preprocess(self, image, target_size=640):
        """Prétraitement identique à l'entraînement."""
        img = cv2.resize(image, (target_size, target_size))
        img = img.astype(np.float32) / 255.0
        img = np.transpose(img, (2, 0, 1))   # HWC -> CHW
        img = np.expand_dims(img, axis=0)      # Ajouter batch dim
        return img

    def predict(self, image, conf_threshold=0.5):
        """Exécuter l'inférence ONNX."""
        input_tensor = self.preprocess(image)
        outputs = self.session.run(None, {self.input_name: input_tensor})
        detections = self.postprocess(outputs, conf_threshold)
        return detections

    def postprocess(self, outputs, conf_threshold):
        """Post-traitement: filtrage par confiance et NMS."""
        predictions = outputs[0][0]  # [num_predictions, 5+num_classes]
        boxes = predictions[:, :4]
        scores = predictions[:, 4]
        class_ids = np.argmax(predictions[:, 5:], axis=1)
        class_scores = np.max(predictions[:, 5:], axis=1)
        final_scores = scores * class_scores

        mask = final_scores > conf_threshold
        return {
            'boxes': boxes[mask],
            'scores': final_scores[mask],
            'class_ids': class_ids[mask]
        }

# Utilisation
onnx_detector = ONNXDetector('best.onnx')
image = cv2.imread('test_image.jpg')
results = onnx_detector.predict(image, conf_threshold=0.5)
print(f"Détections: {len(results['boxes'])}")
ℹ️ Performance d’export
Benchmarks typiques sur NVIDIA T4 avec YOLOv8m (640×640) : PyTorch FP32 = 78 FPS, ONNX Runtime FP32 = 115 FPS, TensorRT FP16 = 280 FPS. L’export TensorRT offre le meilleur ratio performance/qualité pour les déploiements GPU NVIDIA en production.

11. Conclusion et ressources

La détection d’objets avec PyTorch a considérablement évolué ces dernières années, passant de modèles complexes et lents à des architectures comme YOLOv8 et v9 qui atteignent des performances remarquables en temps réel. Les outils modernes — transfer learning, augmentation avancée, mixed precision training, export ONNX — rendent le développement de solutions de détection accessible à un nombre croissant de praticiens, même avec des ressources computationnelles limitées.

Les points clés à retenir de cet article sont les suivants. Premièrement, le transfer learning est incontournable : utilisez toujours un backbone pré-entraîné sur ImageNet comme point de départ, sauf si vous disposez de millions d’images annotées. Deuxièmement, l’augmentation de données est critique : Albumentations avec des pipelines bien conçus peut multiplier la taille effective de votre dataset et améliorer significativement la robustesse du modèle. Troisièmement, le choix de l’architecture dépend du contexte : YOLO pour le temps réel, Faster R-CNN pour la précision maximale, et les modèles transformer-based (DETR, RT-DETR) pour les approches les plus modernes. Quatrièmement, l’évaluation rigoureuse avec mAP@0.5:0.95 est essentielle pour comparer objectivement les modèles. Enfin, l’export et l’optimisation (ONNX, TensorRT) sont des étapes non négligeables qui peuvent multiplier les performances d’inférence par un facteur significatif.

Le domaine continue d’évoluer rapidement avec l’émergence des modèles de fondation en vision (SAM, DINO, Grounding DINO) qui promettent une détection d’objets plus flexible, plus généraliste et potentiellement sans entraînement spécifique (zero-shot detection). Ces avancées ouvrent la voie à des applications encore plus ambitieuses et démocratisent l’accès à la vision par ordinateur de haute qualité.

📚 Sources et références

  1. He, K. et al. (2016). Deep Residual Learning for Image Recognition. CVPR 2016. arXiv:1512.03385
  2. Jocher, G. et al. (2023). Ultralytics YOLOv8. Documentation officielle. docs.ultralytics.com
  3. Wang, C-Y. et al. (2024). YOLOv9: Learning What You Want to Learn Using Programmable Gradient Information. arXiv:2402.13616
  4. Lin, T-Y. et al. (2014). Microsoft COCO: Common Objects in Context. ECCV 2014. arXiv:1405.0312
  5. Tan, M. & Le, Q. (2019). EfficientNet: Rethinking Model Scaling for CNNs. ICML 2019. arXiv:1905.11946
  6. Buslaev, A. et al. (2020). Albumentations: Fast and Flexible Image Augmentations. Information, 11(2). DOI:10.3390/info11020125
  7. PyTorch Documentation. TorchVision Object Detection Finetuning Tutorial. pytorch.org/tutorials
  8. ONNX Runtime Documentation. Performance Tuning Guide. onnxruntime.ai/docs
Retour à l'accueil