pythonpyqt5clipping

How to clip pixmap image according to scene shape


I am trying to clip a QGraphicsPixmapItem with the given boundaries of a QGraphicsScene. However, I think I do not really understand how that clipping works and as far as I read, it should clip the image when you apply setClipRect to your image in the paint method of a QGraphicsPixmapItem.

I have this class, which acts as the viewer:

class ImageViewer(QGraphicsView):
    def __init__(self, parent, tab):
        super().__init__(parent)
        self.setScene(QGraphicsScene(self))

    def displayImage(self, image):
        rgb_image = image.convert('RGB')
        data = rgb_image.tobytes()
        qim = QImage(data, rgb_image.size[0], rgb_image.size[1], QImage.Format_RGB888)
        pixmap = QPixmap(qim)
        self.scene().clear()
        scene_rect = QRectF(0, 0, pixmap.width(), pixmap.height())
        self.setSceneRect(scene_rect)
        pixmapItem = ImagePixmapItem(pixmap, self.sceneRect())
        self.scene().addItem(pixmapItem)
        self.fitInView(self.sceneRect(), Qt.KeepAspectRatio)
        self.drawBorder()
        pixmapItem.rotate()

In here, I have my image, the QPixmap and define the pixmapItem, which is a custom class inheriting from QGraphicsPixmapItem. Also I drew a border just to see where the boundaries of my scene are.

class ImagePixmapItem(QGraphicsPixmapItem):
    def __init__(self, pixmap, scene_rect):
        super().__init__(pixmap)
        self.scene_rect = scene_rect

    def paint(self, painter, option, widget):
        painter.setClipRect(self.scene_rect)
        super().paint(painter, option, widget)
        
    def rotate(self):
        self.setTransformOriginPoint(self.boundingRect().center())
        rotation_angle = 45
        self.setRotation(rotation_angle)

Now, my image has 1000x1000 dimensions. The scene also has 1000x1000 upon starting the program. When I rotate the image, using the rotate method, the square image should - in theory - take up more space than 1000x1000. The paint method is called as soon as the whole program is started and when I scroll or move within the scene. There, I expected, that the setClipRect would clip my image. Parts which are outside of the scene, should not be shown. However, it does not work at all.

I am stuck here and do not know what the issue is.


