Qt Custom Animated Button (Ellipse effect) - python

I am trying to make a custom animated button on PyQt. I found a website which has custom buttons: Buttons website
I already created a topic for making a 3rd button: Stackoverflow for 3rd button
#musicamante helped for the 3rd button, thank you very much again. Now I'm trying to make the 19th button.
My code for 19th button:
import sys, os, time
from math import *
from PySide6 import QtCore, QtWidgets, QtGui
from PySide6.QtWidgets import *
from PySide6.QtCore import *
from PySide6.QtGui import *
class Button19(QPushButton):
Radius = 10
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.backgroundColors = (QtGui.QColor(QtCore.Qt.lightGray),QtGui.QColor(QtCore.Qt.white))
self.foregroundColors = (QtGui.QColor(QtCore.Qt.black), QtGui.QColor(QtCore.Qt.lightGray))
font = self.font()
font.setBold(True)
self.setFont(font)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.hoverAnimation = QtCore.QVariantAnimation(self)
self.hoverAnimation.setStartValue(0.)
self.hoverAnimation.setEndValue(1.)
self.hoverAnimation.setEasingCurve(QtCore.QEasingCurve.OutCubic)
self.hoverAnimation.setDuration(400)
self.hoverAnimation.valueChanged.connect(self.update)
self.setText("Button")
_m_isHover = False
def enterEvent(self, event):
super().enterEvent(event)
self._m_isHover = True
self.hoverAnimation.setDirection(self.hoverAnimation.Forward)
self.hoverAnimation.start()
def leaveEvent(self, event):
super().leaveEvent(event)
self._m_isHover = False
self.hoverAnimation.setDirection(self.hoverAnimation.Backward)
self.hoverAnimation.start()
def isHover(self):
return self._m_isHover
def paintEvent(self, event):
aniValue = self.hoverAnimation.currentValue()
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
path, path2 = QPainterPath(), QPainterPath()
painter.setBrush(QBrush(self.backgroundColors[0]))
painter.setPen(Qt.NoPen)
rect = QRectF(0, 0, self.width(), self.height())
padding = 10
rect = rect.adjusted(-padding * aniValue, -padding * aniValue, padding * aniValue, padding * aniValue)
path.addRoundedRect(rect.adjusted(padding / 2, padding, -padding / 2, -padding), self.Radius, self.Radius)
painter.drawPath(path)
painter.setBrush(QBrush(self.foregroundColors[0]))
painter.setClipPath(path)
radiusEffectSize = 75
path2.addEllipse(self.rect().center(), radiusEffectSize * aniValue, radiusEffectSize * aniValue)
painter.drawPath(path2)
if self.isHover() or self.hoverAnimation.currentValue() > 0.1: # when leaveEvent triggered, still background color black. So must wait to change textcolor (ofcourse there is probably a better way)
painter.setPen(self.foregroundColors[1])
else:
painter.setPen(self.foregroundColors[0])
painter.drawText(self.rect(), Qt.AlignCenter, self.text())
if __name__ == "__main__":
app = QApplication(sys.argv)
wind = QMainWindow()
wind.setStyleSheet("QMainWindow{background-color:rgb(247,247,250)}")
wind.resize(150, 80)
wid = QWidget()
lay = QHBoxLayout(wid)
lay.setAlignment(Qt.AlignCenter)
mycustombutton = Button19()
lay.addWidget(mycustombutton)
wind.setCentralWidget(wid)
wind.show()
sys.exit(app.exec())
Still feels different, not the same. I need help, thanks!

