Draw 3D Rectangle/Polygon with Elevation and Lowering Effect in PyQt - python

In Java, you can draw 3-d rectangles using (see https://way2java.com/awt-graphics/4891/):
void fill3DRect(int x, int y, int width, int height, boolean raised)
Here, the last parameter "raised" is used to lower/elevate the 3d rectangle with respect to the drawing surface.
How can I achieve this effect in PyQt?

It depends on what level of paint you want to use:
There are 2 options:
Using QPainter:
This effect can be achieved by drawing 2 displaced rectangles where the color of the background rectangle is darker than the color of the front:
from PyQt5 import QtCore, QtGui, QtWidgets
def draw3DRect(painter, rect, color, raised=False, offset=QtCore.QPoint(4, 4)):
if raised:
painter.fillRect(rect.translated(offset), color.darker())
painter.fillRect(rect, color)
class Widget(QtWidgets.QWidget):
def paintEvent(self, event):
painter = QtGui.QPainter(self)
r = QtCore.QRect(
self.width() / 4,
self.height() / 4,
self.width() / 2,
self.height() / 2,
)
draw3DRect(painter, r, QtGui.QColor("green"), raised=True)
def sizeHint(self):
return QtCore.QSize(320, 240)
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
w = Widget()
w.show()
sys.exit(app.exec_())
Using QGraphicsDropShadowEffect:
In this case the QWidget and QGraphicsItem support this effect:
from PyQt5 import QtCore, QtGui, QtWidgets
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
w = QtWidgets.QWidget()
lay = QtWidgets.QHBoxLayout(w)
scene = QtWidgets.QGraphicsScene()
view = QtWidgets.QGraphicsView(scene)
rect_item = QtWidgets.QGraphicsRectItem(QtCore.QRectF(0, 0, 200, 100))
rect_item.setBrush(QtGui.QColor("green"))
effect_item = QtWidgets.QGraphicsDropShadowEffect(
offset=QtCore.QPointF(3, 3), blurRadius=5
)
rect_item.setGraphicsEffect(effect_item)
scene.addItem(rect_item)
rect_widget = QtWidgets.QWidget()
rect_widget.setFixedSize(320, 240)
rect_widget.setStyleSheet("background-color:green;")
effect_widget = QtWidgets.QGraphicsDropShadowEffect(
offset=QtCore.QPointF(3, 3), blurRadius=5
)
rect_widget.setGraphicsEffect(effect_widget)
lay.addWidget(view)
lay.addWidget(rect_widget)
w.resize(640, 480)
w.show()
sys.exit(app.exec_())

To my knowledge there is no built in PyQt 3D paint widget/function as you can only paint 2D polygons. But we can create a custom class to emulate 3D painting. From your Java linked reference:
Java supports 3D rectangles but the effect of third dimension is not very visible. As the elevation is less, the effect is negligible. Java designers gave the effect of 3D by drawing lighter and darker lines along the rectangle border.
We can emulate the effect of Java's 3D paint function:
void fill3DRect(int x, int y, int width, int height, boolean raised)
This method draws a solid 3D rectangle with the above specified parameters. The last boolean parameter true indicates elevation above the drawing surface and false indicates etching into the surface.
To obtain a 3D effect in Python we can essentially do the same thing by having two shades of a color then darkening and lighting some sides.
from PyQt5 import QtCore, QtGui, QtWidgets
import sys
class Rectangle3D(QtWidgets.QWidget):
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent)
# Elevated 3D rectangle color settings
self.elevated_border_color = QtGui.QColor(111,211,111)
self.elevated_fill_color = QtGui.QColor(0,255,0)
self.elevated_pen_width = 2.5
# Lowered 3D rectangle color settings
self.lowered_border_color = QtGui.QColor(0,235,0)
self.lowered_fill_color = QtGui.QColor(0,178,0)
self.lowered_pen_width = 2.5
def draw3DRectangle(self, x, y, w, h, raised=True):
# Specify the border/fill colors depending on raised or lowered
if raised:
# Line color (border)
self.pen = QtGui.QPen(self.elevated_border_color, self.elevated_pen_width)
# Fill color
self.fill = QtGui.QBrush(self.elevated_fill_color)
else:
# Line color (border)
self.pen = QtGui.QPen(self.lowered_border_color, self.lowered_pen_width)
# Fill color
self.fill = QtGui.QBrush(self.lowered_fill_color)
painter = QtGui.QPainter(self)
# Draw border color of rectangle
painter.setPen(self.pen)
painter.setBrush(self.fill)
painter.drawRect(x, y, w, h)
# Cover up the top and left sides with filled color using lines
if raised:
painter.setPen(QtGui.QPen(self.elevated_fill_color, self.elevated_pen_width))
else:
painter.setPen(QtGui.QPen(self.lowered_fill_color, self.lowered_pen_width))
painter.drawLine(x, y, x + w, y)
painter.drawLine(x, y, x, y + h)
def paintEvent(self, event):
self.draw3DRectangle(50,50,300,150,True)
self.draw3DRectangle(50,250,300,150,False)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
widget = Rectangle3D()
widget.show()
sys.exit(app.exec_())

