Images continues
Série 3 – corrigé
Introduction
Le code du corrigé est disponible dans une archive Zip et les solutions aux différents exercices sont brièvement décrites ci-dessous.
Exercice 1
Pour pouvoir dessiner un échiquier, il faut tout d'abord savoir comment déterminer la couleur d'une case étant donnée sa position. Pour ce faire, on peut s'aider d'un dessin d'échiquier dans le plan dont les cases sont numérotées en fonction de leur position horizontale et verticale :
Figure 1 : Un échiquier avec numérotation (partielle) des cases
On constate sur ce dessin que la parité de la somme des deux coordonnées d'une case détermine sa couleur. Par exemple, sur l'échiquier ci-dessus, toutes les cases dont la somme des coordonnées est paire sont noires, tandis que celles dont la somme est impaire sont blanches.
Reste à savoir comment déterminer, étant donné un point dans le plan, quelles sont les coordonnées de la case qui le contient. Or cela n'est pas très difficile : si les cases ont un côté de dimension \(w\), alors la position horizontale de la case le contenant est simplement la partie entière de \(\tfrac{x}{w}\), et il en va de même avec la position verticale.
En combinant ces deux observations, et en admettant que les cases de l'échiquier doivent avoir une taille de \(w\) unités et être coloriées avec les couleurs \(c_1\) et \(c_2\), on obtient la formule suivante pour déterminer la couleur \(c\) d'un point de coordonnées \((x, y)\) :
\begin{array}{l} c(x, y) = \begin{cases} c_1 & \text{ si }\left\lfloor\frac{x}{w}\right\rfloor + \left\lfloor\frac{y}{w}\right\rfloor\text{ est pair}\\ c_2 & \text{ sinon} \end{cases} \end{array}
Pour traduire cela en Java, il faut encore faire attention à un petit détail : pour déterminer si une valeur entière est paire, on utilise naturellement le reste de sa division entière par 2. Mais ce reste peut valoir 0, 1 ou … –1 ! En effet, en Java, -1 % 2
vaut -1
et pas 1
comme on pourrait le penser.
En conclusion, le test de parité doit se faire en comparant le reste de la division par 2 avec 0, ou alors en utilisant la méthode floorMod
, qui n'a pas le problème susmentionné. On obtient donc finalement la définition suivante pour la classe Chessboard
:
class Chessboard implements ImageRGB { private final ColorRGB c1, c2; private final float w; public Chessboard(ColorRGB c1, ColorRGB c2, float w) { if (! (w > 0)) throw new IllegalArgumentException(); this.c1 = c1; this.c2 = c2; this.w = w; } @Override public ColorRGB apply(float x, float y) { int sqX = (int)floor(x / w), sqY = (int)floor(y / w); return (sqX + sqY) % 2 == 0 ? c1 : c2; } }
Notez que, pour des questions de présentation, les modificateurs public
, final
et static
appliqués à la classe n'ont pas été inclus ci-dessus. Ils figurent néanmoins bien entendu dans le code du corrigé que nous vous fournissons.
Exercice 2
La classe Mysterious
diffère des classes RedDisk
et Chessboard
en ce qu'elle ne construit pas directement une image, mais elle en modifie une existante, qui est passée à son constructeur. En cela, Mysterious
est similaire à un flot filtrant vu au cours sur les entrées/sorties.
La modification qu'elle applique à l'image qu'on lui passe est visible dans sa méthode apply
, qui multiplie par deux la valeur de x
reçue avant de la passer à la méthode apply
de l'image à modifier.
On pourrait en conclure que l'image produite par Mysterious
sera deux fois plus large que l'image qu'on lui passe. Malheureusement, ce raisonnement est erroné ! En réalité, l'image produite par Mysterious
est deux fois plus étroite que l'image qu'on lui passe.
Pour comprendre cela, admettons que l'on désire appliquer une transformation géométrique à une image donnée, par exemple pour l'aplatir horizontalement d'un facteur 2 justement. On peut exprimer cela au moyen d'une équation qui donne les coordonnées \((x',y')\) des points de l'image après transformation, en fonction de leurs coordonnées \((x, y)\) avant transformation. Pour l'aplatissement horizontal d'un facteur 2, cette équation est simplement : \[ (x', y') = \left(\frac{x}{2}, y\right) \]
Réfléchissons maintenant à l'image représentée par Mysterious
. Les coordonnées passées à sa méthode apply
sont celles de l'image après transformation, c'est-à-dire \((x',y')\). Elle doit les utiliser pour déterminer les coordonnées \((x,y)\) de l'image avant transformation, afin de les passer à la méthode apply
de l'image qu'elle transforme. En utilisant l'équation ci-dessus, on peut facilement déterminer \((x,y)\) en fonction de \((x',y')\), et on obtient :
\[ (x, y) = (2x', y') \]
Ce qui correspond au code de la méthode apply
de Mysterious
.
De manière générale, pour appliquer une transformation donnée à une image en écrivant une fonction de transformation, et étant donné que celle-ci reçoit les coordonnées de l'image transformée et doit en déterminer les coordonnées de l'image originale, il faut appliquer à ces coordonnées la transformation inverse de celle à appliquer à l'image.
Dès lors, pour écrire correctement la classe Rotated
et lui faire faire une rotation dans le sens antihoraire, il faut utiliser les formules de rotation dans le sens horaire. Une manière simple de faire cela est d'inverser le signe de l'angle reçu, ce qui est fait ci-dessous.
class Rotated<T> implements Image<T> { private final Image<T> image; private final float cosA, sinA; public Rotated(Image<T> image, float angle) { this.image = image; this.cosA = (float) cos(-angle); this.sinA = (float) sin(-angle); } @Override public T apply(float x, float y) { float x1 = x * cosA - y * sinA; float y1 = x * sinA + y * cosA; return image.apply(x1, y1); } }
Notez que la classe Rotated
calcule une et une seule fois le sinus et cosinus de l'angle reçu, et les stocke dans des attributs. Cela permet à la méthode apply
, appelée très souvent, d'être beaucoup plus efficace que si elle recalculait ces valeurs à chaque appel.
Exercice 3
L'écriture de la classe HorizontalGradientMask
ne pose pas de problème particulier, il suffit de traduire la formule mathématique donnée dans l'énoncé. Notez qu'elle peut se simplifier en utilisant les méthode min
et max
, ce qui a été fait ci-dessous :
class HorizontalGradientMask implements Image<Float> { @Override public Float apply(float x, float y) { return max(0, min((x + 1f) / 2f, 1f)); } }
Tout comme l'image produite par la classe RedDisk
, celle produite par la classe HorizontalGradientMask
est unique car elle ne dépend d'aucun paramètre. Il est donc logique de n'en créer qu'une instance, que l'on peut stocker dans un attribut statique de Image
, exactement comme pour RED_DISK
:
static final Image<Float> HORIZONTAL_GRADIENT_MASK = new HorizontalGradientMask();
La classe Composed
n'est pas non plus très difficile à écrire, une fois que l'on a compris le principe. Sa méthode apply
doit faire ce qui est dit dans l'énoncé, à savoir :
- utiliser la méthode
apply
de l'arrière-plan (bg
ci-dessous) pour obtenir sa couleur, - utiliser la méthode
apply
de l'avant-plan (fg
ci-dessous) pour obtenir sa couleur, - utiliser la méthode
apply
du masque pour savoir en quelles proportions mélanger les couleurs, - effectuer ce mélange au moyen de la méthode
mixWith
pour obtenir la couleur de l'image composée.
Traduit en Java, cela donne :
class Composed implements Image<ColorRGB> { private final Image<ColorRGB> bg, fg; private final Image<Float> mask; public Composed(Image<ColorRGB> bg, Image<ColorRGB> fg, Image<Float> mask) { this.bg = bg; this.fg = fg; this.mask = mask; } @Override public ColorRGB apply(float x, float y) { return bg.apply(x, y) .mixWith(fg.apply(x, y), mask.apply(x, y)); } }