PyQt5 pixel level collision detection - python

I'm learning Python and thought to make a simple platform game with PyQ5t.
Currently I'm in trouble when I want to make a pixel level collision detection between shapes in my game.
I have set the QPixMap to transparent and also tried to use QGraphicsPixmapItem.HeuristicMaskShape shape mode but collision detection does not work.
If I make shape (Mouse in this case) backgroun for example gray and remove the shape mode then there happens rectangular collision detection.
What I'm missing here? I have spent few hours digging around the Internet but no solution yet...
Here is my code to show the problem, please use arrow keys to move the the red "Mouse" around :)
I hope to see collision detected text when the first pixel in the red circle touches the brown platform.
import sys
from PyQt5.QtGui import QPen, QBrush
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import Qt, QPoint
from PyQt5.QtWidgets import QApplication, QWidget, QGraphicsView, QGraphicsScene, QLabel, QGraphicsPixmapItem, QFrame
class Mouse(QGraphicsPixmapItem):
def __init__(self, parent):
super().__init__()
self.canvas = QtGui.QPixmap(40,40)
self.canvas.fill(Qt.transparent)
self.setPixmap(self.canvas)
self.x = 100
self.y = 100
self.setPos(self.x, self.y)
self.setFlag(QGraphicsPixmapItem.ItemIsMovable)
self.setFlag(QGraphicsPixmapItem.ItemIsFocusable)
self.setShapeMode(QGraphicsPixmapItem.HeuristicMaskShape)
self.setFocus()
parent.addItem(self)
def paint(self, painter, option, widget=None):
super().paint(painter, option, widget)
pen = QPen(Qt.black, 4, Qt.SolidLine)
brush = QBrush(Qt.red, Qt.SolidPattern)
painter.save()
painter.setPen(pen)
painter.setBrush(brush)
painter.drawEllipse(QPoint(20,20),16,16)
painter.restore()
def keyPressEvent(self, e):
if e.key() == Qt.Key_Right:
self.x += 5
if e.key() == Qt.Key_Left:
self.x -= 5
if e.key() == Qt.Key_Up:
self.y -= 5
if e.key() == Qt.Key_Down:
self.y += 5
self.setPos(self.x, self.y)
collides_with_items = self.collidingItems(mode=Qt.IntersectsItemShape)
if collides_with_items:
print("Collision detected!")
for item in collides_with_items:
print(item)
class Platform(QFrame):
PLATFORM_STYLE = "QFrame { color: rgb(153, 0, 0); \
background: rgba(0,0,0,0%); }"
def __init__(self, parent, x, y, width, height):
super().__init__()
self.setGeometry(QtCore.QRect(x, y, width, height))
self.setFrameShadow(QFrame.Plain)
self.setLineWidth(10)
self.setFrameShape(QFrame.HLine)
self.setStyleSheet(Platform.PLATFORM_STYLE)
parent.addWidget(self)
class GameScreen(QGraphicsScene):
def __init__(self):
super().__init__()
# Draw background
background = QLabel()
background.setEnabled(True)
background.setScaledContents(True)
background.setGeometry(0, 0, 1280, 720)
background.setPixmap(QtGui.QPixmap("StartScreen.png"))
background.setText("")
background.setTextFormat(QtCore.Qt.RichText)
self.addWidget(background)
self.line_5 = Platform(self, 0, 80, 431, 16)
self.mouse = Mouse(self)
class Game(QGraphicsView):
def __init__(self):
super().__init__()
self.setWindowTitle("Running Mouse")
self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.gamescreen = GameScreen()
self.setScene(self.gamescreen)
self.show()
if __name__ == '__main__':
app = QApplication([])
game = Game()
sys.exit(app.exec_())

