Qt6/PySide6 - how to position graphics elements - pyqt

I'm new to Qt and trying to figure out how position graphics items?
For example, I want to overlay some text onto a video. However, instead of overlaying them, the text is vertically stacked above the video.
My code is below, I've tried setting the position of the video/text elements (e.g. video.setPos(0,0)) but it didn't work. I also tried using QGraphicsLayout but ran into problems adding the video/text elements.
import sys
from PySide6 import QtWidgets, QtCore, QtMultimedia, QtMultimediaWidgets
class MyWidget(QtWidgets.QWidget):
def __init__(self):
super().__init__()
view = QtWidgets.QGraphicsView(self)
# Create a QT player
player = QtMultimedia.QMediaPlayer(self)
player.setSource(QtCore.QUrl("https://archive.org/download/SampleVideo1280x7205mb/SampleVideo_1280x720_5mb.mp4"))
video = QtMultimediaWidgets.QGraphicsVideoItem()
player.setVideoOutput(video)
text = QtWidgets.QGraphicsTextItem("Hello World")
scene = QtWidgets.QGraphicsScene()
scene.addItem(video)
scene.addItem(text)
view.setScene(scene)
view.setFixedSize(800,800)
player.play()
player.setLoops(-1)
if __name__ == "__main__":
app = QtWidgets.QApplication([])
widget = MyWidget()
widget.resize(800, 800)
widget.show()
sys.exit(app.exec())

The problem is caused by two aspects:
QGraphicsVideoItem has a default size of 320x240; for conceptual and optimization reasons, it does not change its size when video content is loaded or changed;
the video you're using has a different aspect ratio: 320x240 is 4:3, while the video is 1280x720, which is the standard widescreen ratio, 16:9;
By default, QGraphicsVideoItem just adapts the video contents to its current size, respecting the aspectRatioMode. The result is that you get some blank space above and below the video, similarly to what was common when showing movies in old TVs ("letterboxing"):
Since graphics items are always positioned using the top left corner of their coordinate system, you see a shifted image. In fact, if you just print the boundingRect of the video item when playing starts, you'll see that it has a vertical offset:
player.mediaStatusChanged.connect(lambda: print(video.boundingRect()))
# result:
QtCore.QRectF(0.0, 30.0, 320.0, 180.0)
^^^^ down by 30 pixels!
There are various possibilities to solve this, but it all depends on your needs, and it all resorts on connecting to the nativeSizeChanged signal. Then it's just a matter of properly setting the size, adjust the content to the visible viewport area, or eventually always call fitInView() while adjusting the position of other items (and ignoring transformations).
For instance, the following code will keep the existing size (320x240) but will change the offset based on the adapted size of the video:
# ...
self.video = QtMultimediaWidgets.QGraphicsVideoItem()
# ...
self.video.nativeSizeChanged.connect(self.updateOffset)
def updateOffset(self, nativeSize):
if nativeSize.isNull():
return
realSize = self.video.size()
scaledSize = nativeSize.scaled(realSize,
QtCore.Qt.AspectRatioMode.KeepAspectRatio)
yOffset = (scaledSize.height() - realSize.height()) / 2
self.video.setOffset(QtCore.QPointF(0, yOffset))

Related

Making parts of canvas transparent while still detecting and blocking mouse clicks in transparent areas in tkinter?

