Série 11 – Dessin d'histogrammes

Introduction

Le but de cette série est de développer un ensemble de classes permettant de calculer un histogramme à partir de données provenant d'un fichier, de créer une image en nuances de gris à partir de cet histogramme et d'afficher cette image à l'écran. A la fin de cette série vous devriez ainsi pouvoir afficher une fenêtre ressemblant à celle ci-dessous, qui montre un histogramme des nombre de points obtenus à l'examen intermédiaire de ce cours, avec une nuance de gris par section.

images/final-screen-shot.png

Histogramme des points

Cette série est très longue mais constitue un excellent exercice de révision pour l'examen final du cours puisqu'elle touche à de nombreux concepts examinés au long du semestre.

Pour vous permettre de démarrer, nous mettons à votre disposition une archive zip contenant à la fois les fichiers de données nécessaires et une unique classe nommée BarChartMakerGUI qui contient le code gérant l'interface graphique. Il n'est aucunement nécessaire de comprendre le fonctionnement de cette classe, mais uniquement de modifier (lorsque cela vous sera demandé) sa méthode createChartImage. Notez que comme elle dépend des classes Point et Image que vous devrez définir plus bas, il est normal qu'Eclipse signale initialement plusieurs erreurs dans son corps.

Points

La première classe à définir est celle modélisant les points du plan, car elle est nécessaire à la classe des images introduite plus bas. Cette classe des points, immutable et instanciable, est extrêmement simple et ressemble à ceci :

public final class Point {
    public Point(double x, double y) { /* à faire */ }

    public double x() { /* à faire */ }
    public double y() { /* à faire */ }

    public Point translated(double dX, double dY) { /* à faire */ }
}

Le constructeur prend les coordonnées cartésiennes x et y du point à créer, les méthodes x et y permettent d'accéder à ces coordonnées, et translated permet d'obtenir le point résultant d'une translation de dX unités parallélement à l'axe des abscisses et dY unités parallélement à l'axe des ordonnées.

Idéalement, il faudrait lui ajouter une méthode equals structurelle et une méthode hashCode compatible, mais vous pouvez ne pas le faire pour gagner un peu de temps.

Couleurs

La seconde classe à définir est celle modélisant les couleurs, car elle est également nécessaire à la classe des images introduite ci-dessous.

Pour simplifier les choses, les seules « couleurs » que nous utilisons ici sont des nuances de gris. Ces couleurs sont représentées par une énumération Java offrant une unique méthode en plus de celle automatiquement définies pour les énumérations. Cette méthode se nomme brightness et retourne un nombre réel de type float donnant la luminosité de la couleur, comprise entre 0 et 1 (inclus).

L'énumération Color est composée des cinq valeurs suivantes : BLACK (luminosité 0), DARK_GREY (0.25), MIDDLE_GREY (0.5), LIGHT_GREY (0.75) et WHITE (1).

Images

Les points et couleurs étant définis, il est temps de passer aux images elle-mêmes. Tout d'abord, il importe de définir ce qu'est une image.

En informatique, les images sont souvent dicrètes, c'est-à-dire composées d'un nombre fini de points colorés nommés pixels. Une photographie numérique est de ce type. Dans le cadre de cet exercice, nous utiliserons toutefois des images continues, ressemblant à celles que l'on peut produire au moyen de programmes de dessin vectoriels comme Inkscape. Comme nous le verrons, ce type d'images a l'avantage d'être redimensionnable à loisir.

Les images continues peuvent être représentées par l'interface suivante :

public interface Image {
    public double width();
    public double height();
    public boolean containsPoint(Point p);
    public Color colorAtPoint(Point p);
}

Les méthodes width et height donnent simplement les dimensions de l'image, dans une unité qui n'est pas spécifiée ici car la taille absolue des images nous importe peu. En effet, les images sont systématiquement redimensionnées lors de l'affichage, et seules les dimensions relatives des différentes images affichées ont une importance.

La méthode containsPoint retourne vrai si et seulement si le point passé en argument appartient à l'image, c-à-d si sa composante x est comprise entre 0 et la largeur de l'image et sa composante y est comprise entre 0 et la hauteur de l'image.

Finalement, la méthode colorAtPoint permet d'obtenir la couleur de l'image au point donné en argument. Ce point doit appartenir à l'image, c-à-d que containsPoint doit retourner vrai pour lui, faute de quoi colorAtPoint lève l'exception IllegalArgumentException.

Image vide

L'image la plus simple à définir est l'image vide, de largeur et hauteur 0. Comme nous le verrons plus tard, une telle image peut être utile, raison pour laquelle vous pouvez maintenant ajouter à l'interface Image une constante nommée EMPTY_IMAGE qui est une instance (unique) d'une classe anonyme implémentant l'interface Image avec les définitions adéquates de ses différentes méthodes.

Image abstraite

