Manipulation d'images avec PyQt

Vincent Vande Vyvre

Ce tutoriel est destiné à ceux qui découvrent la bibliothèque Qt et ses nombreuses possibilités dans le domaine de l'imagerie. Nous y aborderons les principes de base en personnalisant une interface et en réalisant une visionneuse d'image. Commentez

- Titre : Manipulation d'images avec PyQt

- Auteur : Vincent Vande Vyvre

- Parution : 3 avril 2011

- Mise à jour : 3 avril 2011

- Licence : Copyright ® 2011 Vincent Vande Vyvre. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- I. Introduction

- II. Les types d'images Qt

- III. Les formats d'image

- IV. Les modes d'ouverture

- V. Les supports

- VI. Visionnage d'images

- VII. Conclusions

- VIII. Liens

- IX. Remerciements

I - Introduction

La manipulation d'images revêt plusieurs aspects. Tout d'abord l'utilisation d'images sur des constituants de l'interface, appelés widgets, à des fins de personnalisation de cette interface, ensuite les traitements simples des images, classement, visionnage, gestion des métadonnées et quelques manipulations de base telles que pivotement, redimensionnement, etc. Pour terminer, le traitement d'image impliquant une modification de ses propriétés, accès aux pixels, colorimétrie, etc.

Nous verrons dans ce tutoriel les deux premiers aspects de la manipulation d'images.

II - Les types d'images Qt

QPixmap
Une pixmap est optimisée pour l'affichage à l'écran, celle-ci est chargée en mémoire du côté serveur X ou dans la mémoire de la carte graphique. Les données stockées dans l'instance d'un QPixmap ne sont qu'une référence à l'image chargée sur le serveur X. Son avantage est de profiter des ressources matérielles (accélération graphique) mais, par contre, cela implique que l'image soit traitée dans la boucle principale du programme et non pas dans un thread séparé.
Une visionneuse utilisera de préférence des QPixmap.

QImage
QImage permet un accès direct aux pixels de l'image, est indépendant du matériel, peut être utilisé dans un thread séparé, mais ne profite pas de l'accélération matérielle.
Les QImage seront préférés dans une application de traitement d'images.

QPicture
Les QPicture sont des supports de dessin permettant d'enregistrer une suite de commandes de QPainter et de les reproduire sur diverses images.
Utilisé pour la sérialisation de dessins, estampillages, etc.

QBitmap
QBitmap désigne une image monochrome, avec un bit à 0 pour l'arrière-plan (ou pixel transparent) et un bit à 1 pour l'avant-plan (ou pixel opaque).
L'exemple le plus courant étant la création de curseur personnalisé.

QIcon
Les QIcon sont des objets dynamiques en ce sens qu'ils sont redimensionnables selon les besoins de l'interface et peuvent revêtir divers états : désactivé, actif, survolé, cliqué, etc. Les QIcon sont généralement construits à partir de Qpixmap.
Nous en verrons des exemples d'utilisation sur des widgets.

III - Les formats d'image

Par défaut, Qt peut lire les formats BMP, GIF, ICO, JPEG, JPG, MNG, PBM, PGM, PNG, PPM, SVG, TIF, TIFF, XBM, XPM
et peut enregistrer sous les formats BMP, ICO, JPEG, JPG, PNG, PPM, TIF, TIFF, XBM, XPM.

L'ajout d'autres formats doit se faire par plug-in.

Les deux commandes suivantes permettent de savoir quels sont les formats d'image supportés par votre version de Qt¦ :

Formats supportés en lecture:

for format in QtGui.QImageReader.supportedImageFormats():
print format

Formats supportés en écriture :

for format in QtGui.QImageWriter.supportedImageFormats():
print format

IV - Les modes d'ouverture

Diverses manières permettent d'instancier un objet image, entrons dans le code.

# Avec le nom de fichier
image = QtGui.QImage("fichierImage.jpg")
pixmap = QtGui.QPixmap("fichierImage.jpg")

# L'extension peut être séparée
image = QtGui.QImage("fichierImage", "jpg")
pixmap = QtGui.QPixmap("fichierImage", "jpg")