I'm trying to make a program where the user can paint on the screen. So I want to make an invisible canvas window in fullscreen where only the user's pen marks on the canvas will be visible. The closest thing I found is this function: root.attributes("-transparentcolor","color code here"), which will make all the parts of the window that's in the color you give transparent. So if I give the second parameter the background color of the canvas, then only the pen strokes on the canvas will be visible. This is so close to what I want, except for one thing, the transparent areas can't detect or block mouse clicks! Any mouse clicks will just go through to whatever is behind the tkinter window. Is there a way to make it so the transparent areas will still block mouse clicks? I really need help on this!
Here is a much better way to do this using only tkinter. Explanation is in code comments. Basically uses two windows, one for "blocking" the mouse and being transparent using the "-alpha" attribute and the other window for "hosting" canvas and having one completely transparent color while keeping others opaque using "-transparentcolor" attribute. That also means that this is cross-platform solution too (except I think the -transparentcolor attribute differs a little bit on other OS like Linux where I think it is -splash or sth and maybe something different on MacOS):
from tkinter import Tk, Toplevel, Canvas
# setting the starting coordinate of the line so that
# on motion it is possible to immediately draw it
def set_first(event):
points.extend([event.x, event.y])
# on motion append new coordinates to the list and if there are
# 4 (the minimum), create a new line and save the id
# otherwise update the existing line
def append_and_draw(event):
global line
points.extend([event.x, event.y])
if len(points) == 4:
line = canvas.create_line(points, **line_options)
else:
canvas.coords(line, points)
# when released clear the list to not waste space
# and not necessarily but also set "id" to None
def clear_list(event=None):
global line
points.clear()
line = None
line = None # this is a reference to the current line (id)
points = [] # list to keep track of current line coordinates
line_options = {} # dictionary to allow easier change of line options
# just a variable to more easily store the transparent color
transparent_color = 'grey15'
# creating the root window which will help with drawing the line
# because it will "block" mouse because `-alpha` (0.01 seems to be the lowest value)
# attribute is used, however it makes everything transparent on the window
# so need another window to "host" the canvas
root = Tk()
root.attributes('-alpha', 0.01)
root.attributes('-topmost', True)
root.attributes('-fullscreen', True)
# just press Esc key to close the whole thing, otherwise
# it is only doable by pressing Alt + F4 or turning off
# the computer
root.bind('<Escape>', lambda e: root.quit())
# create the host window, because it allows to have only
# one transparent color while keeping the other opaque and
# visible
top = Toplevel(root)
top.attributes('-transparentcolor', transparent_color)
top.attributes('-topmost', True)
top.attributes('-fullscreen', True)
# set the focus to root because that is where events are bound
root.focus_set()
# create the canvas to draw on
canvas = Canvas(top, bg=transparent_color, highlightthickness=0)
canvas.pack(fill='both', expand=True)
# bind all the events to `root` which "blocks" mouse
# but is also almost (because it has a very small alpha value
# it is not entirely invisible but human eye won't notice much)
# invisible
root.bind('<Button-1>', set_first)
root.bind('<B1-Motion>', append_and_draw)
root.bind('<ButtonRelease-1>', clear_list)
root.mainloop()
Here is an improvable example (you may need to pip install pyautogui, ctypes is a built-in library), it is also Windows only as far as I know:
Note: The other answer using two windows, however, is a lot better but I will keep this too just for the information.
from tkinter import Tk, Canvas
import pyautogui as pag
import ctypes
data = {
'draw': True,
'cur_line_points': [],
'cur_line_id': None
}
# function taken mainly from here: https://stackoverflow.com/a/46596592/14531062
def is_pressed(btn: str = 'left') -> bool:
if btn == 'left':
btn = 0x01
elif btn == 'right':
btn = 0x02
else:
raise Warning("incorrect argument, should be 'left' or 'right'")
return ctypes.windll.user32.GetKeyState(btn) not in (0, 1)
def draw_line(canvas_):
if not data['draw']:
root.after(10, draw_line, canvas_)
return
pressed = is_pressed('left')
cur_line_points = data['cur_line_points']
cur_line_id = data['cur_line_id']
if not pressed:
if cur_line_id is not None:
canvas_.coords(cur_line_id, cur_line_points)
data['cur_line_id'] = None
cur_line_points.clear()
else:
mouse_x, mouse_y = pag.position()
cur_line_points.extend((mouse_x, mouse_y))
len_points = len(cur_line_points)
if len_points == 4:
data['cur_line_id'] = canvas_.create_line(cur_line_points)
elif len_points > 4:
canvas_.coords(cur_line_id, cur_line_points)
root.after(10, draw_line, canvas_)
transparent_color = 'grey15'
root = Tk()
root.config(bg=transparent_color)
root.attributes('-transparentcolor', transparent_color)
root.attributes('-topmost', True)
root.attributes('-fullscreen', True)
canvas = Canvas(root, bg=transparent_color, highlightthickness=0)
canvas.pack(fill='both', expand=True)
draw_line(canvas)
root.mainloop()
Basically detects if mouse button is pressed using the built-in library ctypes and if it is adds the current mouse coordinates (does that using pyautogui library which may need be installed) to a list and then draws a line based on that list (it also keeps the reference of the currently drawn line and simply changes its coordinates instead of drawing a new line each time it loops), the only slight issue is that while drawing the mouse is also interacting with the window below, highlighting text and stuff, couldn't really figure out how to remove that yet but at least you get to draw a line.

