Dans cette étude, nous allons nous intéresser plus particulièrement au traitements des images. Cette étude fait suite au graphismes 2D traités dans le cours précédent. En effet, afin de tirer le meilleur parti et bien exploiter les résultats voulus, nous devons bien maîtriser les graphisme 2D. Je vous invite donc à revoir cette étude dans le cas où vous ne connaîtriez bien pas cette technique.
Dans l'étude sur le graphisme 2D, vous avez vu comment créer des images simples en traçant des lignes et des formes. Des images complexes, telles que des photographies, sont habituellement générées en externe, par exemple avec un scanner ou un logiciel dédié à la manipulation d'images. Il est également possible de produire une image pixel par pixel, et de stocker le résultat dans un tableau. Nous allons aborder tout ces aspects ici.
Afin de bien maîtriser le sujet, nous allons aborder successivement :
Depuis la JDK 1.4, nous disposons du paquetage javax.imageio qui contient la prise en charge immédiate (on dit aussi synchrone) de la lecture et de l'écriture de plusieurs format de fichiers communs, ainsi que le cadre autorisant des tierces parties à ajouter des outils de lecture et d'écriture pour d'autres formats. Plus spécifiquement, le JDK contient des outils de lecture pour les formats GIF, JPEG et PNG et des outils d'écriture pour les formats JPEG et PNG. Ce paquetage sera plutôt utilisé dans le cas où nous construisons une application fenêtrée.
Pour les applets, la démarche est différente puisque l'image doit être téléchargée depuis un serveur Web, ce qui peut prendre, nous nous en doutons, un peu plus de temps. Nous sommes donc obligés de prévoir plutôt une lecture asynchrone. L'applet dispose déjà de méthodes adaptées à cette situation.
Les bases de la bibliothèque sont extrêmement simples. Pour charger une image en mémoire depuis un dispositif externe, comme le disque dur par exemple, vous utiliserez la méthode statique read() de la classe ImageIO.
File fichier = new File("fichier image.jpg");
BufferedImage image = ImageIO.read(fichier); // ou Image image = ImageIO.read(fichier);
...
surface.drawImage(image, 50, 150, null));
Cette image est ensuite représentée en mémoire au travers d'un objet de la classe BufferedImage. C'est d'ailleurs le type d'objet que renvoie la méthode read(). Cette classe BufferedImage hérite elle-même de la classe abstraite Image qui dispose des méthodes minimales correspondant au canevas nécessaire à l'affichage de l'image. La classe BufferedImage dispose de beaucoup plus de méthodes et attributs que la classe Image afin de permettre de nombreux traitements sur les images.
Attention : La lecture du fichier est ici synchrone, c'est-à-dire que la méthode read() attend que l'image soit entièrement chargée avant de retourner l'objet résultant. Cette méthode est donc blocante.
La classe ImageIO choisit un lecteur approprié, en fonction du type de fichier. Dans ce but, elle peut consulter l'extension de fichier et le "numéro magique" situé au début du fichier. Lorsqu'un lecteur adapté ne peut être trouvé ou si le lecteur ne parvient pas à décoder le contenu du fichier, la méthode read() renvoie null.
Par ailleurs, la méthode read() de la classe ImageIO peut également récupérer une image à partir d'une URL :
Image image = ImageIO.read(new URL("http://site/répertoire/fichier image.gif"));
surface.drawImage(image, 50, 150, null));
L'écriture d'une image est tout aussi simple :
File fichier = new File("fichier image.jpg");
String format ="JPEG";
BufferedImage image;
...
ImageIO.write(image, format, fichier);
Ici, la chaîne format identifie le format de l'image, notamment "JPEG" ou "PNG". La classe ImageIO choisit l'outil approprié et enregistre le fichier.
Cette fois-ci, la technique est plus délicate et donc plus complexe. Quand vous écrivez une image, vous pouvez configurer différents paramètres metadata décriture, tel que le taux de compression. Cependant, vous ne pouvez le faire directement avec la méthode write() de ImageIO. A la place, vous devez utiliser d'autres classes (et paquetages) d'ImageIO.
Il est possible d'avoir plus d'un fournisseur de lecture et d'écriture pour un format spécifique. De ce fait, les méthodes telles que getImageWritersByFormatName() de la classe ImageIO renvoit un Iterator. Pour personnaliser les taux de compression en sortie, vous pouvez examiner tous les fournisseurs intallés et trouvez le niveau maximum de compression supporté. Ou vous pouvez simplement utiliser le premier. Voyons ici l'approche la plus simple :
Iterator iter = ImageIO.getImageWriterByFormatName("JPEG"); if (iter.hasNext()) {
ImageWriter = (ImageWriter) iter.next(); ... }
Vous pouvez récupérer les paramètres d'écritures par défaut pour un ImageWriter spécifique à travers sa méthode getDefaultWriteParam(). La méthode retourne un objet de type ImageWriteParam. Pour les JPEG, c'est une instance de javax.imageio.jpeg.JPEGImageWriteParam (bien que vous n'ayez pas besoin de le savoir). Pour chnager le niveau de compression, vous devez appeler l'objet ImageWriteParam dont vous voulez spécifier explicitement le taux de compression :
iwp.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
Ensuite, vous spécifiez la qualité de compression avec :
iwp.setCompressionQuality(float);
Au lieu de sélectionner une valeur au hasard, vous pouvez demander au writer quelles valeurs de qualité de compression il supporte (ou comment il comprimera l'image) :
float[] valeurs = iwp.getCompressionQualityValues();
Voici une méthode qui synthétise toute la démarche que nous venons de découvrir
void écrireFichier(BufferedImage image) {
try {
ImageWriter writer = ImageIO.getImageWritersByFormatName("JPEG").next();
ImageWriteParam iwp = writer.getDefaultWriteParam();
iwp.setCompressionMode(iwp.MODE_EXPLICIT);
float[] valeurs = iwp.getCompressionQualityValues();
iwp.setCompressionQuality(valeurs[valeurs.length-1]);
FileImageOutputStream fichier = new FileImageOutputStream(new File("nom du fichier"));
writer.setOutput(fichier);
IIOImage imageFlux = new IIOImage(image, null, null);
writer.write(null, imageFlux, iwp);
}
catch (IOException ex) {
état.setText("Problème d'enregistrement");
}
}
Ici, le taux de compression est le plus fort. Notez que la méthode écrit directement avec l'objet ImageWriter et non avec l'objet ImageIO. Comme les paramètres de sortie sont personnalisés, vous devez utiliser un objet de type IIOImage (IIO signifiant ImageIO) représentant l'image à écrire. Vous ne pouvez utiliser un BufferedImage complet. Le BufferedImage peut être directement converti en IIOImage comme étape supplémentaire.
Cette fois-ci, l'image doit se trouver dans l'ordinateur où se trouve le serveur Web. En effet, une applet n'a pas le droit de dialoguer et de récupérer des ressources avec un autre ordinateur que ce dernier. Pour des raisons de sécurité évidente, surtout d'ailleurs à cause d'Internet, l'applet n'a aucune autorisation particulière pour récupérer des ressources sur le poste client dans lequel elle a été téléchargée. Généralement, l'image doit être récupérée depuis l'application Web où se trouve l'applet. Nous imaginons bien que du coup, le temps de chargement d'une image dans une applet sera beaucoup plus long que lors d'une application fenêtrée.
La classe ImageIO ne correspond pas à ce cas de figure. En effet, cette classe propose des méthodes read() qui sont synchrones, c'est-à-dire que l'exécution du programme est interrompu tant que la totalité de l'image n'est pas entièrement récupérée. Le temps de téléchargement de l'image pouvant être très long, l'applet serait inutilement bloquée, et donc inutilisable durant tout le temps du transfert de l'image.
Heureusement, l'applet dispose déjà de la méthode intégrée getImage(). Le comportement de cette méthode est opposée à celle de la méthode read(). En effet, getImage() propose un fonctionnement asynchrone.
Voici le format de la méthode getImage() :
getImage(URL, "fichier image.jpg");
Voici un exemple :
Image image = this.getImage(this.getDocumentBase(), "chouette.jpg");
... surface.drawImage(image, 0, 0, this);
...
// this indique que l'applet elle-même sert de spectateur. L'image s'affiche dès qu'elle est entièrement chargée.
La méthode getDocumentBase() permet de renvoyer l'adresse URL complète de l'application Web où se situe la page Web contenant l'applet. Dans cet exemple, je suppose que l'image "chouette.jpg" se trouve dans le même répertoire que la page Web.
Les images sont traitées un peu différemment des formes. En particulier, nous ne faisons pas appel au Paint courant pour dessiner une image car celle-ci contient ses propres informations de couleurs.
Comme pour le texte, la classe Graphics2D dispose de la méthode spécifique drawImage() qui permet de placer l'image à l'emplacement indiqué. Cette méthode attend toutefois en premier argument l'objet de type Image (ou ses decendant comme BufferedImage) qui représente l'image réelle récupérée à partir du disque dur. Nous avons déjà découvert la classe ImageIO qui permet de récupérer un tel fichier.
Image image = ImageIO.read(new File("fichier image.gif"));
surface.drawImage(image, 50, 150, null));
La variable image contient alors une référence à un objet qui encapsule les données images. Vous pouvez afficher l'image grâce à la méthode drawImage() de la classe Graphics.
surface.drawImage(image, positionX, positionY, spectateur));
La classe java.awt.Image représente une vue d'une image. La vue est créée à partir d'une image source fournissant des données sous la forme de pixels. Les images peuvent provenir d'une source statique, comme des fichiers GIF, JPEG, PNG ou dynamique comme un stream d'animation ou un moteur graphique. La classe Image de Java 2 gère également l'animation GIF89a (gifs animés), de sorte qu'il est aussi facile de travailler avec des animations simples qu'avec des images statiques.
Voici un exemple où nous affichons une image à partir du coin supérieur gauche :
package dessin; import java.awt.*; import java.awt.image.*; import java.io.*; import javax.imageio.*; import javax.swing.*; public class Fenêtre extends JFrame { public Fenêtre() throws IOException { this.setDefaultCloseOperation(this.EXIT_ON_CLOSE); this.setSize(358, 260); this.setTitle("Voir image"); this.getContentPane().setBackground(Color.ORANGE); this.getContentPane().add(new Zone()); } public static void main(String[] args) throws IOException { new Fenêtre().setVisible(true); } } class Zone extends JComponent { private BufferedImage image; public Zone() throws IOException { image = ImageIO.read(new File("chouette.jpg")); } public Zone(BufferedImage image) { this.image = image; } protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, null); } }
Une autre version de la méthode drawImage() permet de changer l'échelle d'une image. Cette version utilise deux arguments supplémentaires pour désigner la largeur et la hauteur voulues quelle que soit les dimensions originales de l'image. Cette méthode permet donc de réaliser un reéchantillonage :
surface.drawImage(image, positionX, positionY, largeur, hauteur, spectateur));
Voici un exemple qui adapte la taille de l'image à la dimension de la fenêtre (218x160) tout en respectant le ratio de l'image :
class Zone extends JComponent { private BufferedImage image; private double ratio; public Zone() throws IOException { image = ImageIO.read(new File("chouette.jpg")); ratio = (double)image.getWidth()/image.getHeight(); } public Zone(BufferedImage image) { this.image = image; ratio = (double)image.getWidth()/image.getHeight(); } protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, this.getWidth(), (int)(this.getWidth()/ratio), null); } }
En réalité, il existe 6 versions de la méthode drawImage() :
Jusqu'à présent, dans le tracé, nous n'avons pas utilisé de spectateur puisque les images étaient entièrement récupérée avant d'être affichée. Ce type de traitement est appelé, nous l'avons déjà vu, traitement synchrone. Dans ce cas là, nous plaçons la valeur null dans le dernier paramètre de la méthode drawImage().
Avec les applets toutefois, les images sont traitées de façon asynchrone, ce qui signifie que Java effectue les opérations telles que les chargement et le dimensionnement des images de son côté (permettant au code principal de continuer). Dans une application cliente classique ceci n'est pas très important ; les images sont en général petites, pour les boutons par exemple, et le plus souvent assemblées avec l'application pour un affichage instantané. Toutefois, comme vous le constaterez dans les API de manipulation de données d'images, Java a été conçu pour manipuler des images à la fois localement et sur le Web.
En fait, s'il s'agit d'une nouvelle image, Java n'essaiera même pas de la transférer tant qu'elle ne sera pas nécessaire. L'avantage de cette technique, c'est que Java peut effectuer le traitement des images dans un environnement puissant et multi-thread. Toutefois, cela pose certains problèmes.
Lorsque Java récupère une image, comment savoir si elle est entièrement chargée ? Que se passe-t-il si nous avons besoin de connaître les propriétés de l'image (comme ses dimensions) avant de pouvoir commencer à travailler avec ? Que se passe-t-il si une erreur se produit lors du chargement de l'image ?
Ces problèmes sont traités par les spectateurs, des objets qui implémentent l'interface ImageObserver. Toutes les opérations qui dessinent ou traitent des objets Image reviennent immédiatement, mais prennent un objet spectateur en paramètre. Les objets ImageObserver surveillent l'état de l'image et mettent ces informations à la disposition du reste de l'application. Lorsque les données de l'image sont chargées, un spectateur est informé de la progression. Il est averti de la disponibilté de nouveaux pixels, de l'achèvement d'une zone de l'image et de la survenue d'une erreur en cours de chargement. Le spectateur reçoit dès que possible certains attributs sur l'image tels que la dimensions et d'autres propriétés.
La méthode drawImage(), comme d'autres opérations sur les images (getWidth() et getHeight() de Image notamment), prend une référence sur un objet ImageObserver en paramètre. Cette méthode renvoie une valeur booléenne qui indique si l'image a été (ou pas) affichée entièrement. Si l'image n'a pas encore été téléchargée ou si elle n'est qu'en partie disponible, la méthode drawImage() ne dessine qu'une fraction de l'image et se termine.
En arrière-plan, le système graphique commence (ou continue) à charger les données de l'image. L'objet spectateur est enregistré comme étant intéressé par les informations concernant cette image. Il est donc appelé de façon répétée (périodiquement) lorsque les données supplémentaires sont reçues et lorsque l'image entière est arrivée.
Le spectateur peut faire ce qu'il veut de ces informations. Le plus souvent, il appelle la méthode repaint() pour ordonner au composant graphique contenant l'image de dessiner la zone de l'image fraîchement arrivée.
N'oubliez pas que la méthode repaint() entraîne un appel à la méthode paintComponent(), là où nous plaçons notre code spécifique correspondant à notre affichage personnalisé. De cette manière, le composant graphique peut redessiner l'image à mesure qu'elle arrive ou attendre son chargement complet.
Il existe des spectateurs préfabriqués. Il se trouve précisément dans tous les composants graphiques, comme JComponent. En effet, JComponent implémente l'interface ImageObserver et fournit une fonction de rafraîchissement simple. Cela signifie que tout composant graphique peut être utilisé comme son propre spectateur : nous ne faisons, dans ce cas là, que faire passer une référence à notre propre objet graphique à l'aide de this.
Voici ce que nous pouvons écrire dans le cas d'une applet :

