INITIATION AU TRAITEMENT D'IMAGES avec NUMPY

François louis LAILLIER

À la fin de ce tutoriel vous saurez (si mon premier tuto est réussi) récupérer les données brutes d'une image, les mettre en forme afin de les traiter et de reconstruire l'image résultat. Tout cela sera fait avec l'aide des bibliothèques PIL et NumPy. Côté traitement d'images, nous traiterons la segmentation à deux seuils, la dilatation et l'érosion. Avant de commencer, je tiens à remercier _GUIGUI sans qui ce tutoriel, alors qu'il était presque terminé, n'aurait jamais été publié.

- Titre : INITIATION AU TRAITEMENT D'IMAGES avec NUMPY

- Auteur : François louis LAILLIER

- Parution : 4 juin 2006

- Mise à jour : 19 mars 2007

- Licence : Copyright ® 2007 François louis LAILLIER. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. PRÉREQUIS

- II. Version PYTHON et bibliothèques utilisées

- III. MANIPULATIONS BASIQUES DES IMAGES

- III-A. Ouvrir une image et extraire les données

- III-B. Reconstruire une image à partir d'une matrice

- IV. TRAITEMENT D'IMAGE BASIQUE

- IV-A. Segmentation

- IV-A-1. Rappel de cours

- IV-A-2. Application à la programmation

- IV-A-3. Exemple de l'application

- IV-B. Dilatation

- IV-B-1. Rappel de Cours

- IV-B-2. Application à la programmation

- IV-B-3. Exemple de l'application

- IV-C. Érosion

- IV-C-1. Rappel de cours

- IV-C-2. Application à la programmation

- IV-C-3. Exemple de l'application

- V. CONCLUSION

I - PRÉREQUIS

Pour suivre ce tutoriel, vous devez avoir un minimum de connaissances sur les données des images. Il vous faut aussi connaître un minimum la bibliothèque Python Image Library (PIL). Une connaissance de NumPy est inutile. Je tiens à souligner que la plupart des choses acquises au cours de ce tutoriel sont faisables directement avec PIL. PIL reste quand même limitée à mon goût. Si vous savez faire ce qui est expliqué dans ce tutoriel, vous saurez faire vos propres opérateurs.

II - Version PYTHON et bibliothèques utilisées

- PYTHON 2.5

- PIL 1.1.6

- NumPy

- Système d'exploitation : Windows XP HOME

- Tests effectués avec IDLE 1.2

Vous pouvez également accéder à ces pages depuis notre rubrique Outils Python.

III - MANIPULATIONS BASIQUES DES IMAGES

Dans cette partie du tutoriel, nous apprendrons à manipuler les données brutes issues des images. Nous apprendrons aussi à reconstruire une image grâce à ces données.

III-A - Ouvrir une image et extraire les données

Dans ce paragraphe, le but est d'ouvrir une image en précisant son chemin et remettre en forme les données afin de pouvoir les traiter. Afin de vérifier si votre code fonctionne, je vous conseille de créer une image test, une image de petite dimension dont il sera facile d'imprimer sa composition à l'écran.
Exemple d'image :

test.bmp

Ainsi nous aurons quelque chose de ce type comme données brutes.

0,0,0,0,0,0
0,1,1,1,1,0
0,0,0,0,0,0

Une fois cela fait, nous allons importer les modules nécessaires à ce tutoriel.

Importation des modules

IDLE 1.2
>>> import PIL
>>> import Image # on aurait pu faire from PIL import Image
>>> import numpy
>>> dir()
['Image', 'PIL', '__builtins__', '__doc__', '__name__']

Nous allons la mettre en niveau de gris parce qu'une image est en général composée de trois composantes de couleurs, voire quatre : RGB, RGBA, HSI. Nous travaillerons sur une image grise, image qui pourrait être une image du canal R. (Chaque pixel du canal est codé sur un octet ici. D'où l'intérêt de travailler sur du gris. Image monocanal.) Pour cela, on utilise la fonction grayscale du module ImageOps :

