====== Détection de contours ====== 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 -- {{:nsi:tds:clown.bmp?linkonly}} {{ :nsi:tds:clown.bmp?direct |}} 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)... ===== Modules ===== ==== PIL : Python Imaging Library ==== 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() ==== Numpy ==== 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 : * Demandons les dimensions de l'image. >>> np_img.shape (400, 600, 3) On a donc ici une image de hauteur 400, largeur 600, avec 3 canaux (RVB) * Accédons à un pixel particulier >>> 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. ==== Commandes Numpy dont nous aurons besoin ==== === Créer un tableau vierge === 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') === Créer une copie === 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() === Tests et masques === 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. ==== Matplotlib ==== 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() ===== Les fonctions à réaliser ===== ==== Détection de contours sens horizontal ==== Vous devez écrire une fonction ''%%contour_horizontal(np_image, canal, seuil)%%'' * ''np_image'' un tableau numpy représentant l'image * ''canal'' le numéro de canal : 0 pour rouge, 1 pour vert, 2 pour bleu * ''seuil'' 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() ==== Détection de contours sens vertical ==== 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]''. ==== Synthèse des contours ==== 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() ==== Amélioration ==== 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 : == dilatation == Soit ''c'' un tableau de booléens comme le résultat de ''contour'' obtenu avant. ''dilatation(c)'', 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''. == contraction == C'est l'opération inverse. ''contraction(c)'', 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. ===== Application de filtres ===== 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... * il faudra faire des cas particuliers pour ''i == 0'', ''j == 0''... * L'exécution des boucles ''for'' en pur Python sera longue. * L'idée de sommer les voisins est simple mais la formule que cela donne ici n'est pas très lisible. 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 5x5 au lieu de 3x3. 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.//