| index.html |
|---|
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> <html> <head> <title></title> </head> <body bgcolor="black" text="yellow"> <h2 align="center">C'est chouette...</h2><hr /> <p align="center"> <applet code="dessin.AppletImage.class" width="356" height="240"/> <P> </body> </html> |
| AppletImage.java |
|---|
package dessin; import java.awt.*; import javax.swing.*; public class AppletImage extends javax.swing.JApplet { public void init() { ZoneImage zone = new ZoneImage(this.getImage(this.getDocumentBase(), "chouette.jpg")); this.getContentPane().add(zone); } } class ZoneImage extends JComponent { private Image image; ZoneImage(Image image) { this.image = image; } protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, this); } } |
Dans ce cas là, notre composant ZoneImage est utilisé comme spectateur et appele la méthode repaint() pour redessiner l'image si nécessaire. Si l'image arrive lentement, le composant ZoneImage est averti régulièrement à l'arrivée de nouvelles données. L'image est donc affichée progressivement.
Les propriétés awt.image.incrementaldraw et awt.image.redrawrate contrôle ce comportement. redrawate limite le nombre d'appels à la méthode repaint() : la valeur par défaut est une fois toutes les 100 millisecondes. incrementaldraw est la valeur par défaut. Elle est initialisée à true. Il suffit de la positionner à false pour différer le tracé de l'image.
Plutôt que de travailler avec l'image originale et de l'afficher directement sur le panneau graphique, il peut être judicieux de retailler l'image initiale afin qu'elle corresponde parfaitement à la taille de la fenêtre, surtout si cette image est très grande. Il suffit pour cela de faire une copie de l'image originale au travers de la méthode getScaledInstance() de la classe abstraite Image. Comme cette méthode fait partie de la classe Image, par héritage, la classe BufferedImage en bénéficie.
Image image = ImageIO.read(new File("fichier image.gif"));
Image imageRetaillée = image.getScaledInstance(300, 250, Image.SCALE_AREA_AVERAGING);
...
surface.drawImage(imageRetaillée, 0, 0, null));
Cette méthode adapte l'image d'origine à la taille indiquée ; ici 300 x 250 pixels. Elle renvoie un nouvel objet Image que nous pouvons dessiner comme tout autre image. SCALE_AREA_AVERAGING est une constante de Image indiquant à getScaledInstance() l'algorithme de mise à l'échelle à utiliser. L'algorithme utilisé ici essaie d'obtenir un bon résultat, au détriment de la vitesse. Les autres solutions plus rapides sont SCALE_REPLICATE, qui procède en dupliquant de lignes et des colonnes de balayage (ce qui est plus rapide). Nous pouvons également préciser SCALE_FAST ou SCALE_SMOOTH et laisser l'implémentation choisir un algorithme approprié optimisant la durée ou la qualité.
Mettre une image à l'échelle avant même d'appeler drawImage() peut vraiment améliorer les performances car, dans ce cas, le chargement et le dimensionnement ne sont fait qu'une seule fois. Sinon, les appels répétés à drawImage() provoquent à chaque fois un redimensionnement de l'image, consommateur de ressources.
A titre d'exemple, nous allons reprendre l'exercice qui adapte la taille de l'image à la dimension de la fenêtre (218x160) tout en respectant le ratio de l'image. Mais cette fois-ci, l'image est retaillée :
class Zone extends JComponent { private Image image; private final int largeur = 218-8; public Zone() throws IOException { BufferedImage image = ImageIO.read(new File("chouette.jpg")); double ratio = (double)image.getWidth()/image.getHeight(); this.image = image.getScaledInstance(largeur, (int)(largeur/ratio), Image.SCALE_AREA_AVERAGING); } protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, null); } }
La couleur est un phénomène que fait à la fois intervenir la physique de la matière, notamment les ondes électromagnétiques avec le smatériaux physiques, et l'interprétation de ce phénomène physique par le système visuel constitué notamment de l'oeil et du cerveau.
Nous connaissons le spectre de la lumière blanche mis en évidence par Isaac Newton en 1666 lors de la diffraction de la lumière blanche par un prisme :

