Dessin de panorama
Etape 7

1 Introduction

Le but de cette étape est d'écrire le code permettant de dessiner un panorama résultant du calcul décrit à l'étape précédente. Ce dessin doit être facilement paramétrable, afin de permettre la création d'images variées.

Cette étape est la première de la seconde partie du projet, durant laquelle vous serez moins guidés que durant la première. En particulier, vous avez maintenant le droit de modifier ou augmenter l'interface publique des classes et interfaces proposées, pour peu bien entendu que vous ayez une bonne raison de le faire. De plus, nous nous attendons à ce que vous lisiez et compreniez la documentation des parties de la bibliothèque Java que vous devrez utiliser.

1.1 Dessin de panorama

Le dessin d'un panorama consiste à transformer, d'une manière ou d'une autre, ses données (distance à l'utilisateur, pente du terrain, etc.) en une image.

Dans un premier temps en tout cas, il semble raisonnable de considérer que l'image d'un panorama a la même taille (largeur, hauteur) que le panorama lui-même, et que la couleur de chacun de ses pixels est déterminée par les données associées au point du panorama correspondant.

Dès lors, dessiner un panorama revient à choisir comment transformer les données d'un point du panorama en une couleur. C'est par exemple ce que faisait le programme DrawPanorama fourni à l'étape 6, en coloriant chaque pixel en fonction de la distance à l'utilisateur.

Avant d'examiner comment faire le dessin, il convient toutefois de dire quelques mots au sujet de la représentation des couleurs.

1.1.1 Représentation des couleurs

L'œil humain est équipé de trois types de cônes, sensibles à différentes longueurs d'onde de la lumière. Dès lors, tant et aussi longtemps que l'on ne s'intéresse qu'à leur perception par l'humain, les couleurs peuvent être simplement représentées par un triplet de valeurs.

En informatique, ce triplet de valeurs exprime souvent les proportions dans lesquelles mélanger trois couleurs primaires, le rouge, le vert et le bleu. On parle alors de couleur RGB (pour red, green, blue).

La représentation RGB convient bien aux ordinateurs, car les écrans synthétisent effectivement les couleurs en combinant les proportions requises de rouge, de vert et de bleu. Pour les êtres humains, le modèle RGB est toutefois moins pratique, car il est difficile de se faire une idée de l'aspect d'une couleur étant données ses composantes RGB. De plus, des opérations intuitivement simples, comme l'assombrissement ou l'éclaircissement d'une couleur, sont difficiles à effectuer dans le modèle RGB.

Pour cette raison, d'autres représentations des couleurs, plus adaptées à la perception humaine, ont été inventées. L'une d'entre elles consiste à représenter une couleur par le triplet de :

  1. sa teinte (hue),
  2. sa saturation (saturation), qui exprime sa pureté, et
  3. sa luminosité (brightness).

Cette représentation des couleurs est généralement désignée par l'acronyme TSL ou TSV (le V signifiant alors valeur). En anglais, les acroynmes correspondant sont HSB ou HSL.

La figure ci-dessous illustre l'effet de la teinte et de la saturation sur une couleur de luminosité constante et valant 1. La teinte varie de gauche à droite entre 0° et 359°, tandis que la saturation varie de bas en haut entre 0 et 1.

hue-saturation.png

Figure 1 : Teinte (abscisse) et saturation (ordonnée) d'une couleur

La figure ci-dessous illustre l'effet de la luminosité et de la saturation sur une couleur de teinte constante et valant 0° (rouge). La luminosité varie de gauche à droite entre 0 et 1, tandis que la saturation varie à nouveau de bas en haut entre 0 et 1.

brightness-saturation.png

Figure 2 : Luminosité (abscisse) et saturation (ordonnée) du rouge

D'autres représentations de ces trois paramètres sont visibles sur la page Wikipedia du modèle de couleur TSV.

La représentation HSB convient mieux que la représentation RGB pour le dessin de panorama, car elle permet par exemple facilement de faire correspondre la luminosité d'un pixel à la pente du terrain, afin d'obtenir un effet de relief, et la teinte et/ou la saturation à la distance à l'utilisateur, afin d'obtenir une impression de profondeur. Comme cela a été expliqué à la section 1.2 de l'étape 6, c'est de cette manière que le panorama de l'introduction au projet a été dessiné.

