2

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_())
Rimuto
  • 33
  • 4

1 Answers1

1

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)
musicamante
  • 41,230
  • 6
  • 33
  • 58
  • but recently i found one more way to implement drawing, i just inherit QGraphicsPixmapItem and use QPainter to draw circles and lines on pixmap (add new code to question). As i can see this way provide better performance. the only problem is that small circles e.g. 10 pixels diameter, are always have different shape. Anyway, your notes were very helpful – Rimuto Sep 12 '22 at 00:57
  • 1
    @Rimuto That issue is caused by the fact that you're not using the `QPainter.Antialiasing` hint (which is what I did for the view in my code). That said, while your solution seem to provide "better" performance, it has a **huge** problem: it's *destructive editing*. There's absolutely **no** way to undo what you did, unless you **always** keep a copy of the pixmap before each edit, which is not that really performant, memory-wise: a 1024x768@32 image takes ~2MB, if you don't use a smart undo system, just moving the mouse around for a couple of seconds results in memory usage of half a GB. – musicamante Sep 13 '22 at 01:02
  • 1
    @Rimuto Image (and media, in general) editing should *never* be destructive, so you should always provide meanings to undo what has been done (see the [Qt Undo Framework](https://doc.qt.io/qt-5/qundo.html)). Such a system must always consider memory usage, possibly through the usage of "deltas" (the smallest change between each edit, and the smallest memory footprint for that change). If you don't intend to use vectorial drawing content, that's perfectly fine, but you shall *not* directly draw on the item's pixmap at each change. Besides, you shall not overwrite `self.pixmap`: it's a property. – musicamante Sep 13 '22 at 01:08