Spectre des couleurs.
Ce sont également les couleurs présentes dans l'arc-en-ciel, phénomène résultant de la diffraction de la lumière du soleil dans les gouttelette d'eau.
Une lumière contient une part de lumière achromatique et une part de lumière chromatique. Une lumière est dite achromatique lorsqu'elle contient toutes les longueurs d'onde de façon approximativement égales, ce qui donne du coup le blanc, qui dans ce cas là peut être considéré comme une absence de couleur.
La teinte est le nom de la couleur (violet, rouge, indigo... ), c'est-à-dire de la longueur d'onde dominante. C'est une grandeur qui est repérable : nous pouvons déterminer aisément la longueur d'onde dominante et donner un nom en fonction du spectre vu ci-dessus. Par contre, cette grandeur est non mesurable et non additive : en effet, nous ne pouvons pas déterminer la couleur résultant d'une addition de deux autres couleurs.
La saturation ou indice de pureté représente l'inverse du degré de dilution de la couleur dans la lumière blanche.
La luminosité est l'intensité de la lumière achromatique (sans couleur). Elle est mesurable et additive.
Les lois de Grassman expriment la décomposition d'une lumière colorée en une lumière saturée (chromatique pure) et une lumière blanche (achromatique pure).
La relation fondamentale exprime la décomposition de la lumèire L en une composante chromatique Lc et une composante achromatique Lw (w pour White, blanc en anglais) et s'exprime par :
L = Lc + Lw
Si la composante est Lw nulle, la lumière est complètement saturée. Si la composante Lc est nulle, la lumière est achromatique (blanche). Sinon, dans les autres cas de figure, la lumière est diluée dans le blanc.
Nous définissons par couleur complémentaire une couleur dont l'ajout à une autre couleur donne une lumière blanche :
L(vert) + L(pourpre) = Lw.
Une représentation de la couleur nécessite forcément une discrétisation. Il existe plusieurs systèmes de représentation dans un espace à trois dimensions, soit en fonction de grandeurs physiques telles que la teinte, la saturation et la luminosité, soit en fonction de couleurs (RGB, CMY, etc.).
L'espace des couleurs primaires RGB (Red Green Blue), également appelé RVB en français (Rouge Vert Bleu), est calqué sur notre perception visuelle. Il utilise trois couleurs de base : le rouge (d=700nm), le vert (d=546nm) et le bleu (d=435,8nm).

