Traitement des images

Chapitres traités   

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 :

  1. Comment récupérer une image depuis un fichier externe, ou stockée dans une application Web.
  2. Comment afficher l'image récupérée sur le composant graphique correspondant à la zone cliente d'une fenêtre ou d'une applet.
  3. Et bien sûr, la partie la plus importante, comment réaliser du traitement sur une image ainsi récupérée.
    1. Soit par traitement au niveau de chaque pixel,
    2. Soit par l'intermédiaire de filtres.
  4. Pour terminer, nous verrons comment récupérer les données EXIF stockées dans des images au format JPEG issues d'appareils photo.

 

Choix du chapitre Récupérations et stockage des images

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.

 

Lecture d'un fichier image sur l'ordinateur local pour une application fenêtrée

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.

 

Lecture d'un fichier image à partir d'une URL pour une application fenêtrée

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));

 

Ecriture dans un fichier image depuis une application fenêtrée

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.

 

Choix du taux de compression lors de l'écriture dans un 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.

 

Lecture d'un fichier image à partir d'une applet

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.

  1. Lorsque nous faisons appel à la méthode getImage(), la requête est enregistrée, mais son exécution est retardée.
  2. Ainsi, le chargement de l'image a lieu au premier appel de drawImage().
  3. Le chargement est pris en main par un nouveau thread.
  4. Du coup, l'exécution du programme principal continue sans attendre la fin du chargement.
  5. L'affichage peut donc être progressif.
  6. Par contre, pour connaître la fin du chargement, nous devons utiliser un spectateur (cette notion est introduite dans les chapitres suivants).

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.

 

Choix du chapitre Retour sur le dessin des images (sans traitement)

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);   
   }   
}

 

Imposer une taille à l'image

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);   
   }   
}

 

Toutes les méthodes drawImage()

En réalité, il existe 6 versions de la méthode drawImage() :

public boolean drawImage(Image img, int x, int y, ImageObserver spectateur)
Affiche l'image à l'endroit indiqué
public boolean drawImage(Image img, int x, int y, Color bgcolor, ImageObserver spectateur)
Les parties transparentes sont rendues dans la couleur de fond.
public boolean drawImage(Image img, int x, int y, int width, int height, ImageObserver spectateur)
L’image est déformée pour remplir le rectangle donné.
public boolean drawImage(Image img, int x, int y, int width, int height, Color bgcolor, ImageObserver spectateur)
L’image est déformée pour remplir le rectangle donné. Variante avec couleur de fond.
public boolean drawImage(Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, int sy2, ImageObserver spectateur)
La partie de l'image délimitée par le rectangle donné par les pints d1 et d2 est affichée dans le rectangle donné par les points s1 et s2. Une déformation peut avoir lieu.
public boolean drawImage(Image img, int dx1, int dy1, int dx2, int dy2, int sx1, int sy1, int sx2, int sy2, Color bgcolor, ImageObserver spectateur)
La partie de l'image délimitée par le rectangle donné par les points d1 et d2 est affichée dans le rectangle donné par les points s1 et s2. Une déformation peut avoir lieu. Variante avec couleur de fond.

 

Spectateurs

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.

 

Diagramme UML

 

 

Choix du chapitre Retailler l'image pour qu'elle corresponde à la dimension de la fenêtre

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);   
   }   
} 

Choix du chapitre Les classes représentant une image (différents concepts pour le traitement d'image)

Qu'est-ce que la couleur ?

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.

Quelques définitions

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.

Lois de Grassman

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.

Représentation de la couleur

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.).

Trois composantes de couleurs

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.

Système de couleurs additif

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.

Système de couleurs soustractif

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.

Composantes chromatiques

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.

Diagramme de chromaticité de la CIE

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

Pyramide HSV de Smith

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.

 

Image tampon

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 :

  1. A chaque pixel est associé la couleur d'un point, dans une des formes possibles appelées valeurs d'échantillonage.
  2. Ces valeurs sont interprétées selon le modèle de couleur de l'image.

 

Constitution de la classe tampon BufferedImage

Un BufferedImage est constituée de deux parties :

  1. Un Raster (trame) - le Raster contient les données de l'image. Nous pouvons nous le représenter comme un tableau de valeurs de pixels. Il sait répondre aux questions comme "Quelles sont les valeurs de données du pixel situé aux coordonnées <51, 17> ?". Le Raster d'une image RGB renvoie trois valeurs, tandis que celui d'une image en niveau de gris renvoie une valeur unique. En réalité, il existe deux types de classe de stockage de données. Soit la classe Raster qui permet de lire les pixels sans toutefois pouvoir les modifier (pour des raisons de sécurité). Soit la classe WritableRaster qui, comme son nom l'indique, gère également les modifications des valeurs des pixels. Cette classe WritableRaster est en fait une classe fille de la classe Raster.
  2. Un ColorModel - qui définit la façon d'interpréter les couleurs. Le ColorModel est capable de traduire les valeurs des données provenant du Raster en objet Color. Par exemple, un modèle RGB saura interpréter trois valeurs de données en rouge, vert et bleu. Un modèle de couleur en niveaux de gris saura interpréter une valeur de donnée unique comme un niveau de gris.

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.

 

Constitution de la trame

