PyQt5 QGraphicsView drawing resolution

Question:

I’m new to Qt, I am trying to make a paint application using QGraphicsScene and QGraphicsView. The only way to draw i found out is to add circles and lines to QGraphicsScene on mouseMoveEvent. It works fine, but is there a way to draw like in FabricJS(when added items has the same resolution as an image)?

PyQt drawing:

PyQt drawing

fabricJS drawing:

fabricJS drawing

My code:

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


class QDMWorkingAreaScene(QGraphicsScene):
    def __init__(self, parent=None):
        super().__init__(parent)
        self._color_background = QColor("#393939")

        self.textItems = []
        self.drawingItems = []

        self.empty = True
        self.mainImage = QGraphicsPixmapItem()
        self.mainImage.setTransformationMode(Qt.SmoothTransformation)
        self.dirtySpeechBubbles = []
        self.setBackgroundBrush(self._color_background)


    def setImage(self, pixmap=None):
        if pixmap and not pixmap.isNull():
            self.empty = False
            self.mainImage.setPixmap(pixmap)
        else:
            self.empty = True
            self.mainImage.setPixmap(QPixmap())
        self.addItem(self.mainImage)
        #self.fitInView()

    def hasPhoto(self):
        return not self.empty

    def drawCircle(self, x, y, brushSize, pen, brush):
        self.drawingItems.append(self.addEllipse(x, y, brushSize, brushSize, pen, brush))
        print(len(self.drawingItems))

    def drawLine(self, start_x, start_y, x, y, pen):
        self.drawingItems.append(self.addLine(start_x, start_y, x, y, pen))
        print(len(self.drawingItems))


class QDMGraphicsView(QGraphicsView):
    def __init__(self, grScene, parent = None):
        super().__init__(parent)
        self.empty = True

        #brush drawing settings
        self.drawingMode = True
        self.brushSize = 10
        self.brushColor = Qt.black
        self.lastPoint = QPoint()
        self.brush_line_pen = QPen(self.brushColor, self.brushSize, Qt.SolidLine, Qt.RoundCap)

        #scene settings
        self.grScene = grScene
        self.initUI()
        self.setScene(self.grScene)

        #pan settings
        self.setDragMode(QGraphicsView.RubberBandDrag)
        self._isPanning = False
        self._mousePressed = False

        #zoom settings
        self.zoomInFactor = 1.25
        self.zoomOutFactor = 0.8
        self.zoomClamp = False
        self.zoom = 10
        self.zoomStep = 1
        self.zoomRange = [0, 20]
        if self.drawingMode:
            self.brush = self.grScene.addEllipse(0, 0, self.brushSize, self.brushSize, QPen(Qt.NoPen), self.brushColor)
            self.brush.setFlag(QGraphicsItem.ItemIsMovable)
            self.brush.setZValue(100)

    def initUI(self):
        self.setRenderHints(QPainter.Antialiasing | QPainter.HighQualityAntialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
        self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)

    def setMainImage(self, pixmapItem):
        self.grScene.setImage(pixmapItem)

    def mousePressEvent(self,  event):
        if self.drawingMode and (event.button() == Qt.LeftButton):
            x = self.mapToScene(event.pos()).x()
            y = self.mapToScene(event.pos()).y()
            self.grScene.drawCircle(x - self.brushSize / 2, y - self.brushSize / 2, self.brushSize, QPen(Qt.NoPen), self.brushColor)
            self.lastPoint = self.mapToScene(event.pos())
        elif event.button() == Qt.LeftButton:
            self._mousePressed = True
            if self._isPanning:
                self.setCursor(Qt.ClosedHandCursor)
                self._dragPos = event.pos()
                event.accept()
            else:
                super(QDMGraphicsView, self).mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.drawingMode:
            x = self.mapToScene(event.pos()).x()
            y = self.mapToScene(event.pos()).y()
            self.brush.setPos(x - self.brushSize / 2, y - self.brushSize / 2)
        if(event.buttons() & Qt.LeftButton) & self.drawingMode:
            x = self.mapToScene(event.pos()).x()
            y = self.mapToScene(event.pos()).y()
            self.grScene.drawLine(self.lastPoint.x(), self.lastPoint.y(), x, y, self.brush_line_pen)
            self.lastPoint = self.mapToScene(event.pos())

        elif self._mousePressed and self._isPanning:
            newPos = event.pos()
            diff = newPos - self._dragPos
            self._dragPos = newPos
            self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - diff.x())
            self.verticalScrollBar().setValue(self.verticalScrollBar().value() - diff.y())
            event.accept()
        else:
            super(QDMGraphicsView, self).mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            if event.modifiers() & Qt.ControlModifier:
                self.setCursor(Qt.OpenHandCursor)
            else:
                self._isPanning = False
                self.setCursor(Qt.ArrowCursor)
            self._mousePressed = False
        super(QDMGraphicsView, self).mouseReleaseEvent(event)

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Control and not self._mousePressed:
            self._isPanning = True
            self.setCursor(Qt.OpenHandCursor)
        else:
            super(QDMGraphicsView, self).keyPressEvent(event)

    def keyReleaseEvent(self, event):
        if event.key() == Qt.Key_Control:
            if not self._mousePressed:
                self._isPanning = False
                self.setCursor(Qt.ArrowCursor)
        elif event.key() == Qt.Key_Delete:
            self.deleteSelected()
        else:
            super(QDMGraphicsView, self).keyPressEvent(event)

    def deleteSelected(self):
        for item in self.grScene.selectedItems():
            self.grScene.removeItem(item)

    def getZoomStep(self, mode):
        if mode == "+":
            if self.zoom + self.zoomStep not in range(self.zoomRange[0], self.zoomRange[1]):
                return self.zoom, 1
            else:
                return self.zoom + self.zoomStep, self.zoomInFactor
        elif mode == "-":
            if self.zoom - self.zoomStep not in range(self.zoomRange[0], self.zoomRange[1]):
                return self.zoom, 1
            else:
                return self.zoom - self.zoomStep, self.zoomOutFactor
        return 10, 1

    def wheelEvent(self, event):
        if event.angleDelta().y() > 0:
            self.zoom, zoomFactor = self.getZoomStep("+")
        else:
            self.zoom, zoomFactor = self.getZoomStep("-")
        self.scale(zoomFactor, zoomFactor)

    def fitInView(self, scale=True):
        rect = QRectF(self.grScene.mainImage.pixmap().rect())
        if not rect.isNull():
            self.setSceneRect(rect)
            if self.grScene.hasPhoto():
                unity = self.transform().mapRect(QRectF(0, 0, 1, 1))
                self.scale(1 / unity.width(), 1 / unity.height())
            self.zoom = 5