L'espace des couleurs secondaires CMY (Cyan Magenta Yellow) est basé sur trois couleurs : le jaune, le cyan et le magenta. Ces trois couleurs sont les complémentaires des couleurs primaires.
Les couelurs du système additif sont les couelurs primaires : rouge, vert et bleu. Nous obtenons les autres couelurs par addition de ces couleurs. C'est ce qui se produit par exemple lors de la projection : trois faisceaux rouge, vert et bleu en proportions identiques conduisent à une lumière blanche.

Si l'on projette un faisceau rouge et un faisceau bleu, nous obtenons une lumière magenta ; un faisceau rouge et vert, une lumière jaune ; un faisceau vert et bleu, une lumière cyan.
Nous obtenons ainsi les couleurs secondaires par ajout des couleurs primaires, deux à deux. Vu autrement, l'ajout de vert et de magenta donne du blanc : ce sont donc des couleurs complémentaires, de même que le bleu et le jaune ainsi que le rouge et le cyan. Les couleurs primaires et secondaires sont donc complémentaires.
La synthèse soustractive se produit en imprimerie. C'est pourquoi les imprimeurs utilisent les composantes CMY. Si on soustrait la lumière magenta (par exemple avec un filtre), on obtient de la lumière verte. Si on soustrait la lumière cyan, on obtient de la lumière rouge et si on soustrait la lumière jaune, on obtient de la lumière bleue.

