Série 1 – Images continues

Introduction

Comme son titre l'indique, cette série a pour thème les images, mais représentées d'une manière inhabituelle en informatique. En effet, les images numériques sont généralement représentées sous la forme d'un tableau bidimensionnel de points, appelés pixels (pour picture elements), ayant chacun une couleur. Nous qualifierons cette représentation classique des images de discrète et finie puisque la couleur de l'image n'est connue qu'en un nombre fini de points.

La représentation que nous utiliserons ici est quant à elle continue et infinie. La mémoire d'un ordinateur étant finie, il n'est bien entendu pas possible de représenter directement une telle image par l'ensemble (infini) de ses pixels. Au lieu de cela, on utilise la technique habituellement utilisée en mathématiques, c-à-d qu'on représente une infinité de valeurs au moyen d'une fonction, dont la représentation est, elle, finie.

Par exemple, la fonction mathématique \(f(x) = x^2\), dont la représentation est finie, est composée d'une infinité de paires de valeurs \((x, f(x))\), parmi lesquelles figurent p.ex. (0, 0), (1/2, 1/4) ou encore (12.3, 151.29).

De même, une image peut être vue comme une fonction de deux arguments, les coordonnées x et y d'un point du plan, qui produit une couleur. Par exemple, la fonction suivante est une image d'un disque rouge sur fond blanc, centré à l'origine et de rayon 1 : \[ f(x, y) = \begin{cases} \color{red}\blacksquare \text{ (rouge)} & \text{si } \sqrt{x^2 + y^2} \le 1\\ \color{white}\blacksquare \text{ (blanc)} & \text{sinon} \end{cases} \]

On peut traduire cette idée en Java en définissant une interface pour le concept d'image continue. Cette interface ne contient qu'une seule méthode abstraite et constitue donc une interface fonctionnelle. La méthode abstraite est celle définissant l'image, c-à-d associant une couleur à chaque point du plan :

@FunctionalInterface
public interface ImageRGB {
  public ColorRGB apply(double x, double y);
}

Le type ColorRGB est celui d'une classe que nous vous fournissons et qui modélise une couleur par le triplet de ses composantes rouges, vertes et bleues (abrégé RGB pour red, green et blue), chacune comprises entre 0 et 1. La valeur 0 représente l'absence d'une composante, tandis que la valeur 1 représente la « présence maximale » de cette composante.

Notez qu'il aurait aussi été possible de définir l'interface ImageRGB comme une spécialisation de BiFunction, en la faisant étendre l'interface BiFunction<Double,Double,ColorRGB>. Comme cela n'apporte presque aucun avantage, mais a un impact sur les performances en raison de l'utilisation d'objets de type Double au lieu de valeurs de type double, nous ne l'avons pas fait ici.

Une fois l'interface ImageRGB définie, il est très simple de traduire la fonction de l'image d'un cercle rouge sur fond blanc donnée plus haut. On peut par exemple en faire un attribut statique de l'interface ImageRGB, nommé RED_CIRCLE et défini ainsi :

@FunctionalInterface
public interface ImageRGB {
  public ColorRGB apply(double x, double y);

  public static final ImageRGB RED_CIRCLE = (x, y) -> {
    double r = Math.sqrt(x * x + y * y);
    return r <= 1.0 ? ColorRGB.RED : ColorRGB.WHITE;
  };
}

ColorRGB.RED et ColorRGB.WHITE sont simplement des constantes prédéfinies pour les couleurs (1, 0, 0) et (1, 1, 1), respectivement.

L'utilisation d'une lambda pour définir cette image nous permet d'obtenir une notation très proche de la notation mathématique présentée plus haut.

En affichant cette image à l'écran au moyen de l'infrastructure que nous vous fournissons, on obtient bien ce à quoi on s'attend :

redwhiteunitcircle.png

Figure 1 : Le cercle rouge unitaire sur fond blanc