class WorkingArea(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.initUI()

    def loadImage(self):
        self.view.setMainImage(QPixmap('roi.jpg'))

    def initUI(self):
        self.layout = QVBoxLayout()
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(self.layout)
        self.grScene = QDMWorkingAreaScene()
        self.view = QDMGraphicsView(self.grScene, self)
        self.layout.addWidget(self.view)
        gl = QOpenGLWidget()
        gl.setMouseTracking(True)
        format = QSurfaceFormat()
        format.setSamples(4)
        gl.setFormat(format)
        self.view.setViewport(gl)
        self.setWindowTitle("AutoMangaCleaner")
        self.loadImage()
        self.show()
        #self.showMaximized()

if __name__ == "__main__":
    app = QApplication(sys.argv)

    window = WorkingArea()

    sys.exit(app.exec_())

Edit:
I found out another way to draw recently:

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


class Canvas(QGraphicsPixmapItem):
    def __init__(self, image=None):
        super().__init__()
        self.last_pos = QPoint()

    def setImage(self, image):
        self.pixmap = image
        self.pixmap_clone = self.pixmap.copy()
        self.last_pos = QPoint()
        self.setPixmap(self.pixmap)

    def mousePressEvent(self,  event):
        pos = self.mapToParent(event.pos())
        p = QPainter(self.pixmap_clone)
        p.setBrush(Qt.black)
        p.drawEllipse(pos, 5, 5)
        self.last_pos = pos
        self.setPixmap(self.pixmap_clone)

    def mouseMoveEvent(self, event):
        pos = self.mapToScene(event.pos())
        if(event.buttons() & Qt.LeftButton):
            p = QPainter(self.pixmap_clone)
            p.setPen(QPen(Qt.black, 10, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
            p.drawLine(self.last_pos, event.pos())
            self.last_pos = pos
            self.setPixmap(self.pixmap_clone)



class QDMWorkingAreaScene(QGraphicsScene):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.empty = True
        self._color_background = QColor("#393939")
        self.mainImage = Canvas()
        self.mainImage.setTransformationMode(Qt.SmoothTransformation)
        self.dirtySpeechBubbles = []
        self.setBackgroundBrush(self._color_background)

    def mouseMoveEvent(self, event):
        super().mouseMoveEvent(event)

    def setImage(self, pixmap=None):
        if pixmap and not pixmap.isNull():
            self.empty = False
            self.mainImage.setImage(pixmap)
        else:
            self.empty = True
            self.mainImage.setPixmap(QPixmap())
        self.addItem(self.mainImage)
        #self.fitInView()

    def hasPhoto(self):
        return not self.empty


class QDMGraphicsView(QGraphicsView):
    def __init__(self, grScene, parent = None):
        super().__init__(parent)

        self.empty = True
        self.photo = QGraphicsPixmapItem()

        #text settings
        #fonts, color, outline etc.

        #brush drawing settings
        #self.brush = QGraphicsEllipseItem
        self.drawingMode = True
        self.is_drawing = True
        self.brushSize = 10
        self.brushColor = Qt.black
        self.lastPoint = QPoint()
        self.brush_line_pen = QPen(self.brushColor, self.brushSize, Qt.SolidLine, Qt.RoundCap)
        # self.brush = self.grScene.addEllipse(0, 0, self.brushSize, self.brushSize, QPen(Qt.NoPen), self.brushColor)
        #scene settings
        self.grScene = grScene
        self.initUI()
        self.setScene(self.grScene)

        #pan settings
        self.setDragMode(QGraphicsView.RubberBandDrag)
        self.setDragMode(QGraphicsView.NoDrag)
        self._isPanning = False
        self._mousePressed = False

        #zoom settings
        self.zoomInFactor = 1.25
        self.zoomOutFactor = 0.8
        self.zoomClamp = False
        self.zoom = 10
        self.zoomStep = 1
        self.zoomRange = [0, 20]
        if self.drawingMode:
            self.setDragMode(QGraphicsView.NoDrag)
            self.brush = self.grScene.addEllipse(0, 0, self.brushSize, self.brushSize, QPen(Qt.NoPen), self.brushColor)
            self.brush.setAcceptedMouseButtons(Qt.NoButton)
            self.brush.setFlag(QGraphicsItem.ItemIsMovable)
            self.brush.setZValue(100)

    def initUI(self):
        self.setRenderHints(QPainter.Antialiasing | QPainter.HighQualityAntialiasing | QPainter.TextAntialiasing | QPainter.SmoothPixmapTransform)
        self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate)

        #self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        #self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)


    def setMainImage(self, pixmapItem):
        self.grScene.setImage(pixmapItem)
        self.fitInView()

    def mousePressEvent(self,  event):
        #print("view pos:", self.mapToScene(event.pos()))
        if self.drawingMode and (event.button() == Qt.LeftButton):
            super(QDMGraphicsView, self).mousePressEvent(event)
            #self.grScene.mainImage.mousePressEvent(event)
        if event.button() == Qt.LeftButton:
            self._mousePressed = True
            if self._isPanning:
                self.setCursor(Qt.ClosedHandCursor)
                self._dragPos = event.pos()
                event.accept()
            else:
                super(QDMGraphicsView, self).mousePressEvent(event)


    def mouseMoveEvent(self, event):
        if self.drawingMode:
            x = self.mapToScene(event.pos()).x()
            y = self.mapToScene(event.pos()).y()
            self.brush.setPos(x - self.brushSize / 2, y - self.brushSize / 2)
        if(event.buttons() == Qt.LeftButton) & self.drawingMode:
            super(QDMGraphicsView, self).mouseMoveEvent(event)
        elif self._mousePressed and self._isPanning:
            newPos = event.pos()
            diff = newPos - self._dragPos
            self._dragPos = newPos
            self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - diff.x())
            self.verticalScrollBar().setValue(self.verticalScrollBar().value() - diff.y())
            event.accept()
        else:
            super(QDMGraphicsView, self).mouseMoveEvent(event)
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            if event.modifiers() == Qt.ControlModifier:
                self.setCursor(Qt.OpenHandCursor)
            else:
                self._isPanning = False
                self.setCursor(Qt.ArrowCursor)
            self._mousePressed = False
        super(QDMGraphicsView, self).mouseReleaseEvent(event)

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Control and not self._mousePressed:
            self.drawingMode = False
            self._isPanning = True
            self.setCursor(Qt.OpenHandCursor)
        else:
            super(QDMGraphicsView, self).keyPressEvent(event)

    def keyReleaseEvent(self, event):
        if event.key() == Qt.Key_Control:
            if self.is_drawing:
                self.drawingMode = True
            if not self._mousePressed:
                self._isPanning = False
                self.setCursor(Qt.ArrowCursor)
        elif event.key() == Qt.Key_Delete:
            self.deleteSelected()
        else:
            super(QDMGraphicsView, self).keyPressEvent(event)

    def deleteSelected(self):
        for item in self.grScene.selectedItems():
            self.grScene.removeItem(item)

    def wheelEvent(self, event):
        if event.angleDelta().y() > 0:
            step = self.zoomStep
            fact = self.zoomInFactor
        else:
            step = -self.zoomStep
            fact = self.zoomOutFactor
        zoom = max(self.zoomRange[0], min(self.zoom + step, self.zoomRange[1]))
        if zoom != self.zoom:
            self.zoom = zoom
            self.scale(fact, fact)

    def fitInView(self, scale=True):
        rect = QRectF(self.grScene.mainImage.pixmap.rect())
        if not rect.isNull():
            self.setSceneRect(rect)
            if self.grScene.hasPhoto():
                unity = self.transform().mapRect(QRectF(0, 0, 1, 1))
                self.scale(1 / unity.width(), 1 / unity.height())
            self.zoom = 5


class WorkingArea(QWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.initUI()

    def loadImage(self):
        self.view.setMainImage(QPixmap('roi.jpg'))

    def initUI(self):
        self.setGeometry(0, 0, 800, 800)
        self.layout = QVBoxLayout()
        self.layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(self.layout)

        self.grScene = QDMWorkingAreaScene()

        self.view = QDMGraphicsView(self.grScene, self)
        self.layout.addWidget(self.view)
        gl = QOpenGLWidget()
        gl.setMouseTracking(True)
        format = QSurfaceFormat()
        format.setSamples(4)
        gl.setFormat(format)
        self.view.setViewport(gl)
        self.setWindowTitle("AutoMangaCleaner")
        self.loadImage()
        self.show()
        #self.view.setFocus()

    def mouseMoveEvent(self, event):
        self.view.mouseMoveEvent()


if __name__ == "__main__":
    app = QApplication(sys.argv)

    window = WorkingArea()

    sys.exit(app.exec_())
Asked By: Rimuto

||

Answers:

This is caused by the fact that shape items are always vectorial, so there is no concept of "resolution": no matter the scale, a circle is always a circle, as opposed to raster images which use the concept of pixels.

Since the smooth transformation used for scaling is similar to the blur effect, a possibility is to use the QGraphicsBlurEffect for the items, with a blurRadius value of 1 (as in "1 pixel").

While you could set the effect on each item, that wouldn’t be a good choice for performance reasons: you should instead group all those items in a single parent item. Qt provides the QGraphicsGroupItem class that can be easily created by using scene.createItemGroup().

class QDMWorkingAreaScene(QGraphicsScene):
    def __init__(self, parent=None):
        # ...
        self.drawingGroup = self.createItemGroup([])
        blur = QGraphicsBlurEffect(blurRadius=1)
        self.drawingGroup.setGraphicsEffect(blur)
        self.drawingGroup.setZValue(100)

    # ...

    def drawCircle(self, x, y, brushSize, pen, brush):
        item = QGraphicsEllipseItem(
            round(x), round(y), 
            brushSize, brushSize, 
            self.drawingGroup
        )
        item.setPen(pen)
        item.setBrush(brush)

    def drawLine(self, start_x, start_y, x, y, pen):
        item = QGraphicsLineItem(
            round(start_x), round(start_y), 
            round(x), round(y), 
            self.drawingGroup
        )
        item.setPen(pen)

Consider that you might want to temporarily disable the graphics effect whenever you are going to export the image.

Further notes:

  • again, for optimization reasons, you should use QPainterPathItem when drawing continuous lines, and add lines to its path until the mouse is released, then create a new item when the mouse is pressed again;
  • you should differentiate when creating an ellipse or when starting a new line/path, otherwise you’ll always have both;
  • since you’ll probably want to always use non decimal values (which is what happens when using the primitive constructors addEllipse(), as they always use integer values), you should always round scene positions, as did above; alternatively, just convert the scene point to a QPoint and a QPointF again:
    scenePos = QPointF(self.mapToScene(event.pos()).toPoint())
    self.grScene.drawLine(self.lastPoint, scenePos, self.brush_line_pen)
    self.lastPoint = scenePos
  • using a graphics item for the "brush cursor" has important side effects: first of all, whenever it’s moved near the edge of the scene, the scene will increase its bounding rect, so you should probably do self.setSceneRect(self.mainImage.sceneBoundingRect()) in setImage() (not in fitInView()); then, if the mouse moves outside of the view very fast, the item will still be visible somewhere in the scene: consider toggling its visibility in the enterEvent and leaveEvent of the view;
  • using flags for mouse buttons and keys might be dangerous if you’re not careful enough; use the proper event properties instead, which is simpler and clearer:
    if event.buttons() == Qt.LeftButton:
        # left mouse button pressed *during* a mouse move event
    if event.modifiers() == Qt.ControlModifier:
        # Ctrl key pressed
  • simplify the zooming function:
    def wheelEvent(self, event):
        if event.angleDelta().y() > 0:
            step = self.zoomStep
            fact = self.zoomInFactor
        else:
            step = -self.zoomStep
            fact = self.zoomOutFactor
        zoom = max(self.zoomRange[0], min(self.zoom + step, self.zoomRange[1]))
        if zoom != self.zoom:
            self.zoom = zoom
            self.scale(fact, fact)
Answered By: musicamante