Traitement d'image

In [ ]:
%pylab inline
Populating the interactive namespace from numpy and matplotlib

Introduction

C'est bien connu, une image est composée de pixels. Ces pixels peuvent être décrits par un tableau de nombres. La façon la plus efficace de manipuler des tableaux de nombres en Python est d'utiliser la bibliothèque Numpy.

Dans ce TP vous allez écrire des programmes réalisant quelques tranformations simples sur une image. Ce sera aussi l'occasion pour vous de vous familiariser avec les bases des bibliothèques Numpy et Matplotlib.

Prise en main de Numpy

Exécutez l'instruction suivante :

In [ ]:
pixels = plt.imread("damier-ng.jpeg")

L’image stockée dans le fichier damier-ng.jpeg est maintenant représentée en mémoire sous la forme d’un tableau Numpy accessible sous le nom pixels :

In [ ]:
pixels
Out[ ]:
array([[254, 254, 254, ..., 254, 254, 254],
       [254, 254, 254, ..., 254, 254, 254],
       [255, 255, 255, ..., 255, 255, 255],
       ..., 
       [255, 255, 255, ..., 255, 255, 255],
       [255, 255, 255, ..., 255, 255, 255],
       [255, 255, 255, ..., 255, 255, 255]], dtype=uint8)

Comme le montre les double crochets, il s'agit d'un tableau à deux dimensions. Plus précisément :

In [ ]:
pixels.shape
Out[ ]:
(795, 1024)

L'image comporte 795 pixels en hauteur et 1024 pixels en largeur.

Chaque pixel est représenté par une valeur de type np.uint8, c'est-à-dire par un entier non signé, codé sur 8 bits (entier compris entre 0 et 255).

Pour visualiser l'image on peut utiliser la fonction matplotlib.pyplot.imshow :

In [ ]:
plt.imshow(pixels)
Out[ ]:
<matplotlib.image.AxesImage at 0x7f53a2f14588>

Comme le montre l'affichage ci-dessus, le comportement par défaut de la fonction imshow est de représenter chaque valeur entre 0 et 255 par une échelle de couleur.

In [ ]:
plt.imshow(pixels)
plt.colorbar()
Out[ ]:
<matplotlib.colorbar.Colorbar at 0x7f53a2f9ada0>

Ceci est utile lorsque le tableau 2D numpy contient une information autre qu'une image ordinaire. On peut modifier l'échelle de couleur en ajoutant l'argument cmap :

In [ ]:
plt.imshow(pixels, cmap='gray')
Out[ ]:
<matplotlib.image.AxesImage at 0x7f53a2e7b2b0>

Il faut cependant souligner que dans l'image ci-dessus, les pixels de valeur minimale sont noirs et ceux de valeur maximale sont blancs. Pour obtenir les vrais niveaux de gris il faut de plus utiliser les arguments vmin et vmax :

In [ ]:
plt.imshow(pixels, cmap='gray', vmin=0, vmax=255)
Out[ ]:
<matplotlib.image.AxesImage at 0x7f53a2e5ee80>

Le slicing

Les cases du tableau peuvent être accédées individuellement ou par tranche (slice en anglais) :

Syntaxe Signification
pixels[i, j] Pixel de coordonnées (i, j)
pixels[i] Ligne i (tableau 1D)
pixels[i:r, j:s] Sous-tableau 2D formé des lignes de i à r-1 et des colonnes de j à s-1
pixels[:i, j:] Sous-tableau 2D formé des lignes strictement inférieures à i et des colonnes supérieures ou égales à j
pixels[:, j] Colonne j (tableau 1D)

La bibliothèque Numpy permet d'écrire du code à la fois concis et efficace. Par exemple :

In [ ]:
pixels[50:250, 150:250] = 128
plt.imshow(pixels, cmap='gray', vmin=0, vmax=255)
Out[ ]:
<matplotlib.image.AxesImage at 0x7f53a2d92908>

L’instruction précédente est équivalente à pixels[50:250, 150:250] = tabletable est un tableau de la même taille que pixels[50:250, 150:250] avec toutes ses valeurs à 128.

Les boucles imbriquées qui modifient les différentes cases du tableau sont toujours présentes mais ne sont pas écrites explicitement. De plus, elles sont réalisées par le processeur lui-même ce qui rend le code plus rapide à exécuter. Il faut bien garder à l'esprit que cette instruction ne s'exécute pas en temps constant. Ce temps reste proportionnel au nombre de cases du tableau. Le coefficient de proportionnalité est simplement nettement plus petit.