Avant de passer à la définition d'images plus complexes, on peut constater que la méthode containsPoint se définit très facilement en termes des méthodes width et height. Pour cette raison, il est intéressant de définir une classe héritable nommée AbstractImage qui implémente l'interface Image et fournit une mise en œuvre par défaut de containsPoint. L'écriture de cette classe constitue votre prochaine tâche.

En plus de containsPoint, cette classe peut définir une autre méthode très utile, qui peut s'appeler p.ex. checkContainedPoint et qui prend en argument un point. Cette méthode ne fait rien si ce point est contenu dans l'image mais lève l'exception IllegalArgumentException dans le cas contraire. Elle sera très utile pour mettre en œuvre la méthode colorAtPoint des sous-classes instanciables de AbstractImage.

Image monochromatique

Un type d'image concrète très facile à définir est l'image monochromatique. Une telle image a des dimensions (largeur et hauteur) paramétrables, de même qu'une couleur. Sa méthode colorAtPoint retourne cette couleur pour tous les points qu'elles contient, et lève l'exception IllegalArgumentException pour les autres.

Ce type d'image est modélisé par la classe MonochromaticImage, sous-classe instanciable et immutable de AbstractImage, que vous devez écrire.

Une fois la classe MonochromaticImage écrite, vous pouvez la tester en affichant votre première image à l'écran ! Pour ce faire, modifiez la méthode createChartImage de la classe BarChartMakerGUI du squelette que nous vous fournissons pour lui faire créer une telle image. Ses dimensions peuvent être quelconques (mais non nulles) et la couleur peut p.ex. être MIDDLE_GREY.

En exécutant le programme, vous devriez alors voir apparaître à l'écran une fenêtre monochromatique du type de celle visible ci-dessous. Ça n'est pas encore très passionant, mais vous progressez !

images/monochromatic.png

Image monochromatique (MIDDLE_GREY)

Image encadrée

Pour continuer, ajoutons la possibilité d'encadrer une image. La manière la plus simple de faire cela est d'appliquer le patron Decorator en définissant un décorateur qui ajoute un cadre à une image.