Le Raster (la trame) est lui-même composé de deux parties :

  1. Un DataBuffer - contenant les données brutes dans un tableau. Ainsi, un DataBuffer est une enveloppe de données brutes, qui sont des tableaux de byte, short, ou int. DataBuffer possède des classes filles, DataBufferByte, DataBufferShort et DataBufferInt, qui permettent de créer un DataBuffer à partir des données des tableaux de données brutes.
  2. Un SampleModel (modèle d'échantillonage) - qui interprète les données brutes. Le SampleModel sait comment extraire les valeurs d'un pixel particulier du DataBuffer. Il connaît la structure des tableaux du DataBuffer et sait répondre à la question "Quelles sont les valeurs du pixel <x, y> ?". Les SampleModel sont un peu difficile à manier, mais vous n'aurez certainement jamais besoin d'en créer ou d'en utiliser directement. Nous verrons que la classe Raster possède de nombreuses méthodes statiques qui créent des Raster déjà configurés, y compris leurs DataBuffer et leurs SampleModel.

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.

 

Les modèles de couleur

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é.

 

Diagramme UML

 

 

Choix du chapitre Traitement des images par manipulation des pixels (Mise en pratique)

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.

 

Création d'une image

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

 

Travailler avec les données des images

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 :

  1. la trame représentée par la classe WritableRaster ;
  2. et le modèle de couleur représentée par la classe ColorModel.

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();

 

Manipulation des pixels au travers de la méthode setPixel()

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.

 

Récupération de la couleur d'un pixel au travers de la méthode getPixel()

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]);              
   }  
}

 

Lecture des pixels avec la méthode getDataElements()

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

 

Traitement sur les pixels avec la méthode setDataElements()

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) {
      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();
      ColorModel modèle = image.getColorModel();
      int argb = Color.yellow.getRGB();
      Object données = modèle.getDataElements(argb, null);
      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.setDataElements(x, y, données);
         trame.setDataElements(x, y-1, données);
         trame.setDataElements(x, y+1, données);
      }
   }
}  

 

Changement du type d'une image

Lorsque vous récupérez une image à partir d'un fichier, le type de l'image est fixé. Il est toutefois possible de changer sont type d'image pour avoir, par exemple, une photo en noir et blanc alors que celle-ci a été prise en couleur. Le plus simple consiste à copier l'image originale dans une autre créée avec le bon type comme, par exemple, le type niveau de gris.

Pour copier une image dans une autre, le plus facile est de passer par un Graphics, et de proposer ensuite le tracé de l'image au moyen de la méthode drawImage() surchargée pour ce type de traitement :

 

class Zone extends JComponent {
   private BufferedImage image;
   
   public Zone() throws IOException {
      BufferedImage source = ImageIO.read(new File("chouette.jpg"));
      image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_USHORT_GRAY);
      Graphics2D dessin = image.createGraphics();
      dessin.drawImage(source, null, null);
   }
     
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}

 

Transformation d'un modèle de couleur entre RGB et HSB

Nous remarquons que la plupart des classes propres aux traitements d'image sont conçues pour être manipulées avec le modèle standard ARGB. Dans certains cas, toutefois, il peut être intéressant de travailler plutôt avec le modèle représentant la teinte, la saturation et la luminosité HSB (Hue : teinte, Saturation, Brightness : luminosité). La classe Color propose des méthodes statiques qui permettent de passer de l'un à l'autre. Il existe en effet les méthodes :

  1. Color getHSBColor(float teinte, float saturation, float luminosité)
  2. int HSBtoRGB(float teinte, float saturation, float luminosité)
  3. float[ ] RGBtoHSB(int rouge, int vert, int bleu, float[ ] hsb) // les valeurs retournées sont comprises entre 0F et 1F (pourcentage)

A titre d'exemple, nous allons réhausser les couleurs de la chouette en proposant donc une saturation des couleurs 50% plus fortes.

Sans saturation ---------------------------------------- Avec saturation (légèrement plus de couleur)

class Zone extends JComponent {
   private BufferedImage image;
   
   public Zone() throws IOException {
      image = ImageIO.read(new File("chouette.jpg"));
      WritableRaster trame = image.getRaster();
      ColorModel modèle = image.getColorModel();
      for (int y=0; y<image.getHeight(); y++)
         for (int x=0; x<image.getWidth(); x++) {
            Object données = trame.getDataElements(x, y, null);
            float[] hsb = new float[3];
            Color.RGBtoHSB(modèle.getRed(données), modèle.getGreen(données), modèle.getBlue(données), hsb);
            Object changement = modèle.getDataElements(Color.HSBtoRGB(hsb[0], hsb[1]*1.5F, hsb[2]), null);
            trame.setDataElements(x, y, changement);
         }           
   }
   
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}

 

Création d'un modèle de couleur indexé

Lorsque nous effectuons un traitement d'image non pas sur des photos mais plutôt à partir d'une image vierge qui sera ensuite utilisée afin d'effectuer des tracés, il peut s'avérer intéressant de créer son propre modèle de couleur avec juste le nombre de couleur requis. En effet, le mode par défaut souvent utilisé TYPE_INT_ARGB prend systématiquement 32 bits pour chaque pixel. Si votre tracé n'utlise que trois couleurs, nous obtenons beaucoup de déchets.

Nous avons alors la possibilité de définir son propre modèle de couleur qui est un modèle de couleur indexé. Dans ce cas là, il suffit de spécifier exactement les couleurs à utiliser.

Jusqu'à présent, la démarche consistait à créer d'abord un BufferedImage et ensuite de récupérer à la fois la trame et le modèle de couleur à partir de cette création. Dans cette procédure, nous sommes obligé d'imposer un modèle de couleur prédéfini. Ici, c'est justement ce que nous ne désirons pas faire. La démarche sera donc inversée.

