Traitement d’images

Giuseppe Arcimboldo était un peintre du 16ème siècle qui a créé des portraits imaginaires à partir de fruits, légumes, fleurs, poissons et livres.

Arcimboldo painting

Dans cette feuille, vous allez créer une séquence d’images transformant pas à pas le smiley ci-dessous en une peinture à la Arcimboldo, puis les assembler pour réaliser une petite animation! Vous pourrez ensuite épicer votre réalisation avec Deep Art.

Smiley

Voici les étapes du projet:

  1. Extraire la partie droite de l’image pour avoir une image carrée

  2. Extraire le smiley de son fond

  3. Recadrer l’image sans changement de résolution, de sorte à obtenir une image de taille 256x256 centrée sur le smiley,

  4. Réduire la résolution à 128x128, avec une technique anti-crénelage, par lissage puis sous-échantillonage

  5. Extraire les contours du smiley pour en faire une image noir et blanc.

  6. Mettre en toile de fond un papier-peint représentant des fruits

  7. Choisir trois images de fruits et les extraires de leur fond.

  8. Remplacer les yeux et la bouche par ces fruits

Lors de ces étapes, guidées pas-à-pas, vous apprendrez à:

  • Appliquer un filtre pixel-à-pixel pour extraire le premier plan d’une image

  • Appliquer un filtre de convolution, construire une pyramide Gaussienne et choisir le bon niveau de lissage avant de sous-échantillonner.

  • Appliquer un filtre de convolution par différence pour extraire un contour.

# Automatically reload code when changes are made
%load_ext autoreload
%autoreload 2
from PIL import Image, ImageDraw, ImageFont
import matplotlib.pyplot as plt
from scipy import signal

from utilities import *
from intro_science_donnees import *

Lecture de l’image et extraction d’une sous image carrée

Exercice: Chargez l’image smiley.jpg et affectez-la à la variable img.

Indication: consultez la feuille image de la Semaine2 si besoin.

# YOUR CODE HERE
raise NotImplementedError()
img
assert img.width == 640
assert img.height == 427

La bibliothèque PIL fournit une méthode crop pour recadrer des images. Elle prend en paramètre une liste (left, upper, right, lower) donnant successivement les coordonnées des points en haut à gauche et en bas à droite de la zone rectangulaire (box) à extraire. Voici, par exemple, l’image recadrée pour les points de coordonnées (0,0) et (200,100):

img.crop(box=(0,0,200,100) )

Exercice: Recadrer l’image pour ne garder que sa partie droite et de sorte à ce qu’elle soit carrée. Affectez le résultat à la variable img_carree.

# YOUR CODE HERE
raise NotImplementedError()
img_carree
# Vérifications: on obtient une image de la bonne taille
assert isinstance(img_carree, Image.Image)
assert img_carree.size == (427, 427)

Ce sera la première image de votre animation!

Pour produire ce dernier, vous stockerez les images successives dans une liste images, puis les assemblerez à la fin. Commençons par celle-ci:

# Initialise une liste d'images vide
images = []
# Rajoute une image à la fin
images.append(img_carree)
image_grid(images)

Le générique de l’animation

Vous allez maintenant écrire votre nom sur l’image. Affectez votre nom à la variable name.

# YOUR CODE HERE
raise NotImplementedError()
assert isinstance(name, str)

Étudiez en détail les commandes suivantes qui:

  • Font une copie de l’image

  • Construise un canevas pour dessiner sur cette image

  • choisissent une fonte (si ‘LiberationSans-Regular’ n’est pas disponible, essayez ‘Arial’ à la place)

  • Dessine un texte

img_title = img_carree.copy()
canvas = ImageDraw.Draw(img_title)
font = ImageFont.truetype("LiberationSans-Regular.ttf", size=40)
canvas.text(xy=(10,10), text=name, font=font)

Ajoutez l’image produite à la liste d’images:

# YOUR CODE HERE
raise NotImplementedError()
image_grid(images)
assert len(images) >= 2