The main issue in your code is that the padding computation is wrong.
You are increasing the size of the padding from the current rectangle and then decrease it by half the padding size, which doesn't make a lot of sense.
You should instead consider the default padding minus the extent based on the animation value, then adjust (reduce) the rectangle based to it:
padding = 10 * (1 - aniValue)
path.addRoundedRect(
rect.adjusted(padding, padding, -padding, -padding),
self.Radius, self.Radius
)
That will not be sufficient, though: the radius has to consider the actual size of the widget, but that can be misleading: if you take the smaller dimension (between width and height) the ellipse could be smaller than the rectangle, while in the opposite case it would grow up too early, making the animation quite odd. The actual radius should actually be computed using the hypotenuse of the right triangle of the widget width and height (a "perfect" implementation should also consider the radius of the rounded rectangle, but that would be quite too much):
# using hypot() from the math module
radius = hypot(self.width(), self.height()) / 2
path2.addEllipse(self.rect().center(), radius, radius)
Not enough, though: if you closely look at the original animation, you'll see that the "leave" event will not be the same: there is no circle, the "black" rounded rectangle just fades out. We need to take care of that too:
radius = min(self.width(), self.height())
if (self.hoverAnimation.state()
and self.hoverAnimation.direction() == self.hoverAnimation.Forward):
radius *= aniValue
# always full opacity on "fade in"
opacity = 1.
else:
# "fade out"
opacity = aniValue
path2.addEllipse(self.rect().center(), radius, radius)
painter.save()
painter.setOpacity(opacity)
painter.drawPath(path2)
painter.restore()
Nearly there. But the text drawing still has issues. First of all, the "base" should always be painted, and the "hover" should be painted over with the opacity value specified above (unless you want an alpha value). Then, we should always remember that buttons could also use "mnemonics" (keyboard shortcuts that are always highlighted with an underlined character, specified with a preceding & in Qt).
For optimization reasons, it's better to "replicate" similar functions instead of using local variables. It might not be wonderful for reading purposes, but painting functions should be always try to be as fast as possible.
So, here's the final result:
def paintEvent(self, event):
aniValue = self.hoverAnimation.currentValue()
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
painter.setBrush(QBrush(self.backgroundColors[0]))
painter.setPen(Qt.NoPen)
rect = self.rect()
path = QPainterPath()
padding = 10 * (1 - aniValue)
path.addRoundedRect(
QRectF(rect).adjusted(padding, padding, -padding, -padding),
self.Radius, self.Radius
)
painter.setClipPath(path)
painter.drawPath(path)
painter.setBrush(QBrush(self.foregroundColors[0]))
if aniValue < 1:
# only draw the default text when the animation isn't finished yet
painter.setPen(self.foregroundColors[0])
painter.drawText(rect, Qt.AlignCenter|Qt.TextShowMnemonic, self.text())
if not aniValue:
# no hover, just ignore the rest
return
hoverPath = QPainterPath()
radius = hypot(self.width(), self.height()) / 2
if (aniValue and self.hoverAnimation.state()
and self.hoverAnimation.direction() == self.hoverAnimation.Forward):
hoverPath.addEllipse(rect.center(),
radius * aniValue, radius * aniValue)
painter.drawPath(hoverPath)
else:
hoverPath.addEllipse(rect.center(), radius, radius)
painter.save()
painter.setOpacity(aniValue)
painter.drawPath(hoverPath)
painter.restore()
painter.setPen(self.foregroundColors[1])
painter.drawText(rect, Qt.AlignCenter|Qt.TextShowMnemonic, self.text())
Some further notes:
isHover() is quite pointless unless you need it for something else but painting: except from extreme performance requirements (for which value caching would make sense), underMouse() is usually sufficient; for this case, it is also a bit irrelevant, as we can be quite sure that the hover state only happens when the animation value is 1 or the animation is active (animation.state()) and its direction is Forward;
the "smoothness" of the animation completely depends on its easingCurve(), so please do experiment with all available curves to find what best suits your needs;
when working with plain shapes and no borders ("pens"), Qt normally works fine, as it happens with the code above, but be aware that painting with pixel-based devices (as QWidgets) could create artifacts while using anti-aliasing; in that case you have to consider the "pen width" and translate the drawing by half its size to obtain a "perfect" shape on the screen;

Related

Animate QGraphicsView fitInView method