Nous devons donc suivre l'orde de création suivant :

  1. Création de la trame (Raster) : Rappelez-vous qu'un Raster est composé d'un objet de type DataBuffer (classe abstraite), soit sous forme de byte à l'aide de la classe DataBufferByte, soit sous forme de short à l'aide de la classe DataBufferShort, soit sous forme de int à l'aide de la classe DataBufferInt. Il faut donc pour construire votre trame s'intéresser à ce buffer et le constituer de telle sorte qui corresponde pile au nombre de bits requis pour l'ensemble de l'image.
  2. Création du modèle de couleur indexé (IndexColorModel) : Grâce à cette classe, il sera possible de choisir les couleurs à prendre pour le tracé.
  3. Création du buffeur d'image (BufferedImage) : Ce buffeur d'image sera finalement constitué à partir des deux éléments précédents.

A titre d'exemple, nous allons reprendre le tracé de la sinusoïde avec en plus le tracé de l'axe des abscisses. Nous imposons ici, qu'il n'existera que trois couleurs possibles par pixel, le fond en rouge, l'axe en jaune, et la courbe en vert. Le nombre de bits par pixel est alors de 2 (22 possibilités).


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();
      int longueur = ((largeur+7)*hauteur)/4;
      byte[] données = new byte[longueur];
      DataBuffer buffeur = new DataBufferByte(données, longueur);
      WritableRaster trame = Raster.createPackedRaster(buffeur, largeur, hauteur, 2, null);
      ColorModel modèle = new IndexColorModel(2, 3,
         new byte[] {(byte)Color.red.getRed(), (byte)Color.yellow.getRed(), (byte)Color.green.getRed()},
         new byte[] {(byte)Color.red.getGreen(), (byte)Color.yellow.getGreen(), (byte)Color.green.getGreen()},
         new byte[] {(byte)Color.red.getBlue(), (byte)Color.yellow.getBlue(), (byte)Color.green.getBlue()});
      image = new BufferedImage(modèle, trame, false, null);
      Graphics2D dessin = image.createGraphics();
      dessin.setPaint(Color.yellow);
      dessin.draw(new Line2D.Double(0, hauteur/2, largeur, hauteur/2));
      int argb = Color.green.getRGB();
      Object couleur = modèle.getDataElements(argb, null);
      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.setDataElements(x, y, couleur);
         trame.setDataElements(x, y-1, couleur);
         trame.setDataElements(x, y+1, couleur);
      }
   }
}  

Les données de l'image sont stockées sous la forme d'un tableau d'octets (byte), dont chaque élément comprend quatre pixels (4x2=8). La longueur du tableau est donc calculée en multipliant la largeur et la hauteur de l'image en divisant le résultat par quatre. Nous devons également tenir compte du fait que chaque rangée de l'image démarre sur une frontière d'octets (cas le plus défavorable 7). Par exemple, une image de 13 pixels de large utilisera réellement 2 octets (16 bits) pour chaque rangée :

int longueur = ((largeur+7)*hauteur)/4;

Le tableau d'octet est ensuite créé. La classe Raster contiendra une référence à ce tableau au travers du DataBuffer. Par la suite, et de façon classique, nous changerons dynamiquement les données de l'image. Une fois en possession du tableau de données d'image, nous pouvons facilement créer une DataBuffer :

byte[] données = new byte[longueur];
DataBuffer buffeur = new DataBufferByte(données, longueur);

DataBuffer possède donc plusieurs classes filles, par exemple DataBufferByte, destinées à faciliter la création d'un tampon de données correspondant parfaitement aux tableaux brutes de même type.

L'étape suivante consiste normalement à créer un SampleModel. Nous pourrons alors créer un Raster à partir du SampleModel et du DataBuffer. Par chance, la classe Raster contient quelques méthodes statiques utilisables pour créer des types courants de Raster. L'une de ces méthodes createPackedRaster() crée un Raster à partir de données contenant plusieurs pixels empaquetés en éléments de tableau. Nous l'utilisons en fournissant le tampon de données, la largeur et la hauteur, et en précisant que chaque pixel occupe 2 bits :

WritableRaster trame = Raster.createPackedRaster(buffeur, largeur, hauteur, 2, null);

Le dernier argument est un java.awt.Point indiquant l'emplacement du coin supérieur gauche du Raster. La valeur null indique que nous utilisons <0, 0> (emplacement par défaut).

Le dernier élément du puzzle est le ColorModel. Chaque pixel vaut 0 ou 1 ou 2, mais comment interpréter cela en tant que couleur ? Ici, nous utilisons un IndexColorModel avec une très petite palette ne comportant que trois entrées, une pour le rouge, une pour le jaune et une pour le vert :

ColorModel modèle = new IndexColorModel(2, 3,
   new
byte[] {(byte)Color.red.getRed(), (byte)Color.yellow.getRed(), (byte)Color.green.getRed()},
   new byte[] {(byte)Color.red.getGreen(), (byte)Color.yellow.getGreen(), (byte)Color.green.getGreen()},
   new
byte[] {(byte)Color.red.getBlue(), (byte)Color.yellow.getBlue(), (byte)Color.green.getBlue()});

Le constructeur IndexColorModel utilisé ici accepte le nombre de bits par pixel (2), le nombre d'entrées de la palette (3), et trois tableaux d'octets qui sont les composantes rouge, vert et bleu des couleurs de la palette. Notre palette est constituée de trois couleurs défnies plus haut. Si vous les connaisssez, vous pouvez plutôt écrire directement avec les valeurs décimales, par exemple pour le rouge (255, 0, 0).

Avec tous les éléments en main, nous créons un BufferedImage. Pour créer le BufferedImage, nous passons le modèle de couleur et la trame inscriptible que nous venons de créer :

image = new BufferedImage(modèle, trame, false, null);

 

Récupérer une partie d'image

