Utilisation de QGraphicsItem

Vincent Vande Vyvre

Ce tutoriel traite de l'utilisation des QGraphicsItem. Il est destiné aux développeurs Python ayant déjà de bonnes connaissances dans l'usage du framework Qt. Commentez

- Titre : Utilisation de QGraphicsItem

- Auteur : Vincent Vande Vyvre

- Parution : 3 octobre 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 items graphiques de base

- III. Les scripts Python

- IV. Principes de base

- V. Les drapeaux

- VI. Le script principal

- VII. Création de la loupe

- VIII. Création de la loupe de forme ronde.

- IX. Les déplacements

- X. Les signaux propres aux QGraphicsItem

- XI. Animation

- XII. Les effets graphiques

- XIII. Conclusions

- XIV. Liens

- XV. Remerciements

I - Introduction

QGraphicsItem est la classe de base pour tous les objets graphiques pouvant être affichés dans un QGraphicsScene. Cette classe permet de créer ses propres objets graphiques et de gérer leurs propriétés telles que les données de géométrie de l'objet et ses interactions avec les autres éléments présents dans la scène (dont la détection de collisions entre éléments graphiques).

II - Les items graphiques de base

Qt propose une série de QGraphicsItem de base destinés à représenter les objets graphiques les plus courants :

- QGraphicsEllipseItem : définit un item ellipse ;

- QGraphicsLineItem : définit un item ligne ;

- QGraphicsPathItem : définit un item chemin de tracé ;

- QGraphicsPixmapItem : définit un item pixmap ;

- QGraphicsPolygonItem : définit un item polygone ;

- QGraphicsRectItem : définit un item rectangle ;

- QGraphicsSimpleTextItem : définit un item simple texte ;

- QGraphicsTextItem : définit un item texte plus complexe que l'item précédent.

Nous nous intéresserons plus particulièrement, dans cet article au QGraphicsPixmapItem en réalisant un objet graphique utilisant à la fois les classes QPixmap, QPainter, QPainterPath, l'application de texture, la découpe de zone transparente, les évènements souris et clavier et les signaux pour communiquer avec l'interface utilisateur.
Pour cela nous réaliserons un outil loupe pour visionneuse d'image.

III - Les scripts Python

En bas de cet article, se trouvent les liens permettant de télécharger l'archive contenant les scripts nécessaires. Le code main.py est celui qu'il faut lancer, magnifier.py est celui de la loupe et sera principalement décrit ici et le script miniView.py est une version simplifiée de la visionneuse décrite dans cet article, et dont le code ne demande plus à être détaillé.

IV - Principes de base

Voyons d'abord le fonctionnement de la loupe. On part du principe que l'on dispose d'une image créée en tant que QImage à sa taille réelle et une pixmap QPixmap issue de l'image, réduite aux dimensions de la vue et affichée dans celle-ci.

La loupe aura trois paramètres : sa forme, sa taille et son facteur de grossissement.

L'image créée en premier étant la référence nécessaire de la visionneuse pour recréer la pixmap lors de zoom, on va en créer une copie et la mettre directement au facteur de grossissement choisi pour la loupe. On déposera ensuite sur cette copie un gabarit de la forme et de la taille de la loupe, qui servira à prélever la texture qui sera « peinte » dans le QGraphicsPixmapItem. Ensuite, par un système de pantographe informatique on assurera le déplacement du gabarit sur l'image de référence suivant les déplacements de la loupe effectués par l'utilisateur.

La classe Magnifier se présentera comme ceci :

magnifier.py

class Magnifier(QtGui.QGraphicsPixmapItem):
def __init__(self, main, parent=None, graphic=None, scene=None,
img=None, zoom=None):
super(Magnifier, self).__init__(parent, scene)
self.main = main
d = dict(shape = "square",
size = 160,
factor = 1.0,
image = img,
graphic = graphic,
scene = scene,
cur_pos = None,
zoom = zoom,
background_color = (255, 255, 255, 255),
old_X = None,
old_Y = None)
for key, value in d.iteritems():
setattr(self, key, value)

self.setFlags(QtGui.QGraphicsItem.ItemIsMovable |
QtGui.QGraphicsItem.ItemIsFocusable)
self.setAcceptHoverEvents(True)

self.build_settings()
self._moved = Moved()
self._optionChanged = OptionChanged()