So lets say I have a simple QGraphicsView and QGraphicsScene, and I want to animate the view to smoothly scroll/scale over to an arbitrary QRecF within the scene. I know you can call view.fitInView() or view.centerOn() to 'frame up' on the rect, however neither of those provide for much animation. I can iteratively call view.centerOn() to animate the scrolling nicely, but this doesn't handle scale. I believe I need to modify the view's viewport but I'm not quite sure how to do that (calling view.setSceneRect() seemed promising but that just sets the area that the view can manage, not what it is currently 'looking at').
Based on some C++ answer I figured out how to re-write the fitInView method for the most part, however I am stuck on the part where I actually transform the view to look at a different part of the scene without calling centerOn. Here's what I have so far:
view = QtWidgets.QGraphicsView()
scene = QtWidgets.QGraphicsScene()
targetRect = QtCore.QRectF(500, 500, 500, 500)
viewRect = view.viewport().rect()
sceneRect = view.transform().mapRect(targetRect)
xratio = viewRect.width() / sceneRect.width()
yratio = viewRect.height() / sceneRect.height()
# keep aspect
xratio = yratio = min(xratio, yratio)
# so... now what to do with the ratio? I need to scale the view rect somehow.
# animation
start = view.mapToScene(viewRect).boundingRect()
end = sceneRect # I guess?
animator = QtCore.QVariantAnimation()
animator.setStartValue(start)
animator.setEndValue(end)
animator.valueChanged.connect(view.????) # what am I setting here?
animator.start()
I went down many twisty roads only to end up with an incredibly simple answer, thought I'd post it in case anyone finds it useful to animate both scroll and scale at the same time:
view = QtWidgets.QGraphicsView()
scene = QtWidgets.QGraphicsScene()
targetRect = QtCore.QRectF(500, 500, 500, 500)
animator = QtCore.QVariantAnimation()
animator.setStartValue(view.mapToScene(view.viewport().rect()).boundingRect())
animator.setEndValue(targetRect)
animator.valueChanged.connect(lambda x: view.fitInView(x, QtCore.Qt.KeepAspectRatio))
animator.start()
A simple, basic solution is to map the target rectangle and use that value as end value for the animation.
Note that this solution is not really optimal, for two reasons:
it completely relies on fitInView(), which has to compute the transformation for the whole scene at each iteration of the animation by checking the current viewport size; a better (but more complex) implementation would be to use scale() or setTransform() and also call centerOn() on the currently mapped rectangle of the transformation;
since the scene rect might be smaller than what the viewport is showing, zooming out could be a bit awkward;
class SmoothZoomGraphicsView(QtWidgets.QGraphicsView):
def __init__(self):
super().__init__()
scene = QtWidgets.QGraphicsScene()
self.setScene(scene)
self.pixmap = scene.addPixmap(QtGui.QPixmap('image.png'))
self.animation = QtCore.QVariantAnimation()
self.animation.valueChanged.connect(self.smoothScale)
self.setTransformationAnchor(self.AnchorUnderMouse)
def wheelEvent(self, event):
viewRect = self.mapToScene(self.viewport().rect()).boundingRect()
# assuming we are using a basic x2 or /2 ratio, we need to remove
# a quarter of the width/height and translate to half the position
# betweeen the current rectangle and cursor position, or add half
# the size and translate to a negative relative position
if event.angleDelta().y() > 0:
xRatio = viewRect.width() / 4
yRatio = viewRect.height() / 4
translate = .5
else:
xRatio = -viewRect.width() / 2
yRatio = -viewRect.height() / 2
translate = -1
finalRect = viewRect.adjusted(xRatio, yRatio, -xRatio, -yRatio)
cursor = self.viewport().mapFromGlobal(QtGui.QCursor.pos())
if cursor in self.viewport().rect():
line = QtCore.QLineF(viewRect.center(), self.mapToScene(cursor))
finalRect.moveCenter(line.pointAt(translate))
self.animation.setStartValue(viewRect)
self.animation.setEndValue(finalRect)
self.animation.start()
def smoothScale(self, rect):
self.fitInView(rect, QtCore.Qt.KeepAspectRatio)

Avoiding collisions of QGraphicsItem shapes moved by the mouse

