I have some small utility GUIs that I have made with PyQt, which use a QGraphicsScene with some items in it, and a view that response to user clicks (for making boxes, selecting points, etc).
On the (offline) machine I use, the software was just upgraded from Anaconda 2.5 to Anaconda 4.3, including the switch from PyQt4 to PyQt5. Everything still works, except somehow the transformations for my various QGraphicsItem objects are messed up if the scene rect is defined in anything but pixel coordinates.
Upfront question: What changed as far as item transformations from PyQt4 to PyQt5?
Here is an example of what I'm talking about: The top row is a box selector containing a dummy grayscale in a scene with a bounding rect of (0, 0, 2pi, 4pi). The green box is a QGraphicsRectItem drawn by the user from which I get the LL and UR points (in scene coordinates) after "Done" is clicked. The bottom row is a point layout with a user-clicked ellipse, on a small dummy image that has been zoomed in by 20.
The left and right were made with identical code. The version on the left is the result using PyQt4 under Anaconda 2.5 Python 3.5, whereas the result on the right is using PyQt5 under Anaconda 4.3 Python 3.6.
Clearly there is some sort of item transformation that is handled differently but I haven't been able to find it in any of the PyQt4->PyQt5 documentation (it's all about the API changes).
How do I go about making the line width of the QGraphicsItems be one in device coordinates while still maintaining the correct positions in scene coordinates? More generally, how do I scale a general QGraphicsItem so that it doesn't blow up or get fat based on scene size?
The code is below. SimpleDialog is the primary base class I use for various picker utilities, and it includes MouseView and ImageScene which automatically build in a vertical flip and a background image. The two utilities I used here are BoxSelector and PointLayout.
# Imports
import numpy as np
try:
from PyQt5.QtCore import Qt, pyqtSignal, pyqtSlot, QPointF
from PyQt5.QtGui import QImage, QPixmap, QFont, QBrush, QPen, QTransform
from PyQt5.QtWidgets import (QGraphicsView, QGraphicsScene, QDialog, QSizePolicy,
QVBoxLayout, QPushButton, QMainWindow, QApplication)
except ImportError:
from PyQt4.QtCore import Qt, pyqtSignal, pyqtSlot, QPointF
from PyQt4.QtGui import (QImage, QPixmap, QFont, QBrush, QPen, QTransform,
QGraphicsView, QGraphicsScene, QDialog, QSizePolicy,
QVBoxLayout, QPushButton, QMainWindow, QApplication)
class MouseView(QGraphicsView):
"""A subclass of QGraphicsView that returns mouse click events."""
mousedown = pyqtSignal(QPointF)
mouseup = pyqtSignal(QPointF)
mousemove = pyqtSignal(QPointF)
def __init__(self, scene, parent=None):
super(MouseView, self).__init__(scene, parent=parent)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setSizePolicy(QSizePolicy.Fixed,
QSizePolicy.Fixed)
self.scale(1, -1)
self.moving = False
def mousePressEvent(self, event):
"""Emit a mouse click signal."""
self.mousedown.emit(self.mapToScene(event.pos()))
def mouseReleaseEvent(self, event):
"""Emit a mouse release signal."""
self.mouseup.emit(self.mapToScene(event.pos()))
def mouseMoveEvent(self, event):
"""Emit a mouse move signal."""
if self.moving:
self.mousemove.emit(self.mapToScene(event.pos()))
class ImageScene(QGraphicsScene):
"""A subclass of QGraphicsScene that includes a background pixmap."""
def __init__(self, data, scene_rect, parent=None):
super(ImageScene, self).__init__(parent=parent)
bdata = ((data - np.min(data)) / (np.max(data) - np.min(data)) * 255).astype(np.uint8)
wid, hgt = data.shape
img = QImage(bdata.T.copy(), wid, hgt, wid, QImage.Format_Indexed8)
self.setSceneRect(*scene_rect)
px = QPixmap.fromImage(img)
self.px = self.addPixmap(px)
px_trans = QTransform.fromTranslate(scene_rect[0], scene_rect[1])
px_trans = px_trans.scale(scene_rect[2]/wid, scene_rect[3]/hgt)
self.px.setTransform(px_trans)
class SimpleDialog(QDialog):
"""A base class for utility dialogs using a background image in scene."""
def __init__(self, data, bounds=None, grow=[1.0, 1.0], wsize=None, parent=None):
super(SimpleDialog, self).__init__(parent=parent)
self.grow = grow
wid, hgt = data.shape
if bounds is None:
bounds = [0, 0, wid, hgt]
if wsize is None:
wsize = [wid, hgt]
vscale = [grow[0]*wsize[0]/bounds[2], grow[1]*wsize[1]/bounds[3]]
self.scene = ImageScene(data, bounds, parent=self)
self.view = MouseView(self.scene, parent=self)
self.view.scale(vscale[0], vscale[1])
quitb = QPushButton("Done")
quitb.clicked.connect(self.close)
lay = QVBoxLayout()
lay.addWidget(self.view)
lay.addWidget(quitb)
self.setLayout(lay)
def close(self):
self.accept()
class BoxSelector(SimpleDialog):
"""Simple box selector."""
def __init__(self, *args, **kwargs):
super(BoxSelector, self).__init__(*args, **kwargs)
self.rpen = QPen(Qt.green)
self.rect = self.scene.addRect(0, 0, 0, 0, pen=self.rpen)
self.view.mousedown.connect(self.start_box)
self.view.mouseup.connect(self.end_box)
self.view.mousemove.connect(self.draw_box)
self.start_point = []
self.points = []
self.setWindowTitle('Box Selector')
#pyqtSlot(QPointF)
def start_box(self, xy):
self.start_point = [xy.x(), xy.y()]
self.view.moving = True
#pyqtSlot(QPointF)
def end_box(self, xy):
lx = np.minimum(xy.x(), self.start_point[0])
ly = np.minimum(xy.y(), self.start_point[1])
rx = np.maximum(xy.x(), self.start_point[0])
ry = np.maximum(xy.y(), self.start_point[1])
self.points = [[lx, ly], [rx, ry]]
self.view.moving = False
#pyqtSlot(QPointF)
def draw_box(self, xy):
newpoint = [xy.x(), xy.y()]
minx = np.minimum(self.start_point[0], newpoint[0])
miny = np.minimum(self.start_point[1], newpoint[1])
size = [np.abs(i - j) for i, j in zip(self.start_point, newpoint)]
self.rect.setRect(minx, miny, size[0], size[1])
class PointLayout(SimpleDialog):
"""Simple point display."""
def __init__(self, *args, **kwargs):
super(PointLayout, self).__init__(*args, **kwargs)
self.pen = QPen(Qt.green)
self.view.mousedown.connect(self.mouse_click)
self.circles = []
self.points = []
self.setWindowTitle('Point Layout')
#pyqtSlot(QPointF)
def mouse_click(self, xy):
self.points.append((xy.x(), xy.y()))
pt = self.scene.addEllipse(xy.x()-0.5, xy.y()-0.5, 1, 1, pen=self.pen)
self.circles.append(pt)
And here is the code I used to do the tests:
def test_box():
x, y = np.mgrid[0:175, 0:100]
img = x * y
app = QApplication.instance()
if app is None:
app = QApplication(['python'])
picker = BoxSelector(img, bounds=[0, 0, 2*np.pi, 4*np.pi])
picker.show()
app.exec_()
return picker
def test_point():
np.random.seed(159753)
img = np.random.randn(10, 5)
app = QApplication.instance()
if app is None:
app = QApplication(['python'])
pointer = PointLayout(img, bounds=[0, 0, 10, 5], grow=[20, 20])
pointer.show()
app.exec_()
return pointer
if __name__ == "__main__":
pick = test_box()
point = test_point()
I found that explicitly setting the pen width to zero restores the previous behaviour:
class BoxSelector(SimpleDialog):
def __init__(self, *args, **kwargs):
...
self.rpen = QPen(Qt.green)
self.rpen.setWidth(0)
...
class PointLayout(SimpleDialog):
def __init__(self, *args, **kwargs):
...
self.pen = QPen(Qt.green)
self.pen.setWidth(0)
...
It seems that the default was 0 in Qt4, but it is 1 in Qt5.
From the Qt Docs for QPen.setWidth:
A line width of zero indicates a cosmetic pen. This means that the pen
width is always drawn one pixel wide, independent of the
transformation set on the painter.
Related
I created a new UI window and drew a circle. I would like to have a small figure (think small stick figure) move along the perimeter of the circle at a certain rate. The rate of movement is probably easy enough to change. How do I have an object move along the perimeter of the circle?
from PyQt5 import QtGui
from PyQt5.QtWidgets import QApplication, QMainWindow
import sys
from PyQt5.QtGui import QPainter, QPen, QBrush
from PyQt5.QtCore import Qt
class Window(QMainWindow):
def __init__(self):
super().__init__()
self.title = "Drawing Ellipse"
self.top = 200
self.left = 200
self.width = 750
self.height = 750
self.InitWindow()
def InitWindow(self):
self.setWindowIcon(QtGui.QIcon("icon.png"))
self.setWindowTitle(self.title)
self.setGeometry(self.left, self.top, self.width, self.height)
self.show()
def paintEvent(self, event):
painter = QPainter(self)
painter.setPen(QPen(Qt.black, 5, Qt.SolidLine))
painter.drawEllipse(200,200, 400,400)
App = QApplication(sys.argv)
window = Window()
sys.exit(App.exec())
First of all, there are some issues with your code:
the circle is not correctly centered: if a centered circle positioned at (200, 200) has a 400 diameter, the window should have a width and height equal to 800; but this is not a good choice, as the window could be resized (by the user or the OS), and if you want to ensure that it is actually centered, you need to adapt the rect used for the painting to the current size;
no painting should ever happen in a QMainWindow unless absolutely required: a Qt main window uses a central widget to show its main contents, and if you want to do any "main" custom painting, it should happen in that central widget; there are very few cases for which a paintEvent should be implemented in a QMainWindow, but they are very only for specific and very advanced scenarios;
a QMainWindow has an internal layout that takes into account possible features such as a menu bar, a status bar, and possibly some amount of toolbars and dock widgets, so you cannot rely on a fixed geometry for the painting, as you risk that the painting would be partially hidden by other objects;
existing class properties should never, ever be overwritten; I'm specifically referring to self.width and self.height, which are existing dynamic properties of any QWidget subclass, and should never be overwritten; I know that there are some tutorials that use that pattern (I've already warned some bloggers about this), but you should not follow those suggestions;
Considering the above, the solution is to create a QWidget subclass, and implement the paintEvent of that class. Then, using a QVariantAnimation allows to create a smooth animation that can be used to correctly position the moving image on the perimeter.
In the following example I'm using a QPainterPath with a simple ellipse, which allows me to get the position using the pointAtPercent() function.
from PyQt5.QtWidgets import QApplication, QMainWindow, QWidget
from PyQt5.QtGui import QIcon, QPainter, QPen, QPixmap, QPainterPath
from PyQt5.QtCore import Qt, QRectF, QPoint, QPointF, QVariantAnimation
class CirclingWidget(QWidget):
offset = QPoint()
def __init__(self, diameter=1, relative=True, sprite=None,
anglesPerSecond=360, clockwise=True, offset=None, alignment=None):
super().__init__()
self.diameter = diameter
self.relative = relative
self.clockwise = clockwise
self.setSprite(sprite, offset or alignment)
self.animation = QVariantAnimation(startValue=0., endValue=1.,
duration=360000 / anglesPerSecond, loopCount=-1)
self.animation.valueChanged.connect(self.update)
def setSprite(self, sprite, arg):
if isinstance(sprite, str):
sprite = QPixmap(sprite)
if isinstance(sprite, QPixmap) and not sprite.isNull():
self.sprite = sprite
if isinstance(arg, Qt.AlignmentFlag):
self.setAlignment(arg)
else:
self.setOffset(arg)
else:
self.sprite = None
self.update()
def setAlignment(self, alignment=None):
if self.sprite:
x = y = 0
if alignment is not None:
if alignment & Qt.AlignHCenter:
x = -self.sprite.width() / 2
elif alignment & Qt.AlignRight:
x = -self.sprite.width()
if alignment & Qt.AlignVCenter:
y = -self.sprite.height() / 2
elif alignment & Qt.AlignBottom:
y = -self.sprite.height()
self.offset = QPointF(x, y)
self.update()
def setOffset(self, offset=None):
if self.sprite:
x = y = 0
if isinstance(offset, int):
x = y = offset
elif isinstance(offset, float):
x = self.sprite.width() * offset
y = self.sprite.height() * offset
elif isinstance(offset, (QPoint, QPointF)):
x = offset.x()
y = offset.y()
elif isinstance(offset, (tuple, list)):
x, y = offset
self.offset = QPointF(x, y)
self.update()
def setAnglesPerSecond(self, ratio):
self.animation.setDuration(360000 / ratio)
def setClockwise(self, clockwise=True):
if self.clockwise != clockwise:
self.clockwise = clockwise
self.animation.setCurrentTime(
self.animation.duration() - self.animation.currentTime())
self.update()
def start(self, angle=0):
self.animation.start()
# the angle is considered clockwise, starting from 12h
start = (angle - 90) / 360 % 1
self.animation.setCurrentTime(start * self.animation.duration())
self.update()
def stop(self):
self.animation.stop()
self.update()
def paintEvent(self, event):
painter = QPainter(self)
painter.setRenderHints(painter.Antialiasing)
painter.setPen(QPen(Qt.black, 5, Qt.SolidLine))
size = self.diameter
if self.relative:
size *= min(self.width(), self.height())
rect = QRectF(0, 0, size, size)
rect.moveCenter(QRectF(self.rect()).center())
path = QPainterPath()
path.addEllipse(rect)
painter.drawPath(path)
if self.sprite:
pos = self.animation.currentValue()
if not self.clockwise:
pos = 1 - pos
pos = path.pointAtPercent(pos)
painter.drawPixmap(pos + self.offset, self.sprite)
class Window(QMainWindow):
def __init__(self):
super().__init__()
self.title = "Drawing Ellipse"
self.top = 200
self.left = 200
self._width = 800
self._height = 800
self.InitWindow()
def InitWindow(self):
self.setWindowIcon(QIcon("icon.png"))
self.setWindowTitle(self.title)
self.setGeometry(self.left, self.top, self._width, self._height)
self.circleWidget = CirclingWidget(.5, sprite='image.png',
anglesPerSecond=60, alignment=Qt.AlignCenter)
self.setCentralWidget(self.circleWidget)
self.circleWidget.start(90)
self.show()
if __name__ == '__main__':
import sys
App = QApplication(sys.argv)
window = Window()
sys.exit(App.exec())
Finally, avoid mixed import styles. The following lines might lead to confusion:
from PyQt5 import QtGui
from PyQt5.QtGui import QPainter, QPen, QBrush
You either import QtGui, or you import the individual classes of that submodule.
I'm writing a simple GUI interface for a project using PySide2.
I'm using the typical MVC design pattern, for the sake of clarity I will just post the code of my GUI (without controller and support methods ecc...)
Here's the code:
from PySide2.QtWidgets import *
from PySide2.QtWidgets import QSizePolicy
from PySide2.QtGui import QIcon
from PySide2.QtWidgets import (QPushButton, QMainWindow)
class myView(QMainWindow):
def __init__(self, parent=None):
"""View initializer."""
#Creates blank view of a given size
super().__init__()
# Set some main window's properties
self.setWindowTitle('8D.me')
self.setFixedSize(800, 500) # Block user resize of the window
self.setIcon()
self.generalLayout = QHBoxLayout() #Layout generale
self.button = QPushButton('test3',self)
self.button.setSizePolicy(
QSizePolicy.Preferred,
QSizePolicy.Expanding)
self.generalLayout.addWidget(QPushButton('test2',self),1)
self.generalLayout.addWidget(self.button,3)
# Set the central widget
self._centralWidget = QWidget(self) #creates a QWidget object to play the role of a central widget. Remember that since your GUI class inherits from QMainWindow, you need a central widget. This object will be the parent for the rest of the GUI component.
self.setCentralWidget(self._centralWidget)
self._centralWidget.setLayout(self.generalLayout)
# Insert methods for creating/adding elements to the default view.
# Mehods....
def setIcon(self):
appIcon = QIcon('logo')
self.setWindowIcon(appIcon)
#Insert here the public methods called by the Controller to update the view...
My GUI right no is pretty simple and looks like this:
What I would like to do is change the test 3 button and insert a 2D clickable surface.
More in details, I would like to be able to click anywhere on this surface and get the position of the mouse click.
Basically I would like to create a 2D xy axis and retrieve the coordinates of my mouse click, something like this:
And then if I click at position (1,1) I wll print something like "You clicked at (1,1) on the axis", pretty simple.
I looked around for examples, tutorials and documentation, but I didn't find any proper tool to create what I wanted.
Is there any class inside the PySide2 package that could help me?
If you took literally that your goal is to get the X-Y plane in your image then a possible solution is to use a QGraphicsView:
import math
import sys
from PySide2.QtCore import Signal, QPointF
from PySide2.QtGui import QColor, QPainterPath
from PySide2.QtWidgets import (
QApplication,
QGraphicsScene,
QGraphicsView,
QHBoxLayout,
QMainWindow,
QPushButton,
QWidget,
)
class GraphicsScene(QGraphicsScene):
clicked = Signal(QPointF)
def drawBackground(self, painter, rect):
l = min(rect.width(), rect.height()) / 30
x_left = QPointF(rect.left(), 0)
x_right = QPointF(rect.right(), 0)
painter.drawLine(x_left, x_right)
right_triangle = QPainterPath()
right_triangle.lineTo(-0.5 * math.sqrt(3) * l, 0.5 * l)
right_triangle.lineTo(-0.5 * math.sqrt(3) * l, -0.5 * l)
right_triangle.closeSubpath()
right_triangle.translate(x_right)
painter.setBrush(QColor("black"))
painter.drawPath(right_triangle)
y_top = QPointF(0, rect.top())
y_bottom = QPointF(0, rect.bottom())
painter.drawLine(y_top, y_bottom)
top_triangle = QPainterPath()
top_triangle.lineTo(.5*l, -0.5 * math.sqrt(3) * l)
top_triangle.lineTo(-.5*l, -0.5 * math.sqrt(3) * l)
top_triangle.closeSubpath()
top_triangle.translate(y_bottom)
painter.setBrush(QColor("black"))
painter.drawPath(top_triangle)
def mousePressEvent(self, event):
sp = event.scenePos()
self.clicked.emit(sp)
super().mousePressEvent(event)
class MyView(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("8D.me")
self.setFixedSize(800, 500)
self.btn = QPushButton("test2")
self.view = QGraphicsView()
self.view.scale(1, -1)
self.scene = GraphicsScene()
self.view.setScene(self.scene)
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QHBoxLayout(central_widget)
layout.addWidget(self.btn)
layout.addWidget(self.view)
self.scene.clicked.connect(self.handle_clicked)
def handle_clicked(self, p):
print("clicked", p.x(), p.y())
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MyView()
w.show()
sys.exit(app.exec_())
I'd like to use the wheelEvent to resize an image and place a QGraphicPixmap into a QGraphicsScene.
Before adding the original image, it is resized to around 1/3rd its original size. In the wheelEvent, I'm calling a function that will resize the original image and create a QImage to set the QGraphicsPixmap.
After adding the resized pixmap to the scene, the pixels that were originally under the cursor before the scale have shifted. I'm not sure which positions I need to be mapping to/from the scene to achieve this.
I've tried scaling the graphicsPixmap, scaling and translating the graphicsPixmap, scaling the view and translating the graphicsPixmap/setting an offset.
I clearly don't something about what's happening but I'm not sure what that is..
The WheelEvent below works perfectly until maybe_resize is called.
Depending on the size of the current image in the viewer the maybe_resize method will either resize the current ndarray image, create a new qimage and set a new pixmap in the graphicPixmap, or it exits the method without resizing.
If you run the code as is, the pixmap is in the same place under the cursor, but if you uncomment maybe_resize this is no longer the case.
from PyQt5.QtCore import QRectF, QSize, Qt, pyqtSignal
import cv2
import numpy as np
from PyQt5.QtCore import QRectF, QSize, Qt, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import (QApplication,
QFrame,
QGraphicsPixmapItem,
QGraphicsScene,
QGraphicsView,
QMainWindow,
QSizePolicy)
class GraphicsView(QGraphicsView):
def __init__(self, parent):
super(GraphicsView, self).__init__(parent)
self.pixmap = QPixmap()
self._zoom_level = 0
self._scene = Scene(self)
self.setScene(self._scene)
self.gpm = QGraphicsPixmapItem()
self._scene.addItem(self.gpm)
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setFrameShape(QFrame.NoFrame)
self.has_image = False
def maybe_resize(self, factor):
self.resize_requested(factor)
def read_image(self, path):
self.base_image = cv2.imread(path, -1)
self._original_res = self.base_image.shape
h, w = self.base_image.shape[0], self.base_image.shape[1]
self.resized_image = cv2.resize(self.base_image, (w // 4, h // 4))
self.has_image = True
self.set_image(self.resized_image)
return self.resized_image
def resize_requested(self, factor):
factor = max(1. * (self._zoom_level * factor), 1)
h = int(self.resized_image.shape[0] * factor)
w = int(self.resized_image.shape[1] * factor)
src = cv2.resize(self.base_image, (w, h))
dst = np.ndarray(src.shape, src.dtype)
dst[:, :, :] = src
self.set_image(dst)
def wheelEvent(self, event):
factor = 1.1
if event.angleDelta().y() < 0:
factor = 0.9
self._zoom_level-=1
else:
self._zoom_level+=1
view_pos = event.pos()
scene_pos = self.mapToScene(view_pos)
self.centerOn(scene_pos)
self.scale(factor, factor)
delta = self.mapToScene(view_pos) - self.mapToScene(self.viewport().rect().center())
self.centerOn(scene_pos - delta)
# self.maybe_resize(factor)
def set_image(self, img):
if not self.has_image:
return
shape = img.shape
w = shape[1]
h = shape[0]
self._image = img
q_img_format = QImage.Format_RGB888
try:
bands = shape[2]
except IndexError:
bands = 1
q_img = QImage(img, w, h, w * bands, q_img_format)
self.pixmap = self.pixmap.fromImage(q_img)
self.setSceneRect(QRectF(self.pixmap.rect()))
self.gpm.setPixmap(self.pixmap)
class Scene(QGraphicsScene):
zoom_changed = pyqtSignal(float)
def __init__(self, parent=None):
super(Scene, self).__init__(parent)
class Window(QMainWindow):
def __init__(self):
super(Window, self).__init__()
self.gv = GraphicsView(self)
self.setCentralWidget(self.gv)
def load_image(self, path):
self.gv.read_image(path)
def sizeHint(self):
return QSize(800, 800)
if __name__ == "__main__":
app = QApplication([])
w = Window()
w.load_image('test.jpg')
w.show()
app.exit(app.exec_())
I want to do board with square widgets. When I run code it creates nice board but after resize it become looks ugly. I am trying resize it with resize Event but it exists (probably some errors). I have no idea how to resize children after resize of parent.
Children widgets must be squares so it is also problem since I can not use auto expand. Maybe it is simple problem but I can not find solution. I spend hours testing different ideas but it now works as it should.
This what I want resize (click maximize):
After maximize it looks ugly (I should change children widget but on what event (I think on resizeEvent but it is not works) and how (set from parent or children cause program exit).
This is my minimize code:
import logging
import sys
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QSize
from PyQt5.QtGui import QFont, QPaintEvent, QPainter
from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout
class Application(QApplication):
pass
class Board(QWidget):
def square_size(self):
size = self.size()
min_size = min(size.height(), size.width())
min_size_1_8 = min_size // 8
square_size = QSize(min_size_1_8, min_size_1_8)
logging.debug(square_size)
return square_size
def __init__(self, parent=None):
super().__init__(parent=parent)
square_size = self.square_size()
grid = QGridLayout()
grid.setSpacing(0)
squares = []
for x in range(8):
for y in range(8):
square = Square(self, (x + y - 1) % 2)
squares.append(squares)
square.setFixedSize(square_size)
grid.addWidget(square, x, y)
self.squares = squares
self.setLayout(grid)
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
# how to resize children?
logging.debug('Resize %s.', self.__class__.__name__)
logging.debug('Size %s.', event.size())
super().resizeEvent(event)
class Square(QWidget):
def __init__(self, parent, color):
super().__init__(parent=parent)
if color:
self.color = QtCore.Qt.white
else:
self.color = QtCore.Qt.black
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
logging.debug('Resize %s.', self.__class__.__name__)
logging.debug('Size %s.', event.size())
super().resizeEvent(event)
def paintEvent(self, event: QPaintEvent) -> None:
painter = QPainter()
painter.begin(self)
painter.fillRect(self.rect(), self.color)
painter.end()
def main():
logging.basicConfig(level=logging.DEBUG)
app = Application(sys.argv)
app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)
default_font = QFont()
default_font.setPointSize(12)
app.setFont(default_font)
board = Board()
board.setWindowTitle('Board')
# ugly look
# chessboard.showMaximized()
# looks nize but resize not works
board.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
How should I do resize of square children to avoid holes?
2nd try - improved code but still I have not idea how to resize children
Some new idea with centering it works better (no gaps now) but still I do not know how to resize children (without crash).
After show():
Too wide (it keeps proportions):
Too tall (it keeps proportions):
Larger (it keeps proportions but children is not scaled to free space - I do not know how to resize children still?):
Improved code:
import logging
import sys
from PyQt5 import QtCore, QtGui
from PyQt5.QtCore import QSize
from PyQt5.QtGui import QFont, QPaintEvent, QPainter
from PyQt5.QtWidgets import QApplication, QWidget, QGridLayout, QHBoxLayout, QVBoxLayout
class Application(QApplication):
pass
class Board(QWidget):
def square_size(self):
size = self.size()
min_size = min(size.height(), size.width())
min_size_1_8 = min_size // 8
square_size = QSize(min_size_1_8, min_size_1_8)
logging.debug(square_size)
return square_size
def __init__(self, parent=None):
super().__init__(parent=parent)
square_size = self.square_size()
vertical = QVBoxLayout()
horizontal = QHBoxLayout()
grid = QGridLayout()
grid.setSpacing(0)
squares = []
for x in range(8):
for y in range(8):
square = Square(self, (x + y - 1) % 2)
squares.append(squares)
square.setFixedSize(square_size)
grid.addWidget(square, x, y)
self.squares = squares
horizontal.addStretch()
horizontal.addLayout(grid)
horizontal.addStretch()
vertical.addStretch()
vertical.addLayout(horizontal)
vertical.addStretch()
self.setLayout(vertical)
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
# how to resize children?
logging.debug('Resize %s.', self.__class__.__name__)
logging.debug('Size %s.', event.size())
super().resizeEvent(event)
class Square(QWidget):
def __init__(self, parent, color):
super().__init__(parent=parent)
if color:
self.color = QtCore.Qt.white
else:
self.color = QtCore.Qt.black
def resizeEvent(self, event: QtGui.QResizeEvent) -> None:
logging.debug('Resize %s.', self.__class__.__name__)
logging.debug('Size %s.', event.size())
super().resizeEvent(event)
def paintEvent(self, event: QPaintEvent) -> None:
painter = QPainter()
painter.begin(self)
painter.fillRect(self.rect(), self.color)
painter.end()
def main():
logging.basicConfig(level=logging.DEBUG)
app = Application(sys.argv)
app.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True)
default_font = QFont()
default_font.setPointSize(12)
app.setFont(default_font)
board = Board()
board.setWindowTitle('Board')
# ugly look
# chessboard.showMaximized()
# looks nice but resize not works
board.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()
How should I resize square children without crash?
There are two possible solution.
You can use the Graphics View framework, which is intended exactly for this kind of applications where custom/specific graphics and positioning have to be taken into account, otherwise create a layout subclass.
While reimplementing a layout is slightly simple in this case, you might face some issues as soon as the application becomes more complex. On the other hand, the Graphics View framework has a steep learning curve, as you'll need to understand how it works and how object interaction behaves.
Subclass the layout
Assuming that the square count is always the same, you can reimplement your own layout that will set the correct geometry based on its contents.
In this example I also created a "container" with other widgets to show the resizing in action.
When the window width is very high, it will use the height as a reference and center it horizontally:
On the contrary, when the height is bigger, it will be centered vertically:
Keep in mind that you should not add other widgets to the board, otherwise you'll get into serious issues.
This would not be impossible, but its implementation might be much more complex, as the layout would need to take into account the other widgets positions, size hints and possible expanding directions in order to correctly compute the new geometry.
from PyQt5 import QtCore, QtGui, QtWidgets
class Square(QtWidgets.QWidget):
def __init__(self, parent, color):
super().__init__(parent=parent)
if color:
self.color = QtCore.Qt.white
else:
self.color = QtCore.Qt.black
self.setMinimumSize(50, 50)
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
painter = QtGui.QPainter(self)
painter.fillRect(self.rect(), self.color)
class EvenLayout(QtWidgets.QGridLayout):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setSpacing(0)
def setGeometry(self, oldRect):
# assuming that the minimum size is 50 pixel, find the minimum possible
# "extent" based on the geometry provided
minSize = max(50 * 8, min(oldRect.width(), oldRect.height()))
# create a new squared rectangle based on that size
newRect = QtCore.QRect(0, 0, minSize, minSize)
# move it to the center of the old one
newRect.moveCenter(oldRect.center())
super().setGeometry(newRect)
class Board(QtWidgets.QWidget):
def __init__(self):
super().__init__()
self.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
layout = EvenLayout(self)
self.squares = []
for row in range(8):
for column in range(8):
square = Square(self, not (row + column) & 1)
self.squares.append(square)
layout.addWidget(square, row, column)
class Chess(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout(self)
header = QtWidgets.QLabel('Some {}long label'.format('very ' * 20))
layout.addWidget(header, 0, 0, 1, 3, QtCore.Qt.AlignCenter)
self.board = Board()
layout.addWidget(self.board, 1, 1)
leftLayout = QtWidgets.QVBoxLayout()
layout.addLayout(leftLayout, 1, 0)
rightLayout = QtWidgets.QVBoxLayout()
layout.addLayout(rightLayout, 1, 2)
for b in range(1, 9):
leftLayout.addWidget(QtWidgets.QPushButton('Left Btn {}'.format(b)))
rightLayout.addWidget(QtWidgets.QPushButton('Right Btn {}'.format(b)))
footer = QtWidgets.QLabel('Another {}long label'.format('very ' * 18))
layout.addWidget(footer, 2, 0, 1, 3, QtCore.Qt.AlignCenter)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
w = Chess()
w.show()
sys.exit(app.exec_())
Using the Graphics View
The result will be visually identical to the previous one, but while the overall positioning, drawing and interaction would be conceptually a bit easier, understanding how Graphics Views, Scenes and objects work might require you some time to get the hang of it.
from PyQt5 import QtCore, QtGui, QtWidgets
class Square(QtWidgets.QGraphicsWidget):
def __init__(self, color):
super().__init__()
if color:
self.color = QtCore.Qt.white
else:
self.color = QtCore.Qt.black
def paint(self, qp, option, widget):
qp.fillRect(option.rect, self.color)
class Scene(QtWidgets.QGraphicsScene):
def __init__(self):
super().__init__()
self.container = QtWidgets.QGraphicsWidget()
layout = QtWidgets.QGraphicsGridLayout(self.container)
layout.setSpacing(0)
self.container.setContentsMargins(0, 0, 0, 0)
layout.setContentsMargins(0, 0, 0, 0)
self.addItem(self.container)
for row in range(8):
for column in range(8):
square = Square(not (row + column) & 1)
layout.addItem(square, row, column, 1, 1)
class Board(QtWidgets.QGraphicsView):
def __init__(self):
super().__init__()
scene = Scene()
self.setScene(scene)
self.setAlignment(QtCore.Qt.AlignCenter)
# by default a graphics view has a border frame, disable it
self.setFrameShape(0)
# make it transparent
self.setStyleSheet('QGraphicsView {background: transparent;}')
def resizeEvent(self, event):
super().resizeEvent(event)
# zoom the contents keeping the ratio
self.fitInView(self.scene().container, QtCore.Qt.KeepAspectRatio)
class Chess(QtWidgets.QWidget):
def __init__(self):
super().__init__()
layout = QtWidgets.QGridLayout(self)
header = QtWidgets.QLabel('Some {}long label'.format('very ' * 20))
layout.addWidget(header, 0, 0, 1, 3, QtCore.Qt.AlignCenter)
self.board = Board()
layout.addWidget(self.board, 1, 1)
leftLayout = QtWidgets.QVBoxLayout()
layout.addLayout(leftLayout, 1, 0)
rightLayout = QtWidgets.QVBoxLayout()
layout.addLayout(rightLayout, 1, 2)
for b in range(1, 9):
leftLayout.addWidget(QtWidgets.QPushButton('Left Btn {}'.format(b)))
rightLayout.addWidget(QtWidgets.QPushButton('Right Btn {}'.format(b)))
footer = QtWidgets.QLabel('Another {}long label'.format('very ' * 18))
layout.addWidget(footer, 2, 0, 1, 3, QtCore.Qt.AlignCenter)
if __name__ == '__main__':
import sys
app = QtWidgets.QApplication(sys.argv)
w = Chess()
w.show()
sys.exit(app.exec_())
I am trying to piece together PyQt5 based image viewer Python code from various sources and extend capability to crop regions of interest (ROI) within loaded images. The issue is that the mapped coordinates and mouse clicks consider scroll bar and menu bar when determining pixel locations. Following is the code that loads image and provide bounding box capability, but I cannot seem to draw/crop boxes accurately due to the offset.
from PyQt5.QtCore import QDir, Qt
from PyQt5.QtGui import QImage, QPainter, QPalette, QPixmap
from PyQt5.QtWidgets import (QAction, QApplication, QFileDialog, QLabel,
QMainWindow, QMenu, QMessageBox, QScrollArea, QSizePolicy)
from PyQt5.QtPrintSupport import QPrintDialog, QPrinter
class ImageViewer(QMainWindow):
def __init__(self):
super(ImageViewer, self).__init__()
self.printer = QPrinter()
self.scaleFactor = 0.0
self.imageLabel = QLabel()
self.imageLabel.setBackgroundRole(QPalette.Base)
self.imageLabel.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored)
self.imageLabel.setScaledContents(True)
self.scrollArea = QScrollArea()
self.scrollArea.setBackgroundRole(QPalette.Dark)
self.scrollArea.setWidget(self.imageLabel)
self.setCentralWidget(self.scrollArea)
self.createActions()
self.createMenus()
self.setWindowTitle("Image Viewer")
self.resize(500, 400)
def open(self):
fileName, _ = QFileDialog.getOpenFileName(self, "Open File",
QDir.currentPath())
if fileName:
image = QImage(fileName)
if image.isNull():
QMessageBox.information(self, "Image Viewer",
"Cannot load %s." % fileName)
return
self.imageLabel.setPixmap(QPixmap.fromImage(image))
self.scaleFactor = 1.0
self.printAct.setEnabled(True)
self.fitToWindowAct.setEnabled(True)
self.updateActions()
if not self.fitToWindowAct.isChecked():
self.imageLabel.adjustSize()
def print_(self):
dialog = QPrintDialog(self.printer, self)
if dialog.exec_():
painter = QPainter(self.printer)
rect = painter.viewport()
size = self.imageLabel.pixmap().size()
size.scale(rect.size(), Qt.KeepAspectRatio)
painter.setViewport(rect.x(), rect.y(), size.width(), size.height())
painter.setWindow(self.imageLabel.pixmap().rect())
painter.drawPixmap(0, 0, self.imageLabel.pixmap())
def zoomIn(self):
self.scaleImage(1.25)
def zoomOut(self):
self.scaleImage(0.8)
def normalSize(self):
self.imageLabel.adjustSize()
self.scaleFactor = 1.0
def fitToWindow(self):
fitToWindow = self.fitToWindowAct.isChecked()
self.scrollArea.setWidgetResizable(fitToWindow)
if not fitToWindow:
self.normalSize()
self.updateActions()
def about(self):
QMessageBox.about(self, "About Image Viewer",
"<p>The <b>Image Viewer</b> example shows how to combine "
"QLabel and QScrollArea to display an image. QLabel is "
"typically used for displaying text, but it can also display "
"an image. QScrollArea provides a scrolling view around "
"another widget. If the child widget exceeds the size of the "
"frame, QScrollArea automatically provides scroll bars.</p>"
"<p>The example demonstrates how QLabel's ability to scale "
"its contents (QLabel.scaledContents), and QScrollArea's "
"ability to automatically resize its contents "
"(QScrollArea.widgetResizable), can be used to implement "
"zooming and scaling features.</p>"
"<p>In addition the example shows how to use QPainter to "
"print an image.</p>")
def createActions(self):
self.openAct = QAction("&Open...", self, shortcut="Ctrl+O",
triggered=self.open)
self.printAct = QAction("&Print...", self, shortcut="Ctrl+P",
enabled=False, triggered=self.print_)
self.exitAct = QAction("E&xit", self, shortcut="Ctrl+Q",
triggered=self.close)
self.zoomInAct = QAction("Zoom &In (25%)", self, shortcut="Ctrl++",
enabled=False, triggered=self.zoomIn)
self.zoomOutAct = QAction("Zoom &Out (25%)", self, shortcut="Ctrl+-",
enabled=False, triggered=self.zoomOut)
self.normalSizeAct = QAction("&Normal Size", self, shortcut="Ctrl+S",
enabled=False, triggered=self.normalSize)
self.fitToWindowAct = QAction("&Fit to Window", self, enabled=False,
checkable=True, shortcut="Ctrl+F", triggered=self.fitToWindow)
self.aboutAct = QAction("&About", self, triggered=self.about)
self.aboutQtAct = QAction("About &Qt", self,
triggered=QApplication.instance().aboutQt)
def createMenus(self):
self.fileMenu = QMenu("&File", self)
self.fileMenu.addAction(self.openAct)
self.fileMenu.addAction(self.printAct)
self.fileMenu.addSeparator()
self.fileMenu.addAction(self.exitAct)
self.viewMenu = QMenu("&View", self)
self.viewMenu.addAction(self.zoomInAct)
self.viewMenu.addAction(self.zoomOutAct)
self.viewMenu.addAction(self.normalSizeAct)
self.viewMenu.addSeparator()
self.viewMenu.addAction(self.fitToWindowAct)
self.helpMenu = QMenu("&Help", self)
self.helpMenu.addAction(self.aboutAct)
self.helpMenu.addAction(self.aboutQtAct)
self.menuBar().addMenu(self.fileMenu)
self.menuBar().addMenu(self.viewMenu)
self.menuBar().addMenu(self.helpMenu)
def updateActions(self):
self.zoomInAct.setEnabled(not self.fitToWindowAct.isChecked())
self.zoomOutAct.setEnabled(not self.fitToWindowAct.isChecked())
self.normalSizeAct.setEnabled(not self.fitToWindowAct.isChecked())
def scaleImage(self, factor):
self.scaleFactor *= factor
self.imageLabel.resize(self.scaleFactor * self.imageLabel.pixmap().size())
self.adjustScrollBar(self.scrollArea.horizontalScrollBar(), factor)
self.adjustScrollBar(self.scrollArea.verticalScrollBar(), factor)
self.zoomInAct.setEnabled(self.scaleFactor < 3.0)
self.zoomOutAct.setEnabled(self.scaleFactor > 0.333)
def adjustScrollBar(self, scrollBar, factor):
scrollBar.setValue(int(factor * scrollBar.value()
+ ((factor - 1) * scrollBar.pageStep()/2)))
def mousePressEvent (self, eventQMouseEvent):
self.originQPoint = self.scrollArea.mapFrom(self, eventQMouseEvent.pos())
#self.originQPoint = eventQMouseEvent.pos()
self.currentQRubberBand = QtWidgets.QRubberBand(QtWidgets.QRubberBand.Rectangle, self)
self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, QtCore.QSize()))
self.currentQRubberBand.show()
def mouseMoveEvent (self, eventQMouseEvent):
self.x = int(eventQMouseEvent.x())
self.y = int(eventQMouseEvent.y())
text1 = str(self.x)
text2 = str(self.y)
#print(self.x,self.y)
QtWidgets.QToolTip.showText(eventQMouseEvent.pos() , "X: "+text1+" "+"Y: "+text2,self)
if self.currentQRubberBand.isVisible():
self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, eventQMouseEvent.pos()).normalized() & self.imageLabel.pixmap().rect())
def mouseReleaseEvent (self, eventQMouseEvent):
self.currentQRubberBand.hide()
currentQRect = self.currentQRubberBand.geometry()
self.currentQRubberBand.deleteLater()
cropQPixmap = self.imageLabel.pixmap().copy(currentQRect)
cropQPixmap.save('output.png')
if __name__ == '__main__':
import sys
from PyQt5 import QtGui, QtCore, QtWidgets
app = QApplication(sys.argv)
imageViewer = ImageViewer()
imageViewer.show()
sys.exit(app.exec_())
It is better in these cases that the QRubberBand is the son of the QLabel so there will be no need to make many transformations.
On the other hand, the coordinates of the event are related to the window, so we have to convert it to the coordinates of the QLabel. For this a simple methodology is to convert the local coordinate with respect to the window to global coordinates and then the global coordinates to local coordinates with respect to the QLabel.
And finally when you scale the image you affect the coordinates since the currentQRect is relative to the scaled QLabel but the internal QPixmap is not scaled.
def mousePressEvent (self, event):
self.originQPoint = self.imageLabel.mapFromGlobal(self.mapToGlobal(event.pos()))
self.currentQRubberBand = QtWidgets.QRubberBand(QtWidgets.QRubberBand.Rectangle, self.imageLabel)
self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, QtCore.QSize()))
self.currentQRubberBand.show()
def mouseMoveEvent (self, event):
p = self.imageLabel.mapFromGlobal(self.mapToGlobal(event.pos()))
QtWidgets.QToolTip.showText(event.pos() , "X: {} Y: {}".format(p.x(), p.y()), self)
if self.currentQRubberBand.isVisible() and self.imageLabel.pixmap() is not None:
self.currentQRubberBand.setGeometry(QtCore.QRect(self.originQPoint, p).normalized() & self.imageLabel.rect())
def mouseReleaseEvent (self, event):
self.currentQRubberBand.hide()
currentQRect = self.currentQRubberBand.geometry()
self.currentQRubberBand.deleteLater()
if self.imageLabel.pixmap() is not None:
tr = QtGui.QTransform()
if self.fitToWindowAct.isChecked():
tr.scale(self.imageLabel.pixmap().width()/self.scrollArea.width(),
self.imageLabel.pixmap().height()/self.scrollArea.height())
else:
tr.scale(1/self.scaleFactor, 1/self.scaleFactor)
r = tr.mapRect(currentQRect)
cropQPixmap = self.imageLabel.pixmap().copy(r)
cropQPixmap.save('output.png')