Question 1

Créez un bord gris de 100 pixels d’épaisseur tout autour de l’image.

In [ ]:
 

Question 2

Modifier l'image en inversant l’intensité des pixels. En particulier le blanc et le noir seront inversés.

Utilisez pour cela deux boucles imbriquées

In [ ]:
 

Même s’ils sont en dimension deux ou plus, les tableaux Numpy sont nécessairement stockés à plat en mémoire. La zone de la mémoire qui stocke le tableau contient d’abord des données précisant la forme et le type du tableau, puis viennent ensuite les valeurs les unes à la suite des autres en commençant par la première ligne.

In [ ]:
pixels.size
Out[ ]:
814080

pixels.size est le nombre total de valeurs du tableau et pixels.flat fournit une vue à plat du tableau :

In [ ]:
hauteur, largeur = pixels.shape
x = 600
y = 300
print(pixels[x, y])
print(pixels.flat[x*largeur + y])
81
81

Question 3

Inverser à nouveau les pixels en n'utilisant qu'une seule boucle.

Le code de cette cellule doit à nouveau lire le fichier de façon à partir de l'image originale.

Dans un notebook, de façon générale, il est souhaitable de rendre les cellules de code les plus autonomes possibles entre-elles. Ceci facilite la lisibilité ainsi que la mise au point.

In [ ]:
 

Question 4

Grâce au principe de diffusion il est également possible de réaliser cette inversion sans écrire de boucle. Comment ?

Lisez à nouveau le fichier.

In [ ]:
 

Le concept de vues Numpy

Un tableau Numpy est constitué :

  • des données : mots mémoire contigus dans la RAM
  • de métadonnées : taille des mots, type des éléments, dimensions du tableau, ...

Deux tableaux Numpy peuvent partager les mêmes données mais avec des métadonnées différentes. Par exemple, l'un des tableaux peut être 1D et l'autre 2D. Toute modification de l'un des tableaux modifiera également l'autre tableau puisqu'il partage la même zone mémoire. On dit que les deux tableaux sont deux vues de la même donnée. C'est exactement ce que vous avez observé ci-dessus avec pixels.flat.

Exécutez les cellules de code ci-dessous en observant attentivement les effets produits par ce code.

In [ ]:
T1 = np.arange(12)
T1
Out[ ]:
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11])
In [ ]:
T2 = T1.reshape((3, 4))
T2
Out[ ]:
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
In [ ]:
T2[1, 2] = 27
T2
Out[ ]:
array([[ 0,  1,  2,  3],
       [ 4,  5, 27,  7],
       [ 8,  9, 10, 11]])
In [ ]:
T1
Out[ ]:
array([ 0,  1,  2,  3,  4,  5, 27,  7,  8,  9, 10, 11])
In [ ]:
T3 = T2[1:3, 2:4]
T3
Out[ ]:
array([[27,  7],
       [10, 11]])
In [ ]:
T3[0, 0] = 36
T3
Out[ ]:
array([[36,  7],
       [10, 11]])
In [ ]:
T2
Out[ ]:
array([[ 0,  1,  2,  3],
       [ 4,  5, 36,  7],
       [ 8,  9, 10, 11]])
In [ ]:
T1
Out[ ]:
array([ 0,  1,  2,  3,  4,  5, 36,  7,  8,  9, 10, 11])
In [ ]:
T4 = T3.copy()
T4
Out[ ]:
array([[36,  7],
       [10, 11]])
In [ ]:
T4[0, 0] = 42
T4
Out[ ]:
array([[42,  7],
       [10, 11]])
In [ ]:
T3
Out[ ]:
array([[36,  7],
       [10, 11]])

Matrices de convolution

Une opération courante en traitement d’image consiste à recalculer la valeur d’un pixel en fonction des valeurs des pixels environnants. Ceci s’applique par exemple lorsque l’on souhaite flouter une image. Souvent, la nouvelle valeur du pixel est une combinaison linéaire des valeurs environnantes. On peut alors décrire la transformation par une matrice.

Considérons la matrice