# ou ignorée
image = QtGui.QImage("fichierImage")
pixmap = QtGui.QPixmap("fichierImage")
# dans ce cas Qt déterminera le format par l'en-tête du fichier.
# ! Lorsque l'extension est donnée, celle-ci doit être exacte.

# Avec une autre instance d'image
image = QtGui.QImage("fichierImage.jpg")
pixmap = QtGui.QPixmap(image)

# QPixmap possède une méthode .toImage()
image = pixmap.toImage() ==  image = QtGui.QImage(pixmap)

# et une méthode .fromImage(image, flag)
pixmap = QtGui.QPixmap.fromImage(image, 0)

les différentes valeurs de 'flag' (0 par défaut) peuvent être trouvées ici :
http://www.riverbankcomputing.co.uk/static/Docs/PyQt4/html/qt.html#ImageConversionFlag-enum

À ce stade, il devient utile de savoir si l'image a été correctement chargée.

En effet, en cas de non-ouverture du fichier, Qt ne retourne pas d'erreur mais crée une "null pixmap", il est donc souvent indispensable de vérifier le bon chargement de l'image au moyen des méthodes QPixmap.isNull() et QImage.isNull().

if image.isNull():
print "Image non valide !"

V - Les supports

Les images peuvent être appliquées à différents widgets selon qu'elles sont destinées à la décoration de l'interface ou qu'elles sont l'objet même de l'application.
Nous allons voir l'utilisation de ces différents supports par la pratique, pour cela nous allons créer une interface qui nous servira tout au long de ce tutoriel.

imageViewer.py

# -*- coding: utf-8 -*-

import sys
import os

from PyQt4 import QtCore, QtGui

class ImageViewer(object):
def setupUi(self, Viewer):
Viewer.resize(640, 480)
Viewer.setWindowTitle(u"Exemples d'usage d'images")
self.image_1 = "image1.jpg"
self.image_2 = "image2.png"
self.centralwidget = QtGui.QWidget(Viewer)
self.gridLayout = QtGui.QGridLayout(self.centralwidget)
self.verticalLayout_2 = QtGui.QVBoxLayout()
self.horizontalLayout = QtGui.QHBoxLayout()

# QLabel
self.label = QtGui.QLabel(self.centralwidget)
sizePolicy = QtGui.QSizePolicy(QtGui.QSizePolicy.Preferred,
QtGui.QSizePolicy.Fixed)
self.label.setSizePolicy(sizePolicy)
self.label.setPixmap(QtGui.QPixmap(self.image_1))
self.label.setScaledContents(True)
self.horizontalLayout.addWidget(self.label)

self.verticalLayout = QtGui.QVBoxLayout()
self.label_cmb = QtGui.QComboBox(self.centralwidget)
self.verticalLayout.addWidget(self.label_cmb)
spacerItem = QtGui.QSpacerItem(20, 18, QtGui.QSizePolicy.Minimum,
QtGui.QSizePolicy.Fixed)
self.verticalLayout.addItem(spacerItem)
self.horizontalLayout.addLayout(self.verticalLayout)
self.verticalLayout_2.addLayout(self.horizontalLayout)
self.horizontalLayout_2 = QtGui.QHBoxLayout()

# QPushButton
self.pushButton = QtGui.QPushButton(self.centralwidget)
self.pushButton.setText("PushButton")
icon1 = QtGui.QIcon()
icon1.addPixmap(QtGui.QPixmap(self.image_2),QtGui.QIcon.Normal,
QtGui.QIcon.Off)
self.pushButton.setIcon(icon1)
self.horizontalLayout_2.addWidget(self.pushButton)

self.push_cmb = QtGui.QComboBox(self.centralwidget)
self.horizontalLayout_2.addWidget(self.push_cmb)

# QToolButton
self.toolButton = QtGui.QToolButton(self.centralwidget)
self.toolButton.setText("toolButton")
self.toolButton.setIcon(icon1)
self.toolButton.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
self.horizontalLayout_2.addWidget(self.toolButton)

self.tool_cmb = QtGui.QComboBox(self.centralwidget)
self.horizontalLayout_2.addWidget(self.tool_cmb)
self.verticalLayout_2.addLayout(self.horizontalLayout_2)
self.horizontalLayout_3 = QtGui.QHBoxLayout()

