Tous droits réservés

TP - Effets sur des images

Nous allons maintenant faire un petit TP qui consiste à modifier des images pour augmenter leur luminosité ou encore les flouter.

Noirs et négatifs

Principe

Bon, avant de commencer à modifier des images, il faut que nous sachions bien de quoi nous allons parler dans ce chapitre et que nous voyions quelles méthodes nous allons utiliser. Notre but ici est d’effectuer une transformation sur une image telle qu’augmenter sa luminosité. Pour cela, nous allons appliquer une fonction à chacun des pixels de notre image. Par exemple, pour obtenir la même image exactement, on applique à chaque pixel la fonction identité, c’est-à-dire la fonction qui à un élément associe cet élément.

Il nous faudra alors trouver des fonctions pour faire ce que l’on veut. Grâce à elle, nous pourrons créer une fonction qui renvoie une surface à laquelle on aura appliqué une transformation avec cette fonction.

Nous aurons alors un code comme ceci.

SDL_Surface *creerSurface(SDL_Surface *s)
{
    SDL_Surface *retour = NULL;
    size_t i, j, w = (size_t) s->w, h = (size_t) s->h;
    Uint32 *dst = NULL, *src = NULL;
    if(SDL_LockSurface(s) < 0)
    {
        fprintf(stderr, "Erreur SDL_LockSurface : %s", SDL_GetError());
        goto lock_surface_fail;
    }
    dst = malloc((size_t)s->pitch * h);
    if(NULL == dst)
    {
        perror("Erreur malloc : ");
        goto alloc_memory_fail;
    }
    src = (Uint32 *)s->pixels;
    
    for(i = 0; i < h; i++)
        for(j = 0; j < w; j++)
            /* On modifie chaque pixel */
        
    retour = SDL_CreateRGBSurfaceWithFormatFrom(dst, s->w, s->h, 32, s->pitch,
                                                s->format->format);
    if(NULL == retour)
    {
        fprintf(stderr, "Erreur SDL_CreateRGBSurface : %s", SDL_GetError());
        goto creatergbsurfacefrom_fail;
    }
    
creatergbsurfacefrom_fail:
    free(dst);
alloc_memory_fail:
    SDL_UnlockSurface(s);
lock_surface_fail:
    return retour;
}

Dans l’ordre on bloque la surface pour accéder à ses pixels, on crée un tableau de pixels de la bonne taille, on le modifie, puis on renvoie la surface créée à partir de ce tableau. Le champ format d’une variable de type SDL_Format correspond à une valeur de l’énumération SDL_PixelFormatEnum. Ainsi, si notre surface de départ a le format SDL_PIXELFORMAT_RGBA8888, on retrouvera cette valeur dans s->format->format. On l’utilise alors pour créer notre nouvelle surface. Bien sûr, on pourrait également décider de créer la nouvelle surface dans un autre format.

Ici, nous renvoyons une nouvelle surface, mais nous aurions également pu modifier directement la surface passée en paramètre.

Dans les codes qui vont suivre, nous n’allons pas réécrire toute la fonction, mais seulement le contenu de la boucle for.

Pour utiliser ces fonctions sur une surface créée à partir d’une image, il nous faudra au préalable utiliser la fonction SDL_ConvertSurface pour que le tableau de pixels obtenu soit bien au format SDL_PIXELFORMAT_RGBA8888 (une surface chargée depuis une image au format Bitmap a généralement le format SDL_PIXELFORMAT_RGB888). Sans cela, nous risquons des erreurs de segmentation.

En fait, il nous faudrait juste nous assurer que la surface est dans un format où les pixels sont codés sur quatre octets (quand ils sont codés sur moins d’octet, nous faisons des lectures en dehors du tableau de pixels). Convertir la surface dans un format qui convient est une manière simple de s’assurer de cela.

Niveaux de gris

Nous allons commencer par faire une fonction pour transformer une image couleur en niveau de gris. La question qui vient naturellement est : comment obtenir le niveau de gris d’une couleur ? Déjà, il nous faut comprendre qu’une couleur est grise si ses trois composantes ont la même valeur. Ainsi, un pixel est gris s’il s’agit du pixel (i, i, i). Plus i est grand, plus on va vers le blanc. (255, 255, 255) correspond à du blanc et (0, 0, 0) correspond à du noir.

