Search code examples
pythonpyqtpyqt4

PyQt4 - QGraphicsItem position doesn't map into scene properly after drag


I've created a ImageView widget based on QWidget that contains a QGraphicsView. This widget shows an image and lets you draw a ROI (region of interest) with the mouse through a QGraphicsRectItem. The ImageView widget works well, but if the ROI rectangle is dragged and you want to redraw it in another place, the position captured by the mouse event doesn't maps to scene correctly.

Here are a few images to explain what I mean.

A dialog that contains the ImageView widget, whose actions control the widget itself:

enter image description here

If selection is enabled you can draw a rectangle:

enter image description here

Note the pointer position at the bottom right corner of the rectangle. The ROI just can be drawn inside the image.

If selection is disabled you can drag the previously drawn rectangle:

enter image description here

After this, if selection is enabled and you want to redraw the rectangle:

enter image description here

The pointer position is captured fine (is a fact!), as you can see at the dialog status bar, but this position (used to set the rectangle geometry) doesn't corresponds the rectangle position anymore.

I'm pretty new with Qt, here is the code:

Code

class ImageView(QtGui.QWidget):
    scaleChanged = QtCore.pyqtSignal()
    statusChanged = QtCore.pyqtSignal(str)

    def __init__(self, parent=None):
        super(ImageView, self).__init__(parent)
        self.scale_factor = 0.0

        # Imagen
        self.image_item = QtGui.QGraphicsPixmapItem()

        # ROI
        self.ROI_item = FancyQGraphicsRectItem(self.image_item)
        self.ROI_item.setFlag(self.ROI_item.ItemIsMovable)
        self.ROI_item.setBrush(QtGui.QBrush(QtCore.Qt.NoBrush))
        self.ROI_item.setPen(QtGui.QPen(QtCore.Qt.white, 0, QtCore.Qt.DashDotLine))
        self.ROI_item.setCursor(QtCore.Qt.OpenHandCursor)
        self.ROI_added = False

        # Escena
        self.scene = QtGui.QGraphicsScene()

        # Vista
        self.view = FancyQGraphicsView()
        self.view.ROI_item = self.ROI_item
        self.view.statusChanged.connect(self.change_status)
        self.view.setScene(self.scene)
        self.view.setBackgroundRole(QtGui.QPalette.Dark)
        self.view.setAlignment(QtCore.Qt.AlignCenter)
        self.view.setFrameShape(QtGui.QFrame.NoFrame)
        self.view.setRenderHint(QtGui.QPainter.Antialiasing, False)
        self.view.setMouseTracking(True)

        # Disposición
        layout = QtGui.QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self.view)

        self.setLayout(layout)

    def setImage(self, pixmap):
        self.image_item.setPixmap(pixmap)
        self.scene.addItem(self.image_item)
        self.scene.setSceneRect(0, 0, self.image_item.boundingRect().right(), self.image_item.boundingRect().bottom())
        self.view.setSceneSize()

    def selectionEnable(self, value):
        self.view.selectionEnable(value)
        if value:
            self.ROI_item.setCursor(QtCore.Qt.CrossCursor)
            self.view.setInteractive(False)
            self.view.viewport().setCursor(QtCore.Qt.CrossCursor)
            if not self.ROI_added:
                self.ROI_added = True
        else:
            self.view.viewport().setCursor(QtCore.Qt.ArrowCursor)
            self.ROI_item.setCursor(QtCore.Qt.OpenHandCursor)
            self.view.setInteractive(True)

    def setupDrag(self, value):
        if value:
            self.view.setInteractive(False)
            self.view.setDragMode(self.view.ScrollHandDrag)
        else:
            self.view.setDragMode(self.view.NoDrag)
            self.view.setInteractive(True)

    def normal_size(self):
        if self.scale_factor != 1.0:
            self.view.resetMatrix()
            self.scale_factor = 1.0
            self.scaleChanged.emit()

    def scale_image(self, factor):
        self.scale_factor *= factor
        self.view.scale(factor, factor)
        self.scaleChanged.emit()

    def delete_roi(self):
        self.ROI_item.setRect(0, 0, 0, 0)

    @QtCore.pyqtSlot(str)
    def change_status(self, message):
        self.statusChanged.emit(message)


