0%
Vision par ordinateur

Vision

Comment une machine tire de l'information d'une image.

10-15 min

Vision par ordinateur

Reconnaître un chat sur une photo est, pour un humain, une opération instantanée et inconsciente. C’est aussi le problème qui a résisté le plus longtemps à l’informatique. Pendant des décennies, les chercheurs ont écrit à la main des détecteurs de contours, de coins, de textures, et empilé des règles pour les combiner — sans jamais approcher la robustesse d’un enfant de trois ans.

La difficulté n’est pas le manque d’information : elle est son excès. Une photographie de téléphone contient plusieurs millions de nombres, dont aucun ne signifie quoi que ce soit isolément. La valeur d’un pixel ne dit rien ; c’est l’organisation locale de milliers de pixels qui forme un contour, et l’organisation de milliers de contours qui forme un visage. Et cette organisation doit être reconnue quelle que soit la position de l’objet, son éclairage, son échelle ou son orientation.

L’idée qui a débloqué le problème est étonnamment économe. Plutôt que d’apprendre une règle pour chaque position possible dans l’image, on apprend un petit détecteur et on le promène partout. Un motif appris une fois est reconnu où qu’il se trouve, et le nombre de paramètres à ajuster s’effondre. Cette opération s’appelle la convolution, et nous allons l’écrire nous-mêmes en une dizaine de lignes.

Nous verrons ensuite comment on empile ces détecteurs, en quoi les trois grandes tâches de la vision diffèrent réellement, et pourquoi presque personne n’entraîne aujourd’hui un modèle de vision depuis zéro.

Une image est un tableau de nombres

Une image en niveaux de gris est une matrice : une valeur d’intensité par pixel. Une image couleur est trois matrices superposées, une par canal rouge, vert et bleu.

from sklearn.datasets import load_digits

chiffres = load_digits()
print(chiffres.images.shape)   # (1797, 8, 8)
print(chiffres.images.min(), chiffres.images.max())   # 0.0 16.0
print(chiffres.target[0])      # 0

print(chiffres.images[0].astype(int))
# [[ 0  0  5 13  9  1  0  0]
#  [ 0  0 13 15 10 15  5  0]
#  [ 0  3 15  2  0 11  8  0]
#  [ 0  4 12  0  0  8  8  0]
#  [ 0  5  8  0  0  9  8  0]
#  [ 0  4 11  0  1 12  7  0]
#  [ 0  2 14  5 10 12  0  0]
#  [ 0  0  6 13 10  0  0  0]]

En plissant les yeux, le zéro se devine : deux colonnes de valeurs élevées séparées par un creux central de zéros. C’est exactement ce que la machine reçoit — ni plus, ni moins.

L’échelle est le premier obstacle. Ce jeu de données utilise des vignettes de 8 × 8, soit 64 nombres par image. Une photographie d’entrée courante pour un réseau de vision fait 224 × 224 pixels sur 3 canaux :

print(224 * 224 * 3)   # 150528

150 528 nombres pour une seule image. Toute la suite consiste à traiter ce volume sans faire exploser le nombre de paramètres à apprendre.

La convolution : détecter un motif, où qu’il soit

Une convolution fait glisser une petite matrice de poids — le noyau, ou filtre — sur l’image. À chaque position, elle multiplie terme à terme et additionne. Le résultat est une nouvelle image, appelée carte d’activation, qui indique où le motif recherché est présent.

import numpy as np

# Une barre verticale claire sur fond sombre.
image = np.array([
    [0, 0, 9, 9, 0, 0],
    [0, 0, 9, 9, 0, 0],
    [0, 0, 9, 9, 0, 0],
    [0, 0, 9, 9, 0, 0],
], dtype=float)

# Noyau de Sobel simplifié : détecteur de contour vertical.
noyau = np.array([[1.0, 0.0, -1.0],
                  [1.0, 0.0, -1.0],
                  [1.0, 0.0, -1.0]])

def convoluer(image, noyau):
    h, w = image.shape
    kh, kw = noyau.shape
    sortie = np.zeros((h - kh + 1, w - kw + 1))
    for y in range(sortie.shape[0]):
        for x in range(sortie.shape[1]):
            fenetre = image[y:y + kh, x:x + kw]
            sortie[y, x] = float((fenetre * noyau).sum())
    return sortie

carte = convoluer(image, noyau)
print(carte)
# [[-27. -27.  27.  27.]
#  [-27. -27.  27.  27.]]

Vérifions une valeur à la main. En haut à droite, la fenêtre couvre les colonnes 3, 4, 5, soit les valeurs [[9,0,0],[9,0,0],[9,0,0]]. Le produit avec le noyau donne 9×1 + 0×0 + 0×(−1) répété sur trois lignes, soit 27. La valeur +27 signale une transition clair → sombre. À gauche, la même transition en sens inverse donne −27. Au centre du motif, un noyau symétrique donnerait 0 : rien à signaler.

