PyQT5 – how to use QWidget, QScrollArea and QBrush all together

Question:

I am trying to:

  1. Display an image which is bigger than the window size;
  2. Make the window scrollable, so I can roll through the whole image;
  3. Draw over the image;

Problem is:

  1. As soon as I make the window scrollable, I can’t see the drawing anymore;

How can I solve it?

Minimal example:

class DocumentWindow(QWidget):
    def __init__(self, main_obj):
        super().__init__()

        self.WIDTH = 500
        self.HEIGHT = 500
        self.resize(self.WIDTH, self.HEIGHT)

        file_path = 'invoice_example.jpeg'
        self.pages = {0: Image.open(file_path)}
        image = QImage(file_path)
        self.doc_original_width = image.width()
        self.doc_original_height = image.height()
        self.doc_new_width = image.width()
        self.doc_new_height = image.height()
        palette = QPalette()
        palette.setBrush(QPalette.Window, QBrush(image))
        self.setPalette(palette)
        label = QLabel(None, self)
        label.setGeometry(0, 0, self.WIDTH, self.HEIGHT)

        conWin = QWidget()
        conLayout = QVBoxLayout(self)
        conLayout.setContentsMargins(0, 0, 0, 0)
        conWin.setLayout(conLayout)
        scroll = QScrollArea()
        scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        scroll.setWidgetResizable(True)
        scroll.setWidget(conWin)
        scrollLayout = QVBoxLayout(self)

        # When I enable these lines, I got no visible drawing
        scrollLayout.addWidget(scroll)
        scrollLayout.setContentsMargins(0, 0, 0, 0)

        self.begin = QtCore.QPoint()
        self.end = QtCore.QPoint()
        self.show()
    
    def paintEvent(self, event):
        qp = QtGui.QPainter(self)
        br = QtGui.QBrush(QtGui.QColor(100, 10, 10, 40))  
        qp.setBrush(br)   
        qp.drawRect(QtCore.QRect(self.begin, self.end))
        rect = QtCore.QRect(self.begin, self.end)
        self.coordinates = rect.getCoords()

    def mousePressEvent(self, event):
        self.begin = event.pos()
        self.end = event.pos()
        self.update()

    def mouseMoveEvent(self, event):
        self.end = event.pos()
        self.update()

    def mouseReleaseEvent(self, event):
        x1, y1, x2, y2 = self.coordinates
        if x1 == x2 or y1 == y2:
            return
        self.width_factor = self.doc_original_width / self.doc_new_width
        self.height_factor = self.doc_original_height / self.doc_new_height
        self.normalized_coordinates = [ 
            int(x1*self.width_factor), int(y1*self.height_factor),
            int(x2*self.width_factor), int(y2*self.height_factor) ]
        self.update()
    
Asked By: Julio S.

||

Answers:

The issue is you are placing the scroll area on top of your DocumentWindow widget (where your event handlers are reimplemented), so the drawing is hidden underneath it. You can still see the image because QScrollArea (and other widgets) inherit their parent’s backgroundRole if not explicitly set (and you are displaying the image via a palette).

The drawing needs to be implemented inside the QScrollArea’s widget, which right now is an empty QWidget conWin. For your example this can be simplified by making QScrollArea the top level widget and setting a DocumentWindow instance as the scroll area’s widget.

minimumSizeHint is added to support the scrolling functionality by setting a recommended minimum size for the widget.

from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *

class DocumentWindow(QWidget):

    def __init__(self):
        super().__init__()
        file_path = 'invoice_example.jpeg'
        self.image = QImage(file_path)
        palette = QPalette()
        palette.setBrush(QPalette.Window, QBrush(self.image))
        self.setPalette(palette)
        self.begin = QPoint()
        self.end = QPoint()

    def minimumSizeHint(self):
        return self.image.size()

    def mousePressEvent(self, event):
        self.begin = event.pos()
        self.end = event.pos()
        self.update()

    def mouseMoveEvent(self, event):
        self.end = event.pos()
        self.update()

    def mouseReleaseEvent(self, event):
        x1, y1, x2, y2 = QRect(self.begin, self.end).getCoords()
        if x1 == x2 or y1 == y2:
            return
        self.update()
        
    def paintEvent(self, event):
        qp = QPainter(self)        
        br = QBrush(QColor(100, 10, 10, 40))  
        qp.setBrush(br)   
        qp.drawRect(QRect(self.begin, self.end))


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)
    
    scroll = QScrollArea()
    scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
    scroll.setWidgetResizable(True)
    scroll.setWidget(DocumentWindow())
    scroll.resize(500, 500)
    scroll.show()
    
    sys.exit(app.exec_())
Answered By: alec

The answer from alec may very well explain the basic problem, but I’d like to add some important insights, as there are many issues with your code.

Let’s work through them.

The palette

A UI color palette is used as a reference: there is absolutely no guarantee that the widget will respect it. It may use its colors, it may respect its roles, or it could even completely ignore them at all.

The concept is not unlike a real life palette for a human artist: if you were given a palette of 20 colors, that doesn’t mean that you will be painting your drawing using all of those colors. On the contrary: you may just use only a few of them, and you may also choose to mix some of those colors to make new ones.

