Ajean
Ajean

Reputation: 5659

QGraphicsItem line width / transformation changes from PyQt4 to PyQt5

I have some small utility GUIs that I have made with PyQt, which use a QGraphicsScene with some items in it, and a view that response to user clicks (for making boxes, selecting points, etc).

On the (offline) machine I use, the software was just upgraded from Anaconda 2.5 to Anaconda 4.3, including the switch from PyQt4 to PyQt5. Everything still works, except somehow the transformations for my various QGraphicsItem objects are messed up if the scene rect is defined in anything but pixel coordinates.

Upfront question: What changed as far as item transformations from PyQt4 to PyQt5?

Here is an example of what I'm talking about: The top row is a box selector containing a dummy grayscale in a scene with a bounding rect of (0, 0, 2pi, 4pi). The green box is a QGraphicsRectItem drawn by the user from which I get the LL and UR points (in scene coordinates) after "Done" is clicked. The bottom row is a point layout with a user-clicked ellipse, on a small dummy image that has been zoomed in by 20.

The left and right were made with identical code. The version on the left is the result using PyQt4 under Anaconda 2.5 Python 3.5, whereas the result on the right is using PyQt5 under Anaconda 4.3 Python 3.6.

PyQt4 version of box PyQt5 version of box

PyQt4 version of ellipse PyQt5 version of ellipse

Clearly there is some sort of item transformation that is handled differently but I haven't been able to find it in any of the PyQt4->PyQt5 documentation (it's all about the API changes).

How do I go about making the line width of the QGraphicsItems be one in device coordinates while still maintaining the correct positions in scene coordinates? More generally, how do I scale a general QGraphicsItem so that it doesn't blow up or get fat based on scene size?

The code is below. SimpleDialog is the primary base class I use for various picker utilities, and it includes MouseView and ImageScene which automatically build in a vertical flip and a background image. The two utilities I used here are BoxSelector and PointLayout.

# Imports
import numpy as np
try:
    from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, QPointF
    from PyQt5.QtGui import QImage, QPixmap, QFont, QBrush, QPen, QTransform
    from PyQt5.QtWidgets import (QGraphicsView, QGraphicsScene, QDialog, QSizePolicy,
                                 QVBoxLayout, QPushButton, QMainWindow, QApplication)
except ImportError:
    from PyQt4.QtCore import Qt, pyqtSignal, pyqtSlot, QPointF
    from PyQt4.QtGui import (QImage, QPixmap, QFont, QBrush, QPen, QTransform,
                             QGraphicsView, QGraphicsScene, QDialog, QSizePolicy,
                             QVBoxLayout, QPushButton, QMainWindow, QApplication)

class MouseView(QGraphicsView):
    """A subclass of QGraphicsView that returns mouse click events."""

    mousedown = pyqtSignal(QPointF)
    mouseup = pyqtSignal(QPointF)
    mousemove = pyqtSignal(QPointF)

    def __init__(self, scene, parent=None):
        super(MouseView, self).__init__(scene, parent=parent)

        self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
        self.setSizePolicy(QSizePolicy.Fixed,
                           QSizePolicy.Fixed)
        self.scale(1, -1)

        self.moving = False

    def mousePressEvent(self, event):
        """Emit a mouse click signal."""
        self.mousedown.emit(self.mapToScene(event.pos()))

    def mouseReleaseEvent(self, event):
        """Emit a mouse release signal."""
        self.mouseup.emit(self.mapToScene(event.pos()))

    def mouseMoveEvent(self, event):
        """Emit a mouse move signal."""
        if self.moving:
            self.mousemove.emit(self.mapToScene(event.pos()))


class ImageScene(QGraphicsScene):
    """A subclass of QGraphicsScene that includes a background pixmap."""

    def __init__(self, data, scene_rect, parent=None):
        super(ImageScene, self).__init__(parent=parent)

        bdata = ((data - np.min(data)) / (np.max(data) - np.min(data)) * 255).astype(np.uint8)
        wid, hgt = data.shape

        img = QImage(bdata.T.copy(), wid, hgt, wid, QImage.Format_Indexed8)

        self.setSceneRect(*scene_rect)

        px = QPixmap.fromImage(img)

        self.px = self.addPixmap(px)

        px_trans = QTransform.fromTranslate(scene_rect[0], scene_rect[1])
        px_trans = px_trans.scale(scene_rect[2]/wid, scene_rect[3]/hgt)
        self.px.setTransform(px_trans)

