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; }; }
où 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 :
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 :
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.
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.
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.
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 :
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.
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.
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.
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.