So I have a large application that I've been writing. One part of that application is responsible for displaying images taken from a camera and plotting two graphs.
I've successfully stripped out the entirety of the application that is not relevant to this portion of the application.
It works, what I'm doing with the graphs and images. However, I have concerns.
- paintEvent is being triggered at the main UI level, however, I need the portion of the graph to resize within the main UI. I attempted another paint event at the graphframe level, but it didn't work. So I created a new function that does all the work of a paint event, but it's triggered by the parent paint event.
- I was also thinking that maybe I should use pyqtgraph. One aspect of this code that I'm missing is the ability to track the x and y of where the mouse is on the graph.
- If you look at the Alignment class, you will notice this is where the layout is designed. My main application uses 4 or 5 different classes that will generate a different layout for each class. These layouts are being added to a QStackedWidget, so I can easily switch between the panels. As this graph frame will be used on every one of the panels, this presents an issue, as a single instance can not be present on each of the panels. So I need to find a new approach, which just means I'm probably going to have to ditch the QStackwidget and just generate a new layout each time.
While those are three concerns I have I'm not looking for resolutions, I want to post and get some feedback on the current state. I mention the concerns only to give you context as to what I'm currently working on. Maybe some feedback will push me to switch to pyqtgraph or stay with matplotlib. Any feedback is appreciated.
I rearranged all the code to run on a single file, with only what is important for the portion of work it's related to.
import sys
import cv2
import numpy as np
# from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qtagg import FigureCanvas
from matplotlib.figure import Figure
from PyQt6.QtCore import QRect, Qt
from PyQt6.QtGui import QColor, QImage, QLinearGradient, QPainter, QPixmap
from PyQt6.QtWidgets import (
QApplication,
QFrame,
QGridLayout,
QHBoxLayout,
QLabel,
QMainWindow,
QSizePolicy,
QSplitter,
QStackedWidget,
QStyleFactory,
QTabWidget,
QVBoxLayout,
QWidget,
)
# matplotlib.use("QtAgg")
LIGHT_BLUE = QColor(0, 102, 153)
WHITE = QColor(255, 255, 255)
def layout_generator(*args, layout=None) -> QHBoxLayout:
"""
Generates a layout of the specified type and adds the given arguments to it.
Args:
layout_type: The type of layout to generate (e.g., QHBoxLayout, QVBoxLayout, QGridLayout).
*args: Variable number of arguments to add to the layout. Arguments can be: integers (for spacing),
"*", which adds a stretch
layout Objects
QWidget objects.
Returns:
The generated layout with the added arguments.
"""
if layout is None:
return
for arg in args:
if isinstance(arg, int):
layout.addSpacing(arg)
elif arg == "*":
layout.addStretch(1)
elif (
type(arg) is QHBoxLayout
or type(arg) is QVBoxLayout
or type(arg) is QGridLayout
):
layout.addLayout(arg)
else:
layout.addWidget(arg)
return layout
class GraphFrame:
"""_summary_"""
def __init__(self):
"""_summary_
Returns:
_type_: _description_
"""
self.width = 425
self.height = 350
self.delta = 30
self.frame = QFrame()
self.graphs = dict(fft=None, fvb=None)
self.axis = dict(fft=None, fvb=None)
self.canvas = dict(fft=None, fvb=None)
self.Display_Tab_Widget = QTabWidget()
self.current_image = np.random.randint(0.0, 1.0, size=(10, 10))
self._image()
self._fvb()
self._fft()
self.Display_Tab_Widget.setCurrentIndex(1)
self.create_plot(graph="fvb", x_label="Pixels", layout=self.mpl_vlayout)
self.create_plot(
graph="fft", x_label="Raman shift (cm-1)", layout=self.mpl_ft_layout
)
layout_generator(
self.Display_Tab_Widget, layout=QVBoxLayout(self.frame)
)
def get_graph_frame(self):
return self.frame
def _image(self):
self.cameraImageDisplay = QWidget(self.Display_Tab_Widget)
self.cameraImageDisplay.setAutoFillBackground(True)
self.cameraDisplay = QLabel(self.cameraImageDisplay)
self.cameraDisplay.setText(" ")
self.cameraDisplay.setStyleSheet("border: 0px solid black")
self.cameraDisplay.setAutoFillBackground(False)
self.cameraDisplay.setAlignment(Qt.AlignmentFlag.AlignCenter) # type: ignore
self.Display_Tab_Widget.addTab(self.cameraImageDisplay, "")
self.Display_Tab_Widget.setTabText(
self.Display_Tab_Widget.indexOf(self.cameraImageDisplay),
"Image",
)
def _fvb(self):
self.cameraFVBDisplay = QWidget(self.Display_Tab_Widget)
self.layoutWidget2 = QWidget(self.cameraFVBDisplay)
self.mpl_vlayout = QVBoxLayout(self.layoutWidget2)
self.cameraFVBPlot = QWidget(self.layoutWidget2)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # type: ignore
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(1)
sizePolicy.setHeightForWidth(
self.cameraFVBPlot.sizePolicy().hasHeightForWidth()
)
self.mpl_vlayout.addWidget(self.cameraFVBPlot)
self.Display_Tab_Widget.addTab(self.cameraFVBDisplay, "")
self.Display_Tab_Widget.setTabText(
self.Display_Tab_Widget.indexOf(self.cameraFVBDisplay),
"FVB Display",
)
def _fft(self):
self.FFTDisplay = QWidget(self.Display_Tab_Widget)
self.layoutWidget_2 = QWidget(self.FFTDisplay)
self.mpl_ft_layout = QVBoxLayout(self.layoutWidget_2)
self.FTPlot = QWidget(self.layoutWidget_2)
sizePolicy = QSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # type: ignore
sizePolicy.setHorizontalStretch(0)
sizePolicy.setVerticalStretch(1)
sizePolicy.setHeightForWidth(
self.FTPlot.sizePolicy().hasHeightForWidth()
)
self.mpl_ft_layout.addWidget(self.FTPlot)
self.Display_Tab_Widget.addTab(self.FFTDisplay, "")
self.Display_Tab_Widget.setTabText(
self.Display_Tab_Widget.indexOf(self.FFTDisplay), "FFT Display"
)
def create_plot(self, graph, x_label, layout, y_label="Counts"):
self.graphs[graph] = Figure(figsize=(6, 6), dpi=100, tight_layout=True)
self.axis[graph] = self.graphs[graph].add_subplot(111)
for axis in ["top", "bottom", "left", "right"]:
self.axis[graph].spines[axis].set_linewidth(1.5)
self.axis[graph].grid(axis="both", which="major", linestyle=":")
self.axis[graph].set_xlabel(x_label, fontsize=12, fontfamily="arial")
self.axis[graph].set_ylabel(y_label, fontsize=12, fontfamily="arial")
self.axis[graph].minorticks_on()
self.axis[graph].tick_params(
axis="both",
which="minor",
direction="in",
width=0.72,
length=2.16,
right=True,
top=True,
)
self.axis[graph].tick_params(
axis="both",
which="major",
direction="in",
width=1.224,
length=3.6,
right=True,
top=True,
)
self.canvas[graph] = FigureCanvas(self.graphs.get(graph))
layout.addWidget(self.canvas.get(graph))
self.canvas[graph].draw()
layout.setAlignment(Qt.AlignmentFlag.AlignVCenter)
def update_fft(self, frame, slope, intercept):
ft_y = frame
ft_x = np.arange(len(frame))
self.x_axis_plot = (ft_x * slope) + intercept
self.axis["fft"].cla()
self.axis["fft"].plot(self.x_axis_plot, ft_y)
self.axis["fft"].set_xlim(250, 2500)
self.axis["fft"].grid(axis="both", which="major", linestyle=":")
self.axis["fft"].set_xlabel(
"Raman shift (cm-1)", fontsize=12, fontfamily="arial"
)
self.axis["fft"].set_ylabel("Counts", fontsize=12, fontfamily="arial")
self.graphs["fft"].canvas.draw_idle() # actually draw the new content
def update_fvb(self, frame, x_start, x_stop):
graph = "fvb"
fvb_y = frame
fvb_x = np.array(range(1024))
self.axis[graph].cla() # clear the axes content
self.axis[graph].plot(fvb_x, fvb_y) # plot new data
self.axis[graph].set_xlim(x_start, x_stop)
self.axis[graph].set_ylim(frame.min(), frame.max())
self.axis[graph].grid(axis="both", which="major", linestyle=":")
self.axis[graph].set_xlabel("Pixel", fontsize=12, fontfamily="arial")
self.axis[graph].set_ylabel("Counts", fontsize=12, fontfamily="arial")
self.graphs[graph].canvas.draw_idle() # actually draw the new content
def set_image(self, image=None):
if image is not None:
self.current_image = image
rot_img = np.rot90(self.current_image, k=1)
# rot_img = np.rot90(image, k=1, axes=(1, 0))
img_array = np.array([rot_img, rot_img, rot_img]).T.astype(float)
img = img_array - img_array.min()
if img.max() != 0:
img /= img.max()
else:
img = np.zeros_like(img)
img = cv2.resize(img, (self.cube, self.cube))
img[np.isnan(img)] = 0
img = (img * 255).astype(np.uint8)
if len(img.shape) == 2:
frame = QImage(
img.repeat(1),
img.shape[1],
img.shape[0],
QImage.Format.Format_Grayscale8,
)
elif len(img.shape) == 3:
frame = QImage(
img.repeat(1),
img.shape[1],
img.shape[0],
img.shape[2] * img.shape[1],
QImage.Format.Format_RGB888,
)
display_frame = QPixmap(frame)
self.cameraDisplay.setPixmap(display_frame)
def trigger_paint_event(self, event) -> None:
width = self.frame.rect().width()
height = self.frame.rect().height()
self.cube = min(width, height)
left = int((width - self.cube) / 2)
right = int((height - self.cube) / 2)
self.cameraDisplay.setGeometry(QRect(left, right, self.cube, self.cube))
self.set_image()
self.layoutWidget2.setGeometry(
QRect(
0,
0,
width - 10,
height - 10,
)
)
self.layoutWidget_2.setGeometry(
QRect(
0,
0,
width - 10,
height - 10,
)
)
class UI(QWidget, GraphFrame):
def __init__(self, parent=None):
QWidget.__init__(self)
GraphFrame.__init__(self)
self.parent = parent
self.init_main_panel()
self.parent.showFullScreen()
# self.parent.show()
self.parent.showMaximized()
def init_main_panel(self) -> None:
# self.parent.setWindowFlags(Qt.WindowType.FramelessWindowHint)
self.parent.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.parent.setAttribute(Qt.WidgetAttribute.WA_AcceptTouchEvents, True)
self.build_panel()
def build_panel(self) -> None:
main_layout = QVBoxLayout()
self.widget_stack = self.gen_stacked_widgets()
main_layout.addWidget(self.widget_stack)
self.setLayout(main_layout)
def get_screen_resolution(self):
return self.parent.app.screens()[0].availableGeometry()
def gen_stacked_widgets(self) -> QStackedWidget:
widgets = QStackedWidget()
widgets.addWidget(
AlignmentWidget(
graph=self.get_graph_frame(),
)
)
return widgets
def paintEvent(self, event) -> None:
painter = QPainter(self)
grad1 = QLinearGradient(0, 0, 0, int(event.rect().height() * 0.22))
grad1.setColorAt(0.0, WHITE)
grad1.setColorAt(1, LIGHT_BLUE)
painter.fillRect(event.rect(), grad1)
self.trigger_paint_event(self)
def display_stack(self, idx: int) -> None:
self.widget_stack.setCurrentIndex(idx)
class AlignmentWidget(QWidget):
def __init__(self, graph, parent=None):
QWidget.__init__(self)
self.parent = parent
self._init_panel(graph)
def _init_panel(self, graph):
"""Initialize the user interface."""
self._build_panel(graph)
def _build_panel(self, graph) -> None:
layout = QHBoxLayout()
vertical_splitter = QSplitter(Qt.Orientation.Vertical)
vertical_splitter.setStyleSheet("QSplitter::handle {image: none;}")
horizontal_splitter = QSplitter(Qt.Orientation.Horizontal)
horizontal_splitter.setStyleSheet("QSplitter::handle {image: none;}")
vertical_splitter.addWidget(graph)
vertical_splitter.setStretchFactor(0, 1)
horizontal_splitter.addWidget(vertical_splitter)
horizontal_splitter.setStretchFactor(0, 1)
layout.addWidget(horizontal_splitter)
self.setLayout(layout)
class AppName(QMainWindow):
def __init__(self):
QMainWindow.__init__(self)
self.gui = None
self.init_gui()
def init_gui(self) -> None:
self.gui = UI(self)
self.setCentralWidget(self.gui)
self.setStyle(QStyleFactory.create("Cleanlooks"))
def main():
app = QApplication(sys.argv)
_app = AppName()
app.exec()
sys.exit(0)
if __name__ == "__main__":
main()
1 Answer 1
The manifest color constants such as LIGHT_BLUE
are very nice, thank you.
isinstance
Clearly this works:
elif (
type(arg) is QHBoxLayout
or type(arg) is QVBoxLayout
or type(arg) is QGridLayout
):
It would be better phrased as
elif isinstance(arg, (QHBoxLayout, QVBoxLayout, QGridLayout)):
We prefer to ask about "inherits from?" rather than "is exactly equal?".
design of Public API
def layout_generator(*args, layout=None) -> QHBoxLayout:
...
if layout is None:
return
Prefer verbs when naming functions.
Here, perhaps generate_layout
.
The docstring is helpful; I thank you for it. It is perhaps a bit wordy, and could be pruned a little while still conveying the same information. No need to restate what the signature has already eloquently told us.
wrong return value
if layout is None:
return
You promised to return a QHBoxLayout
.
Always.
But that doesn't happen here.
That return
makes it look like we only evaluated
the function for side effects.
Prefer an explicit return None
in functions
which caller expects to get something back from.
It feels like caller should be responsible for
passing in a valid layout
, or should not call
this at all if we only have None
.
Consider deleting this if
statement.
Consider removing the =None
default value from the signature,
to make layout
a mandatory parameter.
return
_app = AppName()
app.exec()
sys.exit(0)
It looks like we could simply invoke AppName()
,
without assigning it to _app
.
We evaluate app.exec()
for side effects.
Tearing down the process with sys.exit()
is more than what's needed.
Prefer to simply return from main()
once .exec()
has finished.
optional parent
class AlignmentWidget(QWidget):
def __init__(self, graph, parent=None):
I fail to understand what's going on with self.parent
,
given that the OP code never references it.
Maybe it's an artifact of trimming the original
codebase down to a conveniently small subset for review.
In any event, the optional None
aspect doesn't seem to be helpful.
Better to require that caller always supply a parent
,
or else remove it altogether.
unhelpful docstrings
class GraphFrame:
"""_summary_"""
def __init__(self):
"""_summary_
Returns:
_type_: _description_
"""
I have no idea what you're trying to convey to the Gentle Reader. Recommend you elide both docstrings.
If we're going with an optional type annotation,
it's customary to either write def __init__(self) -> None:
,
or to assume that both humans and mypy
understand what a ctor does.
lint
self.cameraImageDisplay = QWidget(self.Display_Tab_Widget)
Pep-8
asks that you name it self.camera_image_display
.
Similar remarks for layoutWidget2
, Display_Tab_Widget
,
paintEvent
, and so on.
I have no idea what the "fvb" in cameraFVBPlot
denotes.
It merits a # comment
.
Better: give the _fvb()
helper a """docstring""".