Search code examples
pythonpyqt5qlabelqpixmap

Rotate a QPixmap inside a QLabel causes the pixmap to move along the x-axis rather than staying inside the QLabel


I am trying to get a ball(QPixmap inside a QLabel) to rotate while bouncing off the edges of the screen. But the ball, even though it does rotate seems to be moving along the axis inside the QLabel so after a few movements of the timer it moves outside the borders of the QLabel and therefore does not appear any longer.

Please see my code below.

from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import random
x = 0
y = 00
velX = 2
velY = 1
randX = random.choice([1, 2, 3])
randY = random.choice([1, 2, 3])


class MainWindow(QMainWindow):
    def __init__(self, *args, **kwargs):
        super(MainWindow, self).__init__(*args, **kwargs)
        self.setWindowTitle('ball move')
        self.setMinimumWidth(800)
        self.setMaximumWidth(800)
        self.setMinimumHeight(500)
        self.setMaximumHeight(500)
        self.setStyleSheet('background-color: black;border:none;')

        self.ballLabel = QLabel(self)
        self.ballPixmap = QPixmap('ball.png')
        self.resizedBallPixmap = self.ballPixmap.scaled(50, 50, Qt.KeepAspectRatio, Qt.FastTransformation)
        self.ballLabel.setFixedSize(50, 50)

        self.ballRotation = 10
        self.ballLabel.setPixmap(self.resizedBallPixmap)
        self.ballLabel.setStyleSheet('border:1px solid red;')
        self.ballLabel.show()

        self.ballLabel.move(0, 0)

    def rotateBall(self):
        self.resizedBallPixmap = self.resizedBallPixmap.transformed(
            QTransform().rotate(self.ballRotation), Qt.SmoothTransformation)

        # self.resizedBallPixmap = self.resizedBallPixmap.transformed(QTransform().translate(self.resizedBallPixmap.size().width()/2,                                    self.resizedBallPixmap.size().height()/2))

        self.ballLabel.setPixmap(self.resizedBallPixmap)


def ballMove():
    global x, y, velX, velY, randX, randY
    if (main_window.ballLabel.pos().x() + 50) > 800:
        velX = -1
        randX = random.choice([1, 2, 3])
        randY = random.choice([1, 2, 3])
    elif main_window.ballLabel.pos().x() < 0:
        velX = 1
        randX = random.choice([1, 2, 3])
        randY = random.choice([1, 2, 3])
    elif (main_window.ballLabel.pos().y() + 50) > 500:
        velY = -1
        randX = random.choice([1, 2, 3])
        randY = random.choice([1, 2, 3])
    elif main_window.ballLabel.pos().y() < 0:
        velY = 1
        randX = random.choice([1, 2, 3])
        randY = random.choice([1, 2, 3])

    x += velX*randX
    y += velY*randY
    main_window.rotateBall()
    main_window.ballLabel.move(x, y)


if __name__ == "__main__":
    app = QApplication([])
    main_window = MainWindow()
    main_window.show()
    timer = QTimer()
    timer.timeout.connect(ballMove)
    timer.start(1000)
    app.exec_()

