Images continues II

CS-108 — Série 5

Introduction

Le but de cette série est de continuer à explorer les images continues de la série 2, mais cette fois en utilisant les lambdas.

Pour démarrer, nous vous suggérons de créer un nouveau projet et d'y importer le code du corrigé de la série 2, mais vous pouvez également utiliser une copie de votre code si vous le préférez.

Exercice 1

Le premier exercice a pour but de transformer la totalité du code écrit lors de la série 2 afin d'utiliser autant que possible les lambdas. Cela vous permettra d'une part de vous familiariser avec leur syntaxe, et d'autre part de constater les gains en concision et clarté (si si) qu'offre cette notation.

Pour mémoire, une lambda est une syntaxe concise permettant de créer une instance d'une classe (anonyme) implémentant une interface dite fonctionnelle, c-à-d possédant exactement une méthode abstraite.

Interface Image

Pour les images continues, l'interface fonctionnelle qui nous intéresse est bien entendu Image, qui ne possède qu'une seule méthode abstraite, apply.

Au passage, vous constaterez que Image définit une fonction à deux arguments, et est donc très similaire à l'interface BiFunction de la bibliothèque Java. Nous aurions d'ailleurs pu utiliser cette interface en lieu et place de Image, au prix d'une perte de performance liée à l'emballage des arguments de la méthode apply, qui aurait dû prendre des objets de type Double au lieu de simples valeurs de type double. En résumé, on a donc la quasi-équivalence suivante :

Image<T>  ≅  BiFunction<Double, Double, T>

Avant d'introduire les lambdas à proprement parler, ajoutez à l'interface Image l'annotation FunctionalInterface et vérifiez que cela ne provoque aucune erreur. Comme nous l'avons vu au cours, cette annotation n'est pas obligatoire, mais permet de s'assurer que l'interface à laquelle on l'applique est bien une interface fonctionnelle.

Lambdas

Une fois l'annotation FunctionalInterface ajoutée, modifiez la totalité du code définissant des images afin d'utiliser des lambdas en lieu et place de classes nommées implémentant l'interface Image. Ce faisant, déplacez la totalité du code contenu dans les différentes classes dans des attributs et méthodes statiques de l'interface Image, selon les règles suivantes :

  • les classes sans constructeur (RedDisk et HorizontalGradiendMask dans le corrigé) sont transformées en attributs statiques de l'interface Image, contenant l'image précédemment contenue dans l'attribut IMAGE des différentes classes — voir ci-dessous pour un exemple,
  • les classes avec constructeur (toutes les autres sauf Mysterious, qui peut être ignorée) sont transformées en méthodes statiques de l'interface Image prenant les mêmes arguments que le constructeur de la classe et retournant l'image correpondante, définie sous forme d'une lambda. Souvenez-vous qu'une lambda a accès aux arguments et variables locales de la méthode dans laquelle elle est définie, ce qui est très utile ici.

Dans le second cas, notez que si la classe représentant l'image dans la série 2 était générique, alors la méthode statique qui lui correspond doit également l'être.

La première des règles ci-dessus peut être illustrée au moyen de la classe RedDisk. Son constructeur ne prenant aucun paramètre, l'image RedDisk.IMAGE peut être transformée en l'attribut statique suivant de l'interface Image :

public interface Image<T> {
  // … méthode apply, etc.

  public static final Image<ColorRGB> RED_DISK = (x, y) -> {
    double r = sqrt(x * x + y * y);
    return r <= 1d ? ColorRGB.RED : ColorRGB.WHITE;
  };
}

Cette définition est très proche de la formulation mathématique de cette image donnée dans l'énoncé de la série 2, ce qui illustre l'intérêt des lambdas.

La seconde des règles ci-dessus peut être illustrée au moyen de la classe Chessboard. Son constructeur prenant trois arguments — les couleurs des cases et leur taille —, on la transforme en une méthode (!) statique de l'interface Image dont les arguments sont ceux du constructeur de l'ancienne classe :

public interface Image<T> {
  // … méthode apply, etc.

  public static Image<ColorRGB> chessboard(ColorRGB c1,
					   ColorRGB c2,
					   double w) {
    // … reste du code
    return (x, y) -> { /* définition de l'image */ }
  }
}

Méthode par défaut

Après avoir transformé la totalité du code définissant des images, il est possible d'y apporter encore une dernière amélioration. En effet, la méthode rotated que vous avez définie, et qui correspond à la classe Rotated de la série 2, devrait ressembler à ceci :

static <U> Image<U> rotated(Image<U> image, double angle) {
  // … code omis
}

Cela implique que, pour l'utiliser afin de tourner une image existante nommée p.ex. board, il faut écrire :

Image<ColorRGB> rotatedBoard =
  Image.rotated(board, Math.toRadians(10));

Or il serait préférable que la méthode rotated puisse être directement appliquée à l'image à tourner, en lui passant l'angle de rotation comme unique argument, de la manière suivante :