Pour avoir le niveau de gris d’une couleur, nous allons faire la moyenne de ses trois composantes. Nous pouvons alors faire une fonction qui prend en paramètre une surface et renvoie une nouvelle surface qui correspond à la surface passée en paramètre en niveau de gris.

for(i = 0; i < h; i++)
    for(j = 0; j < w; j++)
    {
        SDL_GetRGB(p[i * w + j], s->format, &color.r, &color.g, &color.b);
        Uint8 gris = (color.r + color.g + color.b) / 3;
        pixels[i * w + j] = SDL_MapRGB(s->format, gris, gris, gris);
    }

Notons qu’ici, nos pixels gris sont codés sur 32 bits. Pourtant, on aurait pu ne les stocker que sur 8 pixels, puisque les composantes R, G et B sont les mêmes. Si nous enregistrons l’image obtenue avec cette fonction, nous obtiendrons une image dont le poids en mémoire sera le même que l’image originale, alors que si nous utilisons Paint par exemple pour convertir notre image en niveau de gris, son poids aura diminué.

Nous utilisons SDL_MapRGB et pas SDL_MapRGBA car la composante alpha des pixels ne nous intéresse pas. Une des remarques de la documentation est que quand elle est utilisée avec un format de pixel qui a une composante alpha, elle renvoie un pixel dont la composante alpha est 255 (le pixel est totalement opaque). Si nous ne voulons pas perdre la composante alpha de notre image, il nous suffit d’utiliser SDL_MapRGBA et SDL_GetRGBA.

Négatifs

Nous allons maintenant faire en sorte d’obtenir le négatif d’une image. Mais avant d’aller plus loin, qu’est-ce que le négatif d’une image ? Regardons Wikipédia rapidement.

Une image négative est une image dont les couleurs ont été inversées par rapport à l’originale ; par exemple le rouge devient cyan, le vert devient magenta, le bleu devient jaune et inversement. Les régions sombres deviennent claires, le noir devient blanc. Source : Wikipédia

Le négatif d’une image s’obtient donc en « inversant » chacun des pixels. La fonction mathématique qui lui est associée est donc la fonction de [0;255][0\,; 255] dans [0;255][0\,; 255] définie par f(x)=255xf(x) = 255 - x et dont la représentation graphique est la suivante.

Fonction associée au négatif.
Fonction associée au négatif.

Nous pouvons alors écrire ce code.

for(i = 0; i < h; i++)
    for(j = 0; j < w; j++)
    {
        color = SDL_GetRGB(p[i * w + j], s->format, &color.r, &color.g, &color.b);
        pixels[i * w + j] = SDL_MapRGB(s->format, 255 - color.r, 255 - color.g, 255 - color.b);
    }

La composante de chaque pixel est 255 - composante_initiale. Il suffit alors de le faire pour chaque pixel.

De la luminosité

Nous allons maintenant nous attarder sur la luminosité et faire des fonctions pour éclaircir, assombrir et changer le contraste d’une image.

Éclaircir une image - version naïve

Comment pouvons-nous éclaircir une image ? Tout simplement, éclaircir une image, c’est lui ajouter de la luminosité. Le blanc est la couleur la plus lumineuse que nous avons, et le noir est la plus sombre. Pour éclaircir une image, nous pouvons alors faire « tendre » tous ses pixels vers le blanc.

Pour une image en niveau de gris, un pixel de niveau de gris 100 pourrait être modifié en 120 et un pixel de niveau de gris 0 changé en 20. En gros, on rajoute 20 à chaque fois. Plus la valeur ajoutée est grande, plus l’image devient lumineuse.

Il faut cependant faire attention à ne pas dépasser la valeur 255.

Il nous faut un entier nn qui correspond à la valeur à ajouter et la fonction qu’il nous faut utiliser est alors la fonction ff définie pour xx appartenant à [0;255][0\,;255] par