Trois propriétés font toute la puissance de l’opération :

  • Localité. Chaque sortie ne dépend que d’un petit voisinage, ce qui correspond à la structure réelle des images : un contour est un phénomène local.
  • Partage des poids. Les neuf poids du noyau sont les mêmes à toutes les positions. Un détecteur appris une fois fonctionne partout dans l’image.
  • Économie. C’est la conséquence chiffrée du point précédent, et elle est spectaculaire.
# Une couche dense reliant tous les pixels d'une image 224×224×3 à 1000 neurones :
print(150528 * 1000 + 1000)      # 150529000

# Une couche convolutive de 64 noyaux de 3×3 sur 3 canaux :
print(3 * 3 * 3 * 64 + 64)       # 1792

150 millions de paramètres contre 1 792. Un facteur d’environ 84 000. Sans le partage des poids, l’apprentissage profond sur images n’aurait jamais été praticable, et ce n’est pas une question de patience : le nombre d’exemples nécessaires pour ajuster 150 millions de paramètres sur une seule couche est hors d’atteinte.

Réduire, empiler, et voir plus large

Une convolution seule ne regarde qu’un voisinage de 3 × 3. Pour reconnaître un objet entier, il faut élargir progressivement le champ de vision. Deux mécanismes s’y emploient.

Le premier est le sous-échantillonnage, dont la forme la plus courante conserve le maximum de chaque bloc :