Il existe une méthode intéressante dans la classe BufferedImage qui permet de prendre qu'une partie de l'image. Il s'agit de la méthode getSubimage() qui retourne alors un autre BufferedImage et dont la zone à récupérer est spécifiée par les paramètres de la méthode :

BufferedImage copie = image.getSubimage(x, y, largeur, hauteur);

Voici un exemple qui permet de limiter la zone d'affichage à la tête de la chouette :


class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = ImageIO.read(new File("chouette.jpg")).getSubimage(90, 20, 160, 140);
   }
   public Zone(BufferedImage image) {
      this.image = image;
   }   
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}

  

 

Choix du chapitre Traitement des images par filtrage

Dans le chapitre précédent, nous avons vu comment réaliser des traitements sur une image au niveau de chaque pixel. Dans ce chapitre, nous allons effectuer d'autres types de traitement qui cette fois-ci influencerons systématiquement la totalité de l'image. Dans ce contexte, ce type de traitement utilise la technique des filtres.

Dans le chapitre précédent, nous avons surtout vu comment construire une image à partir de rien. Cependant, il arrive souvent que vous possédiez déjà une image et vous souhaitez l'améliorer d'une manière ou d'une autre. Naturellement, vous pouvez passer par les méthodes getPixel()/getDataElements() de la section précédente pour lire les données de l'image, les manipuler et les écrire dans la nouvelle image. Mais heureusement la tecnologie Java 2 fournit déjà un ensemble de filtres qui prennent en charge les opérations les plus courantes de traitement d'image.

Nous connaissons déjà largement la classe BufferedImage qui permet de contrôler les pixels d'une image. Il existe maintenant des classes qui fournissent l'ensemble des traitements possibles par la technique de filtrage. Toutes ces nouvelles classes implémentent l'interface BufferedImageOp. Il existe en réalité, cinq classes qui opèrent des traitements bien spécifiques :

  1. AffineTransformOp : Effectue une transformation géométrique quelconque. La transformation peut comprendre une homothétie (redimensionnement), une rotation, une translation et un cisaillement dans n'importe quelle combinaison. Cet opérateur interpole au besoin les valeurs des pixels, grâce soit à un algorithme rapide de voisin le plus proche, soit à une interpolation bilinéaire de plus haute qualité. Cette classe ne peut pas traiter les images sur place (nécessicité de travailler avec deux BufferedImage).
  2. ColorConvertOp : Réalise des conversions d'un espace de couleur vers un autre (passage de la couleur en noir et blanc par exemple). Elle peut traiter une image sur place.
  3. ConvoleOp : Effectue un type puissant et souple de traitement d'image appelé convolution, qui est employé pour adoucir ou durcir les images et effectuer des détections de contours, entre autre choses. ConvolveOp utilise un objet java.awt.image.Kernel pour contenir la matrice de nombres qui spécifient exactement quelle opération de convolution est effectuée. Les opérations de convolution ne peuvent être faites sur place.
  4. LookupOp : Traite les canaux de couleur d'une image par une table de recherche (en anglaus, lookup table), qui est un tableau faisant correspondre les valeurs de couleur de l'image source vers les valeurs de couleur dans la nouvelle image. L'utilisation de tables de recherche fait de LookupOp une classe de traitement d'image très souple et la plus utilisée. Par exemple, on peut l'utiliser pour éclaircir ou assombrir une image, ou pour en réduire le nombre de niveaux de couleurs distincts. LookupOp peut utiliser soit une table de recherche unique pour opérer sur tous les canaux de couleur d'une image, soit une table de recherche à part pour chaque canal. LookupOp peut être employé pour traiter des images sur place. On utilise généralement LookupOp en conjonction avec java.awt.image.ByteLookupTable.
  5. RescaleOp : Tout comme LookupOp, RescaleOp est employé pour modifier les valeurs des composantes couleurs d'une image. Cependant RescaleOp utilise une simple équation linéaire au lieu d'une table de recherche. Les valeurs de couleur de la destinaiton sont obtenues en multipliant les valeurs source par une constante puis en ajoutant une autre constante. On peut spécifier soit une seule paire de constantes pour toutes les canaux de couleurs, soir des paires de constantes pour chacun des canaux de l'image. RescaleOp peut traiter les images sur place.

 

Opération de filtrage

Pour réaliser une opération de filtrage, il suffit de faire appel à la méthode filter() d'une des classes de filtrage qui implémente l'interface BufferedImageOp sur l'image à traiter, représentée donc par la classe BufferedImage :

filter(BufferedImage source, BufferedImage destination)

Certaines opérations peuvent transformer une image directement, sans passer par une autre image, mais la plupart n'en sont pas capables.
.

En gros, voici, la procédure à suivre pour réaliser un filtre sur une image :

BufferedImage imageOriginale = ImageIO.read(new File("image.jpg"));
BufferedImageOp
opération = new AffineTransformOp(...);
BufferedImage imagefiltrée = new BufferedImage(imageOriginale.getWidth(), imageOriginale.getHeight(), imageOriginale.getType());
opération.filter(imageOriginale, imagefiltrée);
...

 

Transformation affine - AffineTransformOp

Nous allons commencer par la première opération qui s'occupe de réaliser des transformations affines. Les transformations affines sont des transformations en deux dimensions qui préservent le parallélisme des lignes ; cela comprend des opérations telles que le redimmensionnement et les rotations, mais également les translations et les cisaillements.

L'opérateur d'image java.awt.image.AffineTransformOp effectue une transformation géométrique d'une image source pour produire une image de destination. Pour créer un objet AffineTransformOp, précisez la transformation affine souhaitée, sous la forme d'un java.awt.geom.AffineTransform.