An interesting discussion was raised here about preventing collisions of circles, made of QGraphicsEllipseItems, in a QGraphicsScene. The question narrowed the scope to 2 colliding items but the larger goal still remained, what about for any number of collisions?
This is the desired behavior:
When one item is dragged over other items they should not overlap, instead it should move around those items as close as possible to the mouse.
It should not “teleport” if it gets blocked in by other items.
It should be a smooth and predictable movement.
As this becomes increasingly complex to find the best “safe” position for the circle while it’s moving I wanted to present another way to implement this using a physics simulator.
Given the behavior described above it’s a good candidate for 2D rigid body physics, maybe it can be done without but it would be difficult to get it perfect. I am using pymunk in this example because I’m familiar with it but the same concepts will work with other libraries.
The scene has a kinematic body to represent the mouse and the circles are represented by static bodies initially. While a circle is selected it switches to a dynamic body and is constrained to the mouse by a damped spring. Its position is updated as the space is updated by a given time step on each timeout interval.
The item is not actually moved in the same way as the ItemIsMovable flag is not enabled, which means it no longer moves instantly with the mouse. It’s very close but there’s a small delay, although you may prefer this to better see how it reacts to collisions. (Even so, you can fine-tune the parameters to have it move faster/closer to the mouse than I did**).
On the other hand, the collisions are handled perfectly and will already support other kinds of shapes.
import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
import pymunk
class Circle(QGraphicsEllipseItem):
def __init__(self, r, **kwargs):
super().__init__(-r, -r, r * 2, r * 2, **kwargs)
self.setFlag(QGraphicsItem.ItemIsSelectable)
self.static = pymunk.Body(body_type=pymunk.Body.STATIC)
self.circle = pymunk.Circle(self.static, r)
self.circle.friction = 0
mass = 10
self.dynamic = pymunk.Body(mass, pymunk.moment_for_circle(mass, 0, r))
self.updatePos = lambda: self.setPos(*self.dynamic.position, dset=False)
def setPos(self, *pos, dset=True):
super().setPos(*pos)
if len(pos) == 1:
pos = pos[0].x(), pos[0].y()
self.static.position = pos
if dset:
self.dynamic.position = pos
def itemChange(self, change, value):
if change == QGraphicsItem.ItemSelectedChange:
space = self.circle.space
space.remove(self.circle.body, self.circle)
self.circle.body = self.dynamic if value else self.static
space.add(self.circle.body, self.circle)
return super().itemChange(change, value)
def paint(self, painter, option, widget):
option.state &= ~QStyle.State_Selected
super().paint(painter, option, widget)
class Scene(QGraphicsScene):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.space = pymunk.Space()
self.space.damping = 0.02
self.body = pymunk.Body(body_type=pymunk.Body.KINEMATIC)
self.space.add(self.body)
self.timer = QTimer(self, timerType=Qt.PreciseTimer, timeout=self.step)
self.selectionChanged.connect(self.setConstraint)
def setConstraint(self):
selected = self.selectedItems()
if selected:
shape = selected[0].circle
if not shape.body.constraints:
self.space.remove(*self.space.constraints)
spring = pymunk.DampedSpring(
self.body, shape.body, (0, 0), (0, 0),
rest_length=0, stiffness=100, damping=10)
spring.collide_bodies = False
self.space.add(spring)
def step(self):
for i in range(10):
self.space.step(1 / 30)
self.selectedItems()[0].updatePos()
def mousePressEvent(self, event):
super().mousePressEvent(event)
if self.selectedItems():
self.body.position = event.scenePos().x(), event.scenePos().y()
self.timer.start(1000 / 30)
def mouseMoveEvent(self, event):
super().mouseMoveEvent(event)
if self.selectedItems():
self.body.position = event.scenePos().x(), event.scenePos().y()
def mouseReleaseEvent(self, event):
super().mouseReleaseEvent(event)
self.timer.stop()
def addCircle(self, x, y, radius):
item = Circle(radius)
item.setPos(x, y)
self.addItem(item)
self.space.add(item.circle.body, item.circle)
return item
if __name__ == '__main__':
app = QApplication(sys.argv)
scene = Scene(0, 0, 1000, 800)
for i in range(7, 13):
item = scene.addCircle(150 * (i - 6), 400, i * 5)
item.setBrush(Qt.GlobalColor(i))
view = QGraphicsView(scene, renderHints=QPainter.Antialiasing)
view.show()
sys.exit(app.exec_())
**Can adjust the following:
Spring stiffness and damping
Body mass and moment of inertia
Space damping
Space.step time step / how many calls per QTimer timeout
QTimer interval

How to force screen-snip size ratio. PyQt5

