18

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.

Ajean
  • 5,528
  • 14
  • 46
  • 69
  • The question might hinge on what the `QPoint` argument of `mapToScene()` is relative to. If it's relative to the `QGraphicsView` widget's drawing origin in its enclosing widget or to its containing `QFrame`'s drawing origin, then even if Qt wanted to, it could not reliably map points without the widget being displayed, as `QGraphicsView` is scrollable and in both cases the mapping would depend on the scroll position. – blubberdiblub Apr 29 '17 at 10:11
  • I had similar thoughts, and I feel like I need to know what is going on under the hood, but I can't find anything other than how to make things work under normal conditions (with the GUI being used). In my particular cases the view is never going to be scrolled and the scene rect never changed (although the view transformation is not just trivial as depicted here), but even so shouldn't there be a way to programmatically get/set the viewport and whatever other parent offsets are required without doing the drawing? – Ajean May 01 '17 at 15:51
  • What I was wondering is why is it so important to you to test the mapping between viewport and scene coordinates? Wouldn't it be better if you treated the mapping as a black box or implementation detail of Qt? – blubberdiblub May 01 '17 at 16:01
  • I'm not quite sure what you mean - the mapping I'm dealing with is dynamic, different for every instance of the GUI based on parameter inputs, and I'm relying on mapToScene to pull data from the correct place. As I add complication to the transformation, why would I not need to test the actual thing I'm going to be using? – Ajean May 01 '17 at 16:09
  • Well, I'm sure I'm just misunderstanding what you're doing. But from my naive point of view, testing mapToScene or mapFromScene feels like testing whether Qt works correctly, as this is just for transforming mouse clicks (and similar) into scene coordinates for the former and for how the scene is presented to the user in terms of scale, rotation and shear for the latter. The objects in the scene and in the scene alone is what would be by concern. Also, wouldn't you get a different mapping on that HiDPI stuff some systems have? – blubberdiblub May 01 '17 at 16:23
  • Okay I kinda see what you mean, but what I'm really testing is not so much if Qt is working properly, but if my implementation is doing what I think it should - in real code things like putting in `scale(1,-1)` where it should be `scale(-1,1)` (for example) in the transformation would cause havoc and without a unit test could very easily go unnoticed for a long time. Given that the points are going in by mouse click, having the view play in is crucial. – Ajean May 01 '17 at 16:31

2 Answers2

3

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.

Ajean
  • 5,528
  • 14
  • 46
  • 69
1

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? ")

yurisnm
  • 1,630
  • 13
  • 29
  • Sadly, while this code does actually function properly, when I add the resizeEvent override to my own view subclass it does not work (I get the same behavior as before). I was hoping to distill the problem down but I may need to put back a more complicated example into the question. – Ajean May 03 '17 at 15:46
  • I just rolled the question back by one edit, so now the code includes the subclass I'm using plus example data. In this context, adding the resizeEvent method doesn't change the behavior (which may somehow be indicative of whatever is happening underneath...) – Ajean May 03 '17 at 15:50
  • The resize event doesn't happen until the object is being shown, which is what I would expect, as it makes sense in my book. @Ajean That's the same what happens for you, correct? – blubberdiblub May 03 '17 at 16:55
  • @blubberdiblub I believe so, yes ... actually in this answer's code if I remove the resize event override what happens is the point prints correctly before the show but *not* after (which ... kind of makes sense ... ?). However even if in my code above (with the subclass ... sorry for futzing with the code in the question) I add a resizeEvent override and explicitly call `resize` on the view (without showing it) the point still doesn't transform properly. – Ajean May 03 '17 at 16:58
  • @Ajean maybe you can somehow force the layout to do its layouting before being shown? If that doesn't help, one possible explanation could still be that Qt cannot know the size of the left and top window borders before it asks the windowing system to display it. – blubberdiblub May 03 '17 at 17:03
  • @blubberdiblub Interesting idea, I've been so focused on the view and scene I hadn't thought of the layout. You may be right about the window system; it was always a possibility that I can't really do this, but I *hope* there's a way. – Ajean May 03 '17 at 17:07
  • @Ajean the `layout.update()` method looks promising. – blubberdiblub May 03 '17 at 17:09
  • Let us [continue this discussion in chat](http://chat.stackoverflow.com/rooms/143315/discussion-between-ajean-and-blubberdiblub). – Ajean May 03 '17 at 17:14