Si on soustrait à la fois de la lumière magenta, cyan et jaune (par exemple en superposant trois filtres), on n'obtient plus de lumière, donc du noir.
En imprimerie, on utilise les composantes CMY auxquelles est ajoutée une composante noire notée généralement K (blacK) donnant lieu au système CMYK. En effet, dans de nombreux documents, il y a beaucoup de noir : texte, traits, etc. Si on veut générer du texte noir, il faut superposer une lettre jaune, une lettre cyan et une lettre magenta. D'une part, on va utiliser trois encres différentes, et ce, très fréquemment. D'autre part, on demande une grande précision aux lettres (détails fins) et un léger décalage des impressions des trois lettres serait très visible.
Ainsi, à la fois pour des raisons d'économie d'encre et de précision, il a été choisi d'ajouter une quatrième composante.
Les composantes chromatiques (r, g, b) d'une lumière (ou valeurs de chromaticité) sont les proportions dans lesquelles les couleurs primaires sont mélangées afin d'obtenir cette lumière, selon la synthèse additive. Une couleur C s'exprime selon la formule :
C = rR + gG + bB.
La courbe ci-dessous nous permet de déterminer les composantes chromatiques nécessaires à l'obtention d'une couleur de longueur d'onde choisie dans le domaine du visible.