wxPython - frame showing up mostly offscreen, self.Center() not working - related to system display settings?

*Note: I've determined that this issue has to do with my display settings. When I run my code on my laptop (win. 10 display settings: res. = 1920x1080, scale = 150%), the issue occurs. When I'm at work, with the laptop connected to a larger external monitor (w10 display settings: res. 1920x1080, scale=100%), everything is fine and dandy. Is there a way to create this frame such that it'll show up correctly regardless of the user's display settings? I suspect many of my users will run into this issue -- we all use the same model laptop and similar external displays, and the display settings I mentioned above are the W10 default (/'Recommended') display settings for the respective displays.
I've got a program that runs fine until the very last frame. In previous versions, the last frame showed up fine. Then when I added a plot canvas to the frame, needing it now to be wider, I was having a hell of a time getting the frame to resize itself to accommodate the plot -- Layout() didn't work, SetSizerAndFit(sizer) didn't work.. So I just hard-coded the size.
Now my issue is that when the frame is displayed (just resframe = ResultsFrame(None); resframe.Show(), it appears with most of the upper-left half waay off screen (see image below). Here's the code for a mockup of the issue. Actually this one shows up huge - still positioned outside the screen boundary but now also extending past the screen in all directions. The inspection tool still shows it's only 1290x945 (I'm using a laptop with a 1920x1080 screen) but that's obviously not right. Regardless, the frame is still positioned in the same location (offscreen) as the real one from my program.
What am I doing wrong?
Copy/pasting important bit I left out in response to comments: Forgot to mention the behavior is the same when using pos = wx.DefaultPosition as well as just leaving pos out of the __init__; I also can't drag the window around since the entire top bar is offscreen. The main issue isn't size, it's position. The dummy script I wrote (for whatever reason) appears huge onscreen (despite showing the correct size in the inspection tool), but in the actual app I am writing, the frame is the right size, just positioned offscreen. The size issue seen in the dummy script is weird (sizes in InspectionTool all return 1920x945 yet the frame appears far bigger than the screen size, despite being 1920x1080), but does not occur in my actual program, so I'm more concerned with the positioning.
import wx
class ResultFrame(wx.Frame):
def __init__(self, parent):
super(ResultFrame, self).__init__(parent, -1, title = "Results",
size = wx.Size(1290,945), pos = wx.Point(50,50), style = wx.CAPTION|
wx.CLOSE_BOX|wx.MINIMIZE_BOX|wx.SYSTEM_MENU)
self.InitUI()
self.Layout()
self.Center()
def InitUI(self):
self.mainpnl = wx.Panel(self)
self.nbpnl = wx.Panel(self.mainpnl)
self.ctrlpnl = wx.Panel(self.mainpnl)
self.mainsizer = wx.BoxSizer(wx.HORIZONTAL)
self.ctrlsizer = wx.BoxSizer(wx.VERTICAL)
self.restart = wx.Button(self.ctrlpnl, wx.ID_ANY, "Start Over",\
size=(100,40))
self.interp = wx.Button(self.ctrlpnl, wx.ID_ANY, "Help Interpreting\n"
"Results", size=(100,40))
self.nb = wx.Notebook(self.mainpnl)
for tar in range(0,4):
self.nb.AddPage(wx.Panel(self.nb), f"page {tar}")
self.ctrlsizer.Add(self.restart, 0, wx.ALL, 5)
self.ctrlsizer.AddStretchSpacer()
self.ctrlsizer.Add(self.interp, 0, wx.ALL, 5)
self.ctrlpnl.SetSizerAndFit(self.ctrlsizer)
self.mainsizer.Add(self.ctrlpnl, 0, wx.ALL, 0)
self.mainsizer.Add(self.nb, 1, wx.EXPAND)
self.mainpnl.SetSizerAndFit(self.mainsizer)
self.CreateStatusBar()
self.SetStatusText("Results")
def main():
import wx.lib.mixins.inspection
app = wx.App()
frm = ResultFrame(None)
frm.Show()
wx.lib.inspection.InspectionTool().Show(refreshTree=True)
app.MainLoop()
if __name__ == "__main__":
main()

Picture zooming doesnt select the right area

I have an application that displays images. Since people need to read some information out of the image i implemented a zoom functionality.
I set the picture Widget to 600x600. To Preserve the aspect ratio i then scale the picture and draw it to the widget. This works really well.
For the zoom functionality the user should click anywhere on the picture and it should cut out the are 150x150 pixesl around where the cursor clicks. To be precises the click of the cursore should mark the middle of the rectangle i cut out. So if i click on x=300 y=300 the area should be x=225 y=225 width=150 height=150.
To Archive that i scale the coordinates where the user clicks back to the original image resolution, cut out the subimage and scale it back down. Cutting out the scaled image allready loaded in my programm would yield a far worse quality.
The error is simple. The area cut out is not exactly the aerea i would like to cut out. Sometimes it is to far left. Sometimes to far right. Somtes to high sometimes to low... And i fail to see where the problem lies.
I wrote a barebone prototype whith just the functionality needed. After you put in a path to a jpeg picture you should be able to run it.
# -*- coding: utf-8 -*-
"""
Created on Sun Jan 12 12:22:25 2020
#author: Paddy
"""
import wx
class ImageTest(wx.App):
def __init__(self, redirect=False, filename=None):
wx.App.__init__(self, redirect, filename)
self.frame = wx.Frame(None, title='Radsteuer Eintreiber')
self.panelleft = wx.Panel(self.frame)
self.picturepresent=False
self.index=0
self.PhotoMaxSize = 600
self.zoomed=False
#Change path here
self.imagepath='F:\Geolocation\Test\IMG_20191113_174257.jpg'
self.createUI()
self.frame.Centre()
self.frame.Show()
self.frame.Raise()
self.onView()
#Creates UI Elements on Initiation
def createUI(self):
#instructions = 'Bild'
img = wx.Image(self.PhotoMaxSize,self.PhotoMaxSize,clear=True)
self.imageCtrl = wx.StaticBitmap(self.panelleft, wx.ID_ANY,
wx.Bitmap(img),size=(self.PhotoMaxSize,self.PhotoMaxSize))
self.mainSizer = wx.BoxSizer(wx.VERTICAL)
self.imageCtrl.Bind(wx.EVT_LEFT_UP, self.onImageClick)
self.mainSizer.Add(self.imageCtrl, 0, wx.ALL|wx.ALIGN_CENTER, 5)
self.panelleft.SetSizer(self.mainSizer)
self.mainSizer.Fit(self.frame)
self.panelleft.Layout()
def onImageClick(self,event):
if self.zoomed:
self.onView()
self.zoomed=False
else :
#Determin position of mouse
ctrl_pos = event.GetPosition()
print(ctrl_pos)
picturecutof=self.PhotoMaxSize/4
if ctrl_pos[0]-((self.PhotoMaxSize-self.NewW)/2)>0:
xpos=ctrl_pos[0]-((self.PhotoMaxSize-self.NewW)/2)
else:
xpos=0
if ctrl_pos[0]+picturecutof>self.NewW:
xpos=self.NewW-picturecutof
if ctrl_pos[1]-((self.PhotoMaxSize-self.NewW)/2)>0:
ypos=ctrl_pos[1]-((self.PhotoMaxSize-self.NewW)/2)
else:
ypos=0
if ctrl_pos[1]+picturecutof>self.NewH:
ypos=self.NewH-picturecutof
xpos=xpos*self.W/self.NewW
ypos=ypos*self.H/self.NewH
picturecutofx=picturecutof*self.W/self.NewW
picturecutofy=picturecutof*self.H/self.NewH
rectangle=wx.Rect(xpos,ypos,picturecutofx,picturecutofy)
self.img = wx.Image(self.imagepath, wx.BITMAP_TYPE_ANY)
self.img=self.img.GetSubImage(rectangle)
self.img=self.img.Scale(600,600,wx.IMAGE_QUALITY_BICUBIC)
self.imageCtrl.SetBitmap(wx.Bitmap(self.img))
self.imageCtrl.Fit()
self.panelleft.Refresh()
self.zoomed=True
def onView(self,event=None):
self.img = wx.Image(self.imagepath, wx.BITMAP_TYPE_ANY)
# scale the image, preserving the aspect ratio
self.W = self.img.GetWidth()
self.H = self.img.GetHeight()
if self.W > self.H:
self.NewW = self.PhotoMaxSize
self.NewH = self.PhotoMaxSize * self.H / self.W
else:
self.NewH = self.PhotoMaxSize
self.NewW = self.PhotoMaxSize * self.W / self.H
self.img = self.img.Scale(self.NewW,self.NewH,wx.IMAGE_QUALITY_BICUBIC)
self.imageCtrl.SetBitmap(wx.Bitmap(self.img))
self.imageCtrl.Fit()
self.panelleft.Refresh()
if __name__ == '__main__':
app = ImageTest()
app.MainLoop()
There maybe some wird stuff happening in the code that doesnt make entirely sense. Most of it is because the normal programm is much bigger and i removed many features in the prototpy that are having nothing to do with the zooming. It might very well be that i do the scaling wrong. But im out of ideas.
The Functionality for this protopye is simple: Replace the Path to a jpge image of your choiche. Run the programm. Click on the image and it should zoom. Click around your image and you will see zooming is wrong.
Thats it. Thanks for your help.
So i found the answer. But i changed something on the logic also. The picture will now be centered at the position where the user clicked. This is much more intuitive to use. I onle post the onImageClick Function. If you want to use the whole thing feel free to replace it in the original code from the question.
def onImageClick(self,event):
if self.zoomed:
self.onView()
self.zoomed=False
else :
#Determin position of mouse
ctrl_pos = event.GetPosition()
#Set magnification.
scalingfactor=4
#Set Picture size for rectangle
picturecutof=self.PhotoMaxSize/scalingfactor
##Find coordinates by adjusting for picture position
xpos=ctrl_pos[0]-((self.PhotoMaxSize-self.NewW)/2)
ypos=ctrl_pos[1]-((self.PhotoMaxSize-self.NewH)/2)
#if position is out of range adjust
if xpos>self.NewW:
xpos=self.NewW
if xpos<0:
xpos=0
if ypos>self.NewH:
ypos=self.NewH
if ypos<0:
ypos=0
#scale rectangle area to size of the unscaled image
picturecutofx=picturecutof*self.W/self.NewW
picturecutofy=picturecutof*self.H/self.NewH
#scale coordinates to unscaled image
xpos=xpos*self.W/self.NewW
ypos=ypos*self.H/self.NewH
#centeres image onto the coordinates where they were clicked
xpos=xpos-((ctrl_pos[0]*self.W/self.NewW)/scalingfactor)
ypos=ypos-((ctrl_pos[1]*self.H/self.NewH)/scalingfactor)
#if position is out of range adjust
if xpos>self.W-picturecutofx:
xpos=self.W-picturecutofx-5
if xpos<0:
xpos=0
if ypos>self.H-picturecutofy:
ypos=self.H-picturecutofy-5
if ypos<0:
ypos=0
#create rectangle to cut from original image
rectangle=wx.Rect(xpos,ypos,picturecutofx,picturecutofy)
#load original image again
self.img = wx.Image(self.imagepath, wx.BITMAP_TYPE_ANY)
#get subimage
self.img=self.img.GetSubImage(rectangle)
#scale subimage to picture area
self.img=self.img.Scale(self.PhotoMaxSize,self.PhotoMaxSize,wx.IMAGE_QUALITY_BICUBIC)
self.imageCtrl.SetBitmap(wx.Bitmap(self.img))
self.imageCtrl.Fit()
self.panelleft.Refresh()
self.zoomed=True
event.Skip()

PyQt QScrollArea doesn't display widgets

I am somewhat new to GUI programming and very new to PyQt, and I'm trying to build a GUI that displays a list of questions. I have created a QuestionBank class that subclasses QWidget and overrides the .show() method to display the list properly. I have tested this alone and it works correctly. However, the list of questions can be quite long, so I've been trying to make it scrollable. Rather than add a QScrollBar to the widget and then set up the event triggers by hand, I've been trying to my QuestionBank widget in a QScrollArea based on the syntax I've seen in examples online. While the scroll area shows up fine, it does not at all display the question bank but rather just shows a blank outline.
The QuestionBank class looks like this:
class QuestionBank(QWidget):
BUFFER = 10 # space between questions (can be modified)
def __init__(self, parent, questions):
# `parent` should be the QWidget that contains the QuestionBank, or None if
# QuestionBank is top level
# `questions` should be a list of MasterQuestion objects (not widgets)
QWidget.__init__(self, parent)
self.questions = [MasterQuestionWidget(self, q) for q in questions]
self.bottomEdge = 0
def show(self, y=BUFFER):
QWidget.show(self)
for q in self.questions:
# coordinates for each each question
q.move(QuestionBank.BUFFER, y)
q.show()
# update y-coordinate so that questions don't overlap
y += q.frameGeometry().height() + QuestionBank.BUFFER
self.bottomEdge = y + 3 * QuestionBank.BUFFER
# ... other methods down here
My code for showing the scroll bar looks like this:
app = QApplication(sys.argv)
frame = QScrollArea()
qs = QuestionBank(None, QFileManager.importQuestions())
qs.resize(350, 700)
frame.setGeometry(0, 0, 350, 300)
frame.setWidget(qs)
frame.show()
sys.exit(app.exec_())
I have tried many variants of this, including calling resize on frame instead of qs, getting rid of setGeometry, and setting the parent of qs to frame instead of None and I have no idea where I'm going wrong.
If it helps, I'm using PyQt5
Here is the question bank without the scroll area, to see what it is supposed to look like:
Here is the output of the code above with the scroll area:
This variation on the code is the only one that produces any output whatsoever, the rest just have blank windows. I'm convinced its something simple I'm missing, as the frame is obviously resizing correctly and it obviously knows what widget to display but its not showing the whole thing.
Any help is much appreciated, thank you in advance.

PyQt/PySide How to access/move QGraphicsItem after having added it to QGraphicsScene

This might be a very uninformed question.
I've been trying to figure out QGraphics*, and have run into a problem when trying to move an item (a pixmap) relative to or inside of the QGraphicsView.
class MainWindow(QMainWindow,myProgram.Ui_MainWindow):
def __init__(self):
super().__init__()
self.setupUi(self)
self.scene = QGraphicsScene()
self.graphicsView.setScene(self.scene)
pic = QPixmap('myPic.png')
self.scene.addPixmap(pic)
print(self.scene.items())
This is the relevant part of the program with an arbitrary PNG
My goal, for example, would be to move the trash-can to the left-most part of the QGraphicsView.
I tried appending this to the code above:
pics = self.scene.items()
for i in pics:
i.setPos(100,100)
However, it doesn't make a difference, and even if it did, it would be awkward to have to search for it with a "for in".
So my questions are:
How do I access a QPixmap item once I have added it to a QGraphicsView via a QGraphicsScene. (I believe The QPixmap is at this point a QGraphicsItem, or more precisely a QGraphicsPixmapItem.)
Once accessed, how do I move it, or change any of its other attributes.
Would be very grateful for help, or if anyone has any good links to tutorials relevant to the question. :)
The problem is that you need to set the size of your scene and then set positions of the items, check this example:
from PyQt4 import QtGui as gui
app = gui.QApplication([])
pm = gui.QPixmap("icon.png")
scene = gui.QGraphicsScene()
scene.setSceneRect(0, 0, 200, 100) #set the size of the scene
view = gui.QGraphicsView()
view.setScene(scene)
item1 = scene.addPixmap(pm) #you get a reference of the item just added
item1.setPos(0,100-item1.boundingRect().height()) #now sets the position
view.show()
app.exec_()

Resources