Dans ce but, définissez une nouvelle sous-classe instanciable et immutable de AbstractImage, nommée p.ex. FramedImage et prenant en argument une image (l'image à encadrer), une taille de cadre et une couleur de cadre. Le diagramme ci-dessous montre les dimensions des différents éléments d'une image encadrée : \(w_1\) et \(h_1\) sont la largeur et la hauteur de l'image à encadrer, \(w_2\) et \(h_2\) celles de l'image encadrée et \(s\) est la taille du cadre.

images/frame-dimensions.png

Encadrement d'une image

En examinant ce diagramme, on constate que \(w_2 = w_1 + 2s\) et \(h_2 = h_1 + 2s\). Les méthodes width et height de l'image encadrée sont donc faciles à définir. Quant à la méthode colorAtPoint, elle doit commencer par déterminer si le point reçu se trouve dans le cadre ou dans l'image encadrée. Dans le premier cas, elle retourne la couleur du cadre, dans le second elle retourne ce que la méthode colorAtPoint de l'image encadrée retourne !

Une fois la classe FramedImage écrite, vous pouvez vérifier qu'elle fonctionne en encadrant deux fois une image monochromatique avec deux cadres de couleur différente, comme cela a été fait dans l'exemple ci-dessous.

images/mono-framed-twice.png

Image monochromatique (MIDDLE_GREY) encadrée deux fois

Images composées horizontalement

Il est maintenant temps de voir comment composer des images simples pour en obtenir de plus complexes. Pour cela, nous utiliserons naturellement le patron Composite !

Le premier type d'image composite à définir est celui des images placées côte-à-côte horizontalement, et alignées en bas. Pour ce faire, définissez une nouvelle sous-classe instanciable et immutable de AbstractImage, nommée p.ex. HorizontalCompositeImage et prenant en argument les deux images à composer ainsi qu'une couleur de fond à afficher dans le cas où l'une des deux images est plus petite que l'autre. Le diagramme ci-dessous illustre une telle composition. Notez que dans ce diagramme l'image de gauche est plus basse que celle de droite, mais la situation inverse peut aussi se présenter.

images/h-composite-dimensions.png

Alignement horizontal d'images

En examinant ce diagramme, il est facile de déterminer les dimensions d'une telle image en fonction de celles des images qu'elle compose. Quant à la méthode colorAtPoint, sa définition n'est pas beaucoup plus compliquée que dans le cas de l'image encadrée, à vous de la trouver.

La classe HorizontalCompositeImage terminée, vous pouvez la tester en l'utilisant pour créer une image ressemblant à un drapeau tricolore à bandes verticales, comme celui de l'image ci-dessous. Notez que cette image a été construite au moyen de deux instances de HorizontalCompositeImage : la première pour les deux bandes de gauche, la seconde pour cette image composite et la troisième bande.

images/flag-framed.png

Un drapeau (?) encadré

Création d'histogrammes

La possibilité d'aligner horizontalement des images monochromatiques de taille différente permet déjà de produire des images simples d'histogrammes. Il est donc temps de laisser les images de côté un instant pour se concentrer sur la création d'histogrammes à partir de données provenant d'un fichier.

Pour simplifier les choses, nous ne représenterons que des histogrammes pour des données entières.

Histogrammes

Pour modéliser les histogrammes, nous vous proposons de définir une classe immutable et instanciable possédant le profil suivant :

public final class Histogram {
    public Histogram(List<Integer> values) { /* à faire */ }

    public int minValue() { /* à faire */ }
    public int maxValue() { /* à faire */ }

    public int valueCount(int value) { /* à faire */ }
}

Le constructeur prend en argument les valeurs pour lesquelles l'histogramme doit être calculé.

Les méthodes minValue et maxValue retournent respectivement la plus petite et la plus grande valeur contenue dans les données ou lèvent une exception s'il n'y a aucune donnée.

Finalement, la méthode valueCount retourne le nombre d'occurrences de la valeur passée en argument dans les données. Notez qu'elle retourne 0 pour toute valeur qui n'apparaît pas dans les données.

Il va de soi que la méthode valueCount ne peut pas se permettre de re-analyser les données à chaque appel. Le constructeur de la classe Histogram doit donc se charger de cette analyse, dont le résultat (une collection) doit être stocké dans un champ de la classe.

Exemple

Pour illustrer le fonctionnement de cette classe, imaginons que l'on désire construire un histogramme pour les résultats d'un examen. Un total de dix étudiants ont effectué cet examen et ont obtenu les notes suivantes : 1, 1, 3, 3, 4, 4, 4, 4, 4, 6.

Pour construire un histogramme pour ces données, on passe une liste contenant les notes obtenues (dans un ordre quelconque !) au constructeur de la classe Histogram. Une fois l'histogramme créé, il devient possible d'appeler ses méthodes, qui se comportent ainsi :

  • minValue retourne 1, la note minimale rencontrée dans les données,
  • maxValue retourne 6, la note maximale rencontrée dans les données,
  • valueCount retourne 2 lorsqu'on l'applique à la valeur 1, car la note 1 apparaît deux fois dans les données ; elle retourne 5 lorsqu'on l'applique à la valeur (note) 4 ; elle retourne 0 lorsqu'on l'applique à la valeur 2 ; etc.

Lecture de données dans un fichier

Avant de passer à la création d'images d'histogrammes, attelons-nous encore au problème de la création des histogrammes eux-mêmes. Nous vous fournissons le nombre de points obtenus lors de l'examen intermédiaire du cours dans le fichier all.txt, un fichier textuel dans lequel chaque ligne contient une note.

Pour lire ces données, écrivez une méthode statique dans une classe de votre choix qui, étant donné un nom de fichier, retourne une liste de tous les entiers contenus dans ce fichier (un par ligne).

Cette méthode doit créer une instance de FileReader (qui permet la lectures de données textuelles depuis un fichier) puis l'emballer dans une instance de BufferedReader. Cette classe (un décorateur) fournit entre autres la méthode readLine qui lit exactement une ligne du lecteur qu'elle décore et la retourne sous la forme d'une chaîne de caractères. Cette chaîne peut être transformée en un entier au moyen de la méthode valueOf de la classe Integer.

Vous devriez maintenant être capable de produire, sous forme de liste d'entiers, les valeurs contenues p.ex. dans le fichier all.txt que nous vous fournissons.

Image d'histogramme

Pour terminer, il ne reste plus qu'à écrire le code construisant une image correspondant à un histogramme. Vous placerez ce code dans une classe nommée p.ex. ChartMaker, sous la forme de méthodes statiques.

Image d'histogramme simple

L'image d'un histogramme simple est composée de barres dont la hauteur est déterminée par les données et qui sont chacune encadrées par un cadre très fin afin d'être individuellement dissociables. Les images de barres encadrées sont placées côte-à-côte pour construire progressivement l'image de l'histogramme complet.

Notez que pour démarrer la construction de l'image de l'histogramme, il peut être intéressant de commencer avec une image vide (c-à-d la constante EMPTY_IMAGE définie plus haut) puis de la composer avec la première barre, et ainsi de suite.

En construisant une image de l'histogramme des données du fichier all.txt, vous devriez pouvoir obtenir une image ressemblant à celle-ci :

images/all-screen-shot.png

Histogramme des points pour l'ensemble de la volée

Image d'histogramme empilé

Pour terminer, vous pouvez améliorer encore votre programme pour permettre le dessin d'histogrammes empilés. Cela nécessite la définition d'un nouveau type d'image composite, celui des images composées verticalement. Ce nouveau type d'images défini, la création d'histogrammes empilés n'est plus très difficile.

Vous pouvez tester votre code en générant un histogramme empilé à partir des données des fichiers in.txt et sc.txt et en vérifiant que vous obtenez quelque chose ressemblant à l'image de l'introduction.

Michel Schinz – 2013-05-24 14:23