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 :

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

  1. utiliser la méthode apply de l'arrière-plan (bg ci-dessous) pour obtenir sa couleur,
  2. utiliser la méthode apply de l'avant-plan (fg ci-dessous) pour obtenir sa couleur,
  3. utiliser la méthode apply du masque pour savoir en quelles proportions mélanger les couleurs,
  4. 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));
  }
}