9  Apprentissage profond pour la vision par ordinateur

Les chapitres précédents ont établi les fondements de la vision par ordinateur (VC) au moyen de méthodes classiques d’extraction et de représentation de caractéristiques. Au chapitre 7, des descripteurs tels que Local Binary Patterns (LBP) et Histogram of Oriented Gradients (HOG) ont montré comment les textures et les formes peuvent être codées par des descripteurs conçus manuellement. Au chapitre 8, des algorithmes comme Oriented FAST and Rotated BRIEF (ORB) et le détecteur Haar Cascade ont étendu ce principe aux tâches de mise en correspondance, de détection et de reconnaissance d’objets.

Ces techniques restent pertinentes en raison de leur interprétabilité et de leur efficacité computationnelle, mais elles dépendent d’une étape préalable de définition manuelle des descripteurs, appelée ingénierie de caractéristiques (feature engineering). Cette dépendance limite l’adaptation du modèle à des scénarios pour lesquels le descripteur n’a pas été conçu.

L’apprentissage profond (Deep Learning) propose une alternative : au lieu de spécifier manuellement les caractéristiques pertinentes, le modèle apprend automatiquement des représentations à partir des données pendant l’entraînement — processus connu sous le nom d’apprentissage de représentations (representation learning) (GOODFELLOW, 2016). En VC, cette stratégie est principalement mise en œuvre par les réseaux de neurones convolutionnels (Convolutional Neural Networks — CNN), dans lesquels les filtres de convolution ne possèdent plus de coefficients fixes et sont désormais ajustés par des algorithmes d’optimisation.

Bien qu’ils représentent un changement dans la construction des systèmes de reconnaissance de formes, les CNN préservent des concepts déjà étudiés dans cet ouvrage : la convolution, présentée au chapitre 3, demeure l’opération responsable de l’extraction locale de caractéristiques, désormais appliquée avec des coefficients appris plutôt que conçus.

Il convient de souligner que l’objectif de ce chapitre n’est pas d’explorer de manière exhaustive la théorie de l’apprentissage profond, mais plutôt d’offrir une vue d’ensemble de ses fondements et de démontrer comment ces architectures sont appliquées dans le contexte de la VC. Les lecteurs intéressés par un approfondissement théorique et conceptuel dans ce domaine devront se référer à des ouvrages spécialisés de la littérature, tels que Goodfellow (2016) et Lecun (2015)..

9.1 Objectifs du chapitre

À la fin de ce chapitre, l’étudiant devrait être capable de :

  • Relier la convolution apprise par les CNN à la convolution à kernels fixes présentée au chapitre 3 ;
  • Décrire l’architecture de base d’une CNN et la fonction de ses couches principales ;
  • Implémenter, entraîner et évaluer des modèles de CNN pour la classification d’images ;
  • Appliquer le transfert d’apprentissage (transfer learning) pour adapter des modèles pré-entraînés à de nouveaux problèmes ;
  • Utiliser des modèles pré-entraînés pour des tâches de classification, de détection d’objets et de segmentation ;
  • Implémenter, entraîner et évaluer une architecture U-Net pour la segmentation sémantique, en la comparant aux approches classiques ;
  • Préparer des ensembles de données annotés et les intégrer à un pipeline d’entraînement via des plateformes telles que Roboflow ;
  • Intégrer la géométrie computationnelle et l’apprentissage profond dans des applications de réalité augmentée et de photogrammétrie.

La Figure 9.1 synthétise l’organisation des concepts étudiés dans ce chapitre et les relations entre eux.

Figure 9.1: Vue d’ensemble des principaux concepts abordés dans ce chapitre. Source : élaboré avec l’aide du Gemini Notebook ({GOOGLE}, 2025).

9.2 Aperçu : Classification, Détection et Segmentation

Les tâches de VC se distinguent principalement par l’information produite en sortie. La classification attribue une étiquette unique à l’image entière ; la détection d’objets localise et identifie les objets présents dans la scène ; la segmentation associe une classe à chaque pixel et, dans certaines approches, distingue différentes instances d’une même catégorie.

La Table 9.1 résume les tâches étudiées tout au long du livre, en indiquant la question à laquelle chacune répond et la granularité de l’information produite.

Table 9.1: Comparatif entre les principales tâches de VC selon la granularité de l’information produite.
Tâche Question répondue Granularité de la sortie Chapitre
Classification « Quelle est la classe de cette image ? » Une étiquette unique pour l’image entière 7 et 9
Détection d’objets « Quels objets existent et où se trouvent-ils ? » Classe et boîte englobante (bounding box) pour chaque objet 8 et 9
Segmentation sémantique « À quelle classe appartient chaque pixel ? » Une étiquette de classe pour chaque pixel 8 et 9
Segmentation d’instances « Quels pixels appartiennent à chaque objet ? » Une étiquette par pixel pour chaque instance 8 et 9
Segmentation panoptique « Quelle est la classe et l’identité de chaque objet ? » Classe et identifiant d’instance pour chaque pixel 8 et 9

Ces tâches représentent des niveaux croissants d’interprétation de l’image : la classification décrit la scène de manière globale, la détection ajoute la localisation des objets et la segmentation produit une représentation spatiale détaillée, permettant d’analyser chaque région individuellement. Ce chapitre se concentre d’abord sur la classification par CNN, puis étend les mêmes principes à la détection et à la segmentation.

9.3 Configuration de l’environnement

Les exemples de ce chapitre utilisent PyTorch, un framework largement employé dans le développement et l’entraînement de modèles d’apprentissage profond. Le code suivant vérifie la disponibilité des bibliothèques nécessaires et installe automatiquement celles qui ne sont pas encore présentes dans l’environnement d’exécution.

Si PyTorch n’est pas installé, une version compatible avec le matériel disponible est automatiquement sélectionnée : la version avec prise en charge de CUDA, s’il y a un GPU NVIDIA disponible, ou la version pour exécution sur CPU, dans le cas contraire.

Ensuite, l’environnement est initialisé avec l’importation des bibliothèques utilisées tout au long du chapitre, la définition d’une graine aléatoire pour favoriser la reproductibilité des expériences et l’obtention du fichier morph.py — la bibliothèque didactique de traitement morphologique déjà utilisée dans les chapitres précédents —, s’il n’est pas encore disponible dans le répertoire de travail.

import contextlib, importlib, importlib.metadata, importlib.util
import io, os, random, shutil, subprocess, sys, urllib.request, warnings

# Supprime les avertissements PyTorch et les warnings généraux
warnings.filterwarnings("ignore", category=UserWarning)

url = ("https://raw.githubusercontent.com/fzampirolli/"
       "pdi-vc/master/morph/config.py")
if not os.path.exists("config.py"):
    urllib.request.urlretrieve(url, "config.py")

import config

# Silencie stdout et stderr tant au niveau Python qu'au niveau des descripteurs de fichiers du système d'exploitation
def setup_silencioso():
    with open(os.devnull, "w") as fnull:
        old_out = os.dup(1)
        old_err = os.dup(2)
        try:
            os.dup2(fnull.fileno(), 1)
            os.dup2(fnull.fileno(), 2)
            with contextlib.redirect_stdout(fnull), contextlib.redirect_stderr(fnull):
                config.setup()
        finally:
            os.dup2(old_out, 1)
            os.dup2(old_err, 2)
            os.close(old_out)
            os.close(old_err)

setup_silencioso()
from morph import mm