You're providing an empty pixmap (self.canvas), so no matter what shape mode you select, it will always have a null shape, unless you use BoundingRectShape, which uses the pixmap bounding rect.
The fact that you are manually painting a circle really doesn't matter, as painting is not considered for collision detection (nor it should).
You can override the shape() method to provide your own shape (using a QPainterPath):
def shape(self):
path = QtGui.QPainterPath()
path.addEllipse(12, 12, 16, 16)
return path
But, since you're only drawing an ellipse, just use QGraphicsEllipseItem instead.
Some unrequested suggestions:
I'd avoid to use the name "parent" if you're intending the scene (the parent item of a QGraphicsItem could only be another QGraphicsItem), and an item usually shouldn't "add itself" to a scene;
don't overwrite self.x and self.y, as they are QGraphicsItem existing properties;
if you have multiple items in a scene and want to control just one using the keyboard, it's usually better to overwrite the key event methods of the scene or the view (especially if you're using arrow keys: even if the scrollbars are hidden, they can still intercept those movements);
if you still want to use the keyboard events on the item, ensure that the item keeps the keyboard focus: using setFocus is not enough, as it will lose focus as soon as the user clicks elsewhere; a possible solution is to use QGraphicsItem.grabKeyboard() (which works only as soon as the item is added to a scene);
if you set a stylesheet on a QFrame, it's very likely that it will at least partially ignore any frame option set (shape, shadow, etc), as they are style/platform dependant; it's usually better to not mix stylesheets with settings of Qt widgets that are related to visualization, so in your case you'll probably only need to add a simple QFrame with the correct stylesheet parameters set;
while technically it's not a problem, it's usually preferable to be consistent with the import "styles", especially when using complex modules like Qt is: you either import the single classes (from PyQt5.QtWidgets import QApplication, ...) or the submodules (from PyQt5 import QtCore, ...), otherwise it will make the code just confusing;

Related

How to prevent from QGraphicsItem from collide with each other

I am making an app that has a graphic scene where you can drag items in and move them in the scene.
I am trying to prevent collusion between items in the scene and saw this question Prohibit collision of a movable QGraphicsItem with other items which didn't work well for me (perhaps because I am using PyQt6 and translated from qt not very well).
I am using QGraphicsEllipseItem and QGraphicsRectItem but every solution for QGraphicsEllipseItem will help very much.
I understood that I need to block movement only on the axis the colliding is occures on but I didn't managed to immplement it.
I tried to go through the list of the items the item is colliding with to deal quth multipule collusions.
I am using PyQt6 but anything will help
Here is how I tried to solve it:
from PyQt6.QtWidgets import QApplication, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem, QGraphicsItem
from PyQt6 import QtCore
import typing
class Item(QGraphicsEllipseItem):
def __init__(self, geo):
super().__init__(geo)
self.setFlag(self.GraphicsItemFlag.ItemIsMovable)
self.setFlag(self.GraphicsItemFlag.ItemSendsGeometryChanges)
def itemChange(self, change: 'QGraphicsItem.GraphicsItemChange', value: typing.Any) -> typing.Any:
c = super().itemChange(change, value)
if change == QGraphicsItem.GraphicsItemChange.ItemPositionChange:
for collide in self.collidingItems(QtCore.Qt.ItemSelectionMode.IntersectsShape):
if :#from the right
c.setX()#keep on the right
elif :#from the left
c.setX() #keep on the left
if :#from above
c.setY() # keep above
elif :#from below
c.setY() #keep below
return c
app = QApplication([])
view = QGraphicsView()
s = QGraphicsScene()
s.addItem(Item(QtCore.QRectF(50, 50, 50, 50)))
s.addItem(Item(QtCore.QRectF(200, 200, 50, 50)))
view.setScene(s)
view.show()
app.exec()

How can I limit the GraphicsScene in order to only be able to draw on the image loaded?