The same goes with using images, which is similar to crafting a collage: you have a magazine full of images, but you don’t have to use each one of those images, and not even the full content of those images.

Some Qt styles completely ignore palette roles in some cases (or use them as they wish), meaning that even if you set a palette color or brush, that might be completely ignored or used in the "wrong" way.
That means that if the colors or images you display are essential for the usage of your program, you can never rely on the palette.

About the canvas and the widget painting

Using the palette as a "canvas" is conceptually wrong. First of all, for the reasons above (the style may ignore the scroll offset), but it will also make it difficult if not almost impossible to make basic transformations, like zooming the image, or even scrolling it. Also, setting a palette for a widget, automatically sets it for all of its children: with some styles, you may end up with the image being shown as a background of other child widgets: you may end up with that image being shown as background of a scroll bar, or even a context menu.

Most importantly, as alec points out, you’re trying to paint on the wrong widget: the "main window" (DocumentWidget).

Widgets are always stacked on top of their parents, and painting always happens in the reverse order: from bottom to top; the parent widget gets painted first, then all its children get painted above it, recursively (read more about the Qt object tree). This obviously means that if you add a child widget (which is always above its parent), you’re potentially hiding what you draw in the parent.

Consider again the example above: if you draw the background of your painting and then add something on the foreground, it will cover the background that is behind its area.

The only proper way to achieve all this is by implementing drawing functions in a canvas widget.

If you paint on the main window, it would be like painting on the easel that is behind the canvas.

Implementing object structure and event handlers

A canvas is usually shown as a portion of the full paintable area, as the drawing may be too large (or you want to zoom in); that can only happen using a scroll area, but a scroll area is just a container, similarly to a frame for your painting (or a camera only showing part of the whole scene): you don’t normally paint directly on the scroll area, but its contents.

The basic structure is the following:

  • main window
    • scroll area
      • canvas

Note that the main window is theoretically optional: every Qt widget could be a top level window (by default, they all are), so the scroll area may be the "main window"; we normally use a more appropriate window "container" (such as QMainWindow) as we probably need other things in the UI, like buttons, menus, tool bars, etc.

Since we know that the painting should happen in the canvas, this means that any event related to it has to be properly implemented in it, not in its parent; this is not only important for actual "paint events", but also for mouse events: input events are handled by the topmost widget that is normally under the mouse; if it doesn’t handle them, they are propagated to its parents in the object tree until one of them is able to handle them; this is also important for scroll areas, since we should consider events in local coordinates, not parent coordinates.

Further issues

The following are not directly related to the painting subject, but are still important:

  • unless you’re working with peculiar image data or formats unknown to Qt or need special processing, there is no need for PIL and its Image class; if you want to keep a copy of the source image, use the copy() functions of QPixmap or QImage;
  • the widgetResizable is for widgets (and layouts) that allow their content to grow or shrink depending on the possible size of the scroll area’s viewport; that’s not what we want: it will be responsibility of the canvas to request the scroll area to adjust itself, not the other way around;
  • the parent argument of Qt layouts constructors automatically tries to set the layout to that parent; do not use that argument for the wrong widget (as you did in conLayout with QVBoxLayout(self));
  • you should never call self.show() in the __init__ of a widget (here are some reasons why);

A possible implementation

The following code is a basic (and crude) implementation based on your attempt, but considering everything written above.

I also added some aspects, in order to clarify some of those aspects (or increase their awareness):

  • a zoom feature, available using Ctrl + mouse wheel;
  • a selectionPixmap() function that could return a copy of the current selection as a new pixmap;
  • an improved painting behavior that uses a transform in order to properly show the selection rectangle;
from PyQt5.QtCore import *
from PyQt5.QtGui import *
from PyQt5.QtWidgets import *