Image<ColorRGB> rotatedBoard =
  board.rotated(Math.toRadians(10));

Pour obtenir ce résultat, il faut définir la méthode rotated comme une méthode par défaut de l'interface Image, ce qui est le but de cette dernière partie.

Une fois tous ces changements effectués, adaptez le programme principal afin qu'il construise toujours la même image composée du disque et de l'échiquier tourné, mais en utilisant désormais les méthodes statiques de Image plutôt que les classes, que vous aurez supprimées. Vérifiez que vous obtenez toujours la même image à l'écran.

Exercice 2

Le but de ce second exercice est de définir un nouveau type de masque, afin de pouvoir dessiner une représentation du fameux ensemble de Mandelbrot. Pour mémoire, ce que nous avons appelé un masque est une image dont la valeur en un point est un nombre réel compris entre 0 et 1 (inclus).

Masque de Mandelbrot

La valeur de ce que nous appellerons le masque de Mandelbrot en un point \((x, y)\) est déterminée en considérant ce point comme un nombre complexe \(c = x + iy\), puis en calculant les termes de la suite complexe suivante :

\begin{align*} z_0 &= 0\\ z_{n+1} &= z_n^2 + c \end{align*}

jusqu'à ce que l'un d'eux ait un module supérieur à 2, ou que l'on ait calculé un nombre maximum (et prédéfini) \(M\) de termes, par exemple 100.

Une fois cette condition atteinte, la valeur du masque de Mandelbrot au point \((x, y)\) est simplement l'index \(m\) du dernier terme calculé, divisé par l'index maximum \(M\) : \[ f(x, y) = \frac{m}{M} \] Clairement, cette valeur est toujours comprise entre 0 et 1.

Par exemple, le point de coordonnées (0.9, 0) correspond au nombre complexe \(c = 0.9\) (sans partie imaginaire). Les termes de la suite ci-dessus pour ce nombre complexe valent :

\begin{align*} z_0 &= 0\\ z_1 &= z_0^2 + 0.9 = 0.9\\ z_2 &= z_1^2 + 0.9 = 1.71\\ z_3 &= z_2^2 + 0.9 \approx 3.82 \end{align*}

A ce stade, on a un terme dont le module est supérieur ou égal à 2, et il convient donc d'arrêter le calcul de la suite et de conclure que la valeur du masque de Mandelbrot au point (0.9, 0) est (si \(M = 100\)) : \[ f(x, y) = \frac{3}{100} = 0.03 \]

Un autre exemple est celui de l'origine (0, 0), qui correspond au nombre complexe \(c = 0\). Les termes de la suite ci-dessus pour ce nombre complexe valent tous 0 :

\begin{align*} z_0 &= 0\\ z_1 &= z_0^2 + 0 = 0\\ z_2 &= z_1^2 + 0 = 0\\ &\ldots\\ z_{100} &= z_{99}^2 + 0 = 0 \end{align*}

Dès lors, aucun terme de la suite n'a de module supérieur ou égal à deux, et on arrête de les calculer lorsqu'on a atteint le terme d'index \(M\). On en conclut que la valeur du masque de Mandelbrot en ce point est 1.

Ce masque peut être défini sous la forme d'une méthode statique de l'interface Image, nommée mandelbrot et prenant en argument le nombre maximum de termes à calculer, noté \(M\) ci-dessus :

static Image<Double> mandelbrot(int maxIt) {
  // … code (à faire)
}

Pour calculer les termes de la suite, vous pouvez soit définir une classe minimaliste — mais immuable ! — représentant les nombres complexes et offrant des méthodes d'addition, d'élévation au carré et de calcul de module, soit directement manipuler les composantes réelles et imaginaires des termes de la suite, en les représentant chacune par un nombre de type double.

Une fois le masque de Mandelbrot défini, vous pouvez l'utiliser à la place du masque dégradé de la série 2. Vous devriez alors obtenir l'image ci-dessous.

mandelbrot;64.png

Notez que cette image a été obtenue en décalant légèrement le centre de la zone du plan affichée, en mettant l'attribut CENTER_X de la classe Main à -0.6.

S'il vous reste encore du temps, ajoutez à l'interface Image une méthode statique et générique nommée constant, permettant de définir une image constante, dont la valeur est identique en tout point. La valeur en question doit être un paramètre de la méthode.

Cette méthode vous permet de définir deux images de couleur constante et de les mélanger comme ci-dessus au moyen du masque de Mandelbrot, p.ex. :

Image<ColorRGB> white = Image.constant(ColorRGB.WHITE);
Image<ColorRGB> black = Image.constant(ColorRGB.BLACK);
Image<Double> m = Image.mandelbrot(100);
Image<ColorRGB> image = Image.composed(white, black, m);

L'image obtenue devrait alors ressembler à ceci :

mandelbrot-bw;64.png