Search code examples
pythonpyqtpyqt5qgraphicsviewqgraphicsscene

PyQT User Editable Polygons


I am using QPainter and QPolygon classes to paint polygons over an image. I need the user to be able to edit this polygons by dragging the points in them. The QPolygon and QPainter classes don't have any signals, so I can't trigger the event through them.

I have realized that if I keep on using this approach, then the image would have to be rendered each time a point is moved would probably look really bad and not smooth at all.

Anybody know how I could put these points on top of the image instead of painting them in the image?


Solution

  • If you are using QGraphicsView and QGraphicsScene it is best to use the items, do not use QPainter since your task is only to paint and does not handle other types of events. In the following example I show a custom QGraphicsPolygonItem to draw the polygon, and in each vertex another item whose task is to move and modify the points of the polygon. In my example you can load an image, also to create the polygon you must press the polygon menu and then add it with clicks, to indicate that you do not want to add more elements you must press the ESC, and then you can move each vertex by dragging, or moving the polygon dragging from the center of the item. Finally if you want a zoomIn and zoomOut press Ctrl++ and Ctrl+-.

    from enum import Enum
    from functools import partial
    from PyQt5 import QtWidgets, QtGui, QtCore
    
    
    class GripItem(QtWidgets.QGraphicsPathItem):
        circle = QtGui.QPainterPath()
        circle.addEllipse(QtCore.QRectF(-10, -10, 20, 20))
        square = QtGui.QPainterPath()
        square.addRect(QtCore.QRectF(-15, -15, 30, 30))
    
        def __init__(self, annotation_item, index):
            super(GripItem, self).__init__()
            self.m_annotation_item = annotation_item
            self.m_index = index
    
            self.setPath(GripItem.circle)
            self.setBrush(QtGui.QColor("green"))
            self.setPen(QtGui.QPen(QtGui.QColor("green"), 2))
            self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True)
            self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, True)
            self.setFlag(QtWidgets.QGraphicsItem.ItemSendsGeometryChanges, True)
            self.setAcceptHoverEvents(True)
            self.setZValue(11)
            self.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
    
        def hoverEnterEvent(self, event):
            self.setPath(GripItem.square)
            self.setBrush(QtGui.QColor("red"))
            super(GripItem, self).hoverEnterEvent(event)
    
        def hoverLeaveEvent(self, event):
            self.setPath(GripItem.circle)
            self.setBrush(QtGui.QColor("green"))
            super(GripItem, self).hoverLeaveEvent(event)
    
        def mouseReleaseEvent(self, event):
            self.setSelected(False)
            super(GripItem, self).mouseReleaseEvent(event)
    
        def itemChange(self, change, value):
            if change == QtWidgets.QGraphicsItem.ItemPositionChange and self.isEnabled():
                self.m_annotation_item.movePoint(self.m_index, value)
            return super(GripItem, self).itemChange(change, value)
    
    
    class PolygonAnnotation(QtWidgets.QGraphicsPolygonItem):
        def __init__(self, parent=None):
            super(PolygonAnnotation, self).__init__(parent)
            self.m_points = []
            self.setZValue(10)
            self.setPen(QtGui.QPen(QtGui.QColor("green"), 2))
            self.setAcceptHoverEvents(True)
    
            self.setFlag(QtWidgets.QGraphicsItem.ItemIsSelectable, True)
            self.setFlag(QtWidgets.QGraphicsItem.ItemIsMovable, True)
            self.setFlag(QtWidgets.QGraphicsItem.ItemSendsGeometryChanges, True)
    
            self.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor))
    
            self.m_items = []
    
        def number_of_points(self):
            return len(self.m_items)
    
        def addPoint(self, p):
            self.m_points.append(p)
            self.setPolygon(QtGui.QPolygonF(self.m_points))
            item = GripItem(self, len(self.m_points) - 1)
            self.scene().addItem(item)
            self.m_items.append(item)
            item.setPos(p)
    
        def removeLastPoint(self):
            if self.m_points:
                self.m_points.pop()
                self.setPolygon(QtGui.QPolygonF(self.m_points))
                it = self.m_items.pop()
                self.scene().removeItem(it)
                del it
    
        def movePoint(self, i, p):
            if 0 <= i < len(self.m_points):
                self.m_points[i] = self.mapFromScene(p)
                self.setPolygon(QtGui.QPolygonF(self.m_points))
    
        def move_item(self, index, pos):
            if 0 <= index < len(self.m_items):
                item = self.m_items[index]
                item.setEnabled(False)
                item.setPos(pos)
                item.setEnabled(True)
    
        def itemChange(self, change, value):
            if change == QtWidgets.QGraphicsItem.ItemPositionHasChanged:
                for i, point in enumerate(self.m_points):
                    self.move_item(i, self.mapToScene(point))
            return super(PolygonAnnotation, self).itemChange(change, value)
    
        def hoverEnterEvent(self, event):
            self.setBrush(QtGui.QColor(255, 0, 0, 100))
            super(PolygonAnnotation, self).hoverEnterEvent(event)
    
        def hoverLeaveEvent(self, event):
            self.setBrush(QtGui.QBrush(QtCore.Qt.NoBrush))
            super(PolygonAnnotation, self).hoverLeaveEvent(event)
    
    
    class Instructions(Enum):
        No_Instruction = 0
        Polygon_Instruction = 1
    
    
    class AnnotationScene(QtWidgets.QGraphicsScene):
        def __init__(self, parent=None):
            super(AnnotationScene, self).__init__(parent)
            self.image_item = QtWidgets.QGraphicsPixmapItem()
            self.image_item.setCursor(QtGui.QCursor(QtCore.Qt.CrossCursor))
            self.addItem(self.image_item)
            self.current_instruction = Instructions.No_Instruction
    
        def load_image(self, filename):
            self.image_item.setPixmap(QtGui.QPixmap(filename))
            self.setSceneRect(self.image_item.boundingRect())
    
        def setCurrentInstruction(self, instruction):
            self.current_instruction = instruction
            self.polygon_item = PolygonAnnotation()
            self.addItem(self.polygon_item)
    
        def mousePressEvent(self, event):
            if self.current_instruction == Instructions.Polygon_Instruction:
                self.polygon_item.removeLastPoint()
                self.polygon_item.addPoint(event.scenePos())
                # movable element
                self.polygon_item.addPoint(event.scenePos())
            super(AnnotationScene, self).mousePressEvent(event)
    
        def mouseMoveEvent(self, event):
            if self.current_instruction == Instructions.Polygon_Instruction:
                self.polygon_item.movePoint(self.polygon_item.number_of_points()-1, event.scenePos())
            super(AnnotationScene, self).mouseMoveEvent(event)
    
    
    class AnnotationView(QtWidgets.QGraphicsView):
        factor = 2.0
    
        def __init__(self, parent=None):
            super(AnnotationView, self).__init__(parent)
            self.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform)
            self.setMouseTracking(True)
            QtWidgets.QShortcut(QtGui.QKeySequence.ZoomIn, self, activated=self.zoomIn)
            QtWidgets.QShortcut(QtGui.QKeySequence.ZoomOut, self, activated=self.zoomOut)
    
        @QtCore.pyqtSlot()
        def zoomIn(self):
            self.zoom(AnnotationView.factor)
    
        @QtCore.pyqtSlot()
        def zoomOut(self):
            self.zoom(1 / AnnotationView.factor)
    
        def zoom(self, f):
            self.scale(f, f)
            if self.scene() is not None:
                self.centerOn(self.scene().image_item)
    
    
    class AnnotationWindow(QtWidgets.QMainWindow):
        def __init__(self, parent=None):
            super(AnnotationWindow, self).__init__(parent)
            self.m_view = AnnotationView()
            self.m_scene = AnnotationScene(self)
            self.m_view.setScene(self.m_scene)
    
            self.setCentralWidget(self.m_view)
            self.create_menus()
    
            QtWidgets.QShortcut(QtCore.Qt.Key_Escape, self, activated=partial(self.m_scene.setCurrentInstruction, Instructions.No_Instruction))
    
        def create_menus(self):
            menu_file = self.menuBar().addMenu("File")
            load_image_action = menu_file.addAction("&Load Image")
            load_image_action.triggered.connect(self.load_image)
    
            menu_instructions = self.menuBar().addMenu("Intructions")
            polygon_action = menu_instructions.addAction("Polygon")
            polygon_action.triggered.connect(partial(self.m_scene.setCurrentInstruction, Instructions.Polygon_Instruction))
    
        @QtCore.pyqtSlot()
        def load_image(self):
            filename, _ = QtWidgets.QFileDialog.getOpenFileName(self, 
                "Open Image",
                QtCore.QStandardPaths.writableLocation(QtCore.QStandardPaths.PicturesLocation), #QtCore.QDir.currentPath(), 
                "Image Files (*.png *.jpg *.bmp)")
            if filename:
                self.m_scene.load_image(filename)
                self.m_view.fitInView(self.m_scene.image_item, QtCore.Qt.KeepAspectRatio)
                self.m_view.centerOn(self.m_scene.image_item)
    
    
    if __name__ == '__main__':
        import sys
    
        app = QtWidgets.QApplication(sys.argv)
        w = AnnotationWindow()
        w.resize(640, 480)
        w.show()
        sys.exit(app.exec_())