Notez que l'on ne représente ci-dessus qu'une portion de l'image complète, puisqu'elle est infinie. Cette portion est comprise entre –2 et +2 pour la coordonnée x, et entre approximativement –1.4 et +1.4 pour la coordonnée y. Le repère utilisé est donc celui illustré ci-dessous :

Sorry, your browser does not support SVG.

Figure 2 : Repère des images continues

Exercice 1

Créez un nouveau projet Eclipse pour cette série, puis téléchargez l'archive Zip que nous vous fournissons et importez-la dans votre projet, en vous référant au besoin aux instructions que nous vous fournissons. Cette archive contient la définition de l'interface ImageRGB, la classe ColorRGB et un programme principal ImageViewer qui affiche une image à l'écran. En l'état, l'image qu'il affiche est celle du cercle ci-dessus.

Prenez un moment pour vous familiariser avec la classe ColorRGB. Jetez aussi vite un œil à la classe ImageViewer afin de trouver l'endroit, signalé par un commentaire, où l'image à afficher est créée. Vous devrez modifier l'expression de création d'image tout au long de cette série.

Vous pouvez également, si vous le désirez, lire en détail le code des classes ImageViewer et ImageComponent, mais d'une part cela n'est pas nécessaire pour cette série, et d'autre part il utilise des concepts que nous n'avons pas encore vus au cours.

Une fois cela fait, et en vous inspirant de la définition de RED_CIRCLE, ajoutez des méthodes statiques à l'interface ImageRGB pour représenter chacun des trois types d'images ci-dessous.

Echiquier

La première image à définir, sous forme d'une méthode statique de l'interface ImageRGB nommée p.ex. chessboard, représente un échiquier à cellules carrées. La taille des cellules et leurs deux couleurs doivent être des paramètres de la méthode, une taille négative ou nulle provoquant la levée de l'exception IllegalArgumentException.

Conseil : la méthode floor de la classe java.lang.Math peut être utile.

chessboard.png

Figure 3 : Un échiquier noir et blanc aux cases d'une largeur de 0.5

Cible

La seconde méthode à écrire, nommée p.ex. target, décrit une cible formée de cercles concentriques. Là aussi, la largeur des bandes et les deux couleurs doivent être des paramètres de la méthode.

Conseil : la méthode floor de la classe java.lang.Math peut à nouveau être utile.

target.png

Figure 4 : Une cible rouge et bleue aux bandes d'une largeur de 0.5

Dégradé linéaire

La troisième méthode à écrire, nommée p.ex. linearHorizontalGradient, décrit un dégradé linéaire horizontal entre deux couleurs placées à deux coordonnées x distinctes. Les deux couleurs ainsi que les coordonnées x de l'une et de l'autre doivent être des paramètres de la méthode. L'exception IllegalArgumentException doit être levée si la première coordonnée x est supérieure ou égale à la seconde.

Conseil : la méthode mixWith de la classe ColorRGB fournie peut être utile ici.

gradient.png

Figure 5 : Un dégradé linéaire allant du vert (en –1) au bleu (en +1)

Exercice 2

Les méthodes du premier exercice sont ce qu'on pourrait appeler des méthodes sources, dans le sens où elles produisent une image en fonction de certains paramètres. Il est également possible de définir des méthodes dites intermédiaires, qu'on applique à une image donnée et qui en produisent une nouvelle.

Contrairement aux méthodes sources, qui sont des méthodes statiques, les méthodes intermédiaires sont des méthodes par défaut, car on les applique à une image existante pour en obtenir une nouvelle.

Par exemple, on peut définir une méthode par défaut nommée flattened qui aplatit l'image à laquelle on l'applique d'un facteur 2 :

public interface ImageRGB {
  public ColorRGB apply(double x, double y);

  // … autres méthodes

  public default ImageRGB flattened() {
    return (x, y) -> apply(x, y * 2d);
  }
}

Notez bien dans ce code que la composante y est multipliée par deux pour obtenir un rétrécissement de l'image finale. Assurez-vous de comprendre pourquoi avant de continuer !

En l'appliquant à l'image du cercle rouge unitaire, on obtient :