Certaines longueurs d'onde vont poser problème car certaines composantes chromatiques vont être négatives : comment enlever une quantité non présente en peinture ? Un compromis consiste à ajouter suffisamment de blanc (donc un mélange de rouge, de vert et de bleu) afin de rendre toutes les composantes chromatiques positives. L'inconvéniant est que la couleur obtenue sera diluée : il est donc impossible d'exprimer toutes les couleurs saturées en composantes RGB.
En 1935, la CIE (Commission internationale de l'éclairage) a défini un nouveau triplet de couleurs permettant de représenter l'ensemble des couleurs avec des composantes positives. Ce sont les composantes X, Y et Z qui sont les couleurs théoriques répondant aux propriétés suivantes :
Ce système de couleurs est noté XYZ.

Le diagramme de chromaticité, c'est le plan d'équation x+y+z=1 de l'espace XYZ.
Il permet, pour une couleur C donnée, de déterminer sa longueur d'onde dominante (sa teinte) ainsi que son degré de dilution. Dans un diagramme de chromaticité, le blanc est situé au centre. Sur la périphérie se trouvent les couleurs saturées. Pour déterminer la teinte d'un point dans ce diagramme, il suffit de tracer la droite reliant le blanc à ce point. Si on prolonge la droite du côté du point blanc, on obtient la couleur complémentaire. Le degré de dilution est obtenu enfaisant le rapport de la distance entre le point et sa couleur saturée par la distance entre le blanc et la couleur saturée.
Conversion du système RGB vers XYZ et réciproquement - on utilise pour cela les équations suivantes :
x = 0.489989 r + 0.310008 g + 0.2 b
y = 0.26533 r + 0.81249 g + 0.01 b
z = 0.0 r + 0.01 g + 0.99 b
r = 2.3647 x - 0.89658 y - 0.468083 z
g = -0.515155x + 1.426409y - 0.088746z
b = -0.005203 x - 0.014407 y + 1.0092 z
La dénomination de cette représentation vient de la traduction anglaise de teinte, saturation, valeur (ou luminance) : Hue, Saturation,
Value.

Ce modéle est également appelé "modèle du peintre" en référence aux méthodes de peinture : le peintre commence par choisir une couleur (H) puis ajoute du blanc pour désaturer la couleur (S) et ajoute enfin du noir pour dévaleur la couleur (V).
Supposons que vous ayez une image et que vous souhaitiez améliorer son apparence. Vous devez alors avoir accès à chacun de ses pixels et les remplacer par d'autres pixels. Vous pouvez aussi choisir de calculer chaque pixel, par exemple pour afficher le résultat d'une mesure physique ou d'un calcul mathématique.
Jusqu'à présent, nous avons surtout étudié les objets de la classe java.awt.Image ainsi que la façon de les charger et les dessiner. Comment examiner l'intérieur de l'image et actualiser ses données ? En fait, Image ne permet pas d'accéder à ses données. Un objet de la classe Image n'est pas modifiable. D'ailleurs, je le rappelle, la classe Image est abstraite. Nous devons faire appel à une classe beaucoup plus sophistiquée, java.awt.image.BufferedImage.
Ces classes sont étroitement apparentées puisque BufferedImage est en réalité une classe fille de Image. Mais BufferedImage propose de nombreux contrôles sur les données de l'image. Puisqu'il s'agit d'une classe fille de Image, et grâce au polymorphisme, il est possible de passer un BufferedImage à l'une des méthodes de Graphics2D qui acceptent une image.
En réaliter, comme son l'indique, BufferedImage est une image tampon qui représente un tableau rectangulaire de pixels. Une image n'est donc qu'un rectangle de pixels de couleurs, concept assez simple. Toutefois, une grande complexité se cache dans la classe BufferedImage car il existe de nombreuses manières de représenter les couleurs des pixels.
Dans une image de données RGB, les valeurs rouge, vert ou bleu de chaque pixels peuvent être stockées dans des tableaux d'octets, ou chaque pixel peut être représenté par un entier contenant les valeurs rouge, vert ou bleu. Telle image en 16 niveaux de gris stockera 8 pixels dans chaque élément d'un tableau d'entiers. Il existe de nombreuses manières de stocker des données image, et BufferedImage est conçus pour les supporter toutes.
Une image tampon est donc un tableau rectangulaire de pixels qui correspond au canevas suivant :
Un BufferedImage est constituée de deux parties :
C'est ainsi qu'une image est affichée à l'écran. Le système graphique récupère les données de chaque pixel de l'image à partir du Raster. Puis le ColorModel indique ce que doit être la couleur de chaque pixel, et le système graphique est capable de la définir.
Le Raster (la trame) est lui-même composé de deux parties :
L'API 2D comporte diverses variantes de ColorModel, SampleModel et DataBuffer. Elles servent de parpaings de construction commodes pour la plupart des formats de stockage d'image courants.
Il existe plusieurs façon de représenter une couleur. Plusieurs codages des couleurs existent : les valeurs rouge, vert, bleu, (RGB) ; teinte, saturation, valeur (HSV, hue, saturation, value) ; teinte, luminosité, saturation (HLS) ; et d'autres encore. En outre, il est possible de fournir les informations de couleur entière pour chaque pixel, ou un simple indice dans une table de couleurs (palette) pour chaque pixel. La façon de représenter une couleur s'appelle un modèle de couleur. L'API 2D propose des outils de gestion pour tous les modèles de couleur imaginables. Nous n'étudierons ici que deux grandes familles de modèles de couleur : direct et indexé.
Pour générer les données d'un pixel, il est nécessaire de préciser un modèle de couleur ; la classe abstraite java.awt.image.ColorModel représente un modèle de couleur. Par défaut, Java 2D utilise un modèle de couleur directe baptisée ARGB. Le A signifie "alpha", nom traditionnellement utilisé pour transparence. RGB fait référence aux comopsantes rouge, vert et bleu, combinées pour produire une couleur composite unique.
Dans le modèle ARGB par défaut, chaque pixel est représenté par un entier sur 32 bits, décomposé en quatre champs de 8 bits : dans l'ordre, la transparence (Alpha) puis les composantes rouge, vert et bleu :

Pour créer une instance du modèle ARGB par défaut, appelez la méthode statique getRGBDefault() de ColorModel. Elle renvoie un objet DirectColorModel, classe fille de ColorModel.
Dans un modèle de couleur indexé, chaque pixel est représenté par le plus petit élément d'information : un index dans une table de valeurs de couleur réelles. Pour certaines applications, ce modèle peut se révéler plus commode. En précense d'un affichage 8 bits ou moins (comme pour le Web), l'utilisation d'un modèle indexé sera plus efficace, dans la mesure où le matériel utilise une certaine forme de modèle de couleur indexé.

Après voir pris connaissance des différentes classes mises en jeu, notamment la classe BufferedImage, et de la théorie relative à la notion de couleur, nous allons maintenant mettre en pratique ces différents concepts.
La plupart des images que vous manipulez sont simplement lues à partir d'un fichier image. Elles ont pu être soit produites par un appareil photo numériques ou par un scanner, soit produite par un programme de dessin.
Toutefois, il est possible de créer une image de toute pièce, notamment lorsque vous désirez tracer des courbes pixel par pixel. La création de l'image se fait par l'intermédiaire du constructeur de BufferedImage.
BufferedImage(int largeur, int hauteur, int typeImage);
Pour créer une image, construisez un objet BufferedImage en fournissant la taille de l'image et son type. Le type le plus répandu est BufferedImage.TYPE_INT_ARGB, dans lequel chaque pixel est spécifié par un entier décrivant les valeurs de rouge, de vert, de bleu et de transparence (ou alpha).
Le fait de travailler avec la transparence permet de conserver la couleur de fond choisie. Sans transparence le fond est noir. Dans ce contexte, et quelque soit le cas de figure, l'image proposée est une image vide.
BufferedImage image = new BufferedImage(largeur, hauteur, BufferedImage.TYPE_INT_ARGB);
Les types d'images indiquent comment les couleurs des pixels sont codés :
| Types d'images | |
|---|---|
| TYPE_3BYTE_BGR | bleu, vert, rouge, sur 8 bits chacun |
| TYPE_4BYTE_ABGR | alpha, bleu, vert, rouge, sur 8 bits chacun |
| TYPE_4BYTE_ABGR_PRE | alpha, bleu, vert, rouge, sur 8 bits chacun, les couleurs pondérées |
| TYPE_BYTE_BINARY | 1 bit par pixel, groupés en octets |
| TYPE_BYTE_INDEXED | 1 octet par pixel, indice dans une table de couleurs |
| TYPE_BYTE_GRAY | 1 octet par pixel, niveau de gris |
| TYPE_USHORT_555_RGB | rouge, vert, bleu, sur 5 bits, codés dans un short |
| TYPE_USHORT_565_RGB | rouge sur 5, vert sur 6, bleu sur 5 bits |
| TYPE_USHORT_GRAY | niveau de gris sur 16 bits |
| TYPE_INT_RGB | rouge, vert, bleu sur 8 bits chacun, dans un int |
| TYPE_INT_BGR | bleu, vert, rouge (Solaris) |
| TYPE_INT_ARGB | alpha, rouge, vert, bleu sur 8 bits, dans un int |
| TYPE_INT_ARGB_PRE | les couleurs déjà pondérées par alpha |
Avant de travailler sur les pixels de l'image, soit par consultation, soit en proposant une nouvelle valeur de couleur pour un pixel particulier, souvenez-vous que la classe BufferedImage est composée de deux objets :
Ainsi, appelez la méthode getRaster() pour obtenir un objet de type WritableRaster. Cet objet qui correspond à la trame de l'image est utile pour accéder aux pixels de l'image et pour les modifier.
WritableRaster trame = image.getRaster();
Cet objet de type WritableRaster possède un certain nombre de méthodes, notamment la méthode setPixel(). La méthode setPixel() vous permet de fixer un seul pixel. Cette méthode est surchargée pour chaque type d'image.
Ainsi, si votre image est créée à partir du type TYPE_INT_ARGB, la méthode est :
setPixel(int x, int y, int[] données);
Le tableau de données doit alors contenir quatre entiers dont les valeurs sont comprises entre 0 et 255, respectivement pour les composantes rouge, vert, bleu et alpha. (quelque soit le type d'image, le dernier paramètre est toujours un tableau d'entier).
int noir = {0, 0, 0, 255};
trame.setPixel(x, y, noir);
Voici un exemple où la composition de l'image consiste à tracer une sinusoïde en triple épaisseur. La période de la sinusoïde doit toujours correspondre à la largeur de la fenêtre, même si un redimensionnement est proposé.
La couleur de fond de la fenêtre doit être conservé en orange. Le tracé s'effectuera en jaune. Pour conserver la couleur de fond, il est alors judicieux d'utiliser la transparence. Du coup, le meilleur type d'image est alors TYPE_INT_ARGB.
Pour connaître exactement les dimensions de l'image à mettre en place. Il est judicieux de redéfinir la méthode setBounds(). En effet, cette dernière est systématiquement sollicité lorsqu'on change la dimension de la fenêtre ou alors pour l'affichage de la fenêtre la toute première fois.
public class Fenêtre extends JFrame { public Fenêtre() throws IOException { this.setDefaultCloseOperation(this.EXIT_ON_CLOSE); this.setSize(300, 250); this.setTitle("Tracé de dessin"); this.getContentPane().setBackground(Color.ORANGE); this.getContentPane().add(new Zone()); } public static void main(String[] args) throws IOException { new Fenêtre().setVisible(true); } } class Zone extends JComponent { private BufferedImage image; protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, null); } public void setBounds(int x, int y, int largeur, int hauteur) { super.setBounds(x, y, largeur, hauteur); init(); } private void init() { int largeur = this.getWidth(); int hauteur = this.getHeight(); image = new BufferedImage(largeur, hauteur, BufferedImage.TYPE_INT_ARGB); WritableRaster trame = image.getRaster(); int[] jaune = {255, 255, 0, 255}; for (int x=0; x<largeur; x++) { double angle = (double)x/largeur; int y = (int)(hauteur/2*(1+0.9*Math.sin(angle*2*Math.PI))); trame.setPixel(x, y, jaune); trame.setPixel(x,y-1, jaune); trame.setPixel(x, y+1, jaune); } } }
La difficulté dans l'utilisation de cette méthode setPixel() réside dans le fait qu'il ne suffit pas de fournir une valeur Color, mais qu'il faut connaître le mode de couleurs utilisé par l'image en mémoire. Cela dépend du type de l'image. Nous venons de le voir, si votre image est du type TYPE_INT_ARGB, chaque pixel est décrit impérativement par quatre valeurs, une composante de rouge, une de vert, une de bleu et une pour la couche alpha, chacune variant de 0 à 255. Vous devez le fournir dans un tableau de quatre entiers. Si, par exemple, pour ce type d'image, vous proposez un tableau de trois valeurs au lieu de quatre (occultant ainsi la valeur alpha), vous allez avoir une exception qui va être levée et votre programme ne fonctionnera pas correctement.
Si vous désirez, malgré tout, régler votre couleur à l'aide d'un tableau de trois valeurs correspondant uniquement aux couleurs fondamentales sans utiliser la couche alpha, il est alors préférable de choisir un autre type d'image, par exemple le type TYPE_INT_RGB.
class Zone extends JComponent { private BufferedImage image; protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, null); } public void setBounds(int x, int y, int largeur, int hauteur) { super.setBounds(x, y, largeur, hauteur); init(); } private void init() { int largeur = this.getWidth(); int hauteur = this.getHeight(); image = new BufferedImage(largeur, hauteur, BufferedImage.TYPE_INT_RGB); WritableRaster trame = image.getRaster(); int[] jaune = {255, 255, 0}; for (int x=0; x<largeur; x++) { double angle = (double)x/largeur; int y = (int)(hauteur/2*(1+0.9*Math.sin(angle*2*Math.PI))); trame.setPixel(x, y, jaune); trame.setPixel(x,y-1, jaune); trame.setPixel(x, y+1, jaune); } } }
Vous remarquez, vu que nous n'utilisons plus la couche alpha, que le fond de l'image par défaut est noir. Nous pouvons utiliser la méthode setPixel() aussi bien sur des images vides que sur des fichiers images.
De même qu'il est possible de modifier la valeur d'un pixel à l'aide de la méthode setPixel() issue de la classe WritableRaster, nous pouvons faire l'inverse, c'est-à-dire lire la couleur d'un pixel particulier. Pour cela, toujours par l'intermédiaire de la classe WritableRaster (par héritage) il suffit d'utiliser la méthode getPixel(). En réalité, cette méthode est implémentée dans la classe mère Raster. Par ailleurs, tout comme la méthode setPixel(), la méthode getPixel() est surchargée pour correspondre au type d'image choisi.
Si, encore une fois, je prend le type d'image TYPE_INT_ARGB, vous devez au préalable fournir un tableau vierge de quatre entiers, destiné à contenir les valeurs d'échantillonnage.
int[] c = new int[4]; trame.getPixel(x, y, c); Color couleur = new Color(c[0], c[1], c[2], c[3]);
A titre d'exemple, nous allons utiliser la méthode getPixel() afin de récupérer l'histogramme des composantes rouge, vert et bleu d'une photo stockée dans un fichier. Vous remarquerez au passage que cette photo est plutôt surexposée puisque les courbes se situent largement vers la droite. Je rappelle qu'un histogramme consiste à comptabiliser la valeur de chaque intensité lumineuse (de 0 à 255) pour les trois couleurs fondamentales. Le tracé de l'histogramme se fait en surimpression sur la photo. Pour cela, nous utiliserons largement les techniques de tracé de Java 2D.
class Zone extends JComponent { private BufferedImage image, histogramme; private final int largeur = 256; private final int hauteur = 200; private Graphics2D dessin; private final int[] rouge = new int[256]; private final int[] vert = new int[256]; private final int[] bleu = new int[256]; public Zone() throws IOException { image = ImageIO.read(new File("chouette.jpg")); récupérerRVB(); tracerHistogrammes(); } protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, null); surface.drawImage(histogramme, (this.getWidth()-largeur)/2, (this.getHeight()-hauteur)/2, null); } private void récupérerRVB() { Raster trame = image.getRaster(); int[] rgb = new int[3]; int maximum = 0; for (int y=0; y<image.getHeight(); y++) for (int x=0; x<image.getWidth(); x++) { trame.getPixel(x, y, rgb); rouge[rgb[0]]++; vert[rgb[1]]++; bleu[rgb[2]]++; } } private void tracerHistogrammes() { histogramme = new BufferedImage(largeur, hauteur, BufferedImage.TYPE_INT_ARGB); dessin = histogramme.createGraphics(); Rectangle2D rectangle = new Rectangle2D.Double(0, 0, largeur-1, hauteur-1); dessin.draw(rectangle); dessin.setPaint(new Color(1F, 1F, 1F, 0.2F)); dessin.fill(rectangle); changerAxes(); dessin.setPaint(new Color(1F, 0F, 0F, 0.4F)); tracerHistogramme(rouge); dessin.setPaint(new Color(0F, 1F, 0F, 0.4F)); tracerHistogramme(vert); dessin.setPaint(new Color(0F, 0F, 1F, 0.4F)); tracerHistogramme(bleu); } private void changerAxes() { dessin.translate(0, hauteur); double surfaceImage = image.getWidth()*image.getHeight(); double surfaceHistogramme = histogramme.getWidth()*histogramme.getHeight(); dessin.scale(1, -surfaceHistogramme/surfaceImage/3.7); } private void tracerHistogramme(int[] couleur) { for (int i=0; i<255; i++) dessin.drawLine(i, 0, i, couleur[i]); } }
Si votre image est d'un autre type que TYPE_INT_ARGB, et que vous connaissiez le format utilisé pour les pixels, vous pouvez toujours passer par les méthodes getPixel() et setPixel(). Cependant, vous devez également connaître le format d'encodage de chaque composante pour ce type d'image.
L'enjeu ici est de pouvoir récupérer les valeurs des composantes rouge, vert et bleu, quel que soit le modèle de couleur utilisé afin d'effectuer les traitements sur les pixels de façon généralisé indépendamment du type d'image. Effectivement, chaque type d'image possède un modèle de couleurs qui permet de passer les valeurs d'échantillonnage en tableau au modèle de couleurs standard RVB.
Souvenez-vous que le type de l'image est encapsulé dans le modèle de couleur représenté par la classe ColorModel. Au préalable, avant d'effectuer les traitements au niveau du pixel, il faut d'abord récupérer le modèle de couleur au moyen de la méthode getColorModel() de la classe BufferedImage :
ColorModel modèle = image.getColorModel();
Pour déterminer les composantes de couleur d'un pixel, appelez ensuite la méthode getDataElements() de la classe Raster. Cette méthode est l'équivalent de la méthode getPixel(), avec l'avantage sur cette dernière qu'il n'est pas indispensable de connaître le modèle de couleur utilisé.
Object données = trame.getDataElements(x, y, null);
L'objet renvoyé par la méthode getDataElements() est en fait un tableau de valeurs d'échantillonnage. Vous n'avez pas besoin de le savoir pour le traiter, mais cela explique pourquoi cette méthode est appelée getDataElements().
Maintenant que nous avons récupérées les données relatives au pixel choisi, le modèle de couleur peut, dès lors, transformer ces données en valeurs standard ARGB et ainsi effectuer la conversion d'un modèle particulier vers le modèle RVB. Ainsi, la méthode getRGB() de la classe ColorModel renvoie une valeur int comprenant les composantes rouge, vert, bleu et alpha sur quatre blocs de 8 bits chacun. Ensuite, vous pouvez reconstituer une valeur Color à partir de cet entier grâce au constructeur :
Color(int argb, boolean alpha)
Voici donc l'enchênement à réaliser :
ColorModel modèle = image.getColorModel();
Object données = trame.getDataElements(x, y, null);
int argb = modèle.getRGB(données);
Color couleur = new Color(argb, true);
A noter qu'il est aussi possible de récupérer la valeur de chacune des composantes en particulier à l'aide des méthodes respectives getRed(), getGreen(), getBlue() et getAlpha() de la classe ColorModel.
ColorModel modèle = image.getColorModel();
Object données = trame.getDataElements(x, y, null);
int rouge = modèle.getRed(données);
A titre d'exercice, reprenons l'exemple traité sur la recherche des histogrammes d'une image stockée sur le disque dur. Seule la méthode récupérerRVB() subit la modification. En effet, c'était à l'intérieur de cette méthode que nous faisions appel à la méthode getPixel(). Remarquez au passage que grâce à cette méthode getDataElements(), le codage est plus réduit et surtout plus logique et facile à lire.
private void récupérerRVB() { Raster trame = image.getRaster(); ColorModel modèle = image.getColorModel(); int maximum = 0; for (int y=0; y<image.getHeight(); y++) for (int x=0; x<image.getWidth(); x++) { Object données = trame.getDataElements(x, y, null); rouge[modèle.getRed(données)]++; vert[modèle.getGreen(données)]++; bleu[modèle.getBlue(données)]++; } }
A l'aide cette approche, nous voyons plus facilement une plus grande connivence entre le modèle de couleur et la trame elle-même.
.
Lorsque vous souhaitez choisir une couleur particulière pour un pixel, il faut suivre ces étapes à l'envers. La méthode getRGB() de la classe Color fournit une valeur int contenant les composantes alpha, rouge, vert et bleu. Passez cette valeur à la méthode getDataElements() de la classe ColorModel. La valeur de retour est un Object contenant une description de la couleur spécifique à un modèle de couleurs particulier. Renvoyer alors cet objet à la méthode setDataElements() de la classe WritableRaster. Encore une fois, cette méthode est l'équivalent de la méthode setPixel(), avec l'avantage sur cette dernière qu'il n'est pas indispensable de connaître le modèle de couleur utilisé.
ColorModel modèle = image.getColorModel();
int argb = couleur.getRGB();
Object données = modèle.getDataElements(argb, null);
trame.setDataElements(x, y, données);
Il est également possible de proposer une valeur entière argb à l'aide d'une couleur prédéfinie :
int argb = Color.red.getRGB();
A titre d'exemple, nous allons reprendre le tracé de la sinusoïde en triple épaisseur. Ici, nous remplaçons la méthode setPixel() par la méthode setDataElements(), et ceci à l'intérieur de la méthode init().
class Zone extends JComponent { private BufferedImage image; protected void paintComponent(Graphics surface) {