CNNs et computer vision avec PyTorch : Des convolutions à la détection d'objets
Les Convolutional Neural Networks (CNNs) ont révolutionné la computer vision en 2012 avec AlexNet. Aujourd’hui, les CNNs sont partout : reconnaissance faciale, voitures autonomes, diagnostic médical, filtres Instagram. Dans cet article, nous allons comprendre les convolutions, implémenter des architectures classiques, et explorer des applications avancées.
Pourquoi les CNNs pour les images ?
Les fully-connected networks (MLPs) ont deux problèmes majeurs avec les images :
Trop de paramètres
# Image 224x224 RGB
image_size = 224 * 224 * 3 # 150,528 pixels
# MLP avec une seule hidden layer de 1000 neurones
fc1 = nn.Linear(150528, 1000) # 150 millions de paramètres !
Pas de structure spatiale
Les MLPs traitent chaque pixel indépendamment, ignorant que les pixels voisins sont corrélés.
Convolutions
Les convolutions résolvent ces problèmes :
- ✅ Partage de poids : Même filtre utilisé sur toute l’image
- ✅ Localité : Chaque neurone regarde seulement une région locale
- ✅ Invariance de translation : Détecte un pattern partout dans l’image

Les convolutions 2D en détail
Opération de convolution
Une convolution applique un kernel (filtre) qui glisse sur l’image :
import torch
import torch.nn as nn
# Convolution 2D simple
conv = nn.Conv2d(
in_channels=3, # RGB input
out_channels=64, # 64 filtres
kernel_size=3, # Kernel 3x3
stride=1, # Pas de déplacement
padding=1, # Padding pour garder la taille
bias=True # Ajouter un biais
)
# Input : batch d'images RGB
x = torch.randn(32, 3, 224, 224) # (batch, channels, height, width)
# Forward pass
output = conv(x)
print(f"Output shape: {output.shape}") # (32, 64, 224, 224)
# Paramètres de la convolution
num_params = 3 * 64 * 3 * 3 + 64 # (in × out × k × k) + bias
print(f"Paramètres: {num_params:,}") # 1,792 params
Formule de la taille de sortie
def conv_output_size(input_size, kernel_size, stride, padding):
"""
Calcule la taille de sortie d'une convolution
"""
return (input_size - kernel_size + 2 * padding) // stride + 1
# Exemple
input_h, input_w = 224, 224
kernel_size = 3
stride = 1
padding = 1
output_h = conv_output_size(input_h, kernel_size, stride, padding)
print(f"Output size: {output_h}x{output_w}") # 224x224
# Sans padding
output_h_no_pad = conv_output_size(224, 3, 1, 0)
print(f"Sans padding: {output_h_no_pad}") # 222 (perd 2 pixels)
Types de convolutions
# Standard convolution
conv_std = nn.Conv2d(3, 64, kernel_size=3, padding=1)
# Strided convolution (downsampling)
conv_strided = nn.Conv2d(3, 64, kernel_size=3, stride=2, padding=1)
# 224x224 → 112x112
# Large kernel
conv_large = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3)
# Réceptive field plus grand
# Dilated (Atrous) convolution
conv_dilated = nn.Conv2d(3, 64, kernel_size=3, padding=2, dilation=2)
# Réceptive field plus grand sans augmenter les paramètres
# Depthwise separable (pour MobileNet)
conv_depthwise = nn.Conv2d(64, 64, kernel_size=3, padding=1, groups=64)
conv_pointwise = nn.Conv2d(64, 128, kernel_size=1)
# Beaucoup moins de paramètres
Pooling : Réduction de dimensions
Le pooling réduit la taille spatiale pour :
- Diminuer les paramètres
- Augmenter le receptive field
- Créer une invariance de position
Max pooling
# Max Pooling 2x2
maxpool = nn.MaxPool2d(kernel_size=2, stride=2)
x = torch.randn(32, 64, 224, 224)
output = maxpool(x)
print(f"After maxpool: {output.shape}") # (32, 64, 112, 112)
# Prend le maximum dans chaque région 2x2
# Exemple :
# [[1, 2], → max = 4
# [3, 4]]
Average pooling
# Average Pooling
avgpool = nn.AvgPool2d(kernel_size=2, stride=2)
x = torch.randn(32, 64, 224, 224)
output = avgpool(x)
print(f"After avgpool: {output.shape}") # (32, 64, 112, 112)
Global average pooling
# Global Average Pooling : H×W → 1×1
gap = nn.AdaptiveAvgPool2d((1, 1))
x = torch.randn(32, 2048, 7, 7)
output = gap(x)
print(f"After GAP: {output.shape}") # (32, 2048, 1, 1)
# Équivalent à :
# x.mean(dim=[2, 3], keepdim=True)
Architectures CNN classiques
LeNet-5 (1998) : Le pionnier
class LeNet5(nn.Module):
"""
Architecture originale pour MNIST
"""
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(1, 6, kernel_size=5)
self.conv2 = nn.Conv2d(6, 16, kernel_size=5)
self.fc1 = nn.Linear(16 * 5 * 5, 120)
self.fc2 = nn.Linear(120, 84)
self.fc3 = nn.Linear(84, num_classes)
def forward(self, x):
# Input: (batch, 1, 32, 32)
x = torch.relu(self.conv1(x)) # → (batch, 6, 28, 28)
x = torch.max_pool2d(x, 2) # → (batch, 6, 14, 14)
x = torch.relu(self.conv2(x)) # → (batch, 16, 10, 10)
x = torch.max_pool2d(x, 2) # → (batch, 16, 5, 5)
x = x.view(x.size(0), -1) # → (batch, 400)
x = torch.relu(self.fc1(x)) # → (batch, 120)
x = torch.relu(self.fc2(x)) # → (batch, 84)
x = self.fc3(x) # → (batch, 10)
return x
# Test
model = LeNet5()
x = torch.randn(1, 1, 32, 32)
output = model(x)
print(f"Output: {output.shape}") # (1, 10)
VGGNet (2014) : Profondeur avec petits kernels
class VGGBlock(nn.Module):
"""
Bloc VGG : N×(Conv3x3 + ReLU) + MaxPool
"""
def __init__(self, in_channels, out_channels, num_convs):
super().__init__()
layers = []
for _ in range(num_convs):
layers.append(nn.Conv2d(in_channels, out_channels,
kernel_size=3, padding=1))
layers.append(nn.ReLU(inplace=True))
in_channels = out_channels
layers.append(nn.MaxPool2d(kernel_size=2, stride=2))
self.block = nn.Sequential(*layers)
def forward(self, x):
return self.block(x)
class VGG16(nn.Module):
"""
VGG16 : 13 conv + 3 fc = 16 layers
"""
def __init__(self, num_classes=1000):
super().__init__()
self.features = nn.Sequential(
VGGBlock(3, 64, 2), # 224 → 112
VGGBlock(64, 128, 2), # 112 → 56
VGGBlock(128, 256, 3), # 56 → 28
VGGBlock(256, 512, 3), # 28 → 14
VGGBlock(512, 512, 3), # 14 → 7
)
self.classifier = nn.Sequential(
nn.Linear(512 * 7 * 7, 4096),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(4096, 4096),
nn.ReLU(inplace=True),
nn.Dropout(0.5),
nn.Linear(4096, num_classes)
)
def forward(self, x):
x = self.features(x) # → (batch, 512, 7, 7)
x = x.view(x.size(0), -1) # → (batch, 25088)
x = self.classifier(x) # → (batch, num_classes)
return x
# VGG16 a 138M paramètres !
model = VGG16()
print(f"Params: {sum(p.numel() for p in model.parameters()):,}")
Batch Normalization : Stabiliser l’entraînement
La Batch Normalization normalise les activations pour stabiliser et accélérer l’entraînement.
Principe
# Batch Normalization après convolution
class ConvBNReLU(nn.Module):
def __init__(self, in_channels, out_channels):
super().__init__()
self.conv = nn.Conv2d(in_channels, out_channels,
kernel_size=3, padding=1, bias=False)
# bias=False car BN a son propre biais
self.bn = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
x = self.conv(x)
x = self.bn(x)
x = self.relu(x)
return x
# Utilisation
block = ConvBNReLU(64, 128)
x = torch.randn(32, 64, 56, 56)
output = block(x)
print(f"Output: {output.shape}") # (32, 128, 56, 56)
Ordre des opérations
Deux conventions existent :
# Conv → BN → ReLU (plus courant)
nn.Sequential(
nn.Conv2d(64, 128, 3, padding=1, bias=False),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True)
)
# Conv → ReLU → BN (moins courant)
nn.Sequential(
nn.Conv2d(64, 128, 3, padding=1),
nn.ReLU(inplace=True),
nn.BatchNorm2d(128)
)
Ordre recommandé : Conv → BN → ReLU est le plus utilisé (ResNet, EfficientNet). Important : Utilisez
bias=False dans Conv2d avant BatchNorm (le biais de BN rend celui de Conv inutile).ResNet : Residual Connections pour Réseaux Profonds
Les réseaux très profonds (>20 couches) souffrent du problème de vanishing gradients. ResNet résout cela avec des skip connections.
Residual block
class ResidualBlock(nn.Module):
"""
Bloc résiduel de base de ResNet
"""
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
# Main path
self.conv1 = nn.Conv2d(in_channels, out_channels,
kernel_size=3, stride=stride,
padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels,
kernel_size=3, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(out_channels)
# Shortcut connection
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels,
kernel_size=1, stride=stride, bias=False),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
identity = x
# Main path
out = self.conv1(x)
out = self.bn1(out)
out = torch.relu(out)
out = self.conv2(out)
out = self.bn2(out)
# Add skip connection
out += self.shortcut(identity)
out = torch.relu(out)
return out
# Test
block = ResidualBlock(64, 128, stride=2)
x = torch.randn(1, 64, 56, 56)
output = block(x)
print(f"Output: {output.shape}") # (1, 128, 28, 28)
ResNet complet
class ResNet(nn.Module):
"""
ResNet simplifié pour CIFAR-10
"""
def __init__(self, num_classes=10):
super().__init__()
self.conv1 = nn.Conv2d(3, 64, kernel_size=3,
padding=1, bias=False)
self.bn1 = nn.BatchNorm2d(64)
# Residual layers
self.layer1 = self._make_layer(64, 64, num_blocks=2, stride=1)
self.layer2 = self._make_layer(64, 128, num_blocks=2, stride=2)
self.layer3 = self._make_layer(128, 256, num_blocks=2, stride=2)
self.layer4 = self._make_layer(256, 512, num_blocks=2, stride=2)
self.avgpool = nn.AdaptiveAvgPool2d((1, 1))
self.fc = nn.Linear(512, num_classes)
def _make_layer(self, in_channels, out_channels, num_blocks, stride):
layers = []
# Premier bloc avec stride potentiel
layers.append(ResidualBlock(in_channels, out_channels, stride))
# Blocs suivants
for _ in range(1, num_blocks):
layers.append(ResidualBlock(out_channels, out_channels, stride=1))
return nn.Sequential(*layers)
def forward(self, x):
# Initial conv
x = self.conv1(x)
x = self.bn1(x)
x = torch.relu(x)
# Residual layers
x = self.layer1(x)
x = self.layer2(x)
x = self.layer3(x)
x = self.layer4(x)
# Global pooling + classifier
x = self.avgpool(x)
x = x.view(x.size(0), -1)
x = self.fc(x)
return x
# Test
model = ResNet(num_classes=10)
x = torch.randn(1, 3, 32, 32)
output = model(x)
print(f"Output: {output.shape}") # (1, 10)
print(f"Params: {sum(p.numel() for p in model.parameters()):,}")
CNN Moderne : Bloc conv optimal
Le bloc de convolution moderne standard :
class ModernConvBlock(nn.Module):
"""
Bloc de convolution moderne :
Conv (bias=False) → BatchNorm → Activation
"""
def __init__(self, in_channels, out_channels, kernel_size=3,
stride=1, padding=1, activation='relu'):
super().__init__()
self.conv = nn.Conv2d(
in_channels, out_channels,
kernel_size=kernel_size,
stride=stride,
padding=padding,
bias=False # Pas de biais avant BN
)
self.bn = nn.BatchNorm2d(out_channels)
# Activation
if activation == 'relu':
self.act = nn.ReLU(inplace=True)
elif activation == 'gelu':
self.act = nn.GELU()
elif activation == 'silu': # Swish
self.act = nn.SiLU(inplace=True)
else:
self.act = nn.Identity()
def forward(self, x):
x = self.conv(x)
x = self.bn(x)
x = self.act(x)
return x
Visualiser les feature maps
Voir ce que le réseau a appris :
def visualize_feature_maps(model, image, layer_idx=0):
"""
Visualise les feature maps d'une couche
"""
import matplotlib.pyplot as plt
# Hook pour capturer les activations
activations = []
def hook(module, input, output):
activations.append(output.detach())
# Enregistrer le hook
handle = list(model.children())[layer_idx].register_forward_hook(hook)
# Forward pass
model.eval()
with torch.no_grad():
_ = model(image)
# Retirer le hook
handle.remove()
# Visualiser
feature_maps = activations[0][0] # Premier exemple du batch
num_filters = min(64, feature_maps.shape[0]) # Max 64 filtres
fig, axes = plt.subplots(8, 8, figsize=(12, 12))
for i, ax in enumerate(axes.flat):
if i < num_filters:
ax.imshow(feature_maps[i].cpu(), cmap='viridis')
ax.axis('off')
plt.tight_layout()
plt.savefig('feature_maps.png')
print(f"Visualized {num_filters} feature maps from layer {layer_idx}")
# Utilisation
# model = ResNet()
# image = torch.randn(1, 3, 224, 224)
# visualize_feature_maps(model, image, layer_idx=1)
Applications Avancées
Classification d’images
# Exemple complet avec ResNet pré-entraîné
from torchvision import models, transforms
from PIL import Image
# Charger modèle
model = models.resnet50(pretrained=True)
model.eval()
# Préparer image
transform = transforms.Compose([
transforms.Resize(256),
transforms.CenterCrop(224),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
# Prédire
image = Image.open('cat.jpg')
input_tensor = transform(image).unsqueeze(0)
with torch.no_grad():
output = model(input_tensor)
probs = torch.softmax(output, dim=1)
top5_prob, top5_idx = torch.topk(probs, 5)
print("Top 5 predictions:")
for prob, idx in zip(top5_prob[0], top5_idx[0]):
print(f" Class {idx}: {prob:.2%}")
Détection d’objets (Faster R-CNN)
from torchvision.models.detection import fasterrcnn_resnet50_fpn
# Charger modèle de détection pré-entraîné
model = fasterrcnn_resnet50_fpn(pretrained=True)
model.eval()
# Préparer image
image = Image.open('street.jpg')
transform = transforms.ToTensor()
input_tensor = transform(image).unsqueeze(0)
# Détecter objets
with torch.no_grad():
predictions = model(input_tensor)
# Résultats
boxes = predictions[0]['boxes'] # Bounding boxes
labels = predictions[0]['labels'] # Classes
scores = predictions[0]['scores'] # Confiance
# Filtrer par confiance
threshold = 0.5
for box, label, score in zip(boxes, labels, scores):
if score > threshold:
print(f"Detected class {label} with confidence {score:.2f}")
print(f" Box: {box.tolist()}")
Segmentation sémantique (DeepLab)
from torchvision.models.segmentation import deeplabv3_resnet50
# Charger modèle de segmentation
model = deeplabv3_resnet50(pretrained=True)
model.eval()
# Préparer image
transform = transforms.Compose([
transforms.Resize((512, 512)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
image = Image.open('scene.jpg')
input_tensor = transform(image).unsqueeze(0)
# Segmenter
with torch.no_grad():
output = model(input_tensor)['out']
# output: (1, 21, H, W) - 21 classes PASCAL VOC
# Obtenir la classe par pixel
segmentation_mask = output.argmax(1)[0] # (H, W)
# Visualiser
import matplotlib.pyplot as plt
plt.imshow(segmentation_mask.cpu(), cmap='tab20')
plt.savefig('segmentation.png')
Exercices pratiques
Implémenter VGG11
Créez une version simplifiée de VGG avec 11 couches :
class VGG11(nn.Module):
def __init__(self, num_classes=10):
super().__init__()
# Votre code ici
# Architecture : [64] → [128] → [256, 256] → [512, 512] → [512, 512]
# Chaque [] = VGGBlock avec MaxPool
pass
def forward(self, x):
# Votre code ici
pass
ResNet sur CIFAR-10
Entraînez ResNet sur CIFAR-10 et atteignez >90% accuracy :
# Charger CIFAR-10
# Créer ResNet
# Entraîner avec data augmentation
# Mesurer accuracy sur test set
Visualiser feature maps
Visualisez les feature maps de différentes couches et observez la hiérarchie de features (edges → textures → objets).
Conclusion
Dans cet article, vous avez maîtrisé les CNNs et la computer vision :
- ✅ Principe des convolutions 2D (kernels, stride, padding)
- ✅ Pooling (max, average, global average)
- ✅ Architectures classiques (LeNet, VGG, ResNet)
- ✅ Batch Normalization pour stabilité
- ✅ Residual connections pour réseaux profonds
- ✅ Applications (classification, détection, segmentation)
- ✅ Visualisation des feature maps
Prochaine étape : Dans le prochain article, nous allons explorer les RNNs, LSTMs et Transformers pour traiter des séquences. Vous apprendrez à gérer des données temporelles et découvrirez l’architecture Transformer qui révolutionne le NLP.
Pour aller plus loin :
- Consultez Papers with Code pour les architectures SOTA
- Explorez torchvision.models pour plus de modèles
- Découvrez notre guide sur le transfer learning pour réutiliser ces architectures
Projet pratique : Créez un classifieur d’images custom avec ResNet. Utilisez transfer learning, data augmentation agressive, et visualisez les feature maps. Implémentez aussi la détection d’objets simple avec Faster R-CNN. Objectif : comprendre comment les CNNs extraient des features hiérarchiques !