Images continues II
Série 4

Introduction

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

Pour démarrer, nous vous suggérons de créer un nouveau projet Eclipse et d'y importer le code du corrigé de la série précédente, 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 précédente 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 Float au lieu de simples valeurs de type float. En résumé, on a donc la quasi-équivalence suivante :

Image<T>  ≅  BiFunction<Float, Float, T>

Avant d'introduire les lambdas à proprement parler, ajoutez à l'interface Image l'annotation FunctionalInterface et vérifiez qu'Eclipse ne signale 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. Dans le cas de notre corrigé, cela implique de modifier les définitions de RED_DISK, Chessboard, HORIZONTAL_GRADIENT_MASK, Composed et Rotated (Mysterious, ne l'étant plus, peut être abandonnée).

Pour effectuer cette transformation, suivez les règles suivantes :

  1. Lorsque le constructeur de la classe représentant l'image ne prend aucun argument (p.ex. RedDisk), et que l'image qu'elle définit est donc unique, supprimez la définition de la classe et utilisez une lambda pour définir directement l'image sous la forme d'un attribut statique de Image (voir l'exemple de RED_DISK ci-dessous).
  2. Lorsque le constructeur de la classe représentant l'image prend un ou plusieurs arguments (p.ex. Chessboard), transformez cette classe en une méthode (!) statique de Image, prenant les mêmes arguments que le constructeur de la classe originale et retournant une image construite au moyen 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 précédente é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 RED_DISK peut simplement se définir ainsi :

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 3, ce qui illustre l'intérêt des lambdas.

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 précédente, devrait ressembler à ceci :

static <U> Image<U> rotated(Image<U> image, float 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, (float) 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((float) 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 imbriquées, qui n'existent plus. 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<Float> mandelbrot(int maxIt) {
  // … code (à faire)
}

Pour calculer les termes de la suite, vous pouvez soit définir une classe minimaliste 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 float.

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

mandelbrot.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<Float> m = Image.mandelbrot(100);
Image<ColorRGB> image = Image.composed(white, black, m);

L'image obtenue devrait alors ressembler à ceci :

mandelbrot-bw.png