# QRadioButton
self.radioButton = QtGui.QRadioButton(self.centralwidget)
self.radioButton.setText("RadioButton")
self.radioButton.setIcon(icon1)
self.horizontalLayout_3.addWidget(self.radioButton)

# QCheckBox
self.checkBox = QtGui.QCheckBox(self.centralwidget)
self.checkBox.setText("CheckBox")
self.checkBox.setIcon(icon1)
self.checkBox.setLayoutDirection(QtCore.Qt.RightToLeft)
self.horizontalLayout_3.addWidget(self.checkBox)
spacerItem1 = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding,
QtGui.QSizePolicy.Minimum)
self.horizontalLayout_3.addItem(spacerItem1)

# Colors comboBox
self.colors_cmb = QtGui.QComboBox(self.centralwidget)
self.horizontalLayout_3.addWidget(self.colors_cmb)
self.verticalLayout_2.addLayout(self.horizontalLayout_3)

# QGraphicsView
self.vue = QtGui.QGraphicsView(self.centralwidget)
self.verticalLayout_2.addWidget(self.vue)

self.horizontalLayout_4 = QtGui.QHBoxLayout()
self.horizontalLayout_4.setObjectName("horizontalLayout_4")
self.image_btn = QtGui.QToolButton(self.centralwidget)
self.image_btn.setText("Image")
self.image_btn.setObjectName("image_btn")
self.horizontalLayout_4.addWidget(self.image_btn)
spacerItem2 = QtGui.QSpacerItem(40, 20, QtGui.QSizePolicy.Expanding,
QtGui.QSizePolicy.Minimum)
self.horizontalLayout_4.addItem(spacerItem2)
self.verticalLayout_2.addLayout(self.horizontalLayout_4)
self.gridLayout.addLayout(self.verticalLayout_2, 0, 0, 1, 1)
Viewer.setCentralWidget(self.centralwidget)

self.populate_combos()

# Connections
self.label_cmb.currentIndexChanged.connect(self.update_label)
self.push_cmb.currentIndexChanged.connect(self.update_push_button)
self.tool_cmb.currentIndexChanged.connect(self.update_tool_button)

def populate_combos(self):
items = ["setScaledContents(True)", "setScaledContents(False)"]
self.label_cmb.addItems(items)
items = ["Modifier...", "setAutoDefault()", "setDefaut()", "setFlat()"]
self.push_cmb.addItems(items)

items = ["Modifier...", "setAutoRaise()", "ToolButtonIconOnly",
"ToolButtonTextOnly", "ToolButtonTextBesideIcon",
"ToolButtonTextUnderIcon", "ToolButtonFollowStyle"]
self.tool_cmb.addItems(items)

names = ["Rouge", "Vert", "Bleu"]
colors = [QtGui.QColor(255, 0, 0, 255), QtGui.QColor(0, 255, 0, 255),
QtGui.QColor(0, 0, 255, 255)]
pix = QtGui.QPixmap(QtCore.QSize(30, 10))
self.colors_cmb.setIconSize(QtCore.QSize(30, 10))
for idx, name in enumerate(names):
pix.fill(colors[idx])
icon = QtGui.QIcon(pix)
self.colors_cmb.addItem(icon, name)

def update_label(self, idx):
self.label.setScaledContents(not self.label.hasScaledContents())

def update_push_button(self, idx):
if not idx:
return
if idx == 1:
self.pushButton.setAutoDefault(not self.pushButton.autoDefault())
elif idx == 2:
self.pushButton.setDefault(not self.pushButton.isDefault())
else:
self.pushButton.setFlat(not self.pushButton.isFlat())

def update_tool_button(self, idx):
if not idx:
return
if idx == 1:
self.toolButton.setAutoRaise(not self.toolButton.autoRaise())
else:
self.toolButton.setToolButtonStyle(idx-2)

if __name__ == "__main__":
import sys
app = QtGui.QApplication(sys.argv)
Viewer = QtGui.QMainWindow()
ui = ImageViewer()
ui.setupUi(Viewer)
Viewer.show()
sys.exit(app.exec_())