I have a script that is loading an image and displaying it in a QGraphicsView, it also sets the size of the GraphicsView to the dimensions of the image.
I need to be able to zoom in / out while only being able to draw on the image. Currently I can zoom in / out but can draw anywhere on the GraphicsView, meaning that when the image is zoomed out I can draw outside of it as seen below.
What can I do to make it to where the RubberBand can only be drawn on the image?
Here is my GraphicsView:
from PyQt5.QtWidgets import QGraphicsScene, QGraphicsView, QGraphicsItem
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QBrush, QPen
from SelectionBand import *
from time import sleep
class CustomGraphicsView(QGraphicsView):
def __init__(self, parent=None):
super(CustomGraphicsView, self).__init__()
self.setScene(QGraphicsScene(self))
self.setAlignment(Qt.AlignLeft | Qt.AlignTop)
self.scene().setBackgroundBrush(Qt.gray)
self.brush = QBrush(Qt.green)
self.pen = QPen(Qt.blue)
def setImage(self, image):
self.img = self.scene().addPixmap(image)
self.resize(self.img.boundingRect().width(),self.img.boundingRect().height())
self.scene().setSceneRect(self.img.boundingRect())
def wheelEvent(self, event):
if event.modifiers() & Qt.ControlModifier:
x = event.angleDelta().y() / 120
if x > 0:
self.scale(1.05, 1.05)
elif x < 0:
self.scale(.95, .95)
else:
super().wheelEvent(event)
def mousePressEvent(self, event):
pos = self.mapToScene(event.pos())
self.xPos = pos.x()
self.yPos = pos.y()
self.band = SelectionBand(self)
self.band.setGeometry(pos.x(), pos.y(), 0, 0)
item = self.scene().addWidget(self.band)
item.setFlag(QGraphicsItem.ItemIsMovable)
item.setZValue(1)
def mouseMoveEvent(self, event):
pos = self.mapToScene(event.pos())
self.width = pos.x() - self.xPos
self.height = pos.y() - self.yPos
if self.width < 0 and self.height < 0:
self.band.setGeometry(pos.x(), pos.y(), abs(self.width), abs(self.height))
elif self.width < 0:
self.band.setGeometry(pos.x(), self.yPos, abs(self.width), abs(self.height))
elif self.height < 0:
self.band.setGeometry(self.xPos, pos.y(), abs(self.width), abs(self.height))
else: self.band.setGeometry(self.xPos, self.yPos, abs(self.width), abs(self.height))
Here is the RubberBand code:
from typing import ItemsView
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QRubberBand, QWidget,QSizeGrip
from PyQt5.QtCore import QEasingCurve, Qt, QEvent
from PyQt5.QtGui import QCursor, QHoverEvent, QPixmap
import sys
# Functionality:
# - On initial mouse click
# Create new RubberBand, select area until mouse is released
# - Allow for resizing when selected
class SelectionBand(QWidget):
def __init__(self, parent=None):
super(SelectionBand, self).__init__()
# self.setMouseTracking=True
self.draggable = True
self.dragging_threshold = 5
self.borderRadius = 5
self.setWindowFlags(Qt.SubWindow)
layout = QHBoxLayout(self)
layout.setContentsMargins(0,0,0,0)
layout.addWidget(
QSizeGrip(self), 0,
Qt.AlignRight | Qt.AlignBottom)
self._band = QtWidgets.QRubberBand(
QtWidgets.QRubberBand.Rectangle, self)
self._band.show()
self.show()
def resizeEvent(self, event):
self._band.resize(self.size())
Since you're using the scene rect as a reference, you must check that the geometry of the selection is within the scene margins, and you should also consider the minimum size of the rubber band widget.
Also, instead of making complex computations to check if the width or height are "negative", you can create a QRectF using the two points (the clicked position and that received from the mouse movement), then use the normalized() function to get the same rectangle but with positive width and height.
class CustomGraphicsView(QGraphicsView):
def mousePressEvent(self, event):
self.startPos = self.mapToScene(event.pos())
if not self.band:
self.band = SelectionBand(self)
self.bandItem = self.scene().addWidget(self.band)
self.bandItem.setFlag(QGraphicsItem.ItemIsMovable)
self.bandItem.setZValue(1)
sceneRect = self.sceneRect()
if self.startPos.x() < sceneRect.x():
self.startPos.setX(sceneRect.x())
elif self.startPos.x() > sceneRect.right() - self.band.minimumWidth():
self.startPos.setX(sceneRect.right() - self.band.minimumWidth())
if self.startPos.y() < sceneRect.y():
self.startPos.setY(sceneRect.y())
elif self.startPos.y() > sceneRect.bottom() - self.band.minimumHeight():
self.startPos.setY(sceneRect.bottom() - self.band.minimumHeight())
self.bandItem.setPos(self.startPos)
def mouseMoveEvent(self, event):
mousePos = self.mapToScene(event.pos())
# create a normalized rectangle based on the two points
rect = QRectF(self.startPos, mousePos).normalized()
if rect.width() < self.band.minimumWidth():
rect.setWidth(self.band.minimumWidth())
if rect.x() < self.startPos.x():
rect.moveLeft(self.startPos.x())
if rect.height() < self.band.minimumHeight():
rect.setHeight(self.band.minimumHeight())
if rect.y() < self.startPos.y():
rect.moveBottom(self.startPos.y())
sceneRect = self.sceneRect()
if rect.x() < sceneRect.x():
rect.setX(sceneRect.x())
elif rect.x() > sceneRect.right() - self.band.minimumWidth():
rect.setX(sceneRect.right() - self.band.minimumWidth())
if rect.right() > sceneRect.right():
rect.setRight(sceneRect.right())
if rect.y() < sceneRect.y():
rect.setY(sceneRect.y())
elif rect.y() > sceneRect.bottom() - self.band.minimumHeight():
rect.setY(sceneRect.bottom() - self.band.minimumHeight())
if rect.bottom() > sceneRect.bottom():
rect.setBottom(sceneRect.bottom())
self.bandItem.setGeometry(rect)
Consider that using a QWidget for the selection is not a good idea (and your implementation is a bit flawed anyway): you're not using most of the features of a QWidget (except for the size grip, which is not used anyway), so using it is a bit pointless.
You could instead use a subclass of QGraphicsRectItem. This has many advantages: not only it is more coherent for its usage and for the graphics view framework, but, for instance, you can use a cosmetic pen for the border, which will always be clearly shown even when zooming out.
class SelectionBandItem(QGraphicsRectItem):
_minimumWidth = _minimumHeight = 1
def __init__(self):
super().__init__()
color = QApplication.palette().highlight().color()
pen = QPen(color, 1)
pen.setCosmetic(True)
self.setPen(pen)
color.setAlpha(128)
self.setBrush(color)
self.setFlag(QGraphicsItem.ItemIsMovable)
def minimumWidth(self):
return self._minimumWidth
def minimumHeight(self):
return self._minimumHeight
def setGeometry(self, rect):
# we cannot use setRect, as it will only set the drawn rectangle but
# not the position of the item, which makes it less intuitive; it's
# better to use the given rectangle to set the position and then
# resize the item's rectangle
self.setPos(rect.topLeft())
current = self.rect()
current.setSize(rect.size())
self.setRect(current)
def geometry(self):
return self.rect().translated(self.pos())
class CustomGraphicsView(QGraphicsView):
# ...
def mousePressEvent(self, event):
self.startPos = self.mapToScene(event.pos())
if not self.band:
self.band = SelectionBandItem()
self.scene().addItem(self.band)
sceneRect = self.sceneRect()
if self.startPos.x() < sceneRect.x():
self.startPos.setX(sceneRect.x())
elif self.startPos.x() > sceneRect.right():
self.startPos.setX(sceneRect.right())
if self.startPos.y() < sceneRect.y():
self.startPos.setY(sceneRect.y())
elif self.startPos.y() > sceneRect.bottom():
self.startPos.setY(sceneRect.bottom())
self.band.setPos(self.startPos)
def mouseMoveEvent(self, event):
# all of the above, except for this:
self.band.setGeometry(rect)
Then, if you want to add support for the size grip, implement that in the mouseMoveEvent of the SelectionBandItem class.
Unrelated notes:
self.width and self.height are properties of all Qt widgets, you should not overwrite them with other values, especially if those values are not related to self; also, since you're continuously changing those values in the mouseMoveEvent, there's really no point in making them instance attributes: if you need access to the size of the band from other functions, just use self.band.width() or self.band.height();
there's no point in dividing the angle delta by 120 if you're just checking if it's greater or less than 0;
new items are always added on top of the item stack at 0 z-level, so there's little use in setting it to 1;
setMouseTracking is a function and not a variable;
when adding new widgets to a scene, you should not call show in their __init__, as it causes them to show as a normal window for an instant;
the stretch argument of QBoxLayout.addWidget already defaults to zero, if you want to specify the alignment it's better to explicitly use the keyword; also, if you add a widget to a layout there's no need for the parent argument: layout.addWidget(QSizeGrip(), alignment=Qt.AlignRight|Qt.AlignBottom);
if you're adding the graphics view to a layout, calling its resize() is completely useless; the scope of a layout manager is to manage the layout by setting the position and size of all its items, so manually requesting any geometry change is conceptually wrong; while it can resize the widget, as soon as any of the parent or sibling is resized, the layout will override that request;

