Module 6 — Segmentation sémantique : U-Net et DeepLab
Le module 1 a rangé la segmentation dans la troisième famille de tâches. Sur le carrefour, la segmentation sémantique isole la chaussée, les trottoirs, les voies de bus et les passages piétons sans distinguer deux voitures collées. C'est une carte de classes de la taille de l'image. Ce module explique les deux architectures dominantes et pourquoi la perte standard, l'entropie croisée, échoue silencieusement sur les classes minoritaires.
L'idée : encoder puis décoder
Un réseau de segmentation reprend le tronc convolutif d'un modèle de classification, qui réduit progressivement la résolution tout en augmentant le nombre de canaux. À la fin, une carte 7 × 7 contient les caractéristiques abstraites, mais l'information spatiale fine est perdue.
Pour retrouver une carte à la résolution d'origine, il faut décoder : remonter progressivement en résolution, souvent par des convolutions transposées ou par un simple sur-échantillonnage suivi de convolutions classiques. Le premier réseau à formaliser ce schéma symétrique en 2015 s'appelle U-Net.
U-Net : les connexions de saut sauvent les détails
La clé de U-Net n'est pas la symétrie, c'est la connexion de saut entre chaque niveau de l'encodeur et le niveau correspondant du décodeur. Cette connexion transporte directement la carte fine avant sa réduction, et le décodeur la concatène à sa propre carte remontée.
Résultat : la carte finale voit à la fois le contexte abstrait (les caractéristiques de haut niveau du fond) et les détails locaux (les contours nets des premières couches). Sans ces sauts, le décodeur produit des masques flous, avec des bords à cinq ou dix pixels d'imprécision.
import torch
import torch.nn as nn
class BlocDouble(nn.Module):
def __init__(self, c_in, c_out):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(c_in, c_out, 3, padding=1), nn.ReLU(inplace=True),
nn.Conv2d(c_out, c_out, 3, padding=1), nn.ReLU(inplace=True),
)
def forward(self, x): return self.conv(x)
class UNetMini(nn.Module):
def __init__(self, n_classes):
super().__init__()
self.enc1 = BlocDouble(3, 32)
self.enc2 = BlocDouble(32, 64)
self.bas = BlocDouble(64, 128)
self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2)
self.dec2 = BlocDouble(128, 64) # 64 + 64 apres concat
self.up1 = nn.ConvTranspose2d(64, 32, 2, stride=2)
self.dec1 = BlocDouble(64, 32) # 32 + 32 apres concat
self.sortie = nn.Conv2d(32, n_classes, 1)
def forward(self, x):
s1 = self.enc1(x)
s2 = self.enc2(nn.functional.max_pool2d(s1, 2))
b = self.bas(nn.functional.max_pool2d(s2, 2))
d2 = self.dec2(torch.cat([self.up2(b), s2], dim=1))
d1 = self.dec1(torch.cat([self.up1(d2), s1], dim=1))
return self.sortie(d1) # (B, n_classes, H, W)
La sortie a le même et le même que l'entrée, avec un canal par
classe. Un argmax sur l'axe des canaux donne la carte finale.
U-Net a été conçu pour l'imagerie médicale, où les images sont grandes et le jeu est petit. Il tolère un entraînement avec quelques centaines d'images seulement, ce qui en fait un excellent point de départ.
DeepLab : voir large sans réduire la résolution
Le problème du décodeur est qu'il doit compenser la perte de résolution de l'encodeur. DeepLab prend le problème par l'autre bout : et si l'encodeur préservait davantage la résolution ?
La clé est la convolution atrous — aussi appelée convolution dilatée. Elle insère des trous dans le noyau, ce qui agrandit le champ récepteur sans réduire la résolution ni augmenter le nombre de paramètres. Un noyau 3 × 3 dilaté à taux 2 couvre une zone de 5 × 5 avec seulement 9 poids ; à taux 4, une zone de 9 × 9.
Où est le taux de dilatation. À , on retrouve la convolution classique.
DeepLab combine plusieurs branches dilatées en parallèle — le module ASPP (Atrous Spatial Pyramid Pooling), qui capture des contextes à différentes échelles simultanément. C'est ce module qui donne à DeepLab sa robustesse sur les grands objets (comme un bus qui prend la moitié de l'image) et les petits (comme un piéton lointain).
Perte de segmentation : entropie croisée n'est pas seule
La perte standard est l'entropie croisée pixelwise, appliquée à chaque pixel indépendamment.
Sur une image de carrefour, le fond « route » et « ciel » occupe souvent plus de 90 % des pixels. Un modèle qui prédit uniquement ces deux classes atteint une exactitude pixelwise de 90 %, sans jamais détecter un piéton. L'entropie croisée standard n'a aucun contre-poids à ce déséquilibre.
Trois remèdes cohabitent :
- Entropie croisée pondérée : chaque classe reçoit un poids inversement proportionnel à sa fréquence.
- Perte de Dice, dérivée directement du coefficient de Dice.
- Somme des deux, souvent notée CE + Dice.
Dice contre IoU
Le coefficient de Dice mesure le chevauchement, comme l'IoU, mais avec une formule différente :
Les deux sont liés algébriquement : . Ils atteignent 0 et 1 aux mêmes extrêmes, mais Dice pénalise moins sévèrement les petits chevauchements.
La perte de Dice est simplement , calculée sur les probabilités prédites — pas sur les prédictions dures.
def perte_dice(logits, cibles, eps=1e-6):
"""logits: (B, C, H, W). cibles: (B, H, W) entiers."""
proba = torch.softmax(logits, dim=1)
cibles_ohe = torch.nn.functional.one_hot(
cibles, num_classes=logits.shape[1]
).permute(0, 3, 1, 2).float()
inter = (proba * cibles_ohe).sum(dim=(0, 2, 3))
somme = proba.sum(dim=(0, 2, 3)) + cibles_ohe.sum(dim=(0, 2, 3))
dice_par_classe = (2 * inter + eps) / (somme + eps)
return 1 - dice_par_classe.mean()
Sur les classes minoritaires, Dice est bien plus stable que l'entropie croisée parce que le facteur de normalisation dépend de la taille de la classe, pas du nombre total de pixels. C'est la perte à mettre par défaut sur un jeu déséquilibré.
Évaluation : le mIoU par classe
La métrique standard de segmentation sémantique est le mIoU : l'IoU calculée par classe et moyennée. Contrairement à la détection, il n'y a pas d'appariement à faire — on compare deux cartes pixel par pixel.
Sur Cityscapes à 19 classes, un bon modèle obtient environ 80 % de mIoU. Les classes rares (feux tricolores, motos) tirent la moyenne vers le bas — là encore, regarder toujours l'IoU par classe avant de conclure.
Sur un jeu déséquilibré, un modèle constant sur la classe dominante atteint 90 % ou plus d'exactitude sans rien apprendre. Ne jamais rapporter l'exactitude pixelwise seule : le mIoU, calculé classe par classe, est le minimum vital. Sur le carrefour, l'exactitude peut atteindre 95 % pendant que le mIoU sur la classe « piéton » est à 0,10.
smp (segmentation_models.pytorch) offre U-Net et DeepLab v3+ avec des
troncs préentraînés en trois lignes. Avant d'écrire une architecture à la
main, entraînez un Unet(encoder_name="resnet34", encoder_weights="imagenet")
sur votre jeu : vous saurez en une soirée si le problème est faisable.
En résumé
- U-Net repose sur un encodeur-décodeur symétrique dont les connexions de saut préservent les détails fins ; il fonctionne avec peu d'images et reste la référence en imagerie médicale.
- DeepLab utilise des convolutions atrous pour agrandir le champ récepteur sans réduire la résolution, et un module ASPP qui capture le contexte à plusieurs échelles.
- La perte de Dice compense le déséquilibre des classes de pixels, alors que l'entropie croisée pixelwise se laisse dominer par la classe majoritaire. La combinaison CE + Dice est un défaut raisonnable.
- L'évaluation standard est le mIoU calculé par classe puis moyenné ; l'exactitude pixelwise est trompeuse et ne doit jamais être rapportée seule.
Module suivant : la segmentation d'instances avec Mask R-CNN, qui ajoute la question « lequel ? » à celle du « quoi » des pixels.