Les arguments nécessaires à l'instanciation de la classe sont les suivants :

- main : l'application principale ;

- parent : le parent, généralement laissé à None ;

- graphic : l'instance du QGraphicsView de notre visionneuse utilisée pour centrer la loupe et pour mapper des coordonnées entre scène et vue ;

- scene : l'instance de la QGraphicsScene ;

- img : l'instance de la QImage originale ;

- zoom : le facteur de zoom actuel de la pixmap affichée dans la visionneuse.

La loupe nécessite quelques attributs supplémentaires :

- shape : la forme de la loupe, "square" ou "circle" ;

- size : sa taille en pixels ;

- factor : le facteur de grossissement ;

- cur_pos : la position courante de la loupe une fois affichée ;

- background_color : la couleur de fond du QGraphicsView ;

- old_X et old_Y : des références de positionnement lors de déplacement.

Après les attributs d'instances il faut initialiser quelques drapeaux nécessaires au QGraphicsItem.

V - Les drapeaux

La liste suivante décrit les drapeaux les plus couramment utilisés.

- QGraphicsItem.ItemIsMovable : l'item peut être déplacé avec la souris ; si l'item comporte des items enfants, ceux-ci seront déplacés avec lui et, si l'item fait partie d'un groupe d'items sélectionnés, l'ensemble de ceux-ci sera déplacé ;

- QGraphicsItem.ItemIsSelectable : l'item peut être sélectionné. La sélection pourra se faire au moyen de la souris, en cliquant dessus, ou en traçant un QRubberBand, ou de la méthode QGraphicsItem.setSelected(), ou encore par QGraphicsScene.setSelectionArea() ;

- QGraphicsItem.ItemIsFocusable : l'item peut avoir le focus. Indispensable pour des interactions avec le clavier ;

- QGraphicsItem.ItemSendsGeometryChanges : l'item peut notifier les changements décrits par QGraphicsItem.itemChange(). L'utiliser peut avoir une incidence sur les performances du programme ;

- QGraphicsItem.ItemSendsScenePositionChanges : l'item peut notifier ses changements de position. L'usage de ce drapeau est moins réducteur en performances que ItemSendsGeometryChanges, car il ne notifie que les déplacements de l'item, soit ItemScenePositionHasChanged ;

- QGraphicsItem.ItemIgnoresTransformations : l'item n'hérite pas des transformations de son parent. Lorsqu'un item possède des enfants, ceux-ci subissent aussi ses transformations (rotation, dimensionnement, etc.). Avec ce drapeau l'item enfant devient insensible aux transformations de son parent. Utile si l'item contient du texte, pour garantir que celui-ci reste toujours lisible ;

- QGraphicsItem.ItemIgnoresParentOpacity : l'item est insensible aux changements d'opacité de son parent. Par défaut, l'opacité d'un item, définie avec setOpacity(), s'applique aussi à ses items enfants ;

- QGraphicsItem.ItemDoesntPropagateOpacityToChildren : à l'inverse du drapeau précédent, ici c'est l'item qui ne transmet pas sa valeur d'opacité à ses items enfants.

Voir les documentations respectives de PyQt et PySide des GraphicsItemFlag.
Pour la loupe, il est nécessaire que celle-ci soit déplaçable et puisse recevoir le focus.

magnifier.py

self.setFlags(QtGui.QGraphicsItem.ItemIsMovable |
QtGui.QGraphicsItem.ItemIsFocusable)

Les dernières lignes du code de __init__()seront vues plus loin dans l'article.

VI - Le script principal

Le script main.py sert de script principal. Il instanciera l'interface graphique et ensuite la loupe lors d'un clic sur le bouton « Loupe ».

main.py

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

import sys

from PyQt4 import QtCore, QtGui

from miniView import MiniView
from magnifier import Magnifier

class Main(object):
def __init__(self):
MainWindow = QtGui.QMainWindow()
self.ui = MiniView()
self.ui.setupUi(MainWindow, self)
self.magnifier_current_settings = [1.0, "square", 160]
self.window_geometry = MainWindow.geometry()
self.ui.magnifier_btn.clicked.connect(self.set_magnifier)