Making Highly Customized, Hoverable, Overlappable, Widgets

I want to up my game in UI design using PyQt5. I feel like the resources for UI customization in PyQt5 are not easy to find. It is possible to try and make personalized widget, but the overall method seems non-standardized.
I need to build an arrow widget that is hoverable, overlappable with other widgets and highly customized. As I read in this tutorial and some other posts, it possible to do exactly what you need using paintEvent. Thus that is what I tried, but overall, I feel like the method is quite messy, and I'd like some guidelines on building complex Customized, general widget. Here's what I have:
Customized Shape: I built my code based on this
Hoverable property: I read everywhere that modifying the projects styleSheet is usually the way to go, especially if you want to make your Widget general and adapt to colors, the problem is that I wasn't able to find how to use properly self.palette to fetch the current colors of the QApplication styleSheet. I feel like i's have to maybe use enterEvent and leaveEvent, but I tried to redraw the whole widget with a painter in those functions and it said
QPainter::begin: Painter already active
QWidget::paintEngine: Should no longer be called
QPainter::begin: Paint device returned engine == 0, type: 1
QPainter::setRenderHint: Painter must be active to set rendering hints
Overlappable Property: I found a previous post which seemed to have found a solution: create a second widget that is children of the main widget, in order to be able to move the children around. I tried that but it seems that it doesn't want to move, no matter the position I give the widget.
Here is my code:
import sys
from PyQt5.QtWidgets import QWidget, QHBoxLayout, QGraphicsDropShadowEffect, QApplication, QFrame, QPushButton
from PyQt5.QtCore import Qt, QPoint, QLine
from PyQt5.QtGui import QPainter, QPen, QColor, QPalette
class MainWidget(QWidget):
def __init__(self):
super(MainWidget, self).__init__()
self.resize(500, 500)
self.layout = QHBoxLayout()
self.setLayout(self.layout)
self.myPush = QPushButton()
self.layout.addWidget(self.myPush)
self.arrow = ArrowWidget(self)
position = QPoint(-40, 0)
self.layout.addWidget(self.arrow)
self.arrow.move(position)
class ArrowWidget(QWidget):
def __init__(self, parent=None):
super(ArrowWidget, self).__init__(parent)
self.setWindowFlag(Qt.FramelessWindowHint)
self.setAttribute(Qt.WA_TranslucentBackground)
self.w = 200
self.h = 200
self.blurRadius = 20
self.xO = 0
self.yO = 20
self.resize(self.w, self.h)
self.layout = QHBoxLayout()
# myFrame = QFrame()
# self.layout.addWidget(myFrame)
self.setLayout(self.layout)
self.setStyleSheet("QWidget:hover{border-color: rgb(255,0,0);background-color: rgb(255,50,0);}")
shadow = QGraphicsDropShadowEffect(blurRadius=self.blurRadius, xOffset=self.xO, yOffset=self.yO)
self.setGraphicsEffect(shadow)
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.begin(self)
# painter.setBrush(self.palette().window())
# painter.setPen(QPen(QPalette, 5))
ok = self.frameGeometry().width()/2-self.blurRadius/2-self.xO/2
oky = self.frameGeometry().height()/2-self.blurRadius/2-self.yO/2
painter.drawEllipse(QPoint(self.frameGeometry().width()/2-self.blurRadius/2-self.xO/2, self.frameGeometry().height()/2-self.blurRadius/2-self.yO/2), self.w/2-self.blurRadius/2-self.yO/2-self.xO/2, self.h/2-self.blurRadius/2-self.yO/2-self.xO/2)
painter.drawLines(QLine(ok-25, oky-50, ok+25, oky), QLine(ok+25, oky, ok-25, oky+50))
painter.end()
if __name__ == '__main__':
app = QApplication(sys.argv)
testWidget = MainWidget()
testWidget.show()
sys.exit(app.exec_())
If someone could help me make this work and explain along the way to help us better understand the structure of customized widgets and explain a better method that isn't messy like this one, I believe it would be a plus to the beginners like me using PyQt5 as a main Framework for UI making.
There is no "standard" method for custom widgets, but usually paintEvent overriding is required.
There are different issues in your example, I'll try and address to them.
Overlapping
If you want a widget to be "overlappable", it must not be added to a layout. Adding a widget to a layout will mean that it will have its "slot" within the layout, which in turn will try to compute its sizes (based on the widgets it contains); also, normally a layout has only one widget per "layout slot", making it almost impossible to make widget overlap; the QGridLayout is a special case which allows (by code only, not using Designer) to add more widget to the same slot(s), or make some overlap others. Finally, once a widget is part of a layout, it cannot be freely moved nor resized (unless you set a fixedSize).
The only real solution to this is to create the widget with a parent. This will make it possible to use move() and resize(), but only within the boundaries of the parent.
Hovering
While it's true that most widgets can use the :hover selector in the stylesheet, it only works for standard widgets, which do most of their painting by themself (through QStyle functions). About this, while it's possible to do some custom painting with stylesheets, it's generally used for very specific cases, and even in this case there is no easy way to access to the stylesheet properties.
In your case, there's no need to use stylesheets, but just override enterEvent and leaveEvent, set there any color you need for painting and then call self.update() at the end.
Painting
The reason you're getting those warnings is because you are calling begin after declaring the QPainter with the paint device as an argument: once it's created it automatically calls begin with the device argument. Also, it usually is not required to call end(), as it is automatically called when the QPainter is destroyed, which happens when the paintEvent returns since it's a local variable.
Example
I created a small example based on your question. It creates a window with a button and a label within a QGridLayout, and also uses a QFrame set under them (since it's been added first), showing the "overlapping" layout I wrote about before. Then there's your arrow widget, created with the main window as parent, and that can be moved around by clicking on it and dragging it.
import sys
from PyQt5 import QtCore, QtGui, QtWidgets
class ArrowWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
super().__init__(parent)
# since the widget will not be added to a layout, ensure
# that it has a fixed size (otherwise it'll use QWidget default size)
self.setFixedSize(200, 200)
self.blurRadius = 20
self.xO = 0
self.yO = 20
shadow = QtWidgets.QGraphicsDropShadowEffect(blurRadius=self.blurRadius, xOffset=self.xO, yOffset=self.yO)
self.setGraphicsEffect(shadow)
# create pen and brush colors for painting
self.currentPen = self.normalPen = QtGui.QPen(QtCore.Qt.black)
self.hoverPen = QtGui.QPen(QtCore.Qt.darkGray)
self.currentBrush = self.normalBrush = QtGui.QColor(QtCore.Qt.transparent)
self.hoverBrush = QtGui.QColor(128, 192, 192, 128)
def mousePressEvent(self, event):
if event.buttons() == QtCore.Qt.LeftButton:
self.mousePos = event.pos()
def mouseMoveEvent(self, event):
# move the widget based on its position and "delta" of the coordinates
# where it was clicked. Be careful to use button*s* and not button
# within mouseMoveEvent
if event.buttons() == QtCore.Qt.LeftButton:
self.move(self.pos() + event.pos() - self.mousePos)
def enterEvent(self, event):
self.currentPen = self.hoverPen
self.currentBrush = self.hoverBrush
self.update()
def leaveEvent(self, event):
self.currentPen = self.normalPen
self.currentBrush = self.normalBrush
self.update()
def paintEvent(self, event):
qp = QtGui.QPainter(self)
qp.setRenderHints(qp.Antialiasing)
# painting is not based on "pixels", to get accurate results
# translation of .5 is required, expecially when using 1 pixel lines
qp.translate(.5, .5)
# painting rectangle is always 1px smaller than the actual size
rect = self.rect().adjusted(0, 0, -1, -1)
qp.setPen(self.currentPen)
qp.setBrush(self.currentBrush)
# draw an ellipse smaller than the widget
qp.drawEllipse(rect.adjusted(25, 25, -25, -25))
# draw arrow lines based on the center; since a QRect center is a QPoint
# we can add or subtract another QPoint to get the new positions for
# top-left, right and bottom left corners
qp.drawLine(rect.center() + QtCore.QPoint(-25, -50), rect.center() + QtCore.QPoint(25, 0))
qp.drawLine(rect.center() + QtCore.QPoint(25, 0), rect.center() + QtCore.QPoint(-25, 50))
class MainWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout()
self.setLayout(layout)
self.button = QtWidgets.QPushButton('button')
layout.addWidget(self.button, 0, 0)
self.label = QtWidgets.QLabel('label')
self.label.setAlignment(QtCore.Qt.AlignCenter)
layout.addWidget(self.label, 0, 1)
# create a frame that uses as much space as possible
self.frame = QtWidgets.QFrame()
self.frame.setFrameShape(self.frame.StyledPanel|self.frame.Raised)
self.frame.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
# add it to the layout, ensuring it spans all rows and columns
layout.addWidget(self.frame, 0, 0, layout.rowCount(), layout.columnCount())
# "lower" the frame to the bottom of the widget's stack, otherwise
# it will be "over" the other widgets, preventing them to receive
# mouse events
self.frame.lower()
self.resize(640, 480)
# finally, create your widget with a parent, *without* adding to a layout
self.arrowWidget = ArrowWidget(self)
# now you can place it wherever you want
self.arrowWidget.move(220, 140)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
testWidget = MainWidget()
testWidget.show()
sys.exit(app.exec_())

mapToScene requires the view being shown for correct transformations?

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

Qgraphicsview items not being placed where they should be

I recently created a program that will create QgraphicsEllipseItems whenever the mouse is clicked. That part works! However, it's not in the exact place where my cursor is. It seems to be slightly higher than where my mouse cursor is. I do have a QGraphicsRectItem created so maybe the two items are clashing with each other and moving off of one another? How can I get these circles to be placed on top of the rectangle item? Here's the code
class MyView(QtGui.QGraphicsView):
def __init__(self):
QtGui.QGraphicsView.__init__(self)
self.scene = QtGui.QGraphicsScene(self)
self.item = QtGui.QGraphicsRectItem(400, 400, 400, 400)
self.scene.addItem(self.item)
self.setScene(self.scene)
def paintMarkers(self):
self.cursor = QtGui.QCursor()
self.x = self.cursor.pos().x()
self.y = self.cursor.pos().y()
self.circleItem = QtGui.QGraphicsEllipseItem(self.x,self.y,10,10)
self.scene.addItem(self.circleItem)
self.circleItem.setPen(QtGui.QPen(QtCore.Qt.red, 1.5))
self.setScene(self.scene)
class Window(QtGui.QMainWindow):
def __init__(self):
#This initializes the main window or form
super(Window,self).__init__()
self.setGeometry(50,50,1000,1000)
self.setWindowTitle("Pre-Alignment system")
self.view = MyView()
self.setCentralWidget(self.view)
def mousePressEvent(self,QMouseEvent):
self.view.paintMarkers()
Much thanks!
There are two issues with the coordinates you are using to place the QGraphics...Items. The first is that the coordinates from QCursor are global screen coordinates, so you need to use self.mapFromGlobal() to convert them to coordinates relative to the QGraphicsView.
Secondly, you actually want the coordinates relative to the current QGraphicsScene, as this is where you are drawing the item. This is because the scene can be offset from the view (for example panning around a scene that is bigger than a view). To do this, you use self.mapToScene() on the coordinates relative to the QGraphicsView.
I would point out that typically you would draw something on the QGraphicsScene in response to some sort of mouse event in the QGraphicsView, which requires reimplementing things like QGraphicsView.mouseMoveEvent or QGraphicsView.mousePressEvent. These event handlers are passed a QEvent which contains the mouse coordinates relative to the view, and so you don't need to do the global coordinates transformation I mentioned in the first paragraph in these cases.
Update
I've just seen your other question, and now understand some of the issue a bit better. You shouldn't be overriding the mouse event in the main window. Instead override it in the view. For example:
class MyView(QtGui.QGraphicsView):
def __init__(self):
QtGui.QGraphicsView.__init__(self)
self.scene = QtGui.QGraphicsScene(self)
self.item = QtGui.QGraphicsRectItem(400, 400, 400, 400)
self.scene.addItem(self.item)
self.setScene(self.scene)
def paintMarkers(self, event):
# event position is in coordinates relative to the view
# so convert them to scene coordinates
p = self.mapToScene(event.x(), event.y())
self.circleItem = QtGui.QGraphicsEllipseItem(0,0,10,10)
self.circleItem.setPos(p.x()-self.circleItem.boundingRect().width()/2.0,
p.y()-self.circleItem.boundingRect().height()/2.0)
self.scene.addItem(self.circleItem)
self.circleItem.setPen(QtGui.QPen(QtCore.Qt.red, 1.5))
# self.setScene(self.scene) # <-- this line should not be needed here
# Note, I've renamed the second argument `event`. Otherwise you locally override the QMouseEvent class
def mousePressEvent(self, event):
self.paintMarkers(event)
# you may want to preserve the default mouse press behaviour,
# in which case call the following
return QGraphicsView.mousePressEvent(self, event)
Here we have not needed to use QWidget.mapFromGlobal() (what I covered in the first paragraph) because we use a mouse event from the QGraphicsView which returns coordinates relative to that widget only.
Update 2
Note: I've updated how the item is created/placed in the above code based on this answer.

Categories

Resources