skip to Main Content

I’m trying to mimick path editing similar to what you would see in photoshop, which interacts this way…

  1. You select the Path and it’s Points become visible
  2. Users can click and drag any Point item of the Path, to adjust the Path
  3. When users click and drag the Path directly it moves the Path
  4. When the Path is deselected the Points become hidden again

Where I’m having issues are

  1. Making the Points hidden when the Path is deselected but not hidden when a Point of the selected spline is being Edited

Here is what i have:

enter image description here

Here is a reference to something I’m trying to match:

enter image description here

import sys
import math
import random

from PySide2 import QtWidgets, QtGui, QtCore

# SETTINGS
handle_size = 16
handle_color = QtGui.QColor(40,130,230)
handle_radius = 8


class AnnotationPointItem(QtWidgets.QGraphicsEllipseItem):
    def __init__(self, positionFlag=0, pos=QtCore.QPointF(), parent=None):
        super(AnnotationPointItem, self).__init__(-handle_radius, -handle_radius, 2*handle_radius, 2*handle_radius, parent)
        
        self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable | QtWidgets.QGraphicsItem.ItemIsSelectable | QtWidgets.QGraphicsItem.ItemSendsScenePositionChanges)    
        self.setPen(QtGui.QPen(handle_color, 4, QtCore.Qt.SolidLine))
        self.setBrush(QtGui.QBrush(QtGui.QColor('white')))
        self.positionFlag = positionFlag 


    def paint(self, painter, option, widget=None):
        # Remove the selection outline
        # if self.isSelected():
        #     option.state &= ~QtWidgets.QStyle.State_Selected

        super(AnnotationPointItem, self).paint(painter, option, widget)


    # def mousePressEvent(self, event):
    #     # Handle the event, but don't propagate to the parent
    #     # event.accept()
    #     print('clicked....')
    #     return super(AnnotationPointItem, self).mousePressEvent(event)


    def itemChange(self, change, value):
        # print(change, self.isSelected())
        if change == QtWidgets.QGraphicsItem.ItemPositionChange:
            # print('ItemPositionChange')
            pass
        elif change == QtWidgets.QGraphicsItem.ItemPositionHasChanged:
            # print('ItemPositionHasChanged')
            parent = self.parentItem()
            if parent:
                # Get the position of the cursor in the view's coordinates
                if self.positionFlag == 0:
                    parent.setPoints(start=self.pos())
                elif self.positionFlag == 1:
                    parent.setPoints(end=self.pos())
        elif change == QtWidgets.QGraphicsItem.ItemSelectedChange:
            pass
        return super(AnnotationPointItem, self).itemChange(change, value)


class AnnotationPathItem(QtWidgets.QGraphicsLineItem):
    def __init__(self, 
        start=QtCore.QPointF(), 
        end=QtCore.QPointF(), 
        color=QtCore.Qt.green,
        thickness=10,
        parent=None):
        super(AnnotationPathItem, self).__init__(start.x(), start.y(), end.x(), end.y(), parent)

        self._color = color
        self._thickness = thickness

        self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable | QtWidgets.QGraphicsItem.ItemIsSelectable)
        self.setPen(QtGui.QPen(self._color, self._thickness, QtCore.Qt.SolidLine))

        # child items
        self.startPointItem = AnnotationPointItem(positionFlag=0, parent=self)
        self.startPointItem.hide()
        self.startPointItem.setPos(self.line().p1())

        self.endPointItem = AnnotationPointItem(positionFlag=1, parent=self)
        self.endPointItem.hide()
        self.endPointItem.setPos(self.line().p2())


    def itemChange(self, change, value):
        if change == QtWidgets.QGraphicsItem.ItemSelectedChange:
            self.selectionChanged(value)
        return super(AnnotationPathItem, self).itemChange(change, value)


    def selectionChanged(self, selected):
        # Implement what you want to do when the selection changes
        print(self.startPointItem.isSelected(), self.endPointItem.isSelected())
        if selected or self.startPointItem.isSelected() or self.endPointItem.isSelected():
            self.startPointItem.show()
            self.endPointItem.show()
        # else:
        #     self.startPointItem.hide()
        #     self.endPointItem.hide()


    def paint(self, painter, option, widget=None):
        # Remove the selection outline
        if self.isSelected():
            option.state &= ~QtWidgets.QStyle.State_Selected

        super(AnnotationPathItem, self).paint(painter, option, widget)


    def setPoints(self, start=None, end=None):
        currentLine = self.line()
        if start != None:
            currentLine.setP1(start)
        if end != None:
            currentLine.setP2(end)
        self.setLine(currentLine)


