QPainter - after resizing window the coordinates for drawing still change despite making class Canvas()

On the page " QPainter and Bitmap Graphics in PySide6" we can read: "When we only have a single widget in the window this is fine — as long as you don’t resize the window larger than the widget (did you try that?), the coordinates of the container and the single nested widget line up. However, if we add other widgets to the layout this won’t hold — the coordinates of the QLabel will be offset from the window, and we’ll be drawing in the wrong location.

This is easily fixed by moving the mouse handling onto the QLabel itself— it’s event coordinates are always relative to itself."

Despite that change, resizing the main window still breaks coordinates for drawing. I tried to look up solutions in the documentation, but it’s unintelligible. To be clear, I was checking it on the code provided by the course:

import sys
from PySide6 import QtCore, QtGui, QtWidgets
from PySide6.QtCore import Qt

COLORS = [
# 17 undertones https://lospec.com/palette-list/17undertones
'#000000', '#141923', '#414168', '#3a7fa7', '#35e3e3', '#8fd970', '#5ebb49',
'#458352', '#dcd37b', '#fffee5', '#ffd035', '#cc9245', '#a15c3e', '#a42f3b',
'#f45b7a', '#c24998', '#81588d', '#bcb0c2', '#ffffff',
]


class QPaletteButton(QtWidgets.QPushButton):

    def __init__(self, color):
        super().__init__()
        self.setFixedSize(QtCore.QSize(24,24))
        self.color = color
        self.setStyleSheet("background-color: %s;" % color)



class Canvas(QtWidgets.QLabel):

    def __init__(self):
        super().__init__()
        pixmap = QtGui.QPixmap(600, 300)
        # pixmap = QtGui.QPixmap(self.width(), self.height())
        pixmap.fill(Qt.white)

        self.setPixmap(pixmap)

        self.last_x, self.last_y = None, None
        self.pen_color = QtGui.QColor('#000000')

    def set_pen_color(self, c):
        self.pen_color = QtGui.QColor(c)

    def mouseMoveEvent(self, e):
        if self.last_x is None: # First event.
            self.last_x = e.position().x()
            self.last_y = e.position().y()
            return # Ignore the first time.

        canvas = self.pixmap()
        painter = QtGui.QPainter(canvas)
        p = painter.pen()
        p.setWidth(4)
        p.setColor(self.pen_color)
        painter.setPen(p)
        painter.drawLine(self.last_x, self.last_y, e.position().x(), e.position().y())
        painter.end()
        self.setPixmap(canvas)

        # Update the origin for next time.
        self.last_x = e.position().x()
        self.last_y = e.position().y()

    def mouseReleaseEvent(self, e):
        self.last_x = None
        self.last_y = None


class MainWindow(QtWidgets.QMainWindow):

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

        self.canvas = Canvas()


        w = QtWidgets.QWidget()
        l = QtWidgets.QVBoxLayout()
        w.setLayout(l)
        l.addWidget(self.canvas)

        palette = QtWidgets.QHBoxLayout()
        self.add_palette_buttons(palette)
        l.addLayout(palette)

        self.setCentralWidget(w)

    def add_palette_buttons(self, layout):
        for c in COLORS:
            b = QPaletteButton(c)
            b.pressed.connect(lambda c=c: self.canvas.set_pen_color(c))
            layout.addWidget(b)


app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec()

@krzysiek_scarface
Not sure what is wrong with code you provided, I ran it and it works.
Where is a problem, what you try to achieved?

I recorded the app to show how after resizing the window the drawing happens below the pen/mouse cursor: Imgur: The magic of the Internet . I used the same code I posted here. Since it doesn’t happen to you, I guess it must be something with inner workings of my PC. I planned on reinstalling windows anyway, I might have to speed it up.

Ok now I got it, yes I see same behavior as you. I have no idea how it can be solved… but there should be possible.

Hello @krzysiek_scarface,

I think you need to map the coordinates from global to local position, QWiget.mapFromGlobal is probably what you need.

def mouseMoveEvent(self, e):
    local_position = self.mapFromGlobal(e.position())
1 Like

That looks like what I need, but I don’t know how to implement it. I know it’s a method of QWidget, so I tried:

def mouseMoveEvent(self, e):
        self.mapFromGlobal(e.position())

It didn’t work, nothing changed. I tried to replace every instance of e.position() with it, so it looks like that:

def mouseMoveEvent(self, e):
        local_p = self.mapFromGlobal(e.position())

        if self.last_x is None: # First event.
            self.last_x = local_p.x()
            self.last_y = local_p.y()
            return # Ignore the first time.


        canvas = self.pixmap()
        painter = QtGui.QPainter(canvas)

        p = painter.pen()
        p.setWidth(4)
        p.setColor(self.pen_color)
        painter.setPen(p)
        painter.drawLine(self.last_x, self.last_y, local_p.x(), local_p.y())
        painter.end()
        self.setPixmap(canvas)

        # Update the origin for next time.
        self.last_x = local_p.x()
        self.last_y = local_p.y()

The program doesn’t crash but I have to put the window in top left corner of my screen to see anything being drawn:]. I tried again with the code above, but this time using mapFromParent. Lines were drawn, but again, not in correct position after resizing. In fact, even before resizing they are skewed, probably due to some margins.

So I tried your code and in fact I think the pixels are drawn correctly. The issue is when you resize your Canvas widget, the pixmap is not resized as it has a fixed size. So this is not an issue with global to local mapping. You may need to resize your pixmap according to your canvas size. IMHO it could be better to use a QWidget and to paint directly on it.

For a quick & dirty solution for your problem, you can resize the pixmap in the resizeEvent

    def resizeEvent(self, event:QtGui.QResizeEvent):
        new_pix = QtGui.QPixmap(self.width(), self.height())
        new_pix.fill(Qt.GlobalColor.white)
        pixmap = self.pixmap()
        painter = QtGui.QPainter(new_pix)
        painter.drawPixmap(QtCore.QPoint(0, 0), pixmap)
        painter.end()

        self.setPixmap(new_pix)

        return super().resizeEvent(event)

As I said this is a quick and dirty solution, for example, if you make your widget to smaller size, the content is not restored if you increase the size after.

1 Like

Yes, it works. I wrongly understood this part:
"When we only have a single widget in the window this is fine — as long as you don’t resize the window larger than the widget (did you try that?), the coordinates of the container and the single nested widget line up. However, if we add other widgets to the layout this won’t hold — the coordinates of the QLabel will be offset from the window, and we’ll be drawing in the wrong location.

This is easily fixed by moving the mouse handling onto the QLabel itself— it’s event coordinates are always relative to itself."

I thought that means creating Canvas class will allow resizing it while keeping coordinates correct, now I see it only meant adding the pallette.

PS: Is that needed? Code seems to work fine without it: return super().resizeEvent(event)

No it’s not necessary.

I just want to add this example, as I was not really satisfied with my dirty solution. It still uses an image to store the content, but it paints the image on the widget directly, instead of using a QLabel.

import typing as T

import PySide6.QtCore as qc
import PySide6.QtGui as qg
import PySide6.QtWidgets as qw