def set_magnifier(self):
self.mgnf = Magnifier(self, graphic=self.ui.viewer,
scene=self.ui.scene,
img=self.ui.image,
zoom=self.ui.zoom)
self.set_magnifier_options()
self.mgnf.setup()
#self.apply_shadow_effect()
self.mgnf.positionChanged.connect(self.on_magnifier_moved)
self.mgnf.settingsChanged.connect(self.show_magnifier_settings)
self.mgnf.setFocus()

def set_magnifier_options(self):
self.mgnf.set_factor(self.magnifier_current_settings[0])
self.mgnf.set_shape(self.magnifier_current_settings[1])
self.mgnf.set_size(self.magnifier_current_settings[2])
self.show_magnifier_settings(self.magnifier_current_settings)
...

if __name__ == "__main__":
app = QtGui.QApplication(sys.argv)
main = Main()
sys.exit(app.exec_())

Les premières lignes ne devraient pas poser de questions, remarquons que l'on définit les trois paramètres de notre loupe dans une liste.
Lors d'un clic sur le bouton loupe, la fonction set_magnifier() est appelée, la loupe est instanciée avec ses arguments, ses paramètres de départ sont passés à l'instance par la fonction set_magnifier_options(), l'appel de setup() créera l'objet lui-même; pour finir, ses signaux sont connectés et le focus est placé sur la loupe.

Les trois autres fonctions non représentées ici ne servent qu'à l'affichage de données dans l'interface et à l'application d'un effet qui sera vu en fin d'article.

VII - Création de la loupe

Examinons la fonction setup().

magnifier.py

def setup(self):
self.cur_pos = self.__get_center()
self.setPos(self.cur_pos[2], self.cur_pos[3])
self._set_brush()
self.multi = self.factor * (1 / self.zoom)
self.__create_magnifier()
if not self.old_X:
self.recenter_magnifying_glass()
self.old_X, self.old_Y = self.pos().x(), self.pos().y()

La fonction __get_center() retourne une liste de quatre éléments; les coordonnées x et y du centre de la scène et les coordonnées x et y de coin supérieur gauche de notre loupe.

La fonction _set_brush() va simplement créer une brosse à la couleur de l'arrière-plan du QGraphicsView, blanche par défaut, mais on verra plus loin le problème qui se présente lorsque cette couleur est différente.

self.multi est le paramètre multiplicateur nécessaire pour le pantographe, il est le produit du facteur de grossissement multiplié par l'inverse du facteur de zoom.

La loupe est ensuite créée avec l'appel de __create_magnifier(), après quoi celle-ci sera recentrée dans la vue. Cette dernière opération est nécessaire lorsque le centre de la scène n'est pas au centre de la fenêtre.

magnifier.py

def __create_magnifier(self):
self.__set_pixmap()
self.orig = self.__get_orig_center()
if self.shape == "square":
self.__set_square()
else:
self.__set_circle()

def __set_pixmap(self):
w, h = self.image.width(), self.image.height()
self.base = self.image.scaled(w * self.factor, h * self.factor,
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation)

Tout d'abord __set_pixmap() est appelée qui construira self.base, l'image originale redimensionnée au facteur de grossissement de la loupe et qui devient l'image de référence dans laquelle on prélèvera la texture à placer dans le QGraphicsPixmapItem.

Ensuite, __get_orig_center() nous retourne une même liste que __get_center() sauf qu'ici c'est self.base qui sert de référence.

Pour finir, en fonction de la forme choisie, celle-ci est créée.

Commençons par la plus simple, la forme carrée.

magnifier.py

def __set_pix(self):
color = QtGui.QColor()
color.setRgb(0, 0, 0, 0)
pix = QtGui.QPixmap(QtCore.QSize(self.size + 4, self.size + 4))
pix.fill(color)
return pix

def __set_square(self):
self.pix = self.__set_pix()
rect = QtCore.QRect(0, 0, self.size + 4, self.size + 4)
painter = QtGui.QPainter()
painter.begin(self.pix)
painter.setPen(QtGui.QPen(QtCore.Qt.blue, 2,
QtCore.Qt.SolidLine, QtCore.Qt.RoundCap,
QtCore.Qt.MiterJoin))
painter.drawRect(rect)
painter.end()
self.__fill_square()

def __fill_square(self):
painter = QtGui.QPainter()
painter.begin(self.pix)
rect = QtCore.QRect(2, 2, self.size, self.size)
painter.eraseRect(rect)
irect = QtCore.QRect(self.orig[2], self.orig[3], self.size, self.size)
painter.drawImage(rect, self.base, irect)
painter.end()
self.setPixmap(self.pix)