def setup_cap09():
    """Installe les bibliothèques manquantes pour ce chapitre de manière 100% silencieuse."""
    pkgs = {
        "skimage": "scikit-image", "numpy": "numpy",
        "sklearn": "scikit-learn", "matplotlib": "matplotlib",
        "torchviz": "torchviz", "ultralytics": "ultralytics",
        "roboflow": "roboflow",
    }
    for mod, pkg in pkgs.items():
        if importlib.util.find_spec(mod) is None:
            subprocess.run([sys.executable, "-m", "pip", "install", "-q", pkg],
                           stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    if importlib.util.find_spec("torch") is None:
        args = (["torch", "torchvision"] if shutil.which("nvidia-smi")
                 else ["--index-url",
                       "https://download.pytorch.org/whl/cpu",
                       "torch", "torchvision"])
        subprocess.run([sys.executable, "-m", "pip", "install", "-q", *args],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)

    if not shutil.which("dot") and shutil.which("apt-get"):
        subprocess.run(["apt-get", "install", "-y", "-qq", "graphviz"],
                       stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)


setup_cap09()

import cv2, numpy as np, torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
import torchvision.models as models
import torchvision.transforms as T
from PIL import Image
from skimage import data as skdata
from sklearn.datasets import load_digits
from sklearn.model_selection import train_test_split
from torch.utils.data import DataLoader, TensorDataset
from torchvision.datasets import OxfordIIITPet
from torchvision.models.detection import (
    fasterrcnn_resnet50_fpn, FasterRCNN_ResNet50_FPN_Weights)
from torchvision.models.segmentation import deeplabv3_resnet50
from torchvision.transforms.functional import to_tensor
from torchviz import make_dot
from ultralytics import YOLO

torch.manual_seed(42)
FLAG_LIMPAR_DADOS = False
device = "cuda" if torch.cuda.is_available() else "cpu"
gpu = f" ({torch.cuda.get_device_name(0)})" if device == "cuda" else ""
ver = importlib.metadata.version("ultralytics")
print(f"✅ Environnement prêt. OpenCV {cv2.__version__} | "
      f"morph {getattr(mm, '__version__', 'local_file')} | "
      f"PyTorch {torch.__version__} | Ultralytics {ver} | {device}{gpu}")
✅ Environnement prêt. OpenCV 5.0.0 | morph local_file | PyTorch 2.6.0+cu124 | Ultralytics 8.4.113 | cuda (NVIDIA GeForce GTX TITAN X)

9.4 Fondements de l’Apprentissage Profond pour la VC

Les CNN constituent l’architecture principale d’apprentissage profond appliquée à l’analyse d’images. Leur fonctionnement repose sur la composition d’opérations convolutives organisées en couches successives, dans lesquelles les filtres appris lors de l’entraînement transforment l’image en représentations progressivement plus abstraites. Dans cette section, sont présentés les concepts fondamentaux qui relient la convolution spatiale étudiée précédemment aux modèles modernes de VC, notamment l’extraction hiérarchique de caractéristiques, le processus d’entraînement et l’utilisation de modèles pré-entraînés.

9.4.1 Da Convolução Fixa à Convolução Aprendida

O Capítulo 3 apresentou a convolução espacial com kernels fixos, como os operadores de Sobel, projetados para realçar características específicas de uma imagem. Nos Capítulos 7 e 8, o mesmo princípio sustentou descritores como HOG, LBP e ORB, além do detector Haar Cascade: em todos esses casos, os filtros são definidos antes da execução do algoritmo e permanecem inalterados durante o processamento.

A Figure 9.2 retoma o funcionamento da convolução espacial: o simulador permite selecionar diferentes kernels e acompanhar o deslocamento da janela de convolução sobre uma imagem. Em cada posição, os coeficientes do kernel combinam-se com a vizinhança local da imagem — denominada campo receptivo (receptive field) — para produzir um valor do mapa de características (feature map), ilustrando também o compartilhamento de pesos (weight sharing)

As CNNs preservam essa operação, mas substituem kernels fixos por filtros aprendidos: em vez de coeficientes definidos previamente, a rede ajusta esses valores durante o treinamento a partir de exemplos rotulados, buscando minimizar uma função de perda (loss function), que mede a diferença entre as previsões do modelo e as respostas esperadas.

A diferença essencial entre os métodos clássicos e as CNNs, portanto, não está na operação de convolução em si, mas na forma como os filtros são obtidos: enquanto os primeiros utilizam filtros projetados manualmente, as CNNs aprendem, a partir dos dados de treinamento, representações adequadas à tarefa.


9.4.2 De la convolution fixe à la convolution apprise

Le Chapitre 3 a présenté la convolution spatiale avec des kernels fixes, comme les opérateurs de Sobel, conçus pour mettre en évidence des caractéristiques spécifiques d’une image. Dans les Chapitres 7 et 8, le même principe a soutenu des descripteurs tels que HOG, LBP et ORB, ainsi que le détecteur Haar Cascade : dans tous ces cas, les filtres sont définis avant l’exécution de l’algorithme et restent inchangés pendant le traitement.

La Figure 9.2 reprend le fonctionnement de la convolution spatiale : le simulateur permet de sélectionner différents kernels et de suivre le déplacement de la fenêtre de convolution sur une image. À chaque position, les coefficients du kernel se combinent avec le voisinage local de l’image — appelé champ réceptif (receptive field) — pour produire une valeur de la carte de caractéristiques (feature map), illustrant également le partage des poids (weight sharing).

Les CNN préservent cette opération, mais remplacent les kernels fixes par des filtres appris : au lieu de coefficients définis à l’avance, le réseau ajuste ces valeurs pendant l’entraînement à partir d’exemples étiquetés, cherchant à minimiser une fonction de perte (loss function), qui mesure la différence entre les prédictions du modèle et les réponses attendues.

La différence essentielle entre les méthodes classiques et les CNN, par conséquent, ne réside pas dans l’opération de convolution elle-même, mais dans la manière dont les filtres sont obtenus : tandis que les premières utilisent des filtres conçus manuellement, les CNN apprennent, à partir des données d’entraînement, des représentations adaptées à la tâche.

Note: I have translated the content into French as requested, while preserving all LaTeX markers, placeholder tokens (Figure 9.2), and Markdown structure. However, I noticed the original text was in Portuguese, not English — I have translated it accordingly from Portuguese to French. If you intended a different source language, please let me know.

🎯 Simulateur : Opération de Convolution 2D Classique Entrée 12×12 · Noyau 3×3 · Pas 1
IMAGE D'ENTRÉE (12×12)
FILTRE (NOYAU 3×3)
Position Actuelle : (0, 0) [Sortie 10×10]
Entrée (12×12)
Noyau (3×3)
Carte de Sortie (10×10)
∑ (xᵢ × wᵢ) = calcul de la position actuelle...
Figure 9.2: Simulateur interactif de convolution 2D classique : choisissez parmi les trois images synthétiques d’entrée 12×12 (maison, visage heureux ou triste) et un filtre 3×3 (Sobel V, Sobel H, Netteté ou Identité) et avancez pas à pas pour observer comment les produits internes locaux du champ réceptif construisent la carte de caractéristiques cellule par cellule.

Pour comprendre comment cet apprentissage se produit, il est nécessaire d’étudier l’unité de base de traitement des réseaux de neurones : le neurone artificiel.

9.4.3 Neurone Artificiel

Le neurone artificiel (artificial neuron) est l’unité fondamentale de traitement d’un réseau de neurones. Son premier modèle mathématique — un ensemble d’entrées combinées et comparées à un seuil — a été proposé par Mcculloch (1943), sans encore aucun mécanisme d’apprentissage. Le Perceptron (ROSENBLATT, 1958) a fait progresser cette formulation en introduisant une règle d’ajustement des poids à partir d’exemples, devenant ainsi le premier modèle de neurone artificiel capable d’apprendre et la base des architectures modernes d’Apprentissage Profond (Deep Learning). Le terme Apprentissage Profond désigne l’utilisation de réseaux à multiples couches de traitement, capables d’apprendre des représentations hiérarchiques des données : les premières couches apprennent des caractéristiques simples, comme les contours et les textures, et les couches plus profondes combinent progressivement ces représentations pour identifier des structures et des objets plus complexes.

Chaque neurone reçoit un ensemble d’entrées, calcule une combinaison linéaire de ces valeurs et applique une fonction d’activation (activation function), produisant une unique valeur de sortie. Mathématiquement, la combinaison linéaire est donnée par

\[ z=\sum_{i=1}^{n}w_i x_i+b, \]

où \(x_i\) représentent les entrées, \(w_i\) les poids associés à chaque entrée et \(b\) le biais (bias). La sortie du neurone est obtenue en appliquant la fonction d’activation :

\[ y=f(z). \]

Dans les CNN, ce principe prend des formes distinctes selon la couche. Dans les couches convolutionnelles (convolutional layers), chaque neurone ne traite qu’une petite région de l’entrée, appelée champ réceptif (receptive field), préservant ainsi l’organisation spatiale de l’image. Dans les couches entièrement connectées (fully connected layers), chaque neurone reçoit toutes les sorties de la couche précédente, combinant les caractéristiques extraites pour produire la sortie finale du réseau, comme la classe attribuée à l’image.

La Figure 9.3 illustre le fonctionnement d’un neurone artificiel : le simulateur permet de modifier les entrées (\(x_1\) et \(x_2\)), les poids (\(w_1\) et \(w_2\)), le biais (\(b\)) et la fonction d’activation, en observant en temps réel le calcul de la combinaison linéaire et de la sortie correspondante.

⚙️ Simulateur : Neurone artificiel dans un CNN y = f(∑ wᵢxᵢ + b)
x₁ w₁
x₂ w₂
b
Les valeurs de x varient de -3 à 3 car, dans un CNN, les pixels (0–255) sont normalisés avant d'entrer dans le réseau. Le schéma à côté retraduit cette valeur normalisée en nuance de gris, juste pour donner une intuition visuelle — les nombres qui comptent pour le calcul sont ceux des barres.
Champ réceptif → Convolution → Carte de caractéristiques
Activation en f(z)
x₁, x₂ = intensité des pixels dans le champ réceptif · w₁, w₂ = poids du noyau (filtre) · z = résultat de la convolution à cette position · y = valeur du pixel produit dans la carte de caractéristiques, après l'activation.
z = (1.00 × 0.80) + (-1.50 × 0.50) + 0.20 = 0.25 → y = 0.25
Figure 9.3: Simulateur interactif du neurone artificiel dans un contexte de CNN : alternez entre un neurone de couche convolutive (où x_i sont des intensités de pixel dans un champ réceptif et w_i sont des poids du kernel) et un neurone de couche entièrement connectée, en ajustant les entrées, les poids, le biais et la fonction d’activation pour visualiser le calcul de z et de la sortie y en temps réel.

Dans un CNN, des milliers de neurones s’organisent en couches ayant des fonctions spécifiques : les premières sont responsables de l’extraction de caractéristiques par le biais de la convolution, et les dernières réalisent la classification à partir des caractéristiques apprises.

9.4.4 Couche convolutive

La couche convolutive (convolutional layer) est responsable de l’extraction des caractéristiques de l’image. Chaque filtre génère une carte de caractéristiques (feature map), dont l’intensité à chaque position indique la réponse du filtre à la région correspondante de l’entrée.

L’opération effectuée suit le même principe de déplacement et de combinaison locale présenté au Chapitre 3 pour la convolution spatiale. En considérant un noyau \(K\) de dimension \(k \times k\), la valeur produite à la position \((i,j)\) est donnée par

\[ F(i,j)=\sum_{u=0}^{k-1}\sum_{v=0}^{k-1}K(u,v)\,I(i+u,j+v). \]

Il convient de noter une distinction terminologique : l’expression ci-dessus correspond, formellement, à une corrélation croisée (cross-correlation), et non à la convolution mathématique stricte, qui exige la réflexion du noyau avant la combinaison. La plupart des frameworks d’apprentissage profond, y compris PyTorch, implémentent cette opération sans réflexion et la désignent, par convention, comme convolution — convention également adoptée dans ce chapitre. Cette différence n’a aucun effet pratique sur l’entraînement, puisque les coefficients du noyau sont appris et non imposés au préalable.

La principale différence par rapport aux méthodes classiques réside donc dans l’obtention du noyau \(K\) : dans les filtres traditionnels, ses coefficients sont définis manuellement pour mettre en évidence des caractéristiques spécifiques de l’image ; dans les CNN, les coefficients sont initialisés automatiquement et ajustés pendant l’entraînement par le biais de la rétropropagation de l’erreur (backpropagation), ce qui rend chaque filtre spécialisé dans l’identification de motifs pertinents pour la tâche étudiée.

Deux concepts caractérisent cette couche :

  • Partage des poids (weight sharing) : le même filtre est appliqué à toutes les positions de l’image, réduisant considérablement le nombre de paramètres du modèle.
  • Champ réceptif (receptive field) : chaque neurone convolutif ne traite qu’un petit voisinage de l’image, préservant la structure spatiale des données.

En empilant plusieurs couches convolutives, le réseau apprend une hiérarchie de caractéristiques : les premières couches tendent à détecter des motifs simples, comme les bords et les textures, et les couches plus profondes combinent ces informations pour représenter des structures progressivement plus complexes. Après la convolution, la carte de caractéristiques est soumise à une fonction d’activation, introduisant une non-linéarité dans le modèle et élargissant sa capacité à représenter des relations complexes entre les variables d’entrée.

La Figure 9.4 présente cette couche de manière interactive.

⚙️ Simulateur : Opération de Convolution & Carte de caractéristiques F(i,j) = f(∑ K(u,v) · I(i+u, j+v))
IMAGE D'ENTRÉE
KERNEL (FILTRE FIXE)
Zero-Padding (p = 1)
pixel réel marge fixe de l'image (0) padding de l'algorithme (0)
Le même kernel glisse sur toute l'image en réutilisant ses coefficients (partage des poids). Chaque image est déjà entourée d'une marge fixe de 1 pixel de fond (zéros, contour pointillé ambre), isolant la forme sur les quatre côtés. Choisissez une image et un kernel fixe ci-dessus, puis utilisez ◀ ▶ ou "Auto" pour parcourir le champ réceptif — la Feature Map à droite est remplie cellule par cellule, dans le même ordre où la convolution est calculée (les cellules non encore visitées apparaissent comme "···").
📌 La sortie F(i,j) provient du champ réceptif entre (i,j) et (i+2,j+2) ; son centre réel est (i+1,j+1) — 1 ligne et 1 colonne en dessous/à droite de l'indice utilisé pour étiqueter la cellule, toujours dans les deux directions. Ce décalage n'est visible que sur l'axe où le kernel différencie l'image (c'est pourquoi le Sobel V semble décalé seulement sur le côté, et le Sobel H, seulement vers le bas).
Activation à f(z)
🔍 Calcul détaillé dans le champ réceptif actuel
z = 0.00 → y = ReLU(z) = 0.00
Figure 9.4: Simulateur interactif de la couche convolutive : choisissez entre trois images d’entrée 12×12 (maison, visage souriant ou visage triste) pour observer comment les mêmes noyaux fixes réagissent à différentes contours et formes. Naviguez dans le champ réceptif avec les boutons ou la lecture automatique, ajustez la fonction d’activation et alternez le zero-padding, en suivant la carte de caractéristiques révélée cellule par cellule, avec le calcul détaillé terme à terme et la formule de la dimension de sortie en temps réel.

9.4.5 Fonction d’activation

La convolution est une opération linéaire. Pour que le réseau puisse modéliser des relations non linéaires entre les entrées et les sorties, on applique une fonction d’activation (activation function) après chaque couche convolutive.

La fonction la plus utilisée dans les CNN est la ReLU (Rectified Linear Unit), définie par

\[ \mathrm{ReLU}(x)=\max(0,x). \]

Cette fonction préserve les valeurs positives et remplace les valeurs négatives par zéro, introduisant ainsi une non-linéarité dans le modèle et favorisant l’entraînement de réseaux profonds avec un faible coût computationnel.

La Figure 9.5 illustre le fonctionnement de la ReLU appliquée à la fois à des valeurs individuelles et à une carte de caractéristiques, permettant de comparer la sortie avant et après l’activation.

⚡ Simulateur : Fonction d'activation ReLU ReLU(x) = max(0, x)
x = x = -2,50 → ReLU(x) = 0,00
Courbe de la fonction ReLU
Carte de caractéristiques : avant / après
Figure 9.5: Simulateur interactif de la fonction d’activation ReLU : faites glisser le curseur pour voir comment les valeurs négatives sont mises à zéro et les valeurs positives sont préservées, à la fois sur la courbe et sur une carte de caractéristiques réelle.

Les cartes de caractéristiques résultant de la convolution et de l’activation préservent la structure spatiale de l’image. Dans de nombreuses architectures, l’étape suivante réduit leur résolution au moyen d’une opération de pooling.

9.4.6 Pooling

La couche de pooling réduit la résolution spatiale des cartes de caractéristiques, tout en préservant les informations les plus pertinentes pour les étapes suivantes du traitement. L’opération la plus utilisée est le max-pooling, qui sélectionne la valeur maximale dans chaque fenêtre de l’image :

\[ P(i,j)=\max_{(u,v)\in\text{janela}(i,j)}F(u,v). \]

Cette réduction diminue le coût computationnel des couches suivantes et rend la représentation plus robuste aux petites variations de position des motifs présents dans l’image.

La Figure 9.6 présente cette opération sur une carte de caractéristiques de 8×8 pixels, réduite à 4×4 par des fenêtres de 2×2 avec un pas égal à 2, en alternant entre max-pooling et average-pooling — qui calcule, au lieu du maximum, la moyenne des valeurs de la fenêtre correspondante.

🔻 Simulateur : Pooling fenêtre 2×2, pas 2
Type :
Fenêtre actuelle : (0, 0) sur 4×4
O max-pooling conserve uniquement la valeur la plus élevée de chaque fenêtre 2×2, réduisant la résolution spatiale de moitié et préservant les réponses les plus fortes de la carte de caractéristiques.
Carte d'entrée (8×8) — fenêtre actuelle mise en évidence
Carte réduite (4×4)
Figure 9.6: Simulateur interactif de pooling : choisissez entre max-pooling et average-pooling et avancez pas à pas pour observer la réduction de la résolution spatiale de la carte de caractéristiques.

Ensemble, convolution, fonction d’activation et pooling forment le bloc de base utilisé dans la construction d’une CNN.

9.4.7 Entraînement des réseaux de neurones : comment les CNN apprennent

Une CNN apprend en ajustant automatiquement ses paramètres — les coefficients des filtres convolutifs, les poids des couches entièrement connectées et les biais (biases) — à partir d’exemples étiquetés. Cet entraînement est itératif et comporte trois étapes : mesurer l’erreur produite par le réseau au moyen d’une fonction de perte (loss function), calculer comment cette erreur dépend de chaque paramètre grâce à la rétropropagation (backpropagation) et mettre à jour les paramètres avec un algorithme d’optimisation (optimizer).

9.4.7.1 Fonction de perte (Loss Function)

La fonction de perte (loss function) quantifie la différence entre la prédiction du réseau et la réponse correcte, appelée vérité de référence (ground truth). Le résultat est un scalaire \(L\) : plus la perte est faible, plus la prédiction est proche de la réponse attendue.

Dans les problèmes de classification multiclasse, la fonction la plus utilisée est l’Entropie Croisée (Cross-Entropy Loss), appliquée aux probabilités produites par la couche Softmax :

\[ L=-\sum_{c=1}^{C} y_c \log(\hat{y}_c), \]

où \(C\) est le nombre de classes, \(y_c\) est le label réel en codage one-hot et \(\hat{y}_c\) est la probabilité prédite pour la classe \(c\). La perte se rapproche de zéro lorsque le réseau attribue une forte probabilité à la classe correcte et augmente rapidement à mesure que cette probabilité diminue.

La Figure 9.7 illustre ce comportement : le simulateur permet de sélectionner la classe correcte et de modifier les probabilités produites par la Softmax, affichant en temps réel la variation de la fonction de perte.

📉 Simulateur : Fonction de Perte (Entropie Croisée) L = -log(ŷ_cible)
CLASSE RÉELLE DE L'IMAGE (VÉRITÉ TERRAIN : y_c = 1)
PROBABILITÉS ESTIMÉES PAR SOFTMAX (ŷ_c)
🏠 Maison (ŷ_1) : 0.70
😊 Heureux (ŷ_2) : 0.20
😢 Triste (ŷ_3) : 0.10
Courbe de pénalisation L = -log(ŷ_cible)
CALCUL DE LA PERTE :
Perte L = 0.3567
Figure 9.7: Simulateur interactif de la Fonction de Perte (Cross-Entropy) : sélectionnez la classe réelle de l’image (Maison, Heureux ou Triste) et ajustez les probabilités estimées par Softmax pour visualiser le calcul de la pénalisation scalaire et le graphique du logarithme négatif en temps réel.

9.4.7.2 Rétropropagation (Backpropagation)

Après le calcul de la perte, il est nécessaire de déterminer comment chaque paramètre du réseau contribue à ce résultat. Cette étape est réalisée par la rétropropagation (backpropagation), qui applique la Règle de la Chaîne du calcul différentiel pour obtenir le gradient de la fonction de perte par rapport à chaque paramètre.

Pour un paramètre \(w\), ce gradient est donné par

\[ \frac{\partial L}{\partial w}. \]

Le gradient indique comment la perte varie en fonction de petites modifications de \(w\) : un gradient positif indique qu’augmenter \(w\) augmente la perte, et un gradient négatif indique l’effet opposé.

La Figure 9.8 présente ce processus de manière visuelle, montrant la propagation du gradient de la couche de sortie jusqu’aux premières couches convolutives.

⬅️ Simulateur : Rétropropagation (Backpropagation) ∂L/∂w = (∂L/∂y) · (∂y/∂z) · (∂z/∂w)
Étape 1 sur 4 : Sortie (Perte & Softmax)
DIRECTION DE LA PROPAGATION DE L'ERREUR (FLUX INVERSÉ ⟵)
Noyaux Conv1
∂L/∂K
⟵
Max-Pooling
∂L/∂X_pool
⟵
Couches FC
∂L/∂W_fc
⟵
Perte / Softmax
∂L/∂y_pred
🔗 Règle de la Chaîne dans la Couche Actuelle :
Figure 9.8: Simulateur interactif de Rétropropagation : avancez dans les étapes de la Règle de la Chaîne pour suivre le flux du signal d’erreur dans le sens inverse du réseau, en observant le calcul des dérivées partielles du gradient à chaque couche.

9.4.7.3 Algorithmes d’optimisation

Après le calcul des gradients, un algorithme d’optimisation (optimizer) met à jour les paramètres du réseau afin de réduire la fonction de perte. Dans les réseaux profonds, cette recherche s’effectue dans un espace de haute dimension et, en général, non convexe, ce qui rend l’optimisation un problème difficile.

Pour faciliter la compréhension, la Figure 9.9 utilise une surface de perte simplifiée, avec un minimum global, un minimum local et une barrière entre ces régions. Le minimum global correspond à la plus petite valeur de la fonction de perte et représente le meilleur ensemble de paramètres du réseau ; un minimum local présente également une perte faible, mais peut être éloigné de la meilleure solution. Lorsque l’optimisation reste bloquée dans un minimum local, les ajustements des filtres, des poids et des biais deviennent très faibles, et l’entraînement s’arrête avant d’atteindre un modèle avec une erreur moindre.

9.4.7.3.1 Descente de Gradient Stochastique (SGD)

La Descente de Gradient Stochastique (Stochastic Gradient Descent — SGD) met à jour les paramètres dans la direction opposée au gradient :

\[ w_{\text{novo}} = w_{\text{atual}} - \eta \frac{\partial L}{\partial w}, \]

où \(\eta\) est le taux d’apprentissage (learning rate), responsable du contrôle de la taille de la mise à jour. Le SGD utilise uniquement le gradient de l’itération actuelle ; lorsque la recherche atteint un minimum local, les gradients deviennent très faibles et les mises à jour cessent pratiquement.

9.4.7.3.2 Optimiseurs Adaptatifs : Adam

L’Adam (Adaptive Moment Estimation) combine des estimations adaptatives des premiers et deuxièmes moments des gradients (KINGMA, 2015), en adaptant le taux d’apprentissage de chaque paramètre individuellement. Cette adaptation favorise, dans de nombreux cas, le dépassement des minima locaux qui retiendraient le SGD.

La Figure 9.9 compare la trajectoire du SGD et de l’Adam sur la même surface de perte non convexe.

⚡ Simulateur : Optimisation avec Courbes de Niveau (SGD vs. Adam) Relief Non Convexe : Minimum Local vs. Global
💡 Comment lire cette carte : a flèche jaune pointe dans la direction de descente (−∇L), qui est le sens opposé au vecteur gradient (∇L). L'optimiseur avance dans cette direction pour réduire la perte L(w1, w2) jusqu'à atteindre les régions les plus profondes (teintes plus sombres).
ALGORITHME PRINCIPAL (ligne pleine)
TAUX D'APPRENTISSAGE (η)
Carte de Chaleur de la Perte L(w₁, w₂) — cliquez pour choisir le départ
Minimum Global Minimum Local Gradient (↓ descente) Trajectoire principale Fantôme (autre optimiseur)
ÉTAT DE L'OPTIMISATION :
w₁ = 1.80, w₂ = 0.20
Perte L = 2.450
PAS
0
|∇L| (MAGNITUDE)
0.000
Statut : Point Initial
Figure 9.9: Simulador interactif des algorithmes d’optimisation : comparez la trajectoire du SGD et de l’Adam sur une surface de perte non convexe avec carte de chaleur et courbes de niveau. La ligne continue montre l’optimiseur sélectionné avançant pas à pas ; la ligne pointillée montre, pour une comparaison instantanée, le chemin complet que l’autre optimiseur parcourrait depuis le même point initial. Observez comment le SGD reste bloqué au Minimum Local à droite, tandis que l’Adam peut ou non franchir la barrière centrale selon l’impulsion accumulée et le taux d’apprentissage. Cliquez sur n’importe quel point de la carte pour réinitialiser le point initial des poids.

9.4.8 Architecture d’une CNN

Une CNN pour la classification d’images combine les couches présentées dans les sections précédentes. Lors du passage avant (forward pass), l’image traverse successivement les couches convolutives, les fonctions d’activation, les opérations de pooling, l’étape de Flatten, les couches entièrement connectées et, enfin, la couche Softmax, qui produit les probabilités des classes. Pendant l’entraînement, cette prédiction est comparée à l’étiquette correcte pour calculer la fonction de perte, effectuer la rétropropagation et mettre à jour les paramètres via un algorithme d’optimisation.

La Figure 9.10 présente ce flux de traitement et d’entraînement.

Figure 9.10: Architecture simplifiée d’une CNN pour la classification d’images, mettant en évidence le forward pass et les étapes d’entraînement via la fonction de perte, la rétropropagation et l’algorithme d’optimisation.

Après le dernier bloc convolutif, l’opération Flatten réorganise les cartes de caractéristiques en un vecteur unidimensionnel, qui alimente les couches entièrement connectées (fully connected layers), responsables de la combinaison des caractéristiques extraites pour produire les scores (logits) de chaque classe. La couche Softmax convertit ces scores en une distribution de probabilités, utilisée à la fois pour la classification et pour le calcul de la fonction de perte pendant l’entraînement.

La Figure 9.11 présente une version interactive de cette architecture, permettant d’exécuter des étapes successives d’entraînement et d’observer la réduction de la perte, la rétropropagation des gradients et la mise à jour des filtres du réseau.

⚙️ Simulateur : Architecture complète d'un CNN Entrée (12×12) → Conv → Pool → FC → Softmax
IMAGE D'ENTRÉE DU PIPELINE (12×12)
🎯 Entraînement (Propagation avant + Rétropropagation)
Entrée ▸ Conv+ReLU ▸ Pool ▸ Flatten ▸ FC ▸ Softmax ▸ Prédiction
Perte ◂ Optimiseur ◂ Rétropropagation ◂ (à chaque étape)
PERTE (LOSS)
PRÉCISION
Perte : —
Précision : —
Étape d'entraînement : 0
Les noyaux et poids commencent aléatoires (ce ne sont plus les filtres fixes du simulateur précédent). À chaque étape, le réseau effectue la propagation avant sur les 3 images, calcule la perte (cross-entropie) e a précision (combien des 3 images sont correctement classées), rétropropage l'erreur et ajuste tous les poids (y compris les noyaux de la convolution) via la descente de gradient. ⚠️ Comme le « jeu d'entraînement » ne compte que 3 exemples, cela démontre le mécanisme de l'entraînement (perte en baisse, précision en hausse, poids changeant) — pas la capacité à généraliser à de nouvelles images, ce qui exigerait beaucoup plus de données.
Figure 9.11: Simulateur interactif de l’architecture d’un CNN : choisissez l’une des images d’entrée 12×12 (maison, visage heureux ou triste), cliquez sur chaque bloc du pipeline — Entrée, Conv+ReLU, Pooling, Flatten, FC et Softmax — et exécutez de vraies étapes d’entraînement (forward pass + rétropropagation) pour observer la perte et la précision évoluer, les kernels être ajustés et le Softmax commencer à pointer vers la classe correcte.

9.4.9 Comment le Gradient Ajuste les Kernels de la Convolution

La compréhension du processus d’apprentissage dans un réseau neuronal convolutif (CNN) nécessite l’élucidation d’un mécanisme fondamental : comment les coefficients aléatoires d’un filtre initial se transforment-ils en détecteurs précis de bords, de textures et de motifs complexes ?

La réponse réside dans le principe du partage de poids (weight sharing). Pendant l’étape de propagation avant (forward pass), le même filtre de dimension \(3\times3\) glisse sur toute l’étendue de l’image d’entrée. Par conséquent, chaque poids du kernel — comme l’élément \(K[0][0]\) dans le coin supérieur gauche — est réutilisé de multiples fois sur les différentes régions spatiales de la donnée d’entrée.

Pendant l’étape de rétropropagation (backpropagation), cette réutilisation établit une dynamique directe : chaque position spatiale traitée par le filtre génère une contribution individuelle (« vote ») pour la mise à jour du poids correspondant.

9.4.9.1 L’Intuition Derrière le Calcul

Soit \(Z[r][c]\) la carte de caractéristiques pré-activation à la position \((r,c)\) de la fenêtre glissante, obtenue par corrélation croisée entre le kernel \(K\) et l’entrée \(X\) :

\[ Z[r][c] = \sum_{k_r} \sum_{k_c} K[k_r][k_c] \cdot X[r + k_r][c + k_c] \]

En appliquant la règle de la chaîne pour déterminer la contribution d’un poids spécifique \(K[k_r][k_c]\) à la fonction de perte \(L\), on obtient les étapes suivantes :

  1. Erreur Locale (\(dZ\)) : À chaque position \((r,c)\), on calcule la dérivée partielle de la fonction de perte par rapport à la pré-activation : \[dZ[r][c] = \frac{\partial L}{\partial Z[r][c]}\] qui quantifie la responsabilité de cette position spécifique dans l’erreur totale du réseau (\(L\)).

  2. Contribution du Poids : Comme \(\frac{\partial Z[r][c]}{\partial K[k_r][k_c]} = X[r + k_r][c + k_c]\), l’influence d’un poids spécifique \(K[k_r][k_c]\) sur l’erreur de la position \((r,c)\) est obtenue en multipliant l’erreur locale \(dZ[r][c]\) par la valeur du pixel d’entrée aligné sur ce poids au moment du calcul : \[dZ[r][c] \cdot X[r + k_r][c + k_c]\]

  3. Accumulation des Gradients : Le gradient final du poids correspond à la somme des contributions (« votes ») de toutes les positions parcourues par la fenêtre glissante :

\[ \frac{\partial L}{\partial K[k_r][k_c]} = \sum_{(r,c)} dZ[r][c] \cdot X[r + k_r][c + k_c] \]

Cette formulation assure une parité directe entre la dérivation analytique et les valeurs calculées dans le simulateur d’inspection du gradient (Figure 9.12).

9.4.9.2 Le Rôle de la Fonction ReLU comme « Filtre de Pertinence »

L’application de la fonction d’activation ReLU (\(\max(0, z)\)) immédiatement après la convolution introduit une propriété de sélectivité au gradient :

  • Activation Positive (\(Z[r][c] > 0\)) : La dérivée de la ReLU est \(1\). L’erreur locale est propagée intégralement (\(dZ \neq 0\)), permettant à la position de contribuer à la mise à jour des poids du kernel.
  • Activation Inactive (\(Z[r][c] \le 0\)) : La dérivée de la ReLU est \(0\). L’erreur locale est annulée (\(dZ = 0\)), supprimant la contribution de la position au gradient final.

Note didactique : La ReLU garantit que seules les régions spatiales ayant produit des réponses actives pendant la propagation avant possèdent la capacité de modifier les poids du kernel dans le processus de rétropropagation.

9.4.9.3 Mise à Jour des Poids via la Descente de Gradient

Après la consolidation des gradients accumulés de toutes les positions, la mise à jour du poids se fait selon l’algorithme de la Descente de Gradient Stochastique (SGD) :

\[ K[k_r][k_c] \leftarrow K[k_r][k_c] - \eta \cdot \frac{\partial L}{\partial K[k_r][k_c]} \]

où \(\eta\) désigne le taux d’apprentissage (learning rate).

  • Si la somme des gradients est positive, la valeur du poids est réduite.
  • Si la somme est négative, la valeur du poids est augmentée.

9.4.9.4 Exploration du Simulateur Interactif

Note🔗 De l’Architecture Globale à l’Inspection du Gradient

Dans le simulateur d’architecture (Figure 9.11), on observe l’erreur \(dZ\) dérivée de la rétropropagation multicouche complète, issue de la perte d’entropie croisée (Softmax) sur les images d’entrée \(12\times12\).

Pour permettre la vérification analytique du gradient sans la surcharge de \(100\) positions de convolution et de rétropropagation multicouche, le simulateur d’apprentissage du kernel (Figure 9.12) adopte un modèle d’inspection réduit (\(6\times6\)). Dans ce scénario, on simplifie le problème en remplaçant la classification complexe par une métadonnée de calibration scalaire : on ajuste le filtre pour produire une réponse accumulée prédéfinie (\(\text{cible} = 9\)) lors de l’identification d’un motif spécifique (comme un bord à 45 degrés). Le mécanisme d’accumulation des gradients (\(dZ \cdot X\)) reste rigoureusement identique dans les deux formulations.

Pour inspecter cette dynamique au niveau numérique, on utilise le simulateur sur Figure 9.12:

  • Image d’Entrée (\(X\)) : Matrice \(6\times6\).
  • Filtre Convolutionnel (\(K\)) : Matrice \(3\times3\) (9 poids).
  • Carte de Sortie (\(Z\) / \(A\)) : Matrice \(4\times4\) (16 positions de la fenêtre).
  • Fonction de Perte (\(L\)) : Définie par \(L = \frac{1}{2}(S - \text{cible})^2\), où \(S = \sum A[r][c]\) représente la somme globale des activations post-ReLU.

Le rôle de la \(\text{cible} = 9\) : La valeur scalaire \(\text{cible} = 9\) représente l’« énergie d’activation » idéale stipulée pour l’image avec bord diagonal. Comme la carte \(A\) possède 16 positions, cette valeur équivaut à rechercher une réponse moyenne de \(\frac{9}{16} \approx 0,56\) par pixel activé. Lorsque \(S > 9\), le réseau identifie que le filtre réagit avec une intensité excessive au motif, générant une erreur \(dZ > 0\) qui force la réduction des poids \(K\). Lorsque \(S < 9\), les poids sont augmentés pour amplifier le signal.

9.4.9.4.1 Roteiro Sugerido de Experimentação:
  1. Sélection du Poids : Dans la grille \(3\times3\), choisissez le poids à analyser (ex. : \(K[0][0]\)).
  2. Balayage de la Fenêtre : Utilisez le bouton « ▶ Avancer position » pour suivre le déplacement de la fenêtre à travers les 16 positions spatiales. Notez la mise en évidence visuelle dans la cellule de la carte d’entrée qui aligne le pixel \(X\) avec le poids sélectionné.
  3. Analyse du Vote Local : Examinez le produit de l’erreur locale par le pixel d’entrée (\(dZ \cdot X\)) dans le panneau de calcul de la position.
  4. Vérification de l’Historique : Suivez la consolidation des 16 résultats partiels organisés dans les quatre colonnes d’historique, en observant l’accumulation du gradient final.
  5. Mise à Jour du Kernel : Cliquez sur « ▶ Appliquer le pas de descente de gradient » pour visualiser la convergence de la courbe de perte et l’adaptation du kernel aléatoire au motif d’entrée sélectionné.
🧮 Simulateur : Gradient d'un Poids du Noyau ∂Perte / ∂K[kr][kc] = Σ dZ · X
MOTIF D'ENTRÉE (IMAGE 6×6)? Choisit quelle image 6×6 alimente la convolution.
TAUX D'APPRENTISSAGE (η)? Taux d'apprentissage. Ajustez pour voir la différence entre convergence douce (0.002) et effondrement par dépassement (0.02).
Exemple réduit: image 6×6 et filtre 3×3 générant des cartes 4×4. Cliquez sur les onglets "🔍 Comment est-ce calculé ?" sous chaque matrice pour comprendre les calculs pas à pas. Survolez n'importe quelle cellule de X, Z, A, dZ ou K pour voir le calcul exact de cette valeur, avec les éléments utilisés dans les couches associées mis en évidence avec un contour en pointillés/bleu.
1. POIDS DU NOYAU? Sélectionnez quel poids du noyau vous souhaitez analyser individuellement.
NOYAU ACTUEL (K)? Valeurs du filtre 3×3. Le poids sélectionné est mis en évidence en bleu. Survolez un poids pour voir où il est utilisé.
🔍 Comment est-il mis à jour ?
Règle du Gradient :
K ← K − η · ∇K
• η = taux d'apprentissage.
• ∇K = somme des 16 votes dZ × X.
ENTRÉE X (6×6)? Image 6×6. Pixel bleu = chevauchement avec le poids K sélectionné dans la fenêtre actuelle. Survolez un pixel pour voir dans quelles positions de Z il est utilisé.
🔍 Comment fonctionne X ?
Matrice d'entrée. En position (r,c), le poids K multiplie le pixel :
X[r + kr][c + kc]
PRÉ-ACTIVATION Z (4×4)? Résultat de la convolution avant ReLU : Z = Σ K · X. Survolez une cellule pour voir les 9 termes de la somme, en mettant en évidence la fenêtre dans X et tout le noyau K.
🔍 Comment calcule Z ?
Corrélation croisée :
Multiplication point par point du filtre 3×3 sur X :
Z[r][c] = Σ K · X
ACTIVATION A (4×4)? Résultat post-ReLU : A = max(0, Z). Si Z ≤ 0, l'activation est mise à zéro. Survolez une cellule pour mettre en évidence le Z correspondant.
🔍 Comment calcule A ?
Fonction ReLU :
A[r][c] = max(0, Z[r][c])
Somme Globale (S) :
S = Σ A[r][c]
ERREUR dZ (4×4)? Erreur propagée : dZ = (S - cible) · I(Z > 0). Où A=0, l'erreur dZ est aussi 0. Survolez une cellule pour voir le calcul complet, en mettant en évidence le Z correspondant et les 16 cellules de A qui forment S.
🔍 Comment calcule dZ, S et la Perte ?
1. Perte (Loss L) :
L = ½ (S − cible)²
2. Erreur propagée dZ :
dZ = (S − cible) · deriv_ReLU(Z)
2. CALCUL ET SOMME DES "VOTES" DE CHAQUE POSITION? Chaque position (r,c) génère un vote = dZ[r][c] × X[r+kr][c+kc]. La somme des 16 votes forme le gradient du poids.
CALCUL DE CETTE POSITION? Affiche l'erreur locale (dZ) et le pixel d'entrée (X) multipliés à la position actuelle de la fenêtre glissante.
SOMME ACCUMULÉE (GRADIENT)? La valeur accumulée des produits dZ × X de toutes les positions déjà parcourues. Quand elle atteint 16/16, c'est le gradient final du poids.
HISTORIQUE DES 16 POSITIONS (COLONNES c=0, c=1, c=2, c=3)? Suivez la liste des 16 positions organisées en 4 colonnes pour correspondre au mouvement de la fenêtre sur l'image de sortie.
3. UTILISEZ LE GRADIENT POUR METTRE À JOUR LE NOYAU? Applique la règle de la Descente de Gradient (K ← K − η · gradient) pour les 9 poids.
PERTE AU FIL DES MISES À JOUR? Évolution de l'erreur L = ½(S − cible)² :
• Objectif : L → 0 (S → cible).
• Si bloqué à L = 40.5 : Un "dépassement" (saut exagéré) s'est produit. Les poids sont devenus très négatifs, générant Z ≤ 0 (mort de ReLU). Avec S = 0, la perte reste bloquée à ½(0 − 9)² = 40.5.
Figure 9.12: Simulateur interactif du calcul du gradient d’un poids du kernel convolutif.
Note🧠 Synthèse — De la convolution à l’apprentissage de représentations

Les simulateurs de cette section démontrent, de manière séquentielle, comment un CNN transforme une image d’entrée en une estimation probabiliste et comment ses paramètres sont optimisés lors de l’entraînement :

  • Convolution : applique des filtres sur l’image pour extraire des caractéristiques locales, générant des cartes de caractéristiques grâce au partage des poids.
  • ReLU : introduit une non-linéarité dans le système, permettant la modélisation de relations complexes entre les données.
  • Pooling : réduit la résolution spatiale des cartes de caractéristiques, diminuant le coût computationnel et conférant une invariance aux petites translations locales.
  • Flatten : réorganise les cartes multidimensionnelles en un vecteur unidimensionnel pour alimenter les couches suivantes.
  • Couche entièrement connectée : combine les caractéristiques extraites pour produire les scores bruts (logits) associés à chaque classe.
  • Softmax : convertit les logits en une distribution de probabilités normalisée.
  • Fonction de perte : compare la distribution prédite avec la vérité terrain (ground truth), quantifiant scalairement l’erreur du réseau.
  • Rétropropagation : applique la règle de la chaîne pour calculer la dérivée partielle (gradient) de la fonction de perte par rapport à chaque paramètre entraînable.
  • Optimiseur : met à jour les coefficients des filtres, poids et biais dans la direction opposée au gradient, réduisant la perte à chaque itération.

Au fil des itérations, les filtres convolutifs se transforment de valeurs stochastiques en détecteurs spécialisés : les couches initiales apprennent des primitives visuelles de bas niveau (comme les bords et les textures), tandis que les couches plus profondes consolident ces représentations en structures abstraites et sémantiques.

9.5 Applications Pratiques en CV

Après la consolidation théorique des fondements des CNN et la vérification visuelle de chacune de leurs opérations élémentaires au moyen des simulateurs interactifs, il devient essentiel d’observer l’intégration de ces étapes dans des pipelines complets de programmation.

Dans les sections suivantes, la théorie est traduite en code exécutable en PyTorch, explorant les trois tâches fondamentales de la CV : classification, détection d’objets et segmentation sémantique. Cette progression pratique permet d’analyser aussi bien la construction d’une architecture convolutive entraînée de zéro que l’application de stratégies avancées de transfert d’apprentissage (transfer learning) sur des modèles pré-entraînés pour des ensembles de données synthétiques et réels.

9.5.1 Classification d’images avec les CNN

La classification d’images est l’une des applications les plus traditionnelles des CNN. Dans cette tâche, l’objectif est d’attribuer un unique label à l’image d’entrée, comme identifier une catégorie d’objet, une espèce animale ou une classe de diagnostic. Pour cela, la CNN transforme progressivement les valeurs des pixels en représentations d’un niveau d’abstraction plus élevé, en combinant des couches convolutionnelles, des fonctions d’activation et des opérations de réduction spatiale jusqu’à produire une distribution de probabilités entre les classes possibles. Dans cette section, sont présentés l’architecture de base d’une CNN de classification, le flux de transformation des données à travers le réseau et le processus d’entraînement pour l’ajustement des paramètres appris.

9.5.1.1 Entraînement d’un CNN de zéro sur des chiffres

Pour établir une comparaison directe avec les approches présentées au chapitre 7, on développe dans cette section un CNN entraîné sur le même ensemble de données de chiffres manuscrits (load_digits). La différence fondamentale réside dans l’étape de représentation : tandis que les méthodes classiques dépendent de pixels bruts ou de descripteurs calculés manuellement, comme l’Histogram of Oriented Gradients (HOG), le CNN apprend automatiquement les coefficients des filtres convolutifs pendant le processus d’optimisation.

Les codes suivants (consolidés dans la Figure 9.15) effectuent la préparation des données, définissent une architecture convolutive simple en PyTorch, exécutent la boucle d’entraînement via l’algorithme Adam et génèrent les courbes d’évolution de la fonction de perte et de la précision.

9.5.1.1.1 Bloc 1 : Préparation et structuration des données

L’étape initiale de tout pipeline d’apprentissage profond consiste à convertir et adapter les données d’entrée au format exigé par le framework de calcul scientifique.

9.5.1.1.1.1 Le concept de tenseur

En apprentissage profond, la structure de données fondamentale est le tenseur. D’un point de vue computationnel, un tenseur consiste en un arrangement multidimensionnel de nombres généralisé à \(n\) dimensions :

  • Un tenseur d’ordre 0 est un scalaire (une valeur unique).
  • Un tenseur d’ordre 1 est un vecteur (longueur).
  • Un tenseur d’ordre 2 est une matrice (lignes et colonnes).
  • Un tenseur d’ordre 3 ou supérieur représente un volume ou un hyper-arrangement de données.

Dans le contexte de PyTorch, la classe torch.Tensor étend la fonctionnalité des arrangements numériques multidimensionnels (comme ceux de NumPy) en offrant un support pour les opérations accélérées sur matériel via les GPU (Graphics Processing Units) ainsi qu’un support pour le calcul automatique des dérivées (autograd), essentiel pour l’algorithme de rétropropagation.

9.5.1.1.1.2 Analyse du code de prétraitement
  1. Chargement et normalisation des intensités : L’ensemble load_digits contient \(1.797\) échantillons de chiffres manuscrits de \(8 \times 8\) pixels, dont les intensités originales varient sur l’échelle entière de \(0\) à \(16\). La division par \(16.0\) effectue la normalisation des données dans la plage \([0.0, 1.0]\). Cette transformation en virgule flottante (float32) est indispensable dans les réseaux neuronaux pour éviter la saturation des fonctions d’activation et stabiliser le calcul des gradients dans l’algorithme d’optimisation.

  2. Division stratifiée (70 % entraînement / 30 % test) : La fonction train_test_split sépare \(70\%\) des échantillons pour l’ajustement des paramètres du réseau et réserve \(30\%\) pour l’évaluation du modèle sur des données non vues. Le paramètre stratify=y garantit un échantillonnage stratifié, en maintenant la proportion exacte de chacune des 10 classes de chiffres (\(0\) à \(9\)) dans les deux ensembles, évitant ainsi un biais de distribution.

  3. Adaptation dimensionnelle pour la convolution 2D (unsqueeze) : Dans les CNN, les couches convolutives bidimensionnelles (nn.Conv2d) exigent que le tenseur d’entrée possède strictement 4 dimensions dans l’ordre \((N, C, H, W)\) :

    • \(N\) : nombre d’échantillons (batch size).
    • \(C\) : nombre de canaux de couleur (\(1\) pour le niveaux de gris, \(3\) pour RGB).
    • \(H\) : hauteur de l’image en pixels (\(8\)).
    • \(W\) : largeur de l’image en pixels (\(8\)).

    Comme l’arrangement original possède un format \(3\text{D}\) de type \((N, 8, 8)\), l’appel .unsqueeze(1) insère une dimension unitaire spécifiquement à l’indice 1 (la position réservée au canal de couleur \(C\)), transformant la structure en un tenseur \(4\text{D}\) de format \((N, 1, 8, 8)\), conformément à l’exigence de PyTorch.

  4. Conversion des étiquettes (dtype=torch.long) : Les étiquettes des classes \(y\) sont converties en tenseurs entiers de 64 bits (torch.long). Cette spécification de type est une exigence de la fonction de perte d’entropie croisée (nn.CrossEntropyLoss), qui utilise des entiers non négatifs comme indices pour associer la classe correcte aux logits de sortie du réseau.

Attention aux dimensions : La structure finale est représentée par le tenseur (N, 1, 8, 8), où N est le nombre d’échantillons (batch size), 1 est le canal de couleur (niveaux de gris) et 8×8 est la résolution spatiale de l’image en pixels.

La Figure 9.13 illustre une séquence d’échantillons de l’ensemble d’entraînement après le prétraitement et l’adaptation dimensionnelle aux tenseurs de PyTorch. Dans l’étape d’affichage, l’appel img.squeeze().numpy() enchaîne deux transformations : la méthode .squeeze() élimine la dimension unitaire redondante du canal de couleur, réduisant le tenseur \(3\text{D}\) de format (1, 8, 8) à une matrice \(2\text{D}\) de (8, 8) ; ensuite, la méthode .numpy() convertit la structure de PyTorch en une matrice native de NumPy, format exigé par les outils de rendu graphique comme mm.show().

# 1. Chargement et prétraitement des données
digits = load_digits()
X = digits.images.astype(np.float32) / 16.0  # Normalisation à la plage [0, 1]
y = digits.target

# Division stratifiée en ensembles d'entraînement (70%) et de test (30%)
X_treino, X_teste, y_treino, y_teste = train_test_split(
    X, y, test_size=0.3, random_state=42, stratify=y
)

# Adaptation à la dimension attendue par PyTorch : (N_échantillons, Canaux, Hauteur, Largeur)
X_treino_t = torch.tensor(X_treino).unsqueeze(1)   # Dimension : (N, 1, 8, 8)
y_treino_t = torch.tensor(y_treino, dtype=torch.long)
X_teste_t = torch.tensor(X_teste).unsqueeze(1)
y_teste_t = torch.tensor(y_teste, dtype=torch.long)

# Afficher un échantillon
n_amostras = 8
imgs = [img.squeeze().numpy() for img in X_treino_t[:n_amostras]]
imgs_titles = [str(label.item()) for label in y_treino_t[:n_amostras]]
mm.show(imgs, titles=imgs_titles, cols=n_amostras, figsize=(12, 2.5))
Figure 9.13: Échantillons de chiffres de l’ensemble d’entraînement après conversion en tensors PyTorch et normalisation.
9.5.1.1.2 Bloc 2 : Définition de l’architecture convolutionnelle

La construction de modèles dans PyTorch est structurée selon le paradigme de la programmation orientée objet, en créant une classe spécifique pour représenter le réseau de neurones (dans cet exemple, la classe CNNDigitos), qui hérite de toutes les fonctionnalités de la classe de base nn.Module. Le constructeur __init__ est responsable de l’instanciation des couches et de la déclaration de leurs paramètres entraînables, tandis que la méthode forward établit la séquence numérique de la propagation avant (forward pass).

La Figure 9.14 synthétise les transformations spatiales des tensors et le flux de données au sein de la classe CNNDigitos.

Figure 9.14: Représentation du flux des transformations dimensionnelles des tensors à travers l’architecture CNNDigitos.
  1. Constructeur (__init__) et instanciation des composants :
    • Couche convolutionnelle 1 (self.conv1) : Applique \(8\) filtres \(3 \times 3\) avec padding=1 sur l’entrée en niveaux de gris (\(1\) canal), en préservant la résolution spatiale de \(8 \times 8\) pixels.
    • Couche convolutionnelle 2 (self.conv2) : Traite les \(8\) cartes de caractéristiques reçues de la couche précédente en appliquant \(16\) filtres \(3 \times 3\) avec padding=1.
    • Sous-échantillonnage (self.pool) : Instancie l’opération de Max-Pooling avec une fenêtre \(2 \times 2\) et un pas (stride) \(2\), réduisant la dimension spatiale (hauteur et largeur) de moitié à chaque application.
    • Couches entièrement connectées (self.fc1 et self.fc2) : La première projection dense reçoit le tensor aplati de dimension \(16 \times 2 \times 2 = 64\) et produit \(32\) caractéristiques intermédiaires. La seconde projette ces \(32\) caractéristiques sur les \(10\) logits finaux de sortie.
  2. Propagation avant dans la méthode forward :
    • Premier bloc convolutionnel : Le tensor d’entrée au format \((N, 1, 8, 8)\) passe par conv1 + ReLU et est sous-échantillonné par pool, résultant au format \((N, 8, 4, 4)\).
    • Deuxième bloc convolutionnel : Le tensor \((N, 8, 4, 4)\) est traité par conv2 + ReLU et réduit par pool au format \((N, 16, 2, 2)\).
    • Aplatissement (Flatten) : La méthode x.view(x.size(0), -1) reconfigue la structure \(3\text{D}\) en un vecteur \(1\text{D}\) de \(64\) éléments par échantillon, en préservant la dimension du lot \(N\).
    • Classification : Le vecteur de \(64\) éléments alimente fc1 avec une activation ReLU (\(32\) neurones) et se termine dans fc2, produisant les \(10\) logits non normalisés pour le calcul de la fonction de perte.
# 2. Définition de l'architecture convolutive
class CNNDigitos(nn.Module):
    """
    Architecture convolutive compacte :
    2 couches convolutives avec ReLU et Max-Pooling + 2 couches denses.
    """
    def __init__(self, n_classes=10):
        super().__init__()
        
        # Conv1 : 1 canal d'entrée, 8 filtres 3x3 avec padding 1 (sortie : 8x8)
        self.conv1 = nn.Conv2d(1, 8, kernel_size=3, padding=1)
        # Conv2 : 8 canaux d'entrée, 16 filtres 3x3 avec padding 1 (sortie : 4x4)
        self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)

        self.relu = nn.ReLU()

        # Max-Pooling 2x2 avec pas (stride) 2
        self.pool = nn.MaxPool2d(2, 2)

        # Couches entièrement connectées (FC)
        self.fc1 = nn.Linear(16 * 2 * 2, 32)
        self.fc2 = nn.Linear(32, n_classes)

    def forward(self, x):
        # Premier bloc : Conv (8x8) -> ReLU -> Pool (4x4)
        x = self.pool(self.relu(self.conv1(x)))

        # Deuxième bloc : Conv (4x4) -> ReLU -> Pool (2x2)
        x = self.pool(self.relu(self.conv2(x)))
        
        # Aplatissement (Flatten) : reconfigure la matrice 3D (16, 2, 2) en vecteur 1D (64)
        x = x.view(x.size(0), -1)

        # Couche dense intermédiaire avec ReLU
        x = self.relu(self.fc1(x))

        # Couche finale de classification (logits)
        return self.fc2(x)
9.5.1.1.2.1 Analyse des couches et du flux de la classe CNNDigitos
  1. Constructeur (__init__) et instanciation des composants :
    • Couche convolutive 1 (self.conv1) : Applique \(8\) filtres \(3 \times 3\) avec padding=1 sur l’entrée en niveaux de gris (\(1\) canal), en préservant la résolution de \(8 \times 8\) pixels.
    • Couche convolutive 2 (self.conv2) : Traite les \(8\) cartes de caractéristiques reçues en appliquant \(16\) filtres \(3 \times 3\) avec padding=1.
    • Sous-échantillonnage (self.pool) : Instancie l’opération de Max-Pooling avec une fenêtre \(2 \times 2\) et un pas (stride) de \(2\), réduisant les dimensions spatiales (hauteur et largeur) de moitié à chaque application.
    • Couches entièrement connectées (self.fc1 et self.fc2) : La première projection dense reçoit le tenseur aplati de dimension \(16 \times 2 \times 2 = 64\) et produit \(32\) caractéristiques intermédiaires. La seconde projette ces \(32\) caractéristiques sur les \(10\) logits de sortie.
  2. Propagation avant dans la méthode forward :
    • Premier bloc : Le tenseur \((N, 1, 8, 8)\) passe par conv1 + ReLU et est réduit par pool à \((N, 8, 4, 4)\).
    • Deuxième bloc : Le tenseur \((N, 8, 4, 4)\) passe par conv2 + ReLU et est réduit par pool à \((N, 16, 2, 2)\).
    • Aplatissement (Flatten) : La méthode x.view(x.size(0), -1) convertit la structure \(3\text{D}\) en un vecteur \(1\text{D}\) de \(64\) éléments par échantillon.
    • Classification : Le vecteur de \(64\) éléments alimente fc1 avec une activation ReLU (\(32\) neurones) et se termine par fc2, qui produit les \(10\) logits finaux pour le calcul de la perte d’Entropie Croisée.
9.5.1.1.3 Bloc 3 : Instanciation et paramètres d’optimisation

L’étape de configuration de l’apprentissage exige l’instanciation de l’architecture définie et le choix de deux composants centraux : la fonction de perte, qui quantifie l’erreur du modèle, et l’algorithme d’optimisation, responsable de l’ajustement des paramètres vers le minimum de cette fonction.

  1. Instanciation et comptage des paramètres : Le modèle est créé à partir de l’instanciation de l’objet modelo_cnn de la classe CNNDigitos. L’expression sum(p.numel() for p in modelo_cnn.parameters()) parcourt tous les tensors de paramètres entraînables du réseau (poids et biais de chaque couche) et calcule la cardinalité totale du modèle, quantifiant ainsi sa capacité de représentation.

  2. Fonction de perte (nn.CrossEntropyLoss) : La perte d’entropie croisée (Cross-Entropy Loss) est le choix standard pour les problèmes de classification multiclasse. Dans PyTorch, cette implémentation combine en interne l’application de la fonction LogSoftmax avec la perte de log-vraisemblance négative (NLLLoss). Pour cette raison, la couche de sortie du réseau produit des logits bruts, dispensant de l’application explicite de la fonction Softmax à la fin de la méthode forward.

  3. Optimiseur adaptatif (optim.Adam) : La mise à jour des paramètres utilise l’algorithme Adam (Adaptive Moment Estimation), avec un taux d’apprentissage initial \(\eta = 0,01\) (lr=1e-2). Adam combine les principes du moment avec l’adaptation de la taille du pas basée sur la moyenne mobile des dérivées de premier et second ordres, ajustant individuellement le taux d’apprentissage de chaque paramètre du réseau.

# 3. Initialisation du modèle et paramètres d’optimisation
modelo_cnn = CNNDigitos()
num_params = sum(p.numel() for p in modelo_cnn.parameters())
print(f"Paramètres entraînables du modèle : {num_params}")

criterio = nn.CrossEntropyLoss()
otimizador = optim.Adam(modelo_cnn.parameters(), lr=1e-2)
Paramètres entraînables du modèle : 3658
9.5.1.1.4 Bloco 4: Boucle d’Entraînement et Évaluation

L’entraînement d’un CNN se déroule de manière itérative via l’algorithme de Descente de Gradient Stochastique par mini-lots (Mini-batch SGD).

Les courbes d’apprentissage résultant de ce processus sont présentées dans la Figure 9.15, générée à la fin de l’exécution.

  1. Phase d’Entraînement (modelo_cnn.train()): La boucle principale exécute l’entraînement sur \(50\) époques. À chaque époque, les étapes suivantes ont lieu :

    • Mélange Stochastique : La fonction torch.randperm(n) génère une permutation aléatoire des indices des échantillons, garantissant que l’ordre des mini-lots varie à chaque époque pour éviter les biais d’échantillonnage.
    • Division en Mini-lots : L’ensemble d’entraînement est découpé en lots de \(32\) échantillons (tam_lote = 32).
    • Remise à Zéro des Gradients (otimizador.zero_grad()): Nettoie les gradients accumulés dans le tensor à l’itération précédente, évitant la somme indésirable de dérivées entre différents lots.
    • Propagation Avant et Perte : La forward pass calcule les prédictions saida, et l’appel criterio(saida, y_treino_t[idx]) quantifie l’erreur du lot.
    • Rétropropagation (perda.backward()): Applique la règle de la chaîne pour calculer les dérivées partielles de la perte par rapport à chaque paramètre (\(\frac{\partial L}{\partial w}\)).
    • Mise à Jour des Poids (otimizador.step()): Met à jour les paramètres du modèle selon les équations de l’optimiseur Adam.
  2. Phase d’Évaluation (modelo_cnn.eval()): À la fin de chaque époque, le modèle est basculé en mode évaluation. Le contexte with torch.no_grad() désactive temporairement le moteur de calcul automatique des dérivées (autograd), réduisant la consommation mémoire et accélérant l’inférence sur l’ensemble de test (X_teste_t). L’opération .argmax(dim=1) extrait la classe de plus haute probabilité pour chaque échantillon, permettant de calculer la précision sur le test.

  3. Visualisation avec la Bibliothèque morph: La fonction mm.showTrainCurves de la bibliothèque didactique morph consolide l’historique de perte d’entraînement et la précision sur le test dans un unique panneau graphique, permettant de diagnostiquer la convergence du modèle et de surveiller la stabilité de l’apprentissage au fil des époques.

# 4. Boucle d'entraînement (SGD par mini-lots)
n = X_treino_t.size(0)                    # Nombre d'échantillons
tam_lote = 32                             # Taille du mini-lot
epocas = 50                               # Nombre total d'époques
historico_perda, historico_acc = [], []   # Historique des métriques

for epoca in range(epocas):                  # Répète par époque
    modelo_cnn.train()                       # Mode entraînement
    perm = torch.randperm(n)                 # Mélange les échantillons
    perda_epoca = 0.0                        # Accumule les pertes
    for i in range(0, n, tam_lote):          # Parcourt les mini-lots
        idx = perm[i:i + tam_lote]           # Indices du lot
        otimizador.zero_grad()               # Remet les gradients à zéro
        saida = modelo_cnn(X_treino_t[idx])  # Propagation avant
        perda = criterio(saida, y_treino_t[idx]) # Calcule la perte
        perda.backward()                         # Rétropropagation
        otimizador.step()                        # Met à jour les poids
        perda_epoca += perda.item() * len(idx)   # Somme des pertes

    # Évaluation du modèle sur l'ensemble de test à la fin de chaque époque
    modelo_cnn.eval()                            # Mode évaluation
    with torch.no_grad():                        # Sans gradients
        pred_teste = modelo_cnn(X_teste_t).argmax(dim=1)  # Prédictions
        acc_teste = (pred_teste == y_teste_t).float().mean().item()  # Précision
    historico_perda.append(perda_epoca / n)      # Enregistre la perte
    historico_acc.append(acc_teste)              # Enregistre la précision

acc_final_cnn = historico_acc[-1]                # Dernière précision
print(f"Précision finale du CNN sur l'ensemble de test : {acc_final_cnn:.4f}")  # Affiche le résultat

final = mm.showTrainCurves(                      # Trace les courbes
    historico_perda, historico_acc,
    titulo="Evolução do Treinamento da CNN — Base de Dígitos",
    subtitulo=f"Acurácia final no teste: {acc_final_cnn:.4f}",
)
Précision finale du CNN sur l'ensemble de test : 0.9759
Figure 9.15: Courbes d’entraînement et d’évaluation du CNN sur la base de chiffres : évolution de la perte d’entropie croisée sur l’ensemble d’entraînement et de la précision sur l’ensemble de test au fil de 50 époques.
9.5.1.1.5 Bloc 5 : Visualisation du flux d’activations

L’inspection du réseau entraîné permet d’observer la transformation progressive du tensor d’entrée à travers les couches de l’architecture CNNDigitos. La Figure 9.16 illustre les dimensions et les activations intermédiaires obtenues lors du traitement d’un exemple réel du chiffre \(3\).

  1. Sélection et préparation de l’échantillon : La graine stochastique est fixée avec torch.manual_seed(7) pour assurer la reproductibilité des résultats. La première occurrence du chiffre \(3\) dans l’ensemble de données load_digits est isolée, normalisée dans l’intervalle \([0.0, 1.0]\) et reconfigurée comme un tensor x de dimension \((1, 1, 8, 8)\).

  2. Inspection intermédiaire avec mm.showNet : La fonction mm.showNet de la bibliothèque morph exécute la propagation avant (forward pass) du tensor x dans l’instance modelo_cnn préalablement entraînée. En utilisant des hooks de forward, la fonction intercepte l’état numérique des activations dans les couches convolutionnelles (nn.Conv2d), de regroupement (nn.MaxPool2d) et entièrement connectées (nn.Linear), et les retourne dans le dictionnaire acts. Les fonctions d’activation non linéaires (nn.ReLU) ne sont pas enregistrées comme étapes indépendantes, car leur application s’effectue directement sur le tensor de sortie de la couche correspondante.

  3. Vérification des résultats : L’instruction list(acts.keys()) affiche la séquence d’identifiants des couches surveillées, permettant de confirmer la réduction dimensionnelle progressive et la génération du logit de valeur maximale à l’indice correspondant à la classe \(3\), comme démontré dans la Figure 9.16..

torch.manual_seed(7)
digits = load_digits()
idx = np.where(digits.target == 3)[0][0]
img = digits.images[idx] / 16.0
x = torch.tensor(img, dtype=torch.float32).view(1, 1, 8, 8)

# Réutilisation de l'instance du modèle préalablement entraînée
acts = mm.showNet(
    modelo_cnn,
    x,
    titulo="Fluxo de transformações dos tensors ao longo da arquitetura CNNDigitos",
    subtitulo=f"Exemplo real do dataset load_digits (classe verdadeira: {digits.target[idx]})",
)
print("Couches capturées :", list(acts.keys()))
Figure 9.16: Fluxo de activations de la CNN entraînée lors du traitement d’un exemple réel du chiffre 3, du dataset load_digits : dimensions des tenseurs couche par couche, de l’entrée au logit de sortie.
Couches capturées : ['conv1', 'pool', 'conv2', 'pool #2', 'fc1', 'fc2']
9.5.1.1.6 Inspection du graphe computationnel avec torchviz

Alors que mm.showNet privilégie la clarté didactique — en affichant une colonne par couche avec les paramètres entraînables —, la bibliothèque torchviz projette le graphe d’autograd exactement comme PyTorch le construit en interne pour le calcul des gradients. La Figure 9.17 illustre cette perspective en représentant l’architecture CNNDigitos.

  1. Propagation avant tracée : Avec le modèle entraîné en mode eval(), le forward pass sur le tensor x du chiffre \(3\) suffit pour que le moteur d’autograd enregistre toutes les opérations exécutées, y compris celles sans paramètres entraînables, comme la fonction d’activation ReLU et la reconfiguration dimensionnelle view.

  2. Génération du graphe (make_dot): La fonction make_dot(saida, params=...) construit le graphe à partir du tensor de sortie, en parcourant rétroactivement l’historique des opérations jusqu’aux nœuds feuilles (les paramètres entraînables du modèle). Chaque nœud du diagramme représente une opération du backward pass (comme ReluBackward ou AddmmBackward), et non seulement un bloc conceptuel du nn.Module.

  3. Exportation et rendu (.render): La méthode .render(..., format="png", cleanup=True) invoque l’exécutable dot de Graphviz pour compiler l’image au format PNG, en supprimant automatiquement les fichiers intermédiaires de code source.

La Figure 9.17 met en évidence comment ce graphe computationnel, même pour une architecture compacte, présente une densité plus élevée que le panneau du mm.showNet, car il détaille chaque opération atomique responsable du flux des gradients.

# 1. Passe avant avec suivi de gradient activé
modelo_cnn.eval()
saida = modelo_cnn(x)  # Réutilisation du tenseur x (chiffre 3)

# 2. Graphe de base : flux d'opérations jusqu'à la sortie
grafo_simples = make_dot(saida, params=dict(modelo_cnn.named_parameters()))
caminho_simples = grafo_simples.render("cnn_digitos_grafo_simples", format="png", cleanup=True)

# 3. Affichage direct dans l'environnement Quarto/Jupyter
# Le .render() retourne le chemin du fichier PNG généré ; nous devons l'ouvrir comme image
imagem_simples = np.array(Image.open(caminho_simples).convert("RGB"))
mm.show(imagem_simples, figsize=(5,10))
Figure 9.17: Graphe computationnel de la CNNDigitos généré via torchviz, montrant les opérations de forward et les nœuds de gradient (backward) associés à chaque paramètre entraînable.
9.5.1.1.7 Vue détaillée du graphe computationnel avec torchviz

Outre la représentation simplifiée, la bibliothèque torchviz permet d’étendre le graphe d’autograd afin d’inspecter les détails internes de l’exécution du réseau CNNDigitos. La Figure 9.18 présente cette structure étendue pour le même tensor d’entrée x.

  1. Traçage avec attributs d’opération (show_attrs=True):
    L’inclusion des attributs affiche les configurations hyperparamétriques associées à chaque nœud computationnel durant la propagation avant (forward pass), telles que les dimensions de kernel (kernel_size), les pas (stride) et les remplissages (padding) dans les convolutions et les sous-échantillonnages.

  2. Détection des tensors sauvegardés en mémoire (show_saved=True):
    Ce paramètre force l’affichage explicite des tensors intermédiaires que PyTorch conserve en mémoire pendant le forward pass. Ces données sont préservées car elles seront strictement nécessaires au calcul des dérivées partielles durant l’étape de rétropropagation (backward pass).

  3. Génération et compilation des graphes:
    Alors que grafo_simples produit une vue directe du flux de gradients, grafo_detalhado compile le graphe étendu dans le fichier cnn_digitos_grafo_detalhado.png via l’exécutable dot de Graphviz.

Comme observé dans la Figure 9.18, cette visualisation minutieuse est utile pour déboguer la consommation de mémoire vidéo (VRAM) et vérifier comment le moteur de PyTorch alloue en interne chaque nœud de la règle de la chaîne.

# même code précédent (make_dot + render)...

# 2. Graphe détaillé : affichage des dimensions et des tenseurs sauvegardés pour la rétropropagation
grafo_detalhado = make_dot(
    saida,
    params=dict(modelo_cnn.named_parameters()),
    show_attrs=True,   # Affiche les attributs des opérations (ex. : kernel_size, stride)
    show_saved=True,   # Affiche les tenseurs sauvegardés en mémoire pour la rétropropagation
)

caminho_detalhado = grafo_detalhado.render("cnn_digitos_grafo_detalhado", 
                                           format="png", cleanup=True)

# 3. Affichage direct dans l'environnement Quarto/Jupyter
imagem_detalhado = np.array(Image.open(caminho_detalhado).convert("RGB"))
mm.show(imagem_detalhado, figsize=(8, 16))
Figure 9.18: Graphe détaillé de la classe CNNDigitos généré via torchviz.
9.5.1.1.8 Comparaison avec le chapitre 7

La Figure 9.19 rassemble les résultats obtenus sur le même ensemble de données (load_digits), établissant un parallèle direct entre les approches classiques explorées précédemment et le CNN développé dans ce chapitre.

  1. Performance des pixels bruts vs. descripteurs manuels : Dans les expériences du chapitre 7, le classificateur \(k\text{-NN}\) (\(k=3\)) a atteint une précision de \(98,4\%\) lorsqu’il était alimenté directement avec les pixels bruts des images. En revanche, l’extraction préalable de caractéristiques via Histogram of Oriented Gradients (HOG) a abouti à une performance significativement inférieure (\(75,8\%\)). Cette baisse s’explique par le fait que le HOG a été conçu pour capturer les gradients de contours dans des images de plus haute résolution ; dans des matrices de seulement \(8 \times 8\) pixels, la résolution spatiale est insuffisante pour former des histogrammes d’orientation informatifs.

  2. Équivalence du CNN et apprentissage end-to-end : Le réseau convolutif CNNDigitos atteint une performance compétitive de \(97,6\%\), se rapprochant de la précision du \(k\text{-NN}\) avec pixels bruts sur une base de données petite et pré-alignée. Le grand avantage conceptuel réside dans l’apprentissage de représentations : au lieu de dépendre de descripteurs conçus manuellement (handcrafted features) ou de conserver l’intégralité de l’ensemble de données en mémoire pour la recherche de voisins au moment de l’inférence, le CNN optimise automatiquement ses propres filtres convolutifs pendant l’entraînement, générant un modèle compact capable d’effectuer l’extraction de caractéristiques et la classification de manière intégrée (end-to-end).

import matplotlib.pyplot as plt

# Valeurs obtenues au Chapitre 7 (k-NN, k=3), reproduites pour comparaison directe
ACC_KNN_PIXELS_CAP7 = 0.9844
ACC_KNN_HOG_CAP7 = 0.7578

metodos = ["k-NN\n(pixels brutos)", "k-NN\n(HOG)", "CNN\n(este capítulo)"]
acuracias = [ACC_KNN_PIXELS_CAP7, ACC_KNN_HOG_CAP7, acc_final_cnn]

plt.figure(figsize=(5, 4))
cores = ["#6366f1", "#f97316", "#16a34a"]
plt.bar(metodos, acuracias, color=cores)
plt.ylim(0, max(acuracias) + 0.08)
plt.ylabel("Acurácia (conjunto de teste)")
plt.title("Cap. 7 vs. Cap. 9 — Base de Dígitos")

for i, v in enumerate(acuracias):
    plt.text(i, v + 0.02, f"{v:.3f}", ha="center")

plt.tight_layout()
plt.show()
Figure 9.19: Comparaison de précision entre les classificateurs classiques du Chapitre 7 (pixels bruts et HOG avec k-NN) et le CNN entraîné dans ce chapitre, sur la même base de chiffres.
Note🧠 Pourquoi cela fonctionne-t-il ? — Et pourquoi le CNN ne « gagne » pas toujours

Le résultat observé ici répète le schéma déjà vu au Chapitre 7 : le CNN, bien qu’il apprenne automatiquement ses caractéristiques, ne surpasse pas nécessairement le \(k\text{-NN}\) avec des pixels bruts sur cette base spécifique. L’explication est la même : load_digits est une base petite (moins de \(1.800\) exemples), avec des images déjà centrées, normalisées et de très basse résolution (\(8 \times 8\)) — des conditions où la comparaison directe des intensités est déjà hautement informative, et où il y a peu de données pour que le réseau apprenne des filtres véritablement supérieurs aux descripteurs simples.

Le véritable avantage des CNN apparaît dans des scénarios que les descripteurs artisanaux et les classificateurs simples ne peuvent pas aborder : des images plus grandes et plus réalistes, avec des milliers de catégories, une variation substantielle de pose, d’éclairage et de fond, et des ensembles d’entraînement massifs — exactement le régime dans lequel les modèles présentés dans la section « Applications à Grande Échelle », plus loin, ont été entraînés. La leçon pédagogique qui traverse les Chapitres 7, 8 et 9 de ce livre est cohérente : la sophistication d’une méthode doit être proportionnelle à la complexité du problème — utiliser un CNN pour un problème qu’un \(k\text{-NN}\) résout tout aussi bien est un gaspillage de ressources computationnelles, non une vertu.

Cette même proportionnalité vaut pour les outils d’inspection utilisés tout au long du chapitre. Le mm.showNet a été construit à des fins didactiques et fonctionne bien sur des réseaux peu profonds comme CNNDigitos, mais ne passe pas à l’échelle pour des architectures profondes : chaque couche suivie devient une colonne dans la figure, et les couches convolutionnelles avec des centaines de canaux génèrent des mosaïques trop grandes pour une interprétation visuelle ; de plus, les hooks stockent toutes les activations en mémoire, et la mise en page suppose un flux séquentiel, ne représentant pas fidèlement les connexions résiduelles ou les ramifications (comme dans les ResNets ou les modules Inception). Ainsi, showNet doit être compris comme une lentille pédagogique pour les petits réseaux — analogue au rôle de mm.showBoundBox dans le débogage visuel des détections — et non comme un substitut aux outils orientés production, comme TensorBoard ou torchviz.

9.5.1.2 Transfert d’apprentissage

Entraîner un CNN de zéro nécessite généralement une grande quantité de données étiquetées et des ressources computationnelles significatives, car le processus d’entraînement doit ajuster tous les paramètres du réseau. Dans de nombreuses applications, cependant, seul un ensemble réduit de données est disponible pour la tâche d’intérêt. Dans cette situation, le transfert d’apprentissage (transfer learning) réutilise les représentations apprises par un modèle préalablement entraîné sur une tâche source avec un grand volume de données, réduisant ainsi le coût d’entraînement et le besoin de nouvelles échantillons.

En vision par ordinateur, cette stratégie exploite l’organisation hiérarchique des CNN. Les couches initiales apprennent des caractéristiques visuelles de bas niveau, telles que les contours, les textures, les gradients d’intensité et les motifs de couleurs, qui restent utiles dans différents domaines. Les couches plus profondes combinent ces informations pour former des représentations progressivement plus abstraites et spécialisées, liées aux classes présentes dans la base d’entraînement.

Cette section examine dans quelles conditions le transfert d’apprentissage produit de bons résultats. La première expérience montre qu’un extracteur de petite taille et entraîné sur un domaine restreint peut conduire à un transfert négatif (negative transfer). La deuxième démontre pourquoi des modèles profonds pré-entraînés sur de grandes bases d’images atteignent des performances élevées sur de nouvelles tâches. Enfin, la troisième applique cette stratégie à un problème de diagnostic phytosanitaire, illustrant un scénario proche des applications réelles.

9.5.1.2.1 Expérience 1 — Limites d’un extracteur petit et spécialisé

La première expérience montre que le transfert d’apprentissage n’améliore pas toujours les performances d’un modèle. Pour ce faire, l’ensemble de chiffres manuscrits (load_digits) est divisé en deux domaines disjoints :

  • Domaine A (source) : chiffres \(0\) à \(4\), utilisés pour entraîner une petite CNN ;
  • Domaine B (cible) : chiffres \(5\) à \(9\), réindexés de \(0\) à \(4\), formant une nouvelle tâche avec seulement \(20\) échantillons d’entraînement.

L’objectif consiste à évaluer l’effet de la réutilisation de l’extracteur de caractéristiques appris dans le Domaine A sans permettre son adaptation au Domaine B.

9.5.1.2.1.1 Bloco 1 : Division des domaines, rareté et affichage des échantillons

Ce bloc prépare l’ensemble de données pour l’expérience. Contrairement au projet précédent, qui utilisait tous les chiffres dans un problème de classification unique, la base est divisée en deux tâches indépendantes : une tâche source (Domaine A) et une tâche cible (Domaine B).

La Figure 9.20 présente des exemples des deux domaines après la séparation des classes, la conversion en tensors PyTorch et le prétraitement.

  1. Séparation des classes : Les masques booléens mask_A et mask_B séparent les exemples de chaque domaine. Ensuite, le code réindexe les étiquettes du Domaine B (y[mask_B] - 5) dans l’intervalle \([0,4]\), permettant ainsi aux deux modèles d’utiliser cinq classes de sortie.

  2. Rareté des données : Le générateur np.random.default_rng(0) sélectionne uniquement \(20\) échantillons pour l’entraînement du Domaine B, environ quatre par classe, simulant un scénario où l’entraînement à partir de zéro tend à souffrir d’overfitting.

  3. Conversion en tensors : La fonction para_tensor convertit les images au format \((N,1,8,8)\) et les étiquettes en torch.long, compatibles avec les couches nn.Conv2d et la fonction de perte.

  4. Visualisation des échantillons : Le code utilise .squeeze().numpy() pour convertir les tensors en matrices NumPy. La Figure 9.20 présente des exemples des deux domaines et met en évidence la réindexation appliquée aux étiquettes du Domaine B.

# 1. Division du jeu de données en deux domaines disjoints
classes_A, classes_B = [0, 1, 2, 3, 4], [5, 6, 7, 8, 9]
mask_A, mask_B = np.isin(y, classes_A), np.isin(y, classes_B)

XA, yA = X[mask_A], y[mask_A]
XB, yB = X[mask_B], y[mask_B] - 5  # Réindexation des étiquettes dans l'intervalle [0, 4]

# Division en entraînement et test pour les deux domaines
XA_tr, XA_te, yA_tr, yA_te = train_test_split(
    XA, yA, test_size=0.25, random_state=42, stratify=yA
)
XB_tr, XB_te, yB_tr, yB_te = train_test_split(
    XB, yB, test_size=0.25, random_state=42, stratify=yB
)

# Simulation de pénurie extrême dans le domaine cible : seulement 20 échantillons d'entraînement
rng = np.random.default_rng(0)
idx_poucos = rng.choice(len(XB_tr), size=20, replace=False)
XB_tr_poucos, yB_tr_poucos = XB_tr[idx_poucos], yB_tr[idx_poucos]

# Fonction auxiliaire pour la conversion en tenseurs PyTorch
def para_tensor(Ximg, yarr):
    return torch.tensor(Ximg).unsqueeze(1), torch.tensor(yarr, dtype=torch.long)

XA_tr_t, yA_tr_t = para_tensor(XA_tr, yA_tr)
XA_te_t, yA_te_t = para_tensor(XA_te, yA_te)
XB_tr_t, yB_tr_t = para_tensor(XB_tr_poucos, yB_tr_poucos)
XB_te_t, yB_te_t = para_tensor(XB_te, yB_te)

# Affichage d'échantillons des deux domaines
n_amostras = 5
imgs_A = [img.squeeze().numpy() for img in XA_tr_t[:n_amostras]]
titles_A = [f"A: {label.item()}" for label in yA_tr_t[:n_amostras]]

imgs_B = [img.squeeze().numpy() for img in XB_tr_t[:n_amostras]]
titles_B = [f"B: {label.item()} (orig: {label.item()+5})" for label in yB_tr_t[:n_amostras]]

mm.show(
    imgs_A + imgs_B,
    titles=titles_A + titles_B,
    cols=n_amostras,
    figsize=(12, 4.5)
)
Figure 9.20: Échantillons des ensembles d’entraînement après prétraitement et ajustement dimensionnel aux tenseurs PyTorch : Domaine A (chiffres 0 à 4, tâche source) et Domaine B (chiffres 5 à 9 réindexés en 0 à 4, tâche cible).
9.5.1.2.1.2 Bloc 2 : Architecture Modulaire et Routines Génériques

Pour faciliter le transfert d’apprentissage, l’architecture convolutive et la boucle d’entraînement ont été refactorisées par rapport à la classe CNNDigitos du projet précédent.

  1. Modularisation de l’architecture (Différence par rapport à CNNDigitos) :

    • Dans le projet précédent, la classe CNNDigitos déclarait toutes les couches (conv1, conv2, pool, fc1, fc2) comme membres directs d’une seule classe monolithique.
    • Ici, l’architecture est séparée en deux composants : la classe ExtratorConv encapsule le bloc spatial convolutif (\(2\) convolutions \(3 \times 3\), \(2\) Max-Poolings \(2 \times 2\) et l’aplatissement en \(64\) éléments), tandis que la classe CNNCompleta instancie cet extracteur dans self.extrator et ajoute la « tête » de classification (fc1 et fc2).
    • Cette séparation est ce qui permet de copier l’état interne de l’extracteur (state_dict()) d’un modèle à un autre de manière isolée.
  2. Ajustement du nombre de classes de sortie : Alors que CNNDigitos dans le projet précédent possédait \(10\) logits dans la couche de sortie (self.fc2 = nn.Linear(32, 10)), la classe CNNCompleta reçoit n_classes=5 dans le constructeur pour s’adapter à la division des domaines \(A\) et \(B\).

  3. Flexibilisation de la boucle d’entraînement (treinar) :

    • Dans le projet précédent, la boucle d’entraînement itérait directement sur les attributs globaux du modèle (modelo_cnn.parameters()) et calculait des métriques spécifiques en ligne.
    • La fonction treinar abstrait ce processus et introduit le paramètre optionnel parametros. S’il est fourni, l’optimiseur Adam met à jour uniquement les paramètres de cette liste, ignorant les couches dont les gradients ont été désactivés. Cette flexibilité est cruciale pour exécuter l’entraînement avec un gel partiel du réseau.
  4. Isolation de l’évaluation (calcular_acuracia) : Comme cela a été fait lors de la phase de test du projet précédent, la fonction place le modèle en mode eval() et utilise le contexte torch.no_grad() pour désactiver l’autograd, calculant la précision via .argmax(dim=1).

# Définition du bloc convolutif réutilisable (même extraction que le projet précédent)
class ExtratorConv(nn.Module):
    def __init__(self):
        super().__init__()
        self.conv1 = nn.Conv2d(1, 8, 3, padding=1)
        self.conv2 = nn.Conv2d(8, 16, 3, padding=1)
        self.pool = nn.MaxPool2d(2, 2)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.pool(self.relu(self.conv1(x)))
        x = self.pool(self.relu(self.conv2(x)))
        return x.view(x.size(0), -1)

# Architecture modulaire combinant l'extracteur et la tête de classification
class CNNCompleta(nn.Module):
    def __init__(self, n_classes=5):
        super().__init__()
        self.extrator = ExtratorConv()
        self.fc1 = nn.Linear(64, 32)
        self.fc2 = nn.Linear(32, n_classes)
        self.relu = nn.ReLU()

    def forward(self, x):
        x = self.extrator(x)
        x = self.relu(self.fc1(x))
        return self.fc2(x)

# Routine générique d'entraînement avec optimisation sélective des paramètres
def treinar(modelo, X_t, y_t, epocas, lr, tam_lote=16, parametros=None):
    # Paramètres entraînables
    params = parametros if parametros is not None else modelo.parameters()  
    otim = optim.Adam(params, lr=lr)               # Optimiseur Adam
    crit = nn.CrossEntropyLoss()                   # Fonction de perte
    n_amostras = X_t.size(0)                       # Nombre d'échantillons
    for _ in range(epocas):                        # Répète par époque
        perm = torch.randperm(n_amostras)          # Mélange les échantillons
        for i in range(0, n_amostras, tam_lote):   # Parcourt les mini-lots
            idx = perm[i:i + tam_lote]             # Indices du lot
            otim.zero_grad()                       # Met à zéro les gradients
            perda = crit(modelo(X_t[idx]), y_t[idx])  # Calcule la perte
            perda.backward()                       # Rétropropagation
            otim.step()                            # Met à jour les poids

# Routine d'évaluation
def calcular_acuracia(modelo, X_t, y_t):
    modelo.eval()                                  # Mode évaluation
    with torch.no_grad():                          # Sans gradients
        pred = modelo(X_t).argmax(dim=1)           # Classes prédites
    return (pred == y_t).float().mean().item()     # Retourne la précision
9.5.1.2.1.3 Bloco 3: Pré-entraînement, Transfert et Analyse Comparative

Ce bloc exécute la comparaison entre l’ajustement du modèle à partir de zéro et l’application du transfert avec gel statique de l’extracteur. Pour une transparence totale de l’expérience, les tailles des ensembles d’entraînement et de test des deux domaines sont imprimées dans le terminal.

  1. Quantification des échantillons par domaine :

    • Domaine A (source, chiffres \(0\) à \(4\)): Compte \(675\) échantillons d’entraînement (\(75\%\)) et \(226\) de test (\(25\%\)), fournissant des données abondantes pour que le modelo_origem apprenne l’extracteur convolutif jusqu’à atteindre \(100\%\) de précision.
    • Domaine B (cible, chiffres \(5\) à \(9\)): Dispose de \(224\) échantillons de test au total, mais son ensemble d’entraînement est intentionnellement réduit de \(672\) à seulement \(20\) échantillons (XB_tr_poucos), créant un scénario sévère de pénurie de données.
  2. Étape 1 : Pré-entraînement sur le Domaine A (Source) : Le modelo_origem est entraîné à partir de zéro sur les \(675\) échantillons des chiffres \(0\) à \(4\). Pendant \(40\) époques, l’extracteur convolutif ajuste ses filtres pour identifier les traits caractéristiques de ces cinq premiers chiffres, atteignant \(100\%\) de précision sur l’ensemble de test (\(226\) échantillons).

  3. Étape 2 : Transfert de poids et gel :

    • On crée le modelo_transferencia pour résoudre la tâche du Domaine B (chiffres \(5\) à \(9\)).

    • Les poids appris dans le Domaine A sont copiés via :

      • load_state_dict(modelo_origem.extrator.state_dict())
    • Gel : La boucle for p in modelo_transferencia.extrator.parameters(): p.requires_grad = False désactive le calcul des gradients dans les couches convolutives.

    • Entraînement sélectif : L’appel treinar(...) passe strictement les paramètres des couches denses (params_cabeca), ajustant la tête de classification avec seulement les \(20\) échantillons d’entraînement.

  4. Étape 3 : Entraînement à partir de zéro sur le Domaine B (Contrôle expérimental) : Le modelo_do_zero possède la même architecture, mais est entraîné à partir de zéro sur les mêmes \(20\) échantillons du Domaine B, sans aucune réutilisation de poids, pendant les mêmes \(40\) époques.

  5. Analyse des résultats (Figure 9.21) :

    • Avec transfert gelé (\(72,77\%\)): En réutilisant l’extracteur entraîné sur le Domaine A et en gelant ses paramètres, le réseau atteint \(72,77\%\) de précision sur le test (\(224\) échantillons) en ajustant uniquement les couches denses.
    • Entraîné à partir de zéro (\(76,79\%\)): L’entraînement à partir de zéro surpasse le transfert gelé sur l’ensemble de test du Domaine B.
    • Cause de la différence : En raison d’un modèle minuscule (seulement \(16\) filtres convolutifs dans des matrices de \(8 \times 8\)), l’extracteur entraîné sur le Domaine A est devenu hyperspécialisé dans les formes géométriques des chiffres \(0\) à \(4\). En gelant rigidement ces quelques filtres, le modèle cible s’est trouvé limité à des détecteurs inadéquats pour \(5\) à \(9\). Le réseau entraîné à partir de zéro, même avec seulement \(20\) échantillons, a réussi à adapter ses \(16\) filtres directement aux traits du Domaine B.
# Fixar semente para reprodutibilidade
torch.manual_seed(42)

# Exibição do tamanho dos grupos de treino e teste
print("=== Detalhamento do Tamanho das Bases ===")
print(f"Domínio A (0-4) — Treino: {len(XA_tr_t)} amostras | Teste: {len(XA_te_t)} amostras")
print(f"Domínio B (5-9) — Treino completo: {len(XB_tr)} | Treino reduzido: {len(XB_tr_poucos)}",
      f"| Teste: {len(XB_te_t)} amostras\n")

# 1. Pré-treinamento na tarefa de origem (Domínio A: dígitos 0-4)
modelo_origem = CNNCompleta(n_classes=5)                       # Cria CNN

#######
treinar(modelo_origem, XA_tr_t, yA_tr_t, epocas=40, lr=1e-2)   # Treina modelo
         
acc_A = calcular_acuracia(modelo_origem, XA_te_t, yA_te_t)     # Mede acurácia
                          
print(f"Acurácia no domínio de origem A "                      # Exibe resultado
      f"(dígitos 0-4, {len(XA_te_t)} testes): " f"{acc_A:.4f}")

# 2. Transferência de Aprendizado (Extrator Congelado)
modelo_transferencia = CNNCompleta(n_classes=5)        # Cria CNN
modelo_transferencia.extrator.load_state_dict(         # Copia extrator
    modelo_origem.extrator.state_dict())

for p in modelo_transferencia.extrator.parameters():   # Percorre extrator
    p.requires_grad = False                            # Congela pesos

params_cabeca = list(modelo_transferencia.fc1.parameters())  # FC1
params_cabeca += list(modelo_transferencia.fc2.parameters()) # +FC2

#######
treinar(modelo_transferencia, XB_tr_t, yB_tr_t,              # Treina cabeça
         epocas=40, lr=1e-2, parametros=params_cabeca)

acc_transferencia = calcular_acuracia(modelo_transferencia, XB_te_t, yB_te_t) # Mede acurácia

# 3. Treinamento do Zero no Domínio B
modelo_do_zero = CNNCompleta(n_classes=5)                     # Cria CNN

#######
treinar(modelo_do_zero, XB_tr_t, yB_tr_t, epocas=40, lr=1e-2) # Treina modelo
         
acc_do_zero = calcular_acuracia(modelo_do_zero,  XB_te_t, yB_te_t) # Mede acurácia
                               

print(f"Domínio de destino B (dígitos 5-9), apenas {len(XB_tr_poucos)} ", 
      f"exemplos de treino ({len(XB_te_t)} testes):")
print(f"  Com transferência (extrator congelado): {acc_transferencia:.4f}")
print(f"  Treinando do zero (mesmos dados/épocas): {acc_do_zero:.4f}")

# Visualização comparativa
plt.figure(figsize=(4.5, 4))
plt.bar(["Do zero", "Transferência"], [acc_do_zero, acc_transferencia], 
        color=["#dc2626", "#16a34a"])
plt.ylim(0, max([acc_do_zero, acc_transferencia]) + 0.1)
plt.ylabel("Acurácia no domínio B (teste)")
plt.title(f"Efeito da Transferência ({len(XB_tr_poucos)} exemplos de treino)")

for i, v in enumerate([acc_do_zero, acc_transferencia]):
    plt.text(i, v + 0.02, f"{v:.3f}", ha="center")

plt.tight_layout()
plt.show()
=== Detalhamento do Tamanho das Bases ===
Domínio A (0-4) — Treino: 675 amostras | Teste: 226 amostras
Domínio B (5-9) — Treino completo: 672 | Treino reduzido: 20 | Teste: 224 amostras

Acurácia no domínio de origem A (dígitos 0-4, 226 testes): 1.0000
Domínio de destino B (dígitos 5-9), apenas 20  exemplos de treino (224 testes):
  Com transferência (extrator congelado): 0.7277
  Treinando do zero (mesmos dados/épocas): 0.7679
Figure 9.21: Comparação de acurácia no conjunto de teste do Domínio B (dígitos 5 a 9) sob restrição de dados (20 exemplos de treino): demonstração do impacto do congelamento rígido e da transferência negativa em redes de baixa capacidade.
9.5.1.2.1.4 Bloco 4 : Visualisation du flux d’activations avec mm.showNet

Pour confirmer que l’extraction de caractéristiques réutilisée préserve les transformations dimensionnelles étudiées dans le projet précédent, on utilise à nouveau la fonction mm.showNet de la bibliothèque morph. La Figure 9.22 affiche le flux d’activations du modelo_transferencia lors du traitement d’un échantillon du Domaine B (chiffre \(7\), réindexé pour la classe \(2\)).

  1. Préservation du flux convolutionnel : Comme l’architecture ExtratorConv reproduit les mêmes couches de convolution et de pooling que la CNNDigitos du projet précédent, les dimensions des tensors intermédiaires restent en \((1, 8, 4, 4)\) dans le premier bloc.
  2. Inspection de la tête adaptée : La différence par rapport au projet précédent apparaît dans la couche de sortie (fc2) : alors que le modèle du projet précédent projetait le vecteur intermédiaire en \(10\) logits (classes de \(0\) à \(9\)), le modèle de transfert projette le vecteur en \(5\) logits (classes de \(0\) à \(4\)), capturant les probabilités relatives du Domaine B.
# Sélectionne le premier échantillon de test du Domaine B
x_amostra_B = XB_te_t[0:1]  # Tenseur de dimension (1, 1, 8, 8)
classe_verdadeira = yB_te_t[0].item()
classe_original = classe_verdadeira + 5

# Inspection du flux d'activations dans le modèle de transfert
acts_transfer = mm.showNet(
    modelo_transferencia,
    x_amostra_B,
    titulo="Fluxo de ativações no modelo de transferência (Domínio B)",
    subtitulo=f"Amostra do dígito {classe_original} (rótulo reindexado: {classe_verdadeira})",
)

print("Couches capturées dans le modèle de transfert :\n", list(acts_transfer.keys()))
Figure 9.22: Flux d’activations et transformations dimensionnelles des tenseurs dans le modèle de transfert d’apprentissage lors du traitement d’un échantillon de test du Domaine B (chiffre 7, réindexé en classe 2).
Couches capturées dans le modèle de transfert :
 ['extrator.conv1', 'extrator.pool', 'extrator.conv2', 'extrator.pool #2', 'fc1', 'fc2']
9.5.1.2.1.5 Analyse de l’expérience 1

Le modèle entraîné de zéro atteint une précision supérieure à celle du modèle avec transfert d’apprentissage et extracteur figé. Ce résultat caractérise un cas de transfert négatif (negative transfer) et découle de trois facteurs :

  1. Faible capacité : L’extracteur ne possède que \(16\) filtres \(3 \times 3\), insuffisants pour apprendre des représentations généralisables.

  2. Spécialisation au domaine : L’entraînement avec les chiffres \(0\) à \(4\) produit des filtres peu discriminants pour les chiffres \(5\) à \(9\).

  3. Absence d’adaptation : Le gel empêche l’extracteur d’ajuster ses filtres à la nouvelle tâche.

Note💡 Provocation pédagogique

Cette expérience utilise un petit extracteur entraîné sur un domaine restreint. Le résultat serait-il différent si l’extracteur avait appris ses représentations sur une base de millions d’images et une grande diversité d’objets ?

9.5.1.2.2 Expérience 2 — Quand le Transfert Fonctionne Vraiment (ResNet-18 Pré-entraînée)

La deuxième expérience reprend la même structure que la première — peu d’exemples d’entraînement, deux classes, comparaison entre stratégies —, mais remplace l’extracteur artisanal de \(16\) filtres par la ResNet-18, une architecture de \(18\) couches pré-entraînée sur ImageNet (\(1,4\) million d’images, \(1.000\) catégories), et le dataset synthétique de chiffres par des photographies réelles du Oxford-IIIT Pet Dataset (PARKHI, 2012).

La tâche : distinguer deux races canines — Carlin et Boxer — à partir de seulement \(15\) photographies d’entraînement par classe.

Astuce🐶 Pourquoi ce scénario ?

Le défi ici ne réside pas dans la similarité visuelle entre les races — le Carlin et le Boxer ont des tailles et des proportions bien distinctes —, mais dans la rareté des données : seulement \(30\) photographies réelles au total, sans aucune image synthétique. C’est le type de problème à faible budget de données qui motive, en pratique, l’utilisation de réseaux pré-entraînés : il n’y a ni le temps ni les ressources pour photographier et étiqueter des milliers de chiens avant d’entraîner un classificateur à partir de zéro.

9.5.1.2.2.1 Bloc 1 : Chargement du Jeu de Données Réel et Échantillonnage Parcimonieux
  1. Source : le Oxford-IIIT Pet Dataset (PARKHI, 2012) est chargé via torchvision.datasets.OxfordIIITPet, qui télécharge automatiquement les \(7.349\) photographies et leurs étiquettes de race lors de la première exécution.
  2. Filtrage : seules les deux races d’intérêt (Pug, Boxer) sont conservées.
  3. Parcimonie délibérée : seules \(15\) photographies d’entraînement par classe (\(30\) au total) sont tirées au sort — le reste constitue l’ensemble de test, utilisé exclusivement pour l’évaluation.

La Figure 9.23 affiche des échantillons d’entraînement de chaque race.

import random

RACAS_ALVO = ["Pug", "Boxer"]
N_TREINO_POR_CLASSE = 15
N_TESTE_POR_CLASSE = 20

# 1. Téléchargement du jeu de données complet (37 races) — licence CC BY-SA 4.0
pets_completo = OxfordIIITPet(
    root="dados_pets", split="trainval", target_types="category", download=True
)
nomes_racas = pets_completo.classes
indices_alvo = [nomes_racas.index(r) for r in RACAS_ALVO]

# 2. Filtrage des deux races d'intérêt, séparées par classe
por_classe = {idx: [] for idx in indices_alvo}
for img, lbl in pets_completo:
    if lbl in indices_alvo:
        por_classe[lbl].append(img)

# 3. Échantillonnage : peu d'images d'entraînement, plus d'images de test
rng = random.Random(42)
imgs_treino, y_treino, imgs_teste, y_teste = [], [], [], []
for classe_idx, idx_original in enumerate(indices_alvo):
    imgs_raca = por_classe[idx_original][:]
    rng.shuffle(imgs_raca)
    imgs_treino += imgs_raca[:N_TREINO_POR_CLASSE]
    y_treino += [classe_idx] * N_TREINO_POR_CLASSE
    imgs_teste += imgs_raca[N_TREINO_POR_CLASSE : N_TREINO_POR_CLASSE + N_TESTE_POR_CLASSE]
    y_teste += [classe_idx] * N_TESTE_POR_CLASSE

print(f"Entraînement : {len(imgs_treino)} images | Test : {len(imgs_teste)} images")

amostras_pil = imgs_treino[:4] + imgs_treino[N_TREINO_POR_CLASSE:N_TREINO_POR_CLASSE + 4]
amostras_exibicao = [np.array(img.convert("RGB")) for img in amostras_pil]  # PIL -> ndarray
titulos_exibicao = [RACAS_ALVO[0]] * 4 + [RACAS_ALVO[1]] * 4
mm.show(amostras_exibicao, titles=titulos_exibicao, cols=4, figsize=(11, 6))
Figure 9.23
9.5.1.2.2.2 Bloc 2 : Trois stratégies sur la même architecture

Pour isoler l’effet du transfert d’apprentissage, les trois stratégies réutilisent exactement la même architecture (ResNet-18), en ne variant que l’origine des poids et les paramètres qui restent entraînables :

  1. do_zero : poids aléatoires (weights=None) — équivalent à entraîner l’architecture de la ResNet-18 entièrement de zéro, comme dans le Bloc 2 de l’Expérience 1.
  2. congelado : poids pré-entraînés sur ImageNet, avec requires_grad = False sur toutes les couches convolutionnelles — seule la nouvelle couche finale est entraînée.
  3. fine_tuning : poids pré-entraînés sur ImageNet comme point de départ, mais sans gel — tout le réseau s’adapte au nouveau domaine, avec un taux d’apprentissage faible pour ne pas détruire les connaissances préalables.

Dans tous les cas, la couche finale fc est remplacée par nn.Linear(fc.in_features, 2), correspondant aux deux races cibles.

transformacao_resnet = T.Compose([
    T.Resize((224, 224)),
    T.ToTensor(),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])

def prepara_tensores(imgs, labels):
    X = torch.stack([transformacao_resnet(img.convert("RGB")) for img in imgs])
    y = torch.tensor(labels, dtype=torch.long)
    return X, y

X_tr, y_tr = prepara_tensores(imgs_treino, y_treino)
X_te, y_te = prepara_tensores(imgs_teste, y_teste)

def cria_modelo_pets(estrategia):
    pesos = None if estrategia == "do_zero" else models.ResNet18_Weights.DEFAULT
    modelo = models.resnet18(weights=pesos)
    if estrategia == "congelado":
        for p in modelo.parameters():
            p.requires_grad = False
    modelo.fc = nn.Linear(modelo.fc.in_features, len(RACAS_ALVO))
    return modelo

modelo_do_zero = cria_modelo_pets("do_zero")
modelo_congelado = cria_modelo_pets("congelado")
modelo_fine_tuning = cria_modelo_pets("fine_tuning")
9.5.1.2.2.3 Bloc 3 : Entraînement comparatif et analyse de la précision

En réutilisant les fonctions génériques treinar et calcular_acuracia, définies dans le Bloc 2 de l’Expérience 1, les trois modèles sont entraînés sur le même ensemble de \(30\) photographies et évalués sur l’ensemble de test (images jamais vues pendant l’entraînement) :

  • Le modèle do_zero a tendance à surajuster rapidement aux \(30\) photographies d’entraînement, sans généraliser à l’ensemble de test — \(30\) exemples sont drastiquement insuffisants pour ajuster les \(11\) millions de paramètres de la ResNet-18 à partir de zéro.
  • Le modèle congelado devrait déjà atteindre une précision considérablement supérieure, car il réutilise, sans aucun ajustement, des caractéristiques visuelles génériques (bords, textures, contours) apprises sur ImageNet — seule la nouvelle couche linéaire doit être ajustée aux \(30\) photographies.
  • Le modèle fine_tuning tend à égaler ou à dépasser l’extracteur figé, car il part des mêmes connaissances préalables, mais permet en outre un ajustement fin de tout le réseau aux particularités visuelles des races.

La Figure 9.24 résume les trois résultats.

torch.manual_seed(42)

configuracoes = [
    ("Do zero",              modelo_do_zero,      None, 2e-3),
    ("Extrator congelado",   modelo_congelado,    "fc", 1e-3),
    ("Fine-tuning completo", modelo_fine_tuning,  None, 1e-4),
]

resultados_pets = {}
for nome, modelo, alvo_params, taxa in configuracoes:
    parametros = modelo.fc.parameters() if alvo_params == "fc" else None
    treinar(modelo, X_tr, y_tr, epocas=15, lr=taxa, tam_lote=8, parametros=parametros)
    resultados_pets[nome] = calcular_acuracia(modelo, X_te, y_te)
    print(f"{nome}: {resultados_pets[nome]*100:.1f}%")

plt.figure(figsize=(5.5, 4))
cores = ["#dc2626", "#f59e0b", "#16a34a"]
plt.bar(resultados_pets.keys(), resultados_pets.values(), color=cores)
plt.ylim(0, 1.05)
plt.axhline(0.5, color="gray", linestyle="--", linewidth=1, label="Chute aleatório (50%)")
plt.ylabel("Acurácia no teste")
plt.title("Pug vs. Boxer — 15 fotos de treino/classe")
for i, v in enumerate(resultados_pets.values()):
    plt.text(i, v + 0.02, f"{v*100:.1f}%", ha="center")
plt.xticks(rotation=10)
plt.legend()
plt.tight_layout()
plt.show()
Figure 9.24
9.5.1.2.2.4 Bloc 4 : Inspection qualitative des prédictions

Comme dans l’Expérience 1 et dans la section suivante sur le diagnostic foliaire, il est instructif d’observer individuellement certaines prédictions du meilleur modèle (typiquement fine_tuning ou congelado) sur des photographies réelles de test, en comparant l’étiquette prédite avec la race réelle.

melhor_modelo = modelo_fine_tuning  # ou modelo_congelado, selon le résultat du Bloc 3
melhor_modelo.eval()

idx_amostras = list(range(4)) + list(range(N_TESTE_POR_CLASSE, N_TESTE_POR_CLASSE + 4))

imgs_pred, titulos_pred = [], []
with torch.no_grad():
    for idx in idx_amostras:
        entrada = X_te[idx].unsqueeze(0)
        pred_idx = melhor_modelo(entrada).argmax(dim=1).item()
        real_idx = y_te[idx].item()
        marcador = "✓" if pred_idx == real_idx else "✗"
        imgs_pred.append(np.array(imgs_teste[idx].convert("RGB")))  # PIL -> ndarray
        titulos_pred.append(f"{marcador} previsto: {RACAS_ALVO[pred_idx]}\n"
                             f"real: {RACAS_ALVO[real_idx]}")

mm.show(imgs_pred, titles=titulos_pred, cols=4, figsize=(12, 7))
Figure 9.25
# Nettoyage explicite des données téléchargées et libération de la mémoire
if FLAG_LIMPAR_DADOS:
    if os.path.exists('./dados_pets'):
        shutil.rmtree('./dados_pets')
        print('🧹 Répertoire de données temporaires ./dados_pets supprimé avec succès.')

    if torch.cuda.is_available():
        torch.cuda.empty_cache()
9.5.1.2.3 Expérience 3 — Diagnostic phytosanitaire par apprentissage par transfert

L’expérience précédente a montré qu’une ResNet-18 pré-entraînée sur ImageNet peut s’adapter à une nouvelle tâche en utilisant peu d’échantillons. Désormais, la même stratégie est appliquée à un problème de diagnostic phytosanitaire. La ResNet-18 doit classer des images de feuilles en trois catégories : ["folha_saudavel", "folha_doente", "sintoma_desconhecido"].

  • folha_saudavel : feuille sans lésions visibles.
  • folha_doente : feuille présentant des taches sombres simulant une maladie fongique.
  • sintoma_desconhecido : feuille avec chlorose jaunâtre, représentant un motif différent de la maladie connue.
Astuce🌱 Pourquoi ce scénario ?

Le diagnostic phytosanitaire constitue une application importante de la vision par ordinateur (VC) dans l’agriculture de précision. Un modèle pré-entraîné sur ImageNet peut réutiliser des caractéristiques telles que les bords, les textures et les motifs de couleur pour apprendre cette nouvelle tâche avec peu d’images.

9.5.1.2.3.1 Bloc 1 : Génération du dataset synthétique

Ce bloc génère un ensemble synthétique avec 30 images par classe pour l’entraînement et 8 pour la validation, totalisant respectivement 90 et 24 images.

  1. Génération de la feuille : La fonction desenha_folha_base crée le contour de la feuille, en variant la taille, l’orientation et la tonalité de vert.

  2. Simulation de la maladie : La fonction aplica_manchas_doenca ajoute des taches sombres irrégulières simulant des lésions fongiques.

  3. Simulation d’un autre symptôme : La fonction aplica_sintoma_desconhecido ajoute des régions jaunâtres représentant un motif distinct de la maladie connue.

  4. Visualisation des échantillons : La Figure 9.26 présente des exemples des trois catégories de l’ensemble synthétique.

CLASSES_FOLHA = ["folha_saudavel", "folha_doente", "sintoma_desconhecido"]


def desenha_folha_base(tam_img, rng):
    '''Dessine le contour ovale d'une feuille verte avec nervure centrale,
    avec de petites variations de teinte, de taille et d'orientation entre les échantillons.'''
    img = np.full((tam_img, tam_img, 3), 245, dtype=np.uint8)  # fond clair
    cx, cy = tam_img // 2, tam_img // 2
    eixo_a = rng.randint(int(tam_img * 0.30), int(tam_img * 0.38))
    eixo_b = rng.randint(int(tam_img * 0.20), int(tam_img * 0.26))
    angulo = rng.uniform(-15, 15)
    verde = (rng.randint(40, 70), rng.randint(120, 160), rng.randint(40, 70))
    cv2.ellipse(img, (cx, cy), (eixo_a, eixo_b), angulo, 0, 360, verde, -1, cv2.LINE_AA)
    ang_rad = np.deg2rad(angulo)
    dx, dy = np.cos(ang_rad), np.sin(ang_rad)
    p1 = (int(cx - eixo_a * dx), int(cy - eixo_a * dy))
    p2 = (int(cx + eixo_a * dx), int(cy + eixo_a * dy))
    cv2.line(img, p1, p2, (25, 90, 25), 2, cv2.LINE_AA)  # nervure centrale
    return img, (cx, cy, eixo_a, eixo_b, angulo)


def aplica_manchas_doenca(img, centro_folha, rng, n_manchas=(4, 8)):
    '''Simule des lésions foliaires : taches sombres aux bords irréguliers
    (motif typique des maladies fongiques).'''
    cx, cy, eixo_a, eixo_b, _ = centro_folha
    for _ in range(rng.randint(*n_manchas)):
        raio = rng.randint(1, 3)
        px = cx + rng.randint(-int(eixo_a * 0.7), int(eixo_a * 0.7))
        py = cy + rng.randint(-int(eixo_b * 0.7), int(eixo_b * 0.7))
        cor_mancha = (rng.randint(50, 90), rng.randint(25, 45), rng.randint(10, 25))
        cv2.circle(img, (px, py), raio, cor_mancha, -1, cv2.LINE_AA)
        cv2.circle(img, (px, py), raio + 1, (120, 85, 30), 1, cv2.LINE_AA)  # halo
    return img


def aplica_sintoma_desconhecido(img, centro_folha, rng):
    '''Simule un motif distinct (marbrures jaunâtres/chlorose), différent
    des taches sombres de la maladie connue.'''
    cx, cy, eixo_a, eixo_b, _ = centro_folha
    for _ in range(rng.randint(3, 5)):
        eixo_m = (rng.randint(1, 3), rng.randint(2, 3))
        px = cx + rng.randint(-int(eixo_a * 0.6), int(eixo_a * 0.6))
        py = cy + rng.randint(-int(eixo_b * 0.6), int(eixo_b * 0.6))
        cor_clorose = (rng.randint(200, 235), rng.randint(195, 225), rng.randint(50, 90))
        ang_m = rng.uniform(0, 180)
        cv2.ellipse(img, (px, py), eixo_m, ang_m, 0, 360, cor_clorose, -1, cv2.LINE_AA)
    return img


def aplica_ruido_sal_pimenta(img, prop_ruido=0.02, rng=None):
    '''Applique du bruit sel (points blancs) et poivre (points noirs) aléatoires.
    prop_bruit : fraction de pixels modifiés (ex : 0.02 = 2 % des pixels).'''
    if prop_ruido <= 0:
        return img
    
    img_ruido = img.copy()
    num_pixels = int(prop_ruido * img.shape[0] * img.shape[1])
    n_sal = num_pixels // 2
    n_pimenta = num_pixels - n_sal

    # Applique Sel (Blanc - [255, 255, 255])
    for _ in range(n_sal):
        y = rng.randint(0, img.shape[0] - 1)
        x = rng.randint(0, img.shape[1] - 1)
        img_ruido[y, x] = [255, 255, 255]

    # Applique Poivre (Noir - [0, 0, 0])
    for _ in range(n_pimenta):
        y = rng.randint(0, img.shape[0] - 1)
        x = rng.randint(0, img.shape[1] - 1)
        img_ruido[y, x] = [0, 0, 0]

    return img_ruido


def gera_folha(classe_idx, tam_img=128, rng=None, prop_ruido=0.02):
    rng = rng or random.Random()
    img, geometria = desenha_folha_base(tam_img, rng)
    nome = CLASSES_FOLHA[classe_idx]
    
    if nome == "folha_doente":
        img = aplica_manchas_doenca(img, geometria, rng)
    elif nome == "sintoma_desconhecido":
        img = aplica_sintoma_desconhecido(img, geometria, rng)
        
    ruido_exp = rng.randint(-3, 3)  # légère variation d'exposition
    img = np.clip(img.astype(np.int16) + ruido_exp, 0, 255).astype(np.uint8)
    
    # Application du bruit Sel et Poivre
    img = aplica_ruido_sal_pimenta(img, prop_ruido=prop_ruido, rng=rng)
    
    return img


def gera_conjunto(n_por_classe, tam_img=128, seed=0, prop_ruido=0.02):
    rng = random.Random(seed)
    imgs, labels = [], []
    for classe_idx in range(len(CLASSES_FOLHA)):
        for _ in range(n_por_classe):
          imgs.append(gera_folha(classe_idx, tam_img=tam_img, rng=rng, prop_ruido=prop_ruido))
          labels.append(classe_idx)
    return imgs, labels


N_POR_CLASSE_TREINO, N_POR_CLASSE_VAL = 30, 8

imgs_treino, labels_treino = gera_conjunto(n_por_classe=N_POR_CLASSE_TREINO, seed=42, 
                                           prop_ruido=0.02)
imgs_val, labels_val = gera_conjunto(n_por_classe=N_POR_CLASSE_VAL, seed=123, prop_ruido=0.02)

print(f"Entraînement : {len(imgs_treino)} images ({N_POR_CLASSE_TREINO} par classe) | "
      f"Validation : {len(imgs_val)} images ({N_POR_CLASSE_VAL} par classe)")

# Affichage de 2 échantillons de chaque classe (6 images au total)
amostras_exibir, titulos_exibir = [], []
for classe_idx, nome in enumerate(CLASSES_FOLHA):
    for k in range(2):
        idx = classe_idx * N_POR_CLASSE_TREINO + k
        amostras_exibir.append(imgs_treino[idx])
        titulos_exibir.append(nome)

mm.show(amostras_exibir, titles=titulos_exibir, cols=3, figsize=(10, 7))
Entraînement : 90 images (30 par classe) | Validation : 24 images (8 par classe)
Figure 9.26: Échantillons synthétiques du dataset de diagnostic foliaire : feuille saine, feuille malade (taches sombres) et symptôme inconnu (chlorose jaunâtre) avec bruit sel et poivre.
Note🧠 Piège Fréquent

Le transfert d’apprentissage exige que chaque classe présente des motifs visuels distincts. Répéter la même image avec des étiquettes différentes empêche la couche de classification d’apprendre une frontière de décision, car l’extracteur génère pratiquement les mêmes caractéristiques pour tous les échantillons.

Dans cette expérience, chaque image est générée de manière indépendante, avec des motifs visuels compatibles avec sa classe (feuille saine, lésions fongiques ou chlorose), fournissant ainsi des informations suffisantes pour l’entraînement de la couche de classification.

9.5.1.2.3.2 Bloco 2: Préparation des tenseurs et adaptation de l’architecture

Des modèles comme ResNet-18 exigent des images colorées de \(224 \times 224\) pixels normalisées selon les statistiques d’ImageNet (\(\mu = [0,485; 0,456; 0,406]\) et \(\sigma = [0,229; 0,224; 0,225]\)).

  1. Transformation d’entrée (transforms.Compose): on applique le redimensionnement et la normalisation standard à chaque image du dataset synthétique, produisant les tenseurs X_treino/X_val et les étiquettes y_treino/y_val — chaque exemple est une image réellement distincte, associée à l’étiquette correcte de sa classe.
  2. Gel de l’extracteur: la boucle for p in modelo_resnet.parameters(): p.requires_grad = False désactive les gradients dans les couches convolutionnelles pré-entraînées.
  3. Nouvelle couche finale: la couche modelo_resnet.fc est remplacée par une nouvelle instance nn.Linear(modelo_resnet.fc.in_features, n_classes_destino), récemment initialisée et avec des gradients actifs par défaut. Le nombre de caractéristiques d’entrée est obtenu dynamiquement à partir de la couche originale elle-même (in_features, égal à \(512\) dans ResNet-18), plutôt que d’être fixé manuellement dans le code — pratique recommandée, car elle rend le fragment réutilisable pour d’autres variantes de l’architecture sans modifications.
# 1. Pipeline de transformations attendues par ResNet
transformacao_resnet = T.Compose([
    T.Resize((224, 224)),
    T.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])


def prepara_tensores(imgs, labels):
    tensores = [transformacao_resnet(T.functional.to_tensor(img)) for img in imgs]
    X = torch.stack(tensores)
    y = torch.tensor(labels, dtype=torch.long)
    return X, y


# 2. Conversion du dataset synthétique (Bloc 1) en tenseurs normalisés
X_treino, y_treino = prepara_tensores(imgs_treino, labels_treino)
X_val, y_val = prepara_tensores(imgs_val, labels_val)

loader_treino = DataLoader(TensorDataset(X_treino, y_treino), batch_size=16, shuffle=True)
loader_val = DataLoader(TensorDataset(X_val, y_val), batch_size=16, shuffle=False)

# 3. Chargement de ResNet-18 pré-entraînée et gel de l'extracteur
n_classes_destino = len(CLASSES_FOLHA)
modelo_resnet = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)