I want to modify Screen-Snip code from GitHub/harupy/snipping-tool so that every screen-snip has a ratio of 3 x 2. (I will save as 600 x 400 px image later)
I'm not sure how to modify self.end dynamically so that the user clicks and drags with a 3 x 2 ratio. The mouse position will define the x coordinate, and the y coordinate will be int(x * 2/3)
Any suggestions on how to do this? I promise I've been researching this, and I just can't seem to "crack the code" of modifying only the y coordinate of self.end
Here is the code:
import sys
import PyQt5
from PyQt5 import QtWidgets, QtCore, QtGui
import tkinter as tk
from PIL import ImageGrab
import numpy as np
import cv2 # package is officially called opencv-python
class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
root = tk.Tk()
screen_width = root.winfo_screenwidth()
screen_height = root.winfo_screenheight()
self.setGeometry(0, 0, screen_width, screen_height)
self.setWindowTitle(' ')
self.begin = QtCore.QPoint()
self.end = QtCore.QPoint()
self.setWindowOpacity(0.3)
QtWidgets.QApplication.setOverrideCursor(
QtGui.QCursor(QtCore.Qt.CrossCursor)
)
self.setWindowFlags(QtCore.Qt.FramelessWindowHint)
print('Capture the screen...')
self.show()
def paintEvent(self, event):
qp = QtGui.QPainter(self)
qp.setPen(QtGui.QPen(QtGui.QColor('black'), 3))
qp.setBrush(QtGui.QColor(128, 128, 255, 128))
qp.drawRect(QtCore.QRect(self.begin, self.end)) ##### This seems like the place I should modify. #########
def mousePressEvent(self, event):
self.begin = event.pos()
self.end = self.begin
self.update()
def mouseMoveEvent(self, event):
self.end = event.pos()
self.update()
def mouseReleaseEvent(self, event):
self.close()
x1 = min(self.begin.x(), self.end.x())
y1 = min(self.begin.y(), self.end.y())
x2 = max(self.begin.x(), self.end.x())
y2 = max(self.begin.y(), self.end.y())
img = ImageGrab.grab(bbox=(x1, y1, x2, y2))
img.save('capture.png')
img = cv2.cvtColor(np.array(img), cv2.COLOR_BGR2RGB)
cv2.imshow('Captured Image', img)
cv2.waitKey(0)
cv2.destroyAllWindows()
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
window = MyWidget()
window.show()
app.aboutToQuit.connect(app.deleteLater)
sys.exit(app.exec_())
You don't need to "change the y coordinate", you just need to use the correct arguments to create the rectangle.
There are various ways to initialize a QRect, you are using the two points, another one (and more common) is to use the coordinates of the origin and the size of the rectangle.
Once you know the width, you can compute the height, and make it negative if the y of the end point is above the begin.
Note that in this way you could get a "negative" rectangle (negative width, with the "right" edge actually at the left, the same for the height/bottom), so it's usually better to use normalized, which also allows you to get the correct coordinates of the rectangle for screen grabbing.
class MyWidget(QtWidgets.QWidget):
# ...
def getRect(self):
# a commodity function that always return a correctly sized
# rectangle, with normalized coordinates
width = self.end.x() - self.begin.x()
height = abs(width * 2 / 3)
if self.end.y() < self.begin.y():
height *= -1
return QtCore.QRect(self.begin.x(), self.begin.y(),
width, height).normalized()
def paintEvent(self, event):
qp = QtGui.QPainter(self)
qp.setPen(QtGui.QPen(QtGui.QColor('black'), 3))
qp.setBrush(QtGui.QColor(128, 128, 255, 128))
qp.drawRect(self.getRect())
def mouseReleaseEvent(self, event):
self.close()
rect = self.getRect()
img = ImageGrab.grab(bbox=(
rect.topLeft().x(),
rect.topLeft().y(),
rect.bottomRight().x(),
rect.bottomRight().y()
))
# ...
I suggest you to use a delayed setGeometry as in some systems (specifically Linux), the "final" geometry is actually applied only as soon as the window is correctly mapped from the window manager, especially if the window manager tends to apply a geometry on its own when the window is shown the first time. For example, I have two screens, and your window got "centered" on my main screen, making it shifted by half width of the other screen.
Also consider that importing Tk just for the screen size doesn't make much sense, since Qt already provides all necessary tools.
You can use something like that:
class MyWidget(QtWidgets.QWidget):
# ...
def showEvent(self, event):
if not event.spontaneous():
# delay the geometry on the "next" cycle of the Qt event loop;
# this should take care of positioning issues for systems that
# try to move newly created windows on their own
QtCore.QTimer.singleShot(0, self.resetPos)
def resetPos(self):
rect = QtCore.QRect()
# create a rectangle that is the sum of the geometries of all available
# screens; the |= operator acts as `rect = rect.united(screen.geometry())`
for screen in QtWidgets.QApplication.screens():
rect |= screen.geometry()
self.setGeometry(rect)

Draw a correct grid with PyQt5