Quelle que soit la représentation choisie pour une couleur (RGB, HSB ou autre), il est possible de lui associer une quatrième composante représentant son opacité. Cette composante ne fait pas à proprement parler partie de la couleur, mais est utilisée lorsqu'on la mélange à une autre couleur.

1.1.2 Canaux

Etant donné que tous les pixels d'une image ont une couleur, et que ces couleurs ont trois (voir quatre) composantes, il est possible de décomposer une image en trois (ou quatre) canaux (channels en anglais) qui sont des « images » ne contenant qu'une seule composante de la couleur de chaque pixel.

Par exemple, une image couleur peut être décomposée en un canal rouge, un canal vert et un canal bleu, comme dans la figure ci-dessous. De manière similaire, cette image pourrait être décomposée en un canal teinte, un canal saturation et un canal luminosité.

zumsee-rgb.png

Figure 3 : Une image (coin haut gauche) et ses canaux R, G et B

Mathématiquement, la différence entre une image et un canal peut se comprendre ainsi :

  • une image est une fonction de type \(\Bbb{R}^2\rightarrow\Bbb{R}^3\) qui fait correspondre une couleur, donc un triplet de valeurs réelles, à un point du plan,
  • un canal est une fonction de type \(\Bbb{R}^2\rightarrow\Bbb{R}\) qui fait correspondre une seule valeur réelle à un point du plan.

Clairement, il est facile de combiner trois fonctions du second type, donc trois canaux, pour en obtenir une du premier type, donc une image. Nous utiliserons cette caractéristique pour dessiner un panorama en dessinant séparément les canaux de l'image finale, puis en les combinant. Par exemple, le canal luminosité sera dessiné, comme expliqué plus haut, en fonction de la pente du terrain du panorama.

Comme nous le verrons plus bas, certaines fonctions à une variable sont souvent utiles lors de la définition de canaux, et il vaut donc la peine de leur donner un nom. Dans le cadre de ce projet, nous définirons les trois fonctions suivantes :

\begin{align*} \newcommand{\clamp}{\mathop{\rm clamp}\nolimits} \newcommand{\cycle}{\mathop{\rm cycle}\nolimits} \newcommand{\invert}{\mathop{\rm invert}\nolimits} \clamp(x) &= \max(0, \min(x, 1))\\ \cycle(x) &= x\bmod 1\\ \invert(x) &= 1 - x \end{align*}

1.1.3 Peintres

Nous appellerons peintres les entités chargées de transformer un panorama en une image. Etant donné ce qui précède, nous distinguerons deux types de peintres :

  1. les peintres de canal, qui étant donné un panorama produisent un canal,
  2. les peintres d'image, qui étant donné trois (ou quatre) peintres de canal produisent une image.

2 Mise en œuvre Java

A partir de cette étape, la mise en œuvre Java sera moins guidée que précédemment. En particulier, l'interface publique des classes et interfaces ne sera plus décrite de manière aussi détaillée qu'avant.

Les noms des classes, interfaces et méthodes donnés ci-dessous ne sont également que des propositions, libre à vous d'en choisir d'autres. Cela dit, afin de ne pas compliquer inutilement le processus de correction, il vous est demandé de ne pas trop vous éloigner de la mise en œuvre que nous vous proposons.

Les trois interfaces de cette étape sont à placer dans un nouveau paquetage nommé ch.epfl.alpano.gui, destiné à contenir la totalité du code lié à l'interface graphique (GUI signifiant graphical user interface).

2.1 Interface ChannelPainter

L'interface ChannelPainter est une interface fonctionnelle représentant un peintre de canal.

L'unique méthode abstraite de cette interface, nommée p.ex. valueAt, prend en arguments les coordonnées entières x et y d'un point et retourne la valeur du canal en ce point, un nombre à virgule flottante de type float.

En plus de cette méthode abstraite, l'interface ChannelPainter offre une méthode statique, nommée p.ex. maxDistanceToNeighbors qui, étant donné un panorama, retourne un peintre de canal dont la valeur pour un point est la différence de distance entre le plus lointain des voisins et le point en question. Pour les points situés dans les bords, dont certains voisins sont hors du panorama, on considère que la distance à l'observateur de ces voisins est nulle.