class Canvas(QWidget):
    begin = end = QPointF()
    def __init__(self, image=None, width=640, height=480):
        super().__init__()
        if isinstance(image, str):
            image = QPixmap(image)
        elif isinstance(image, QImage):
            image = QPixmap.fromImage(image)
        if not image or image.isNull():
            self.pixmap = QPixmap(max(1, width), max(1, height))
            self.pixmap.fill(Qt.white)
        else:
            self.pixmap = image
        self.baseWidth = self.pixmap.width()
        self.baseHeight = self.pixmap.height()
        self.setFixedSize(self.pixmap.size())

    def selectionRect(self):
        '''
            Return an adjusted (integer based) QRect that properly corresponds
            to the image pixels; about the "-1" below, see the notes in the
            documentation about QRect.right() and QRect.bottom()
        '''
        # "normalize" the rectangle to always have a positive width and height
        # even if the selection is "negative" (the end point is above or at the 
        # left of the begin point)
        r = QRectF(self.begin, self.end).normalized()
        return QRect(
            QPoint(round(r.x()), round(r.y())), 
            QPoint(round(r.right()) - 1, round(r.bottom()) - 1)
        )

    def selectionPixmap(self):
        rect = self.selectionRect()
        if rect.isEmpty():
            return QPixmap()
        return self.pixmap.copy(rect)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            # store the begin position as a 0-1 ratio based on the image size;
            # do the same for the end position as well, since at this moment the
            # selection is null
            self.begin = self.end = QPointF(
                event.x() / self.xRatio, 
                event.y() / self.yRatio
            )
            self.update()
        else:
            super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if event.buttons() == Qt.LeftButton: # note: buttons(), not button()
            # update the end position as above
            self.end = QPointF(
                event.x() / self.xRatio, 
                event.y() / self.yRatio
            )
            self.update()
        else:
            super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            event.accept()
        else:
            super().mouseReleaseEvent(event)

    def paintEvent(self, event):
        qp = QPainter(self)
        qp.drawPixmap(self.rect(), self.pixmap)
        selection = self.selectionRect()
        if not selection.isNull():
            # set a transform for the view in order to draw the selection rect
            # based on the current scaling; note the usage of a cosmetic pen,
            # that makes the pen always have the same width at different zoom
            # ratios (try to comment it and zoom in to see the difference)
            qp.setTransform(QTransform().scale(self.xRatio, self.yRatio))
            selectColor = self.palette().highlight().color()
            pen = QPen(selectColor)
            pen.setCosmetic(True)
            qp.setPen(pen)
            selectColor.setAlphaF(.5)
            qp.setBrush(selectColor)
            qp.drawRect(selection)

    def resizeEvent(self, event):
        self.xRatio = self.width() / self.baseWidth
        self.yRatio = self.height() / self.baseHeight

    def wheelEvent(self, event):
        event.ignore()


class CanvasContainer(QScrollArea):
    _scrollPos = None
    def __init__(self, canvas):
        super().__init__()
        self.canvas = canvas
        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
        self.setAlignment(Qt.AlignCenter)
        self.setWidget(self.canvas)

    def mousePressEvent(self, event):
        if event.button() == Qt.MiddleButton:
            self.setCursor(Qt.ClosedHandCursor)
            self._scrollPos = event.pos()
            self._scrollX = self.horizontalScrollBar().value()
            self._scrollY = self.verticalScrollBar().value()
        else:
            super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self._scrollPos: # we assume that the middle button is pressed
            delta = event.pos() - self._scrollPos
            self.horizontalScrollBar().setValue(
                self._scrollX - delta.x())
            self.verticalScrollBar().setValue(
                self._scrollY - delta.y())
        else:
            super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if self._scrollPos:
            # clear _scrollPos to prevent that mouseMoveEvent got handled
            # using another mouse button
            self._scrollPos = None
            self.unsetCursor()
        else:
            super().mouseReleaseEvent(event)

    def wheelEvent(self, event):
        '''
            Zoom in or out depending on the vertical scrolling.
            The zoom only happens when the Control modifier is pressed, and it
            will attempt to keep the position of the canvas that was under the
            mouse cursor *before* zooming, similarly to how map zooming works,
            for instance in Google Maps
        '''
        if event.modifiers() == Qt.ControlModifier:
            delta = event.angleDelta().y()
            # only vertical scrolling is considered
            if delta:
                pos = event.pos()
                ratio = 2 if delta > 0 else .5

                # map the cursor position in canvas coordinates
                canvasPos = self.canvas.mapFrom(self.viewport(), pos)
                # get the "real" cursor position in a range between 0 and 1
                xRatio = canvasPos.x() / self.canvas.width()
                yRatio = canvasPos.y() / self.canvas.height()

                # "scale" the canvas according to the zoom ratio
                newSize = self.canvas.size() * ratio
                self.canvas.setFixedSize(newSize)

                # map the ratios above to the new size to get the approximate
                # position of the mouse in the new size
                x = newSize.width() * xRatio
                y = newSize.height() * yRatio
                # subtract the values above by the actual mouse position
                self.horizontalScrollBar().setValue(x - pos.x())
                self.verticalScrollBar().setValue(y - pos.y())

                # accept the event to prevent its propagations to parents
                event.accept()
                return

        # the scrolling was not handled, use the default behavior (scroll)
        super().wheelEvent(event)


class ImageViewer(QWidget):
    def __init__(self, pixmap=None):
        super().__init__()

        self.canvas = Canvas(pixmap)
        self.scroll = CanvasContainer(self.canvas)

        layout = QVBoxLayout(self)
        layout.addWidget(self.scroll)



app = QApplication([])
win = ImageViewer('invoice_example.jpeg')
win.show()
app.exec()

Final considerations

Writing an image viewer or editor is often a underestimated task; as you can see, it is not rocket science, but it also is not an easy subject either.

Be aware that using a basic QWidget/QPainter pair as a canvas may be fine for simple usage, but having more complex requirements brings in more complexity and difficulties.
If you plan to create an image editor (even a simple one), that QWidget/QPainter pairing is probably not the best choice: consider digging into the Qt Graphics View Framework instead, but ensure that you take your time in order to understand how it works: it is an extremely powerful and extensible framework, it requires a lot of knowledge and experience in order to be used, but it could make the whole process much easier in the end.

Answered By: musicamante
Categories: questions Tags: , ,
Answers are sorted by their score. The answer accepted by the question owner as the best is marked with
at the top-right corner.