Bonus \(\clubsuit\): Modifier ce qui précède pour personnaliser votre générique. Voici quelques idées:

  • explorez les options de canvas.text pour dessiner le texte selon vos préférences (couleur, fonte, position, …);

  • ajoutez non pas une seule image à images, mais une séquence d’images de sorte à réaliser un générique qui défile;

  • explorez les fonctions de canvas pour réaliser tout dessin de votre goût sur l’image. Indications:

  • rajoutez autant de cellules ci-dessus que vous en aurez besoin;

  • rassemblez les commandes pour rajouter du texte à une image en une fonction réutilisable dans utilities.py

Détection de l’avant plan

L’objectif est maintenant de déterminer la position du smiley sur l’image. Pour cela nous allons commencer par détecter quels pixels sont dans l’avant plan (le smiley), et non dans l’arrière plan.

Un premier essai

Reprenons la fonction foreground_filter du TP 2, qui est fournie dans utilities.py. Prenez le temps d’en consulter le code pour vous remémorer son fonctionnement.

show_source(foreground_filter)

Appliquez cette fonction à img_carree, affectez le résultat à foreground0, et visualisez le résultat:

# YOUR CODE HERE
raise NotImplementedError()
plt.imshow(foreground0, cmap='gray');
# Vérifications:
assert isinstance(foreground0, np.ndarray)    # foreground est un tableau ...
assert foreground0.dtype == np.dtype('bool')  # de booléens ...
assert foreground0.shape == img_carree.size   # de même taille que l'image

Le résultat est-il probant? Réessayez avec différentes valeurs de seuil theta. Laquelle donne le meilleur résultat?

YOUR ANSWER HERE

Est-ce satisfaisant? Quel est le problème?

YOUR ANSWER HERE

Le smiley a la jaunisse

Pour améliorer cela, nous allons utiliser un filtre pixel-à-pixel mesurant non pas le niveau de gris mais le niveau de jaune de chaque pixel.

Exercice: Implantez la fonction yellowness_filter dans le fichier utilities.py en utilisant, par exemple, la formule \(r+g-b\).
Indication: si vous n’avez plus en tête comment extraire et calculer avec des différentes couches de couleur de l’image, consulter la feuille feature_extraction de la semaine 2.

show_source(yellowness_filter)

Vérifiez le résultat à l’oeil:

img_yellowness = yellowness_filter(img_carree)
plt.imshow(img_yellowness, cmap='gray')
plt.colorbar();
# Vérifications: 
assert isinstance(img_yellowness, np.ndarray)      # img_yellowness est un tableau numpy ...
assert img_yellowness.dtype == np.dtype('float64') # de nombre flottants ...
assert img_yellowness.shape == img_carree.size     # de même taille que l'image

Ajoutez img_yellowness à la liste d’images:

# YOUR CODE HERE
raise NotImplementedError()
image_grid(images)

Généralisons!

Notez que la formule \(r+g-b\) est un produit scalaire! Nommément, c’est le produit scalaire \(v.w\) de la couleur du pixel – représentée par le vecteur \(v = (r,g,b)\) – avec le vecteur \(w=(1,1,-1)\). Le rôle du coefficient \(-1\) est de discriminer les couleurs du blanc – représentée par le vecteur \((255,255,255)\) – qui contiennent du bleu en plus du jaune.

Une autre façon de discriminer ces couleurs, est de diviser le produit scalaire par la norme de \(v\). Cela nous donne alors \(\frac{v . w}{|v|}\), où \(w=(1,1,0)\). À un coefficient multiplicateur et centrage près, on retrouve la formule \(\frac{\overline v.\overline c}{|\overline v|,|\overline c|}\) de la corrélation de \(v\) avec la couleur jaune \(c=(255,255,0)\). La corrélation est maximale lorsque \(v\) est proportionnel à \(c\), c’est-à-dire que \(v\) représente une couleur jaune quelconque, qu’elle soit sombre ou claire.

