Algorithmes de retouches de photos (contraste, piqué, bruit, ...)

Sujet proposé par Matthieu Moy pour l'année 2019-2020.

Introduction

La photo numérique ouvre beaucoup de possibilités en terme de post-traitement d'image. Nous nous intéresserons ici à la gestion du contraste, avec plusieurs familles de traitements :

Le sujet est très libre, et pourra explorer plusieurs (mais pas forcément toutes) des pistes suivantes :

Premiers pas avec Numpy/Scipy/Scikit-image

NumPy fournit des structure de données efficaces (tableaux N dimensions), SciPy des algorithmes en tous genre (en particulier des algorithmes travaillant sur les structures de données NumPy), et SciKit-Image est une bibliothèque spécialisée dans le traitement d'images.

Un exemple de quelques traitements est donné dans test_img_contrast.py.

Premiers pas avec CImg

Faisons un essai avec la bibliothèque CImg. Téléchargez la bibliothèque (il suffit d'un fichier CImg.h), et crééz un fichier basic_contrast.cpp dans le même répertoire, contenant :

#include "CImg.h"
#include <stdlib.h>
using namespace cimg_library;

int main(int argc, char **argv) {
        const char *argv1 = (argc > 1) ? argv[1] : "global_contrast";
        CImg<float> original("lena.png");
        CImg<float> image = +original;
        image = image * 2 - 100;
        (original, image).display();
        return 0;
}

Compilez et exécutez-le comme ceci (la compilation prend plusieurs secondes, c'est normal)

g++ -pthread -lX11 basic_contrast.cpp -o basic_contrast
./basic_contrast

Une fenêtre apparaît avec le contenu suivant :

./cimg.jpg

Oups, l'original a l'air d'être identique à l'image modifiée ! En fait, CImg a appliqué une normalisation à notre image au moment de l'affichage, et a essentiellement annulé ce que nous venions de faire. Le plus simple pour désactiver cette normalisation est de convertir l'image en entiers 8 bits par pixels (nous utilisons pour l'instant des float). La conversion se passera mal pour les valeurs non comprises entre 0 et 255, mais pas de panique, voici une fonction to_uint8 qui s'occupe de tout :

#include "CImg.h"
#include <stdlib.h>
using namespace cimg_library;

CImg<unsigned char> to_int8(CImg<float> const & img) {
        CImg<unsigned char> i8 = img;
        // fix badly clipped pixels
        for (int i = 0; i < img.width(); i++) {
                for (int j = 0; j < img.height(); j++) {
                        for (int c = 0; c < 3; c++) {
                                if (img(i, j, c) <= 0.0) {
                                        i8(i, j, c) = 0;
                                } else if (img(i, j, c) >= 255.) {
                                        i8(i, j, c) = 255;
                                }
                        }
                }
        }
        return i8;
}

int main(int argc, char **argv) {
        const char *argv1 = (argc > 1) ? argv[1] : "global_contrast";
        CImg<float> original("lena.png");
        CImg<float> image = +original;
        image = image * 2 - 100;
        (to_int8(original), to_int8(image)).display();
        return 0;
}

Relancez et regardez le résultat. En cliquant sur une des images, on peut observer la valeur de chaque pixel en plaçant la souris dessus :

./cimg-select.png

Ce point particulier nous permet d'observer un défaut de notre méthode d'augmentation de contraste : les composantes vertes et bleues étaient négatives et ont été tronquées à 0 (« clipped » en anglais). Le résultat est un pixel rouge pur, ce qui n'était pas du tout le cas sur notre image d'origine. Une meilleure option est de changer d'espace de couleur pour faire l'opération. Nous allons utiliser l'espace Lab au lieu de RGB: le canal L représente la luminance du pixel (entre 0 et 100), et les canaux a et b représentent la couleur. CImg nous permet de passer d'un espace à l'autre facilement :

#include "CImg.h"
#include <stdlib.h>
using namespace cimg_library;

CImg<unsigned char> to_int8(CImg<float> const & img) {
        CImg<unsigned char> i8 = img;
        // fix badly clipped pixels
        for (int i = 0; i < img.width(); i++) {
                for (int j = 0; j < img.height(); j++) {
                        for (int c = 0; c < 3; c++) {
                                if (img(i, j, c) <= 0.0) {
                                        i8(i, j, c) = 0;
                                } else if (img(i, j, c) >= 255.) {
                                        i8(i, j, c) = 255;
                                }
                        }
                }
        }
        return i8;
}

int main(int argc, char **argv) {
        const char *argv1 = (argc > 1) ? argv[1] : "global_contrast";
        CImg<float> original("lena.png");
        CImg<float> image = +original;
        image.RGBtoLab();
        CImg<float> L = image.get_shared_channel(0);
        CImg<float> a = image.get_shared_channel(1);
        CImg<float> b = image.get_shared_channel(2);
        L = L * 2 - 75;
        image.LabtoRGB();
        (to_int8(original), to_int8(image)).display();
        return 0;
}

Si on trouve l'image trop terne, on peut multiplier les canaux a et b, par exemple par 1.5 pour un effet un peu brutal.

On peut noter l'utilisation de plusieurs fonctionnalités intéressantes de CImg :

Algorithmes intéressants

Contraste global

Régler le contraste globale d'une image peut se faire de plusieurs manières différentes (dans les explications, on suppose que la valeur d'un pixel est comprise dans l'intervalle [0,1]) :

  • Un simple coefficient multiplicateur appliqué à tous les pixels. Ceci correspond à une correction d'exposition pour une photo, mais ne modifie pas réellement le « contraste », cela permet simplement d'éclaircir ou d'assombrir l'ensemble de l'image.

  • Un ajustement affine du type ax + b, qui avec a>1 et b<0 permet d'assombrir les pixels sombres et d'éclaircir les pixels clairs, donc cette fois-ci d'augmenter le contraste. Mais cette méthode est très brutale, les pixels pouvant sortir de l'intervalle [0, 1].

  • Une courbe en S, définie par :

    • f(0) = 0
    • f(1) = 1
    • Un point pivot de coordonnées (x0, y0), par lequel passe la courbe : f(x0) = y0.
    • La dérivée d0 = f’(x0) de la courbe sur le point pivot.

    On peut par exemple choisir une fonction f de la forme f(x) = ax3 + bx2 + cx + d. Pour chaque valeur de x0, y0, d on peut trouver a, b, c, d par résolution d'un système linéaire. On trouve trivialement d = 0 donc il s'agit d'un système 3x3, paramétré par x0, y0 et d0. On peut résoudre ce système symboliquement une fois pour toutes, ou plus simplement résoudre le système au cas par cas avec un solveur comme numpy.linalg.solve, ce qui peut donner ceci :

./s-curve.gif
  • Pour l'ajustement affine ou la courbe en S, une première technique consiste à appliquer la fonction séparément sur les cannaux R, G et B, mais cette méthode change la couleur des pixels et pas seulement le contraste. Une autre méthode consiste à calculer pour chaque pixel une norme N, puis le coefficient c = f(N) ⁄ N (>1 pour les pixels à éclaircir, <1 pour les pixels à assombrir), et à appliquer multiplier chaque cannal R, G et B par le coefficient c. Ainsi le ratio entre les cannaux de couleurs est inchangé, donc les couleurs sont préservées. Plusieurs normes sont possibles, par exemple la moyenne de R, G et B, ou le max.

Gestion des ombres et lumières

  • Égalisation d'histogramme
  • Un autre algorithme, plus simple et très utilisé: pour augmenter la luminance des zones d'ombre, créer une image masque pour repérer les ombres (pixels ayant une valeur L inférieure à une constante, par exemple 30), affecter la valeur 1.0 aux ombres et 0.0 au reste. Appliquer un flou à l'image obtenue, et ajouter cette image, multipliée par une constante à l'image d'origine. De la même manière, on peut diminuer la luminance des zones claires (en soustrayant le masque des zones claires). Cet algorithme peut être affiné avec un grand nombre de paramètres (offerts à l'utilisateur dans certains logiciels de traitement photos) : la constante à partir de laquelle on considère qu'un pixel est sombre ou clair, le rayon du flou, la constante par laquelle on multiplie le masque, le type de flou (gaussien ou autre).

Contraste local et netteté

  • Masque flou, classiquement contrôlé par 3 paramètres : le rayon (de l'ordre de 1 pixel pour augmenter la netteté, plusieurs pixels pour augmenter le contraste local), la quantité qui dit de combien on augmente le contraste, et un seuil qui permet de ne pas toucher les pixels des zones uniformes (pour éviter d'amplifier inutilement le bruit). Voici à quoi peut ressembler une application (de gauche à droite : l'image originale, l'image avec contraste renforcé, et le masque intermédiaire utilisé) :
./unsharp-mask.jpg
  • Les techniques par convolution où l'image est convoluée avec une petite matrice. En plaçant un coefficient positif grand au centre de la matrice, et des coefficients négatifs petits ailleurs on obtient des effets similaires au masque flou.

Pyramide gaussienne

La pyramide gaussienne peut être vue comme une généralisation du masque flou : au lieu de séparer l'image en deux couches (contraste local et global), on la décompose en une succession d'images, chacune représentant un niveau de détail de l'image. On peut alors augmenter ou réduire chaque niveau individuellement.

Pour aller plus loin

  • Remplacer les flous gaussiens par un filtre bilatéral pour éviter les effets de halo.
  • Une fois satisfait avec le contraste d'une image, aumenter la taille de l'image (par interpolation et suréchantillonage). L'interpolation naive « voisin le plus proche » donne des résultats pixelisés, on préfèrera donc une interpolation bicubique par exemple.
  • Combiner débruitage et renforcement de contraste en une seule convolution avec Laplacian of Gaussian.
  • Implémenter une interface graphique permettant de choisir les paramètres à appliquer à vos utilisateurs.
  • Vendre l'interface graphique et conquérir le monde ?