La fonction __set_square() appelle tout d'abord __set_pix() qui lui retourne une instance de QPixmap transparente (color.setRgb(0, 0, 0, 0)) et de la taille choisie pour la loupe (augmentée de quatre pixels afin de lui tracer un cadre de deux pixels d'épaisseur).

Une instance de QPainter est créée, il n'est pas nécessaire dans ce code d'en conserver une référence, par contre; le painter demande que l'on lui désigne le support sur lequel peindre, appelé device dans la documentation anglaise, c'est ce que fait painter.begin(self.pix), puis un pinceau est créé, ici, de couleur bleue et de deux pixels d'épaisseur. Les arguments Qt.SolidLine, Qt.RoundCap et Qt.MiterJoin désignent respectivement le style de ligne, de terminaison et de jonction.

Une fois n'est pas coutume, la documentation nous montre cela en images : PyQt, PySide

Ensuite, le cadre est tracé avec la méthode drawRect(rect) en lui donnant en argument le rectangle défini à la deuxième ligne de la fonction après quoi, le painter est fermé avec painter.end().

On passe à la fonction __fill_square(), où une instance de QPainter est créée et le contenu du cadre est effacé avec la méthode eraseRect(rect). À la création, on sait que l'intérieur du cadre est vide mais lorsque la loupe sera déplacée, il faudra repeindre ce contenu. Bien sûr, la nouvelle texture remplace la précédente, mais uniquement dans la zone réellement repeinte. Ceci sera détaillé lors de la création de la loupe ronde.

