Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

mapToScene requires the view being shown for correct transformations?

Tags:

python

pyqt

Primary issue: the QGraphicsView.mapToScene method returns different answers depending on whether or not the GUI is shown. Why, and can I get around it?

The context is I'm trying to write unit tests but I don't want to actually show the tools for the tests.

The small example below illustrates the behavior. I use a sub-classed view that prints mouse click event positions in scene coordinates with the origin at the lower left (it has a -1 scale vertically) by calling mapToScene. However, mapToScene does not return what I am expecting before the dialog is shown. If I run the main section at the bottom, I get the following output:

Size is (150, 200)
Putting in (50, 125) - This point should return (50.0, 75.0)
Before show(): PyQt5.QtCore.QPointF(84.0, -20.0)
After show() : PyQt5.QtCore.QPointF(50.0, 75.0)

Before show(), there is a consistent offset of 34 pixels in x and 105 in y (and in y the offset moves in reverse as if the scale is not being applied). Those offset seem rather random, I have no idea where they are coming from.

Here is the example code:

import numpy as np
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QPointF, QPoint
from PyQt5.QtWidgets import (QDialog, QGraphicsView, QGraphicsScene,
                             QVBoxLayout, QPushButton, QApplication,
                             QSizePolicy)
from PyQt5.QtGui import QPixmap, QImage

class MyView(QGraphicsView):
    """View subclass that emits mouse events in the scene coordinates."""

    mousedown = pyqtSignal(QPointF)

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

        self.setSizePolicy(QSizePolicy.Fixed,
                           QSizePolicy.Fixed)

        # This is the key thing I need
        self.scale(1, -1)

    def mousePressEvent(self, event):
        return self.mousedown.emit(self.mapToScene(event.pos()))

class SimplePicker(QDialog):

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

        # Get a grayscale image
        bdata = ((data - data.min()) / (data.max() - data.min()) * 255).astype(np.uint8)
        wid, hgt = bdata.shape
        img = QImage(bdata.T.copy(), wid, hgt, wid,
                     QImage.Format_Indexed8)

        # Construct a scene with pixmap
        self.scene = QGraphicsScene(0, 0, wid, hgt, self)
        self.scene.setSceneRect(0, 0, wid, hgt)
        self.px = self.scene.addPixmap(QPixmap.fromImage(img))

        # Construct the view and connect mouse clicks
        self.view = MyView(self.scene, self)
        self.view.mousedown.connect(self.mouse_click)

        # End button
        self.doneb = QPushButton('Done', self)
        self.doneb.clicked.connect(self.accept)

        # Layout
        layout = QVBoxLayout(self)
        layout.addWidget(self.view)
        layout.addWidget(self.doneb)

    @pyqtSlot(QPointF)
    def mouse_click(self, xy):
        print((xy.x(), xy.y()))


if __name__ == "__main__":

    # Fake data
    x, y = np.mgrid[0:4*np.pi:150j, 0:4*np.pi:200j]
    z = np.sin(x) * np.sin(y)

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

    pick = SimplePicker(z)

    print("Size is (150, 200)")
    print("Putting in (50, 125) - This point should return (50.0, 75.0)")
    p0 = QPoint(50, 125)
    print("Before show():", pick.view.mapToScene(p0))

    pick.show()
    print("After show() :", pick.view.mapToScene(p0))

    qapp.exec_()

This example is in PyQt5 on Windows, but PyQt4 on Linux does the same thing.

like image 233
Ajean Avatar asked Apr 19 '17 23:04

Ajean


2 Answers

Upon diving into the C++ Qt source code, this is the Qt definition of mapToScene for a QPoint:

QPointF QGraphicsView::mapToScene(const QPoint &point) const
{
    Q_D(const QGraphicsView);
    QPointF p = point;
    p.rx() += d->horizontalScroll();
    p.ry() += d->verticalScroll();
    return d->identityMatrix ? p : d->matrix.inverted().map(p);
}

The critical things there are the p.rx() += d->horizontalScroll(); and likewise vertical scroll. A QGraphicsView always contains scroll bars, even if they are always off or not shown. The offsets observed before the widget is shown are from the values of the horizontal and vertical scroll bars upon initialization, which must get modified to match the view/viewport when the widgets are shown and layouts calculated. In order for mapToScene to operate properly, the scroll bars must be set up to match the scene/view.

If I put the following lines put before the call to mapToScene in the example, then I get the appropriate transformation result without the necessity of showing the widget.

pick.view.horizontalScrollBar().setRange(0, 150)
pick.view.verticalScrollBar().setRange(-200, 0)
pick.view.horizontalScrollBar().setValue(0)
pick.view.verticalScrollBar().setValue(-200)

To do this more generally, you can pull some relevant transformations from the view.

# Use the size hint to get shape info
wid, hgt = (pick.view.sizeHint().width()-2,
            pick.view.sizeHint().height()-2) # -2 removes padding ... maybe?

# Get the opposing corners through the view transformation
px = pick.view.transform().map(QPoint(wid, 0))
py = pick.view.transform().map(QPoint(0, hgt))

# Set the scroll bars accordingly
pick.view.horizontalScrollBar().setRange(px.y(), px.x())
pick.view.verticalScrollBar().setRange(py.y(), py.x())
pick.view.horizontalScrollBar().setValue(px.y())
pick.view.verticalScrollBar().setValue(py.y())

This is a hack-ish and ugly solution, so while it does work there may be a more elegant way to handle this.

like image 142
Ajean Avatar answered Nov 05 '22 00:11

Ajean


have you tried implementing your own qgraphicsview and overriding your resizeEvent? When you mess around with mapTo"something" you gotta take care of your resizeEvents, have a look in this piece of code I've took from yours and modified a bit ><

from PyQt5.QtCore import QRectF
from PyQt5.QtWidgets import (QGraphicsScene, QGraphicsView, QVBoxLayout,
                             QApplication, QFrame, QSizePolicy)
from PyQt5.QtCore import QPoint


class GraphicsView(QGraphicsView):

    def __init__(self):
        super(GraphicsView, self).__init__()


        # Scene and view
        scene = QGraphicsScene(0, 0, 150, 200,)
        scene.setSceneRect(0, 0, 150, 200)


    def resizeEvent(self, QResizeEvent):
        self.setSceneRect(QRectF(self.viewport().rect()))

qapp = QApplication(['python'])

# Just something to be a parent

view = GraphicsView()


# Short layout


# Make a test point
p0 = QPoint(50, 125)

# Pass in the test point before and after
print("Passing in point: ", p0)
print("Received point before show:", view.mapToScene(p0))
view.show()
print("Received point after show:", view.mapToScene(p0))

qapp.exec_()

Is that the behavior you wanted? ")

like image 1
yurisnm Avatar answered Nov 05 '22 00:11

yurisnm