Dans ce code, vous aurez à remplacer dans les lignes 12 et 13 les chemins des images situées sur votre disque. Pour self.image_2, choisissez de préférence une image de taille réduite. Une icône fera très bien l'affaire.

Remarque sur les chemins des images :

- soit les images sont dans le même dossier que le script, utilisez "imageX.jpg" ;

- soit elles sont dans un sous-dossier (ex. "medias/") utilisez, "medias/imageX.jpg" ;

- soit elles sont dans le dossier parent (vers le haut) utilisez, "../imageX.jpg" ;

- soit vous ne vous en sortez pas, utilisez le chemin complet ;

- d'autre part, "Couché-de-soleil.jpg" retournera une image nulle, utilisez u"Couché-de-soleil.jpg".
(Non, Qt n'est pas sensible aux fautes d'orthographe.)

Voyons le code, tout d'abord le QLabel, rien de compliqué, un QPixmap ou un QPicture peuvent lui être appliqué directement. Un argument optionnel permet d'ajuster la taille de l'image à l'espace du QLabel, mais cet agrandissement n'est pas proportionnel, l'image peut donc apparaître trop étirée dans un des deux axes.

On ne peut joindre texte et image dans un Qlabel.

Les QPushButton et QToolButton demandent la création d'un QIcon qui sera appliqué avec la méthode setIcon(icon).

Une option permet de déterminer la taille de l'icône mais sera inopérante si la taille choisie est supérieure à celle du bouton. Il est souvent préférable de laisser Qt choisir la taille de l'icône.

Vous remarquerez, dans les combos que ces deux types de boutons n'ont pas les mêmes options d'apparence.

Pour obtenir un résultat comme celui-ci :

on choisira des QPushButton.

Les QRadioButton et QCheckBox demandent aussi un QIcon pour l'insertion d'une image. Ici, les options d'apparence se limitent à la direction du widget, c'est-à-dire que la case à cocher peut être positionnée à droite comme dans le cas de la QCheckBox.

Pour terminer avec les widgets pouvant être décorés au moyen d'images, nous avons un QComboBox où les images sont insérées au moyen de QIcon. Ces icônes étant créées à partir de pixmaps dans la dernière partie de la fonction populate_combos().

Toutes les possibilités d'insertion d'image dans tous les widgets possibles ne peuvent être vues ici, mais les méthodes utilisées dans le code devraient permettre de répondre à toutes les situations.

Des personnalisations d'interface plus poussées, couleur de fond, couleur de texte, image de fond, feront appel au styleSheet.

VI - Visionnage d'images

Lorsque l'image est l'objet même de l'application, comme dans une visionneuse, divers widgets peuvent servir de support tels que QFrame, QWidget ou encore QScrollArea.
Ces widgets impliquent toutefois que notre code implémente les fonctions de positionnement ou de centrage qui peuvent s'avérer de vrais casse-tête, surtout si l'on désire que la fenêtre soit redimensionnable ou, dans le cas du QScrollArea où l'apparition d'une barre de défilement repositionne systématiquement l'image dans le coin supérieur gauche du widget.

Qt nous propose un conteneur beaucoup plus performant pour l'affichage d'image: le QGraphicsView.
Plus exactement, la paire QGraphicsView et QGraphicsScene.

Le QGraphicsView ou la vue, est l'espace physique dans lequel se positionnera la scène. Ce widget hérite de QAbstractScrollArea ce qui nous permettra de profiter de fonctionnalités "ready-to-use" comme nous le verrons avec l'outil panoramique.

Le QGraphicsScene ou la scène, est l'espace virtuel dans lequel nous placerons notre image. Cet espace étant virtuel, il ne doit pas obligatoirement être dimensionné, dans ce tuto, nous lui donnerons cependant, les dimensions de l'image à afficher, pour profiter, entre autres, du centrage automatique de l'image dans la scène.

La vue sera donc une fenêtre sur un espace illimité par défaut.

Dans le code, nous avons utilisé la méthode la plus simple pour instancier la vue :