Exercice (bonus \(\clubsuit\)):

  1. Implanter dans utilities.py la fonction color_correlation_filter calculant, pour chaque pixel d’une image, sa corrélation avec une couleur donnée.

Indication: Numpy permet de calculer la corrélation entre deux vecteurs u et v avec np.corrcoef(u, v)[0, 1].

show_source(color_correlation_filter)
  1. Appliquer cette fonction à img_carree avec la couleur jaune \((255,255,0)\) et affecter le résultat à img_yellowness.

# YOUR CODE HERE
raise NotImplementedError()
# YOUR CODE HERE
raise NotImplementedError()
  1. Le smiley n’est en fait pas en jaune pur. Extraire une zone du smiley et en calculer la couleur moyenne. Puis utiliser cette couleur pour appliquer le filtre.

# YOUR CODE HERE
raise NotImplementedError()
plt.imshow(img_yellowness, cmap="gray")
plt.colorbar();
  1. Quels sont les avantages et inconvénients de cette méthode?

    YOUR ANSWER HERE

Ajoutez img_yellowness à la liste d’images:

# YOUR CODE HERE
raise NotImplementedError()
image_grid(images)

Avant plan par jaunisse

Exercice: Utilisez un seuil sur img_yellowness pour calculer les pixels du smiley; affectez le résultat à la variable foreground.

# YOUR CODE HERE
raise NotImplementedError()
plt.imshow(foreground, cmap='gray');
# Vérifications:
assert isinstance(foreground, np.ndarray)    # foreground est un tableau ...
assert foreground.dtype == np.dtype('bool')  # de booléens ...
assert foreground.shape == img_carree.size   # de même taille que l'image

Ajoutez foreground à la liste d’images:

# YOUR CODE HERE
raise NotImplementedError()
image_grid(images)

En boîte \(\clubsuit\)

Exercice (bonus):

  • Calculez les coordonnées de la plus petite boîte rectangulaire (bounding box) englobant le smiley. Pour cela, il faut ignorer les points épars. On pourra par exemple fixer un seuil \(s\) et ne considérer que les lignes et colonnes contenant au moins \(s\) pixels.

  • Dessinez la boite sur l’image d’origine, et ajoutez le résultat à la liste d’images.

Recadrage (crop)

Calcul du centre du smiley

Exercice: Calculez les coordonnées du centre du smiley et affectez le résultat à la variable center.

Indications:

  • Vous pouvez par exemple calculer le barycentre des coordonnées \((i,j)\) des pixels dans le smiley.
    Consultez le code de la fonction elongation de la semaine 2.

  • Si vous l’avez calculée ci-dessus, vous pouvez aussi vous baser sur la boîte englobante.

# YOUR CODE HERE
raise NotImplementedError()
assert np.linalg.norm(center-np.array([275, 230])) < 5

Exercice (bonus \(\clubsuit\)): dessiner le centre sur l’image et ajouter le résultat à la liste d’images. Pour cela, on doit reconvertir notre tableau de booléen en image RGB.

# Convertir le tableau de booléen en image PIL couleur
foreground_bw = np.expand_dims(foreground*255, axis= 2) # Rajouter une dimension (les niveaux de couleurs)
foreground_bw = np.repeat(foreground_bw, 3, axis = 2)   # Dupliquer les valeurs sur les trois niveaux RGB
foreground_bw = Image.fromarray(np.uint8(foreground_bw)) # Convertir l'array numpy à une image PIL
# Afficher le centre du smiley avec un point ou un petit cercle de couleur
# YOUR CODE HERE
raise NotImplementedError()

Recadrage du smiley

Exercice: Recadrez votre image pour qu’elle soit centrée sur le smiley et de taille 256 x 256.

# YOUR CODE HERE
raise NotImplementedError()
plt.imshow(img_cropped);

Procédez de même pour foreground en affectant le résultat à foreground_cropped.

Indication: Utilisez Image.fromarray pour reconvertir foreground en Image.