Toutefois, le constructeur AffineTransformOp nécessite, certe une transformation affine, mais également une stratégie d'interpolation. Une interpolation est en effet nécessaire pour calculer les pixels de l'image cible si ceux-ci ne correspondent pas exactement aux pixels de l'image source. Il existe trois algorithmes correspondant stratégies d'interpolation :

  1. AffineTranformOp.TYPE_NEAREST_HEIGHBOR : prend la couleur du pixel le plus proche. Stratégie la plus rapide en terme de temps de réponse.
  2. AffineTranformOp.TYPE_BILINEAR : la couleur est calculée avec une combinaison des quatre pixels source. L'interpolation bilinéaire nécesite un peu plus de temps, mais elle donne de meilleurs résultats que la précédente.
  3. AffineTranformOp.TYPE_BICUBIC : la couleur est calculée avec une combinaison des neufs pixels source. C'est cette interpolation qui procure le meilleur résultat au détriment, bien entendu, du temps de calcul nécessaire pour réaliser cet algorithme.

En réalité, nous retrouvons dans la classe AffineTransform à peu près les mêmes types de méthodes de transformations que celles que nous avons étudiées dans l'étude sur le graphisme 2D, savoir :

  1. getRotateInstance(double angle, double centreX, double centreY) : effectue une rotation de l'image suivant l'angle désiré. La rotation se réalise autour du point fixé par les coordonnées <centreX, centreY>.
  2. getScaleInstance(double multiplicateurX, double multiplicateurY) : effectue un changement d'échelle sur l'image, où le rapport est fixé suivant les axes par les coefficients de multiplication.
  3. getShearInstance(double x, double y) : effectue les distortions de l'image suivant le coefficent précisé suivant l'axe des x et suivant l'axe des y.
  4. getTranslateInstance(double x, double y) : effectue une translation de l'image suivant les valeurs de décalage précisées par les coordonnées x et y.

A titre d'exemple, nous allons afficher une photo qui a été réalisée avec un capteur de 6.5 MPx. Nous devons donc récupérer cette photo au format JPEG et effectuer deux transformations. D'une part, nous devons réduire la taille de cette image de telle sorte que sa surface soit 100 fois plus petite. Par ailleurs, la photo doit subir une rotation de 90° dans le sens horaire. Il faut que la qualité finale de cette image subisse le moins de déperdition de qualité possible.

===>

 

class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      BufferedImage source = ImageIO.read(new File("couché de soleil.jpg"));

      BufferedImage imageRetaillée = new BufferedImage(source.getWidth()/10, source.getHeight()/10, source.getType());
      AffineTransform retailler = AffineTransform.getScaleInstance(0.1, 0.1);
      int interpolation = AffineTransformOp.TYPE_BICUBIC;
      AffineTransformOp retaillerImage = new AffineTransformOp(retailler, interpolation);
      retaillerImage.filter(source, imageRetaillée);

image = new BufferedImage(source.getHeight()/10, source.getWidth()/10, source.getType()); double centreDeRotation = imageRetaillée.getHeight()/2; AffineTransform pivoter = AffineTransform.getRotateInstance(Math.toRadians(90), centreDeRotation, centreDeRotation); AffineTransformOp pivoterImage = new AffineTransformOp(pivoter, interpolation); pivoterImage.filter(imageRetaillée, image); } protected void paintComponent(Graphics surface) { surface.drawImage(image, 0, 0, null); } }

 

Réglage de l'intensité de chacune des composantes RGB - RescaleOp

La remise à l'échelle (rescale) consiste à mulitplier les valeurs des pixels d'une image par une constante. La taille de l'image n'est pas affectée (pour le cas où vous pensiez que remise à l'échelle signifiait mise à l'échelle), mais les couleurs de ses pixels le sont. Par exemple, dans une image RGB, chaque valeur rouge, vert et bleu de chaque pixel est multiplié par le multiplicateur d'échelle. Nous pouvons également ajuster le résultat par un déplacement.

Dans l'API 2D, la remise à l'échelle est effectuée par la classe java.awt.image.RescaleOp. Pour créer un tel opérateur, indiquez le multiplicateur, le déplacement, et certaines indications de contrôle de qualité de conversion (les mêmes que pour la classe Graphics2D).

opération = new RescaleOp(multiplicateur, déplacement, qualité);

Ainsi, les  opérations de “rescaling” modifient les intensités des  composantes RGB par deux paramètres :

  1. un facteur multiplication  m
  2. un décalage d.

La nouvelle intensité est :

i’ = i * m + d

Ainsi,

  1. si m < 1, l’image est assombrie.
  2. si m > 1, l’image est plus brillante.
  3. d est compris entre 0 et 256 et ajoute un éclairement supplémentaire.

Exemples :

op = new RescaleOp(.5f, 0, null) plus sombre
op = new RescaleOp(.5f, 64, null)  plus sombre avec éclairement
op = new RescaleOp(1.2f, 0, null)
  plus brillant
op = new RescaleOp(1.5f, 0, null)  encore plus brillant

Les valeurs d'échantillonnage qui sont trop grandes ou trop petites après le changement d'échelle prennent la valeur de la plus grande ou de la plus petite valeur autorisée. Si l'image est au format ARGB (le cas le plus fréquent pour les photos), le changement d'échelle est traité séparément pour le rouge, le vert et le bleu, mais pas pour la couche alpha. Du coup, il est préférable de choisir le type d'image BufferedImage.TYPE_INT_RGB.


Voici un exemple qui permet d'assombrir l'image de la chouette :

===>

class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = assombrir(ImageIO.read(new File("chouette.jpg")));
   }
   private BufferedImage assombrir(BufferedImage source) {
      BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_RGB);
      RescaleOp assombrir = new RescaleOp(0.8f, 0, null);
      assombrir.filter(source, image);
      return image;
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}