self.vue = QtGui.QGraphicsView(self.centralwidget)
self.verticalLayout_2.addWidget(self.vue)

Nous créerons la scène lorsque nous importerons l'image puisque nous avons choisi de lui donner les dimensions de l'image.

La scène aurait pu être créée dès le départ en utilisant la méthode suivante :

self.scene = QtGui.QGraphicsScene()
self.vue = QtGui.QGraphicsView(self.scene)
self.verticalLayout_2.addWidget(self.vue)

Remarquez : pas de parent pour la scène, la vue a la scène pour parent et c'est toujours la vue que nous plaçons dans le layout.

Complétons notre code, dans le groupe des connexions (lignes 104-106), ajoutons une ligne :

self.image_btn.clicked.connect(self.get_image)

ensuite ajoutons la fonction get_image() à la fin de notre classe :

def get_image(self):
img = unicode(QtGui.QFileDialog.getOpenFileName(Viewer,
u"Ouverture de fichiers",
"", "Image Files (*.png *.jpg *.bmp)"))
if not img:
return
self.open_image(img)

Cette fonction permettra de choisir une image en cliquant sur le bouton "Image". Remarquez l'utilisation d'Unicode pour éviter le problème d'ouverture cité plus avant.

Terminons en ajoutant les fonctions open_image() et view_current() :

def open_image(self, path):
w_vue, h_vue = self.vue.width(), self.vue.height()
self.current_image = QtGui.QImage(path)
self.pixmap = QtGui.QPixmap.fromImage(self.current_image.scaled(w_vue, h_vue,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation))
self.view_current()

def view_current(self):
w_pix, h_pix = self.pixmap.width(), self.pixmap.height()
self.scene = QtGui.QGraphicsScene()
self.scene.setSceneRect(0, 0, w_pix, h_pix)
self.scene.addPixmap(self.pixmap)
self.vue.setScene(self.scene)

C'est ici que les choses deviennent intéressantes, voyons ces fonctions en détail.

La fonction open_image() :

Nous partons du principe que nous afficherons notre image à la taille de la vue, nous implémenterons un zoom ensuite, donc commençons par extraire la taille de la vue w_vue et h_vue, respectivement largeur et hauteur.

Créons notre image en tant que QImage, celle-ci ne sera jamais affichée mais nous en aurons besoin plus tard.

Nous créons ensuite notre pixmap à la dimension de la vue.

Les arguments de la méthode .scaled() :

- Les modes de redimensionnement :

- Les méthodes de redimensionnement :

La fonction view_current() :

Nous relevons les dimensions de notre pixmap, w_pix et h_pix.

Nous instancions une scène et nous lui attribuons les dimensions de notre pixmap.

Les deux dernières lignes placent notre image dans la scène et ensuite celle-ci dans la vue.

Variante : Si la scène a été créée directement avec la vue, comme indiqué plus haut, la fonction view_current() se présentera comme ceci :

def view_current(self):
w_pix, h_pix = self.pixmap.width(), self.pixmap.height()
self.scene.clear()
self.scene.setSceneRect(0, 0, w_pix, h_pix)
self.scene.addPixmap(self.pixmap)

la ligne 'self.vue.setScene(self.scene)' ne se justifiant plus ici.

Afin de comprendre pourquoi nous imposons à la scène les dimensions de l'image, je vous propose de tester le code avec des dimensions très différentes de l'image.
ex. si l'image mesure 2500x1800 px, essayez ceci :

self.scene.setSceneRect(0, 0, 6000, 4000)
# et ceci :
self.scene.setSceneRect(0, 0, 500, 300)

Testez le code et vous constaterez que l'image n'est plus centrée.

Remettez le code dans l'état initial.

self.scene.setSceneRect(0, 0, w_pix, h_pix)

Comme nous ne sommes jamais satisfaits, implémentons un zoom. Tout d'abord, il faut mettre la vue à l'écoute de la roulette de la souris. Dans la définition de la vue (ligne 84), rajoutons l'évènement wheelEvent.

Nous devons avoir ceci :

# QGraphicsView
self.vue = QtGui.QGraphicsView(self.centralwidget)
self.vue.wheelEvent = self.wheel_event
self.verticalLayout_2.addWidget(self.vue)