Solution

  • Explanation:

    To understand the problem then the size of self.resizedBallPixmap must be analyzed using the following code:

    def rotateBall(self):
        print(self.resizedBallPixmap.size())
        # ...
    

    Output:

    PyQt5.QtCore.QSize(50, 50)
    PyQt5.QtCore.QSize(59, 58)
    PyQt5.QtCore.QSize(70, 68)
    PyQt5.QtCore.QSize(81, 80)
    PyQt5.QtCore.QSize(94, 93)
    PyQt5.QtCore.QSize(110, 108)
    PyQt5.QtCore.QSize(128, 126)
    PyQt5.QtCore.QSize(149, 147)
    PyQt5.QtCore.QSize(173, 171)
    PyQt5.QtCore.QSize(201, 199)
    PyQt5.QtCore.QSize(233, 231)
    PyQt5.QtCore.QSize(271, 268)
    PyQt5.QtCore.QSize(314, 311)
    ...
    

    As you can see, the size of the QPixmap is changing, and why is it changing? because when rotating a rectangle the ex-inscribed rectangle must be bigger, and what causes the rectangle to be bigger? Well, the size of the QLabel is not enough to draw the QPixmap and it only paints the left part, making the user observe that the ball advances.

    Solution:

    The solution is that when the QPixmap is rotated it is cut and only the necessary part is kept. In addition, each rotation transformation generates distortion, so it is not recommended to iterate over the same QPixmap but to maintain the original QPixmap and increase the angle of rotation.

    class MainWindow(QMainWindow):
        def __init__(self, *args, **kwargs):
            # ...
    
            self.ballLabel.move(0, 0)
            self.angle = 0
    
        def rotateBall(self):
            self.angle += self.ballRotation
            pixmap = self.resizedBallPixmap.transformed(
                QTransform().rotate(self.angle), Qt.FastTransformation
            )
            r = QtCore.QRect(self.resizedBallPixmap.rect())
            r.moveCenter(pixmap.rect().center())
            pixmap = pixmap.copy(r)
            self.ballLabel.setPixmap(pixmap)

    A better solution is to use elements of the Qt Graphics Framework such as the QGraphicsItems that implements rotation and translation.

    class MainWindow(QMainWindow):
        def __init__(self, *args, **kwargs):
            super(MainWindow, self).__init__(*args, **kwargs)
            self.setWindowTitle("ball move")
            self.setFixedSize(800, 500)
    
            scene = QGraphicsScene()
            scene.setSceneRect(QRectF(QPointF(), QSizeF(self.size())))
            view = QGraphicsView(scene)
            self.setCentralWidget(view)
    
            self.setStyleSheet("background-color: black;border:none;")
    
            pixmap = QPixmap("ball.png").scaled(
                50, 50, Qt.KeepAspectRatio, Qt.FastTransformation
            )
            self.ballLabel = scene.addPixmap(pixmap)
            self.ballLabel.setTransformOriginPoint(self.ballLabel.boundingRect().center())
    
    
    class BallManager(QObject):
        positionChanged = pyqtSignal(QPointF)
        angleChanged = pyqtSignal(float)
    
        def __init__(self, parent=None):
            super(BallManager, self).__init__(parent)
    
            self.pos = QPointF(0, 0)
            self.angle = 0
    
            self.vel = QPointF(2, 1)
            self.rand = QPointF(*random.sample([1, 2, 3], 2))
    
            self.step_angle = 10
    
            self.timer = QTimer(interval=1000, timeout=self.ballMove)
            self.timer.start()
    
        def ballMove(self):
            if (self.pos.x() + 50) > 800:
                self.vel.setX(-1)
                self.randX = QPointF(*random.sample([1, 2, 3], 2))
            elif self.pos.x() < 0:
                self.vel.setX(1)
                self.rand = QPointF(*random.sample([1, 2, 3], 2))
            elif (self.pos.y() + 50) > 500:
                self.vel.setY(-1)
                self.rand = QPointF(*random.sample([1, 2, 3], 2))
            elif self.pos.y() < 0:
                self.vel.setY(1)
                self.rand = QPointF(*random.sample([1, 2, 3], 2))
            self.pos += QPointF(self.vel.x() * self.rand.x(), self.vel.y() * self.rand.y())
            self.angle += self.step_angle
    
            self.positionChanged.emit(self.pos)
            self.angleChanged.emit(self.angle)
    
    
    if __name__ == "__main__":
        app = QApplication([])
        main_window = MainWindow()
        main_window.show()
        manager = BallManager()
        manager.positionChanged.connect(main_window.ballLabel.setPos)
        manager.angleChanged.connect(main_window.ballLabel.setRotation)
        app.exec_()