$$ K = \frac{1}{100} \begin{pmatrix} 1 & 2 & 4 & 2 & 1 \\ 2 & 4 & 8 & 4 & 2 \\ 4 & 8 & 16 & 8 & 4 \\ 2 & 4 & 8& 4 & 2 \\ 1 & 2 & 4 & 2 & 1 \end{pmatrix} $$

Hormis les pixels trop près du bord de l’image, chaque pixel est le centre d’un carré de 5 pixels de côté. Nous nous proposons de remplacer la valeur de ce pixel par la moyenne des 25 pixels du carré, en utilisant la matrice $K$ comme coefficients de pondération. Les pixels proches du centre auront ainsi plus d’influence sur la valeur moyenne.

Question 5

Définir un tableau Numpy correspondant à la matrice $K$ ci-dessus.

In [ ]:
 

Question 6

Coder la fonction filtrer.

La fonction ne retourne rien. Elle modifie le tableau T passé en agument.

Le calcul de la moyenne des $(2s+1)^2$ pixels peut se faire en utilisant la vectorialisation Numpy et la fonction np.sum.

In [ ]:
def filtrer(T, K):
    """ Effectue un moyennage des valeurs du tableau T, pondérée
        par les coefficients de la matrice K.
        
        La matrice K est supposée être carrée et de dimension 2s + 1
        Les pixels situés sur le bord extérieur de largeur s
        ne sont pas modifiés. 
        Les autres pixels (i, j) sont remplacés par la moyenne 
        des pixels situés dans le carré de centre (i, j) et 
        de largeur 2s + 1. La moyenne est pondérée à l'aide des
        coefficients de K.
    """
    ### À COMPLÉTER

On souhaite flouter le visage de la fillette de l'image ecole-ng.jpeg :

In [ ]:
from IPython.display import Image
Image("ecole-ng.jpeg", width=400)
Out[ ]:

Question 7

Modifier l'image ecole-ng.jpeg de façon à ce que le visage de la fillette soit floutée.

Repérez les coordonnées du visage afin de ne flouter que celui-ci.

Il peut être nécessaire d'appliquer plusieurs fois le filtre afin d'obtenir un flou suffisant.

In [ ]:
 

L'opération effectuée sur T par la fonction filtrer s'appelle une convolution. La matrice K est le noyau de la convolution. La convolution avec les matrices Gx ou Gy ci-dessous permet d'obtenir un effet autre qu'un floutage.

$$ G_x = \begin{pmatrix} -1 & 0 & 1 & \\ -2 & 0 & 2 & \\ -1 & 0 & 1 \end{pmatrix} \quad \text{ ou } \quad G_y = \begin{pmatrix} -1 & -2 & -1 & \\ 0 & 0 & 0 & \\ 1 & 2 & 1 \end{pmatrix} $$

Vous pouvez remarquer que les sommes des coefficients de ces matrices sont nulles. Si on applique la fonction filtrer avec elles, on ne calcule donc pas vraiment une moyenne. D’ailleurs, le tableau qui en résulte peut contenir des valeurs négatives. Il ne peut donc pas être interprété comme étant une image, du moins pas directement.

Question 8

Coder la fonction filtrer2.

Il s'agit essentiellement du même calcul que pour filtrer. La différence est que la fonction retourne ici un tableau $R$ de type float au lieu de modifier le tableau $T$.

Pour créer un tableau de flottants de même dimensions que T, on peut utiliser l'instruction R = np.zeros(T.shape).

In [ ]:
def filtrer2(T, K):
    """ Effectue la convolution du tableau T avec le tableau K.
        
        La matrice K est supposée être carrée et de dimension 2s+1.
        La fonction retourne un tableau R de mêmes dimensions que T.
        Les valeurs de R situés sur le cadre extérieur de largeur s
        ont une valeur nulle. 
    """
    ### À COMPLÉTER

Question 9

Effectuer la convolution de l'image ecole-ng.jpeg avec le noyau Gx ci-dessus. Stocker le résultat dans le tableau Px.

Vous devez obtenir Px[95, 150] == 532.0 et Px[471, 601] == -10.0.

In [ ]:
 

Question 10

Afficher le sous-tableau $3\times 3$ de l'image ecole-ng.jpeg centré sur le pixel (95, 150). Faire de même pour le pixels (471, 601).

Quelle information sur l'image fournit la valeur Px[i, j] ?

In [ ]:
 

Question 11

Représenter la distribution statistique des valeurs de Px sous la forme d'un histogramme.

Utilisez pour cela la fonction plt.hist.