class DrawingCanvas(qw.QWidget):
    """"""

    def __init__(
        self,
        parent: T.Optional[qc.QObject] = None,
    ):
        super().__init__(parent)
        self.setMouseTracking(True)

        self.pen = qg.QPen()
        self.pen.setWidth(5)
        self.pen.setCapStyle(qc.Qt.PenCapStyle.RoundCap)
        self.pen.setJoinStyle(qc.Qt.PenJoinStyle.RoundJoin)
        self.pen.setColor(qc.Qt.GlobalColor.black)
        self.pen_width = 10

        self._active = False
        self._image = qg.QImage()
        self._last_point = qc.QPoint()
        self._right_click = False
        self.clear()

    @property
    def pen_color(self) -> qg.QColor:
        return self.pen.color()

    @pen_color.setter
    def pen_color(self, new_pen_color: qg.QColor):
        self.pen.setColor(new_pen_color)

    @property
    def pen_width(self) -> float:
        return self.pen_width

    @pen_width.setter
    def pen_width(self, new_pen_width: float):
        self.pen.setWidthF(new_pen_width)

    def mousePressEvent(self, event: qg.QMouseEvent) -> None:
        self._active = True
        self._last_point = event.position()
        self._right_click = event.button() == qc.Qt.MouseButton.RightButton

    def mouseReleaseEvent(self, event: qg.QMouseEvent) -> None:
        self._active = False

    def mouseMoveEvent(self, event: qg.QMouseEvent) -> None:
        if not self._active:
            return
        self.draw_point(event)
        self._last_point = event.position()
        self.update()

    def draw_point(self, event: qg.QMouseEvent) -> None:

        pen = qg.QPen(self.pen)

        if self._right_click:
            c = self.pen_color.toHsv()
            c.setHsv((c.hue() + 180) % 359, c.saturation(), c.value())
            pen.setColor(c)
            pen.setWidthF(2 * pen.widthF())

        p = qg.QPainter(self._image)
        p.setPen(pen)
        p.setRenderHints(qg.QPainter.RenderHint.Antialiasing, True)
        p.drawLine(self._last_point, event.position())
        p.end()

    def resizeEvent(self, event: qg.QResizeEvent) -> None:
        if self.width() > self._image.width() or self.height() > self._image.height():
            newWidth = max(self.width(), self._image.width())
            newHeight = max(self.height(), self._image.height())
            self.resizeImage(qc.QSize(newWidth, newHeight))
            self.update()

    def resizeImage(self, newSize: qc.QSize):
        if self._image.size() == newSize:
            return

        newImage = qg.QImage(newSize, qg.QImage.Format.Format_RGB32)
        newImage.fill(qc.Qt.GlobalColor.white)
        p = qg.QPainter(newImage)
        p.drawImage(qc.QPoint(0, 0), self._image)
        self._image = newImage

    def paintEvent(self, event: qg.QPaintEvent) -> None:
        p = qg.QPainter(self)
        p.setRenderHint(qg.QPainter.RenderHint.TextAntialiasing)
        dirtyRect = event.rect()
        p.drawImage(dirtyRect, self._image, dirtyRect)
        p.end()

    def save(self, filename: str):
        """save pixmap to filename"""
        self._image.save(filename)

    @property
    def image(self) -> qg.QImage:
        return self._image

    def load(self, filename: str):
        """load pixmap from filename"""
        self._image.load(filename)
        self._image = self._image.scaled(
            self.size(), qg.Qt.AspectRatioMode.KeepAspectRatio
        )
        self.update()

    def clear(self, color: T.Optional[qg.QColor] = None):
        """Clear the image_"""
        self.image.fill(color or qc.Qt.GlobalColor.white)
        self.update()


