Série 5 – 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 :

Sorry, your browser does not support SVG.

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 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}{w}\right\rfloor + \left\lfloor\frac{y}{w}\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.

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 chessboard :

public static ImageRGB chessboard(ColorRGB c1,
                                  ColorRGB c2,
                                  double w) {
    if (! (w > 0))
        throw new IllegalArgumentException();

    return (x, y) -> {
        double sqX = Math.floor(x / w);
        double sqY = Math.floor(y / w);
        return ((int)(sqX + sqY)) % 2 == 0 ? c1 : c2;
    };
}

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 \(w\) 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}}{w}\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 target :

public static ImageRGB target(ColorRGB c1,
                              ColorRGB c2,
                              double w) {
    if (! (w > 0))
        throw new IllegalArgumentException();

    return (x, y) -> {
        double r = Math.sqrt(x * x + y * y);
        return (int)Math.floor(r / w) % 2 == 0 ? c1 : c2;
    };
}

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 :

Sorry, your browser does not support SVG.

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 linearHorizontalGradient :

public static ImageRGB linearHorizontalGradient(ColorRGB cL,
                                                double xL,
                                                ColorRGB cR,
                                                double xR) {
    if (! (xR > xL))
        throw new IllegalArgumentException();

    return (x, y) -> {
        if (x <= xL)
            return cL;
        else if (x >= xR)
            return cR;
        else {
            double p = (x - xL) / (xR - xL);
            return cL.mixWith(cR, p);
        }
    };
}

Exercice 2

Avant de définir la méthode rotated, il convient de comprendre pourquoi, dans la méthode flattened, 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 fonction anonyme retournée par flattened. Les coordonnées passées à cette fonction 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 valueAt 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) = (x', 2y') \] Ce qui correspond à la fonction anonyme retournée par flattened.

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.

Etant donnée cette constation et les formules de rotation d'un point dans le plan, il est facile de définir la méthode rotated, en prenant bien garde à inverser le sens de la rotation. On obtient alors :

public default ImageRGB rotated(double a) {
    double cosInvA = Math.cos(-a);
    double sinInvA = Math.sin(-a);
    return (x, y) -> {
        double rX = x * cosInvA - y * sinInvA;
        double rY = x * sinInvA + y * cosInvA;
        return valueAt(rX, rY);
    };
}

Exercice 3

La transformation d'une image discrète en image continue peut se séparer en deux sous-problèmes, qui sont :

  1. la transformation du système de coordonnées du plan en celui de l'image discrète, et
  2. 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 en haut à gauche de l'image et dont l'axe des ordonnées pointe vers le bas.

La fonction anonyme retournée par la méthode ofDiscreteImage 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.

Sorry, your browser does not support SVG.

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 :

  1. Une dilatation verticale d'un facteur –1, pour inverser la coordonnée \(y\).
  2. Une dilatation pour que l'image ait une largeur de 2 unités dans le plan.
  3. 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 ofDiscreteImage :

public static ImageRGB ofDiscreteImage(ColorRGB bg,
                                       BufferedImage i) {
    double w = i.getWidth();
    double h = i.getHeight();
    double s = w / 2d;

    return (x, y) -> {
        int iX = (int)Math.round( x * s + w / 2d);
        int iY = (int)Math.round(-y * s + h / 2d);

        if (0 <= iX && iX < w && 0 <= iY && iY < h)
            return new ColorRGB(i.getRGB(iX, iY));
        else
            return bg;
    };
}