Travaux pratiques - Modèles de segmentation

L’objectif de cette séance de trvaux pratiques est de mettre en œuvre un réseau de neurones convolutif profond pour la segmentation sémantique des images. Nous verrons comment adapter les CNN classiques à cette tâche de prédiction dense et localisée, sur un jeu de données annoté de photos d’animaux.

# Téléchargement du jeu de données Oxford-IIT-Pets en version réduite
!wget -nc https://cedric.cnam.fr/vertigo/Cours/ml2/oxford-iit-pets-small-128.tar.xz
# Décompression du jeu de données
!tar -xf oxford-iit-pets-small-128.tar.xz && rm oxford-iit-pets-small-128.tar.xz # on supprime l'archive

Chargement du jeu de données Oxford-IIT-Pets-small

Pour cette séance, nous allons commencer par illustrer la mise en forme d’un jeu de données avec TensorFlow/Keras. En effet, jusqu’à présent, nous avons surtout utilisé des jeux de données préintégrés à la bibliothèque.

Question

Observez la structure du dossier oxford-iit-pets-small/. Comment est-il organisé ? Où se trouvent les images ? Où se trouvent les étiquettes ?

import tensorflow as tf

Nous allons créer un jeu de données TensorFlow permettant de lire les images. Il s’agit d’une instance de la classe tf.data.Dataset, qui implémente l’interface des générateurs Python. Pour ce faire, nous pouvons commencer par lire le fichier trainval.txt qui contient la liste des fichiers à utiliser pour l’apprentissage.

# Créé un Dataset TensorFlow à partir de la liste des exemples d'apprentissage
filenames = tf.data.TextLineDataset("oxford-iit-pets-small-128/trainval.txt")
# Extraire seulement les noms de fichier
filenames = filenames.map(lambda fname: tf.strings.split(fname)[0])

# Transforme les noms des exemples en chemins vers les fichiers JPEG
images = filenames.map(lambda fname: tf.strings.join(["oxford-iit-pets-small-128/images/", fname, ".jpg"]))
print(images)

On peut observer que la variable images est un objet de type MapDataset qui renvoie des chaînes de caractère (tf.string). Chacune de ces chaînes correspond au chemin vers un fichier au format JPEG (une image du jeu de données). Nous pouvons maintenant appliquer la méthode map pour lire le contenu du fichier au format binaire (fonction tf.io.read_file) puis décoder l’image (tf.io.decode_image).

# Lecture des fichiers JPEG et décodage en matrice, puis normalisation entre 0 et 1
def set_im_shape(t):
    t.set_shape((128, 128, 3))
    return t
def set_l_shape(t):
    t.set_shape((128, 128))
    return t
images = images.map(tf.io.read_file).map(lambda f: tf.io.decode_image(f, channels=3)).map(lambda im: tf.cast(im, "float32") / 255.).map(set_im_shape)

print(images)

Le jeu de données images renvoie bien maintenant une collection MapDataset. Chaque élément du jeu de données est une image de dimensions (128, 128, 3) et de type float32.

Nous pouvons appliquer la même séquence d’opérations pour créer la collection qui contiendra les masques de segmentation des images. Ces masques sont des images, contenant pour chaque pixel un entier encodant la classe associée au pixel: 0 pour le fond (background), 1 pour un animal, 2 pour une bordure.

# Transforme les noms des exemples en chemins vers les fichiers PNG (masques de segmentation)
masks = filenames.map(lambda fname: tf.strings.join(["oxford-iit-pets-small-128/annotations/", fname, ".png"]))
# Lecture des fichiers PNG et décodage en matrice, on soustrait 1 pour que les classes numérotées [1,2,3] deviennent [0,1,2]
masks = masks.map(tf.io.read_file).map(tf.io.decode_image).map(lambda m: (m-1)[:,:,0]).map(set_l_shape)

Question

Quelles sont les dimensions et le type des éléments renvoyés par le jeu de données masks ?

Finalement, nous pouvons assembler les jeux de données ensemble à l’aide de la fonction zip pour créer une collection dataset qui renverra, pour chaque exemple, le couple (image, masque).

# Concatène les deux Dataset ensemble pour produire des paires (image, masque)
dataset = tf.data.Dataset.zip((images, masks))

for im, mask in dataset:
    print(im)
    print(mask)
    break

Question

À l’aide de Matplotlib, afficher quelques exemples d’images et de cartes sémantiques associées. Quelle est la signification des trois classes 0, 1 et 2 ?

Implémentation d’un modèle de segmentation

Nous pouvons maintenant implémenter un modèle de segmentation. Il s’agit d’un modèle entièrement convolutif, c’est-à-dire sans aucune couche entièrement connectée (Dense). Le modèle sera composé d’un bloc encodeur qui réduit la dimension spatiale, et d’un bloc décodeur qui reconstitue la dimension spatiale jusqu’à obtenir une sortie de mêmes dimensions que l’image de départ. Le tenseur de sortie aura en revanche \(K=3\) canaux (un canal par classe). Pour un pixel donné, la valeur \(y[i,j]\) de la sortie sera un vecteur de trois éléments représentant la probabilité (après softmax) d’appartenir à la classe 0, à la classe 1 ou à la classe .