class MainWindow(qw.QMainWindow):
    """An Application example to draw using a pen"""

    def __init__(self, parent=None):
        qw.QMainWindow.__init__(self, parent)

        self.painter_widget = DrawingCanvas()
        self.bar = self.addToolBar("Menu")
        self.bar.setToolButtonStyle(qc.Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
        self._save_action = self.bar.addAction(
            qw.QApplication.style().standardIcon(
                qw.QStyle.StandardPixmap.SP_DialogSaveButton
            ),
            "Save",
            self.on_save,
        )
        self._save_action.setShortcut(qg.QKeySequence.StandardKey.Save)
        self._open_action = self.bar.addAction(
            qw.QApplication.style().standardIcon(
                qw.QStyle.StandardPixmap.SP_DialogOpenButton
            ),
            "Open",
            self.on_open,
        )
        self._open_action.setShortcut(qg.QKeySequence.StandardKey.Open)
        self.bar.addAction(
            qw.QApplication.style().standardIcon(
                qw.QStyle.StandardPixmap.SP_DialogResetButton
            ),
            "Clear",
            self.painter_widget.clear,
        )
        self.bar.addSeparator()

        self.color_action = qg.QAction(self)
        self.color_action.triggered.connect(self.on_color_clicked)
        self.bar.addAction(self.color_action)

        self.pen_width_slider = qw.QSlider(qc.Qt.Orientation.Horizontal)
        self.pen_width_slider.setMinimum(1)
        self.pen_width_slider.setMaximum(500)
        self.pen_width_slider.setMaximumWidth(200)
        self.pen_width_slider.valueChanged.connect(self.on_width_changed)

        self.bar.addWidget(self.pen_width_slider)

        self.setCentralWidget(self.painter_widget)

        self.set_color(qc.Qt.GlobalColor.black)

        self.mime_type_filters = ["image/png", "image/jpeg"]

    def on_width_changed(self, width):
        self.painter_widget.pen_width = self.pen_width_slider.value() / 10

    @qc.Slot()
    def on_save(self):
        dialog = qw.QFileDialog(self, "Save File")
        dialog.setMimeTypeFilters(self.mime_type_filters)
        dialog.setFileMode(qw.QFileDialog.FileMode.AnyFile)
        dialog.setAcceptMode(qw.QFileDialog.AcceptMode.AcceptSave)
        dialog.setDefaultSuffix("png")
        dialog.setDirectory(
            qc.QStandardPaths.writableLocation(
                qc.QStandardPaths.StandardLocation.PicturesLocation)
        )

        if dialog.exec() == qw.QFileDialog.DialogCode.Accepted:
            if dialog.selectedFiles():
                self.painter_widget.save(dialog.selectedFiles()[0])

    @qc.Slot()
    def on_open(self):
        dialog = qw.QFileDialog(self, "Open File")
        dialog.setMimeTypeFilters(self.mime_type_filters)
        dialog.setFileMode(qw.QFileDialog.FileMode.ExistingFile)
        dialog.setAcceptMode(qw.QFileDialog.AcceptMode.AcceptOpen)
        dialog.setDefaultSuffix("png")
        dialog.setDirectory(
            qc.QStandardPaths.writableLocation(
                qc.QStandardPaths.StandardLocation.PicturesLocation)
        )

        if dialog.exec() == qw.QFileDialog.DialogCode.Accepted:
            if dialog.selectedFiles():
                self.painter_widget.load(dialog.selectedFiles()[0])

    @qc.Slot()
    def on_color_clicked(self):
        color = qw.QColorDialog.getColor(qc.Qt.GlobalColor.black, self)
        if color:
            self.set_color(color)

    def set_color(self, color: qg.QColor = qc.Qt.GlobalColor.black):
        # Create color icon
        pix_icon = qg.QPixmap(32, 32)
        pix_icon.fill(color)

        self.color_action.setIcon(qg.QIcon(pix_icon))
        self.painter_widget.pen.setColor(color)
        self.color_action.setText(qg.QColor(color).name())


if __name__ == "__main__":
    import sys

    a = qw.QApplication.instance() or qw.QApplication(sys.argv)
    win = MainWindow()
    win.showMaximized()
    a.exec()

That is a lot of code! Thank you very much. That is sure to teach me a lot. I see a lot here that I didn’t have chance to familiarise myself with yet- I guess they could be called “good practices”?
I have 2 questions:

  1. Twice you used if *condition*: return to stop the method from executing:
def mouseMoveEvent(self, event: qg.QMouseEvent) -> None:
        if not self._active:
            return
        self.draw_point(event)
        self._last_point = event.position()
        self.update()
def resizeImage(self, newSize: qc.QSize):
        if self._image.size() == newSize:
            return

        newImage = qg.QImage(newSize, qg.QImage.Format.Format_RGB32)
        newImage.fill(qc.Qt.GlobalColor.white)
        p = qg.QPainter(newImage)
        p.drawImage(qc.QPoint(0, 0), self._image)
        self._image = newImage

Why didn’t you use an opposite condition instead, like that:

def mouseMoveEvent(self, event: qg.QMouseEvent) -> None:
        if self._active:
            self.draw_point(event)
            self._last_point = event.position()
            self.update()
  1. I’m not sure what happens here: qw.QApplication.instance() or qw.QApplication(sys.argv). Should I worry about that?

This is called “Guard clause”. It avoid nested if which are usually harder to read and to maintain. In that case, it’s not really important, but it can be useful when you have more conditions.

Here is an example for illustration

def send_data(data):
    if is_connected:
        if config_ok:
            if is_in_test_mode:
                send_data_on_uart(data)
                return True
            else:
                print('Must be in test mode')
        else:
            print('Configuration must be done')
    else:
        print('Must be connected')
    return False

And with Guard clause

def send_data(data):
    if not is_connected:
        print('Must be connected')
        return False
 
    if not config_ok:
        print('Configuration must be done')
        return False
 
    if not is_in_test_mode:
        print('Must be in test mode')
        return False
 
    # Everything is ok, we can send the data
    send_data(data)
    return True

You can find plenty of information and examples if you search for something like “Guard Clause Python”

Sorry for that, I forgot to clean it. That’s because I’ve made this code in a Jupyter Notebook and in some case the application instance is not destroyed between two runs. But it will work fine from a python script so you don’t have to worry at all. You can keep it like this, or remove the qw.QApplication.instance() or part.

1 Like

Thank you, now I know what to google. I think we can put this thread to rest. I might sometimes look at that code when in need of general pointers.

1 Like