# YOUR CODE HERE
raise NotImplementedError()
plt.imshow(foreground_cropped);

Ajoutez le foreground_cropped à la liste d’image! Vous pouvez aussi ajouter l’image recadrée maintenant, ou bien plus tard selon votre goût.

# YOUR CODE HERE
raise NotImplementedError()
image_grid(images)

Réduction de la résolution

Pour diverses raisons, on pourrait être amené à changer la résolution de nos images. Esssayons de réduire la résolution de notre image à 128x128 pixels.

img_downsampled = foreground_cropped.resize((128, 128))
plt.imshow(img_downsampled, interpolation="none") # imshow applique un anti-crénelage par défaut

L’opération de sous-échantillonnage permettant de réduire la résolution d’une image est sujette au problème de crénelage (aliasing en anglais). Plusieurs techniques d’anti-crénelage peuvent être mise en place pour interpoler des valeurs entre les pixels. Pour appliquer un filtre d’anti-crénelage lors du sous-échantillonnage, il suffit de passer Image.ANTIALIAS comme second paramètre de la fonction resize.

Exercice: Appliquer l’anti-crénelage lors du sous-échantillonnage. Voyez-vous une différence?

# YOUR CODE HERE
raise NotImplementedError()

Lissage par pyramide Gaussienne

Nous allons maintenant illustrer comment lisser une image par convolutions successives.

Dans la convolution que nous allons utiliser, chaque pixel de la nouvelle image est obtenu à partir du pixel d’origine et de ses huit voisins en y appliquant des coefficients. Ces coefficients sont donnés par une matrice \(3\times3\) appelée le noyau (kernel en anglais). Nous utiliserons le noyau suivant. Le \(.25\) au centre spécifie par exemple que le pixel d’origine contribuera avec un coefficient de \(1/4\), tandis que celui du dessus contribuera avec un coefficient de \(1/8\):

ker = np.outer([1, 2, 1], [1, 2, 1])
ker = ker/np.sum(ker)
ker

Nous appliquons seize fois de suite la convolution à l’image foreground_cropped en affichant les résultats intermédiaire pour visualiser l’évolution. La convolution est calculée en utilisant signal.convolve2d; les options précisent le comportement au bord.

M = foreground_cropped

fig = plt.figure(figsize=(20,20))
for k in range(16):
    M = signal.convolve2d(M, ker, boundary='wrap', mode='same')

    fig.add_subplot(4,4,k+1)
    plt.imshow(M, cmap='gray')

On note que, au fur et à mesure, les pixels isolés deviennent de plus en plus sombres: ils sont en effet influencés par leurs nombreux voisins noirs. On note aussi que l’image devient de plus en plus floue; il ne faudrait donc pas aller trop loin.

Une autre manière de procéder est d’utiliser le filtrage gaussien de la librairie scipy:

from scipy.ndimage import gaussian_filter
img_filtered = gaussian_filter(M, sigma=2)
plt.imshow(img_filtered, cmap='gray');

Changez la valeur de sigma et observer le résultat.

Exercice (bonus \(\clubsuit\)): À quelle propriété de la fonction Gaussienne correspond le paramètre sigma (\(\sigma\)). Expliquer avec vos mots pourquoi augmenter \(\sigma\) floute davantage l’image

YOUR ANSWER HERE

Il ne reste plus qu’à appliquer à nouveau un seuil, pour obtenir une image propre:

foreground_cropped_clean = M > 0.7
plt.imshow(foreground_cropped_clean, cmap='gray');
smiley = transparent_background_filter(img_cropped, foreground_cropped_clean)
smiley

Rajoutez les images que vous souhaitez à votre liste d’images

# YOUR CODE HERE
raise NotImplementedError()
image_grid(images)

Extraction des contours

Nous souhaitons maintenant extraire les contours de l’image. Remettons l’image en réels:

M = foreground_cropped_clean * 1.0