for parametro in modelo_resnet.parameters():
    parametro.requires_grad = False

# 4. Remplacement de la couche finale pour les 3 nouvelles classes cibles
modelo_resnet.fc = nn.Linear(modelo_resnet.fc.in_features, n_classes_destino)

print(f"Nouvelle couche finale : {modelo_resnet.fc}")
9.5.1.2.3.3 Bloco 3: Boucle d’Entraînement et Évaluation

Avec l’extracteur figé et la nouvelle couche de sortie correctement intégrée, on exécute l’ajustement fin (fine-tuning) de la nouvelle tête de classification.

  1. Optimisation ciblée : l’optimiseur Adam reçoit strictement modelo_resnet.fc.parameters(), ne mettant à jour que la nouvelle couche de sortie — le reste du réseau reste figé, comme défini dans le Bloco 2.
  2. Exécution de la boucle : à chaque époque, le modèle itère sur les lots d’entraînement, calcule la perte par entropie croisée et ajuste les poids de la couche finale ; ensuite, on évalue la précision sur l’ensemble de validation (images jamais vues durant l’entraînement).
  3. Courbes d’entraînement : la Figure 9.27 suit l’évolution de la perte d’entraînement et de la précision de validation au fil des époques — comme les trois classes sont visuellement distinctes entre elles, on s’attend à une convergence réelle, bien au-dessus du seuil de \(33\%\) correspondant à un choix aléatoire parmi \(3\) classes.
dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu")
modelo_resnet = modelo_resnet.to(dispositivo)

criterio = nn.CrossEntropyLoss()
otimizador = optim.Adam(modelo_resnet.fc.parameters(), lr=1e-3)

historico_perda, historico_acc = [], []
epocas = 10

for epoca in range(epocas):
    modelo_resnet.train()
    perda_acumulada, n_batches = 0.0, 0

    for X_batch, y_batch in loader_treino:
        X_batch, y_batch = X_batch.to(dispositivo), y_batch.to(dispositivo)

        otimizador.zero_grad()
        saidas = modelo_resnet(X_batch)
        perda = criterio(saidas, y_batch)
        perda.backward()
        otimizador.step()

        perda_acumulada += perda.item()
        n_batches += 1
    historico_perda.append(perda_acumulada / n_batches)

    modelo_resnet.eval()
    acertos, total = 0, 0
    with torch.no_grad():
        for X_batch, y_batch in loader_val:
            X_batch, y_batch = X_batch.to(dispositivo), y_batch.to(dispositivo)
            predicoes = modelo_resnet(X_batch).argmax(dim=1)
            acertos += (predicoes == y_batch).sum().item()
            total += y_batch.size(0)
    acc = acertos / total
    historico_acc.append(acc)

    print(f"Époque {epoca+1}/{epocas} — perte : {historico_perda[-1]:.4f} — ",
          f" précision_val : {acc*100:.1f}%")

f=mm.showTrainCurves(
    historico_perda, historico_acc,
    titulo="Fine-Tuning da ResNet-18 — Diagnóstico Foliar",
    subtitulo="Apenas a nova camada linear (fc) é treinada; o extrator permanece congelado",
)
Figure 9.27
9.5.1.2.3.4 Bloco 4: Inspeção Qualitativa das Previsões

Além da curva de precisão agregada, é instrutivo observar individualmente algumas previsões do modelo no conjunto de validação, comparando o rótulo previsto com o rótulo real. A Figure 9.28 exibe duas amostras de cada classe.

modelo_resnet.eval()

# Deux échantillons de chaque classe dans l'ensemble de validation
idx_amostras = [0, N_POR_CLASSE_VAL, 2 * N_POR_CLASSE_VAL,
                1, N_POR_CLASSE_VAL + 1, 2 * N_POR_CLASSE_VAL + 1]

imgs_pred, titulos_pred = [], []
with torch.no_grad():
    for idx in idx_amostras:
        entrada = X_val[idx].unsqueeze(0).to(dispositivo)
        pred_idx = modelo_resnet(entrada).argmax(dim=1).item()
        real_idx = y_val[idx].item()
        marcador = "✓" if pred_idx == real_idx else "✗"
        imgs_pred.append(imgs_val[idx])
        titulos_pred.append(f"{marcador} previsto: {CLASSES_FOLHA[pred_idx]}\n"+
                            f"real: {CLASSES_FOLHA[real_idx]}")

mm.show(imgs_pred, titles=titulos_pred, cols=3, figsize=(10, 7))
Figure 9.28
9.5.1.2.3.5 Analyse de l’expérience 3

La ResNet-18 atteint une haute précision même en utilisant un ensemble réduit d’images synthétiques. Ce résultat montre que les représentations apprises sur ImageNet restent utiles dans un domaine complètement différent, ne nécessitant que l’adaptation de la couche de classification.

L’expérience illustre également une situation courante dans les applications réelles, où la disponibilité de données étiquetées est limitée. Dans ces scénarios, le transfert d’apprentissage réduit le temps d’entraînement et permet d’obtenir des modèles performants même sans entraîner l’ensemble du réseau.

Note🧠 Synthèse comparative — Quand le transfert d’apprentissage fonctionne-t-il ?

Les trois expériences montrent que le transfert d’apprentissage dépend de la capacité de généralisation de l’extracteur de caractéristiques.

  • Expérience 1 : un petit extracteur, entraîné sur un domaine restreint, apprend des représentations peu généralisables et peut produire un transfert négatif.

  • Expérience 2 : une ResNet-18 pré-entraînée sur ImageNet transfère des représentations générales à une tâche de classification de races de chiens et de chats, atteignant une haute précision avec peu d’échantillons.

  • Expérience 3 : la même stratégie adapte le modèle à un problème de diagnostic phytosanitaire, montrant qu’un seul extracteur peut servir de base à différents domaines d’application.

9.5.1.2.4 Comparaison des Approches de Transfert

La Table 9.2 résume les résultats obtenus lors des trois expériences.

Table 9.2: Comparaison entre les trois scénarios de transfert d’apprentissage présentés dans cette section.
Aspect Expérience 1 Expérience 2 Expérience 3
Extracteur Petit CNN ResNet-18 ResNet-18
Entraînement de l’extracteur Chiffres (\(0\)–\(4\)) ImageNet ImageNet
Capacité de généralisation Faible Élevée Élevée
Nouvelle tâche Chiffres (\(5\)–\(9\)) Races de chiens et de chats Diagnostic phytosanitaire
Résultat Transfert négatif Transfert positif Transfert positif

9.5.2 Détection d’objets

Les expériences précédentes ont montré comment le transfert d’apprentissage adapte des modèles pré-entraînés à des tâches de classification d’images. Le même principe sous-tend également les architectures de détection d’objets, où un extracteur de caractéristiques pré-entraîné fournit des représentations visuelles générales, tandis que des modules spécialisés localisent et classifient les objets dans l’image.

Les sections suivantes présentent la Faster R-CNN comme exemple de détecteur pré-entraîné utilisé directement pour l’inférence, puis une expérience complète d’ajustement fin avec l’architecture YOLO.

9.5.2.1 Faster R-CNN : Détecteur pré-entraîné

La détection d’objets étend l’utilisation de modèles pré-entraînés à une tâche plus complexe que la classification. La Faster R-CNN utilise une CNN pré-entraînée, comme la ResNet-50, comme extracteur de caractéristiques (backbone) et ajoute des modules spécialisés pour localiser et classifier les objets.