Related

How to draw and fill a rectangle in a PyQtGraph plot at position where mouse is clicked

I am using PyQt5 and PyQtGraph. I have simplified the example code below. Then I want to draw in the plot view a small red rectangle each time the mouse is clicked at the position where the mouse is clicked, thus accumulating several red rectangles in the plot view. The code below has a #??? comment where I need some help with code that will draw the red rectangle(s).
import sys
from PyQt5 import QtWidgets
import numpy as np
import pyqtgraph as pg
from pyqtgraph import PlotWidget, plot
# *********************************************************************************************
# *********************************************************************************************
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("My MainWindow")
self.qPlotWidget = pg.PlotWidget(self)
self.qPlotWidget.setLabel("bottom", "X-Axis")
self.qPlotWidget.setLabel("left", "Y-Axis")
self.qPlotWidget.scene().sigMouseClicked.connect(self.mouseClickedEvent)
data1 = np.zeros((2, 2), float) # create the array to hold the data
data1[0] = np.array((1.0, 10.0))
data1[1] = np.array((2.0, 20.0))
pen1 = pg.mkPen(color=(255,0,0), width=1) # red
self.qPlotWidget.plot(data1, pen=pen1, name="data1")
def mouseClickedEvent(self, event):
print("mouseClickedEvent")
pos = event.scenePos()
if (self.qPlotWidget.sceneBoundingRect().contains(pos)):
mousePoint = self.qPlotWidget.plotItem.vb.mapSceneToView(pos)
print("mousePoint=", mousePoint)
# draw and fill a 2-pixel by 2-pixel red rectangle where
# the mouse was clicked at [mousePoint.x(), mousePoint.y()]
# ??? add code here
def resizeEvent(self, event):
size = self.geometry()
self.qPlotWidget.setGeometry(10, 10, size.width()-20, size.height()-20)
if __name__ == '__main__':
app = QtWidgets.QApplication(sys.argv)
w = MainWindow()
screen = QtWidgets.QDesktopWidget().screenGeometry()
w.setGeometry(100, 100, screen.width()-200, screen.height()-200) # x, y, Width, Height
w.show()
sys.exit(app.exec_())
What you could do is to create an empty scatter plot item and add it to self.qPlotWidget. Then in mousrClickedEvent you could add the mouse position to the list of points of this scatter plot item, i.e.
class MainWindow(QtWidgets.QMainWindow):
def __init__(self, parent=None):
.... as before ....
# add empty scatter plot item with a red brush and a square as the symbol to plot widget
brush = pg.mkBrush(color=(255,0,0))
self.scatterItem = pg.ScatterPlotItem(pen=None, size=10, brush=brush, symbol='s')
self.qPlotWidget.addItem(self.scatterItem)
def mouseClickedEvent(self, event):
pos = event.scenePos()
if (self.qPlotWidget.sceneBoundingRect().contains(pos)):
mousePoint = self.qPlotWidget.plotItem.vb.mapSceneToView(pos)
# add point to scatter item
self.scatterItem.addPoints([mousePoint.x()], [mousePoint.y()])

How to scale a QPixmap preserving aspect and centering the image?

I want to use an image (svg file) as background in an QMdiArea. I load the image as QPixmap and scale it in the resizeEvent method to the size of the QMdiArea using
self._background_scaled = self._background.scaled(self.size(), QtCore.Qt.KeepAspectRatio)
self.setBackground(self._background_scaled)
But that puts the image in the upper left corner and repeats it in either X or Y. I want the image to be centered.
How do I scale the QPixmap so it is resized preserving aspect ratio but then add borders to get the exact size?
The setBackground() method accepts a QBrush that is built based on the QPixmap you pass to it, but if a QBrush is built based on a QPixmap it will create the texture (repeating elements) and there is no way to change that behavior. So the solution is to override the paintEvent method and directly paint the QPixmap:
import sys
from PySide2 import QtCore, QtGui, QtWidgets
def create_pixmap(size):
pixmap = QtGui.QPixmap(size)
pixmap.fill(QtCore.Qt.red)
painter = QtGui.QPainter(pixmap)
painter.setBrush(QtCore.Qt.blue)
painter.drawEllipse(pixmap.rect())
return pixmap
class MdiArea(QtWidgets.QMdiArea):
def __init__(self, parent=None):
super().__init__(parent)
pixmap = QtGui.QPixmap(100, 100)
pixmap.fill(QtGui.QColor("transparent"))
self._background = pixmap
#property
def background(self):
return self._background
#background.setter
def background(self, background):
self._background = background
self.update()
def paintEvent(self, event):
super().paintEvent(event)
painter = QtGui.QPainter(self.viewport())
background_scaled = self.background.scaled(
self.size(), QtCore.Qt.KeepAspectRatio
)
background_scaled_rect = background_scaled.rect()
background_scaled_rect.moveCenter(self.rect().center())
painter.drawPixmap(background_scaled_rect.topLeft(), background_scaled)
if __name__ == "__main__":
app = QtWidgets.QApplication(sys.argv)
mdiarea = MdiArea()
mdiarea.show()
mdiarea.background = create_pixmap(QtCore.QSize(100, 100))
sys.exit(app.exec_())

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)