Solution

  • Relative coordinates and transformations

    Except for functions that are explicitly based on a "parent" context (eg. pos(), which is in parent coordinates), everything is always in item coordinates, including paint():

    All painting is done in local coordinates.

    When paint() is called, it is assumed that the QPainter received is already in the context of all the transformation set to the item, including transformations of its parents, the view and its own. That is because paint() should always be implemented seamlessly, no matter the transformations applied to the item, even indirectly.

    This is for obvious and good implementation reasons: if you have an item that shows a rectangle, you only need to call painter.drawRect(), without caring about its possible scaling or rotation. Calling setRotation() will automatically rotate that rectangle when it's shown, drawRect() will draw a rectangle in that transformed paint context, and it will eventually be shown as a rotated rectangle.

    Relative clipping

    All setClip...() functions of QPainter specify the following:

    Note that the clip rectangle is specified in logical (painter) coordinates.

    This is identical to the above, and means that clipping will be set on the current transform context too: if the painter is rotated, the clip rect is rotated as well, similarly to the drawRect() example above.

    That's the reason for which simply calling setClipRect() with the scene rect is pointless: the "rect" is orthogonal to the already transformed matrix of the painter, and since that rect also coincides with the pixmap rect, the result is unchanged. Try to change that call to a smaller rectangle and you'll see that the clipping will still be orthogonal to the item.

    Possible solutions

    There are many possible ways to correctly achieve what requested, each one with their own benefits and drawbacks.

    Reset the transform before clipping

    One possibility is to store the current transform of the painter, then temporarily reset the transform, set the clipping, and finally restore the transform again before calling super().paint():

    class ImagePixmapItem(QGraphicsPixmapItem):
        ...
        def paint(self, painter, option, widget):
            realTransform = painter.transform()
            painter.resetTransform()
            view = self.scene().views()[0]
            mapRect = view.mapFromScene(self.scene_rect).boundingRect()
            painter.setClipRect(mapRect)
            painter.setTransform(realTransform)
            super().paint(painter, option, widget)
    

    The main drawback of this approach is that it relies on the fact that there must always be exactly one view. If the scene is set on more views, it will work unexpectedly for any view that is not the "first", or can even raise an exception if no view exists at all (for instance, for off screen rendering).

    I would not suggest this approach, as the only "benefit" is to be able to just use setClipRect(), but the complexity of doing that, the overhead of setting/resetting the transform, and the drawbacks noted above don't make it effective.

    Set the clipping in item coordinates

    Since, as said, calling setClipRect() in the current painter context is ineffective, and the above solution is unreliable, a more accurate solution is to get the actual scene rect mapped to the item transform, and use setClipPath() with a QPainterPath containing that mapped rectangle (which in reality is a QPolygonF, as a transform can also have perspective).

        def paint(self, painter, option, widget):
            map = self.mapFromScene(self.scene_rect)
            path = QPainterPath()
            path.addPolygon(map)
            painter.setClipPath(path)
            super().paint(painter, option, widget)
    

    This is much better than the first solution, but still requires transform mapping and constructing a QPainterPath every time paint() is called (which can happen a lot, even with caching) even if the scene and transform don't change.

    Use a parent item for clipping

    As suggested by ekhumoro, it's possible to add a top level QGraphicsRectItem that acts as a clip mask (by setting the ItemClipsChildrenToShape flag) and then add all elements that needs clipping as children of that item.

        def displayImage(self, image):
            ...
            clipItem = self.scene().addRect(scene_rect)
            clipItem.setFlag(QGraphicsItem.ItemClipsChildrenToShape)
            clipItem.setPen(QPen(Qt.NoPen))
            # see the changed argument signature
            pixmapItem = ImagePixmapItem(pixmap, clipItem)
            ...
    
    
    class ImagePixmapItem(QGraphicsPixmapItem):
        # no __init__() nor paint() override required
        def rotate(self):
            ...
    

    This is an improvement, as paint() doesn't need to be overridden (therefore avoiding the Python bottleneck). The only drawback is that all items must be set as children of the clipItem and that level of complexity can create some issues when inspecting the object structure.

    Note that, since both __init__ and paint are now unnecessary, subclassing just for rotate becomes quite pointless, as you can just call setTransformOriginPoint() and setRotation(45) within displayImage().

    Override drawForeground()

    Finally, as long as all items have to be clipped, you could override drawForeground() on the view or the scene, set a possibly "hollow region" as masking, and draw the given rectangle above all the contents.

    The "hollow region" is a QRegion based on the rect argument of drawForeground() (the possibly full rectangle drawn on the view, or a portion of it), subtracted by a QRegion created with the scene rect.

    In this way, the given rect will only paint in the parts that are outside of the scene, and you don't need to implement clipping at all.

    class ImageViewer(QGraphicsView):
        ...
        def drawForeground(self, qp, rect):
            full = QRegion(rect.toRect())
            mask = QRegion(self.sceneRect().toRect())
            qp.setClipRegion(full - mask)
            qp.fillRect(rect, self.palette().base())
    

    Alternatively, the same code can also be used in a subclass of QGraphicsScene, but in displayImage() you need to call self.scene().setSceneRect() instead, because calling the view's setSceneRect() does not affect the sceneRect of the scene.

    This approach is actually a "hack", but has its benefits: you don't need to override paint() at all (or even require subclassing, as explained in the previous point), and the object structure is preserved.

    Still, it's not without issues: if the scene is changed frequently (eg. due to fast scrolling/scaling or animations), it can affect performance even if no real clipping is in effect; it also completely clears out any background explicitly set using setBackgroundBrush() or overriding drawBackground() outside of the "clip" region.