f(x)={255si x+n>255x+nsinon. f(x) = \begin{cases} 255 &\text{si $x + n > 255$}\\ x + n &\text{sinon} \end{cases}.

Pour assombrir une image, le principe est le même. C’est juste qu’il faut enlever une valeur plutôt qu’en rajouter une. En fait, cela revient à éclaircir avec une valeur négative. Nous n’allons donc pas faire de nouvelle fonction pour cela mais modifier l’ancienne (il ne faut pas passer en dessous de 0). On a alors

f(x)={255si x+n>2550si x+n<0x+nsinon.f(x) = \begin{cases} 255 &\text{si $x + n > 255$}\\ 0 &\text{si $x + n < 0$}\\ x + n &\text{sinon} \end{cases}.

On a alors ces deux courbes pour n=50n = 50 (en rouge) et n=50n = -50 (en vert).

Courbes de la fonction de changement de luminosité.
Courbes de la fonction de changement de luminosité.

La courbe en bleue est celle de l’identité, c’est-à-dire qu’il s’agit du pixel normal sans aucun éclairement. On va d’abord faire une fonction f qui applique notre transformation à une composante d’un pixel (donc à un Uint8).

Uint8 f(Uint8 c, int n)
{
    if(c + n > 255)
        return 255;
    else if(c + n < 0)
        return 0;
    else
        return c + n;
}

On a alors notre boucle for.

for(i = 0; i < s->h; i++)
    for(j = 0; j < s->w; j++)
    {
        SDL_GetRGB(p[i * s->w + j], s->format, &color.r, &color.g, &color.b);
        color.r = f(color.r, n);
        color.g = f(color.g, n);
        color.b = f(color.b, n);
        pixels[i * s->w + j] = SDL_MapRGB(s->format, color.r, color.g, color.b);
    }

Notons que le prototype de la fonction est alors SDL_Surface *creerSurface(SDL_Surface *s, int n).

Éclaircir une image - version améliorée

Notre fonction pour éclaircir fonctionne bien, mais a un grand défaut : toutes les composantes de pixel qui sont supérieures à 255 - valeur, avec valeur la valeur ajoutée à cette composante, sont mises à 255. Les nuances de couleurs entre ces pixels seront annulées. Ainsi, si on veut éclaircir notre image d’une valeur de 50 et que toutes les composantes de tous les pixels de notre image sont supérieures à 205, on se retrouvera avec une image totalement blanche ce qui ne correspond pas à ce que l’on veut obtenir.

En fait, ce qu’il nous faut, c’est augmenter les valeurs des composantes différemment suivant si ces dernières sont grandes ou faibles. Ce qu’il nous faudrait, c’est que les noirs restent noirs, que les blancs restent blancs, et que les gris soient éclaircis. En gros, il nous faut quelque chose qui suit ce genre de courbe.

Le genre de courbe que l’on veut
Le genre de courbe que l’on veut

Ce genre de courbe est obtenu à l’aide des fonctions puissances qui ont en effet ce type de comportement sur [0;1][0\,; 1]. Pour « étendre » ce comportement à [0;255][0\,;255], nous allons utiliser la fonction ff définie pour xx appartenant à [0;255][0\,;255] par

f(x)=255×(x255)n. f(x) = 255 \times \biggl( \frac{x}{255} \biggr) ^{\! n}.

La courbe bleue est obtenue pour n = 0.5, la verte du dessus pour n = 1/3 et la verte du dessous pour n = 2. Pour n = 1, on obtiendra bien sûr une droite (ce qui correspond à un changement nul de luminosité). On remarque que pour n > 1, la courbe est « en dessous » de la droite d’équation y=xy = x (ce qui correspond à un assombrissement) et que pour n < 1, elle est « au-dessus » (ce qui correspond à une hausse de la luminosité). Par ailleurs, plus n est grand devant 1 (respectivement petit devant 1), plus la courbe descendra (respectivement montera) et donc plus l’image sera assombrie (respectivement éclairée).

Notre nouvelle fonction d’éclairage prendra alors en paramètre ce coefficient n et appliquera à chaque pixel p la fonction ff. Commençons comme tout-à-l’heure par écrire notre fonction f.

Uint8 f(Uint8 c, int n)
{
    return (Uint8) (255 * SDL_pow((double) c / 255, n));
}

Notre boucle reste la même, on appelle la fonction pour chaque composante de chaque pixel.

Non seulement, cela nous permet de garder les nuances de notre image, mais en plus, notre code est raccourci. Il faut juste faire attention à caster c en double avant de faire la division pour bien faire une division flottante et non entière.

Contraster une image

Maintenant, faisons une opération plus compliquée : créons une fonction pour augmenter (ou diminuer) le contraste d’une image.

Mais avant tout qu’est-ce que le contraste d’une image ?

Nous pouvons voir le contraste d’une image comme une propriété qui indique si la différence de luminosité entre les différents pixels de l’image est grande. Ainsi, le contraste de l’image est maximum si les pixels sombres sont entièrement noir et ceux blancs entièrement blanc, et le contraste est nul si l’image est uniformément grise.

Cela permet de comprendre que pour augmenter le contraste, il faut en fait rendre les pixels lumineux encore plus lumineux et ceux sombres encore plus sombre. On peut par exemple faire cela en augmentant le niveau de gris d’un pixel s’il est entre 128 et 255 et en le diminuant sinon. Pour diminuer le contraste de l’image, il faudrait bien sûr faire le contraire, c’est-à-dire augmenter le niveau de gris des pixels s’ils sont entre 0 et 128 et les diminuer s’ils sont entre 128 et 255.

Nous voulons donc une courbe de ce genre.

La courbe de contraste.
La courbe de contraste.

Pour l’obtenir, il faut juste ruser un peu et utiliser les fonctions utilisées pour la luminosité. En effet, on obtient des courbes de ce genre avec la fonction ff définie sur [0;255][0\,; 255] par

f(x)={2552×(2x255)nsi x[0;2552]2552552×(2(255x)255)nsi x]2552;255].f(x) = \begin{cases} \frac{255}{2} \times \Bigl( \frac{2x}{255}\Bigr) ^{n} &\text{si $x \in \Bigl[0\, ; \frac{255}{2}\Bigr]$}\\ 255 - \frac{255}{2} \times \Bigl( \frac{2(255- x)}{255}\Bigr) ^{n} &\text{si $x \in \Bigl]\frac{255}{2}\, ; 255\Bigr]$} \end{cases}.