paint method doesn't paint whole widget when resized

I have a PyQt window built in Qt Designer and I am writing a custom paint method. The main window creates a label and sets it as the central widget. Then I override the paint method to draw a simple column chart.
The widget works well until it is resized. The widget calls the resize method and repaints as expected, but it uses the same size rectangle as before it was resized. There's a big black area -- the resized part -- that's not being painted on.
To test this out I grab the rectangle of the widget and draw a big rectangle with a light blue fill and red line outside. When the window is resized part of the outer rectangle is missing too.
Debugging statements show that the new rectangle is the correct size and the width and height values are fed properly into the paint event.
But when I resize, this is what I see. Why is paint not painting in the black area? I've checked my code and there are no hard coded limits on the paint. Is there some hidden clipping that occurs?
I couldn't find any questions with exactly this problem, so it would seem I'm missing something. This similar question says to invalidate the window before repaint, but that's for C++:
Graphics.DrawImage Doesn't Always Paint The Whole Bitmap?
Do I need to invalidate the widget somehow? I couldn't find the PyQt method to do that.
import sys
from PyQt5 import QtCore, QtGui, QtWidgets, uic
import PyQt5 as qt
import numpy as np
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.label = QtWidgets.QLabel()
self.max_x = 600
self.max_y = 400
canvas = QtGui.QPixmap(self.max_x, self.max_y)
self.label.setPixmap(canvas)
self.setCentralWidget(self.label)
np.random.seed(777)
self.x_time = np.linspace(0, 12.56, 3000)
rand_data = np.random.uniform(0.0, 1.0, 3000)
self.data = np.sin(self.x_time) + rand_data
pal = self.palette()
pal.setColor(self.backgroundRole(), QtGui.QColor('black'))
self.setPalette(pal)
self.setAutoFillBackground(True)
def resizeEvent(self, a0: QtGui.QResizeEvent):
print("resizeEvent")
max_x = self.size().width()
max_y = self.size().height()
self.draw(max_x, max_y)
def mousePressEvent(self, a0: QtGui.QMouseEvent):
print("mousePressEvent")
def paintEvent(self, a0: QtGui.QPaintEvent):
print("New window size = ", self.size())
print("canvas size = ", self.label.size())
max_x = self.label.size().width()
max_y = self.label.size().height()
self.draw(max_x, max_y)
def draw(self, max_x, max_y):
x_final = self.x_time[-1]
data = self.data/np.max(np.abs(self.data))
data = [abs(int(k*max_y)) for k in self.data]
x_pos_all = [int(self.x_time[i]*max_x / x_final) for i in range(len(data))]
# Find and use only the max y value for each x pixel location
y_pos = []
x_pos = list(range(max_x))
cnt = 0
for x_pixel in range(max_x):
mx = 0.0
v = x_pos_all[cnt]
while cnt < len(x_pos_all) and x_pos_all[cnt] == x_pixel:
if data[cnt] > mx:
mx = data[cnt]
cnt += 1
y_pos.append(mx)
print("data = ")
dat = np.array(data)
print(dat[dat > 0].shape[0])
painter = QtGui.QPainter(self.label.pixmap()) # takes care of painter.begin(self)
pen = QtGui.QPen()
rect = self.label.rect()
print("rect = {}".format(rect))
painter.fillRect(rect, QtGui.QColor('lightblue'))
pen.setWidth(2)
pen.setColor(QtGui.QColor('green'))
for i in range(len(x_pos)):
painter.setPen(pen)
painter.drawLine(x_pos[i], max_y, x_pos[i], max_y - y_pos[i])
pen.setWidth(5)
pen.setColor(QtGui.QColor('red'))
painter.setPen(pen)
painter.drawRect(rect)
painter.end()
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()
I expect that as the widget is resized, the paint event will repaint over the entire new size of the widget, not just the original size. Curiously, the graph part (green lines) looks like it is scaling as I resize, but everything's just cut off at the original widget size.
How do I fix this?
If you are using a QLabel then it is not necessary to override paintEvent since it is enough to create a new QPixmap and set it in the QLabel.
import sys
import numpy as np
from PyQt5 import QtCore, QtGui, QtWidgets
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.label = QtWidgets.QLabel()
self.setCentralWidget(self.label)
np.random.seed(777)
self.x_time = np.linspace(0, 12.56, 3000)
rand_data = np.random.uniform(0.0, 1.0, 3000)
self.data = np.sin(self.x_time) + rand_data
pal = self.palette()
pal.setColor(self.backgroundRole(), QtGui.QColor("black"))
self.setPalette(pal)
self.setAutoFillBackground(True)
def resizeEvent(self, a0: QtGui.QResizeEvent):
self.draw()
super().resizeEvent(a0)
def draw(self):
max_x, max_y = self.label.width(), self.label.height()
x_final = self.x_time[-1]
data = self.data / np.max(np.abs(self.data))
data = [abs(int(k * max_y)) for k in self.data]
x_pos_all = [int(self.x_time[i] * max_x / x_final) for i in range(len(data))]
y_pos = []
x_pos = list(range(max_x))
cnt = 0
for x_pixel in range(max_x):
mx = 0.0
v = x_pos_all[cnt]
while cnt < len(x_pos_all) and x_pos_all[cnt] == x_pixel:
if data[cnt] > mx:
mx = data[cnt]
cnt += 1
y_pos.append(mx)
print("data = ")
dat = np.array(data)
print(dat[dat > 0].shape[0])
pixmap = QtGui.QPixmap(self.size())
painter = QtGui.QPainter(pixmap)
pen = QtGui.QPen()
rect = self.label.rect()
print("rect = {}".format(rect))
painter.fillRect(rect, QtGui.QColor("lightblue"))
pen.setWidth(2)
pen.setColor(QtGui.QColor("green"))
painter.setPen(pen)
for x, y in zip(x_pos, y_pos):
painter.drawLine(x, max_y, x, max_y - y)
pen.setWidth(5)
pen.setColor(QtGui.QColor("red"))
painter.setPen(pen)
painter.drawRect(rect)
painter.end()
self.label.setPixmap(pixmap)
app = QtWidgets.QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()
Update:
Why can I not shrink the window after enlarging it? The layout of the QMainWindow takes as a reference the minimum size of the QMainWindow to the minimumSizeHint of the centralWidget, and in your case it is the QLabel that takes as minimumSizeHint the size of the QPixmap. If you want to be able to reduce the size you must override that method:
class Label(QtWidgets.QLabel):
def minimumSizeHint(self):
return QtCore.QSize(1, 1)
class MainWindow(QtWidgets.QMainWindow):
def __init__(self):
super().__init__()
self.label = Label()
self.setCentralWidget(self.label)
# ...
Why was the whole area not being painted before? Because you were painting a copy of the QPixmap: painter = QtGui.QPainter(self.label.pixmap()), not the stored QPixmap of the QLabel so nothing has been modified.