Concernant cet extracteur, l’architecture intègre deux « têtes » principales :

  • Region Proposal Network (RPN) : propose des régions de l’image ayant une forte probabilité de contenir des objets.
  • Tête de classification : affine ces régions, attribue une classe à chaque objet et ajuste ses boîtes englobantes.

Le code suivant utilise une Faster R-CNN avec des poids pré-entraînés sur COCO pour détecter des objets dans une image, produisant leurs classes, coordonnées et scores de confiance.

Note🔍 Où se trouve le transfert d’apprentissage ici ?

Contrairement aux expériences précédentes, cet exemple n’effectue pas d’ajustement fin (fine-tuning). Le modèle exécute uniquement l’inférence (eval()), réutilisant directement les poids du backbone, de la RPN et de la tête de classification entraînés sur COCO.

L’adaptation à un nouveau domaine exigerait de remplacer la couche box_predictor par une nouvelle tête de classification, compatible avec les classes de l’application, et de l’entraîner sur un ensemble d’images annotées. Cette procédure suit le même principe présenté dans la section sur le transfert d’apprentissage et constitue le flux habituel pour des applications spécifiques, telles que la détection de nuisibles, de défauts de fabrication ou de véhicules.

  1. Catégories COCO : Le code récupère les noms des classes à partir des méta-informations des poids (FasterRCNN_ResNet50_FPN_Weights.DEFAULT.meta["categories"]). Bien que cette liste contienne \(91\) entrées pour des raisons historiques liées au format d’annotation de COCO, seules \(80\) correspondent à des catégories d’objets.

  2. Inférence : L’image chargée par mm.read() est convertie en tensor et traitée par le modèle en mode évaluation (eval()). Le code ne conserve que les détections dont la confiance est supérieure à \(80\%\).

  3. Annotation de l’image : Pour chaque objet détecté, le code dessine la boîte englobante (cv2.rectangle) et écrit la classe prédite ainsi que sa confiance (cv2.putText).

  4. Visualisation : La Figure 9.29 présente l’image annotée avec les détections réalisées par le modèle.

# 1. Chargement de l'image et des noms des catégories du COCO
url_imagem = "https://raw.githubusercontent.com/pytorch/hub/master/images/dog.jpg"
url_imagem = "http://images.cocodataset.org/val2017/000000039769.jpg"
img = mm.read(url_imagem)

pesos_coco = FasterRCNN_ResNet50_FPN_Weights.DEFAULT
categorias_coco = pesos_coco.meta["categories"]  # Mappage indice -> nom de la classe

# 2. Chargement du modèle Faster R-CNN pré-entraîné
modelo_detection = fasterrcnn_resnet50_fpn(weights=pesos_coco).eval()

# 3. Exécution de l'inférence sans calcul de gradients
with torch.no_grad():
    predicao = modelo_detection([to_tensor(img)])[0]

# 4. Filtrage des détections avec une confiance supérieure à 80%
limiar_confianca = 0.8
mascara_confianca = predicao["scores"] >= limiar_confianca

caixas_filtradas = predicao["boxes"][mascara_confianca].numpy()
scores_filtrados = predicao["scores"][mascara_confianca].numpy()
labels_filtrados = predicao["labels"][mascara_confianca].numpy()

img_com_caixas = img.copy()

# 5. Dessin des boîtes englobantes et des étiquettes de classe
for box, score, label_idx in zip(caixas_filtradas, scores_filtrados, labels_filtrados):
    x1, y1, x2, y2 = box.astype(int)
    nome_classe = categorias_coco[label_idx]
    texto_rotulo = f"{nome_classe}: {score:.2f}"

    # Dessine le rectangle rouge (RGB : 255, 0, 0) avec une épaisseur de 3 pixels
    cv2.rectangle(img_com_caixas, (x1, y1), (x2, y2), (255, 0, 0), 3)

    # Écrit la classe et la confiance au-dessus de la boîte englobante
    cv2.putText(
        img_com_caixas,
        texto_rotulo,
        (x1, max(y1 - 10, 20)),
        cv2.FONT_HERSHEY_SIMPLEX,
        0.8,
        (255, 0, 0),
        2,
        cv2.LINE_AA,
    )

# 6. Affichage graphique de l'image résultante
mm.show(img_com_caixas, title="Faster R-CNN (COCO) — Détection avec classe et confiance")
Figure 9.29

9.5.2.2 Détection d’objets et transfert d’apprentissage avec YOLO

Les sections précédentes ont appliqué le transfert d’apprentissage à des problèmes de classification d’images, où le modèle associe une seule étiquette à l’image entière. Dans cette section, le même principe est étendu à la détection d’objets, une tâche qui exige d’identifier simultanément ce qui est présent dans l’image et où se trouve chaque objet.

La sous-section précédente a présenté la Faster R-CNN comme exemple de détecteur pré-entraîné utilisé directement pour l’inférence, sans aucune adaptation au nouveau domaine. Dans cette expérience, le modèle passe par une étape d’ajustement fin (fine-tuning) : on part d’une architecture YOLO (You Only Look Once) pré-entraînée sur l’ensemble COCO et on adapte le réseau pour détecter et classer les objets d’un nouveau domaine.

Contrairement à la Faster R-CNN, qui effectue la détection en deux étapes, la famille YOLO adopte une architecture à étape unique (single-stage detector), estimant, en une seule propagation à travers le réseau, les boîtes englobantes (bounding boxes), la confiance de chaque détection et la classe correspondante. Cette stratégie réduit le coût computationnel et rend possibles des applications en temps réel.

À titre d’exemple, l’expérience utilise un ensemble synthétique de formes géométriques (triangles, carrés, étoiles, entre autres), avec des variations de couleur, de taille, de rotation et une dégradation par bruit de type sel et poivre.

9.5.2.2.1 Bloc 1 : Génération du dataset synthétique

Le bloc suivant génère un ensemble synthétique pour l’entraînement et l’évaluation du détecteur. Chaque image contient entre un et trois objets appartenant à l’une des neuf classes :

CLASSES = [
    'Triangle', 'Square', 'Pentagon', 'Hexagon',
    'Heptagon', 'Circle', 'Ellipse', 'Star', 'Cross'
]
  1. Génération des formes : Les fonctions poligono_regular, poligono_estrela et poligono_cruz construisent les coordonnées des objets. La fonction desenha_objeto dessine chaque forme avec une position, une taille, une orientation et une couleur aléatoires et calcule sa bounding box.

  2. Annotation au format YOLO : La fonction gera_imagem_ruidosa génère entre un et trois objets par image et convertit chaque bounding box au format YOLO, représenté par la classe et les coordonnées normalisées du centre, de la largeur et de la hauteur.

  3. Dégradation de l’image : La fonction adiciona_ruido_sal_pimenta ajoute un bruit impulsif, simulant des imperfections d’acquisition.

  4. Visualisation des échantillons : La Figure 9.30 présente des exemples de l’ensemble synthétique avec les bounding boxes superposées par la fonction mm.showBoundBox().

CLASSES = [
    'Triangle', 'Square', 'Pentagon', 'Hexagon',
    'Heptagon', 'Circle', 'Ellipse', 'Star', 'Cross'
]
N_LADOS = {'Triangle': 3, 'Square': 4, 'Pentagon': 5, 'Hexagon': 6, 'Heptagon': 7}

def poligono_regular(cx, cy, r, n_lados, rot_graus):
    ang0 = np.deg2rad(rot_graus - 90)
    angs = ang0 + 2 * np.pi * np.arange(n_lados) / n_lados
    return np.stack([cx + r * np.cos(angs), cy + r * np.sin(angs)], axis=1)

def poligono_estrela(cx, cy, r_externo, rot_graus, n_pontas=5):
    r_interno = r_externo * 0.45
    ang0 = np.deg2rad(rot_graus - 90)
    angs = ang0 + np.pi * np.arange(2 * n_pontas) / n_pontas
    raios = np.where(np.arange(2 * n_pontas) % 2 == 0, r_externo, r_interno)
    return np.stack([cx + raios * np.cos(angs), cy + raios * np.sin(angs)], axis=1)

def poligono_cruz(cx, cy, r, rot_graus, espessura_rel=0.35):
    w = r * espessura_rel
    base = np.array([
        (-w, -r), (w, -r), (w, -w), (r, -w), (r, w), (w, w),
        (w, r), (-w, r), (-w, w), (-r, w), (-r, -w), (-w, -w),
    ])
    theta = np.deg2rad(rot_graus)
    R = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])
    return base @ R.T + np.array([cx, cy])

def desenha_objeto(img, classe_idx, cx, cy, tamanho, rotacao, cor):
    nome = CLASSES[classe_idx]
    if nome in N_LADOS:
        pts = poligono_regular(cx, cy, tamanho, N_LADOS[nome], rotacao)
        cv2.fillPoly(img, [pts.astype(np.int32)], cor)
        xs, ys = pts[:, 0], pts[:, 1]
    elif nome == 'Star':
        pts = poligono_estrela(cx, cy, tamanho, rotacao)
        cv2.fillPoly(img, [pts.astype(np.int32)], cor)
        xs, ys = pts[:, 0], pts[:, 1]
    elif nome == 'Cross':
        pts = poligono_cruz(cx, cy, tamanho, rotacao)
        cv2.fillPoly(img, [pts.astype(np.int32)], cor)
        xs, ys = pts[:, 0], pts[:, 1]
    elif nome == 'Circle':
        cv2.circle(img, (int(cx), int(cy)), int(tamanho), cor, -1)
        xs, ys = np.array([cx - tamanho, cx + tamanho]), np.array([cy - tamanho, cy + tamanho])
    else:  # Ellipse
        eixo = (int(tamanho), int(tamanho * 0.6))
        cv2.ellipse(img, (int(cx), int(cy)), eixo, rotacao, 0, 360, cor, -1)
        ang = np.deg2rad(rotacao)
        dx = np.hypot(eixo[0] * np.cos(ang), eixo[1] * np.sin(ang))
        dy = np.hypot(eixo[0] * np.sin(ang), eixo[1] * np.cos(ang))
        xs, ys = np.array([cx - dx, cx + dx]), np.array([cy - dy, cy + dy])
    return xs.min(), ys.min(), xs.max(), ys.max()

def adiciona_ruido_sal_pimenta(img, quantidade=0.05):
    img_ruidosa = img.copy()
    h, w, c = img_ruidosa.shape
    num_ruido = int(quantidade * h * w)
    
    # Sel (255, 255, 255)
    coords_sal = [np.random.randint(0, i - 1, num_ruido) for i in (h, w)]
    img_ruidosa[coords_sal[0], coords_sal[1]] = [255, 255, 255]
    
    # Poivre (0, 0, 0)
    coords_pimenta = [np.random.randint(0, i - 1, num_ruido) for i in (h, w)]
    img_ruidosa[coords_pimenta[0], coords_pimenta[1]] = [0, 0, 0]
    
    return img_ruidosa