La fonction get_model implémente une architecture similaire à celle de U-Net. Contrairement aux modèles précédemment définis, cette fonction utilise l’interface fonctionnel de Keras qui permet de décrire un modèle par les opérations appliquées sur un tenseur x.

Question

Dans la fonction get_model, à quoi correspond la variable previous_block_activation ? Comment est-elle utilisée ? De quelle type d’architecture s’agit-il ?

from tensorflow import keras
from tensorflow.keras import layers

def get_model(img_size, num_classes):
    inputs = keras.Input(shape=img_size + (3,))

    # Bloc d'entrée: stride 2 pour diminuer la résolution
    x = layers.Conv2D(32, 3, strides=2, padding="same")(inputs)
    x = layers.BatchNormalization()(x)
    x = layers.Activation("relu")(x)

    previous_block_activation = x  # Stockage du résidu

    # Encodeur
    # 3 blocs: un bloc à 64 canaux, un bloc à 128 canaux, un bloc à 256 canaux
    # Les 3 blocs utilisent la même structure: Convolution + Convolution + MaxPooling
    for filters in [64, 128, 256]:
        x = layers.Activation("relu")(x)
        x = layers.SeparableConv2D(filters, 3, padding="same")(x)
        x = layers.BatchNormalization()(x)

        x = layers.Activation("relu")(x)
        x = layers.SeparableConv2D(filters, 3, padding="same")(x)
        x = layers.BatchNormalization()(x)

        x = layers.MaxPooling2D(3, strides=2, padding="same")(x)

        # Réduction de la dimension du résidu
        residual = layers.Conv2D(filters, 1, strides=2, padding="same")(previous_block_activation)
        x = layers.add([x, residual])  # Ajout du résidu
        previous_block_activation = x  # Stockage du résidu pour le bloc suivant

    # Décodeur
    # Principe identique à l'encodeur mais dans le sens inverse
    for filters in [256, 128, 64, 32]:
        x = layers.Activation("relu")(x)
        x = layers.Conv2DTranspose(filters, 3, padding="same")(x)
        x = layers.BatchNormalization()(x)

        x = layers.Activation("relu")(x)
        x = layers.Conv2DTranspose(filters, 3, padding="same")(x)
        x = layers.BatchNormalization()(x)

        x = layers.UpSampling2D(2)(x)

        # Augmentation de la dimension du résidu
        residual = layers.UpSampling2D(2)(previous_block_activation)
        residual = layers.Conv2D(filters, 1, padding="same")(residual)
        x = layers.add([x, residual])  # Ajout du résidu
        previous_block_activation = x  # Stockage du résidu pour le bloc suivant

    # Couche finale (convolution 2D 1x1 pour la classification)
    outputs = layers.Conv2D(num_classes, 1, activation="softmax", padding="same")(x)

    return keras.Model(inputs, outputs)
model = get_model((128, 128), 3)
model.summary()

Entraînement du modèle

Pour entraîner le modèle, nous devons spécifier avec TensorFlow la taille de batch ainsi que quelques paramètres sur le chargement des données :

train_batches = (
    dataset
    .cache() # stocke les données en cache (permet d'éviter des lectures trop nombreuses sur le disque)
    .shuffle(1000) # mélange les données aléatoirement
    .batch(8) # taille du batch
    .prefetch(buffer_size=tf.data.AUTOTUNE)) # charge de façon asynchrone le batch suivant à chaque itération

test_batches = dataset.batch(16)

Question

Compléter le code ci-dessous pour compiler le modèle puis lancer son apprentissage sur le jeu d’entraînement. On entraînera le modèle sur cinq époques, en utilisant la SparseCategoricalCrossentropy. Pour les métriques, on affichera à la fois l”accuracy ainsi que l’intersection sur union.

Comme la classe numéro 2 correspond à la « bordure » de la segmentation, on souhaite ne pas apprendre cette classe. On peut désactiver son calcul dans l’entropie croisée en utilisant le bon paramètre. Voir la documentation de Keras à ce sujet.

# À compléter
iou = tf.keras.metrics.IoU(num_classes, target_class_ids=[0], sparse_y_pred=False)
loss = tf.keras.losses.SparseCategoricalCrossentropy(...)
model.compile(...)

Question

À l’aide de la méthode predict_on_batch, calculer les cartes sémantiques prédites pour quelques exemples du jeu de données de test. Afficher ces prédictions à l’aide de Matplotlib.

Question

À l’aide de Keras, évaluer les performances de segmentation (accuracy et IoU) du modèle sur le jeu de test.