def pooling_max(carte, taille=2):
    h, w = carte.shape
    return carte[:h - h % taille, :w - w % taille].reshape(
        h // taille, taille, w // taille, taille).max(axis=(1, 3))

print(pooling_max(np.abs(carte)))   # [[27. 27.]]

La carte de 2 × 4 devient 1 × 2. On perd la position exacte du contour et on garde son intensité : c’est précisément le compromis voulu pour la classification, où savoir qu’il y a un contour importe plus que sa position au pixel près.

Le second mécanisme est l’empilement. Chaque couche voit les sorties de la précédente, donc un voisinage de plus en plus large de l’image d’origine :

Couche 1  (3×3 pixels vus)    →  contours, taches de couleur
Couche 2  (~7×7 pixels vus)   →  coins, angles, courbes
Couche 3  (~15×15)            →  motifs : œil, roue, texture
Couche 4+ (une grande partie) →  parties d'objets, puis objets

Cette hiérarchie n’est pas imposée par le programmeur : elle émerge de l’entraînement, et elle a été retrouvée expérimentalement dans les réseaux entraînés en visualisant ce qui active le plus chaque filtre. C’est l’apprentissage de représentations vu dans la leçon précédente, appliqué aux images.

Deux dates structurent cette histoire : LeNet-5, publié par Yann LeCun et coll. en 1998, applique déjà ce schéma à la lecture de chèques ; AlexNet, en 2012, le porte à l’échelle d’ImageNet et déclenche la vague actuelle.

Classer, détecter, segmenter

Ces trois mots désignent des tâches souvent confondues. Elles se distinguent sans ambiguïté par la forme de leur sortie, ce qui détermine le coût de l’annotation et la difficulté du problème.

TâcheQuestionSortie pour une image 224×224 et 20 classes
Classificationqu’y a-t-il sur cette image ?20 nombres (une probabilité par classe)
Détectionoù sont les objets, et lesquels ?une liste : classe, score et 4 coordonnées par objet
Segmentationà quelle classe appartient chaque pixel ?224 × 224 × 20 = 1 003 520 nombres
print(224 * 224 * 20)   # 1003520

L’écart entre 20 et un million de valeurs de sortie explique l’essentiel : la segmentation exige des annotations au pixel près, dont le coût de production est sans commune mesure avec une simple étiquette d’image.

En détection, la métrique de référence mesure le recouvrement entre la boîte prédite et la boîte de référence. Elle se calcule exactement :

def iou(a, b):
    """Intersection sur union de deux boîtes (x1, y1, x2, y2)."""
    x1 = max(a[0], b[0]); y1 = max(a[1], b[1])
    x2 = min(a[2], b[2]); y2 = min(a[3], b[3])
    inter = max(0, x2 - x1) * max(0, y2 - y1)
    aire_a = (a[2] - a[0]) * (a[3] - a[1])
    aire_b = (b[2] - b[0]) * (b[3] - b[1])
    return inter / (aire_a + aire_b - inter)

print(round(iou((10, 10, 50, 50), (30, 30, 70, 70)), 3))   # 0.143
print(round(iou((10, 10, 50, 50), (12, 12, 52, 52)), 3))   # 0.822

Le premier cas : deux boîtes de 40 × 40 décalées de 20 pixels se recouvrent sur 20 × 20 = 400 pixels, pour une union de 1600 + 1600 − 400 = 2800, soit 400 / 2800 ≈ 0,143. La convention usuelle considère une détection comme correcte au-delà de 0,5. La seconde boîte, décalée de 2 pixels seulement, se recouvre sur 38 × 38 = 1 444 pixels pour une union de 1 756, soit 0,822 : correcte.

Le transfert d’apprentissage

Entraîner un réseau de vision depuis zéro demande beaucoup d’images annotées — ImageNet en fournit environ 1,2 million pour 1000 catégories — et un budget de calcul conséquent. Presque personne ne dispose de cela pour un problème métier particulier : classer des pièces défectueuses, trier des radiographies, reconnaître une espèce végétale.

Le transfert d’apprentissage résout ce problème en deux étapes. On reprend un réseau déjà entraîné sur un grand corpus, on gèle ses couches — elles ont appris des détecteurs de contours, de textures et de motifs qui restent pertinents pour d’autres images — et on ne réapprend que la dernière couche, celle qui décide.

L’économie se chiffre. Avec un tronc produisant un vecteur de 2048 caractéristiques et 10 classes cibles, la couche à apprendre compte 2048 × 10 + 10 = 20 490 paramètres, au lieu des dizaines de millions du réseau complet.

Reste à savoir si le principe se vérifie. Testons-le honnêtement : nous « pré-entraînons » une représentation sur les chiffres 5 à 9, puis nous apprenons à reconnaître les chiffres 0 à 4 avec seulement 5 exemples par classe.

import numpy as np
from sklearn.datasets import load_digits
from sklearn.decomposition import PCA
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import train_test_split

X, y = load_digits(return_X_y=True)

source = X[y >= 5]                       # « pré-entraînement » : classes 5 à 9
cible_X, cible_y = X[y < 5], y[y < 5]    # tâche cible : classes 0 à 4

Xp, Xt, yp, yt = train_test_split(cible_X, cible_y, test_size=0.4,
                                  random_state=0, stratify=cible_y)

indices = np.concatenate([np.where(yp == c)[0][:5] for c in range(5)])

brut = LogisticRegression(max_iter=5000).fit(Xp[indices], yp[indices])
print(round(brut.score(Xt, yt), 3))                       # 0.936

# La représentation est apprise sur les classes 5-9 uniquement,
# sans jamais voir une étiquette de la tâche cible.
pca = PCA(n_components=30, random_state=0).fit(source)
tete = LogisticRegression(max_iter=5000).fit(pca.transform(Xp[indices]), yp[indices])
print(round(tete.score(pca.transform(Xt), yt), 3))         # 0.931

Le résultat mérite d’être commenté franchement : la représentation transférée ne fait pas mieux que les pixels bruts (0,931 contre 0,936). L’expérience ne démontre pas le bénéfice du transfert, et il serait malhonnête de la présenter comme si elle le faisait.

Ce résultat négatif est instructif. Le transfert n’apporte rien ici pour trois raisons identifiables : une vignette de 8 × 8 ne contient que 64 valeurs, déjà exploitables directement ; l’analyse en composantes principales est une transformation linéaire, incapable de construire la hiérarchie de motifs qui fait la valeur d’un tronc convolutif ; et 30 composantes sur 64 dimensions ne compressent presque rien. Le transfert produit ses gains documentés quand le tronc est profond, non linéaire, et pré-entraîné sur des images beaucoup plus riches que ces vignettes. La mécanique montrée ci-dessus — geler la représentation, n’apprendre que la tête — est celle qu’on emploie en pratique ; l’échelle, elle, n’est pas celle qui la rend rentable.

À retenir

  • Une image est un tableau de nombres, et le problème est leur nombre : 150 528 valeurs pour une seule photographie de 224 × 224 en couleur.
  • La convolution promène un petit noyau de poids partagés sur toute l’image. C’est ce partage qui rend l’affaire possible : 1 792 paramètres au lieu de 150 millions pour une couche dense équivalente.
  • Sous-échantillonner et empiler les couches élargit progressivement le champ de vision et fait émerger une hiérarchie — contours, motifs, parties d’objets — que personne n’a spécifiée.
  • Classification, détection et segmentation diffèrent par la forme de leur sortie, de 20 nombres à plus d’un million. Le coût de l’annotation suit la même progression.
  • Le transfert d’apprentissage consiste à geler un tronc pré-entraîné et à ne réapprendre que la couche de décision, soit environ 20 000 paramètres au lieu de millions. Son bénéfice dépend de la richesse du tronc : sur des vignettes 8 × 8 avec une transformation linéaire, l’expérience ci-dessus n’en montre aucun.

Commentaires

Les commentaires sont alimentés par GitHub Discussions

Connectez-vous avec GitHub pour participer à la discussion

Lien copié !