Wednesday 27 February 2013

Subclassing QGraphicsEffect in PySide

I was recently looking at writing a QGraphicsEffect subclass, but could find surprisingly few (working) examples. Having now got it doing more or less what I wanted, I just wanted to share in case anyone else is looking for an example. All this does is draw a rectangular outline around a QGraphicsItem, like this:

Here's the code - Python 3 and using PySide, but will presumably work with PyQt if you change the imports:

"""
This code is distributed under the terms and conditions of the MIT License.

http://opensource.org/licenses/mit-license.php

Copyright (c) 2013 John Dickinson

Permission is hereby granted, free of charge, to any person obtaining a copy 
of this software and associated documentation files (the "Software"), to deal 
in the Software without restriction, including without limitation the rights 
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
copies of the Software, and to permit persons to whom the Software is 
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all 
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
SOFTWARE.
"""

from PySide.QtCore import Qt, QPoint, QRectF

from PySide.QtGui import QTransform, QPainter, QPen, QColor, QGraphicsEffect

DEFAULT_OUTLINE_WIDTH = 5
DEFAULT_OUTLINE_OFFSET = 1
DEFAULT_OUTLINE_COLOR = QColor(0, 0, 255)
DEFAULT_OUTLINE_LINESTYLE = Qt.SolidLine

