Search code examples
pythonqtpyqttransformationqt4.8

PyQt mapping the coordinates of a mouse press to the coordinate system of an image


In my program I'm trying to map the coordinates of a mousepress back to the coordinate dimensions of an image. I'm using PyQt4 in Python. The program below demonstrates the problem. I have a widget that makes a few image transformations. After those image transformations the image is shown in the center of the widget, while maintaining the original aspect ratio of the image. Since the image is scaled and translated, a coordinate of a MouseEvent must be remapped to the coordinate system of the Image.

The program below has a class "ScalingWidget", that should be able to do these transformations and should also be able to remap the coordinate in a mouseReleaseEvent back to the coordinate system of the image. This works perfectly as expected when I show the widget outside a Layout and mainwindow, but it gets evil when I embed the widget in a bigger gui. Then the coordinates after mapping them back to the Image coordinates suddenly displays an offset.

The minimal program below can be started with and without the bug by specifing flag -b when starting the program. The option -n can put the instance of ScalingWidget deep and deeper inside a "gui", and the deeper it is embedded in the layouts the more strong the bug will be visible. The stupid thing is, although drawing indicates that the transformations are correct, the mapped coordinates (printed in the window title and console) indicate that remapping them back to the image coordinates is screwed up when the -b flag is present.

So my question is: What am I doing wrong with remapping the mouse coordinates back to the image dimensions when my ScalingWidget is embedded in a layout?

I don't expect the remapping to be pixel perfect, but just as accurate as the end user can position the mouse. There are two points x=20, y=20 and at x=380 and y=380 these can be used as reference point.

Any help is most welcome!

#!/usr/bin/env python

from PyQt4 import QtGui
from PyQt4 import QtCore
import sys
import argparse

class ScalingWidget (QtGui.QWidget):
    ''' Displays a pixmap optimally in the center of the widget, in such way
        the pixmap is shown in the middle
    '''
    white   = QtGui.QColor(255,255,255)
    black   = QtGui.QColor(  0,  0,  0)
    arcrect = QtCore.QRect(-10, -10, 20, 20)

    def __init__(self):
        super(ScalingWidget, self).__init__()
        self.pixmap = QtGui.QPixmap(400, 400)
        painter = QtGui.QPainter(self.pixmap)
        painter.fillRect(self.pixmap.rect(), self.white)
        self.point1 = QtCore.QPoint(20, 20)
        self.point2 = QtCore.QPoint(380, 380)
        painter.setPen(self.black)
        painter.drawRect(QtCore.QRect(self.point1, self.point2))
        painter.end()
        self.matrix = None

    def sizeHint(self):
        return QtCore.QSize(500,400)

    ##
    # Applies the default transformations
    #
    def _default_img_transform(self, painter):
        #size of widget
        winheight   = float(self.height())
        winwidth    = float(self.width())
        #size of pixmap
        scrwidth    = float(self.pixmap.width())
        scrheight   = float(self.pixmap.height())
        assert(painter.transform().isIdentity())

        if scrheight <= 0 or scrwidth <= 0:
            raise RuntimeError(repr(self) + "Unable to determine Screensize")

        widthr  = winwidth / scrwidth
        heightr = winheight / scrheight

        if widthr > heightr:
            translate = (winwidth - heightr * scrwidth) /2
            painter.translate(translate, 0)
            painter.scale(heightr, heightr)
        else:
            translate = (winheight - widthr * scrheight) / 2
            painter.translate(0, translate)
            painter.scale(widthr, widthr)

        # now store the matrix used to map the mouse coordinates back to the 
        # coordinates of the pixmap
        self.matrix = painter.deviceTransform()

    def paintEvent(self, e):
        painter = QtGui.QPainter(self)
        painter.setClipRegion(e.region())

        # fill the background of the entire widget.
        painter.fillRect(self.rect(), QtGui.QColor(0,0,0))

        # transform to place the image nicely in the center of the widget.
        self._default_img_transform(painter)
        painter.drawPixmap(self.pixmap.rect(), self.pixmap, self.pixmap.rect())
        pen = QtGui.QPen(QtGui.QColor(255,0,0))

        # Just draw on the points used to make the black rectangle of the pix map
        # drawing is not affected, be remapping those coordinates with the "same"
        # matrix is.
        pen.setWidth(4)
        painter.setPen(pen)
        painter.save()
        painter.translate(self.point1)
        painter.drawPoint(0,0)
        painter.restore()
        painter.save()
        painter.translate(self.point2)
        painter.drawPoint(0,0)
        painter.restore()

        painter.end()

    def mouseReleaseEvent(self, event):
        x, y = float(event.x()), float(event.y())
        inverted, invsucces = self.matrix.inverted()
        assert(invsucces)
        xmapped, ymapped = inverted.map(x,y)
        print x, y
        print xmapped, ymapped
        self.setWindowTitle("mouse x,y = {}, {}, mapped x, y = {},{} "
                                .format(x, y, xmapped, ymapped)
                            )