Maintenant, la texture est appliquée au moyen de la méthode drawImage(cible, source, aire). Ses arguments devraient être clairs, la cible est la zone dans la pixmap où on désire peindre (donc l'intérieur du cadre), la source est l'image de base et l'aire est la superficie d'image à y prélever, donnée elle aussi avec une instance de QRect. Dans ce cas, les deux rectangles ont les mêmes dimensions, mais pas les mêmes origines. Si on utilise cette méthode avec deux rectangles de tailles différentes, Qt dimensionnera la source pour la faire entrer dans la cible. Des effets plus amusants qu'esthétiques peuvent être obtenus de cette manière.

Quelques exemples :

Après avoir fermé le painter, nous appliquons la pixmap à l'instance du QGraphicsPixmapItem. La loupe est maintenant visible et se positionnera au centre de la vue si nécessaire.

Ce schéma représente la création de la loupe avec les noms utilisés dans le code.

VIII - Création de la loupe de forme ronde.

magnifier.py

def __set_circle(self):
self.pix = self.__set_pix()
rect = QtCore.QRect(1, 1, self.size + 3, self.size + 3)
painter = QtGui.QPainter()
painter.begin(self.pix)
painter.setRenderHints(QtGui.QPainter.Antialiasing, True)
painter.setPen(QtGui.QPen(QtCore.Qt.blue, 1,
QtCore.Qt.SolidLine, QtCore.Qt.RoundCap,
QtCore.Qt.MiterJoin))
painter.drawEllipse(rect)
rect = QtCore.QRectF(2, 2, self.size, self.size)
self.path = QtGui.QPainterPath()
self.path.addEllipse(rect)
painter.drawPath(self.path)
painter.end()
self.__fill_circle()

À nouveau une pixmap transparente est créée comme support pour le painter auquel est appliqué un nouveau paramètre avec la méthode setRenderHints(), le mode de rendu. Les différents modes sont détaillés plus bas.

Pour le cadre nous utilisons la méthode drawEllipse(rect) qui prend en argument le rectangle dans lequel s'inscrit l'ellipse. Notons la légère différence de dimension du cadre, due au mode de tracé, si on avait utilisé les mêmes dimensions, le cercle aurait eu les points cardinaux légèrement aplatis.

Ensuite, un chemin de traçage est créé, un QPainterPath, circulaire et légèrement plus petit que notre cadre et que l'on trace dans le pixmap.

Les QPainterPath sont des chemins utilisés pour le tracé de formes diverses et/ou pour leur remplissage. Il peut s'agir de formes géométriques de base, lignes, arcs, rectangles, ellipses ou de formes géométriques complexes conçues à partir des formes de bases. Si vous avez déjà fait du dessin vectoriel, alors vous avez déjà utilisé des chemins ils en sont la base.

Les modes de rendus de QPainter :

- QPainter.Antialiasing : utilise le mode anticrénelage ;

- QPainter.TextAntialiasing : utilise le mode anticrénelage pour le texte si possible. Pour empêcher l'utilisation de ce rendu, n'utilisez pas (QPainter.TextAntialiasing, False) mais plutôt QFont.NoAntialias dans la configuration de polices ;

- QPainter.SmoothPixmapTransform  : utilise un algorithme bilinéaire à la place de l'algorithme de voisinage utilisé dans le premier mode ;

- QPainter.HighQualityAntialiasing : (rendu OpenGl), nécessite de s'assurer du support OpenGl sur la plateforme où tournera le programme ;

- QPainter.NonCosmeticDefaultPen : applique par défaut la plume non cosmétique. Une plume est dite cosmétique lorsque son épaisseur ne sera pas modifiée par des changements d'échelle de l'objet graphique.

magnifier.py

def __fill_circle(self):
painter = QtGui.QPainter()
brush = QtGui.QBrush()
brush.setTexture(self.__set_texture())
painter.begin(self.pix)
painter.fillPath(self.path, brush)
painter.end()
self.setPixmap(self.pix)

def __set_texture(self):
texture = QtGui.QPixmap(QtCore.QSize(self.size + 1, self.size + 1))
irect = QtCore.QRect(self.orig[2], self.orig[3],
self.size + 1, self.size + 1)
prect = QtCore.QRect(0, 0, self.size + 1, self.size + 1)
painter = QtGui.QPainter()
painter.begin(texture)
painter.fillRect(prect, self.brush)
painter.drawImage(prect, self.base, irect)
painter.end()
return texture

Le remplissage du cercle se fera ensuite avec la méthode fillPath(path, brush) où la brosse représente une pixmap créée par la fonction __set_texture(). Celle-ci demande d'être vue en détail.

D'abord, une pixmap de la taille de notre loupe est créée; ensuite, deux rectangles sont définis; irect qui a pour origine le coin supérieur gauche du gabarit dans l'image de référence et prect destiné au painter. Un QPainter est instancié et appliqué au pixmap texture, après quoi on remplit ce pixmap avec la brosse créée précédemment et qui contient la couleur d'arrière-plan du QGraphicsView puis on peint la zone d'image définie par irect sur le pixmap.

Pourquoi remplir d'abord le pixmap avec la couleur d'arrière-plan de la visionneuse ?

Parce que lorsque la loupe sortira de l'image, la zone de texture ne remplira plus totalement la loupe et les anciennes textures resteront visibles. Pour vérifier ceci, il faut commenter la ligne 221 painter.eraseRect(rect), lancer le script, créer une loupe et la sortir de l'image.

On devrait obtenir un résultat comme ceci:

Résumé en images de la construction de notre loupe ronde.

IX - Les déplacements

Afin d'être déplacé au moyen de la souris, un QGraphicsItem doit accepter les signaux d'évènements de celle-ci.

Pour rappel, la construction du QGraphicsItem définissait les propriétés suivantes :

self.setFlags(QtGui.QGraphicsItem.ItemIsMovable |
QtGui.QGraphicsItem.ItemIsFocusable)
self.setAcceptHoverEvents(True)

Outre les drapeaux dont on a vu la signification plus haut, il est défini que l'item accepte les évènements de survol de la souris par la méthode setAcceptHoverEvents(True). La réimplémentation de l'évènement hoverEnterEvent() sera sans effet si cette propriété n'est pas explicitement définie.

L'évènement hoverEnterEvent() est utilisé ici pour changer l'aspect du pointeur de la souris afin de signifier à l'utilisateur la possibilité de déplacer la loupe.

magnifier.py

def hoverEnterEvent(self, event):
shape = QtGui.QPixmap("medias/cross_cursor_32.png")
cursor = QtGui.QCursor(shape, -1, -1)
self.setCursor(cursor)

Ce pointeur personnalisé est attribué à self, c'est-à-dire à la loupe, il n'y a donc aucune utilité d'utiliser un hoverLeaveEvent() pour replacer l'ancienne forme du pointeur lorsque celui-ci quitte la loupe.

Une remarque s'impose ici. L'évènement hoverEnterEvent() ne sera émis que si la zone du QGraphicsItem survolée est d'une opacité suffisante. Dans le cas de la loupe ronde on sait que la forme réelle de l'objet est carrée, les quatre coins étant simplement transparents. Ceci explique que le signal du survol n'est émis que si le pointeur entre dans la zone visible de la loupe.

Après moult essais, il semble que l'opacité minimale requise pour émettre un signal de survol soit de 50 %. Il n'est pas exclu que ceci soit aussi dépendant de la plate-forme.

À titre d'exemple, un objet tel que le curseur personnalisé n'émettra jamais de signal de survol, sa transparence correspond à une opacité de 15 % approximativement.

Suite à une pression du bouton gauche de la souris le déplacement de la loupe peut être amorcé.

magnifier.py

def mousePressEvent(self, event):
pos = event.scenePos()
if event.button() == 1:
if self.menu_wdg.isVisible():
self.menu_wdg.hide()
self.old_X, self.old_Y = pos.x(), pos.y()
event.accept()
else:
event.ignore()

def mouseMoveEvent(self, event):
pos = event.scenePos()
if event.button() == 2:
return
cur = self.pos()
X, Y = pos.x(), pos.y()
m_X, m_Y = X - self.old_X, Y - self.old_Y
self.old_X, self.old_Y = X, Y
self.moveBy(m_X, m_Y)
self.__update(m_X, m_Y)
event.accept()

Ici, si le bouton cliqué est le gauche (event.button() == 1) la position de la souris en coordonnées scène est sauvegardée.

Ensuite, la fonction mouseMoveEvent() comparera la position courante de la souris avec la dernière position connue et appliquera au QGraphicsItem les mêmes mouvements au moyen de la méthode moveBy(x, y), où x et y indiquent le nombre de pixels du déplacement.

La fonction __update(x, y) est alors appelée qui procèdera au renouvellement de la texture de la loupe.

magnifier.py

def __update(self, x, y):
self.orig[2] += x * self.multi
self.orig[3] += y * self.multi
if self.shape == "square":
self.__fill_square()
else:
self.__fill_circle()

En premier lieu il faut convertir la grandeur du déplacement avec le facteur multiplicateur. Le gabarit sur l'image originale aura un déplacement plus grand que la loupe sur l'écran. Le fameux pantographe, en somme.

Puis, selon sa forme, le remplissage de la loupe est appelé.

Le code de la loupe comporte aussi une gestion des évènements clavier permettant de déplacer la loupe avec les flèches ainsi que de fermer la loupe avec la touche Escape. Cette partie de code étant particulièrement triviale, elle ne nécessite pas de développements particuliers.

Le code réimplémente aussi un contextMenuEvent() qui affichera un menu destiné à paramétrer la loupe.

magnifier.py

def contextMenuEvent(self, event):
self.__show_settings(event.screenPos())
event.accept()

Remarquons qu'on utilise ici la position du pointeur de la souris en coordonnées écran.

Ce menu se présentera comme ceci :

Lorsqu'un des paramètres de la loupe est modifié dans le menu, un signal est émis par celle-ci.

Ce signal permettra, par exemple, à l'application de mettre à jour les paramètres courants de la loupe, les préférences utilisateur ou encore un menu popup associé au bouton de la loupe.

Étant donné que les QGraphicsItem n'héritent pas directement de QObject, il faut créer les signaux séparément et les implémenter dans la classe Magnifier() de cette manière :

magnifier.py

class Magnifier(QtGui.QGraphicsPixmapItem):
def __init__(self, main, parent=None, graphic=None,
scene=None, img=None, zoom=None):
super(Magnifier, self).__init__(parent, scene)
...

self._moved = Moved()
self._optionChanged = OptionChanged()

def positionChanged():
def fget(self):
return self._moved.positionChanged
return locals()
positionChanged = property(**positionChanged())

def settingsChanged():
def fget(self):
return self._optionChanged.settingsChanged
return locals()
settingsChanged = property(**settingsChanged())

Et, tout à la fin du script magnifier.py, les deux classes implémentant les signaux :

class Moved(QtCore.QObject):
positionChanged = QtCore.pyqtSignal(list)
def __init__(self):
super(Moved, self).__init__()

class OptionChanged(QtCore.QObject):
settingsChanged = QtCore.pyqtSignal(list)
def __init__(self):
super(OptionChanged, self).__init__()

On a donc un signal qui transmettra les déplacements de la loupe (positionChanged) et un deuxième signal pour les changements de paramètres (settingsChanged).

Ces signaux s'utilisent avec la méthode emit(), comme dans la fonction set_pos() :

magnifier.py

def set_pos(self, pos):
if isinstance(pos, QtCore.QPointF):
X, Y = pos.x(), pos.y()
else:
X, Y = pos[0], pos[1]
cur = self.pos()
m_X, m_Y = X - self.pos().x(), Y - self.pos().y()
self.moveBy(m_X, m_Y)
self.__update(m_X, m_Y)
self.positionChanged.emit([cur.x(), cur.y(), X, Y])

X - Les signaux propres aux QGraphicsItem

On a vu en décrivant les drapeaux, au début de cet article, que les QGraphicsItem pouvaient eux aussi communiquer leur changement de géométrie, de positionnement, etc. Cette fonctionnalité n'est pas utilisée dans le code de cet article, elle est donc détaillée à titre complémentaire.

Cette propriété des QGraphicsItem s'utilise en réimplémentant QGraphicsItem.itemChange(). En voici un exemple :

def itemChange(self, change, value):
if change == QtGui.QGraphicsItem.ItemScenePositionHasChanged:
print "ItemScenePositionHasChanged: {0}".format(value.toPoint())
elif change == QtGui.QGraphicsItem.ItemVisibleChange:
print "ItemVisibleChange: {0}".format(value.toBool())

Les types change sont décrits ici: PyQt, PySide

Quant à value, il s'agit d'un QVariant: PyQt, PySide

XI - Animation

Pour des raisons de simplification, le centre de l'image a été utilisé comme référence pour la création de la loupe. Il est tout à fait possible, cependant, que le centre de l'image ne soit pas au centre de la fenêtre graphique et pourrait même, si l'image est fortement zoomée, être en dehors de celle-ci. La loupe pourrait être créée au centre du QGraphicsView, moyennant quelques calculs supplémentaires mais, cela m'aurait ôté le plaisir de vous parler des animations des QGraphicsItem.

Pour recentrer la loupe dans l'espace graphique, on peut utiliser la méthode setPos().

magnifier.py

def recenter_magnifying_glass(self):
c = self.size / 2.0
dest = self.graphic.mapToScene(self.graphic.width() / 2 - c,
self.graphic.height() / 2 - c)
deltaX = dest.x() - self.pos().x()
deltaY = dest.y() - self.pos().y()
j, k, l, m = deltaX / 50, deltaY / 50, self.pos().x(), self.pos().y()
for i in range (50):
l, m = l + j, m + k
self.setPos(QtCore.QPointF(l, m))
self.__update(j, k)
QtCore.QCoreApplication.processEvents()

Tout d'abord, on fixe les déplacements deltaX et deltaY, respectivement sur l'axe horizontal et l'axe vertical; ensuite, nous divisons ces déplacements par cinquante et enfin, on applique ces mouvements dans une boucle de cinquante itérations. La valeur cinquante est totalement arbitraire.

C'est cette méthode qui est utilisée par défaut. Inutile de préciser que la fluidité du mouvement sera dépendante des performances de l'ordinateur. La loupe ronde nécessitant plus d'opérations pour être mise à jour sera légèrement plus lente.

Qt propose aussi les QGraphicsItemAnimation() pour automatiser les transformations de QGraphicsItem, avec, pour avantage, une meilleure maîtrise de la fluidité indépendamment de la plate-forme.

magnifier.py

def recenter_magnifying_glass(self):
timer = QtCore.QTimeLine(1000);
timer.setFrameRange(0, 50)
self.animation = QtGui.QGraphicsItemAnimation()
self.animation.setItem(self);
self.animation.setTimeLine(timer)

c = self.size / 2.0
dest = self.graphic.mapToScene(self.graphic.width() / 2 - c,
self.graphic.height() / 2 - c)
deltaX = dest.x() - self.pos().x()
deltaY = dest.y() - self.pos().y()
j, k, l, m = deltaX / 50, deltaY / 50, self.pos().x(), self.pos().y()
for i in range (50):
l, m = l + j, m + k
self.animation.setPosAt(i / 50.0, QtCore.QPointF(l, m))
self.__update(j, k)
timer.start()

On utilise une QTimeLine() pour définir la durée de notre animation. Son argument (1000) est en millisecondes.

Un nombre de frames, qui seront créés pour cette animation, est choisi, toujours arbitrairement, soit cinquante.

Après calcul des deltaX et deltaY, on procède à la création des frames puis, timer.start() lancera l'animation.

L'utilisation de la timeline fera que les déplacements de la loupe ronde ou carrée seront identiques ; toutefois, on notera que, avec cette méthode, la texture de la loupe est, dès le départ, la texture finale.

Remarque : dans le code, cette fonction est renommée recenter_magnifying_glass2(self), il faudra donc, modifier les noms de ces deux dernières fonctions pour les tester alternativement, ou renommer leur appel à la ligne 163.

XII - Les effets graphiques

Les effets graphiques sont des modifications de rendu d'un objet graphique intervenant entre cet objet et l'espace graphique, donc sans altération de l'objet source lui-même.

Du fait de l'absence de modification de l'objet source, un effet graphique peut être appliqué et annulé à volonté, permettant ainsi, par exemple, de recréer un effet avec des paramètres différents, comme dans une boîte de dialogue de filtres graphiques proposant une fonction d'aperçu.

Pour créer ses propres effets graphiques il faudra sous-classer QGraphicsEffect et y réimplémenter sa méthode draw(). Celle-ci prend une instance de QPainter en argument. L'effet sera appliqué au QGraphicsItem avec la méthode setGraphicsEffect() et peut être annulé simplement avec QGraphicsEffect.setEnabled(False).

Quatre effets graphiques de base sont proposés par Qt :

- QGraphicsOpacityEffect : modifie l'opacité de l'objet ;

- QGraphicsBlurEffect : applique un flou d'une largeur donnée en pixels ;

- QGraphicsColorizeEffect : colorise l'objet ;

- QGraphicsDropShadowEffect : applique une ombre portée.

Les trois premiers effets n'ayant pas beaucoup d'intérêt pour l'objet loupe, on utilisera l'effet d'ombrage comme exemple.

La création de l'effet consiste principalement à le paramétrer.

main.py

def apply_shadow_effect(self):
self.shadow = QtGui.QGraphicsDropShadowEffect()
self.shadow.setColor(QtGui.QColor(50, 50, 50, 200))
self.shadow.setXOffset(5)
self.shadow.setYOffset(5)
self.shadow.setBlurRadius(3)
self.mgnf.setGraphicsEffect(self.shadow)
self.shadow.setEnabled(False)

Une instance de QGraphicsDropShadowEffect() est créée. La couleur de l'ombre est définie, ici avec une opacité de 200, les paramètres XOffset et YOffset représentent la valeur du décalage de l'ombre mais aussi le côté où elle sera appliquée (ici en bas à droite) ; si les deux valeurs avaient été négatives, l'ombre aurait été placée en haut à gauche.

Un flou de trois pixels est appliqué à l'ombre et l'effet est appliqué au QGraphicsItem avec sa méthode setGraphicsEffect().

L'effet peut être désactivé avec self.shadow.setEnabled(False), pour l'exemple, dans les évènements clavier de notre loupe on trouvera ces lignes :

magnifier.py

def keyPressEvent(self, event):
...
elif event.key() == 83:
# Key S used for enable / disable shadow effect
try:
self.main.shadow.setEnabled(not self.main.shadow.isEnabled())
except:
pass

Ces lignes permettent de tester l'effet ombrage avec le raccourci S.

XIII - Conclusions

Cet article est une approche des très nombreuses possibilités des QGraphicsItem qui sont à considérer comme de puissants outils de travail pouvant apporter une réelle plus-value aux applications où l'aspect graphique est un critère important.

XIV - Liens

Les scripts pour Python v2.x : QGraphicsItem_2.tar.gz et QGraphicsItem_2.zip
et pour Python v3.x : QGraphicsItem_3.tar.gz et QGraphicsItem_3.zip

Les scripts ont été testés
sous Linux-2.6.32-33-generic-i686, Python 2.6.5, PyQt 4.7.2,
Linux-3.0-ARCH-x86_64, Python 3.2.2, PyQt 4.8.5, et Windows 7 (64 bytes), Python 2.7.2, PyQt 4.8.5.

XV - Remerciements

J'adresse mes remerciements à dourouc05 et Claude LELOUP pour leurs conseils et corrections orthographiques.