def gera_imagem_ruidosa(tam_img=160, n_objetos=(1, 3), taxa_ruido=0.01, rng=None):
    rng = rng or random.Random()
    img_limpa = np.full((tam_img, tam_img, 3), 255, dtype=np.uint8)
    anotacoes = []
    
    for _ in range(rng.randint(*n_objetos)):
        classe_idx = rng.randrange(len(CLASSES))
        tamanho = rng.randint(tam_img // 10, tam_img // 5)
        cx = rng.randint(tamanho + 2, tam_img - tamanho - 2)
        cy = rng.randint(tamanho + 2, tam_img - tamanho - 2)
        rotacao = rng.uniform(0, 360)
        cor = tuple(rng.sample(range(30, 226), 3))
        
        x0, y0, x1, y1 = desenha_objeto(img_limpa, classe_idx, cx, cy, tamanho, rotacao, cor)
        x0, y0 = max(x0, 0), max(y0, 0)
        x1, y1 = min(x1, tam_img), min(y1, tam_img)
        
        # Format YOLO : (classe, x_centre, y_centre, largeur, hauteur) normalisés
        xc, yc = (x0 + x1) / 2 / tam_img, (y0 + y1) / 2 / tam_img
        w, h = (x1 - x0) / tam_img, (y1 - y0) / tam_img
        anotacoes.append((classe_idx, xc, yc, w, h)) 
        
    img_ruidosa = adiciona_ruido_sal_pimenta(img_limpa, quantidade=taxa_ruido)
    return img_ruidosa, anotacoes
# Génération de 5 échantillons pour affichage initial en haut du projet
n_amostras_iniciais = 5
rng_demo = random.Random(42)

imgs_demo = []
titulos_demo = []

for idx in range(n_amostras_iniciais):
    img_ruid, anotacoes = gera_imagem_ruidosa(rng=rng_demo)
    
    # Enregistrement temporaire de l'annotation pour lecture native par mm.showBoundBox
    filename_temp = f"temp_label_{idx}.txt"
    with open(filename_temp, "w") as f:
        for c, xc, yc, w, h in anotacoes:
            f.write(f"{c} {xc:.4f} {yc:.4f} {w:.4f} {h:.4f}\n")
            
    img_anotada = mm.showBoundBox(img_ruid, filename=filename_temp, fmt="yolo", show=False)
    imgs_demo.append(img_anotada)
    titulos_demo.append(f"Amostra {idx+1}")

# Affichage du panneau de 5 échantillons
mm.show(
    imgs_demo,
    titles=titulos_demo,
    cols=n_amostras_iniciais,
    figsize=(14, 3)
)
Figure 9.30: Échantillons initiaux du dataset synthétique bruité d’objets géométriques avec les boîtes englobantes du format YOLO superposées.
9.5.2.2.2 Bloc 2 : Organisation du Dataset et création du fichier data.yaml

Ce bloc organise l’ensemble de données au format attendu par la bibliothèque Ultralytics YOLO. Les images et les annotations sont réparties dans des répertoires séparés pour l’entraînement et la validation, tandis que le fichier data.yaml regroupe les informations nécessaires à l’entraînement du détecteur.

shapes_dataset/
├── data.yaml
├── images/
│   ├── train/
│   └── val/
└── labels/
    ├── train/
    └── val/
  1. Génération de l’ensemble de données : Le code crée 90 images pour l’entraînement et 20 pour la validation. Pour chaque image, il enregistre un fichier .txt contenant une ligne par objet, au format YOLO (classe, x_c, y_c, largeur, hauteur), avec toutes les coordonnées normalisées.

  2. Organisation des fichiers : Les images sont stockées dans images/train et images/val, tandis que les annotations correspondantes sont enregistrées dans labels/train et labels/val, en préservant le même nom de fichier.

  3. Création du fichier data.yaml : Le code génère automatiquement le fichier de configuration contenant le chemin du dataset, les répertoires d’entraînement et de validation, ainsi que la correspondance entre les indices numériques et les noms des neuf classes.

base_dir = "shapes_dataset"
rng_global = random.Random(42)

for split, n_imgs in [("train", 90), ("val", 20)]:
    os.makedirs(f"{base_dir}/images/{split}", exist_ok=True)
    os.makedirs(f"{base_dir}/labels/{split}", exist_ok=True)
    
    for i in range(n_imgs):
        img_ruid, anotacoes = gera_imagem_ruidosa(rng=rng_global)
        cv2.imwrite(f"{base_dir}/images/{split}/{i:04d}.jpg", img_ruid)
        
        with open(f"{base_dir}/labels/{split}/{i:04d}.txt", "w") as f:
            for c, xc, yc, w, h in anotacoes:
                f.write(f"{c} {xc:.4f} {yc:.4f} {w:.4f} {h:.4f}\n")

with open(f"{base_dir}/data.yaml", "w") as f:
    f.write(
        f"path: {os.path.abspath(base_dir)}\n"
        "train: images/train\nval: images/val\nnames:\n"
    )
    for i, nome in enumerate(CLASSES):
        f.write(f"  {i}: {nome}\n")

print("Jeu de données bruité généré avec succès : 90 images d'entraînement et 20 de validation.")
Jeu de données bruité généré avec succès : 90 images d'entraînement et 20 de validation.
9.5.2.2.3 Bloco 3: Pré-traitement avec filtre médian

Ce bloc applique un pré-traitement pour réduire l’effet du bruit de type sel et poivre introduit lors de la génération du dataset. Le filtre médian (cv2.medianBlur) supprime ce type de dégradation tout en préservant mieux les contours des objets que les filtres de lissage conventionnels.

  1. Filtrage de l’image : Le code applique un filtre médian avec une fenêtre \(3 \times 3\) à l’image bruitée, réduisant les pixels impulsifs sans modifier les annotations de l’ensemble de données.

  2. Visualisation comparative : La Figure 9.31 compare l’image originale et l’image filtrée, en maintenant les bounding boxes superposées grâce à la fonction mm.showBoundBox().

# 1. Chargement du premier échantillon bruité du dataset
caminho_img = f"{base_dir}/images/train/0000.jpg"
caminho_label = f"{base_dir}/labels/train/0000.txt"

img_ruidosa = mm.read(caminho_img)

# 2. Prétraitement avec Filtre Médian (fenêtre 3x3)
img_filtrada = cv2.medianBlur(img_ruidosa, ksize=3)

# 3. Superposition des bounding boxes avec mm.showBoundBox
img_ruid_anotada = mm.showBoundBox(img_ruidosa, filename=caminho_label, fmt="yolo", show=False)
img_filt_anotada = mm.showBoundBox(img_filtrada, filename=caminho_label, fmt="yolo", show=False)

# 4. Affichage comparatif avec mm.show
mm.show(
    [img_ruid_anotada, img_filt_anotada],
    titles=[
        "1. Image Bruyante Originale (Sel et Poivre)",
        "2. Prétraitée (Filtre Médian 3x3)"
    ],
    cols=2,
    figsize=(9, 4)
)
Figure 9.31: Comparaison entre l’image originale avec bruit de type sel et poivre et l’image après application du Filtre Médian (3x3). Les bounding boxes au format YOLO restent inchangées.
9.5.2.2.4 Bloco 4: Prétraitement du dataset et ajustement fin de YOLOv8

Ce bloc applique le filtre médian à l’ensemble d’images et réalise l’ajustement fin (fine-tuning) du détecteur YOLOv8n pré-entraîné sur l’ensemble COCO. Le filtrage réduit l’effet du bruit impulsif introduit lors de la génération des images, tandis que l’entraînement adapte les paramètres du réseau au nouveau domaine de formes géométriques.

  1. Filtrage par lots : Le code parcourt les répertoires train et val et applique cv2.medianBlur avec une fenêtre \(3 \times 3\) sur toutes les images, en conservant les annotations YOLO d’origine.

  2. Ajustement fin du détecteur : Le réseau YOLO("yolov8n.pt"), initialement entraîné sur COCO, est adapté à l’ensemble géométrique via la fonction .train(). L’entraînement utilise des images avec une résolution de \(320 \times 320\) pixels pendant \(30\) époques.

  3. Évaluation du modèle : La fonction .val() calcule les métriques de détection sur l’ensemble de validation, y compris la précision (precision), le rappel (recall) et la mAP50 (mean Average Precision avec un seuil d’IoU égal à \(0,5\)).

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

base_dir = "shapes_dataset"
base_dir_filt = "shapes_dataset_filtrado"

# 1. Crée une COPIE filtrée du jeu de données dans un dossier séparé
#    (le jeu de données original dans base_dir reste bruité, intact)
for split in ["train", "val"]:
    pasta_imgs_orig = f"{base_dir}/images/{split}"
    pasta_labels_orig = f"{base_dir}/labels/{split}"
    pasta_imgs_filt = f"{base_dir_filt}/images/{split}"
    pasta_labels_filt = f"{base_dir_filt}/labels/{split}"

    os.makedirs(pasta_imgs_filt, exist_ok=True)
    os.makedirs(pasta_labels_filt, exist_ok=True)

    for nome_arq in os.listdir(pasta_imgs_orig):
        if nome_arq.endswith(".jpg"):
            img_ruidosa = cv2.imread(f"{pasta_imgs_orig}/{nome_arq}")
            img_filtrada = cv2.medianBlur(img_ruidosa, ksize=3)
            # écrit la version filtrée dans le dossier NOUVEAU, ne remplace pas l'original
            cv2.imwrite(f"{pasta_imgs_filt}/{nome_arq}", img_filtrada)

    # copie les étiquettes (elles ne changent pas avec le filtre)
    for nome_arq in os.listdir(pasta_labels_orig):
        shutil.copy(f"{pasta_labels_orig}/{nome_arq}", f"{pasta_labels_filt}/{nome_arq}")

# 2. data.yaml pointant vers le jeu de données FILTRÉ
with open(f"{base_dir_filt}/data.yaml", "w") as f:
    f.write(
        f"path: {os.path.abspath(base_dir_filt)}\n"
        "train: images/train\nval: images/val\nnames:\n"
    )
    for i, nome in enumerate(CLASSES):
        f.write(f"  {i}: {nome}\n")

print("Prétraitement terminé : jeu de données filtré enregistré dans un dossier séparé.\n")

# Télécharger le modèle YOLOv8 pré-entraîné (yolov8n.pt) s'il n'est pas déjà présent
with contextlib.redirect_stdout(io.StringIO()), \
    contextlib.redirect_stderr(io.StringIO()):
    modelo_yolo = YOLO("yolov8n.pt")
print("Modèle YOLOv8 chargé.")
Prétraitement terminé : jeu de données filtré enregistré dans un dossier séparé.

Modèle YOLOv8 chargé.
# 3. Callback personnalisé pour imprimer uniquement l'époque en cours d'exécution
def on_train_epoch_start(trainer):
    epoch_atual = trainer.epoch + 1
    total_epochs = trainer.epochs
    # Écrit directement dans la sortie standard d'origine (en contournant le silencieux)
    sys.__stdout__.write(f"🔄 Processando Época {epoch_atual}/{total_epochs}...\n")
    sys.__stdout__.flush()

# Ajoute le callback au modèle
modelo_yolo.add_callback("on_train_epoch_start", on_train_epoch_start)

# 4. Gestionnaire de contexte pour silencer les déchets d'Ultralytics (C/C++ et Python)
@contextlib.contextmanager
def silenciar_logs():
    logger = logging.getLogger("ultralytics")
    disabled_state = logger.disabled
    logger.disabled = True
    
    with open(os.devnull, "w") as fnull:
        old_stdout_fd = os.dup(1)
        old_stderr_fd = os.dup(2)
        try:
            os.dup2(fnull.fileno(), 1)
            os.dup2(fnull.fileno(), 2)
            with contextlib.redirect_stdout(fnull), contextlib.redirect_stderr(fnull):
                yield
        finally:
            os.dup2(old_stdout_fd, 1)
            os.dup2(old_stderr_fd, 2)
            os.close(old_stdout_fd)
            os.close(old_stderr_fd)
            logger.disabled = disabled_state

# Exécution de l'entraînement
print("--- Démarrage de l'entraînement YOLOv8 ---")
with silenciar_logs():
    resultados_treino = modelo_yolo.train(
        data=f"{base_dir_filt}/data.yaml",   # <-- entraîne sur le jeu de données filtré
        epochs=30,
        imgsz=320,
        batch=16,
        device=device,
        verbose=False,
        plots=False
    )
    metricas = modelo_yolo.val(verbose=False)

# 5. Métriques finales
precision = metricas.results_dict["metrics/precision(B)"]
recall = metricas.results_dict["metrics/recall(B)"]
map50 = metricas.results_dict["metrics/mAP50(B)"]

print("\n--- Performance optimisée du modèle YOLOv8 ---")
print(f"Précision : {precision*100:.2f}%")
print(f"Rappel (Recall) : {recall*100:.2f}%")
print(f"mAP à 50% (IoU 0,50) : {map50*100:.2f}%")
--- Démarrage de l'entraînement YOLOv8 ---

--- Performance optimisée du modèle YOLOv8 ---
Précision : 87.87%
Rappel (Recall) : 80.28%
mAP à 50% (IoU 0,50) : 86.96%
9.5.2.2.5 Bloc 5 : Comparaison d’inférence : Image bruitée et image restaurée

Après le réglage fin de YOLOv8 sur l’ensemble restauré, une comparaison visuelle est effectuée entre la détection appliquée directement sur une image dégradée par le bruit sal et poivre et la même image après le filtre médian.

L’objectif est d’observer comment une étape simple de prétraitement peut influencer la qualité des prédictions d’un détecteur déjà adapté au nouveau domaine.

  1. Inférence avec YOLO : La fonction modelo_yolo.predict() exécute la détection sur les deux versions de l’image, en utilisant un seuil de confiance de \(25\%\) (conf=0.25).

  2. Visualisation des prédictions : La fonction plot() génère les images annotées avec les boîtes englobantes et les étiquettes prédites par le modèle. La Figure 9.32 présente la comparaison entre les deux scénarios.

# 1. Chargement d'un échantillon de test original (sans le filtre sauvegardé en lot)
caminho_teste = f"{base_dir}/images/val/0002.jpg"
img_ruidosa_teste = mm.read(caminho_teste)

# 2. Application ponctuelle du Filtre Médian (3x3) pour comparaison
img_filtrada_teste = cv2.medianBlur(img_ruidosa_teste, ksize=3)

# 3. Inférence avec le modèle YOLOv8 entraîné
pred_ruidosa = modelo_yolo.predict(img_ruidosa_teste, conf=0.25, verbose=False)[0]
pred_filtrada = modelo_yolo.predict(img_filtrada_teste, conf=0.25, verbose=False)[0]

# 4. Extraction des matrices annotées par le générateur du YOLO (conversion BGR -> RGB)
img_pred_ruid = cv2.cvtColor(pred_ruidosa.plot(), cv2.COLOR_BGR2RGB)
img_pred_filt = cv2.cvtColor(pred_filtrada.plot(), cv2.COLOR_BGR2RGB)

# 5. Affichage comparatif standardisé via mm.show
mm.show(
    [img_pred_ruid, img_pred_filt],
    titles=[
        f"Inférence sur l'image bruitée ({len(pred_ruidosa.boxes)} objets)",
        f"Inférence sur l'image filtrée ({len(pred_filtrada.boxes)} objets)"
    ],
    cols=2,
    figsize=(10, 4)
)
Figure 9.32
# Nettoyage explicite des données téléchargées et libération de la mémoire
if FLAG_LIMPAR_DADOS:
    if os.path.exists(base_dir):
        shutil.rmtree(base_dir)
        print('🧹 Diretório de dados temporários {} removido com sucesso.'.format(base_dir))

    if os.path.exists(base_dir_filt):
        shutil.rmtree(base_dir_filt)
        print('🧹 Diretório de dados temporários {} removido com sucesso.'.format(base_dir_filt))

    if torch.cuda.is_available():
        torch.cuda.empty_cache()
Note🧠 Un domaine bien plus éloigné que celui des chiffres

Dans l’expérience de transfert d’apprentissage entre chiffres manuscrits (domaines A et B), la tâche source et la tâche cible partageaient des statistiques visuelles très proches : toutes deux étaient des traits en niveaux de gris sur fond uniforme. Ici, la distance entre les domaines est bien plus grande — le YOLO a été pré-entraîné sur des photographies naturelles en couleur de COCO (personnes, animaux, véhicules, objets du quotidien), et la tâche cible consiste en formes géométriques synthétiques, de couleur unie et au contour bien défini, sans texture, éclairage ni arrière-plan complexe.

Néanmoins, le transfert d’apprentissage reste avantageux : les couches initiales d’un détecteur entraîné sur COCO apprennent des filtres génériques — détecteurs de bords, de coins et de régions de contraste — qui restent utiles pour délimiter le contour d’un triangle ou d’une étoile, même si le contenu visuel final est assez distinct. C’est pourquoi permettre l’ajustement fin (fine-tuning) de toutes les couches, combiné à un prétraitement cohérent entre l’entraînement et l’inférence pour atténuer le bruit sel et poivre, permet d’atteindre des taux de précision élevés dans la détection d’objets du nouveau domaine.

9.5.2.2.6 Utilisation d’un Ensemble de Données Réel

Le pipeline ci-dessus a été entièrement construit autour du format d’annotation YOLO (classe, \(x_{centre}\), \(y_{centre}\), largeur, hauteur, normalisés par la largeur et la hauteur de l’image), précisément afin qu’il puisse être réutilisé sans modification si le lecteur dispose d’un ensemble d’images réelles annotées de la même manière — par exemple, un ensemble d’images d’objets géométriques photographiés ou rendus, chacun accompagné d’un fichier .txt correspondant au même format utilisé ici. Pour cela, il suffirait de :

  1. Organiser les images réelles dans shapes_dataset/images/train et shapes_dataset/images/val, ainsi que les fichiers .txt d’annotation correspondants dans les dossiers labels/train et labels/val (un fichier d’annotation par image, même nom de base, extension .txt) ;
  2. Ajuster le fichier data.yaml si le nombre ou les noms des classes diffèrent ;
  3. Exécuter les mêmes cellules d’entraînement, d’ajustement fin et de visualisation déjà présentées, sans aucune autre modification de code.

Cette séparation entre génération/organisation des données et entraînement du modèle est, en pratique, la raison pour laquelle les formats d’annotation standardisés (comme celui du YOLO) sont si largement adoptés : ils permettent de remplacer l’ensemble de données d’entrée — synthétique par réel, un domaine par un autre — tout en maintenant inchangé tout le reste du pipeline de transfert d’apprentissage.

9.5.3 Segmentation d’objets

Le même principe de transfert d’apprentissage sous-tend également les architectures de segmentation d’images, dans lesquelles un extracteur de caractéristiques pré-entraîné fournit des représentations visuelles générales, tandis qu’une tête spécialisée effectue la classification dense, pixel par pixel.

Les sections suivantes présentent DeepLabV3 comme exemple de segmentateur pré-entraîné utilisé directement pour l’inférence, puis l’architecture U-Net, entraînée de zéro et comparée à une ligne de base morphologique classique.

9.5.3.1 DeepLabV3 : Segmentateur Pré-entraîné

En segmentation sémantique, l’objectif ne se limite pas à la localisation des objets par des boîtes englobantes (bounding boxes). Le réseau attribue une classe à chaque pixel de l’image, produisant une carte d’étiquettes ayant la même résolution que l’entrée. Des architectures telles que DeepLabV3, avec un backbone ResNet-50, utilisent un extracteur de caractéristiques pré-entraîné et une tête spécialisée pour réaliser cette classification dense.

  1. Chargement et inférence : Le modèle deeplabv3_resnet50(weights="DEFAULT") charge des poids pré-entraînés sur l’ensemble Pascal VOC, qui définit \(21\) classes de segmentation. Le code convertit l’image en tensor, ajoute la dimension de batch (unsqueeze(0)) et exécute l’inférence.

  2. Carte des classes : La sortie du modèle possède la dimension \((1, 21, H, W)\), contenant une valeur pour chaque classe à chaque pixel. L’opération .argmax(dim=1) sélectionne la classe ayant la réponse la plus élevée à chaque position, générant une matrice bidimensionnelle d’étiquettes de dimensions \((H, W)\).

  3. Visualisation : Le code convertit la carte d’étiquettes au format attendu par mm.show(), qui affiche le résultat de la segmentation sur la Figure 9.33..

# 1. Chargement de l'image (retourne numpy.ndarray)
url_imagem = "https://raw.githubusercontent.com/pytorch/hub/master/images/dog.jpg"
url_imagem = "http://images.cocodataset.org/val2017/000000039769.jpg"
img = mm.read(url_imagem)

# 2. Chargement du modèle DeepLabV3 pré-entraîné en mode évaluation
modelo_segmentacao = deeplabv3_resnet50(weights="DEFAULT").eval()

# 3. Exécution de l'inférence sans calcul de gradients
with torch.no_grad():
    tensor_entrada = to_tensor(img).unsqueeze(0)  # Format (1, C, H, W)
    saida = modelo_segmentacao(tensor_entrada)["out"]
    
    # Sélection de la classe avec la plus grande probabilité par pixel (argmax sur l'axe des canaux)
    mapa_classes = saida.argmax(dim=1).squeeze(0).byte().cpu().numpy()

# 4. Affichage de la carte de segmentation sémantique
mm.show(
    [img,mapa_classes],
    title=["Image originale","Segmentation sémantique (DeepLabV3)"]
)
Figure 9.33

9.5.3.2 Segmentação Semântica com Arquitetura U-Net

A subseção anterior apresentou um modelo pré-treinado (DeepLabV3) que produz diretamente um rótulo de classe por pixel. Esta seção completa a sequência de projetos práticos — classificação e detecção — abordando a segmentação semântica implementada e treinada do zero com a U-Net, a arquitetura de referência introduzida por Ronneberger (2015).

Na classificação, o mapa de características final era achatado (flatten) em um vetor, descartando a informação espacial em favor de um único rótulo por imagem. A U-Net, por sua vez, produz uma saída com a mesma resolução espacial da entrada: um mapa bidimensional no qual cada pixel recebe sua própria classificação. Essa exigência — preservar detalhe espacial de alta resolução ao mesmo tempo em que se constrói contexto semântico em camadas profundas — motiva a arquitetura de codificador-decodificador com conexões de atalho (skip connections).

O cenário utilizado simula a segmentação de nódulos em exames médicos sintéticos: imagens em escala de cinza contêm uma região circular (“nódulo”) sobreposta a um fundo, ambos contaminados por ruído gaussiano e com médias de intensidade muito próximas — um desafio intencional de baixo contraste, ideal para demonstrar o ganho do aprendizado espacial em relação à limiarização pontual.

9.5.3.2.1 Bloc 1 : Générateur de l’ensemble synthétique de nodules et affichage initial

Le générateur suivant produit des paires (image, masque) : l’image contient une région circulaire d’intensité légèrement supérieure à celle du fond, toutes deux affectées par le même écart-type de bruit gaussien. Le masque binaire délimite exactement la région du nodule et sert de vérité de référence (ground truth).

  1. Construction du nodule : La fonction gera_imagem_com_nodulo superpose le nodule au fond sur une matrice \(64 \times 64\) et applique un bruit gaussien.
  2. Visualisation avec mm.show : La Figure 9.34 illustre les deux premiers échantillons et leurs masques respectifs.
TAM_IMG = 64


def gera_imagem_com_nodulo(
    tam=TAM_IMG,
    raio_min=7,
    raio_max=15,
    media_fundo=95,
    media_nodulo=118,
    sigma_ruido=26,
    rng=None,
):
    rng = rng or np.random.default_rng()
    fundo = rng.normal(media_fundo, sigma_ruido, (tam, tam))
    nodulo = rng.normal(media_nodulo, sigma_ruido, (tam, tam))
    mascara = np.zeros((tam, tam), dtype=np.uint8)

    raio = int(rng.integers(raio_min, raio_max))
    cx = int(rng.integers(raio + 4, tam - raio - 4))
    cy = int(rng.integers(raio + 4, tam - raio - 4))

    cv2.circle(mascara, (cx, cy), raio, 255, -1)
    imagem = np.clip(np.where(mascara > 0, nodulo, fundo), 0, 255).astype(
        np.uint8
    )
    return imagem, mascara


# Génération des ensembles d'entraînement et de validation
rng_dados = np.random.default_rng(42)
N_TREINO, N_VAL = 160, 40

imgs_treino, masks_treino = zip(
    *[gera_imagem_com_nodulo(rng=rng_dados) for _ in range(N_TREINO)]
)
imgs_val, masks_val = zip(
    *[gera_imagem_com_nodulo(rng=rng_dados) for _ in range(N_VAL)]
)

print(
    f"Ensemble d'entraînement : {N_TREINO} images | Ensemble de validation : {N_VAL} images\n"
)

# Affichage d'échantillons initiaux 
mm.show(
    [imgs_treino[0], masks_treino[0], imgs_treino[1], masks_treino[1]],
    titles=["Image 1", "Masque 1", "Image 2", "Masque 2"],
    cols=4,
    figsize=(11, 3),
)
Ensemble d'entraînement : 160 images | Ensemble de validation : 40 images
Figure 9.34: Échantillons du jeu de données synthétique de nodules : image en niveaux de gris sous bruit et masque binaire de référence correspondant.
9.5.3.2.2 Bloco 2: Ligne de base classique (filtrage, Otsu et morphologie)

Avant d’employer l’U-Net, on évalue la performance d’un pipeline morphologique classique construit avec la bibliothèque morph : un lisseur gaussien (mm.blur), un seuillage d’Otsu (mm.threshold) et une ouverture morphologique (mm.open) pour l’élimination des bruits isolés.

  1. Métrique d’Intersection sur Union (IoU) : La fonction iou_mascaras calcule le degré de chevauchement pixel par pixel entre la prédiction et le masque réel.
  2. Exécution et comparatif : La Figure 9.35 affiche le résultat de la segmentation classique sur une image de test, démontrant les limitations du seuil global sous faible contraste.
def iou_mascaras(predita, referencia):
    p, r = predita > 0, referencia > 0
    intersecao = np.logical_and(p, r).sum()
    uniao = np.logical_or(p, r).sum()
    return intersecao / uniao if uniao else 1.0


def segmenta_classico(imagem, elemento_estrutural):
    suavizada = mm.blur(imagem, 7)
    binaria = mm.threshold(suavizada)
    return mm.open(binaria, elemento_estrutural)


elemento_estrutural = mm.sedisk(5)
ious_classico = [
    iou_mascaras(segmenta_classico(img, elemento_estrutural), mask)
    for img, mask in zip(imgs_val, masks_val)
]
iou_classico_medio = float(np.mean(ious_classico))
print(
    f"IoU moyen (ligne de base classique) sur la validation : {iou_classico_medio:.4f}\n"
)

predicao_classica_exemplo = segmenta_classico(imgs_val[0], elemento_estrutural)

mm.show(
    [imgs_val[0], masks_val[0], predicao_classica_exemplo],
    titles=["Image", "Masque de référence", "Prédiction classique"],
    cols=3,
    figsize=(9, 3.2),
)
IoU moyen (ligne de base classique) sur la validation : 0.7516
Figure 9.35: Ligne de base classique de segmentation : lissage, seuillage d’Otsu et ouverture morphologique affichés.
9.5.3.2.3 Bloco 3: Construction de l’architecture U-Net et fonctions de perte

La U-Net est une architecture en forme de « U » (d’où son nom), conçue spécifiquement pour la segmentation d’images. Elle est composée de deux chemins qui travaillent ensemble :

  • 🔽 Encodeur : descend dans l’image, réduisant la résolution spatiale à chaque étape tout en extrayant des caractéristiques de plus en plus abstraites (contours → textures → formes → contexte).
  • 🔼 Décodeur : remonte, reconstruisant la résolution originale grâce à des convolutions transposées (upsampling), jusqu’à générer un masque de la même taille que l’image d’entrée.

L’élément qui rend la U-Net spéciale, ce sont les connexions de saut (skip connections) : elles acheminent les cartes de caractéristiques de l’encodeur directement vers l’étape correspondante du décodeur, à la même résolution. Cela évite que des détails fins — comme les contours et les bords — ne se perdent lors de la compression spatiale.

Flux général de l’architecture :

Entrée
  │
  ▼
Encodeur (Conv → Conv → Pool) × 3
  │
  ├──── connexions de saut réinjectant les cartes haute résolution  ────┐
  ▼                                                                     │
Base (goulot d’étranglement)                                            │
  │                                                                     │
  ▼                                                                     │
Décodeur (Upsample → Concat → Conv → Conv) × 3  ◄───────────────────────┘
  │
  ▼
Sortie 1×1 (logits)

Composants principaux :

  1. Bloc convolutif de base (BlocoConv) L’unité fondamentale répétée dans tout le réseau. Elle applique deux convolutions \(3 \times 3\) en séquence, chacune suivie d’une activation ReLU, avec un padding qui préserve les dimensions spatiales de l’entrée. C’est ce bloc qui apparaît aussi bien dans l’encodeur que dans le décodeur.

  2. Convolution transposée (nn.ConvTranspose2d) C’est l’opération responsable de l’upsampling dans le décodeur : au lieu de réduire la résolution spatiale (comme le fait le MaxPool2d dans l’encodeur), elle l’augmente, apprenant les poids nécessaires pour « défaire » la compression et retrouver progressivement la taille originale de l’image.

  3. Perte combinée (BCE + Dice) La fonction perda_segmentacao additionne deux métriques complémentaires :

    • Entropie croisée binaire (BCE) : évalue la justesse pixel par pixel.
    • Coefficient de Dice : évalue la superposition globale entre le masque prédit et le masque réel.

    Ensemble, elles équilibrent la précision locale avec la fidélité de la forme segmentée dans son ensemble.

class BlocoConv(nn.Module):

    def __init__(self, canais_entrada, canais_saida):
        super().__init__()
        self.rede = nn.Sequential(
            nn.Conv2d(canais_entrada, canais_saida, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
            nn.Conv2d(canais_saida, canais_saida, kernel_size=3, padding=1),
            nn.ReLU(inplace=True),
        )

    def forward(self, x):
        return self.rede(x)


class UNetCompacta(nn.Module):

    def __init__(self, canais_entrada=1, base=8):
        super().__init__()
        self.enc1 = BlocoConv(canais_entrada, base)
        self.enc2 = BlocoConv(base, base * 2)
        self.enc3 = BlocoConv(base * 2, base * 4)
        self.pool = nn.MaxPool2d(2)

        self.fundo = BlocoConv(base * 4, base * 8)

        self.up3 = nn.ConvTranspose2d(
            base * 8, base * 4, kernel_size=2, stride=2
        )
        self.dec3 = BlocoConv(base * 8, base * 4)
        self.up2 = nn.ConvTranspose2d(
            base * 4, base * 2, kernel_size=2, stride=2
        )
        self.dec2 = BlocoConv(base * 4, base * 2)
        self.up1 = nn.ConvTranspose2d(
            base * 2, base, kernel_size=2, stride=2
        )
        self.dec1 = BlocoConv(base * 2, base)

        self.saida = nn.Conv2d(base, 1, kernel_size=1)

    def forward(self, x):
        e1 = self.enc1(x)
        e2 = self.enc2(self.pool(e1))
        e3 = self.enc3(self.pool(e2))
        f = self.fundo(self.pool(e3))

        d3 = self.dec3(torch.cat([self.up3(f), e3], dim=1))
        d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
        d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
        return self.saida(d1)


def para_tensores(imagens, mascaras):
    X = (
        torch.tensor(np.stack(imagens), dtype=torch.float32).unsqueeze(1)
        / 255.0
    )
    Y = (
        torch.tensor(np.stack(mascaras), dtype=torch.float32).unsqueeze(1)
        / 255.0
    )
    return X, Y


def perda_dice(logits, alvo, eps=1e-6):
    probs = torch.sigmoid(logits)
    intersecao = (probs * alvo).sum(dim=(1, 2, 3))
    uniao = probs.sum(dim=(1, 2, 3)) + alvo.sum(dim=(1, 2, 3))
    dice = (2 * intersecao + eps) / (uniao + eps)
    return 1 - dice.mean()


def perda_segmentacao(logits, alvo):
    return nn.functional.binary_cross_entropy_with_logits(
        logits, alvo
    ) + perda_dice(logits, alvo)
9.5.3.2.4 Bloc 4 : Entraînement de la U-Net et Évaluation des Performances

L’entraînement est exécuté sur \(35\) époques en utilisant l’optimiseur Adam. À chaque époque, on surveille la Perte d’Entraînement et l’indice IoU moyen sur l’ensemble de validation.

  1. Boucle d’Entraînement : Les paramètres sont mis à jour avec des mini-lots de \(16\) échantillons.
  2. Évolution Graphique : La Figure 9.36 affiche le graphique avec la progression de la perte et de l’IoU.
modelo_unet = UNetCompacta()
print(
    f"Paramètres entraînables de l'U-Net : {sum(p.numel() for p in modelo_unet.parameters())}"
)

X_treino_unet, Y_treino_unet = para_tensores(imgs_treino, masks_treino)
X_val_unet, Y_val_unet = para_tensores(imgs_val, masks_val)

otimizador_unet = optim.Adam(modelo_unet.parameters(), lr=1e-3)
n = X_treino_unet.size(0)
tam_lote = 16
epocas_unet = 35
historico_perda_unet, historico_iou_unet = [], []

for epoca in range(epocas_unet):
    if epoca % 5 == 0:  # Imprime toutes les 5 époques
        print(epoca + 1, "/", epocas_unet)
    modelo_unet.train()
    perm = torch.randperm(n)
    perda_epoca = 0.0

    for i in range(0, n, tam_lote):
        idx = perm[i : i + tam_lote]
        otimizador_unet.zero_grad()
        logits = modelo_unet(X_treino_unet[idx])
        perda = perda_segmentacao(logits, Y_treino_unet[idx])
        perda.backward()
        otimizador_unet.step()
        perda_epoca += perda.item() * len(idx)

    modelo_unet.eval()
    with torch.no_grad():
        predicao_val = (torch.sigmoid(modelo_unet(X_val_unet)) > 0.5).float()
        intersecao = (predicao_val * Y_val_unet).sum(dim=(1, 2, 3))
        uniao = ((predicao_val + Y_val_unet) > 0).float().sum(dim=(1, 2, 3))
        iou_epoca = (intersecao / uniao.clamp(min=1e-6)).mean().item()

    historico_perda_unet.append(perda_epoca / n)
    historico_iou_unet.append(iou_epoca)

iou_unet_final = historico_iou_unet[-1]
print(f"IoU moyen final de l'U-Net en validation : {iou_unet_final:.4f}")

# Graphique de l'évolution de l'entraînement
r = mm.showTrainCurves(historico_perda_unet, historico_iou_unet,
        titulo="Treinamento da U-Net — Segmentação de Nódulos Sintéticos",
        subtitulo="Perda no conjunto de treino e IoU médio no conjunto de validação \
            ao longo de 35 épocas")
Paramètres entraînables de l'U-Net : 120681
1 / 35
6 / 35
11 / 35
16 / 35
21 / 35
26 / 35
31 / 35
IoU moyen final de l'U-Net en validation : 0.8876
Figure 9.36: Évolution de l’entraînement de l’U-Net compacte : réduction de la perte et élévation de l’indice IoU moyen sur l’ensemble de validation au fil de 35 époques.
9.5.3.2.5 Bloc 5 : Comparatif Quantitatif et Qualitatif (Classique vs. U-Net)

La comparaison entre l’approche classique et la U-Net met en évidence la supériorité de l’apprentissage de représentations dans les scénarios à faible contraste.

  1. Tableau de métriques : Le graphique à barres dans la Figure 9.37 contraste le IoU moyen des deux méthodes lors de la validation.
  2. Visualisation qualitative : La comparaison visuelle sur trois échantillons démontre comment les connexions de raccourci permettent de récupérer le contour du nodule, même sous un bruit prononcé.
# 1.  comparatif 
print(f"IoU moyen (Lissage classique + Otsu) : {iou_classico_medio:.4f}")
print(f"IoU moyen (U-Net) : {iou_unet_final:.4f}")

# 2. Affichage qualitatif côte à côte sur 3 échantillons via mm.show
imgs_comparativas = []
titulos_comparativos = []

for i in range(3):
    with torch.no_grad():
        pred_unet = (
            (torch.sigmoid(modelo_unet(X_val_unet[i : i + 1])) > 0.5)
            .float()
            .squeeze()
            .numpy()
            * 255
        )

    pred_classico = segmenta_classico(imgs_val[i], elemento_estrutural)

    imgs_comparativas.extend(
        [imgs_val[i], masks_val[i], pred_classico, pred_unet.astype(np.uint8)]
    )

    t_prefix = f"Amostra {i+1}"
    titulos_comparativos.extend(
        [
            f"{t_prefix}: Imagem",
            f"{t_prefix}: Referência",
            f"{t_prefix}: Clássico",
            f"{t_prefix}: U-Net",
        ]
    )

mm.show(
    imgs_comparativas,
    titles=titulos_comparativos,
    cols=4,
    figsize=(11, 7.5),
)
IoU moyen (Lissage classique + Otsu) : 0.7516
IoU moyen (U-Net) : 0.8876
Figure 9.37: Comparativo quantitatif (IoU moyen) et qualitatif entre l’approche classique et l’U-Net entraînée sur trois échantillons de validation.
Note🧠 Pourquoi l’U-Net surpasse-t-elle le seuillage fixe ?

Le seuillage d’Otsu applique une valeur de coupure globale sur l’intensité locale. Lorsque la différence de moyenne entre le nodule et le fond est faible par rapport au bruit gaussien, cette règle commet des erreurs systématiques au niveau des bords.

L’U-Net contourne cette limitation en combinant le contexte sémantique large extrait par l’encodeur avec les détails spatiaux fins préservés par les connexions de saut. Cela permet d’identifier la présence du nodule et de délimiter ses contours avec précision, même sous un bruit intense.

9.5.4 Ingénierie des données pour la vision par ordinateur (Roboflow)

Une U-Net peut être entraînée à partir d’images annotées sur des plateformes d’ingénierie des données pour la vision par ordinateur, telles que Roboflow. Ces plateformes permettent d’organiser des ensembles de données, de réaliser des annotations, d’appliquer des étapes de prétraitement et d’augmentation des données, d’entraîner des modèles et d’exporter les données dans différents formats. Dans cette section, toutefois, l’exemple reprend la détection d’objets géométriques, en utilisant des ensembles de données déjà présentés dans cet ouvrage.

ImportantDépendance à la connexion et à la clé API

Les cellules de cette section exigent une connexion à Internet et une clé API gratuite de Roboflow (app.roboflow.com). Dans l’espace de travail du projet, accédez à ⚙ → API Roboflow et copiez la clé API privée.

Créez, dans ce dossier, le fichier chave_roboflow.txt contenant uniquement la clé, sans guillemets. Un modèle de ce fichier est disponible dans chave_roboflow.txt.exemplo.

Ajoutez chave_roboflow.txt au fichier .gitignore, car il contient une information d’authentification qui ne doit être ni versionnée ni partagée.

9.5.4.1 Détection d’objets avec Roboflow

Roboflow permet d’effectuer une inférence sur des images locales, permettant d’évaluer les performances du modèle hébergé dans l’identification des objets d’intérêt.

Outre l’inférence, le dataset peut être exporté au format png-mask-semantic, dans lequel chaque image est accompagnée d’un masque de segmentation sémantique. Dans ce masque, chaque pixel représente la classe à laquelle appartient l’objet correspondant. Les paires image-masque sont utilisées comme données d’entraînement pour la UNetCompacta.

9.5.4.2 Connexion, téléchargement et vérification du dataset

Le code établit une connexion avec le Workspace mctest et le projet geometric-test00, version 6, puis effectue le téléchargement du dataset vers dados/datasetRoboFlow. Ensuite, il vérifie le shape des images dans chaque split. La fonction de vérification est réutilisée ultérieurement dans la section.

from roboflow import Roboflow
from pathlib import Path
from PIL import Image
from collections import Counter

def contar_shapes(raiz, splits=("train", "valid", "test")):
    """Compte la forme (hauteur, largeur, canaux) des images par split."""
    for split in splits:
        pasta = raiz / split / "images"
        if not pasta.exists():
            print(f"{split}: dossier introuvable")
            continue

        shapes = Counter()
        for arquivo in pasta.iterdir():
            if arquivo.is_file():
                with Image.open(arquivo) as img:
                    shapes[(img.height, img.width, len(img.getbands()))] += 1

        txt = ", ".join(f"{s}: {n}" for s, n in shapes.items())
        print(f"{split}: {txt}")


chave = Path("chave_roboflow.txt")

if not chave.exists():
    print("Clé Roboflow introuvable : clé_roboflow.txt")
else:
    with open(chave) as f:
        api_key = f.read().strip()

    # Projet :
    # https://app.roboflow.com/mctest/geometric-test00/models
    # geometric-test00/6

    rf = Roboflow(api_key=api_key)
    projeto = rf.workspace("mctest").project("geometric-test00")
    versao = projeto.version(6)

    print(
        f"ID : {versao.version} | Nom : {versao.name} | "
        f"Images : {versao.images}"
    )

    raiz = Path("dados/datasetRoboFlow")
    versao.download("yolov8", location=str(raiz))

    print("Dataset :", raiz)
    contar_shapes(raiz)

9.5.4.3 Téléchargement du dataset de manière reproductible (alternative)

En alternative au dataset obtenu via Roboflow, on peut utiliser un dataset disponible dans un dépôt GitHub, également organisé selon les mêmes splits et contenant les mêmes classes d’objets. Les ensembles de données ne sont toutefois pas identiques : les images du GitHub ont une résolution de 608×608 pixels, tandis que les images exportées par Roboflow sont en 640×640 pixels.

Le code ci-dessous effectue le téléchargement du dataset depuis GitHub, s’il n’est pas encore disponible localement, et réutilise contar_shapes pour vérifier les dimensions des images dans chaque split.

import os

if not os.path.exists("dados/dataset"):
    cmd = (
        "git clone --no-checkout --depth 1 --filter=blob:none "
        "https://github.com/fzampirolli/pdi-vc.git tmp_repo && "
        "cd tmp_repo && git sparse-checkout set all/cap09/dados/dataset "
        "&& git checkout && cd .. && mkdir -p dados && "
        "cp -r tmp_repo/all/cap09/dados/dataset dados/dataset && "
        "rm -rf tmp_repo"
    )
    !{cmd}

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
  versao_recente = projeto.versions()[-1]
  print(f"Versão mais recente: {versao_recente.version.split('/')[-1]}")

  contar_shapes(Path("dados/dataset"))

9.5.4.4 Comparaison d’une image de chaque dataset

Les deux datasets possèdent des images avec des résolutions différentes : 608×608 sur GitHub et 640×640 sur Roboflow. Pour une comparaison visuelle directe, les images sont redimensionnées à la même dimension avant d’être affichées côte à côte (Figure 9.38).

import cv2

if not chave.exists():
    print("Clé Roboflow introuvable : clé_roboflow.txt")
else:
    caminho1 = next((raiz / "train/images").iterdir())
    caminho2 = next((Path("dados/dataset") / "train/images").iterdir())

    img1 = mm.read(str(caminho1))
    img2 = mm.read(str(caminho2))

    # Redimensionne les deux à la même taille (la plus petite des deux)
    largura = min(img1.shape[1], img2.shape[1])
    altura = min(img1.shape[0], img2.shape[0])
    img1_r = cv2.resize(img1, (largura, altura))
    img2_r = cv2.resize(img2, (largura, altura))

    mm.show(
        [img1_r, img2_r],
        title=[f"Roboflow {img1.shape}", f"GitHub {img2.shape}"],
    )
Figure 9.38

9.5.4.5 Inférence avec le modèle entraîné

Le modèle entraîné dans la version 6 est utilisé pour effectuer l’inférence sur une image de test locale. La prédiction prend en compte les seuils de confiance et de chevauchement employés par la suppression des non-maxima (Non-Maximum Suppression, NMS), et le résultat est présenté sur l’image annotée de la Figure 9.39.

# version.model est déprécié ; utiliser version.models()

if not chave.exists():
    print("Clé Roboflow introuvable : chave_roboflow.txt")
else:
    modelo = versao.models()[0]

    img_path = "dados/dataset/test/images/00001.jpg"
    pred = modelo.predict(img_path, confidence=40, overlap=30)
    resp = pred.json()  # inclut les boîtes détectées dans resp["predictions"]

    altura, largura, _ = mm.read(img_path).shape
    print("Dimensions de l'image de test :", (altura, largura))

    pred.save("resultado.jpg")  # image annotée

    mm.show(
        mm.read("resultado.jpg"),
        title="Résultat de l'inférence avec le modèle Roboflow",
        figsize=(6, 6)
    )
Figure 9.39

9.5.4.6 Évaluation des prédictions avec IoU et classe

Roboflow renvoie chaque boîte avec les coordonnées du centre (x, y) en pixels et la classe prédite, tandis que les étiquettes locales (dados/dataset/test/labels/00001.txt) suivent le format YOLO, avec le centre et les dimensions normalisés dans l’intervalle [0, 1]. Avant la comparaison à l’aide de mm.IoU, les boîtes doivent être converties dans le même format, avec les coordonnées du coin supérieur gauche et les dimensions exprimées en pixels.

Une prédiction n’est considérée comme correcte que lorsque la classe prédite coïncide avec la classe de la boîte réelle et que son Intersection over Union (IoU) est supérieure ou égale au seuil défini, en adoptant, dans cet exemple, 0,5 (50 %) comme valeur par défaut.

ImportantLe class_id de Roboflow ne correspond pas à l’index des labels locales

Lors de l’exportation, Roboflow réordonne les classes par ordre alphabétique dans le data.yaml, indépendamment de l’ordre utilisé dans le projet d’origine, conservé dans le data.yaml du GitHub. Ainsi, class_id = 0 correspond à Circulo dans la réponse de l’API, tandis que le même index correspond à Triangulo dans les labels locales.

Par conséquent, la comparaison doit être effectuée par le nom de la classe (p["class"]), en le convertissant ensuite en index correspondant dans la liste locale. Les class_id ne doivent pas être comparés directement.

# resp, largura e altura foram definidos na célula anterior

# Ordem das classes usada nas labels locais (dados/dataset/*/labels/*.txt)
CLASSES_LOCAIS = ["Triangulo", "Quadrado", "Pentagono", "Hexagono",
                   "Heptagono", "Circulo", "Elipse"]

def predicao_correta(pred: tuple, real: tuple, limiar: float = 0.5) -> bool:
    """True se mesma classe e mm.IoU(caixa_pred, caixa_real) >= limiar."""
    classe_pred, caixa_pred = pred
    classe_real, caixa_real = real
    return classe_pred == classe_real and mm.IoU(caixa_pred, caixa_real) >= limiar

def caixa_roboflow(p: dict) -> tuple:
    """Converte predição do Roboflow para (classe, (x, y, w, h))."""
    caixa = (p["x"] - p["width"] / 2, p["y"] - p["height"] / 2,
             p["width"], p["height"])
    # usa o nome da classe (não o class_id!) para casar com a ordem local
    classe = CLASSES_LOCAIS.index(p["class"])
    return (classe, caixa)

def carrega_labels_yolo(caminho_txt: str, largura: int, altura: int) -> list:
    """Lê rótulos YOLO (normalizados) e converte para (classe, (x, y, w, h))."""
    caixas = []
    with open(caminho_txt) as f:
        for linha in f:
            classe, xc, yc, w, h = map(float, linha.split())
            w_px, h_px = w * largura, h * altura
            x_px = xc * largura - w_px / 2
            y_px = yc * altura - h_px / 2
            caixas.append((int(classe), (x_px, y_px, w_px, h_px)))
    return caixas

if not chave.exists():
    print("Chave do Roboflow não encontrada: chave_roboflow.txt")
else:
    caixas_pred = [caixa_roboflow(p) for p in resp["predictions"]]
    caixas_real = carrega_labels_yolo(
        "dados/dataset/test/labels/00001.txt", largura, altura
    )

    acertos = sum(
        any(predicao_correta(cp, cr, limiar=0.5) for cr in caixas_real)
        for cp in caixas_pred
    )
    print(f"{acertos}/{len(caixas_pred)} predições corretas (classe + IoU >= 50%)")

Pour visualiser le résultat, chaque prédiction est dessinée sur l’image : en vert lorsqu’il s’agit d’une réussite (classe + IoU ≥ seuil) et en rouge lorsqu’il s’agit d’une erreur, comme le montre la Figure 9.40..

import cv2

VERDE, VERMELHO = (0, 255, 0), (255, 0, 0)

if not chave.exists():
    print("Clé Roboflow introuvable : chave_roboflow.txt")
else:
    img_acertos = mm.read(img_path).copy()

    if not chave.exists():
        print("Clé Roboflow introuvable : chave_roboflow.txt")
    else:
        for cp in caixas_pred:
            classe_pred, (x, y, w, h) = cp
            correta = any(predicao_correta(cp, cr, limiar=0.5) for cr in caixas_real)
            cor = VERDE if correta else VERMELHO

            p1, p2 = (int(x), int(y)), (int(x + w), int(y + h))
            cv2.rectangle(img_acertos, p1, p2, cor, 2)
            cv2.putText(img_acertos, str(classe_pred), (p1[0], p1[1] - 5),
                        cv2.FONT_HERSHEY_SIMPLEX, 0.5, cor, 2)

    mm.show(
        img_acertos,
        title=f"{acertos}/{len(caixas_pred)} prédictions correctes "
            f"(classe + IoU >= 50%)",
        figsize=(6, 6)
    )
Figure 9.40

9.5.5 Applications géométriques et intégrées

Le chapitre se termine en intégrant deux piliers de la VC : la géométrie projective (étudiée dans les chapitres 6 et 8) et l’apprentissage profond. La combinaison de ces approches soutient des applications pratiques dans le monde réel, comme illustré ci-dessous.

9.5.5.1 Réalité Augmentée avec Marqueurs et Homographie

Imaginez une caméra pointée vers une table sur laquelle quelqu’un a collé un petit marqueur ArUco. Selon l’angle de la caméra, ce marqueur apparaît pivoté, incliné, en perspective — jamais parfaitement carré. C’est précisément cette distorsion que l’homographie sait « lire » et corriger (ou, dans notre cas, répliquer vers une nouvelle image).

Le flux complet d’une application de RA basée sur des marqueurs suit trois étapes :

  1. Mise en situation : le marqueur est inséré dans une scène réelle, subissant une transformation de perspective (simulant l’angle de la caméra).
  2. Détection : l’algorithme localise le marqueur dans la scène et récupère les coordonnées exactes de ses 4 coins avec cv2.aruco.ArucoDetector.
  3. Substitution : grâce à l’homographie entre le marqueur « idéal » et le marqueur « détecté », on projette une nouvelle image virtuelle exactement sur la zone du marqueur — comme si celui-ci s’était transformé en une fenêtre vers un autre contenu, comme le montre la Figure 9.41.
Note

Détail technique important : l’ArUco nécessite une marge blanche autour du motif (la « zone de silence ») pour que le détecteur puisse différencier le marqueur du fond. C’est pourquoi le code ci-dessous ajoute une bordure avec cv2.copyMakeBorder et utilise une interpolation cv2.INTER_NEAREST lors de la déformation de l’image, évitant ainsi que la rotation ne floute les petits carrés noirs et blancs et n’empêche la détection.

# 1. Génération du marqueur ArUco synthétique, déjà avec une marge blanche (zone calme)
# → cette marge est essentielle pour que le détecteur puisse « voir » le marqueur
dic = cv2.aruco.getPredefinedDictionary(cv2.aruco.DICT_4X4_50)
aruco_bruto = cv2.aruco.generateImageMarker(dic, 7, 200)
aruco_gray = cv2.copyMakeBorder(
    aruco_bruto, 40, 40, 40, 40, cv2.BORDER_CONSTANT, value=255
)  # 200 -> 280px, avec 40px de cadre blanc de chaque côté
aruco = cv2.cvtColor(aruco_gray, cv2.COLOR_GRAY2BGR)
lado = aruco.shape[0]  # 280

# 2. Scène réelle de fond, en utilisant une image d'exemple de skimage.data
fundo = cv2.cvtColor(skdata.coffee(), cv2.COLOR_RGB2BGR)
cena = cv2.resize(fundo, (640, 480))

# Coins du marqueur « de face » (src) et sa position tournée/inclinée dans la scène (dst)
src = np.float32([[0, 0], [lado, 0], [lado, lado], [0, lado]])
dst = np.float32([[190, 160], [420, 70], [470, 330], [150, 370]])  # rotation + perspective

# 3. Projette le marqueur (avec des bords nets) sur la scène réelle
H_cena, _ = cv2.findHomography(src, dst)
mask_cena = cv2.warpPerspective(
    np.full((lado, lado), 255, np.uint8), H_cena, (640, 480),
    flags=cv2.INTER_NEAREST,
)
cena[mask_cena > 0] = cv2.warpPerspective(
    aruco, H_cena, (640, 480), flags=cv2.INTER_NEAREST
)[mask_cena > 0]

# 4. Détection du marqueur dans la scène (comme le ferait une caméra)
det = cv2.aruco.ArucoDetector(dic, cv2.aruco.DetectorParameters())
corners, ids, _ = det.detectMarkers(cena)

assert ids is not None and len(corners) > 0, \
    "Marcador não detectado — confira iluminação/contraste da cena."

# 5. Image virtuelle (une autre image de skimage.data) qui va « remplacer » le marqueur
# Remarque : nous utilisons le carré INTÉRIEUR du marqueur (sans la marge) comme zone de projection,
# donc l'homographie de l'image virtuelle utilise 'src' original (200x200), pas 'side' avec bordure
src_interno = np.float32([[0, 0], [200, 0], [200, 200], [0, 200]])
virtual = cv2.resize(
    cv2.cvtColor(skdata.camera(), cv2.COLOR_RGB2BGR), (200, 200)
)

# Les coins détectés correspondent au marqueur AVEC la marge (280x280),
# donc nous recalculons H_ra en utilisant 'src' avec marge, pour garder la projection cohérente
H_ra, _ = cv2.findHomography(src, corners[0][0])

# L'homographie détectée est appliquée à l'image virtuelle (redimensionnée à 'side')
virtual_grande = cv2.resize(virtual, (lado, lado))
ra = cena.copy()
mask_ra = cv2.warpPerspective(
    np.full((lado, lado), 255, np.uint8), H_ra, (640, 480), flags=cv2.INTER_NEAREST
)
ra[mask_ra > 0] = cv2.warpPerspective(
    virtual_grande, H_ra, (640, 480), flags=cv2.INTER_NEAREST
)[mask_ra > 0]

# Affichage : scène avec marqueur vs. scène avec Réalité Augmentée appliquée
mm.show(
    [cv2.cvtColor(cena, cv2.COLOR_BGR2RGB), cv2.cvtColor(ra, cv2.COLOR_BGR2RGB)],
    titles=["Marqueur dans la Scène Réelle (tourné)", "Superposition Virtuelle via Homographie"],
    cols=2,
    figsize=(9, 4),
)
Figure 9.41: Réalité augmentée basée sur un marqueur ArUco : marqueur inséré dans une scène réelle et tourné, détecté et remplacé par une image virtuelle via homographie.

Résumé du pipeline :

Étape Fonction Clé
1. Génération Marqueur ArUco avec marge blanche generateImageMarker + copyMakeBorder
2. Mise en scène Insère un marqueur déformé dans la scène findHomography + warpPerspective
3. Détection Localise le marqueur et retourne les coins ArucoDetector.detectMarkers
4. Remplacement Projette une image virtuelle sur le marqueur findHomography + warpPerspective

💡 Leçon : le détecteur qui « ne trouve rien » est courant en vision par ordinateur. Demandez-vous toujours : « ai-je fourni suffisamment de contraste et d’espace ? » — cela vaut pour ArUco, les codes QR et la reconnaissance faciale.

9.5.5.2 Photogrammétrie et référence d’échelle

La photogrammétrie permet d’estimer les dimensions physiques d’objets à partir d’images numériques. Pour cela, on utilise un objet de référence aux dimensions connues, placé dans la même scène que l’objet d’intérêt. Cette procédure établit une relation entre les distances mesurées en pixels et leurs dimensions correspondantes dans le monde réel.

Considérons, par exemple, une carte de crédit, dont les dimensions suivent la norme internationale ISO/IEC 7810. Comme sa largeur est exactement de 8,56 cm, il suffit de déterminer combien de pixels cette largeur occupe dans l’image pour calculer le facteur de conversion entre pixels et centimètres. Si la carte correspond à 140 pixels, alors chaque pixel représentera approximativement 0,061 cm. Ce même facteur d’échelle peut être appliqué pour estimer les dimensions de tout autre objet situé dans le même plan de la scène, comme illustré dans la Figure 9.42.

La procédure peut être divisée en deux étapes principales :

  1. Segmentation et bounding box : localiser, dans l’image, à la fois l’objet de référence et l’objet d’intérêt, en utilisant des techniques telles que la segmentation par couleur, le seuillage, la détection de contours ou des méthodes de détection d’objets.
  2. Conversion en dimensions physiques : calculer le rapport \(\mathrm{cm/pixel}\) à partir de la largeur connue de l’objet de référence et l’utiliser pour convertir les mesures de l’objet d’intérêt de pixels en centimètres.
Note

Condition pour des mesures fiables

La conversion entre pixels et centimètres suppose que l’objet de référence et l’objet d’intérêt soient approximativement dans le même plan et à la même distance de la caméra. Dans ces conditions, l’échelle reste pratiquement constante sur toute l’image. Les différences de profondeur, l’inclinaison de la caméra ou les distorsions de l’objectif peuvent introduire des erreurs dans les mesures estimées.

COR_REFERENCIA = (200, 200, 200)  # Carte de référence (grise)
COR_OBJETO = (60, 60, 220)  # Objet cible (rouge)

# Dessin de la scène synthétique
cena_medicao = np.full((300, 500, 3), 255, dtype=np.uint8)
cv2.rectangle(
    cena_medicao, (30, 200), (30 + 140, 200 + 88), COR_REFERENCIA, -1
)
cv2.rectangle(cena_medicao, (250, 100), (250 + 220, 100 + 150), COR_OBJETO, -1)


def caixa_delimitadora_por_cor(imagem_bgr, cor_bgr, tolerancia=40):
    diferenca = np.abs(imagem_bgr.astype(int) - np.array(cor_bgr)).sum(axis=2)
    mascara = (diferenca < tolerancia).astype(np.uint8) * 255
    contornos, _ = cv2.findContours(
        mascara, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
    )
    maior_contorno = max(contornos, key=cv2.contourArea)
    return cv2.boundingRect(maior_contorno)


x_ref, y_ref, w_ref_px, h_ref_px = caixa_delimitadora_por_cor(
    cena_medicao, COR_REFERENCIA
)
x_obj, y_obj, w_obj_px, h_obj_px = caixa_delimitadora_por_cor(
    cena_medicao, COR_OBJETO
)

# Calcul d'échelle physique
LARGURA_REFERENCIA_CM = 8.56
razao_cm_por_px = LARGURA_REFERENCIA_CM / w_ref_px
largura_obj_cm = w_obj_px * razao_cm_por_px
altura_obj_cm = h_obj_px * razao_cm_por_px

# Dessin des boîtes et mesures estimées
resultado = cena_medicao.copy()
cv2.rectangle(
    resultado, (x_ref, y_ref), (x_ref + w_ref_px, y_ref + h_ref_px), (0, 180, 0), 2
)
cv2.rectangle(
    resultado, (x_obj, y_obj), (x_obj + w_obj_px, y_obj + h_obj_px), (0, 180, 0), 2
)

cv2.putText(
    resultado,
    f"{LARGURA_REFERENCIA_CM:.2f} cm",
    (x_ref, y_ref - 8),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.55,
    (0, 120, 0),
    2,
)

cv2.putText(
    resultado,
    f"{largura_obj_cm:.1f} x {altura_obj_cm:.1f} cm",
    (x_obj, y_obj - 8),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.6,
    (0, 120, 0),
    2,
)

mm.show(
    [
        cv2.cvtColor(cena_medicao, cv2.COLOR_BGR2RGB),
        cv2.cvtColor(resultado, cv2.COLOR_BGR2RGB),
    ],
    titles=[
        "Scène originale",
        "Mesure par référence d'échelle (centimètres)",
    ],
    cols=2,
    figsize=(9, 4),
)
Figure 9.42: Mesure de dimensions physiques réelles à l’aide d’une carte de référence d’échelle connue (8,56 cm) rendue. Le rectangle gris représente la carte de référence et le rectangle bleu représente l’objet cible. Les dimensions estimées de l’objet sont affichées en centimètres.

Résumé du pipeline :

Étape Ce qu’elle fait Fonction clé
1. Scène synthétique Dessine la carte de référence et l’objet cible avec des couleurs distinctes cv2.rectangle
2. Segmentation Isole chaque objet par couleur et extrait son contour cv2.findContours
3. Boîte englobante Obtient la boîte englobante (position et taille en pixels) de chaque objet cv2.boundingRect
4. Mise à l’échelle Convertit les pixels en centimètres en utilisant la largeur connue de la carte Règle de trois : \(\text{cm/pixel} = \dfrac{8{,}56}{w_{ref\_px}}\)
5. Annotation Dessine les boîtes et affiche les mesures estimées sur l’image cv2.rectangle + cv2.putText

💡 Application dans le monde réel : c’est exactement la technique utilisée par les apps de e-commerce qui estiment la taille d’un produit à partir d’une photo prise à côté d’une carte, par les systèmes agricoles qui mesurent des fruits sur des tapis roulants, et même par les expertises forensiques qui calculent les dimensions de traces sur des scènes de crime — tout repose sur la même idée : une règle connue dans la photo elle-même.

9.6 Résumé

Ce chapitre, qui clôt la deuxième partie de l’ouvrage, a présenté :

  • Convolution et pooling appris : la même opération mathématique de convolution qu’au chapitre 3, mais avec des kernels traités comme des paramètres ajustés par l’entraînement, au lieu d’être définis manuellement ;

  • Le partage des poids et la hiérarchie des caractéristiques comme propriétés qui rendent les CNN efficaces et capables d’apprendre des représentations de plus en plus abstraites dans des couches successives ;

  • L’entraînement d’une CNN de zéro, avec des performances comparables — et pas nécessairement supérieures — aux classificateurs classiques du chapitre 7 sur une base petite et simple, soulignant que le choix de la méthode doit être proportionnel à la complexité réelle du problème ;

  • Le transfert d’apprentissage, démontré expérimentalement comme une stratégie efficace pour des tâches avec peu de données étiquetées, réutilisant un extracteur de caractéristiques déjà entraîné sur une tâche connexe, et ses limites, mises en évidence par le transfert négatif entre des domaines très distincts ;

  • Des applications à grande échelle avec des modèles pré-entraînés de classification, de détection (Faster R-CNN) et de segmentation (DeepLabV3), suivant le même principe de transfert d’apprentissage à l’échelle industrielle ;

  • Le transfert d’apprentissage appliqué à la détection d’objets, en ajustant un YOLO pré-entraîné sur COCO pour localiser et classer des objets géométriques synthétiques, illustrant le même principe de gel partiel dans un domaine source et cible encore plus éloignés l’un de l’autre ;

  • La segmentation sémantique avec U-Net, implémentée et entraînée de zéro sur un ensemble synthétique à faible contraste, surpassant une ligne de base classique de seuillage grâce aux connexions de raccourci entre l’encodeur et le décodeur ;

  • L’ingénierie des données pour la vision par ordinateur avec Roboflow, en utilisant un dataset et un modèle pré-entraîné pour effectuer une inférence sur la détection d’objets géométriques, ainsi qu’en comparant des ensembles de données avec des résolutions différentes, mais avec les mêmes classes d’objets ;

  • L’intégration de la géométrie computationnelle (homographie et calibrage) et de l’apprentissage profond dans deux applications réelles qui clôturent l’ouvrage : la réalité augmentée et la photogrammétrie.

9.7 🤖 Utilisation de Gemini Notebook comme tuteur complémentaire

Dans cette édition, l’utilisation de Gemini Notebook est encouragée comme outil d’apprentissage complémentaire. Fondé sur l’intelligence artificielle, le système utilise exclusivement les documents fournis par l’auteur comme source de connaissance, produisant des réponses alignées sur le contenu et l’approche adoptés tout au long de ce chapitre.

Important🎓 Étudiez avec le tuteur intelligent

🚀 ACCÉDER À GEMINI NOTEBOOK : CHAPITRE 09

🌐 Langue et langage de programmation

Le projet de ce chapitre dans Gemini Notebook a été construit uniquement avec le texte en portugais et les exemples de code en Python. Si vous étudiez à partir de l’édition en anglais ou en français, ou si vous suivez le parcours en C++, les réponses du tuteur peuvent ne pas correspondre exactement à la version que vous lisez.

⚠️ Avis concernant le contenu généré par l’IA

Bien qu’il s’agisse d’un outil précieux de soutien à l’étude, Gemini Notebook peut éventuellement produire des réponses incomplètes, imprécises ou incorrectes. Il est recommandé de valider les informations en consultant le matériel du chapitre, les livres, les articles scientifiques et d’autres sources académiques fiables. Dans la mesure du possible, exécutez et expérimentez les exemples pratiques présentés tout au long du texte afin de consolider la compréhension des concepts.

9.8 Liste d’exercices

Les exercices suivants consolident les concepts présentés dans ce chapitre à travers des adaptations, des expériences et des extensions des algorithmes développés tout au long du texte, en utilisant les bibliothèques PyTorch, ultralytics et la bibliothèque didactique morph.

  1. (10%) Étudiez l’impact de la profondeur dans une architecture convolutionnelle. En partant du réseau à deux couches du Projet Pratique 1, ajoutez une troisième couche convolutionnelle avec 32 filtres avant les couches entièrement connectées. Entraînez la nouvelle architecture en conservant le même nombre d’époques et la même répartition des données. Comparez la précision sur l’ensemble de test et le nombre total de paramètres entraînables par rapport au réseau original, en discutant si l’augmentation de la profondeur a apporté un bénéfice mesurable pour des images de dimension \(8 \times 8\).

  2. (15%) Évaluez le seuil de données nécessaires dans le domaine cible pour que l’entraînement d’un CNN de zéro devienne compétitif avec le transfert d’apprentissage. En faisant varier le nombre d’échantillons d’entraînement disponibles dans le domaine B pour \(\{5, 10, 20, 40, 80\}\), mesurez la précision de test pour les deux stratégies. Présentez les résultats dans un graphique en lignes et déterminez à partir de quel volume de données l’entraînement de zéro atteint des performances équivalentes à celles de l’extracteur pré-entraîné.

  3. (15%) Étudiez la stratégie de réglage fin partiel (fine-tuning) par rapport au gel total des poids. Dans le scénario de transfert d’apprentissage entre domaines de chiffres, décongelez la deuxième couche convolutionnelle (conv2) de l’extracteur afin qu’elle soit mise à jour conjointement avec la tête de classification pendant l’entraînement dans le domaine B. Comparez la précision obtenue avec le gel total et avec l’entraînement de zéro, en discutant du compromis entre la capacité d’adaptation et le risque de sur-ajustement (overfitting).

  4. (20%) Évaluez l’influence de la profondeur du gel (freeze) sur les performances des détecteurs YOLO soumis au transfert d’apprentissage. En utilisant l’ensemble de données synthétique de formes géométriques, exécutez le réglage fin en faisant varier le paramètre de gel du backbone pour \(\{0, 5, 10, 15\}\). Enregistrez la métrique \(\text{mAP}_{50}\) sur l’ensemble de validation pour chaque configuration, présentez les données dans un tableau et discutez si le gel partiel est avantageux lorsque les domaines source (COCO) et cible (formes géométriques) sont significativement distincts.

  5. (20%) Étudiez l’importance des connexions de raccourci (skip connections) dans l’architecture U-Net pour la segmentation sémantique. Implémentez une variante UNetSemAtalhos qui fonctionne comme un autoencodeur convolutionnel traditionnel, en supprimant les concaténations entre les étages de l’encodeur et du décodeur. Entraînez les deux modèles sur la même base de nodules synthétiques, comparez l’IoU moyen sur l’ensemble de validation et présentez visuellement la différence dans la précision des contours segmentés par chaque méthode.

  6. (20%) — Défi : segmentation sémantique avec Roboflow. Utilisez un projet Roboflow de type instance segmentation, contenant les sept classes de formes géométriques utilisées dans ce chapitre. Exportez l’ensemble de données au format coco-segmentation et développez une procédure pour convertir les polygones stockés dans les fichiers _annotations.coco.json en masques sémantiques multiclasses, dans lesquels chaque pixel reçoit l’indice de la classe correspondante et la valeur 0 représente le fond. Utilisez les images et les masques résultants pour entraîner la UNetCompacta. Évaluez l’IoU moyen sur l’ensemble de test et comparez visuellement les masques prédits avec les annotations originales. Discutez des principales difficultés rencontrées lors de la conversion des annotations COCO en masques et des effets des objets superposés ou appartenant à différentes classes.

  7. (Bonus – 10%) Développez un système interactif qui combine la détection d’objets (YOLO) avec la mesure par référence d’échelle (photogrammétrie). Entraînez le détecteur pour identifier deux classes dans une scène : une « Carte de Référence » (dimension connue de \(8{,}56\text{ cm} \times 5{,}39\text{ cm}\)) et un « Objet Cible ». Lors de l’inférence sur une nouvelle image, utilisez la dimension en pixels de la boîte englobante de la carte détectée pour convertir les dimensions de la boîte de l’objet cible en centimètres. Affichez l’image traitée avec les étiquettes de classe, la probabilité de confiance et les dimensions physiques estimées superposées.

  8. (Bonus – 10%) Développez un système d’estimation de profondeur par vision stéréo à partir de deux images de la même scène obtenues depuis des positions différentes, simulant une paire de caméras stéréo. Considérez que la distance entre les deux positions de capture (baseline) est connue.

    Utilisez l’une des méthodes de détection d’objets présentées dans le chapitre, comme YOLO, pour localiser les objets d’intérêt dans les deux images. Pour chaque détection, établissez la correspondance entre le même objet dans les deux positions et déterminez sa disparité. À partir de la disparité, de la baseline et des paramètres de la caméra, utilisez la géométrie stéréo pour estimer la distance de chaque objet par rapport aux caméras.

    Comme extension de la bibliothèque didactique morph, modifiez la méthode showBoundBox afin qu’en plus de la classe et de la confiance de la détection, elle présente sur chaque boîte englobante la distance estimée de l’objet. Le résultat doit permettre de visualiser directement, sur les images, la classe, la précision (confiance) et la profondeur de chaque objet détecté.

    Présentez les deux images avec les détections, les correspondances entre les objets, l’image de disparité et une représentation de la profondeur estimée. Discutez de la manière dont la distance entre les caméras, la précision de la détection et de la correspondance, la résolution des images et la position de l’objet dans la scène influencent la qualité de l’estimation.

    Pour la validation, utilisez au moins un objet dont la distance à la caméra est connue. Comparez la profondeur estimée avec la valeur réelle et rapportez l’erreur absolue et l’erreur relative. Discutez également des limites de la méthode lorsqu’un objet n’est pas correctement détecté dans les deux images ou lorsque la correspondance entre les régions observées est ambiguë.

9.9 Clôture de la Partie II

Ce chapitre conclut la Partie II du livre et clôt la séquence de contenus initiée au chapitre 6, consacrée à la représentation, la détection, la description et la mise en correspondance de caractéristiques dans les images. Tout au long de ces chapitres, des méthodes classiques de vision par ordinateur (VC) basées sur des caractéristiques conçues manuellement, telles que Sobel, LBP, HOG, ORB et Haar Cascade, ainsi que des méthodes fondées sur des caractéristiques apprises automatiquement, représentées par les CNN, ont été présentées.

Les exemples et expériences développés montrent qu’aucune de ces approches n’est universellement supérieure. Le choix de la technique la plus adaptée dépend des caractéristiques du problème, de la disponibilité des données d’entraînement, des exigences de précision et des contraintes computationnelles de l’application. Dans des problèmes bien structurés et avec peu de données, les descripteurs classiques offrent souvent des solutions simples et efficaces. En revanche, des tâches plus complexes tendent à bénéficier de la capacité d’apprentissage offerte par les CNN.

Plusieurs directions d’étude peuvent approfondir les concepts présentés dans cette partie du livre, parmi lesquelles :

  • Les architectures modernes de CNN, telles que ResNet, EfficientNet et les Vision Transformers, qui élargissent la capacité de représentation et les performances dans les tâches de classification et de reconnaissance visuelle ;
  • La détection et la segmentation d’objets, avec un accent sur les familles YOLO et les modèles de segmentation basés sur des prompts, comme Segment Anything ;
  • La reconstruction tridimensionnelle et le SLAM (Simultaneous Localization and Mapping), qui utilisent plusieurs images pour estimer la géométrie de la scène et la trajectoire de caméras en mouvement ;
  • Les modèles génératifs d’images, tels que les réseaux adversariaux génératifs (GAN) et les modèles de diffusion, capables de synthétiser des images réalistes à partir d’exemples ou de descriptions textuelles.

Les fondements développés tout au long de la Partie II constituent la base de ces domaines et d’autres domaines avancés de la VC, dans lesquels la représentation adéquate des informations visuelles demeure un élément central pour l’analyse et la compréhension des images.

Références du chapitre

Les concepts et algorithmes présentés dans ce chapitre s’appuient sur des références classiques et contemporaines de la littérature sur l’apprentissage profond appliqué à la vision par ordinateur :

  • Mcculloch (1943), pour la proposition de la première abstraction mathématique et logique du neurone artificiel, posant les fondements conceptuels du traitement neuronal computationnel.
  • Rosenblatt (1958), pour la formulation originale du Perceptron, modèle précurseur du neurone artificiel utilisé dans les architectures modernes d’apprentissage profond.
  • Goodfellow (2016) et Lecun (2015), pour les fondements des réseaux de neurones, de la convolution, des fonctions d’activation et de l’entraînement des modèles profonds.
  • Bishop (2006), pour les concepts rigoureux de reconnaissance des formes, de probabilité, d’estimation du maximum de vraisemblance et de méthodes statistiques appliquées à l’apprentissage automatique.
  • Ronneberger (2015), pour l’architecture U-Net, utilisée dans la segmentation sémantique avec des connexions de raccourci entre l’encodeur et le décodeur.
  • Redmon (2016), pour l’architecture YOLO (You Only Look Once), utilisée dans les expériences de détection d’objets avec transfert d’apprentissage.
  • Ren (2015), pour l’architecture Faster R-CNN, employée comme modèle pré-entraîné de détection d’objets.
  • He (2016), pour l’architecture ResNet, base de divers extracteurs de caractéristiques pré-entraînés utilisés dans ce chapitre.
  • Chen (2018), pour l’architecture DeepLabV3, utilisée comme modèle pré-entraîné de segmentation sémantique.
  • Kirillov (2023), pour le modèle Segment Anything (SAM), mentionné comme piste d’étude pour la segmentation promptable.
  • {google} (2025), concernant l’outil Gemini Notebook, utilisé dans l’élaboration de l’infographie de synthèse du chapitre et mis à disposition comme support complémentaire à l’étude.

9.10 💻 Partie Pratique avec des Exercices de Programmation

La présente liste d’Exercices de Programmation (EP) consolide les formulations théoriques présentées tout au long du Chapitre 9 — Apprentissage Profond pour la Vision par Ordinateur — à travers un parcours pratique appliqué. Contrairement à l’entraînement de réseaux de neurones complets avec PyTorch, qui exige du temps d’exécution et parfois un GPU, les EP de ce chapitre isolent les grandeurs intermédiaires d’un pipeline réel d’apprentissage profond — la sortie d’une seule couche convolutive, le résultat d’une opération de pooling, le comptage des paramètres entraînables d’une architecture, le chevauchement entre boîtes englobantes candidates, la qualité d’un masque de segmentation et le filtre de suppression des non-maximums — permettant de valider manuellement chaque étape du raisonnement sans dépendre de bibliothèques d’apprentissage automatique ni d’un entraînement réel.

L’enchaînement des exercices reproduit le flux conceptuel du chapitre et croît en difficulté à chaque étape : on commence par le calcul manuel de la sortie d’une couche convolutive apprise (🟢), à partir d’un kernel et d’un biais déjà entraînés ; on avance vers l’opération de pooling (🟢, maximum et moyenne), qui réduit la résolution spatiale entre les blocs convolutifs ; on poursuit avec le comptage des paramètres entraînables (🟡) d’une architecture CNN complète, mettant en évidence pourquoi le partage des poids rend ces réseaux tellement plus économes qu’une couche entièrement connectée équivalente ; on approfondit le calcul de l’Intersection sur Union (IoU) et de la Suppression des Non-Maximums (NMS) (🟡), étape de post-traitement commune aux détecteurs comme Faster R-CNN et YOLO ; on passe à l’évaluation des masques de segmentation (🟠) avec les mêmes métriques IoU et Dice utilisées pour comparer U-Net à la ligne de base morphologique classique ; et on conclut avec un pipeline intégré (🔴), reliant la sortie d’un détecteur d’objets (après NMS) à une mesure du monde réel par référence d’échelle — le même principe que la photogrammétrie étudiée dans l’intégration finale du chapitre.

Chaque fois que cela a du sens, chaque exercice indique des méthodes de la bibliothèque didactique morph.py (la même utilisée tout au long du chapitre, importée sous le nom mm) qui résolvent une étape du problème ou servent de référence pour vérifier vos propres calculs — sans toutefois remplacer le raisonnement que vous devez implémenter.

ImportantDirectives pour la Résolution des Exercices de Programmation

Dans tous les exercices de ce chapitre, les étapes de discrétisation ou d’arrondi numérique doivent employer l’arrondi standard à l’entier le plus proche (round half away from zero), atténuant les ambiguïtés pour les valeurs dont la fraction est exactement égale à \(0{,}5\). Sauf indication explicite contraire : (i) l’opération de « convolution » suit la convention adoptée par les frameworks d’apprentissage profond — corrélation croisée, sans inversion spatiale du kernel, exactement comme présenté dans la section « Couche convolutive » ; (ii) le remplissage (padding) se fait avec des zéros ; (iii) les boîtes englobantes sont spécifiées au format coin-à-coin \((x_1, y_1, x_2, y_2)\), avec \(x_1 < x_2\) et \(y_1 < y_2\) ; et (iv) les vecteurs/matrices suivent une indexation à partir de \(0\), avec la convention [ligne][colonne] pour les structures bidimensionnelles.

🎯 Objectif de ce Carnet

Le carnet permet de développer, valider, organiser et tester des solutions d’Exercices de Programmation (EPs) dans des environnements interactifs, comme Colab, avec les mêmes cas de test que Moodle, en les y copiant uniquement au moment d’enregistrer la note officielle.

Téléchargement

Téléchargez morph.py et testsuite.py en exécutant la cellule ci-dessous :

import os, urllib.request

url = "https://raw.githubusercontent.com/fzampirolli/pdi-vc/master/morph/config.py"
if not os.path.exists("config.py"):
    urllib.request.urlretrieve(url, "config.py")

import config
config.setup(testsuite=True)
from morph import mm
from testsuite import TestSuite
✅ Environnement prêt. Morph : 1.1.9 | OpenCV : 5.0.0 | TestSuite : 1.1.2

Exécution des tests

Pour évaluer les tests, exécutez TestSuite("EP09_01.extensão").run() dans une nouvelle cellule, en remplaçant l’extension par celle du langage utilisé (.py, .java, .c, .cpp, .js ou .r). Le système télécharge les cas de test depuis GitHub, exécute le programme et calcule automatiquement la note.

Pour tester directement du code Python, sans enregistrer de fichier, utilisez run_code(codigo) en passant le code comme chaîne de caractères dans une variable codigo :

codigo = """
# ... votre code ici ...
"""
TestSuite("EP09_01").run_code(codigo)

9.10.1 EP09_01 🟢 Convolution 2D Manuelle (Forward d’une Couche Apprise)

PyTorch, présenté dans ce chapitre, exécute nn.Conv2d(x) en un seul appel — mais derrière cela se cache simplement la corrélation croisée entre un kernel (déjà entraîné) et un voisinage de l’entrée, suivie de la somme d’un biais et d’une activation, exactement comme formalisé dans la Section « Couche Convolutionnelle ». La différence essentielle par rapport à la convolution de kernels fixes du Chapitre 3 est qu’ici, les valeurs du kernel et du biais sont déjà prêtes (comme si elles avaient été apprises par descente de gradient), et il vous revient de reproduire manuellement la passe directe (forward pass) que le framework exécute en interne.

Avant d’entraîner une véritable CNN, vous êtes chargé d’implémenter cette passe directe à partir de zéro, pour une seule couche convolutionnelle avec un seul canal d’entrée et un seul filtre de sortie, incluant la prise en charge d’un padding et d’un stride arbitraires.

9.10.1.1 📋 Directives d’Implémentation

  1. Entrée : Lire les dimensions \(H \times W\) de la carte de caractéristiques d’entrée, puis ses \(H \times W\) valeurs réelles.

  2. Kernel et biais : Lire les dimensions \(k_h \times k_w\) du kernel (déjà entraîné), ses valeurs réelles, et le biais \(b\) (réel, scalaire).

  3. Hyperparamètres : Lire le padding \(p\) (entier, nombre de zéros ajoutés à chaque bord) et le stride \(s\) (entier, pas de glissement).

  4. Remplissage : Ajouter \(p\) zéros sur chacune des quatre bordures de la carte d’entrée avant la corrélation.

  5. Corrélation croisée : Pour chaque position de sortie \((i, j)\), calculer \[ z(i,j) = b + \sum_{u=0}^{k_h-1} \sum_{v=0}^{k_w-1} K(u,v) \cdot X_{pad}(i \cdot s + u,\; j \cdot s + v), \] en parcourant l’entrée sans inverser le kernel (convention des frameworks d’apprentissage profond, différente de la convolution mathématique classique).

  6. Activation : Appliquer ReLU à chaque valeur : \(a(i,j) = \max(0, z(i,j))\).

  7. Dimensions de sortie : \(O_h = \lfloor (H + 2p - k_h)/s \rfloor + 1\) et \(O_w = \lfloor (W + 2p - k_w)/s \rfloor + 1\).

  8. Sortie : Imprimer \(O_h\) et \(O_w\) sur la première ligne, suivis de \(O_h\) lignes avec chacune \(O_w\) valeurs réelles (la carte de caractéristiques de sortie, déjà avec ReLU appliquée), formatées avec 4 décimales.

9.10.1.2 📌 Contraintes Computationnelles

  • Un canal d’entrée, un filtre de sortie : Il n’est pas nécessaire de gérer plusieurs canaux ou plusieurs filtres dans cette version simplifiée.
  • Sans inversion du kernel : Implémentez la corrélation croisée, et non la convolution mathématique classique avec kernel inversé — c’est l’opération que PyTorch (et la plupart des frameworks) appelle « convolution ».
  • Remplissage par zéros : Les \(p\) pixels ajoutés à chaque bord valent toujours \(0\).
  • Formatage : Toutes les valeurs de sortie doivent avoir exactement 4 décimales, même lorsque la valeur est entière (ex. : 2.0000).

9.10.1.3 🧠 Fondements Théoriques

Élément Rôle dans la couche convolutionnelle
Kernel \(K\) Paramètres appris par descente de gradient, analogues aux coefficients d’un filtre fixe du Chapitre 3, mais ajustés par rétropropagation
Biais \(b\) Décalage appris, ajouté après la corrélation — permet au neurone de « s’activer » même avec une entrée nulle
Padding Contrôle la dimension spatiale de sortie et évite la perte d’information aux bords à chaque couche
Stride Contrôle le pas du déplacement ; des valeurs \(> 1\) réduisent la résolution spatiale, comme une forme de sous-échantillonnage intégrée à la convolution elle-même
ReLU Introduit une non-linéarité après la combinaison linéaire, exactement comme dans la Section « Fonction d’Activation »

9.10.1.4 🧩 Méthodes de morph.py pouvant aider

  • mm.readImg(h, w, dtype='float') — lit directement une matrice \(h \times w\) de valeurs réelles depuis l’entrée standard, évitant ainsi le parsing manuel de la carte de caractéristiques et du kernel.
  • mm.correlacao0(f, kernel, bias) — implémente la même somme de corrélation croisée + biais que vous allez calculer à la main, mais sans prise en charge du padding ou du stride, et convertit le résultat en uint8 (tronque les valeurs négatives et décimales). Peut servir de référence conceptuelle ou pour vérifier le cas le plus simple (\(p=0\), \(s=1\)), mais ne remplace pas votre implémentation complète — qui doit préserver le signe, les décimales, le padding, le stride et ReLU.

9.10.1.5 📦 Spécification d’Entrée et de Sortie (VPL)

Entrée :

  • Ligne 1 : Entiers \(H\) et \(W\).
  • Les \(H\) lignes suivantes : \(W\) valeurs réelles chacune (carte d’entrée).
  • Ligne suivante : Entiers \(k_h\) et \(k_w\).
  • Les \(k_h\) lignes suivantes : \(k_w\) valeurs réelles chacune (kernel).
  • Ligne suivante : Réel \(b\) (biais).
  • Ligne suivante : Entiers \(p\) et \(s\).

Sortie :

  • Ligne 1 : Entiers \(O_h\) et \(O_w\).
  • Les \(O_h\) lignes suivantes : \(O_w\) valeurs réelles chacune, avec 4 décimales.

9.10.1.6 📌 Exemples

Entrée Sortie Observation
3 3
1 2 0
0 1 2
1 0 1
2 2
1 1
1 1
-2
0 1
2 2
2.0000 3.0000
0.0000 2.0000
Padding 0, stride 1 : sortie \(2\times2\) sans remplissage.
3 3
1 2 0
0 1 2
1 0 1
2 2
1 0
0 1
0
1 2
2 2
1.0000 0.0000
1.0000 2.0000
Padding 1, stride 2 : entrée remplie de zéros avant la corrélation.
🎮 Simulateur : Convolution 2D manuelle 🟢 corrélation croisée + biais + ReLU

Entrée fixe 4×4, noyau fixe 2×2 (surligné en bleu) — ajustez padding (p), stride (s) et biais (b), exactement les paramètres que l'EP09_01 demande en entrée, et voyez comment ils modifient la taille et les valeurs de la sortie.

Padding (p)
Pas (s)
Biais (b)
(0,0)
Entrée X rembourrée (avec padding)
original padding (0) fenêtre actuelle
Noyau K (2×2)
Sortie Y = ReLU(X⊛K + b)

💡 Chaque position du curseur révèle une cellule de la matrice de sortie. Parcourez toutes les positions pour compléter la carte de sortie. Changer p, s ou b réinitialise l'exploration, car la carte de sortie change de taille et/ou de valeurs.

Figure 9.43: Simulateur EP09_01 : Convolution 2D manuelle (corrélation croisée + biais + ReLU, avec padding et stride ajustables)
%%writefile EP09_01.py
# Code Python
Overwriting EP09_01.py
TestSuite("EP09_01.py").run()
✔️ EP09_01.cases existe déjà dans casos/
📋 3 cas chargé(s) depuis casos/EP09_01.cases

🔍 Test de Python : EP09_01.py
⚠️ EP09_01.py : fichier vide (moins de 3 lignes). Tests ignorés.

9.10.2 EP09_02 🟢 Pooling manuel (Maximum et moyenne)

Entre les blocs convolutifs, l’architecture typique d’un CNN intercale des couches de pooling, qui réduisent la résolution spatiale de la carte de caractéristiques sans introduire de nouveaux paramètres entraînables — contrairement à la convolution, le pooling n’a pas de poids : il résume simplement chaque fenêtre de l’entrée à une seule valeur, par un maximum ou par une moyenne, exactement comme formalisé dans la section “Pooling”.

Vous êtes chargé d’implémenter cette opération à partir d’une fenêtre glissante carrée, sans chevauchement partiel sur les bords (uniquement des fenêtres complètes), en supportant les deux types les plus courants : max (préserve la valeur la plus saillante, typiquement utilisée pour retenir les bords et les textures fortes) et avg (lisse la région, préservant l’information d’intensité moyenne).

9.10.2.1 📋 Directives d’implémentation

  1. Entrée : Lire les dimensions \(H \times W\) de la carte de caractéristiques d’entrée et ses \(H \times W\) valeurs réelles.
  2. Fenêtre : Lire les entiers \(k\) (taille de la fenêtre carrée \(k \times k\)) et \(s\) (stride).
  3. Type : Lire une chaîne de caractères, max ou avg, indiquant le type de pooling.
  4. Sans remplissage : Cette opération n’utilise pas de padding ; les fenêtres qui dépasseraient le bord de l’entrée sont ignorées.
  5. Calcul : Pour chaque position de sortie \((i,j)\), calculer le maximum ou la moyenne des \(k \times k\) valeurs de la fenêtre correspondante, commençant à \((i \cdot s,\, j \cdot s)\).
  6. Dimensions de sortie : \(O_h = \lfloor (H - k)/s \rfloor + 1\) et \(O_w = \lfloor (W - k)/s \rfloor + 1\).
  7. Sortie : Imprimer \(O_h\) et \(O_w\) sur la première ligne, suivis de \(O_h\) lignes avec \(O_w\) valeurs réelles chacune, formatées avec 4 décimales.

9.10.2.2 📌 Contraintes computationnelles

  • Fenêtre carrée : \(k \times k\), sans support pour les fenêtres rectangulaires dans cette version.
  • Sans padding : seules les fenêtres entièrement contenues dans l’entrée sont considérées — les dimensions “restantes” sont simplement ignorées.
  • avg utilise une division réelle : la moyenne est toujours \(\text{somme}/k^2\), même lorsque le résultat a de nombreuses décimales — arrondissez uniquement lors du formatage final, conformément à la directive générale du chapitre.
  • Formatage : toutes les valeurs de sortie avec exactement 4 décimales.

9.10.2.3 🧠 Fondement théorique

Élément Rôle dans l’architecture
Pooling maximum Préserve l’activation la plus forte de la fenêtre ; courant après les couches convolutives pour retenir les bords et les textures saillantes
Pooling moyen Lisse la région, préservant l’intensité moyenne ; courant dans les couches finales (global average pooling)
Absence de paramètres Différencie le pooling de la convolution : réduit la résolution spatiale sans coût supplémentaire d’entraînement
Réduction de résolution Contribue à l’invariance aux petites translations et à la réduction du coût computationnel des couches suivantes

9.10.2.4 🧩 Méthodes de morph.py qui peuvent aider

Le morph.py n’implémente pas le pooling avec sous-échantillonnage directement, mais deux familles d’opérations montrent la même idée sous un autre angle, utile pour vérifier votre intuition :

  • mm.dil(f, Bc) / mm.dil0(f, B) — dilatation morphologique : remplace chaque pixel par le maximum de son voisinage défini par l’élément structurant \(B\) (ex. : mm.sebox(n) pour une fenêtre \((2n+1)\times(2n+1)\)). Conceptuellement, c’est un “max-pooling sans sous-échantillonnage” (produit une image de même taille, au lieu d’une image réduite).
  • mm.blur(f, N) — lissage par moyenne dans une fenêtre \(N \times N\), analogue au avg-pooling, également sans réduction de résolution.
  • mm.readImg(h, w, dtype='float') — utile pour lire la carte d’entrée en virgule flottante.

9.10.2.5 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entiers \(H\) et \(W\).
  • \(H\) lignes suivantes : \(W\) valeurs réelles chacune.
  • Ligne suivante : Entiers \(k\) et \(s\).
  • Ligne suivante : max ou avg.

Sortie :

  • Ligne 1 : Entiers \(O_h\) et \(O_w\).
  • \(O_h\) lignes suivantes : \(O_w\) valeurs réelles chacune, avec 4 décimales.

9.10.2.6 📌 Exemples

Entrée Sortie Observation
4 4
1 3 2 4
5 6 1 2
2 1 0 3
4 2 5 1
2 2
max
2 2
6.0000 4.0000
4.0000 5.0000
Pooling maximum, fenêtre \(2\times2\), stride 2.
4 4
1 3 2 4
5 6 1 2
2 1 0 3
4 2 5 1
2 2
avg
2 2
3.7500 2.2500
2.2500 2.2500
Pooling moyen sur les mêmes fenêtres.
🎮 Simulateur : Pooling manuel 🟢 sans padding, fenêtres complètes

Entrée 4×4 fixe — ajustez la taille de la fenêtre (k), le stride (s) et le type, exactement les paramètres que l'EP09_02 lit en entrée, et voyez comment ils modifient la taille et les valeurs de la sortie.

Fenêtre (k)
Stride (s)
Type
(0,0)
Entrée X (4×4)
hors de la fenêtre fenêtre actuelle écarté (reste)
Sortie Y (pooling)

💡 Chaque position du curseur révèle une cellule de la matrice de sortie. Les cellules grises en pointillés sur l'entrée sont des « restes » qu'aucune fenêtre n'atteint — notez que cela se produit lorsque (H−k) n'est pas un multiple de s. Changer k, s ou le type réinitialise l'exploration.

Figure 9.44: Simulateur EP09_02 : Pooling manuel (maximum vs. moyenne, avec fenêtre k et pas s réglables)
%%writefile EP09_02.py
# Code Python
Overwriting EP09_02.py
TestSuite("EP09_02.py").run()
✔️ EP09_02.cases existe déjà dans casos/
📋 4 cas chargé(s) depuis casos/EP09_02.cases

🔍 Test de Python : EP09_02.py
⚠️ EP09_02.py : fichier vide (moins de 3 lignes). Tests ignorés.

9.10.3 EP09_03 🟡 Comptage des paramètres entraînables d’un CNN

Cet EP formalise le comptage des paramètres entraînables d’un CNN. Étant donné la description textuelle d’une petite architecture, composée de couches convolutives, de pooling et entièrement connectées, déterminez, pour chaque couche, le nombre de paramètres entraînables et le total du réseau.

L’architecture doit être interprétée séquentiellement : la sortie d’une couche convolutive devient l’entrée de la couche suivante compatible. Ainsi, le nombre de canaux produits par une couche CONV détermine le nombre de canaux d’entrée (cin) de la couche convolutive suivante.

Dans une couche convolutive, il est important de distinguer les canaux d’entrée et les canaux de sortie :

  • \(c_{in}\) (channels in) est le nombre de canaux qui entrent dans la couche. Une image en niveaux de gris possède \(c_{in}=1\), alors qu’une image RVB possède \(c_{in}=3\). Dans une couche convolutive intermédiaire, cin est généralement égal au nombre de canaux produits par la couche CONV précédente.
  • \(c_{out}\) (channels out) est le nombre de canaux produits par la couche. Il est égal au nombre de filtres utilisés. Par conséquent, si une couche possède 16 filtres, elle produit \(c_{out}=16\) canaux.

Par exemple, considérez la séquence :

CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1
POOL
FC 784 10 1

La première convolution reçoit une image avec un canal et produit 8 canaux. Après le pooling, la seconde convolution reçoit ces 8 canaux et produit 16 canaux. La couche POOL ne modifie pas le nombre de canaux, elle peut seulement réduire les dimensions spatiales. La couche FC reçoit le nombre d’entrées indiqué dans la description elle-même.

Chaque filtre convolutif possède des dimensions

\[ k_h \times k_w \times c_{in}. \]

Ainsi, une couche avec \(c_{out}\) filtres possède

\[ k_h \cdot k_w \cdot c_{in} \cdot c_{out} \]

poids. S’il y a un biais, on ajoute un paramètre pour chaque filtre, totalisant ainsi \(c_{out}\) paramètres supplémentaires.

Le point central de cet exercice est d’observer que la quantité de paramètres d’une couche convolutive ne dépend pas des dimensions spatiales (\(H \times W\)) de la carte de caractéristiques. Cela est dû au partage des poids : le même filtre est réutilisé à différentes positions de l’entrée.

9.10.3.1 📋 Directives d’implémentation

  1. Entrée : Lire l’entier \(L\) (nombre de couches de l’architecture, dans l’ordre où elles sont appliquées).

  2. Couches : Lire \(L\) lignes, chacune décrivant une couche dans l’un des trois formats :

    • CONV kh kw cin cout bias — couche convolutive avec un kernel \(k_h \times k_w\), \(c_{in}\) canaux d’entrée, \(c_{out}\) canaux de sortie et bias (0 ou 1), indiquant s’il y a un biais par filtre ;
    • POOL — couche de pooling (maximum ou moyen), qui ne possède pas de paramètres entraînables et préserve le nombre de canaux ;
    • FC in out bias — couche entièrement connectée avec in entrées, out sorties et bias (0 ou 1), indiquant s’il y a un biais par neurone.
  3. Cohérence entre les couches CONV : dans une séquence de couches convolutives, le cin d’une couche doit correspondre au cout de la couche convolutive précédente. Une couche POOL ne modifie pas ce nombre de canaux.

    Par exemple :

    CONV 3 3 1 8 1
    POOL
    CONV 3 3 8 16 1

    La première CONV produit 8 canaux, qui sont reçus par la seconde CONV. Par conséquent, dans la seconde couche, cin=8 et cout=16.

  4. Paramètres d’une couche CONV :

    Chacun des \(c_{out}\) filtres possède \(k_h \cdot k_w \cdot c_{in}\) poids. Par conséquent,

    \[ P_{\mathrm{CONV}} = k_h \cdot k_w \cdot c_{in} \cdot c_{out} + c_{out}\cdot\text{bias}. \]

  5. Paramètres d’une couche FC :

    \[ P_{\mathrm{FC}} = \text{in}\cdot\text{out} + \text{out}\cdot\text{bias}. \]

  6. Paramètres d’une couche POOL : toujours \(0\).

  7. Total du réseau : additionner les paramètres entraînables de toutes les couches.

  8. Sortie : Pour chaque couche, dans l’ordre de lecture, imprimer Camada i: P, où \(i\) commence à \(1\) et \(P\) est le nombre de paramètres de cette couche. À la fin, imprimer Total: T.

9.10.3.2 📐 Exemple pour comprendre cin et cout

Considérez la séquence :

CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1

Dans la première couche :

  • cin=1 : un canal entre ;
  • cout=8 : il existe 8 filtres et, par conséquent, 8 canaux sortent.

Chaque filtre possède

\[ 3\cdot3\cdot1=9 \]

poids. Comme il existe 8 filtres :

\[ 9\cdot8=72 \]

poids. Avec un biais par filtre :

\[ 72+8=80. \]

Dans la seconde couche :

  • cin=8 : les 8 canaux produits par la première CONV entrent ;
  • cout=16 : il existe 16 filtres et, par conséquent, 16 canaux sortent.

Chaque filtre possède

\[ 3\cdot3\cdot8=72 \]

poids. Comme il existe 16 filtres :

\[ 72\cdot16=1152 \]

poids. Avec 16 biais :

\[ 1152+16=1168. \]

Ainsi, les deux couches possèdent, respectivement, 80 et 1168 paramètres entraînables.

Observez que cout n’est pas \(cin\) multiplié par le nombre de filtres. Le nombre de filtres est exactement cout : chaque filtre combine tous les canaux d’entrée et produit un seul canal de sortie.

9.10.3.3 📌 Contraintes computationnelles

  • Indépendance de la dimension spatiale : l’entrée ne fournit pas \(H \times W\). Le comptage d’une couche CONV dépend uniquement de kh, kw, cin et cout.
  • Cohérence des canaux : pour deux couches CONV consécutives, le cin de la seconde doit être égal au cout de la première. Une couche POOL préserve le nombre de canaux.
  • bias toujours 0 ou 1 : multiplier directement le terme de biais par cette valeur.
  • Couches POOL sans arguments supplémentaires : la ligne contient uniquement le mot POOL.
  • Couches FC : le nombre d’entrées in est fourni explicitement. Il n’est pas nécessaire de calculer les dimensions spatiales produites par les couches précédentes.
  • Toutes les valeurs numériques d’entrée sont des entiers non négatifs.

9.10.3.4 🧠 Fondement théorique

Élément Rôle dans le comptage des paramètres
\(c_{in}\) Nombre de canaux reçus par la couche
\(c_{out}\) Nombre de filtres et, par conséquent, de canaux produits par la couche
Filtre convolutif Chaque filtre possède \(k_h \cdot k_w \cdot c_{in}\) poids et produit un canal de sortie
Partage des poids Le même filtre est réutilisé à différentes positions de l’entrée, rendant le comptage indépendant de \(H \times W\)
Biais Un seul paramètre supplémentaire par filtre (CONV) ou par neurone (FC)
Pooling Peut modifier \(H \times W\), mais ne possède pas de paramètres entraînables et préserve le nombre de canaux
Couche FC Possède un poids pour chaque combinaison entre entrée et neurone de sortie

9.10.3.5 🧩 Méthodes de morph.py pouvant aider

Cet exercice est purement arithmétique et n’utilise pas directement les fonctions de morph.py. Le comptage peut, cependant, être vérifié sur une architecture réelle implémentée en PyTorch via :

sum(p.numel() for p in modelo.parameters())

Cette expression comptabilise les paramètres du modèle, y compris les poids et les biais.

9.10.3.6 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entier \(L\).
  • \(L\) lignes suivantes : description de chaque couche, au format CONV kh kw cin cout bias, POOL ou FC in out bias.

Sortie :

  • \(L\) lignes au format Camada i: P.
  • Dernière ligne : Total: T.

9.10.3.7 📌 Exemples

Entrée Sortie Observation
3
CONV 3 3 1 8 1
POOL
FC 1352 10 1
Camada 1: 80
Camada 2: 0
Camada 3: 13530
Total: 13610
Réseau simple avec une convolution, pooling et couche de classification.
5
CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1
POOL
FC 400 10 1
Camada 1: 80
Camada 2: 0
Camada 3: 1168
Camada 4: 0
Camada 5: 4010
Total: 5258
Petit CNN avec deux convolutions, deux poolings et une couche entièrement connectée.
6
CONV 3 3 1 8 1
POOL
CONV 3 3 8 16 1
POOL
FC 256 32 1
FC 32 10 1
Camada 1: 80
Camada 2: 0
Camada 3: 1168
Camada 4: 0
Camada 5: 8224
Camada 6: 330
Total: 9802
Petit CNN avec deux convolutions, pooling intermédiaire et deux couches entièrement connectées pour la classification.
🎮 Simulateur : Comptage des paramètres 🟡 partage des poids
CONV Bloc bleu POOL Cylindre vert FC Losange orange BATCH Pile rouge 🖱️ Faites glisser pour déplacer les couches
32×32
1
4
3
🧠 Visualisation 3D
🖱️ Faites glisser les couches | Molette zoom | P pause
Figure 9.45: Simulateur EP09_03 : Comptage de paramètres — Convolution vs. couche entièrement connectée
%%writefile EP09_03.py
# Code Python
Overwriting EP09_03.py
TestSuite("EP09_03.py").run()
✔️ EP09_03.cases existe déjà dans casos/
📋 3 cas chargé(s) depuis casos/EP09_03.cases

🔍 Test de Python : EP09_03.py
⚠️ EP09_03.py : fichier vide (moins de 3 lignes). Tests ignorés.

9.10.4 EP09_04 🟡 Intersection sur Union (IoU) et Suppression des Non-Maximums (NMS)

Les modèles de détection d’objets peuvent produire plusieurs boîtes englobantes candidates pour un même objet, avec différentes positions et scores de confiance. L’étape de post-traitement chargée d’éliminer ces détections redondantes est la Suppression des Non-Maximums (NMS), dont l’opération fondamentale utilise la métrique de l’Intersection sur Union (IoU).

La NMS utilise cette mesure pour décider quelles boîtes doivent être conservées. En général, la boîte avec la plus haute confiance est sélectionnée en premier ; ensuite, les boîtes qui présentent une IoU supérieure à un certain seuil avec la boîte sélectionnée sont considérées comme redondantes et supprimées. Le processus est répété jusqu’à ce qu’il ne reste plus de boîtes candidates.

Dans cet exercice, vous devrez implémenter l’algorithme de NMS à partir de zéro, en calculant l’IoU entre les boîtes et en appliquant successivement le critère de sélection et de suppression pour produire l’ensemble final de détections.

9.10.4.1 📋 Directives d’implémentation

  1. Entrée : Lire l’entier \(N\) (nombre de boîtes candidates) et le seuil réel \(\tau\) (seuil d’IoU pour la suppression), sur la même ligne.

  2. Boîtes : Lire \(N\) lignes, chacune avec cinq valeurs réelles :

    x1 y1 x2 y2 score

    où \((x_1,y_1)\) représente le coin supérieur gauche, \((x_2,y_2)\) le coin inférieur droit et score le score de confiance.

  3. Intersection sur Union : Pour deux boîtes \(A\) et \(B\),

    \[ IoU(A,B)= \frac{\operatorname{Aire}(A\cap B)} {\operatorname{Aire}(A\cup B)}. \]

    L’aire d’intersection doit être calculée à partir du chevauchement des intervalles en \(x\) et en \(y\). S’il n’y a pas de chevauchement, l’aire d’intersection est nulle.

  4. Algorithme glouton de NMS :

    1. Trier les boîtes par score décroissant. En cas d’égalité, conserver l’ordre original de lecture.

    2. Sélectionner la boîte avec le score le plus élevé parmi les boîtes restantes et l’ajouter à l’ensemble de sortie.

    3. Calculer l’IoU entre la boîte sélectionnée et toutes les boîtes encore restantes. Supprimer les boîtes pour lesquelles

    \[ \text{IoU} > \tau. \]

    1. Répéter les étapes (b) et (c) jusqu’à ce qu’il ne reste plus de boîtes.
  5. Sortie : Pour chaque boîte conservée, dans l’ordre où elle a été sélectionnée, imprimer son indice original (position de lecture, commençant à \(0\)) et son score, formaté avec 4 décimales. À la fin, imprimer :

    Total conservées : X

9.10.4.2 📌 Contraintes de calcul

  • Suppression stricte : seules les boîtes avec \(\text{IoU} > \tau\) sont supprimées. Les boîtes avec \(\text{IoU} = \tau\) sont conservées.
  • Indices originaux : la sortie fait référence à la position où chaque boîte a été lue dans l’entrée (commençant à \(0\)), et non à sa position après le tri.
  • Tri stable : en cas de score égaux, l’ordre original de lecture doit être préservé.
  • Rectangles alignés sur les axes : toutes les boîtes sont spécifiées par deux coins, avec \(x_1 < x_2\) et \(y_1 < y_2\) garantis en entrée.
  • Coordonnées et scores : les valeurs réelles peuvent être positives ou négatives, selon les limites définies par l’entrée, mais les dimensions des boîtes sont toujours positives.

9.10.4.3 🧠 Fondements théoriques

Élément Rôle dans le post-traitement de détection
IoU Quantifie le chevauchement spatial entre deux boîtes ; \(\text{IoU}=1\) pour des boîtes identiques et \(\text{IoU}=0\) pour des boîtes sans chevauchement
Tri par confiance Fait en sorte que la boîte avec le plus grand score soit analysée en premier
Seuil \(\tau\) Définit la quantité de chevauchement nécessaire pour qu’une boîte soit considérée comme redondante
Suppression Supprime les boîtes qui présentent un grand chevauchement avec une boîte déjà sélectionnée
Boîtes distantes Ont une IoU proche de zéro et, en général, ne sont pas supprimées par cette règle

9.10.4.4 🧩 Méthodes de morph.py qui peuvent aider

  • mm.IoU(boxA, boxB) — calcule la métrique d’IoU, mais attend les boîtes au format \((x,y,w,h)\), c’est-à-dire coin supérieur gauche, largeur et hauteur. L’entrée de cet exercice utilise le format \((x_1,y_1,x_2,y_2)\). La conversion est directe :

    \[ w=x_2-x_1,\qquad h=y_2-y_1. \]

    L’utilisation de cette fonction est facultative. L’objectif principal de l’exercice est d’implémenter correctement le processus de sélection et de suppression de la NMS.

9.10.4.5 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : entier \(N\) et réel \(\tau\).
  • \(N\) lignes suivantes : \(x_1\ y_1\ x_2\ y_2\ \text{score}\).

Sortie :

  • Une ligne par boîte conservée, dans l’ordre de sélection : indice score.
  • Dernière ligne : Total conservées : X.
🎮 Simulateur : IoU et suppression non maximale 🟡 SNM
Boîte sélectionnée Boîte conservée Boîte supprimée Boîte candidate
5
0.50
Défaut
🎯 Visualisation des boîtes
📋 Étapes de la SNM
Figure 9.46: Simulateur EP09_04 : IoU et suppression des non-maximums (NMS)
%%writefile EP09_04.py
# Code Python
Overwriting EP09_04.py
TestSuite("EP09_04.py").run()
✔️ EP09_04.cases existe déjà dans casos/
📋 3 cas chargé(s) depuis casos/EP09_04.cases

🔍 Test de Python : EP09_04.py
⚠️ EP09_04.py : fichier vide (moins de 3 lignes). Tests ignorés.

9.10.5 EP09_05 🟠 Évaluation de segmentation : IoU et Dice pixel par pixel

Le Bloc 2 de la section « Segmentation sémantique avec l’architecture U-Net » définit, en quelques lignes, la fonction iou_mascaras, utilisée pour mesurer la qualité de la ligne de base morphologique classique (lissage + Otsu + ouverture) et, plus loin, celle de la U-Net elle-même après entraînement. Contrairement à l’IoU de l’EP09_04 — calculé sur des boîtes englobantes (régions rectangulaires décrites par quatre nombres) —, l’IoU de segmentation est calculé pixel par pixel : chaque position de l’image est comparée individuellement entre le masque prédit et le masque de référence.

Vous êtes chargé de généraliser cette évaluation, en implémentant non seulement l’IoU pixel par pixel, mais aussi le coefficient de Dice, une autre métrique de chevauchement largement utilisée en segmentation médicale (y compris dans la fonction perda_dice, mentionnée dans le même bloc du chapitre comme base de la fonction de perte utilisée pour entraîner la U-Net).

9.10.5.1 📋 Directives d’implémentation

  1. Entrée : Lire les dimensions \(H \times W\) des masques.

  2. Masque prédit : Lire \(H\) lignes contenant chacune \(W\) valeurs entières (0 ou 1) — par exemple, la sortie d’une U-Net après seuillage à \(0{,}5\) sur la sigmoïde, comme dans le Bloc 4 du chapitre.

  3. Masque de référence : Lire \(H\) lignes supplémentaires avec chacune \(W\) valeurs entières (0 ou 1) — la vérité terrain (ground truth).

  4. Intersection et union : En considérant chaque pixel comme appartenant à l’objet lorsque sa valeur est non nulle, \[ \text{intersection} = \sum_{i,j} \mathbb{1}[P_{ij}=1 \wedge R_{ij}=1], \qquad \text{union} = \sum_{i,j} \mathbb{1}[P_{ij}=1 \vee R_{ij}=1]. \]

  5. IoU pixel par pixel : \[ \text{IoU} = \frac{\text{intersection}}{\text{union}}. \]

  6. Coefficient de Dice : \[ \text{Dice} = \frac{2 \cdot \text{intersection}}{|P| + |R|}, \] où \(|P|\) et \(|R|\) sont le nombre total de pixels d’objet dans chaque masque.

  7. Convention pour les masques vides : si les deux masques ne contiennent aucun pixel d’objet (union \(= 0\) et \(|P|+|R|=0\)), considérez la correspondance comme trivialement parfaite : \(\text{IoU} = \text{Dice} = 1{,}0\).

  8. Sortie : Deux lignes, IoU: X.XXXX et Dice: X.XXXX, chaque valeur avec 4 décimales.

9.10.5.2 📌 Contraintes de calcul

  • Toute valeur non nulle compte comme objet : traitez les valeurs différentes de \(0\) (pas seulement \(1\)) comme appartenant au masque, en répliquant la vérification predita > 0 utilisée dans iou_mascaras au chapitre.
  • Mêmes dimensions : les deux masques ont toujours exactement \(H \times W\) éléments.
  • Convention de vide : appliquez la règle de l’item 7 uniquement lorsque les deux masques sont entièrement vides ; si un seul est vide, l’intersection est \(0\) et l’IoU/Dice résultant sera également \(0\).

9.10.5.3 🧠 Fondement théorique

Élément Rôle dans l’évaluation de segmentation
IoU pixel par pixel Généralise la métrique de l’EP09_04 à des régions de forme arbitraire — pas seulement des rectangles — en comparant le masque prédit et la référence position par position
Coefficient de Dice Métrique liée à l’IoU (toujours \(\text{Dice} \ge \text{IoU}\)), plus sensible aux petites intersections et largement utilisée comme fonction de perte en segmentation (fonction perda_dice du chapitre)
Convention des masques vides Évite la division par zéro et reconnaît que « aucun objet prédit, aucun objet réel » est, par définition, une réussite
Comparaison classique vs. U-Net Le chapitre utilise exactement ce type de métrique pour justifier, numériquement, pourquoi la U-Net surpasse la ligne de base morphologique dans des scénarios à faible contraste

9.10.5.4 🧩 Méthodes de morph.py pouvant aider

  • mm.readImg(h, w, dtype='uint8') — lit directement chaque masque binaire \(h \times w\) depuis l’entrée standard (les valeurs \(0/1\) tiennent parfaitement dans le type entier standard).
  • La fonction iou_mascaras elle-même, définie dans le Bloc 2 de la section U-Net du chapitre (ne fait pas partie de morph.py, mais du code du chapitre), est l’inspiration directe de cet exercice — il vaut la peine de relire ces quelques lignes avant de programmer.
  • Pour une extension optionnelle (non exigée par cet EP), mm.connectedComponents ou mm.label0 (vus dans le contexte de l’analyse des composantes connexes) permettraient de labelliser chaque nodule individuellement et de calculer l’IoU par composante, plutôt que sur le masque entier.

9.10.5.5 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Entiers \(H\) et \(W\).
  • \(H\) lignes suivantes : \(W\) valeurs entières (0 ou 1) — masque prédit.
  • \(H\) lignes suivantes : \(W\) valeurs entières (0 ou 1) — masque de référence.

Sortie :

  • Ligne 1 : IoU: X.XXXX.
  • Ligne 2 : Dice: X.XXXX.
Astuce💡 Exemple illustratif

Considérons un masque prédit avec un carré \(2\times2\) de pixels actifs et une référence décalée d’une colonne, se chevauchant sur seulement la moitié de la surface :

Prédit          Référence
0 0 0 0         0 0 0 0
0 1 1 0         0 0 1 1
0 1 1 0         0 0 1 1
0 0 0 0         0 0 0 0

Intersection \(=2\) pixels, union \(=6\) pixels (\(4+4-2\)), donc \(\text{IoU}=2/6\approx0{,}3333\) et \(\text{Dice}=2\cdot2/(4+4)=0{,}5000\) — notez que le Dice est toujours supérieur ou égal à l’IoU pour le même chevauchement.

9.10.5.6 📌 Exemples

Entrée Sortie Observation
4 4
0 0 0 0
0 1 1 0
0 1 1 0
0 0 0 0
0 0 0 0
0 0 1 1
0 0 1 1
0 0 0 0
IoU: 0.3333
Dice: 0.5000
Masques \(4\times4\) avec chevauchement partiel de 2 pixels.
🎮 Simulateur : IoU et Dice pixel par pixel 🟠 Segmentation
Intersection (VP) Prédite uniquement (FP) Référence uniquement (FN) Fond (VN)
5×5
Carré
🔵 Masque prédit
🟡 Masque de référence
🎯 Comparaison visuelle
📊 Calculs et formules
Figure 9.47: Simulador EP09_05: Évaluation de la segmentation — IoU et Dice pixel par pixel
%%writefile EP09_05.py
# Code Python
Overwriting EP09_05.py
TestSuite("EP09_05.py").run()
✔️ EP09_05.cases existe déjà dans casos/
📋 4 cas chargé(s) depuis casos/EP09_05.cases

🔍 Test de Python : EP09_05.py
⚠️ EP09_05.py : fichier vide (moins de 3 lignes). Tests ignorés.

9.10.6 EP09_06 🔴 Pipeline intégré : de la détection à la mesure du monde réel

Cet exercice final intègre les deux exercices de détection et le principe de photogrammétrie présenté dans la section « Photogrammétrie et référence d’échelle » — exactement le même calcul que celui implémenté dans la figure de mesure par référence d’échelle de ce chapitre. Le scénario reproduit une situation réaliste : un détecteur (Faster R-CNN ou YOLO) génère plusieurs boîtes candidates se chevauchant pour le même objet d’intérêt ; après les avoir filtrées par NMS, la boîte survivante de plus haute confiance est utilisée, avec une boîte de référence de largeur réelle connue (comme la carte de \(8{,}56\) cm), pour estimer les dimensions réelles de l’objet détecté.

9.10.6.1 📋 Directives d’implémentation

  1. Référence connue : Lire la valeur réelle \(L_{ref}\) (largeur réelle de l’objet de référence, en cm), puis les quatre réels \(x_1\ y_1\ x_2\ y_2\) de sa boîte englobante en pixels (déjà connue, sans nécessité de détection).
  2. Candidates de l’objet à mesurer : Lire l’entier \(N\) (nombre de boîtes candidates produites par le détecteur pour l’objet d’intérêt) et le seuil réel \(\tau\) ; puis lire les \(N\) lignes de boîtes candidates, chacune avec \(x_1\ y_1\ x_2\ y_2\ \text{score}\).
  3. Étape 1 — NMS : Appliquer exactement l’algorithme de Suppression Non Maximale de l’EP09_04 aux \(N\) boîtes candidates, en utilisant le seuil \(\tau\), pour éliminer les détections redondantes du même objet.
  4. Étape 2 — Sélection de la boîte finale : Après le NMS, la boîte de plus grand score parmi celles conservées est la détection finale de l’objet (l’entrée garantit que toutes les boîtes candidates correspondent à un seul objet physique, donc la première boîte sélectionnée par le NMS est déjà le résultat final).
  5. Étape 3 — Mesure par référence d’échelle : Calculer le rapport \(\text{cm/pixel} = L_{ref} / \text{largeur de la référence en pixels}\) et l’appliquer à la fois à la largeur et à la hauteur (en pixels) de la boîte finale de l’objet, obtenant ainsi ses dimensions réelles estimées en centimètres.
  6. Sortie : D’abord, une ligne par boîte conservée après le NMS (même format que l’EP09_04) : indice score. Ensuite, la ligne Total conservées : X. Enfin, la ligne Objet : L x A cm, où \(L\) et \(A\) sont la largeur et la hauteur estimées de l’objet, chacune avec 2 décimales.

9.10.6.2 📌 Contraintes de calcul

  • Réutiliser intégralement le NMS de l’EP09_04 — même règle de départage, même critère de suppression (\(\text{IoU} > \tau\)).
  • La référence ne passe pas par le NMS : sa boîte est donnée directement, sans candidates concurrentes.
  • Rapport unique pour la largeur et la hauteur : comme dans la figure de photogrammétrie du chapitre, le même rapport cm/pixel (dérivé de la largeur de la référence) est appliqué à la fois à la largeur et à la hauteur de l’objet — il n’y a pas de calibration verticale séparée.

9.10.6.3 🧠 Fondement théorique

Étape Concept du chapitre
Plusieurs boîtes candidates Sortie brute d’un détecteur comme le Faster R-CNN ou le YOLO, avant le post-traitement
NMS (EP09_04) Filtre les détections redondantes, ne conservant que la plus fiable pour l’objet
Référence d’échelle connue Même principe que la carte de \(8{,}56\) cm utilisée dans la section « Photogrammétrie et référence d’échelle »
Conversion pixel → centimètre Règle de trois simple : \(\text{cm/pixel} = L_{ref} / w_{ref\_px}\), appliquée à la boîte finale de l’objet

9.10.6.4 🧩 Méthodes de morph.py pouvant aider

  • mm.IoU(boxA, boxB) — la même fonction suggérée dans l’EP09_04, ici réutilisée dans l’étape de NMS de ce pipeline intégré (rappelez-vous de la conversion de format : \(w = x_2-x_1\), \(h = y_2-y_1\)).
  • Si vous avez déjà résolu l’EP09_04 en encapsulant le NMS dans une fonction dédiée, c’est le moment idéal de réutiliser ce code — l’intégration de modules déjà testés individuellement est exactement la pratique d’ingénierie que cet exercice veut renforcer.

9.10.6.5 📦 Spécification d’entrée et de sortie (VPL)

Entrée :

  • Ligne 1 : Réel \(L_{ref}\).
  • Ligne 2 : \(x_1\ y_1\ x_2\ y_2\) de la boîte de référence.
  • Ligne 3 : Entier \(N\) et réel \(\tau\).
  • \(N\) lignes suivantes : \(x_1\ y_1\ x_2\ y_2\ \text{score}\) des boîtes candidates de l’objet.

Sortie :

  • Une ligne par boîte conservée après le NMS : indice score.
  • Ligne suivante : Total conservées : X.
  • Dernière ligne : Objet : L x A cm.

9.10.6.6 📌 Exemples

Entrée Sortie Observation
8.56
30 200 170 288
3 0.5
250 100 470 250 0.92
255 105 468 245 0.88
600 600 650 650 0.40
0 0.9200
2 0.4000
Total conservées : 2
Objet : 13.45 x 9.17 cm
La boîte 1 est supprimée car elle chevauche fortement la boîte 0 ; la détection finale de l’objet est la boîte 0.
🎮 Simulateur : Pipeline intégré — De la détection à la mesure 🔴 Photogrammétrie
Boîte sélectionnée Boîte masquée Référence Objet final
8.56
0.50
Modèle
🎯 Visualisation du pipeline
📋 Pipeline étape par étape
Figure 9.48: Simulateur EP09_06 : Pipeline Intégré — Détection à la Mesure du Monde Réel
%%writefile EP09_06.py
# Code Python
Overwriting EP09_06.py
TestSuite("EP09_06.py").run()
✔️ EP09_06.cases existe déjà dans casos/
📋 3 cas chargé(s) depuis casos/EP09_06.cases

🔍 Test de Python : EP09_06.py
⚠️ EP09_06.py : fichier vide (moins de 3 lignes). Tests ignorés.