Manipulation d'images avec PyQt
Par
Vincent Vande Vyvre () (Blog)
Utilisation d'images dans les interfaces créées avec Qt
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.
image = QtGui.QImage (" fichierImage . jpg " )
pixmap = QtGui.QPixmap (" fichierImage . jpg " )
image = QtGui.QImage (" fichierImage " , " jpg " )
pixmap = QtGui.QPixmap (" fichierImage " , " jpg " )
image = QtGui.QImage (" fichierImage " )
pixmap = QtGui.QPixmap (" fichierImage " )
image = QtGui.QImage (" fichierImage . jpg " )
pixmap = QtGui.QPixmap (image)
image = pixmap.toImage () = = image = QtGui.QImage (pixmap)
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 que ces images
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
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 ()
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 ()
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)
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 ()
self.radioButton = QtGui.QRadioButton (self.centralwidget)
self.radioButton.setText (" RadioButton " )
self.radioButton.setIcon (icon1)
self.horizontalLayout_3.addWidget (self.radioButton)
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)
self.colors_cmb = QtGui.QComboBox (self.centralwidget)
self.horizontalLayout_3.addWidget (self.colors_cmb)
self.verticalLayout_2.addLayout (self.horizontalLayout_3)
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 ()
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, 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.size ().width (), self.vue.size ().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.size ().width (), self.pixmap.size ().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 par après, 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 :
Qt.IgnoreAspectRatio le rapport largeur/hauteur de l'image originale ne sera pas
respecté, dans la majorité des cas cela entrainera une
déformation disgracieuse ;
Qt.KeepAspectRatio le rapport de taille sera respecté et le plus petit agrandissement
possible sera utilisé. C'est le mode que nous choisissons ;
Qt.KeepAspectRatioByExpanding le rapport de taille sera respecté et le plus grand agrandissement
possible sera utilisé.
Note : il n'est pas impossible qu'une barre de défilement apparaisse, les dimensions de la vue
retournées par Qt peuvent être dépendantes du système ;
-
les méthodes de redimensionnement :
Qt.FastTransformation redimensionnement rapide sans antialias ;
Qt.SmoothTransformation redimensionnement avec antialias, le filtre utilisé est de type bilineaire.
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 la scène 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.size ().width (), self.pixmap.size ().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 )
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 satisfait, 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 :
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.size ().width (), self.pixmap.size ().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 apparait 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 :
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
IX. Remerciements