Pour éclaircir ou assombrir une image, nous pouvons employer un RescaleOp pour augmenter ou diminuer linéairement l'intensité de chaque valeur de couleur. Des effets d'éclaircissement ou d'assombrissement plus réaliste demandent cependant une transformtion non linéaire. Il est largement préférable d'utiliser dans ce cas là l'opérateur LookupOp traité ci-dessous. Par exemple, on peut utiliser LookupOp pour gérer l'éclaicissement en se basant sur la fonction de racine carrée, qui augmente les couleurs intermédiaire que celles qui sont claires ou foncées.

Toutefois, RescaleOp peut s'avérer utile pour des traitements auxquels nous ne pensons pas à priori. Par exemple, si vous récupérez une photo à partir d'un négatif, il est alors nécessaire d'inverser toutes les couleurs de l'image. Ce type d'opération est linéaire, donc cette classe est tout-à-fait adaptée à la situation :

BufferedImage imageOriginale = ImageIO.read(new File("négatif.jpg"));
RescaleOp
opération = new RescaleOp(-1.0F, 255F, null);
BufferedImage imagefiltrée = opération.filter(imageOriginale, imagefiltrée);

 

Remplacement des intensités de couleur par inspection dans des tables créées de toutes pièces - LookupOp

L'opération LookupOp permet de spécifier une correspondance arbitraire des valeurs d'échantillonnage. Vous devez fournir un tableau précisant comment chaque valeur doit être transformée.

Le constructeur LookupOp() nécessite un objet de type LookupTable et éventuellement une carte de conseils d'affichage. En réalité, la classe LookupTable est abstraite. Il existe deux sous-classes concrètes : ByteLookupTable et ShortLookupTable. Comme les valeurs de couleur RVB sont des octets, nous utilisons généralement ByteLookupTable. Ce type de tableau peut être construit à partir d'un tableau d'octets et d'un indice entier.

Dans les constructeurs de type LookupTable (en réalité ByteLookupTable() et ShortLookupTable()) :

  1. Le premier argument est un décalage dans la table,
  2. Le deuxième est un tableau (d'entiers courts ou d'octets) à une ou deux dimensions suivant si nous décidons d'opérer les mêmes changements pour les trois couleurs ou si chaque composante RGB doit être modifiée de façon indépendante.

ByteLookupTable nouvelletable = new ByteLookupTable(décalage, tableauOctets);
opération = new LookupOp(nouvelletable, qualité);

Attention : La recherche est appliquée séparément à chaque valeur de couleur, mais pas à la couche alpha. Pour le traitement , il est alors nécessaire de prendre le type d'image BufferedImage.TYPE_INT_RGB. Vous ne pouvez pas appliquer une LookupOp sur une image à couleurs indexées. Dans ces images, chaque valeur d'échantillonnage est en fait un indice dans une palette de couleurs.


Voici un exemple qui permet d'inverser la valeur de toutes les couleurs. Cela peut être bien utile dans le cas où nous devons scanner des négatifs :


class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = inverser(ImageIO.read(new File("chouette.jpg")));
   }
   private BufferedImage inverser(BufferedImage source) {
      BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), BufferedImage.TYPE_INT_RGB);
      byte[] inverser = new byte[256];
      for (int i=0; i<256; i++) inverser[i] = (byte) (255-i);
      ByteLookupTable table = new ByteLookupTable(0, inverser);
      LookupOp inversion = new LookupOp(table, null);
      inversion.filter(source, image);
      return image;
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}  


Voici un autre exemple qui permet de récupérer une seule composante de couleur, comme ici le rouge :


class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = composanteRouge(ImageIO.read(new File("chouette.jpg")));
   }
   private BufferedImage composanteRouge(BufferedImage source) {
      BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), source.getType());
      byte[] normal = new byte[256];
      byte[] zéro = new byte[256];
      for (int i=0; i<256; i++) {
         normal[i] = (byte)i;
         zéro[i] = 0;
      }
      byte[][] rouge = { normal, zéro, zéro};
      ByteLookupTable table = new ByteLookupTable(0, rouge);
      LookupOp inversion = new LookupOp(table, null);
      inversion.filter(source, image);
      return image;
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}

Voici ci-dessous un ensemble de méthodes, à l'intérieur d'une classe dénommée LookupFabrique, qui peuvent s'avérer très utiles dans vos différents traitements d'image :

class LookupFabrique {
  static short[] clair = new short[256];
  static short[] meilleurClair = new short[256];
  static short[] simplifie = new short[256];
  static short[] inverser = new short[256];
  static short[] ident = new short[256];
  static short[] zero = new short[256];
  static {
    for (int i = 0; i < 256; i++) {
      clair[i] = (short)(128 + i / 2);
      meilleurClair[i] = (short)(Math.sqrt((double)i / 255.0) * 255.0);
      simplifie[i] = (short)(i - (i % 32));
      inverser[i] = (short)(255 - i);
      ident[i] = (short)i;
      zero[i] = (short)0;
    }
  }
  static LookupOp createClair() { 
      return new LookupOp(new ShortLookupTable(0, clair), null);
  }
  static LookupOp createMeilleurClair() { 
      return new LookupOp( new ShortLookupTable(0, meilleurClair), null);
  }
  static LookupOp createSimplifie() { 
      return new LookupOp( new ShortLookupTable(0, simplifie), null); 
  }
  static LookupOp createInverser() {
      return new LookupOp(new ShortLookupTable(0, inverser), null); 
  }