En fait, la courbe obtenue est en deux bouts. Tout d’abord la courbe d’éclaircissement ramenée sur [0;2552]\Bigl[0\, ; \frac{255}{2}\Bigr] et son symétrique sur [2552;255]\Bigl[\frac{255}{2}\, ; 255\Bigr].

La courbe bleue est obtenue pour n = 2, la verte pour n = 1/3 et la rouge pour n = 1/2. On remarque alors que plus n sera grand devant 1 (respectivement petit devant 1), plus l’image sera contrastée (respectivement moins l’image sera contrastée). Avec n = 0, le contraste de l’image obtenue sera nul.

Nous allons simplifier notre fonction ff en remarquant que x>2552x > \frac{255}{2}, f(x)=255f(255x)f(x) = 255 - f(255 - x). De plus, puisque nos valeurs sont toujours entières, nous allons prendre 127 pour la moitié de 255 et donc

f(x)={127×(2x255)nsi x[0;127]255f(255x)si x[128;255].f(x) = \begin{cases} 127 \times \Bigl( \frac{2x}{255}\Bigr) ^{n} &\text{si $x \in [0\, ; 127]$}\\ 255 - f(255 - x) &\text{si $x \in [128\, ; 255]$} \end{cases}.

Il est maintenant temps de coder. Nous allons tout d’abord coder une fonction qui prend en paramètre une composante de pixel et un entier et renvoie l’image de ce pixel par la fonction ff.

Uint8 f(Uint8 c, double n)
{
    if(c <= 255 / 2)
        return (Uint8)( (255/2) * SDL_pow((double) 2 * c / 255, n));
    else
        return 255 - f(255 - c, n);
}

Là encore, notre boucle principale ne change pas.

Floutage

Un peu de théorie

Tout comme les autres effets que nous avons codés jusqu’ici, le floutage peut-être vu comme l’application d’une fonction à une image. La question qui se pose est : quelle fonction utiliser ? Quelles opérations faut-il faire pour que notre image soit floue ?

Il faut se dire que flouter l’image revient à changer certains de ses pixels pour que l’image soit moins reconnaissable. Par exemple, si on remplace un pixel sur trois par un pixel blanc, l’image obtenue sera différente mais restera proche de l’originale.

Le problème est que si par exemple on a une image toute rouge, placer des pixels blancs ne la floutera pas. En fait, on veut un effet de floutage, mais on veut que les couleurs de l’image obtenue restent proches de celles de départ. Il faut donc remplacer les pixels par des pixels de couleurs différentes, mais proches. Dans cette partie, nous allons voir l’algorithme de flou gaussien qui est assez intuitif.

Le flou gaussien

L’algorithme de flou gaussien est un algorithme assez simple à appréhender. Il consiste à remplacer un pixel par la moyenne des pixels qui l’entourent. Ainsi, on aura à peu près mélangé les pixels proches, ce qui donnera comme on le veut des couleurs proches de l’image de départ, tout en ayant du flou.

Puisque cet algorithme modifie les pixels en utilisant les valeurs d’autres pixels, cela veut dire que nous ne pouvons pas modifier directement le tableau de pixels. Nous sommes obligés dans ce cas de créer un nouveau tableau de pixels.

Flou gaussien sur un pixel
Flou gaussien sur un pixel.

Dans l’exemple ci-dessus, le pixel rouge sera remplacé par la moyenne de ce pixel et des huit pixels qui l’entourent.

Pour obtenir une image encore plus floue, nous pouvons également remplacer un pixel par la moyenne de ce pixel, des deux pixels en hauts, des deux pixels en bas, des deux à gauches, des deux sur chaque diagonale. Nous le remplaçons par la moyenne des pixels du carré de cinq pixels dont il est le centre.

Flou gaussien avec le carré de cinq pixels
Flou gaussien avec le carré de cinq pixels.

En fait, nous le remplaçons par la moyenne d’un carré dont il est le centre et plus ce carré est gros, plus l’image semblera floue. Pour un entier nn donné (dans les deux images données en exemple, nn prend respectivement les valeurs 1 et 2) on obtient alors cette formule générale (en considérant notre image comme une matrice) :

ai,j=1(2n+1)2k=ini+n(l=jnj+nak,l). a_{i,j} = \frac{1}{(2n + 1)^2} \sum_{k = i - n}^{i + n} \biggl( \sum_{l = j - n}^{j + n} a_{k,l} \biggr).

En gros, on fait la moyenne des pixels situés à au plus nn lignes et nn colonnes du pixel de départ. Il faut juste faire attention aux pixels qui se trouvent trop près du bord de l’image. On peut alors faire le code qui nous donne le flou voulu. Nous allons d’abord faire une fonction qui calcule la moyenne pour un pixel c’est-à-dire pour chacune de ses composantes.

static Uint32 moyenne(SDL_Surface *surface, int i, int j, int n)
{
    const int initial_h = SDL_max(i - n, 0);
    const int initial_w = SDL_max(j - n, 0);
    const int final_h = SDL_min(i + n, surface->h - 1);
    const int final_w = SDL_min(j + n, surface->w - 1);
    const int nb_pixel = ((final_h - initial_h) * (final_w - initial_w));
    const Uint32 *p = surface->pixels;

    Uint32 sum_r = 0, sum_g = 0, sum_b = 0;
    SDL_Color color;

    for (i = initial_h; i < final_h; i++)
        for(j = initial_w; j < final_w; j++)
        {
            SDL_GetRGB(p[i * surface->w + j], surface->format, &color.r, &color.g, &color.b);
            sum_r += color.r;
            sum_g += color.g;
            sum_b += color.b;
        }

    return SDL_MapRGB(surface->format, sum_r / nb_pixel, sum_g / nb_pixel, sum_b / nb_pixel);
}

On calcule d’abord nos bornes pour ne pas dépasser les limites de notre tableau. Puis on fait notre somme à l’aide de deux boucles et enfin on la divise par le nombre d’éléments. Notre flou est alors très simple à faire, notre double boucle est la suivante.

for(i = 0; i < h; i++)
    for(j = 0; j < w; j++)
        pixels[i * w + j] = moyenne(s, i, j, n);

Du détourage ?

Nous n’allons pas nous attarder sur le pourquoi du comment, mais cette fonction de floutage nous permet également de faire du détourage, c’est-à-dire d’obtenir par exemple le contour d’un personnage dans une image. Pour cela, il nous faut faire cette opération, avec pp le pixel que l’on traite et mm la moyenne des pixels qui l’entourent.

ai,j=255pm. a_{i,j} = 255 - \lvert p - m\rvert.

La valeur absolue permet d’être sûr d’avoir un résultat plus petit que 255. En gros, il s’agit de ce code.

for(i = 0; i < h; i++)
    for(j = 0; j < w; j++)
        pixels[i * w + j] = 255 - SDL_abs(p[i * w + j] - moyenne(s, i, j, n));

Ce TP est maintenant fini. Mais nous n’avons touché que la surface du sujet. Nous pouvons par exemple faire une image « tirée » vers le rouge en augmentant seulement la composante rouge de ses pixels (un éclairement rouge en fait) ou faire le NON logique d’une image. On pourrait même jouer avec deux images de même taille en effectuant leur OU logique, leur ET logique, ou encore leur XOR logique. Il y a vraiment de quoi s’amuser.