class SimpleDialog(QDialog):
    """A base class for utility dialogs using a background image in scene."""

    def __init__(self, data, bounds=None, grow=[1.0, 1.0], wsize=None, parent=None):
        super(SimpleDialog, self).__init__(parent=parent)

        self.grow = grow

        wid, hgt = data.shape
        if bounds is None:
            bounds = [0, 0, wid, hgt]

        if wsize is None:
            wsize = [wid, hgt]

        vscale = [grow[0]*wsize[0]/bounds[2], grow[1]*wsize[1]/bounds[3]]

        self.scene = ImageScene(data, bounds, parent=self)

        self.view = MouseView(self.scene, parent=self)
        self.view.scale(vscale[0], vscale[1])

        quitb = QPushButton("Done")
        quitb.clicked.connect(self.close)

        lay = QVBoxLayout()
        lay.addWidget(self.view)
        lay.addWidget(quitb)

        self.setLayout(lay)

    def close(self):
        self.accept()

class BoxSelector(SimpleDialog):
    """Simple box selector."""

    def __init__(self, *args, **kwargs):
        super(BoxSelector, self).__init__(*args, **kwargs)

        self.rpen = QPen(Qt.green)
        self.rect = self.scene.addRect(0, 0, 0, 0, pen=self.rpen)

        self.view.mousedown.connect(self.start_box)
        self.view.mouseup.connect(self.end_box)
        self.view.mousemove.connect(self.draw_box)

        self.start_point = []
        self.points = []

        self.setWindowTitle('Box Selector')

    @pyqtSlot(QPointF)
    def start_box(self, xy):
        self.start_point = [xy.x(), xy.y()]
        self.view.moving = True

    @pyqtSlot(QPointF)
    def end_box(self, xy):
        lx = np.minimum(xy.x(), self.start_point[0])
        ly = np.minimum(xy.y(), self.start_point[1])
        rx = np.maximum(xy.x(), self.start_point[0])
        ry = np.maximum(xy.y(), self.start_point[1])
        self.points = [[lx, ly], [rx, ry]]
        self.view.moving = False

    @pyqtSlot(QPointF)
    def draw_box(self, xy):
        newpoint = [xy.x(), xy.y()]
        minx = np.minimum(self.start_point[0], newpoint[0])
        miny = np.minimum(self.start_point[1], newpoint[1])
        size = [np.abs(i - j) for i, j in zip(self.start_point, newpoint)]
        self.rect.setRect(minx, miny, size[0], size[1])

class PointLayout(SimpleDialog):
    """Simple point display."""

    def __init__(self, *args, **kwargs):
        super(PointLayout, self).__init__(*args, **kwargs)

        self.pen = QPen(Qt.green)

        self.view.mousedown.connect(self.mouse_click)

        self.circles = []
        self.points = []

        self.setWindowTitle('Point Layout')

    @pyqtSlot(QPointF)
    def mouse_click(self, xy):
        self.points.append((xy.x(), xy.y()))
        pt = self.scene.addEllipse(xy.x()-0.5, xy.y()-0.5, 1, 1, pen=self.pen)
        self.circles.append(pt)

And here is the code I used to do the tests:

def test_box():

    x, y = np.mgrid[0:175, 0:100]
    img = x * y

    app = QApplication.instance()
    if app is None:
        app = QApplication(['python'])

    picker = BoxSelector(img, bounds=[0, 0, 2*np.pi, 4*np.pi])
    picker.show()
    app.exec_()

    return picker

def test_point():

    np.random.seed(159753)
    img = np.random.randn(10, 5)

    app = QApplication.instance()
    if app is None:
        app = QApplication(['python'])

    pointer = PointLayout(img, bounds=[0, 0, 10, 5], grow=[20, 20])
    pointer.show()

    app.exec_()

    return pointer

if __name__ == "__main__":
    pick = test_box()
    point = test_point()

Upvotes: 1

Views: 2038

Answers (1)

ekhumoro
ekhumoro

Reputation: 120608

I found that explicitly setting the pen width to zero restores the previous behaviour:

class BoxSelector(SimpleDialog):
    def __init__(self, *args, **kwargs):
        ...    
        self.rpen = QPen(Qt.green)
        self.rpen.setWidth(0)
        ...

class PointLayout(SimpleDialog):    
    def __init__(self, *args, **kwargs):
        ...
        self.pen = QPen(Qt.green)
        self.pen.setWidth(0)
        ...

It seems that the default was 0 in Qt4, but it is 1 in Qt5.

From the Qt Docs for QPen.setWidth:

A line width of zero indicates a cosmetic pen. This means that the pen width is always drawn one pixel wide, independent of the transformation set on the painter.

Upvotes: 1

Related Questions