class MainWindow(QtWidgets.QWidget):
    def __init__(self):
        super(MainWindow, self).__init__()
        self.resize(1200,1200)

        self.scene = QtWidgets.QGraphicsScene(self)
        self.scene.setBackgroundBrush(QtGui.QColor(40,40,40))

        self.view = QtWidgets.QGraphicsView(self)
        self.view.setSceneRect(-4000, -4000, 8000, 8000)
        self.view.setRenderHints(QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform)
        self.view.setMouseTracking(True)
        self.view.setScene(self.scene)

        self.addButton = QtWidgets.QPushButton("Add Annotation", self)
        self.addButton.clicked.connect(self.add_annotation)

        layout = QtWidgets.QVBoxLayout(self)
        layout.addWidget(self.view)
        layout.addWidget(self.addButton)
        self.setLayout(layout)

        # samples
        item = AnnotationPathItem(QtCore.QPointF(-70, -150), QtCore.QPointF(150, -350))
        self.scene.addItem(item)


    def add_annotation(self):
        r = random.randint(0,255)
        g = random.randint(0,255)
        b = random.randint(0,255)
        color = QtGui.QColor(r,g,b)
        startPos = QtCore.QPointF(random.randint(-200,200), random.randint(-200,200))
        endPos = QtCore.QPointF(random.randint(-200,200), random.randint(-200,200))
        item = AnnotationPathItem(startPos, endPos, color)
        self.scene.addItem(item)


if __name__ == '__main__':
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    app.exec_()

2