def start_bug():
    ''' Displays the mouse press mapping bug.
        This is a bit contrived, but in the real world
        a widget is embedded in deeper in a gui
        than a single widget, besides the problem
        grows with the depth of embedding.
    '''
    app = QtGui.QApplication(sys.argv)
    win     = QtGui.QWidget()
    layout  = QtGui.QVBoxLayout()
    win.setLayout(layout)
    widget = None
    for i in range(0, args.increase_bug):
        if i < args.increase_bug-1:
            widget = QtGui.QWidget()
            layout.addWidget(widget)
            layout= QtGui.QVBoxLayout()
            widget.setLayout(layout)
        else:
            layout.addWidget(ScalingWidget())
    win.show()
    sys.exit(app.exec_())

def start_no_bug():
    ''' Does not show the mapping bug, the mouse event.x() and .y() map nicely back to
        the coordinate system of the pixmap
    '''
    app = QtGui.QApplication(sys.argv)
    win = ScalingWidget()
    win.show()
    sys.exit(app.exec_())

# parsing arguments
parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter)
parser.add_argument('-b', '--display-bug', action='store_true',
                    help="Toggle this option to get the bugged version"
                    )
parser.add_argument('-n', '--increase-bug', type=int, default=1,
                    help="Increase the bug by n times."
                    )

if __name__ == "__main__":
    args = parser.parse_args()
    if args.display_bug:
        start_bug()
    else:
        start_no_bug()

Solution

  • The basic idea of the _default_image_transform is correct. The error is in the end of the function.

    def _default_img_transform(self, painter):
        #size of widget
        winheight   = float(self.height())
        winwidth    = float(self.width())
        #size of pixmap
        scrwidth    = float(self.pixmap.width())
        scrheight   = float(self.pixmap.height())
        assert(painter.transform().isIdentity())
    
        if scrheight <= 0 or scrwidth <= 0:
            raise RuntimeError(repr(self) + "Unable to determine Screensize")
    
        widthr  = winwidth / scrwidth
        heightr = winheight / scrheight
    
        if widthr > heightr:
            translate = (winwidth - heightr * scrwidth) /2
            painter.translate(translate, 0)
            painter.scale(heightr, heightr)
        else:
            translate = (winheight - widthr * scrheight) / 2
            painter.translate(0, translate)
            painter.scale(widthr, widthr)
    
        # now store the matrix used to map the mouse coordinates back to the 
        # coordinates of the pixmap
        self.matrix = painter.deviceTransform() ## <-- error is here
    

    The last line of the function _default_image_transform should be:

    self.matrix = painter.transform()
    

    According to the documentation one should only call the QPainter.deviceTransform() when you are working with QT::HANDLE which is an platform dependent handle. Since I wasn't working with a platform dependent handle I shouldn't have called it. It works out when I show the widget, but not when it is embedded in a layout. Then the deviceTransform matrix is different from the normal QPainter.transform() matrix. See also http://doc.qt.io/qt-4.8/qpainter.html#deviceTransform