1
\$\begingroup\$

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.

  1. 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.
  2. 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.
  3. 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()
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

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""".

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.