In [ ]:
 

Question 12

Représenter graphiquement les valeurs de Px à l'aide de la fonction plt.imshow.

Ajouter l'instruction plt.colorbar() afin afficher l'échelle de couleur.

Quelle peut-être l'utilité de cette opération de convolution ?

In [ ]:
 

Question 13

Créer une image en niveau de gris qui met en évidence les contours de l'image ecole-ng.jpeg.

In [ ]:
 

Images en couleur

On considère à présent les trois images en niveau de gris canal0.jpeg, canal1.jpeg et canal2.jpeg :

In [ ]:
canal0 = plt.imread('canal0.jpeg')
canal1 = plt.imread('canal1.jpeg')
canal2 = plt.imread('canal2.jpeg')
figure, (ax0, ax1, ax2) = plt.subplots(1, 3, figsize=(10,5))
ax0.imshow(canal0, cmap='gray', vmin=0, vmax=255)
ax1.imshow(canal1, cmap='gray', vmin=0, vmax=255)
ax2.imshow(canal2, cmap='gray', vmin=0, vmax=255)
Out[ ]:
<matplotlib.image.AxesImage at 0x7f898c0e20f0>

Ces trois images sont en fait les trois canaux d’une même image couleur codée au format RGB. Les canaux 0, 1 et 2 représentent respectivement les intensités de rouge, de vert et de bleu. Chaque combinaison d’intensités correspond à une couleur particulière.

Question 14

Combien de couleurs différentes peut-on coder dans le format RGB ?

In [ ]:
 

Question 15

De quelle couleur sont les manches des brosses ?

Une image RGB complète est représentée par un tableau Numpy à trois dimensions. Sa forme est (H, L, 3)H est la hauteur et L la largeur de l'image.

  • pixels[i, j, 0] est la quantité de rouge du pixel (i, j)

  • pixels[i, j, 1] est la quantité de vert du pixel (i, j)

  • pixels[i, j, 2] est la quantité de bleu du pixel (i, j)

Question 16

Construire le tableau pixels à partir des tableaux des trois canaux et afficher l'image RGB.

Une façon de construire l'image est de créer un tableau Numpy 3D à l'aide de la fonction np.zeros puis d'y copier chaque canal.

Utilisez dtype=np.uint8 comme argument optionnel de la fonction nb.zeros afin que les éléments aient le bon type.

Pour afficher l'image, utilisez la fonction imshow sans l'argument cmap.

In [ ]:
 

Question 17

Assembler différemment les trois canaux de façon à obtenir une image avec des manches jaunes

In [ ]:
 

Stéganographie

Hérodote (484-445 av. J.C.) rapporte qu’Histiée incite son gendre Aristagoras, gouverneur de Milet, à se révolter contre son roi, Darius, et pour ce faire, « il fit raser la tête de son esclave le plus fidèle, lui tatoua son message sur le crâne et attendit que les cheveux eussent repoussé ; quand la chevelure fut redevenue normale, il fit partir l’esclave pour Milet ». Cette anecdote est un des premiers exemples de stéganographie, l’art de dissimuler une information dans un objet. Le fichier tranquility.png est une photographie d’un jardin dans un temple Zen. Vous pouvez la visualiser avec le logiciel de votre choix. Cepandant, le fichier contient une seconde image cachée dans la première. Chaque pixel d’une image RGB est codé sur 3 octects (un par canal). Au lieu d’utiliser les 8 bits pour coder un entier quelconque entre 0 et 255, seuls les quatre bits de poids forts ont été utilisés pour coder l’image apparente. Les quatre bits de poids faibles peuvent alors être utilisés pour l’image cachée.

Question 18

Faire apparaître l’image cachée dans le fichier tranquility.png.

Pour une raison obscure, le tableau retourné par imread contient des valeurs flottantes entre 0 et 1 lorsque le fichier image est au format PNG. Pour obtenir des entiers entre 0 et 255 il suffit d'exécuter l'instruction :

pixels = np.uint8(pixels * 255).

In [ ]:
 

Mona Lisa au photomaton

In [ ]:
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 5))
pixels = plt.imread('mona-lisa.jpeg')
pixels2 = plt.imread('mona-lisa-x4.jpeg')
ax1.imshow(pixels)
ax2.imshow(pixels2)
Out[ ]:
<matplotlib.image.AxesImage at 0x7f8986339b70>