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.
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 !
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.
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.
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.
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.
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 :
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.