class FancyQGraphicsView(QtGui.QGraphicsView):
    statusChanged = QtCore.pyqtSignal(str)
    scene_size = (0, 0)
    ROI_item = None
    event_origin = None
    selection = False
    click = False

    def mousePressEvent(self, event):
        if self.selection:
            event_pos = self.mapToScene(event.pos())
            pos = (int(event_pos.x()), int(event_pos.y()))
            if 0 <= pos[0] < self.scene_size[0] and 0 <= pos[1] < self.scene_size[1]:
                self.event_origin = event_pos
            else:
                self.event_origin = None
            self.click = True
        else:
            QtGui.QGraphicsView.mousePressEvent(self, event)

    def mouseMoveEvent(self, event):
        event_pos = self.mapToScene(event.pos())
        if self.selection and self.click:
            if self.event_origin:
                self.statusChanged.emit("x1: {0:>5d}    y1: {1:>5d}    "
                                        "x2: {2:>5d}    y2: {3:>5d}".format(int(self.event_origin.x()),
                                                                            int(self.event_origin.y()),
                                                                            int(event_pos.x()),
                                                                            int(event_pos.y())))
                if event_pos.x() < 0:
                    event_pos.setX(0)
                elif event_pos.x() > self.scene_size[0] - 1:
                    event_pos.setX(self.scene_size[0] - 1)
                if event_pos.y() < 0:
                    event_pos.setY(0)
                elif event_pos.y() > self.scene_size[1] - 1:
                    event_pos.setY(self.scene_size[1] - 1)
                self.ROI_item.setRect(QtCore.QRectF(self.event_origin, event_pos).normalized())
                print self.ROI_item.rect(), self.event_origin, event_pos
            else:
                self.statusChanged.emit("x: {0:>5d}    y: {1:>5d}".format(int(event_pos.x()), int(event_pos.y())))
        else:
            self.statusChanged.emit("x: {0:>5d}    y: {1:>5d}".format(int(event_pos.x()), int(event_pos.y())))
            QtGui.QGraphicsView.mouseMoveEvent(self, event)

    def mouseReleaseEvent(self, event):
        if self.selection:
            self.click = False
            if self.event_origin:
                self.event_origin = None
        else:
            QtGui.QGraphicsView.mouseReleaseEvent(self, event)

    def selectionEnable(self, value):
        self.selection = value

    def setSceneSize(self):
        rect = self.scene().sceneRect()
        self.scene_size = (rect.width(), rect.height())


class FancyQGraphicsRectItem(QtGui.QGraphicsRectItem):

    def mousePressEvent(self, event):
        self.setCursor(QtCore.Qt.ClosedHandCursor)
        QtGui.QGraphicsRectItem.mousePressEvent(self, event)

    def mouseMoveEvent(self, event):
        QtGui.QGraphicsRectItem.mouseMoveEvent(self, event)
        # Maybe this could be modified

    def mouseReleaseEvent(self, event):
        self.setCursor(QtCore.Qt.OpenHandCursor)
        QtGui.QGraphicsRectItem.mouseReleaseEvent(self, event)

Why is this happening? Previously I've tried to implement a restricted movable area into the image for the rectangle, but that item doesn't recognize its position properly in the scene when is dragged.


Solution

  • Your issue occurs because of the way you are using the QGraphicsRectItem. While you are setting the rectangle correctly initially, the item is originally placed at coordinates (0,0) of the scene. As such, the QGraphicsRectItem extends from (0,0) to the bottom right coordinate of your rectangle (in scene coordinates).

    When you move the ROI, you are translating the entire item, not just the rectangle within the item. Which means the item is no longer located at (0,0), and so the coordinates you are feeding it are offset because you are using scene coordinates rather than item coordinates.

    There are various methods (such as QGraphicsItem.mapFromScene()) which can translate the coordinates to the correct reference points (note that this should take into account the fact that your ROI is a child of self.image_item as well if that ever gets moved away from (0,0)).

    Another alternative is that you could relocate the ROI to the initial click coordinate, and then size it according to the difference between the initial click coordinate and the current click coordinate. So in the mouseMoveEvent you could do:

    self.ROI_item.setPos(self.event_origin)
    self.ROI_item.setRect(QtCore.QRectF(QtCore.QPointF(0,0), event_pos-self.event_origin).normalized())
    

    However, I suspect this may break if the parent item is moved, or if there is scaling applied to the QGraphicsView. In such cases you would probably need to investigate using the QGraphicsItem.mapFromScene() method (although it is likely to be useful to always relocate the item to the initial click position, if only to reduce the bounding box of the item)