shrunk-redwhiteunitcircle.png

Figure 6 : Le cercle rouge unitaire aplatit

Le but de ce second exercice est de définir une méthode de rotation, nommée p.ex. rotated, qui applique une rotation autour de l'origine à l'image. L'angle de rotation est passé en radians, et un angle positif représente une rotation dans le sens contraire des aiguilles d'une montre.

Conseil : si vous ne vous souvenez plus des formules permettant d'effectuer une rotation dans le plan, consultez la page Wikipedia à ce sujet.

rotated-chessboard.png

Figure 7 : Un échiquier tourné de 30°

Exercice 3

Comme l'introduction le mentionne, notre notion d'image continue et infinie contraste avec la notion d'image discrète et finie utilisée habituellement en informatique. Il est toutefois possible de passer de l'une à l'autre assez facilement.

Ainsi, la classe ImageComponent que nous vous fournissons convertit les images infinies et continues qu'on lui demande d'afficher en images discrètes, car il est bien entendu impossible d'afficher une image infinie continue à l'écran.

Le but de ce troisième exercice est d'ajouter à ImageRGB une méthode statique, nommée p.ex. ofDiscreteImage, faisant le passage inverse. C'est-à-dire qu'elle doit prendre en argument une image discrète et une couleur de fond et produire une image infinie de type ImageRGB.

Pour tous les points situés en dehors des bornes de l'image discrète, l'image retournée par cette méthode doit produire la couleur de fond. Pour ceux situés dans les bornes de l'image discrète, elle doit produire la couleur du pixel le plus proche du point donné.

Pour faciliter les choses, l'image discrète doit être redimensionnée et translatée de manière à ce que son centre se trouve à l'origine, et qu'elle ait une largeur de 2 unités. Bien entendu, le redimensionnement doit préserver le rapport largeur/hauteur de l'image.

Pour représenter les images discrètes, utilisez la classe BufferedImage du paquetage java.awt.image. Sa méthode getRGB permet d'obtenir la couleur d'un pixel, dont les trois composantes sont « empaquetées » dans un entier. Cet entier peut directement être passé au second constructeur de notre classe ColorRGB pour obtenir la couleur correspondante.

Attention, le repère des images discrètes est différent de celui utilisé pour nos images continues, et il faut en tenir compte pour calculer les coordonnées à passer à la méthode getRGB. Dans le repère des images discrètes, le coin en haut à gauche a les coordonnées (0, 0), tandis que le coin en bas à droite a les coordonnées (w, h) où w est la largeur de l'image en pixels, h sa hauteur. Ces dimensions s'obtiennent au moyen des méthodes getWidth et getHeight de la classe BufferedImage. Ce repère est présenté dans la figure ci-dessous.

Sorry, your browser does not support SVG.

Figure 8 : Repère des images discrètes

Pour obtenir une image BufferedImage à partir d'un fichier sur disque ou présent sur le réseau, il convient d'utiliser la classe ImageIO du paquetage javax.imageio, et éventuellement la classe URL du paquetage java.net pour représenter les adresses URL. Par exemple, une image de Dubrovnik peut être obtenue depuis notre serveur et transformée en BufferedImage au moyen des deux lignes suivantes :

URL u = new URL("http://cs108.epfl.ch/e/images/dub.jpg");
BufferedImage discreteImage = ImageIO.read(u);

En appliquant votre méthode de transformation à cette image de Dubrovnik et une couleur de fond noir, vous devriez obtenir l'image ci-dessous.

dubrovnik-framed.png

Figure 9 : Une image discrète et finie convertie en image continue et infinie

A noter que la technique consistant à retourner le point le plus proche, nommée nearest neighbor en anglais, est loin d'être idéale et produit des images de relativement mauvaise qualité dès que l'on effectue, par exemple, une rotation ou un redimensionnement. Il existe d'autres méthodes qui produisent de meilleurs résultats, comme l'interpolation bilinéaire, que les personnes qui auraient du temps à disposition pourront essayer de mettre en œuvre.