  static short[][] rougeSeul = { inverser, ident, ident };
  static short[][] vertSeul = { ident, inverser, ident };
  static short[][] bleuSeul = { ident, ident, inverser };

  static LookupOp createInverserRouge() {
    return new LookupOp( new ShortLookupTable(0, rougeSeul), null);
  }
      
  static short[][] sansRouge= { zero, ident, ident };
  static short[][] sansVert = { ident, zero, ident };
  static short[][] sansBleu = { ident, ident, zero };

  static LookupOp createSansRouge() {
    return new LookupOp( new ShortLookupTable(0, sansRouge), null);
  }
}

 

Conversion d'espace de couleurs - ColorConvertOp

A l'aide de la classe ColorConvertOp, il est possible de changer d'espace de couleurs. L'une des grosses utilisations, est le passage en noir et blanc d'une photo couleur. Le constructeur de cette classe réclame un objet de la classe ColorSpace qui définie le type de conversion à réaliser suivi, éventuellement, de la carte de conseils d'affichage.


class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = ImageIO.read(new File("chouette.jpg"));
      ColorConvertOp gris = new ColorConvertOp(ColorSpace.getInstance(ColorSpace.CS_GRAY), null);
      gris.filter(image, image);
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}








 

Calcul d'une convolution mathémathique - ConvolveOp

La plus puissante des transformations est ConvolveOp, qui effectue une convolution mathématique. Nous préférons éviter d'entrer trop dans les détails mathématiques des convolutions, mais l'idée principale à retenir est assez simple.

Considérons, par exemple, l'élaboration d'un filtre de flou. Cet effet est obtenu en remplaçant chaque pixel par la valeur moyenne des huits pixels voisins et du point considéré. Intuitivement, nous pouvons comprendre pourquoi cette opération rend l'image plus floue. Mathématiquement, cette moyenne peut être exprimée sous la forme d'une convolution utilisant le noyau suivant :

| 1/9    1/9    1/9 |
| 1/9    1/9    1/9 |
| 1/9    1/9    1/9 |

Le noyau d'une convolution est une matrice qui indique le coefficient multiplicateur appliqué à chaque voisin du pixel considéré.
.

Pour construire une opération de convolution, il faut commencer par définir un tableau contenant les valeurs du noyau, puis construire un objet Kernel (noyau). Ensuite, construisez une objet ConvolveOp à partir du noyau, et servez-vous-en pour filtrer l'image. En réalité, il existe deux constructeurs ConvolveOp() :

ConvolveOp(Kernel noyau)
ConvolveOp(Kernel noyau, int conditionAuBord, RenderingHints qualité)

Les conditions au bord sont :

  1. EDGE_NO_OP : Les pixels du bord sont copiés sans modification.
  2. EDGE_ZERO_FILL : Les pixels au bord sont traités comme s'ils étaient entourés de zéros (par défaut).

Voici donc quelques exemples où vous pourrez remarquer, qu'avec très peu d'écriture, vous avez des résultats spectaculaires :

Flou artistique (moyenneur)

class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = flou(ImageIO.read(new File("chouette.jpg")));
   }
   private BufferedImage flou(BufferedImage source) {
      BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), source.getType());
      float[] flou = new float[] {
         1f/9f, 1f/9f, 1f/9f,
         1f/9f, 1f/9f, 1f/9f,
         1f/9f, 1f/9f, 1f/9f
      };
      Kernel noyau = new Kernel(3, 3, flou);
      ConvolveOp opération = new ConvolveOp(noyau);
      opération.filter(source, image);
      return image;
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}
Détection de contour

class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = contour(ImageIO.read(new File("chouette.jpg")));
   }
   private BufferedImage contour(BufferedImage source) {
      BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), source.getType());
      float[] contour = new float[] {
         0f, -1f, 0f,
         -1f, 4f, -1f,
         0f, -1f, 0f
      };
      Kernel noyau = new Kernel(3, 3, contour);
      ConvolveOp opération = new ConvolveOp(noyau);
      opération.filter(source, image);
      return image;
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}
Accentuation des couleurs (augmentation de la netteté)

class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = accentuation(ImageIO.read(new File("chouette.jpg")));
   }
   private BufferedImage accentuation(BufferedImage source) {
      BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), source.getType());
      float[] accentuation = new float[] {
         0f, -1f, 0f,
         -1f, 5f, -1f,
         0f, -1f, 0f
      };
      Kernel noyau = new Kernel(3, 3, accentuation);
      ConvolveOp opération = new ConvolveOp(noyau);
      opération.filter(source, image);
      return image;
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}  
Détection de contour suivant l'axe des x par le détecteur de Sobel

class Zone extends JComponent {
   private BufferedImage image;
   public Zone() throws IOException {
      image = filtre(ImageIO.read(new File("chouette.jpg")));
   }
   private BufferedImage filtre(BufferedImage source) {
      BufferedImage image = new BufferedImage(source.getWidth(), source.getHeight(), source.getType());
      float[] filtre = new float[] {
         1f/4f, 0f, -1f/4f,
         2f/4f, 0f, -2f/4f,
         1f/4f, 0f, -1f/4f
      };
      Kernel noyau = new Kernel(3, 3, filtre);
      ConvolveOp opération = new ConvolveOp(noyau);
      opération.filter(source, image);
      return image;
   }
   protected void paintComponent(Graphics surface) {
      surface.drawImage(image, 0, 0, null);   
   }   
}

Pour l'axe des y, il faut proposer le noyau suivant :

      float[] filtre = new float[] {
         -1f/4f, -2f/4f, -1f/4f,
         0f, 0f, 0f,
         1f/4f, 2f/4f, 1f/4f
      };

 