>>> import ImageOps
>>> img=ImageOps.grayscale(img)

Nous allons maintenant extraire toutes les données brutes de l'image. On fait cela grâce à la fonction getdata()

>>> imgdata = img.getdata()
>>> print list(imgdata)
[0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0]

On voit bien qu'après le getdata, nous avons les données de l'image sous forme d'une liste 1D, avec tous les pixels les uns à la suite des autres. Il faut donc remettre cette liste sous forme de matrice. Pour cela, on a besoin des dimensions de l'image. On utilisera l'attribut size

>>> larg, haut = img.size
>>> larg
6
>>> haut
3

C'est ici que NumPy intervient, on va se servir de NumPy qui est un module qui gère bien les tableaux. Nous allons mettre les données que nous avons récupérées dans un tableau (array)

>>> tab = numpy.array(imgdata)

Si vous tapez dans la console :

>>> tab
array([  0,   0,   0,   0,   0,   0,   0, 255, 255, 255, 255,   0,   0,
0,   0,   0,   0,   0])

Alors que si vous tapez :

>>> print tab
[  0   0   0   0   0   0   0 255 255 255 255   0   0   0   0   0   0   0]

La différence nous montre que si nous appelons tab, le shell nous dit que c'est un tableau qui a les données suivantes, et nous indique le type si nous en avons spécifié un, alors que si nous imprimons tab, la console nous indique seulement les données du tableau (non séparées par des virgules).
Nous sommes au même point que précédemment : nos données se présentent sous une forme 1D. Pour mettre ces données en forme, il faut se pencher du côté des shape (forme).

>>> print numpy.shape(tab)
(18,)

Cela nous indique les dimensions x,y d'une liste ou tableau. En utilisant la fonction reshape, nous allons mettre la matrice en forme.

>>> matrix = numpy.reshape(tab,(larg,haut))
>>> matrix
array([[  0,   0,   0],
[  0,   0,   0],
[  0, 255, 255],
[255, 255,   0],
[  0,   0,   0],
[  0,   0,   0]])
>>> matrix= numpy.reshape(tab,(haut,larg))
>>> matrix
array([[  0,   0,   0,   0,   0,   0],
[  0, 255, 255, 255, 255,   0],
[  0,   0,   0,   0,   0,   0]])

On voit bien ici que le souci est dans la compréhension, quand on donne les dimensions d'une image, on donne souvent largeur*hauteur (cf. plus haut avec la fonction size) alors que lorsqu'on travaille avec des matrices, c'est toujours donné sous le format lignes*colonnes.

Bon voilà nous avons vu les commandes à connaître pour faire une fonction OuvrirImg.

Fonction OuvrirImg

def OuvrirImg(path):
Img = Image.open(str(path))
Img1 = ImageOps.grayscale(Img)
largeur,hauteur = Img1.size
imdata=Img1.getdata()
tab=numpy.array(imdata)
matrix = numpy.reshape(tab,(hauteur,largeur))
return matrix

>>> a = OuvrirImg("c:\\test.bmp")
>>> print a
[[  0   0   0   0   0   0]
[  0 255 255 255 255   0]
[  0   0   0   0   0   0]]

III-B - Reconstruire une image à partir d'une matrice

Maintenant nous savons ouvrir une image et mettre ses données sous forme de matrice. Le but de ce paragraphe est de reconstruire une image à partir d'une matrice de type ARRAY.

>>> a = OuvrirImg("c:\\test.bmp")

Nous avons chargé l'image dans la variable a. Il faut créer une nouvelle image afin d'écrire dedans. Cela se fait avec la fonction new du module Image, il faut que l'image soit de la taille du tableau.

>>> Copie = Image.new("L",(a.shape[1],a.shape[0]))

Ici, nous venons de créer l'objet image Copie, une image en niveau de gris. (mode='L'), de dimension (nombre de colonnes x le nombre de lignes). Cette image est vide. Nous allons la remplir. Pour cela la fonction réciproque de getdata() est utilisée. Comme getdata nous retourne les données sous forme de liste 1D, il faudra une liste 1D comme argument pour putdata. Nous allons donc mettre « à plat » la matrice à l'aide de l'attribut flat. Pour voir le résultat de façon concrète nous sommes obligé d'utiliser list(a.flat)