Answers


  1. You cannot achieve this by checking the selection changes of the item, because when an item gets selected upon mouse press, the view has already deselected all the other.

    Instead, you can use the selectionChanged signal of the scene, which is emitted when all selection changes are completed, and decide whether to show items or not. Since items are usually not created with a scene at first, you have to connect to the signal upon the ItemSceneChange item change:

        def itemChange(self, change, value):
            if change == QGraphicsItem.ItemSceneChange:
                if self.scene():
                    self.scene().selectionChanged.disconnect(
                        self.updateChildSelection)
                if value:
                    value.selectionChanged.connect(self.updateChildSelection)
            return super(AnnotationPathItem, self).itemChange(change, value)
    
        def updateChildSelection(self):
            selection = set(self.scene().selectedItems())
            showItems = bool(
                set((self, self.startPointItem, self.endPointItem)) & selection
            )
            self.startPointItem.setVisible(showItems)
            self.endPointItem.setVisible(showItems)
    
    Login or Signup to reply.
  2. While my other answer is acceptable, I don’t really like the fact that each item is connected to the selection change signal.

    In case the scene may potentially contain lots of items (in the order of hundreds or thousands), this means that every time the selection changes the signal will call the connected function of each one of those items, and even unnecessarily if the selection change doesn’t affect any of those item types.

    The Graphics View framework is quite fast, but there’s little point in repeating the same calls that result in the same output (such as scene.selectedItems()) especially when dealing with the python bottleneck.

    In such situations, it’s usually preferable to use centralized functions to handle such a common and repeated job, possibly adopting some optimizations to improve its performance and return to the event loop as soon as possible.
    Doing that may increase code complexity, but could also result in faster response times for complex scenes with an undefined (and possibly extremely high) item count.

    The following alternative uses a custom QGraphicsScene to handle selection changes and child visibility on its own; when the signal is emitted, it only iterates between all known "annotation items", and each item will eventually iterate and change the visibility of its control points only in case the selection change has actually affected them.

    I also added a basic object subclass intended to be used as a mixin, with the benefit of being able to use it as a primitive class for any annotation-based item that uses control points. With proper implementation, you can also use this with all basic QGraphicsItems, including QGraphicsPathItem.

    class AnnotationPointItem(QGraphicsEllipseItem):
        def __init__(self, pos=QPointF(), parent=None):
            super().__init__(
                -handle_radius, -handle_radius, 
                2*handle_radius, 2*handle_radius, 
                parent
            )
            
            self.setFlags(
                QGraphicsItem.ItemIsMovable 
                | QGraphicsItem.ItemIsSelectable 
                | QGraphicsItem.ItemSendsScenePositionChanges
            )
            self.setPen(QPen(handle_color, 4, Qt.SolidLine))
            self.setBrush(Qt.white)
    
        def itemChange(self, change, value):
            if change == QGraphicsItem.ItemPositionHasChanged:
                parent = self.parentItem()
                if parent:
                    parent.updatePoint(self)
            return super().itemChange(change, value)
    
    
    class _AnnotationItemBase:
        _pointsVisible = False
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self._controlPoints = []
            # a set used for performance reasons in selection checks;
            # self is included by default to improve comparison
            self._cpset = set([self] + self._controlPoints)
    
        def addControlPoints(self, *cplist):
            self.insertControlPoints(-1, *cplist)
    
        def insertControlPoints(self, index, *cplist):
            if index < 0:
                index = len(self._controlPoints)
            self._controlPoints[index:index] = cplist
            self._cpset |= set(cplist)
    
        def removeControlPoints(self, *cplist):
            for cp in cplist:
                if cp in self._controlPoints:
                    self._controlPoints.remove(cp)
            self._cpset -= set(cplist)
    
        def updateSelection(self, selection):
            if not selection and not self._pointsVisible:
                # the selection is empty, and control points were not previously 
                # visible, so there is no point (pun intended) in going further
                return
    
            # check if self or any control points are part of the selection; 
            # note that the intuitive approach would use set intersection (&):
            #     visible = self in selection or bool(selection & self._cpset)
            # in reality, set.isdisjoint() seems to a better approach than 
            # intersection because of its actual behavior (see note below)
            visible = not self._cpset.isdisjoint(selection)
            if self._pointsVisible != visible:
                for i in self._controlPoints:
                    i.setVisible(visible)
                self._pointsVisible = visible
    
        def itemChange(self, change, value):
            if change == QGraphicsItem.ItemSceneChange:
                oldScene = self.scene()
                if oldScene != value:
                    if oldScene:
                        oldScene.unsubscribeAnnotationItem(self)
                    if value:
                        value.subscribeAnnotationItem(self)
            return super().itemChange(change, value)
    
    
    class AnnotationPathItem(_AnnotationItemBase, QGraphicsLineItem):
        def __init__(
            self, 
            start=QPointF(), end=QPointF(), 
            color=Qt.green, thickness=10, 
            parent=None
        ):
            super().__init__(start.x(), start.y(), end.x(), end.y(), parent)
    
            self._color = color
            self._thickness = thickness
    
            self.setFlags(
                QGraphicsItem.ItemIsMovable 
                | QGraphicsItem.ItemIsSelectable
            )
            self.setPen(QPen(self._color, self._thickness, Qt.SolidLine))
    
            # child items
            self.startPointItem = AnnotationPointItem(parent=self)
            self.startPointItem.hide()
            self.startPointItem.setPos(self.line().p1())
    
            self.endPointItem = AnnotationPointItem(parent=self)
            self.endPointItem.hide()
            self.endPointItem.setPos(self.line().p2())
    
            self.addControlPoints(self.startPointItem, self.endPointItem)
    
        def paint(self, painter, option, widget=None):
            # Remove the selection outline
            if self.isSelected():
                option.state &= ~QStyle.State_Selected
    
            super().paint(painter, option, widget)
    
        def setPoints(self, start=None, end=None):
            currentLine = self.line()
            if start != None:
                currentLine.setP1(start)
            if end != None:
                currentLine.setP2(end)
            self.setLine(currentLine)
    
        def updatePoint(self, cp):
            if cp == self.startPointItem:
                self.setPoints(start=cp.pos())
            elif cp == self.endPointItem:
                self.setPoints(end=cp.pos())
    
    
    class CustomScene(QGraphicsScene):
        def __init__(self, *args, **kwargs):
            super().__init__(*args, **kwargs)
            self.multiSelectionItems = set()
            self.selectionChanged.connect(self.updateAnnotationSelections)
    
        def subscribeAnnotationItem(self, item):
            self.multiSelectionItems.add(item)
    
        def unsubscribeAnnotationItem(self, item):
            self.multiSelectionItems.discard(item)
    
        def updateAnnotationSelections(self):
            selection = set(self.selectedItems())
            for item in self.multiSelectionItems:
                item.updateSelection(selection)
    
    
    class MainWindow(QWidget):
        def __init__(self):
            super().__init__()
    
            self.scene = CustomScene(self)
            ...
    

    Note the remark about the set comparison: I’ve not done thorough research about the low level implementation, but according to this and this related posts, using set.isdisjoint() is a better choice, and despite what the documentation may let assume, which says:

    Return True if the set has no elements in common with other. Sets are disjoint if and only if their intersection is the empty set.

    As far as I can understand, that can (should?) probably be rephrased as:

    Return False as soon as any element in the set is found on other.

    Assuming that my understanding is correct, not set.isdisjoint(other) is much faster than checking the intersection, and especially for this case:

    • set intersections create a further set, iterating through all contents of both sets, and that has to be eventually checked with bool();
    • isdisjoint() just returns False as soon as a common element is found, which is exactly what we need; if no element is found in the selection, it would still return True, but without the overhead of creating a further set;
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search