Choix du chapitre Récupération des données EXIF d'une image JPEG prise par un appareil photo

Pour terminer toute cette étude, je vous propose maintenant de pouvoir récupérer des informations de prises de vue issues d'un appareil photo et dont les données sont codées à l'intérieur même d'une image JPEG. Ce codage respecte un standard, et ce type de données est stockée suivant le format EXIF.

En réalité, il n'existe pas dans le JDK actuel de classes qui permettent de récupérer ces données EXIF, vous devez, soit réaliser un ensemble de classes permettant de réaliser ces opérations ou alors passer par une bibliothèque tierce qui traite de ce genre de problème. Cette bibliothèque existe en effet au site : http://www.drewnoakes.com/code/exif/.

J'ai personnellement utilisée cette archive et vous avez ci-dessous un exemple d'utilisation. Si vous de désirez pas allez sur le site en question, vous pouvez toujours télécharger l'archive que j'ai moi-même récupérée : metadata-extractor-2.3.1.jar

Attention, dans vos projets, vous devez impérativement intégrer cette archive pour que le fonctionnement soit correct.
.

import com.drew.imaging.jpeg.*;
import com.drew.metadata.*;
import com.drew.metadata.exif.*;
import java.io.*;
import java.util.*;

public class  Principal {
   public static void main(String[] args) throws JpegProcessingException, MetadataException {
      Metadata metadata = new Metadata();
      new ExifReader(new File("image.jpg")).extract(metadata);    
      Directory exif = metadata.getDirectory(ExifDirectory.class);
      System.out.println("Vitesse : "+exif.getDescription(ExifDirectory.TAG_EXPOSURE_TIME));
      System.out.println("Ouverture : "+exif.getDescription(ExifDirectory.TAG_FNUMBER));
      System.out.println("largeur : "+exif.getInt(ExifDirectory.TAG_EXIF_IMAGE_WIDTH));
      System.out.println("hauteur : "+exif.getInt(ExifDirectory.TAG_EXIF_IMAGE_HEIGHT));
      System.out.println("ASA : "+exif.getDescription(ExifDirectory.TAG_ISO_EQUIVALENT));
      System.out.println("Date : "+exif.getDate(ExifDirectory.TAG_DATETIME));
      System.out.println("Fabriquant : "+exif.getDescription(ExifDirectory.TAG_MAKE));
      System.out.println("Modèle d'appareil : "+exif.getDescription(ExifDirectory.TAG_MODEL));
      System.out.println("Décalage d'exposition : "+exif.getDescription(ExifDirectory.TAG_EXPOSURE_BIAS));
      System.out.println("Focale : "+exif.getDescription(ExifDirectory.TAG_FOCAL_LENGTH));
      System.out.println("Horizontal / Vertical : "+exif.getDescription(ExifDirectory.TAG_ORIENTATION));
      System.out.println("Type de priorité : "+exif.getDescription(ExifDirectory.TAG_EXPOSURE_PROGRAM));
      System.out.println("Mesure d'exposition : "+exif.getDescription(ExifDirectory.TAG_EXPOSURE_MODE));
      System.out.println("Mode d'exposition : "+exif.getDescription(ExifDirectory.TAG_METERING_MODE));
      System.out.println("Flash : "+exif.getDescription(ExifDirectory.TAG_FLASH));
      System.out.println("Balance des blancs : "+exif.getDescription(ExifDirectory.TAG_WHITE_BALANCE_MODE));
/*      Iterator directories = metadata.getDirectoryIterator();
      while (directories.hasNext()) {
          Directory directory = (Directory)directories.next();
          // iterate through tags and print to System.out
          Iterator tags = directory.getTagIterator();
          while (tags.hasNext()) {
              Tag tag = (Tag)tags.next();
              // use Tag.toString()
              System.out.println(tag);
          }
      }  */
   }
}

Voici le résultat :

Vitesse : 1/160 sec
Ouverture : F16
largeur : 3504
hauteur : 2336
ASA : 400
Date : Mon Jun 05 17:41:24 CEST 2006
Fabriquant : Canon
Modèle d'appareil : Canon EOS 20D
Décalage d'exposition : 0 EV
Focale : 105,0 mm
Horizontal / Vertical : Top, left side (Horizontal / normal)
Type de priorité : Aperture priority
Mesure d'exposition : Auto exposure
Mode d'exposition : Multi-segment
Flash : Flash did not fire, auto
Balance des blancs : Manual white balance

L'objet exif de la classe Directory dispose d'un ensemble de méthodes qui permettent de récupérer les informations souhaitées. Vous devez alors préciser, en paramètre de ces méthodes, l'information requise en choisissant la constante adaptée de la classe ExifDirectory.

Voici l'ensemble de ces méthodes :

  1. getDescription() : qui délivre, sous forme de chaîne, la description complète de la donnée souhaitée, avec en plus l'unité de mesure si cela se présente. Certaines description ne fonctionnent qu'avec cette méthode. Ce sera la plus utilisée.
  2. getString() : qui propose également l'information souhaitée sous forme de chaîne de caractères mais de façon plus réduite, généralement sans les unités.
  3. getInt(), getDouble(), getDate(), etc. : Ces méthodes sont intéressantes puisqu'elles formattent l'information dans le type de valeur adapté au besoin des différents calculs si nécessaire.

 

Choix du chapitre Logiciel de traitement d'images

Et pour conclure, voici un tout petit logiciel de traitement d'images, qui n'a bien entendu aucune prétention, mais qui toutefois, met en oeuvre les différentes classes que nous venons de découvrir tout au long de cette étude :

Pour découvrir l'ensemble du projet défini au moyen de Netbeans : Traitement images.zip