I'm struggling a bit with PyQt5: I have to implement Conway's Game of Life and I started out with the GUI general setup. I thought about stacking (vertically) two widgets, one aimed at displaying the game board and another one containing the buttons and sliders.
This is what I came up with (I'm a total noob)
I'd like to fit the grid correctly with respect to the edges. It looks like it builds the grid underneath the dedicated canvas: it would be great to fix the canvas first and then paint on it but this whole thing of layouts, widgets and all that blows my mind.
This is my (fastly and poorly written) code
import sys
from PyQt5.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QLabel, QSlider, QPushButton, QWidget
from PyQt5.QtCore import Qt, QRect
from PyQt5.QtGui import QPixmap, QColor, QPainter
WINDOW_WIDTH, WINDOW_HEIGHT = 800, 600
SQUARE_SIDE = 20
ROWS, COLS = int(WINDOW_HEIGHT/SQUARE_SIDE), int(WINDOW_WIDTH/2*SQUARE_SIDE)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
layout = QVBoxLayout()
buttons_layout = QHBoxLayout()
self.label = QLabel()
self.label.setContentsMargins(0,0,0,0)
self.label.setStyleSheet('background-color: white; ')
self.label.setAlignment(Qt.AlignCenter)
slider = QSlider(Qt.Horizontal)
start_button = QPushButton('Start')
pause_button = QPushButton('Pause')
reset_button = QPushButton('Reset')
load_button = QPushButton('Load')
save_button = QPushButton('Save')
layout.addWidget(self.label)
buttons_layout.addWidget(start_button)
buttons_layout.addWidget(pause_button)
buttons_layout.addWidget(reset_button)
buttons_layout.addWidget(load_button)
buttons_layout.addWidget(save_button)
buttons_layout.addWidget(slider)
layout.addLayout(buttons_layout)
widget = QWidget()
widget.setLayout(layout)
self.setCentralWidget(widget)
self.make_grid()
def make_grid(self):
_canvas = QPixmap(WINDOW_WIDTH, WINDOW_HEIGHT)
_canvas.fill(QColor("#ffffff"))
self.label.setPixmap(_canvas)
painter = QPainter(self.label.pixmap())
for c in range(COLS):
painter.drawLine(SQUARE_SIDE*c, WINDOW_HEIGHT, SQUARE_SIDE*c, 0)
for r in range(ROWS):
painter.drawLine(0, SQUARE_SIDE*r, WINDOW_WIDTH, SQUARE_SIDE*r)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
window.setFixedSize(WINDOW_WIDTH, WINDOW_HEIGHT)
window.setWindowTitle("Conway's Game of Life")
window.show()
app.exec_()
Thank you for your help, have a nice day!
The reason for the pixmap not being show at its full size is because you're using WINDOW_WIDTH and WINDOW_HEIGHT for both the window and the pixmap. Since the window also contains the toolbar and its own margins, you're forcing it to be smaller than it should, hence the "clipping out" of the board.
The simpler solution would be to set the scaledContents property of the label:
self.label.setScaledContents(True)
But the result would be a bit ugly, as the label will have a size slightly smaller than the pixmap you drawn upon, making it blurry.
Another (and better) possibility would be to set the fixed size after the window has been shown, so that Qt will take care of the required size of all objects:
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MainWindow()
# window.setFixedSize(WINDOW_WIDTH, WINDOW_HEIGHT)
window.setWindowTitle("Conway's Game of Life")
window.show()
window.setFixedSize(window.size())
app.exec_()
Even if it's not part of your question, I'm going to suggest you a slightly different concept, that doesn't involve a QLabel.
With your approach, you'll face two possibilities:
continuous repainting of the whole QPixmap: you cannot easily "clear" something from an already painted surface, and if you'll have objects that move or disappear, you will need that
adding custom widgets that will have to be manually moved (and computing their position relative to the pixmap will be a serious PITA)
A better solution would be to avoid at all the QLabel, and implement your own widget with custom painting.
Here's a simple example:
class Grid(QWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setMinimumSize(800, 600)
self.columns = 40
self.rows = 30
# some random objects
self.objects = [
(10, 20),
(11, 21),
(12, 20),
(12, 22),
]
def resizeEvent(self, event):
# compute the square size based on the aspect ratio, assuming that the
# column and row numbers are fixed
reference = self.width() * self.rows / self.columns
if reference > self.height():
# the window is larger than the aspect ratio
# use the height as a reference (minus 1 pixel)
self.squareSize = (self.height() - 1) / self.rows
else:
# the opposite
self.squareSize = (self.width() - 1) / self.columns
def paintEvent(self, event):
qp = QPainter(self)
# translate the painter by half a pixel to ensure correct line painting
qp.translate(.5, .5)
qp.setRenderHints(qp.Antialiasing)
width = self.squareSize * self.columns
height = self.squareSize * self.rows
# center the grid
left = (self.width() - width) / 2
top = (self.height() - height) / 2
y = top
# we need to add 1 to draw the topmost right/bottom lines too
for row in range(self.rows + 1):
qp.drawLine(left, y, left + width, y)
y += self.squareSize
x = left
for column in range(self.columns + 1):
qp.drawLine(x, top, x, top + height)
x += self.squareSize
# create a smaller rectangle
objectSize = self.squareSize * .8
margin = self.squareSize* .1
objectRect = QRectF(margin, margin, objectSize, objectSize)
qp.setBrush(Qt.blue)
for col, row in self.objects:
qp.drawEllipse(objectRect.translated(
left + col * self.squareSize, top + row * self.squareSize))
Now you don't need make_grid anymore, and you can use Grid instead of the QLabel.
Note that I removed one pixel to compute the square size, otherwise the last row/column lines won't be shown, as happened in your pixmap (consider that in a 20x20 sided square, a 20px line starting from 0.5 would be clipped at pixel 19.5).

How to create circular image using pyqt4?

Here I wrote this code but did not work:
import sys
from PyQt4 import QtGui, QtCore
class CricleImage(QtCore.QObject):
def __init__(self):
super(CricleImage, self).__init__()
self.pix = QtGui.QGraphicsPixmapItem(QtGui.QPixmap("bird(01).jpg"))
#drawRoundCircle
rect = self.pix.boundingRect()
self.gri = QtGui.QGraphicsRectItem(rect)
self.gri.setPen(QtGui.QColor('red'))
if __name__ == '__main__':
myQApplication = QtGui.QApplication(sys.argv)
IMG = CricleImage()
#scene
scene = QtGui.QGraphicsScene(0, 0, 400, 300)
scene.addItem(IMG.pix)
#view
view = QtGui.QGraphicsView(scene)
view.show()
sys.exit(myQApplication.exec_())
One possible solution is to overwrite the paint() method of the QGraphicsPixmapItem and use setClipPath to restrict the painting region:
from PyQt4 import QtCore, QtGui
class CirclePixmapItem(QtGui.QGraphicsPixmapItem):
#property
def radius(self):
if not hasattr(self, "_radius"):
self._radius = 0
return self._radius
#radius.setter
def radius(self, value):
if value >= 0:
self._radius = value
self.update()
def paint(self, painter, option, widget=None):
painter.save()
rect = QtCore.QRectF(QtCore.QPointF(), 2 * self.radius * QtCore.QSizeF(1, 1))
rect.moveCenter(self.boundingRect().center())
path = QtGui.QPainterPath()
path.addEllipse(rect)
painter.setClipPath(path)
super().paint(painter, option, widget)
painter.restore()
if __name__ == "__main__":
import sys
app = QtGui.QApplication(sys.argv)
pixmap = QtGui.QPixmap("logo.jpg")
scene = QtGui.QGraphicsScene()
view = QtGui.QGraphicsView(scene)
view.setRenderHints(
QtGui.QPainter.Antialiasing | QtGui.QPainter.SmoothPixmapTransform
)
it = CirclePixmapItem(pixmap)
scene.addItem(it)
it.radius = pixmap.width() / 2
view.show()
sys.exit(app.exec_())
Update:
# ...
view = QtGui.QGraphicsView(
scene, alignment=QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft
)
# ...
view.show()
it.setPos(80, 80)
sys.exit(app.exec_())
Second possible solution:
import sys
#from PyQt4 import QtCore, QtGui
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *
class Label(QLabel):
def __init__(self, *args, antialiasing=True, **kwargs):
super(Label, self).__init__(*args, **kwargs)
self.Antialiasing = antialiasing
self.setMaximumSize(200, 200)
self.setMinimumSize(200, 200)
self.radius = 100
self.target = QPixmap(self.size())
self.target.fill(Qt.transparent) # Fill the background with transparent
# Upload image and zoom to control level
p = QPixmap("head2.jpg").scaled(
200, 200, Qt.KeepAspectRatioByExpanding, Qt.SmoothTransformation)
painter = QPainter(self.target)
if self.Antialiasing:
# antialiasing
painter.setRenderHint(QPainter.Antialiasing, True)
painter.setRenderHint(QPainter.HighQualityAntialiasing, True)
painter.setRenderHint(QPainter.SmoothPixmapTransform, True)
path = QPainterPath()
path.addRoundedRect(
0, 0, self.width(), self.height(), self.radius, self.radius)
# pruning
painter.setClipPath(path)
painter.drawPixmap(0, 0, p)
self.setPixmap(self.target)
class Window(QWidget):
def __init__(self, *args, **kwargs):
super(Window, self).__init__(*args, **kwargs)
layout = QHBoxLayout(self)
layout.addWidget(Label(self))
self.setStyleSheet("background: green;")
if __name__ == "__main__":
app = QApplication(sys.argv)
w = Window()
w.show()
sys.exit(app.exec_())
Another approach, slightly different from the one provided by eyllanesc. While this might seem much more complicated than that, I believe that it offers a better implementation and interface, with the addition of better performance.
In this case, instead of overriding the paint method (that is run everytime the item is painted, which happens very often), I'm using the shape() function along with the QGraphicsItem.ItemClipsToShape flag, that allows to limit the painting only within the boundaries of the path shape.
What shape() does is to return a QPainterPath that includes only the "opaque" portions of an item that will react to mouse events and collision detection (with the scene boundaries and its other items). In the case of a QGraphicsPixmapItem this also considers the possible mask (for example, a PNG based pixmap with transparent areas, or an SVG image). By setting the ItemClipsToShape we can ensure that the painting will only cover the parts of the image that are within that shape.
The main advantage of this approach is that mouse interaction and collision detection with other items honors the actual circle shape of the item.
This means that if you click outside the circle (but still within the rectangle area of the full image), the item will not receive the event. Also, if the image supports masking (a PNG with transparent areas) which by default would not be part of the shape, this method will take that into account.
Also, by "caching" the shape we are also speeding up the painting process a bit (since Qt will take care of it, without any processing done using python).
class CircleClipPixmapItem(QtGui.QGraphicsPixmapItem):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setFlag(self.ItemClipsToShape)
self.updateRect()
def updateRect(self):
baseRect = super().boundingRect()
minSize = min(baseRect.width(), baseRect.height())
self._boundingRect = QtCore.QRectF(0, 0, minSize, minSize)
self._boundingRect.moveCenter(baseRect.center())
self._shape = QtGui.QPainterPath()
self._shape.addEllipse(self._boundingRect)
# the shape might include transparent areas, using the & operator
# I'm ensuring that _shape only includes the areas that intersect
# the shape provided by the base implementation
self._shape &= super().shape()
def setPixmap(self, pm):
super().setPixmap(pm)
# update the shape to reflect the new image size
self.updateRect()
def setShapeMode(self, mode):
super().setShapeMode(mode)
# update the shape with the new mode
self.updateRect()
def boundingRect(self):
return self._boundingRect
def shape(self):
return self._shape
Keep in mind that there's a catch about both methods: if the aspect ratio of the image differs very much from 1:1, you'll always end up with some positioning issues. With my image, for example, it will always be shown 60 pixel right from the actual item position. If you want to avoid that, the updateRect function will be slightly different and, unfortunately, you'll have to override the paint() function (while still keeping it a bit faster than other options):
def updateRect(self):
baseRect = super().boundingRect()
minSize = min(baseRect.width(), baseRect.height())
self._boundingRect = QtCore.QRectF(0, 0, minSize, minSize)
# the _boundingRect is *not* centered anymore, but a new rect is created
# as a reference for both shape intersection and painting
refRect= QtCore.QRectF(self._boundingRect)
refRect.moveCenter(baseRect.center())
# note the minus sign!
self._reference = -refRect.topLeft()
self._shape = QtGui.QPainterPath()
self._shape.addEllipse(self._boundingRect)
self._shape &= super().shape().translated(self._reference)
# ...
def paint(self, painter, option, widget):
# we are going to translate the painter to the "reference" position,
# let's save its state before that
painter.save()
painter.translate(self._reference)
super().paint(painter, option, widget)
painter.restore()
This will make the boundingRect (and resulting internal shape) position the whole item at the top-left of the item position.
The following image shows the differences between the two approaches; I've used a PNG with transparent areas to better explain the whole concept.
On the top there is the source image, in the middle the paint() override approach, and finally the shape() implementation at the bottom.
While there seems to be no difference between the two methods, as shown on the examples on the left, on the right I've highlighted the actual boundaries of each item, by showing their boundingRect (in blue), shape (in red), which will be used for mouse events, collision detection and paint clipping; the green circle shows the overall circle used for both shape and painting.
The examples in the middle show the positioning based on the original image size, while on the right you can see the absolute positioning based on the effective circle size as explained above.
Drawing a circle around the image
Unfortunately, the ItemClipsToShape flag doesn't support antialiasing for clipping: if we just draw a circle after painting the image the result will be ugly. On the left you can see that the circle is very pixellated and does not overlap perfectly on the image. On the right the correct painting.
To support that, the flag must not be set, and the paint function will be a bit different.
class CircleClipPixmapItem(QtGui.QGraphicsPixmapItem):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# we don't need this anymore:
# self.setFlag(self.ItemClipsToShape)
# always set the shapeMode to the bounding rect without any masking:
# if the image has transparent areas they will be clickable anyway
self.setShapeMode(self.BoundingRectShape)
self.updateRect()
self.pen = QtGui.QPen(QtCore.Qt.red, 2)
# ...
def setPen(self, pen):
self.pen = pen
self.update()
def paint(self, painter, option, widget):
# we are going to translate the painter to the "reference" position,
# and we are also changing the pen, let's save the state before that
painter.save()
painter.translate(.5, .5)
painter.setRenderHints(painter.Antialiasing)
# another painter save "level"
painter.save()
# apply the clipping to the painter
painter.setClipPath(self._shape)
painter.translate(self._reference)
super().paint(painter, option, widget)
painter.restore()
painter.setPen(self.pen)
# adjust the rectangle to precisely match the circle to the image
painter.drawEllipse(self._boundingRect.adjusted(.5, .5, -.5, -.5))
painter.restore()
# restore the state of the painter

Categories