Par exemple, étant donné un panorama de taille 3×3 dont les distances à l'observateur sont celles données par la table suivante :

40 50
20 30 30
10 10 10

le peintre retourné par maxDistanceToNeighbors produit les valeurs suivantes pour les points correspondants :

-∞ -10
10 20
10 20 20

Par exemple, la valeur du point central est calculée ainsi, où \(m\) représente le peintre et \(d\) la distance à l'observateur :

\begin{align*} m(1,1) &= max\left[d(0,1), d(2,1), d(1,0), d(1,2)\right] - d(1,1)\\ &= max\left[20, 30, 40, 10\right] - 30\\ &= 10 \end{align*}

Un conseil : aidez-vous de la méthode distanceAt de Panorama prenant trois arguments — dont une valeur par défaut — pour simplifier la définition de la méthode maxDistanceToNeighbors.

Finalement, l'interface ChannelPainter offre plusieurs méthodes par défaut permettant de transformer un peintre pour en obtenir un autre. Il s'agit de :

  • quatre méthodes nommées p.ex. add, sub, mul et div, permettant respectivement d'ajouter, de soustraire, de multiplier ou de diviser la valeur produite par le peintre par une constante passée en argument à la méthode,
  • une méthode, nommée p.ex. map, permettant d'appliquer à la valeur produite par le peintre un opérateur unaire (de type DoubleUnaryOperator) passé en argument à la méthode,
  • trois méthodes nommées p.ex. inverted, clamped et cycling, permettant d'appliquer à la valeur produite par le peintre chacune des trois fonctions nommées de manière (presque) identique dans la section 1.1.2.

Par exemple, il devrait être possible d'utiliser les méthodes ci-dessus de la manière suivante pour obtenir un peintre de canal nommé ici gray.

Panorama p = …;
ChannelPainter gray =
  ChannelPainter.maxDistanceToNeighbors(p)
  .sub(500)
  .div(4500)
  .clamp()
  .inverted();

Ce peintre de canal produit une valeur comprise entre 0 et 1 et telle que :

  1. tout point dont la distance maximale aux voisins est inférieure à 500 m est dessiné en blanc,
  2. tout point dont la distance maximale aux voisins est supérieure à 5000 m est dessiné en noir,
  3. tous les autres points sont dessinés en une teinte de gris intermédiaire, proportionnelle à la distance maximale aux voisins.

Cette technique de calcul permet d'obtenir un dessin du profil des montagnes, comme l'illustre la figure 4 plus bas, qui montre un exemple d'image obtenue au moyen de ce peintre.

Notez que ce peintre pourrait aussi être défini d'autres manières en utilisant la méthode map, par exemple ainsi :

ChannelPainter gray =
  ChannelPainter.maxDistanceToNeighbors(p)
  .map(d -> (d - 500d) / 4500d)
  .clamped()
  .inverted();

2.2 Interface ImagePainter

L'interface ImagePainter est une interface fonctionnelle représentant un peintre d'image.

L'unique méthode abstraite de cette interface, nommée p.ex. colorAt, prend en arguments les coordonnées x et y d'un point et retourne la couleur de l'image en ce point.

L'interface ImagePainter offre deux méthodes statiques permettant d'obtenir un peintre d'image à partir de plusieurs peintres de canaux :

  1. une méthode nommée p.ex. hsb, prenant en arguments 4 peintres de canaux correspondant à la teinte, la saturation, la luminosité et l'opacité, et retournant le peintre d'image correspondant,
  2. une méthode nommée p.ex. gray, prenant en arguments 2 peintres de canaux correspondant à la teinte de gris et à l'opacité, et retournant le peintre d'image correspondant.

Les couleurs manipulées par ce type de peintres sont des instances de la classe Color du paquetage javafx.scene.paint. Les méthodes statiques hsb et gray de cette classe sont particulièrement utiles ici.

2.3 Interface PanoramaRenderer

L'interface PanoramaRenderer n'offre qu'une seule méthode statique, nommée p.ex. renderPanorama, permettant d'obtenir l'image d'un panorama étant donnés ce panorama et un peintre d'image.

