Dans cet exercice, je propose d'étudier des méthodes (rudimentaires) de détection de contour.
Nous travaillerons sur cet image que vous pouvez télécharger – clown.bmp
Vous êtes libres de choisir autre chose. Faites attention cependant que certaines images auront peut-être une gestion des couleurs différentes. Il s'agit ici d'une image avec 3 canaux RVB. Une image pourrait être en CMYK ou NB ou encore avec un canal alpha (transparence)…
Nous permettra surtout de charger le fichier et éventuellement de l'afficher.
from PIL import Image
pil_img = Image.open('clown.bmp')
pil_img.show()
Le module incontournable qui contient notamment des outils extrêmement puissants pour travailler sur des tableaux. Or, les images sont des tableaux.
from PIL import Image
import numpy as np
pil_img = Image.open('clown.bmp')
np_img = np.array(pil_img).astype('int')
Remarquez que dans la commande de transition PIL → Numpy, nous avons précisé un type int. Dans le fichier image, les données sont des des octets non signés, c'est à dire des valeurs entre 0 et 255. Puisque notre tableau Numpy nous servira à faire des calculs, il peut être intéressant de s'autoriser plus de valeurs. On aurait pu aussi demander un type float ou un type bool.
Suite à cela quelques exemples de commandes :
>>> np_img.shape (400, 600, 3)
On a donc ici une image de hauteur 400, largeur 600, avec 3 canaux (RVB)
>>> np_img[40,56,:] [162 146 133]
Le pixel en ligne 162, colonne 146 a les valeurs 162 (rouge), 146 (vert) et 133 (bleu). Notez qu'en écrivant : on a bien demandé les 3 canaux.
Si on demandait np_img[:,:,0] on obtiendrait tout le tableau pour le canal rouge.
Vous avez remarqué qu'on écrivait [i,j] et pas [i][j]. Cette notation spéciale est liée au module Numpy. Numpy créé un nouveau type de tableau.
Nous voulons créer une représentation des contours de l'image. Nous allons produire un tableau aux même dimension (hauteur, largeur) mais ne contenant que des booléens. Un True correspondra à un contour. Un False sera la valeur par défaut.
Nous voulons donc créer un tableau numpy, contenant des False, au même dimension que l'image d'origine.
H = np_img.shape[0] # hauteur L = np_img.shape[1] # largeur tab = np.full((H,L), False, dtype='bool')
Il arrive que l'on doive lire un tableau que l'on modifie en même temps… Dans ce cas on peut créer une copie, comme une photographie, de l'état initial du tableau. On peut ensuite lire sur la copie et modifier l'original.
tab_copie = tab_original.copy()
Numpy vient avec des commandes qui évitent d'écrire explicitement des boucles for. Numpy réalise lui-même les boucles for mais il le fait avec une efficacité incomparable avec ce que nous obtiendrions si on les écrivait en pur Python.
Ainsi, supposons que l'on ait un tableau contenant des valeurs et que l'on veuille identifier les cases contenant certaines valeurs et que l'on veuille modifier ces cases là…
>>> t = np.array([[2, 9, 15],[1, 0, 6],[7,11,1]])
>>> t > 5
array([[False, True, True],
[False, False, True],
[ True, True, False]])
On a créé un tableau Numpy qui contient True là où le test est vrai, False ailleurs. Ce tableau fonctionnera comme un masque :
>>> m = t > 5
>>> t[m] = 100
>>> t
array([[ 2, 100, 100],
[ 1, 0, 100],
[100, 100, 1]])
L'affectation t[m] = 100 n'agit que dans les cases où m est True.
On aurait pu le faire avec des boucles for mais cela aurait été plus longs à écrire et beaucoup plus long à l'exécution.
Pour visualiser les images résultant de nos calculs, le plus simple est de passer par matplotlib.pyplot.
import matplotlib.pyplot as plt plt.imshow(tab_numpy) plt.show()
Vous devez écrire une fonction contour_horizontal(np_image, canal, seuil)
np_image un tableau numpy représentant l'imagecanal le numéro de canal : 0 pour rouge, 1 pour vert, 2 pour bleuseuil le niveau déclenchant une détection de contour
Cette fonction doit renvoyer un tableau contour numpy de booléens de même dimensions que l'image.
contour[i,j] doit valoir True quand l'écart entre np_image[i,j,canal] et np_image[i+1,j,canal] est supérieur à seuil. Sinon False.
Pour expérimentez, supposant la fonction disponible, vous pouvez faire :
import numpy as np
import matplotlib.pyplot as plt
from PIL import Image
pil_img = Image.open('clown.bmp')
np_img = np.array(img).astype('int')
c = contour_horizontal(np_img, 0, 15)
plt.imshow(c)
plt.show()
Vous devez écrire une fonction contour_vertical(np_image, canal, seuil).
La seule différence est qu'à présent, on compare np_image[i,j,canal] et np_image[i,j+1,canal].
On a de quoi faire des contours horizontaux et verticaux sur les 3 canaux. Cela nous fait donc 6 tableaux de de contours.
cr_hor = contour_horizontal(np_img, 0, 15) cv_hor = contour_horizontal(np_img, 1, 15) cb_hor = contour_horizontal(np_img, 2, 15) cr_ver = contour_vertical(np_img, 0, 15) cv_ver = contour_vertical(np_img, 1, 15) cb_ver = contour_vertical(np_img, 2, 15)
On voudrait produire un tableau c de même dimension dont chaque case serait True si et seulement si au moins des cases correspondantes dans les 6 tableaux est True.
Il s'agit donc de faire un OU logique entre les 6 tableaux.
Malheureusement, Numpy ne comprend pas
cr_hor or cv_hor or ...
Il faudrait pouvoir expliquer comment faire ce calcul case à case.
En passant par des boucles for, c'est possible. Il faut boucler sur toutes les lignes et colonnes et faire l'opération logique. Mais pour utiliser la pleine puissance de Numpy il y a mieux.
def f(a, b):
return a or b
vec_f = np.vectorize(f)
La fonction f ci-dessus est un simple OU logique. Si on essaie f(np_tab_1, np_tab_2) on aura une erreur car numpy ne saura pas faire le or sur les tableaux eux-mêmes.
L'opération np.vectorize sert à produire une variante de f qui ne s'appliquera pas sur les tableaux eux-mêmes mais sur les cases de ces tableaux. Ainsi vec_f(np_tab_1, np_tab_2) produira un tableau de même taille que np_tab_1 et np_tb_2 et donc les cases contiennent les résultats du calcul de f pour chaque case !
Dit autrement, vectorize confie à Numpy le soin de gérer les boucles for nécessaire pour parcourir toutes les cases des tableaux.
À vous : créez une fonctions contour(np_img, seuil) qui calcul les seuils horizontaux et verticaux de tous les canaux, utilise vec_f pour faire un OU entre tous ces tableaux et renvoie le résultat.
Comme précédemment, testez avec
... c = contour(np_img, 15) plt.imshow(c) plt.show()
Le contour obtenu est très bruité. Il est possible d'améliorer un peu les choses avec des opérations simples. Je vous en propose deux :
Soit c un tableau de booléens comme le résultat de contour obtenu avant.
dilatation©, modifie c de cette façon : un pixel de c passe à True dès lors qu'au moins un de ces voisins (haut, bas, gauche, droite) est à True.
C'est l'opération inverse.
contraction©, modifie c de cette façon : un pixel de c passe à False dès lors qu'au moins un de ces voisins (haut, bas, gauche, droite) est à False.
Armé de ces deux fonctions, vous pouvez essayer par exemple :
... c = contour(np_img, 15) dilatation(c) contraction(c) plt.imshow(c) plt.show()
Les fonctions dilatation et contraction ont pour but de manger les petites aspérités.
Le traitement d'image fait un grand usage de filtres. Prenons un exemple simple : une filtre moyenne.
Un filtre moyenne consiste à parcourir l'image et faire un calcul à chaque pixel. Pour chaque pixel, on calcule la somme des valeurs de ses 8 voisins plus la valeur du pixel lui même. On divise le tout par 9.
On a donc, pour chaque pixel pris la valeur moyenne de son voisinage immédiat.
On n'aurait pas trop de mal à le faire avec des boucles for :
new_img = img.copy()
for l in range(H):
for c in range(L):
total = img[i-1,j-1] + img[i-1,j] + ... + img[i+1,j+1]
new_img[i,j] = total // 9 # // pour arrondir à l'entier
return new_img
Mais…
i == 0, j == 0…for en pur Python sera longue.Donc on aimerait une autre forme. On invente une opération appelée le produit de convolution. C'est un outil mathématique très utile, je ne détaille pas trop, voici l'idée.
Je me donne une matrice :
mask = np.array([1, 1, 1],
[1, 1, 1],
[1, 1, 1])
Cette matrice représente le calcul désiré : la case centrale représente le pixel traité. On attribue un poids de 1 à ce pixel et à ses 8 voisins. Visuellement c'est plus parlant que le gros calcul
img[i-1,j-1] + ... + img[i+1,j+1]
Le produit de convolution $image \star mask$ va consister à produire la nouvelle image appliquant ce masque à chaque pixel de l'image. Donc le produit de convolution se charge des deux boucles for !
Malheureusement, Numpy n'a pas de produit de convolution en 2D et il nous faut un module de plus…
from scipy import ndimage
mask = np.array([1, 1, 1],
[1, 1, 1],
[1, 1, 1])
# np_img est toujours l'image de clown à 3 canaux
np_img[:,:,0] = ndimage.convolve(np_img[:,:,0], mask) //9
# le canal rouge a ainsi subi le filtre moyenne !
C'est évidemment beaucoup plus rapide à l'exécution de cette façon.
Le filtre moyenne permet de lisser l'image. Les petites variations sont gommées par le moyennage. Cela correspond à un flou. C'est utile pour préparer la détection de contour. Vous pouvez voir dans l'exemple du clown que beaucoup de contours sont détectés dans les cheveux du clown. C'est un effet de texture : beaucoup de petites variations peu significatives. Si on les lisse par moyennage, la détection de contour fonctionnera mieux.
Remarque : j'ai obtenu un bon filtrage de la texture des cheveux en moyennant deux fois de suite. On pourrait aussi utiliser un filtre 5×5 au lieu de 3×3. Bien sûr cela peut amener à ne plus détecter certains contours que l'on voulait conserver… C'est tout le travail du traitement d'image : trouver les bons réglages en fonction des cas. L'intelligence articielle permet aujourd'hui de trouver automatiquement les bons réglages.