Le principe est de calculer la valeur absolue de la différence entre chaque pixel et son voisin de gauche. S’ils sont identiques, on obtient zéro. S’ils sont différents – on est sur un bord – on obtient une valeur positive. De manière équivalente, on calcule la différence entre l’image et l’image décallée de un pixel.

Note: lors de cette opérations de différence, on perd une bande de 1 pixel en largeur; pour conserver l’image carrée, on a aussi supprimé une bande de 1 pixel en hauteur.

contour_horizontal = np.abs(M[:-1, 1:] - M[:-1, 0:-1])
plt.imshow(contour_horizontal, cmap='gray');

Notez que c’est déjà presque parfait, sauf lorsque le contour est horizontal (pourquoi?).

Pour améliorer cela, procédez de même verticalement et affectez le résultat dans contour_vertical.

# YOUR CODE HERE
raise NotImplementedError()
plt.imshow(contour_vertical, cmap='gray');

Maintenant, c’est au tour des contours verticaux d’être peu détectés. Qu’à cela ne tienne, il suffit de sommer les deux résultats.

contour = contour_horizontal + contour_vertical

Et voilà le travail!

plt.imshow(contour, cmap='gray');

Superposition d’images

Nous allons maintenant vous montrer comment superposer deux images. Récupérons nos images de fruits du TP 2:

fruit_dir = os.path.join(data_dir, 'apples_and_bananas_simple')
fruits = load_images(fruit_dir, "*.png")
image_grid(fruits)

Et choisissons une banane:

banana = fruits[18]

On convertit les deux images en tableaux numpy:

M = np.array(smiley)
B = np.array(banana)

On choisit les coordonnés où l’on veut supperposer la banane

i = 80
j = 130

On extrait dans P la zone de l’image qui contiendra l’image, on calcule l’avant plan de la banane et, pour tous ces pixels de l’avant plan, on affecte les couleurs de F à P:

P = M[i:i+32, j:j+32]
F = foreground_filter(banana)
P[F] = B[F]

Enfin on réinsère P dans l’image d’origine:

M[i:i+32, j:j+32] = P

Et voilà:

plt.imshow(M);

Superposez de même un autre fruit là où vous le souhaitez!

Puis rajoutez l’image à la liste.

# YOUR CODE HERE
raise NotImplementedError()

Bilan

Voici toutes les images que vous avez produites au cours du traitement

image_grid(images)

Production de l’animation

Nous allons maintenant assembler toutes les images pour produire l’animation illustrant toutes les étapes du traitement d’images ci-dessus.

La cellule suivante utilise les widgets de Jupyter pour construire une mini application interactive:

import ipywidgets

# Vue
output = ipywidgets.Output()
play = ipywidgets.Play(min=0, max=len(images)-1, value=0, interval=500)
slider = ipywidgets.IntSlider(min=0, max=len(images)-1, value=0)
controls = ipywidgets.HBox([play, slider])
application = ipywidgets.VBox([controls, output])

# Controleur
def update(event):
    with output:
        output.clear_output(wait=True)
        fig = Figure()
        ax = fig.add_subplot()
        ax.imshow(images[slider.value], cmap='gray')
        display(fig)

slider.observe(update)
output.on_displayed(update)
ipywidgets.jslink((play, "value"), (slider, "value"))

application

Pour pouvoir exporter l’animation, nous allons produire une vidéo à l’aide de OpenCV que l’on peut appeler directement depuis Python. Un autre outil classique est ffmpeg.

video(images, "video.mp4")

Selon votre navigateur, vous pourrez visualiser la vidéo directement ici:

from IPython.display import Video
Video('video.mp4')

Sinon, téléchargez là.

Revue de code

Cette cellule vous permet à vous et votre instructeur d’évaluer rapidement la qualité de votre code. Elle s’appuye notamment sur l’outil flake8 qui vérifie que votre code respecte les conventions de codage usuelles de Python, telles que définies notamment par le document PEP 8. Si la cellule suivante affiche des avertissements, suivez les indications données pour peaufiner votre code.

code_checker("flake8 utilities.py")