Drawing a polygon in PyQt

Background
I would like to draw a simple shape on the screen, and I have selected PyQt as the package to use, as it seems to be the most established. I am not locked to it in any way.
Problem
It seems to be over complicated to just draw a simple shape like for example a polygon on the screen. All examples I find try to do a lot of extra things and I am not sure what is actually relevant.
Question
What is the absolute minimal way in PyQt to draw a polygon on the screen?
I use version 5 of PyQt and version 3 of Python if it makes any difference.
i am not sure, what you mean with
on the screen
you can use QPainter, to paint a lot of shapes on any subclass of QPaintDevice e.g. QWidget and all subclasses.
the minimum is to set a pen for lines and text and a brush for fills. Then create a polygon, set all points of polygon and paint in the paintEvent():
import sys, math
from PyQt5 import QtCore, QtGui, QtWidgets
class MyWidget(QtWidgets.QWidget):
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent)
self.pen = QtGui.QPen(QtGui.QColor(0,0,0)) # set lineColor
self.pen.setWidth(3) # set lineWidth
self.brush = QtGui.QBrush(QtGui.QColor(255,255,255,255)) # set fillColor
self.polygon = self.createPoly(8,150,0) # polygon with n points, radius, angle of the first point
def createPoly(self, n, r, s):
polygon = QtGui.QPolygonF()
w = 360/n # angle per step
for i in range(n): # add the points of polygon
t = w*i + s
x = r*math.cos(math.radians(t))
y = r*math.sin(math.radians(t))
polygon.append(QtCore.QPointF(self.width()/2 +x, self.height()/2 + y))
return polygon
def paintEvent(self, event):
painter = QtGui.QPainter(self)
painter.setPen(self.pen)
painter.setBrush(self.brush)
painter.drawPolygon(self.polygon)
app = QtWidgets.QApplication(sys.argv)
widget = MyWidget()
widget.show()
sys.exit(app.exec_())

Categories