Série 2 – Images continues : corrigé
Introduction
Le code du corrigé est disponible sous la forme d'une archive Zip, qui contient également le code de l'énoncé. Les solutions aux différents exercices sont brièvement discutées ci-dessous.
Exercice 1
Echiquier
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 \(s\), alors la position horizontale de la case le contenant est simplement la partie entière de \(\tfrac{x}{s}\), 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 \(s\) unités et être coloré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{displaymath} c(x, y) = \begin{cases} c_1 & \text{ si }\left\lfloor\frac{x}{s}\right\rfloor + \left\lfloor\frac{y}{s}\right\rfloor\text{ est pair}\\ c_2 & \text{ sinon} \end{cases} \end{displaymath}
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 il faut savoir que ce reste peut valoir 0, 1 ou … -1 ! En effet, en Java, -1 % 2
vaut -1
et pas 1
comme on pourrait le penser. Cela est une caractéristique de la division entière tronquée qu'utilise Java, comme expliqué dans l'énoncé de l'étape 2 du projet. La division par défaut définie dans ce même énoncé n'a pas ce problème.
En conclusion, le test de parité doit se faire en comparant le reste de la division par 2 avec 0. On obtient donc finalement la définition suivante pour la méthode valueAt
de la classe ChessboardGenerator
:
public ColorRGB valueAt(double x, double y) { double sqX = Math.floor(x / squareSize); double sqY = Math.floor(y / squareSize); return ((int)(sqX + sqY)) % 2 == 0 ? color1 : color2; }
Cible
Une fois l'échiquier défini, la cible est très facile à définir. Là aussi il y a alternance de deux couleurs, ce qui suggère l'utilisation de la parité d'une valeur pour déterminer la couleur. Et cette valeur est très naturellement le numéro de la bande de la cible, où la bande centrale a le numéro 0, celle qui l'entoure directment le numéro 1, et ainsi de suite. Pour déterminer ce numéro de bande à partir des coordonnées d'un point, on utilise la distance du point par rapport au centre du plan.
En combinant ces différentes observations, on obtient la formule suivante pour la couleur \(c\) d'un point de coordonnées \((x, y)\) d'une cible dont les bandes ont une largeur \(s\) et des couleurs \(c_1\) et \(c_2\) :
\begin{displaymath} c(x, y) = \begin{cases} c_1 & \text{ si }\left\lfloor\frac{\sqrt{x^2 + y^2}}{s}\right\rfloor\text{ est pair}\\ c_2 & \text{ sinon} \end{cases} \end{displaymath}
Traduit en Java, cela donne la définition suivante pour la méthode valueAt
:
public ColorRGB valueAt(double x, double y) { double r = Math.sqrt(x * x + y * y); return (int)Math.floor(r / stripeWidth) % 2 == 0 ? color1 : color2; }
Dégradé linéaire
Pour définir l'image du dégradé linéaire, il faut d'une part se rendre compte que le problème est unidimensionnel, dans le sens où la coordonnée \(y\) ne joue aucun rôle, et d'autre part qu'il y a trois cas à distinguer, dont deux sont triviaux.
En effet, tous les points à gauche de la coordonnée correspondant à la première couleur sont justement de cette couleur. Il en va de même pour tous les points à droite de la coordonnée correspondant à la deuxième couleur, qui sont de cette couleur.
Le troisième cas, celui des points situés entre les deux coordonnées, est le seul véritablement intéressant. Soit donc un point dont la coordonnée \(x\) est comprise entre les coordonnées gauche et droite du dégradé, \(x_l\) et \(x_r\), comme illustré dans la figure ci-dessous :
Figure 2 : Coordonnées d'un dégradé linéaire
Il est facile de voir que pour connaître la proportion \(p\) de la couleur de droite qui doit figurer dans le mélange à la position \(x\), il suffit de calculer à quelle distance \(x\) se trouve de \(x_l\), par rapport à l'intervalle \([x_l;x_r]\). On obtient alors la formule ci-dessous :
\begin{displaymath} c(x, y) = \begin{cases} c_l & \text{si }x \le x_l\\ c_r & \text{si }x \ge x_r\\ c_l \oplus_p c_r\text{ où } p = (x - x_l)/(x_r - x_l) & \text{sinon} \end{cases} \end{displaymath}
où \(c_1\oplus_p c_2\) représente le mélange, dans la couleur \(c_1\), d'une proportion \(p\) de la couleur \(c_2\), c-à-d exactement ce que la méthode mixWith
de la classe ColorRGB
fait.
Cette formule se traduit aisément en Java, pour obtenir la définition suivante de la méthode valueAt
:
public ColorRGB valueAt(double x, double y) { if (x <= leftX) return leftColor; else if (x >= rightX) return rightColor; else { double p = (x - leftX) / (rightX - leftX); return leftColor.mixWith(rightColor, p); } }
Exercice 2
Avant de définir la classe RotationTransformer
, il convient de comprendre pourquoi, dans la méthode valueAt
de ShrinkVerticallyByTwoTransformer
, la composante \(y\) est multipliée par 2 et pas divisée comme on pourrait s'y attendre au vu de l'aplattissement de l'image transformée.
Pour comprendre cela, admettons que l'on désire appliquer une transformation géométrique à une image donnée, par exemple pour l'aplatir verticalement 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 vertical d'un facteur 2, cette équation est simplement : \[ (x', y') = \left(x, \frac{y}{2}\right) \]
Réfléchissons maintenant à la méthode valueAt
d'un transformateur, p.ex. ShrinkVerticallyByTwoTransformer
. Les coordonnées passées à cette méthode sont celles de l'image après transformation, c'est-à-dire \((x',y')\). La méthode valueAt
doit les utiliser pour déterminer les coordonnées \((x,y)\) de l'image avant transformation, afin de les passer à la méthode valueAt
de l'image qu'elle transforme. En utilisant l'équation ci-dessus, on peut facilement déterminer comment calculer \((x,y)\) en fonction de \((x',y')\), et on obtient :
\[ (x, y) = (x', 2y') \]
Ce qui correspond à la méthode valueAt
de ShrinkVerticallyByTwoTransformer
.
De manière générale, pour appliquer une transformation donnée à une image en écrivant une classe transformatrice, 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.
Etant donnée cette constation et les formules de rotation d'un point dans le plan, il est facile de définir la classe RotationTransformer
, en prenant bien garde à inverser le sens de la rotation. La méthode valueAt
se définit alors ainsi :
public ColorRGB valueAt(double x, double y) { double rX = x * Math.cos(-angle) - y * Math.sin(-angle); double rY = x * Math.sin(-angle) + y * Math.cos(-angle); return imageToRotate.valueAt(rX, rY); }
Bien entendu, l'inversion de signe pourrait également se faire dans le constructeur afin d'éviter de la refaire pour chaque point.
Exercice 3
La transformation d'une image discrète en image continue peut se séparer en deux sous-problèmes, qui sont :
- la transformation du système de coordonnées du plan en celui de l'image discrète, et
- la transformation de l'image discrète en image continue, par une forme d'interpolation.
Ces deux sous-problèmes sont résolus l'un après l'autre ci-dessous.
Transformation des coordonnées
Nos images continues sont dans le système de coordonnées du plan, et la classe ImageViewer
affiche une portion de ce plan. La version de ImageViewer
que nous vous fournissons affiche ainsi toujours une zone centrée à l'origine et allant de -2 à +2 pour la coordonnée \(x\). La plage de la coordonnée \(y\) dépend de la forme de la fenêtre.
Les images discrètes de Java, c-à-d les instances de la classe BufferedImage
, utilisent quant à elles un système de coordonnées basé sur les pixels dont l'origine se trouve dans le coin haut-gauche de l'image et dont l'axe des ordonnées pointe vers le bas.
La méthode valueAt
de l'adaptateur d'image discrète reçoit un point dans le système de coordonnées du plan et doit le transformer dans le système de coordonnées de l'image afin de connaître la position du pixel dont la couleur doit être retournée.
La figure ci-dessous illustre les deux systèmes de coordonnées superposés. Le système de coordonnées du plan est en noir, celui de l'image en rouge, et w et h désignent respectivement la largeur et la hauteur de l'image. Comme dit dans l'énoncé, dans le plan, l'image doit avoir une largeur de 2 unités et être centrée à l'origine.
Figure 2 : Le système de coordonnées du plan (noir) et de l'image (rouge)
En examinant cette image, on constate que le passage du système de coordonnées du plan (celui de valueAt
) à celui de l'image (celui de getRGB
) peut se faire en trois étapes :
- Une dilatation verticale d'un facteur -1, pour inverser la coordonnée \(y\).
- Une dilatation pour que l'image ait une largeur de 2 unités dans le plan.
- Une translation pour que l'image soit centrée à l'origine.
L'ordre dans lequel les opérations ci-dessus sont effectuées peut bien entendu être changé, mais cela requiert une adaptation des paramètres. Par exemple, la translation à effectuer n'est pas la même avant ou après les dilatations.
Transformation en image continue
La transformation ci-dessus produit les coordonnées d'un point dans le système de coordonnées de l'image. A supposer que ce point se trouve dans la zone effectivement couverte par l'image, un problème reste à résoudre : les coordonnées sont des valeurs réelles, alors que seules des valeurs entières peuvent être passées à getRGB
, étant donnée la nature discrète de l'image. En effet, la couleur de l'image n'est connue qu'en un petit nombre fini de points.
Il faut donc trouver une manière de convertir ces coordonnées réelles en coordonnées entières. La manière la plus simple, proposée dans l'énoncé et appelée voisin le plus proche, consiste à les arrondir. Un appel à Math.round
fait l'affaire.
Mise en œuvre en Java
En combinant la transformation de systèmes de coordonnées et la conversion des coordonnées réelles en coordonnées discrètes, on obtient le code ci-dessous pour la méthode valueAt
de la classe DiscreteImageAdapter
:
public ColorRGB valueAt(double x, double y) { double s = w / 2.0; int iX = (int)Math.round( x * s + w / 2.0); int iY = (int)Math.round(-y * s + h / 2.0); if (0 <= iX && iX < w && 0 <= iY && iY < h) return new ColorRGB(discreteImage.getRGB(iX, iY)); else return backgroundColor; }