class RectOutlineGraphicsEffect(QGraphicsEffect):
    
    def __init__(self, 
                 width=DEFAULT_OUTLINE_WIDTH,
                 offset=DEFAULT_OUTLINE_OFFSET,
                 color=DEFAULT_OUTLINE_COLOR,
                 style=DEFAULT_OUTLINE_LINESTYLE,
                 parent=None):
        QGraphicsEffect.__init__(self, parent)
        
        self.width = width
        self.offset = offset
        self.color = color
        self.style = style
        
    def boundingRectFor(self, rect):
        pad = self.width + self.offset
        return rect.adjusted(-pad, -pad, pad, pad)
        
    def draw(self, painter):
        
        offset = QPoint()
        if self.sourceIsPixmap():
            # No point in drawing in device coordinates (pixmap will be scaled 
            # anyway)
            pixmap = self.sourcePixmap(Qt.LogicalCoordinates, offset, 
                                       QGraphicsEffect.PadToTransparentBorder)
        else:
            # Draw pixmap in device coordinates to avoid pixmap scaling
            pixmap = self.sourcePixmap(Qt.DeviceCoordinates, offset, 
                                       QGraphicsEffect.PadToTransparentBorder)
            painter.setWorldTransform(QTransform())

        painter.setPen(QPen(self.color, self.width, self.style))
        if painter.testRenderHint(QPainter.Antialiasing):
            left_top_adjust = ((self.width - 1) // 2) + self.offset
            right_bottom_adjust = left_top_adjust + left_top_adjust
        else:
            left_top_adjust = (((self.width + 1) // 2) - 2) + self.offset
            right_bottom_adjust = ((self.width // 2) - 1) + left_top_adjust + \
                                  self.offset
        painter.drawRect(QRectF(offset.x() - left_top_adjust, 
                                offset.y() - left_top_adjust, 
                                pixmap.rect().width() + right_bottom_adjust, 
                                pixmap.rect().height() + right_bottom_adjust))
        
        painter.drawPixmap(offset, pixmap)
        

For your convenience I made a little application to test it:


Here's the code for the test app - it assumes the code for RectOutlineGraphicsEffect above is saved in a module called rect_outline_graphics_effect.py:

#!/usr/bin/env python

"""
This code is distributed under the terms and conditions of the MIT License.

http://opensource.org/licenses/mit-license.php

Copyright (c) 2013 John Dickinson

Permission is hereby granted, free of charge, to any person obtaining a copy 
of this software and associated documentation files (the "Software"), to deal 
in the Software without restriction, including without limitation the rights 
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
copies of the Software, and to permit persons to whom the Software is 
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all 
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 
SOFTWARE.
"""

import sys

from PySide.QtCore import Qt, QRectF

from PySide.QtGui import (QPainter, QPen, QApplication, QMainWindow, 
                          QHBoxLayout, QVBoxLayout, QWidget, QLabel, 
                          QPushButton, QSpinBox, QComboBox, QGraphicsView, 
                          QGraphicsScene, QGraphicsItem, QGraphicsRectItem, 
                          QColorDialog)

import rect_outline_graphics_effect as cust_effect
       
class TestGraphicsScene(QGraphicsScene):

    def __init__(self, parent=None):
        QGraphicsScene.__init__(self, parent)
       
        self.effect_width = cust_effect.DEFAULT_OUTLINE_WIDTH
        self.effect_offset = cust_effect.DEFAULT_OUTLINE_OFFSET
        self.effect_color = cust_effect.DEFAULT_OUTLINE_COLOR
        self.effect_style = cust_effect.DEFAULT_OUTLINE_LINESTYLE
       
        self.rect = None

    def mousePressEvent(self, e):
        if e.button() == Qt.RightButton:
            # Draw a rect of size 1 initially. We'll embiggen it in 
            # mouseMoveEvent
            self.rect = QGraphicsRectItem(QRectF(e.scenePos().x(), 
                                                 e.scenePos().y(), 1, 1))
            self.rect.setFlag(QGraphicsItem.ItemIsMovable, True)
            self.rect.setFlag(QGraphicsItem.ItemIsSelectable, True)
            effect = cust_effect.RectOutlineGraphicsEffect(self.effect_width,
                                                           self.effect_offset,
                                                           self.effect_color,
                                                           self.effect_style,
                                                           self)
            self.rect.setGraphicsEffect(effect)
            self.rect.setPen(QPen(Qt.black, 1))
            self.addItem(self.rect)
        elif e.button() == Qt.LeftButton:
            QGraphicsScene.mousePressEvent(self, e)

    def mouseMoveEvent(self, e):
        if self.rect and (e.buttons() & Qt.RightButton):
            self.rect.setRect(QRectF(self.rect.rect().x(), 
                                     self.rect.rect().y(), 
                                     e.scenePos().x() - self.rect.rect().x(), 
                                     e.scenePos().y() - self.rect.rect().y()))
        QGraphicsScene.mouseMoveEvent(self, e)
       
    def outline_width_changed(self, value):
        self.effect_width = value
        for item in self.items():
            item.graphicsEffect().width = value
            item.graphicsEffect().updateBoundingRect()
           
    def outline_offset_changed(self, value):
        self.effect_offset = value
        for item in self.items():
            item.graphicsEffect().offset = value
            item.graphicsEffect().update()
           
    def outline_color_changed(self, color):
        self.effect_color = color
        for item in self.items():
            item.graphicsEffect().color = color
            item.graphicsEffect().update()
           
    def outline_style_changed(self, value):
        selection = [Qt.SolidLine, Qt.DashLine, Qt.DotLine, Qt.DashDotLine, 
                     Qt.DashDotDotLine][value]
        self.effect_style = selection
        for item in self.items():
            item.graphicsEffect().style = selection
            item.graphicsEffect().update()
           
class MainWindow(QMainWindow):

    def __init__(self):
        super(MainWindow, self).__init__()

        self.scene = TestGraphicsScene()
        self.scene.setSceneRect(QRectF(0, 0, 600, 600))
       
        self.view = QGraphicsView(self.scene)
        self.view.setRenderHint(QPainter.Antialiasing)
       
        self.width_spin = QSpinBox()
        self.width_spin.setMinimum(1)
        self.width_spin.setValue(cust_effect.DEFAULT_OUTLINE_WIDTH)
        self.width_spin.valueChanged.connect(self.scene.outline_width_changed)
       
        self.offset_spin = QSpinBox()
        self.offset_spin.setMinimum(0)
        self.offset_spin.setValue(cust_effect.DEFAULT_OUTLINE_OFFSET)
        self.offset_spin.valueChanged.connect(
                                            self.scene.outline_offset_changed)
       
        self.color_button = QPushButton("")
        self.color_button.setStyleSheet("background-color: rgb(%s, %s, %s);" % 
                                    (cust_effect.DEFAULT_OUTLINE_COLOR.red(),
                                     cust_effect.DEFAULT_OUTLINE_COLOR.green(),
                                     cust_effect.DEFAULT_OUTLINE_COLOR.blue()))
        self.color_button.clicked.connect(self.effect_color_select)
       
        self.line_style_combo = QComboBox()
        self.line_style_combo.addItems(["SolidLine", "DashLine", "DotLine",
                                        "DashDotLine", "DashDotDotLine"])
        self.line_style_combo.setCurrentIndex(0)
        self.line_style_combo.activated.connect(
                                            self.scene.outline_style_changed)
       
        control_layout = QHBoxLayout()
        control_layout.addWidget(QLabel("Width:"))
        control_layout.addWidget(self.width_spin)
        control_layout.addSpacing(10)
        control_layout.addWidget(QLabel("Offset:"))
        control_layout.addWidget(self.offset_spin)
        control_layout.addSpacing(10)
        control_layout.addWidget(QLabel("Color:"))
        control_layout.addWidget(self.color_button)
        control_layout.addSpacing(10)
        control_layout.addWidget(QLabel("Style:"))
        control_layout.addWidget(self.line_style_combo)
        control_layout.addStretch()

        main_layout = QVBoxLayout()
        main_layout.addWidget(QLabel(
                        "Drag with right mouse button to draw rectangles. "
                        "Select and drag them around with the left mouse "
                        "button"))
        main_layout.addWidget(self.view)
        main_layout.addLayout(control_layout)

        self.widget = QWidget()
        self.widget.setLayout(main_layout)

        self.setCentralWidget(self.widget)
        self.setWindowTitle("Example Graphics Effect Subclass")
       
    def effect_color_select(self):
        color = QColorDialog.getColor(self.scene.effect_color)
        if color.isValid():
            self.color_button.setStyleSheet(
                                        "background-color: rgb(%s, %s, %s);" % 
                                                            (color.red(),
                                                             color.green(),
                                                             color.blue()))
            self.scene.outline_color_changed(color)
       
if __name__ == '__main__':
    app = QApplication(sys.argv)
    frame = MainWindow()
    frame.show()
    sys.exit(app.exec_())


You can see that the RectOutlineGraphicsEffect has member variables for the width, color and line-style of the pen it is drawn with, as well as a member that controls the 'offset', the distance between the QGraphicsItem and the effect. Using the test application above you can change all of these dynamically to see what it looks like. Draw rectangles by dragging with the right mouse button pressed. You can then select and move those rectangles by clicking them with the left mouse button and dragging them.

Things to Note


What you definitely don't want to do when writing a QGraphicsEffect subclass is paint outside the bounds. If you do, your (re)painting won't work properly and you'll get odd effects. For example, when you drag your QGraphicsItem you'll see something like this:

which probably isn't what you want.

The notes on QGraphicsEffect are fairly brief, and may leave you with some doubts, so I'll try to explain what you need to do with regard to painting and the bounds.

Firstly, bear in mind that all QGraphicsEffect gives you is a QPixmap of the QGraphicsItem the effect is being applied to - you don't have access to the QGraphicsItem itself. You are just drawing on top of a bitmap (or manipulating the bitmap itself), and this limits what you can usefully do. One of the things you can do, and what my example above does, is paint outside of the bounds of the underlying QGraphicsItem. This is because QGraphicsEffect has its own bounds which can be bigger than the pixmap of the QGraphicsItem that you are drawing on. To specify what those bounds are you need to override boundingRectFor. All you are doing here is returning a rectangle that says what the bounds of your QGraphicsEffect are. Here's mine:

def boundingRectFor(self, rect):
    pad = self.width + self.offset
    return rect.adjusted(-pad, -pad, pad, pad)


The rect passed in here is the size of the QPixmap derived from the underlying QGraphicsItem we are going to apply an effect to, so if you just return it then you're saying that the bounds of your QGraphicsEffect are the same as the pixmap of the QGraphicsItem. In my case, I'm drawing around the QGraphicsItem so I need to specify bigger bounds, enough to include the width of my pen and the offset between the QGraphicsItem and the outline I'm drawing.

The documentation for the draw method of QGraphicsEffect gives you a stub of C++ code to get you started:

MyGraphicsEffect::draw(QPainter *painter)
 {
     ...
     QPoint offset;
     if (sourceIsPixmap()) {
         // No point in drawing in device coordinates (pixmap will be scaled anyways).
         const QPixmap pixmap = sourcePixmap(Qt::LogicalCoordinates, &offset);
         ...
         painter->drawPixmap(offset, pixmap);
     } else {
         // Draw pixmap in device coordinates to avoid pixmap scaling;
         const QPixmap pixmap = sourcePixmap(Qt::DeviceCoordinates, &offset);
         painter->setWorldTransform(QTransform());
         ...
         painter->drawPixmap(offset, pixmap);
     }
     ...
 }

which in our Python example has become:

def draw(self, painter):
        
        offset = QPoint()
        if self.sourceIsPixmap():
            # No point in drawing in device coordinates (pixmap will be scaled 
            # anyway)
            pixmap = self.sourcePixmap(Qt.LogicalCoordinates, offset, 
                                       QGraphicsEffect.PadToTransparentBorder)
        else:
            # Draw pixmap in device coordinates to avoid pixmap scaling
            pixmap = self.sourcePixmap(Qt.DeviceCoordinates, offset, 
                                       QGraphicsEffect.PadToTransparentBorder)
            painter.setWorldTransform(QTransform())

I'm not sure if we ever actually get inside the self.sourceIsPixmap() condition, but it doesn't matter for the purposes of our example since we're applying the effect to QGraphicsRectItem objects. I leave it as an exercise for the reader to see what happens when applying it to a pixmap.

Anyway, the key thing to understand here is the call to sourcePixmap. The first parameter is the coordinate system to use for describing the pixmap the method returns. I've just followed the C++ example here. The second parameter, offset, is a QPoint that gets set with the coordinates of the top left of the pixmap returned by the method (in the coordinate system specified in the first parameter). In other words the QGraphicsItem that we're going to apply the effect to has been turned into a bitmap, the method returns us that bitmap, and offset gets the top left of that bitmap. (Apologies for having a member self.offset, and this local offset - they aren't related. This local offset is just using the name the sourcePixmap method gives to this parameter, while the member self.offset is literally the offset from the QGraphicsItem to the outline, and I'm following the usage of QGraphicsDropShadowEffect which has a self.offset that does something similar). The bitmap may or may not include some padding around the underlying QGraphicsItem, which brings us to our third parameter.

If the third parameter is set to QGraphicsEffect.NoPad then the QPixmap returned will coincide with the bounds of the underlying QGraphicsItem. If it's set to QGraphicsEffect.PadToEffectiveBoundingRect then the pixmap returned will be the size of the rectangle you've specified in boundingRectFor (which could be significantly bigger than the underlying QGraphicsItem). Finally, if the third parameter is set to QGraphicsEffect.PadToTransparentBorder one pixel of padding will be added around the pixmap returned. In all cases, both the width and height of the returned pixmap, and offset, which describes its top left in our coordinate system, will be affected by what you choose as the third parameter.

Note that if you are writing your own QGraphicsEffect subclass and want to see the effect of the three different padding flags then you can just paint a rectangle at the coordinate specified by offset and with the width and height of the pixmap. In other words, a rectangle that shows the unmodified position and dimensions of the pixmap returned by sourcePixmap:

def draw(self, painter):
    offset = QPoint()
    if self.sourceIsPixmap():
        pixmap = self.sourcePixmap(Qt.LogicalCoordinates, offset, 
                                   QGraphicsEffect.PadToTransparentBorder)
    else:
        pixmap = self.sourcePixmap(Qt.DeviceCoordinates, offset, 
                                   QGraphicsEffect.PadToTransparentBorder)
        painter.setWorldTransform(QTransform())
    
    painter.setRenderHint(QPainter.Antialiasing, False)
    painter.setPen(QPen(Qt.red, 1, Qt.SolidLine))
    painter.drawRect(QRectF(offset.x(), 
                            offset.y(), 
                            pixmap.rect().width(), 
                            pixmap.rect().height()))
    painter.drawPixmap(offset, pixmap) 

And here's what we get when we do this with the three different flags:

In this example, the pixmap is significantly larger when using PadToEffectiveBoundingRect because my boundingRectFor method adds 6 pixels of padding.

To recap, if you paint outside of the bounds of your QGraphicsEffect you will get problems, and the bounds are defined by your implementation of boundingRectFor. The size of the pixmap returned by sourcePixmap may or may not be the size of the rectangle defining your QGraphicsEffect's bounds (it will be if you use PadToEffectiveBoundingRect). So depending which flag you pass to sourcePixmap, you may or may not be able to paint outside of the bounds of the pixmap returned by it, but in all cases if you paint outside of the bounds defined in boundingRectFor you will get problems. If you look at my example at the top of this post, you'll see I am painting a larger rectangle than the one returned from sourcePixmap ("offset.x() - left_top_adjust" etc.), but it doesn't cause a problem because I'm using PadToTransparentBorder and I'm still within the bounds described by boundingRectFor.

The rest of the draw method is just normal painting stuff, except that you have to remember to draw the pixmap of your underlying QGraphicsItem at the end if you want to see it.

Problems


If you look at the code where I work out how big my outline rectangle needs to be:

if painter.testRenderHint(QPainter.Antialiasing):
    left_top_adjust = ((self.width - 1) // 2) + self.offset
    right_bottom_adjust = left_top_adjust + left_top_adjust
else:
    left_top_adjust = (((self.width + 1) // 2) - 2) + self.offset
    right_bottom_adjust = ((self.width // 2) - 1) + left_top_adjust + \
                  self.offset
painter.drawRect(QRectF(offset.x() - left_top_adjust, 
                        offset.y() - left_top_adjust, 
                        pixmap.rect().width() + right_bottom_adjust, 
                        pixmap.rect().height() + right_bottom_adjust))

There's obviously quite a lot of messing about and we have to behave differently depending on whether we're painting with anti-aliasing. What I want is simply to have my outline painted with the width given by the effect's self.width member, at the offset from the QGraphicsItem given by the self.offset member. The rectangle we draw has to be adjusted by some values from the dimensions of the pixmap returned by sourcePixmap, although how much by depends on which padding flag we use. I happen to be using PadToTransparentBorder but whichever one we use we still have to make adjustments. Anti-aliasing in particular changes the width of the pen (a pen of width 5 will be painted 6 pixels wide), so we need to accommodate it.

What I've found though is that there is a limit to what you can do to paint the outline in the right place. In particular, if I set the width of the QGraphicsRectItem's pen to 2 (instead of 1 as it is in the example code above):

self.rect.setGraphicsEffect(effect)
self.rect.setPen(QPen(Qt.black, 2))

then I don't get the offset painted how I want:


Here, the offset is set to zero, so the blue outline should be touching the black rectangle of the QGraphicsRectItem. While this is true for the top and left of the outline, it's not true for the bottom and right. The problem is that the change in width of the pen that we draw the QGraphicsRectItem with has broken the adjustments I make in draw. While I have a special case to cope with anti-aliasing, I don't have any logic to cope with whether the QGraphicsRectItem's pen-width is odd or even, which is what I would need to fix this problem. The difficulty with that though is that the QGraphicsEffect subclass doesn't have any access to the QGraphicsItem it is being applied to. All it has is a pixmap of that QGraphicsItem, which doesn't 'know' what pen it was drawn with.

There is of course a work-around for this, which is to add my own QGraphicsItem member to my QGraphicsEffect subclass, and have it set in __init__,so that my QGraphicsEffect would be initialised something like:

self.rect = QGraphicsRectItem(QRectF(e.scenePos().x(), 
                                     e.scenePos().y(), 1, 1))
...
effect = cust_effect.RectOutlineGraphicsEffect(self.rect, self.effect_width, 
                                               self.effect_offset, self.effect_color,
                                               self.effect_style, self)

I could then refer to the QGraphicsRectItem in my draw method, check its pen-width and adjust the dimensions of my outline rectangle accordingly. Partly I haven't done this just to keep the example simple, but there's also an ugliness to coupling my QGraphicsEffect to a QGraphicsItem in this way, and it makes my QGraphicsEffect behave differently to the stock ones such as QGraphicsDropShadowEffect which don't require you to pass in the QGraphicsItem to their constructor.

Overall, you have to keep in mind that all QGraphicsEffect is giving you is a bitmap of your QGraphicsItem to draw on, which is obviously potentially useful, but also quite limited, and it might not be the best solution for your particular needs.