>>> print a
array([[  0,   0,   0,   0,   0,   0],
[  0, 255, 255, 255, 255,   0],
[  0,   0,   0,   0,   0,   0]]

>>> list(a.flat)
[0, 0, 0, 0, 0, 0, 0, 255, 255, 255, 255, 0, 0, 0, 0, 0, 0, 0]
>>> Copie.putdata(list(a.flat))

Les données sont intégrées à l'image. Maintenant deux options :

- soit l'affichage direct de l'image ;

- soit la sauvegarde du fichier.

Pour l'affichage, on utilise la fonction show.

>>> Copie.show()

Pour la sauvegarde, nous utiliserons la fonction save en indiquant comme argument le chemin.

>>> Copie.save(fp="C:\\Rebuilt.bmp")

Il ne nous reste plus qu'à aller voir sur notre disque si l'image rebuilt.bmp existe. Nous pouvons programmer la fonction rebuildImg grâce aux commandes vues ci-dessus.

Fonction RebuildImg

def RebuildImg(data,path): #data de l'image à reconstruire, plus chemin de sortie.
Copie = Image.new("L",(data.shape[1],data.shape[0]))
Copie.putdata(list(data.flat))
Copie.save(fp=str(path))

>>> RebuildImg(a,"c:\\IMG.bmp")

Après ces deux paragraphes, nous savons prendre les données brutes de l'image et reconstruire une image. Dans les prochains paragraphes, le traitement d'image sera le centre d'intérêt.

IV - TRAITEMENT D'IMAGE BASIQUE

Pour ce qui est du traitement d'image, nous ferons des choses basiques :

- une fonction seuillage avec possibilité de donner deux seuils ;

- une érosion compatible sur les images binaires et en niveaux de gris ;

- une dilatation compatible sur les images binaires et en niveaux de gris.

Cette partie sera constituée d'un rappel de cours sur les morphologies mathématiques, puis de l'application en programmation.

IV-A - Segmentation

IV-A-1 - Rappel de cours

La segmentation est l'opération qui permet de séparer une image en deux ensembles d'objets. Le critère de sélection est la valeur des pixels présents dans l'image. Le but étant de créer deux populations, nous allons séparer l'ensemble des pixels en deux ensembles distincts. Nous mettrons dans une population les pixels ayant pour valeurs des valeurs supérieures au seuil donné, ce sera la population blanche, ou objet. Dans l'autre population, nous mettrons les pixels ayant des valeurs inférieures au seuil donné, ce sera la population noire, ou fond. On peut exécuter un double seuillage, c'est-à-dire établir la population blanche étant la population répondant à deux critères de seuils. Par exemple la population blanche sera les pixels ayant une valeur inférieure au seuil 1 mais supérieure au seuil 2.

IV-A-2 - Application à la programmation

Je vais écrire un algorithme simple, mais qui nous permettra de bien comprendre le but recherché. Ensuite je décrirai les commandes permettant de mettre en œuvre le seuillage.

Algorithme du seuillage

Faire ouvrir Image à seuiller
Créer nouveaux tableaux
Faire pour tout i dans l'image à seuiller
regarder pixel[i]
si pixel[i] inférieur seuil 1 et si pixel[i] supérieur seuil 2
mettre tableau[i] à 1
sinon mettre tableau[i] à zéro.
Fin si
Créer l'image résultat
Fin faire

Nous admettrons pour la suite du développement que la variable Img est le résultat de l'ouverture d'une image par la fonction OuvrirImg().

>>> Img = OuvrirImg("c:\\test.bmp")

Tout d'abord, je tiens à souligner que faire un seuillage sur test.bmp n'est pas judicieux, car il n'y a que deux valeurs de pixels. Donc les pixels sont déjà triés en deux populations. Une fois la fonction programmée, elle marchera pour les images en niveaux de gris. Je vais développer plusieurs solutions afin que vous puissiez voir comment on peut faire la même chose de façon différente. Pour commencer, nous allons construire le nouveau tableau. Comme vous avez pu le voir dans la FAQ, il existe plusieurs façons pour créer un tableau. Tout dépend de comment on veut procéder.

>>> tab=[0]*2 #crée une liste de 0 de longueur 2
>>> tab
[0, 0]

Il est peut-être judicieux d'utiliser la fonction append.

>>> tab.append(12)
>>> tab
[0, 0, 12]

Il faut se rendre compte d'une chose, notre image a deux dimensions. Pour le seuillage, travailler à une dimension est suffisant. Donc nous fabriquerons un tableau avec la fonction append. Vous aurez compris que nous travaillerons sur notre matrice au format « FLAT ». Nous utiliserons une boucle for pour parcourir les pixels. Nous ferons un test de comparaison des valeurs des pixels.

>>> Img = OuvrirImg("c:\\test.bmp")
>>> ImgF = list(Img.flat)
>>> SeuilHaut = 128
>>> SeuilBas = 25
>>> Tab = []
>>> for i in range(len(ImgF)):
if ImgF[i]<SeuilBas and ImgF[i] > SeuilHaut:
Tab.append(1)
else:Tab.append(0)
>>> print Tab

Fonction Seuillage

def Seuillage(img,seuil_B,seuil_H):
imgF=list(img.flat)
Threshold=[]
for i in range(len(imgF)):
if imgF[i] < seuil_H and imgF[i] > seuil_B:
Threshold.append(1)
else: Threshold.append(0)
Threshold=numpy.array(Threshold)
Threshold=Threshold.reshape(img.shape[0],img.shape[1])
RebuildImg(Threshold*255,"c:\\seuillage.bmp")
return Threshold

Ci-dessus est implémentée la fonction Seuillage(), j'ai quelques commentaires à faire sur certaines lignes. Dans l'algorithme nous avions vu que nous devions construire l'image résultante de notre traitement. Tout se passe dans cette ligne RebuildImg(Threshold*255,"c:\\seuillage.bmp"), nous multiplions les valeurs contenues dans Threshold par 255 pour l'affichage. En effet l'image Threshold est une image binaire, cela veut dire que ses valeurs sont comprises entre 0 et 1. Pour que les objets apparaissent en blanc, je multiplie donc le tableau par 255. Le return Threshold retourne un tableau de type binaire pour que l'on puisse faire des opérations booléennes dessus (ET, OU, UNION, etc., etc.).

Cependant, dans le souci du détail, Guigui_ m'a fait parvenir une façon de faire un seuillage 100 % NumPy et celui-ci est plus rapide.

Fonction Seuillage

import numpy

def seuillage(img, seuil, vbasse = 0, vhaute = 255):
return numpy.where(img>= seuil, vhaute, vbasse)

Voilà, je pense que cela se passe de commentaire, la fonction where construit un array qui a pour valeur 255 lorsque les pixels sont supérieurs au seuil, sinon ils sont à zéro. Pour obtenir un objet plus « Booleen », remplacez 255 par 1.

IV-A-3 - Exemple de l'application

J'ai effectué un seuillage de 125-250 sur l'image de lenna.GIF. Voici le résultat.
lenna.gif

seuillage.gif

IV-B - Dilatation

IV-B-1 - Rappel de Cours

La dilatation est avec l'érosion et quelques autres opérateurs, un opérateur de base en morphologie. Nous pouvons créer plusieurs filtres morphologiques avec la dilatation et l'érosion. L'érosion est une analogie de la dilatation, quand vous aurez compris la dilatation, il sera aisé pour vous de comprendre l'érosion. Admettons un ensemble d'objets. Posons-nous la question : « Est-ce qu'en le parcourant dans l'image mon élément structurant touche mon objet ? » La réponse positive à cette question s'appelle le DILATÉ de mon objet.
Qu'est-ce que l'élément structurant ?
L'élément structurant est un masque que nous faisons parcourir sur toute l'image. À chaque position, on se pose la question posée précédemment, sachant que la réponse sera appliquée à l'endroit où est centré l'objet.
Exemple avec un objet, un élément structurant carré 3x3 et deux positions de celui-ci :

Dans le premier cas : est-ce que l'élément structurant touche l'objet ?
Réponse : NON, donc le pixel sous le centre de l'élément structurant (case rouge) n'est pas un pixel du dilaté de l'objet.

Dans le deuxième cas : est-ce que l'élément structurant touche l'objet ?
Réponse : OUI, donc le pixel sous le centre de l'élément structurant appartient au dilaté de l'objet.

IV-B-2 - Application à la programmation

Nous avons vu les bases de la dilatation, mais lorsqu'il faut coder tout cela, on utilise une autre définition de la dilatation. En programmation, le dilaté d'un pixel c'est la valeur MAX au voisinage de ce pixel compris dans l'élément structurant. La définition citée plus haut ne marche que pour les images binaires, cependant, on peut dilater une image en niveaux de gris. La définition que je viens de citer fonctionne avec les images binaires comme en niveaux de gris.
Un autre problème intervient en programmation, c'est l'effet de bord. La gestion des bords en traitement d'images est assez subjective, pour certains comme pour la plupart des cas, nous supprimons les objets touchant les bords, la gestion des bords est alors une chose obsolète. Pour nous, la gestion des bords est respectée. Pour cela, nous allons agrandir notre image. Nous allons ajouter une bordure d'un pixel, car comme vous l'avez deviné, nous aurons des problèmes du genre index out of range.
Exemple d'une dilatation sur une matrice de nombres. RAPPEL DE DÉFINITION : le dilaté est la valeur MAX comprise au voisinage d'un pixel compris dans l'élément structurant.

La valeur surlignée est bien la valeur MAX comprise dans l'élément structurant. La valeur surlignée est bien le dilaté des valeurs à cet endroit.

Algorithme de la dilatation

Ouvrir Image à Dilater
Ajouter une bordure à l'image pour gérer l'effet de bord
Créer un nouveau tableau pour stocker les résultats de la taille de l'image ORIGINALE
x=largeur de l'image+bordure
y=hauteur de l'image+bordure
pour tous les i allant de 1 à x-1:
pour tous les j allant de 1 à j-1:
tableau[i-1][j-1]=max(voisinage 3x3 de ce pixel)
Reconstruire l'image dilatée

Pour gérer les bords, nous allons créer une fonction. Mais quel type de pixels allons-nous mettre dans cette bordure ? Nous allons mettre des pixels NOIRS, car ils n'interagiront pas avec les valeurs.

0,0,0,0,0
1,1,1                         0,1,1,1,0
1,1,1    --->fonction()---->  0,1,1,1,0
1,1,1                         0,1,1,1,0
0,0,0,0,0

Pour cela, il suffit de créer un tableau de dimensions (x+2,y+2).

Algorithme de la fonction Bord

Ouvrir Image à dilater
Créer un tableau (x+2,y+2) rempli de 0
Pour i de 1 à x-1:
Pour j de 1 à y-1:
tableau[i][j]=Image[i-1][j-1]
fin Pour
fin Pour

Faire DILATATION

Voici la fonction bord().

La fonction Bord

def Bord(data):
data=numpy.array(data)
x=data.shape[1]+2
y=data.shape[0]+2
new=[x*[0]]*y  #création du tableau
new=numpy.array(new)

h=1
for i in range(1,y-1):
for j in range(1,x-1):
new[i][j]=data[i-1][j-1] #remplissage du tableau

return new

Il suffit maintenant de parcourir ce tableau de 1 à x-1 et de 1 à y-1, de regarder dans un voisinage 3x3 les valeurs et appliquer la valeur max au point d'ancrage de l'élément structurant.
Voici la fonction dilatation()

La fonction Dilatation

def Dilatation(img):

Dilate=[0]*(img.shape[1]-2)*(img.shape[0]-2)
h=0
for i in range(1,img.shape[0]-1):
for j in range(1,img.shape[1]-1):
Dilate[h]=max([img[i-1][j-1],img[i][j-1],img[i+1][j-1],img[i-1][j],img[i][j],img[i+1][j],img[i-1][j+1],img[i][j+1],img[i+1][j+1]])
#Mise de la valeur max du voisinage 3x3 au point(x,y)
h+=1

Dilate=numpy.array(Dilate)
Dilate=numpy.reshape(Dilate,(img.shape[0]-2,img.shape[1]-2))
RebuildImg(Dilate,"c:\\dilate.bmp")

Voilà, nous avons programmé la dilatation. Pour l'érosion vous verrez que très peu de changements sont à apporter.

IV-B-3 - Exemple de l'application

>>> a = OuvrirImg("c:\\bob.bmp")
>>> b = Bord(a)
>>> c = Dilatation(b)

bob.bmp

bob dilaté

bob seuillé 128-255

bob seuillé puis dilaté

On voit bien que les objets en blanc gagnent du « terrain » alors que les objets en noir en perdent. Nous avons dilaté.

IV-C - Érosion

IV-C-1 - Rappel de cours

Comme je l'ai dit précédemment, l'érosion est un opérateur de base en traitement d'image. Je pense que vous avez compris ce qu'est la DILATATION, alors je ne vais pas m'étendre et me perdre dans les définitions complexes. Rappelez-vous que nous nous posions une question pour savoir si un pixel appartenait au dilaté de cet objet. Il existe une question pour l'érosion.
Admettons un ensemble d'objets.
Posons-nous la question : est-ce que mon élément structurant est complètement inclus dans mon objet ?
Les réponses positives constitueront l'érodé de l'objet.

Dans le premier cas, posons-nous la question : est-ce que l'élément structurant est complètement inclus dans mon objet ?

- Dans le premier cas, la réponse est négative.

- Dans le second, elle est positive, donc le pixel situé sous le centre de l'élément structurant sera mis à 1. C'est aussi simple que cela.

IV-C-2 - Application à la programmation

Comme pour la dilatation, il existe une astuce pour calculer l'érodé d'un objet. Pour la dilatation, nous prenions le max dans un voisinage 3x3, pour l'érosion nous prendrons le minimum dans un voisinage 3x3. Pour la gestion des bords, nous aurons une fonction bord2 proche de la fonction bord sauf que l'on ajoutera un bord de pixels blancs pour qu'il ne fausse pas le résultat.

La fonction Bord2

def Bord2(data):
data=numpy.array(data)
x=data.shape[1]+2
y=data.shape[0]+2
new=[x*[1]]*y  #création du tableau
new=numpy.array(new)

h=1
for i in range(1,y-1):
for j in range(1,x-1):
new[i][j]=data[i-1][j-1] #remplissage du tableau

return new

Voici le code de l'érosion.

La fonction Erosion

def Erosion(img):

Erode=[0]*(img.shape[1]-2)*(img.shape[0]-2)
h=0
for i in range(1,img.shape[0]-1):
for j in range(1,img.shape[1]-1):
Erode[h]=min([img[i-1][j-1],img[i][j-1],img[i+1][j-1],img[i-1][j],img[i][j],img[i+1][j],img[i-1][j+1],img[i][j+1],img[i+1][j+1]])
#Mise de la valeur max du voisinage 3x3 au point(x,y)
h+=1

Erode=numpy.array(Erode)
Erode=numpy.reshape(Erode,(img.shape[0]-2,img.shape[1]-2))
RebuildImg(Erode,"c:\\erode.bmp")

Voilà l'érosion est programmée. C'est très similaire à la dilatation à quelques détails près.

IV-C-3 - Exemple de l'application

>>> a = OuvrirImg("c:\\bob.bmp")
>>> b = Bord2(a)
>>> c = Erosion(b)

bob.bmp

bob érodé

V - CONCLUSION

Voilà c'est terminé pour ce tutoriel, dans un prochain tutoriel, nous apprendrons à convoluer une image par un noyau et programmer une détection de contour grâce à cela.