Dans ce projet, nous utiliserons la bibliothèque JavaFX pour réaliser l'interface graphique. Dès lors, les images produites par l'interface PanoramaRenderer doivent être des images JavaFX, c-à-d des instances de la classe Image du paquetage javafx.scene.image ou de l'une de ses sous-classes.

En particulier, les images modifiables, à utiliser ici, sont des instances de WritableImage, dont la méthode getPixelWriter permet d'obtenir un « écrivain de pixels » de type PixelWriter grâce auquel il est possible de définir la couleur des pixels de l'image. Lisez la documentation de ces classes, très simples, pour comprendre comment les utiliser.

Attention : les images JavaFX sont un type différent d'images que celles utilisées jusqu'à présent dans les programmes d'exemples des étapes précédentes.

2.4 Tests

A partir de cette étape, aucun fichier de vérification de signatures ne vous est fourni, étant donné que les signatures des différentes méthodes n'est plus spécifiée en détail. Pour la même raison, aucun test unitaire ne sera plus fourni à l'avenir, à vous d'écrire les vôtres.

En plus d'écrire des tests unitaires pour cette étape, nous vous conseillons de plus d'adapter le programme de dessin de panorama fourni à l'étape 6 pour obtenir quelques images, comme expliqué ci-après.

2.4.1 Panorama en niveaux de gris

L'extrait de programme ci-dessous, qui peut être intégré à celui de l'étape 6, permet de dessiner une image en niveaux de gris faisant resortir les profils des montagnes.

Panorama p = …;                 // voir étape 6

ChannelPainter gray =
  ChannelPainter.maxDistanceToNeighbors(p)
  .sub(500)
  .div(4500)
  .clamped()
  .inverted();

ChannelPainter distance = p::distanceAt;
ChannelPainter opacity =
  distance.map(d -> d == Float.POSITIVE_INFINITY ? 0 : 1);

ImagePainter l = ImagePainter.gray(gray, opacity);

Image i = PanoramaRenderer.renderPanorama(p, l);
ImageIO.write(SwingFXUtils.fromFXImage(i, null),
              "png",
              new File("niesen-profile.png"));

En incorporant cet extrait de programme à celui de l'étape 6 et en exécutant le résultat, vous devriez obtenir l'image suivante :

niesen-profile.png

Figure 4 : Le Niesen en noir et blanc (taille réelle)

2.4.2 Panorama en couleur

Les équations ci-dessous définissent quatre canaux \(h\), \(s\), \(b\) et \(o\) en fonction de la distance à l'observateur \(d\) et de la pente du terrain \(p\) :

\begin{align*} h(x, y) &= 360\cdot\cycle\left[\frac{d(x, y)}{100\,000}\right]\\ s(x, y) &= \invert\left[\clamp\left[\frac{d(x, y)}{200\,000}\right]\right]\\ b(x, y) &= 0.3 + 0.7\cdot\invert\left[\frac{2\,p(x, y)}{\pi}\right]\\ o(x, y) &= \begin{cases} 0 & \text{si } d(x, y) = \infty\\ 1 & \text{sinon} \end{cases} \end{align*}

En combinant ces quatre canaux de manière à ce que \(h\) soit la teinte, \(s\) la saturation, \(b\) la luminosité et \(o\) l'opacité de la couleur de chaque pixel, on obtient l'image ci-dessous du panorama du Niesen.

niesen-shaded.png

Figure 5 : Le Niesen en couleur (taille réelle)

Notez que ces formules sont également celles qui ont été utilisées pour dessiner le panorama de l'introduction, et seront celles à utiliser par défaut jusqu'à la fin du projet.

3 Résumé

Pour cette étape, vous devez :

  • écrire les interfaces ChannelPainter, ImagePainter et PanoramaRenderer (ou équivalent) en fonction des indications données plus haut,
  • tester votre code,
  • documenter la totalité des entités publiques que vous avez définies.

Aucun rendu n'est à faire pour cette étape avant le rendu final. N'oubliez pas de faire régulièrement des copies de sauvegarde de votre travail en suivant nos indications à ce sujet.