et, à la fin de notre code, ajoutons ces fonctions :

def wheel_event (self, event):
steps = event.delta() / 120.0
self.zoom(steps)
event.accept()

def zoom(self, step):
w_pix, h_pix = self.pixmap.width(), self.pixmap.height()
w, h = w_pix * (1 + 0.1*step), h_pix * (1 + 0.1*step)
self.pixmap = QtGui.QPixmap.fromImage(self.current_image.scaled(w, h,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.FastTransformation))
self.view_current()

Voyons cela en détail.

La fonction wheel_event() :
event.delta() nous retourne la rotation de la roulette en 1/8 de degré, la plupart des souris étant crantées tous les 15° nous divisons par 120 pour obtenir le nombre de crans, plus évident pour l'utilisateur. Autrement dit, "chaque cran = un niveau de zoom" ;
event.delta() sera positif en cas de rotation vers l'avant de la souris et négatif pour une rotation vers l'utilisateur.

La fonction zoom() :
Récupérons tout d'abord les dimensions de notre pixmap w_pix et h_pix.
Appliquons à ces dimensions notre facteur d'agrandissement comme ceci :
supposons une largeur de pixmap de 600 pxl, un pas de zoom de 0.1 (valeur que vous décidez vous-même) et deux steps (crans de souris),
nouvelle largeur = 600 * (1 + 0.1 * 2)

Maintenant, c'est ici que nous découvrons l'intérêt d'avoir conservé une instance de notre image originale, en effet, nous ne pouvons nous permettre de recharger l'image depuis le disque pour chaque saut de zoom et d'autre part, si nous redimensionnons directement notre pixmap nous allons voir celle-ci se dégrader de façon exponentielle, chaque redimensionnement amplifiant les erreurs du précédent.

Pour vous en convaincre, modifiez le code comme ceci : dans la fonction zoom() remplacez

self.pixmap = QtGui.QPixmap.fromImage(self.current_image.scaled(w, h,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.FastTransformation))

par ceci :

self.pixmap = self.pixmap.scaled(w, h, QtCore.Qt.KeepAspectRatio,
QtCore.Qt.FastTransformation)

Testez le code, la dégradation de l'image apparaît rapidement.

Il nous manque encore une chose, c'est de pouvoir déplacer l'image "à la main" lorsque celle-ci est plus grande que la vue.

Retournons dans notre code et ajoutons une ligne à la définition de notre vue pour obtenir ceci :

# QGraphicsView
self.vue = QtGui.QGraphicsView(self.centralwidget)
self.vue.setDragMode(QtGui.QGraphicsView.ScrollHandDrag)
self.vue.wheelEvent = self.wheel_event
self.verticalLayout_2.addWidget(self.vue)

C'est tout ce dont nous avons besoin pour notre outil panoramique. Les options de la méthode setDragMode() sont :

- QGraphicsView.noDrag supprimer toute fonctionnalité préalablement définie ;

- QGraphicsView.ScrollHandDrag déplacer l'image avec la souris ;

- QGraphicsView.RubberBandDrag tirer un rectangle de sélection.

Relancez le script, zoomez et déplacez l'image, nous avons construit une modeste visionneuse.

VII - Conclusions

Nous avons vu ici, les outils élémentaires de Qt permettant une manipulation simple d'images avec peu de lignes de code ainsi que les bases de la personnalisation d'interface.

Toutefois, ceci n'est qu'un aperçu des possibilités de Qt, des techniques plus poussées deviennent accessibles par une étude des classes suivantes :
QtGui.QImage ;
QtGui.QPixmap ;
QtGui.QImageReader ;
QtGui.QImageWriter ;
QtGui.QIcon ;
et QtGui.QMatrix.

VIII - Liens

Pour accéder à la documentation de l'ensemble des classes :
PyQt ;
PySide.

Et bien sûr, n'hésitez pas à utiliser le forum pour commenter cet article ou poser vos questions :
Forum PyQt PySide.

IX - Remerciements

Je tiens à remercier dourouc05Claude LELOUP et jacques_jean pour leurs conseils et impitoyable correction orthographique.