src/open_vp_cal/widgets/graph_widget.py (241 lines of code) (raw):

""" Copyright 2024 Netflix Inc. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. Module: line_graph This module uses the pyqtgraph library along with PySide6 to create an interactive line graph widget. """ from typing import Dict, Tuple, List from PySide6.QtWidgets import QVBoxLayout, QWidget, QListView, QHBoxLayout, QAbstractItemView, QCheckBox from PySide6.QtCore import Qt, QStringListModel, Signal, QObject, QSize, Slot, QItemSelectionModel import pyqtgraph as pg from pyqtgraph import exporters from open_vp_cal.core import constants, utils from open_vp_cal.core.constants import Results from open_vp_cal.led_wall_settings import LedWallSettings class LineGraphModel(QObject): """ Class: LineGraphModel This class represents the data model for a line graph. It contains the data points for the graph. """ data_changed = Signal() def __init__(self, data_lines: Dict[str, Dict[str, Tuple]] = None) -> None: """ Initialize the LineGraphModel instance. :param data_lines: A dictionary containing the name of the line, the colour, and the data points. :type data_lines: Dict[str, Dict[str, Tuple]] """ super().__init__() if not data_lines: data_lines = {} self._data_lines = data_lines def get_data(self): """ Retrieve the data of the line graph. :return: The data lines of the line graph. """ return self._data_lines def set_data(self, data_lines): """ Set the data lines of the line graph and emit a signal that the data has changed. :param data_lines: A dictionary containing the name of the line, the colour, and the data points. :return: None """ self._data_lines = data_lines self.data_changed.emit() def clear_data(self) -> None: """ Clears all the data from the model """ self._data_lines = {} class DisplayFiltersWidget(QWidget): """ Class Which contains the display filters for the line graph """ def __init__(self): super().__init__() self.target_display_checkbox = QCheckBox("Target") self.target_display_checkbox.setChecked(True) self.pre_cal_display_checkbox = QCheckBox("PreCal") self.pre_cal_display_checkbox.setChecked(True) self.post_cal_display_checkbox = QCheckBox("PostCal") self.post_cal_display_checkbox.setChecked(True) self.target_display_checkbox.stateChanged.connect(self.plot) self.pre_cal_display_checkbox.stateChanged.connect(self.plot) self.post_cal_display_checkbox.stateChanged.connect(self.plot) self.display_filter_layout = QHBoxLayout() self.display_filter_layout.addWidget(self.target_display_checkbox) self.display_filter_layout.addWidget(self.pre_cal_display_checkbox) self.display_filter_layout.addWidget(self.post_cal_display_checkbox) def plot(self) -> None: """ Needs to be implemented in the subclass and is called each time the display filters are changed """ raise NotImplementedError("This method must be implemented by a subclass.") def get_display_filters(self) -> List: """ Based on the state of the checkboxes, we return a list of the display filters we want to plot Returns: A list of the display filters we want to plot """ display_filters = [] if self.target_display_checkbox.isChecked(): display_filters.append(constants.DisplayFilters.TARGET) if self.pre_cal_display_checkbox.isChecked(): display_filters.append(constants.DisplayFilters.PRE_CAL) if self.post_cal_display_checkbox.isChecked(): display_filters.append(constants.DisplayFilters.POST_CAL) return display_filters class LineGraphView(DisplayFiltersWidget): """ Class: LineGraphView This class represents the view for a line graph. It is responsible for the visual representation of the line graph. """ def __init__(self, model: LineGraphModel, max_scale: int = 1, default_scale: int = 0.125, plot_reference: bool = True, display_plot_labels: bool = False) -> None: """ Initialize the LineGraphView instance. :param model: The data model for the line graph. :type model: LineGraphModel """ super().__init__() self.display_plot_labels = display_plot_labels self.plot_reference = plot_reference self._model = model # Create a PlotWidget instance self.graph_widget = pg.PlotWidget() # Create a QListView instance self.list_view = QListView() self.list_view.setSelectionMode(QAbstractItemView.ExtendedSelection) # Set up the layout self.main_layout = QVBoxLayout() self.horizontal_layout = QHBoxLayout() self.horizontal_layout.addWidget(self.list_view) self.horizontal_layout.addWidget(self.graph_widget) self.main_layout.addLayout(self.horizontal_layout) # Add The Display Filter Layout self.main_layout.addLayout(self.display_filter_layout) self.setLayout(self.main_layout) # Create a QStringListModel instance for the list view self.list_model = QStringListModel() self.list_view.setModel(self.list_model) # Update the list view with the line labels self.update_list_model() # Set the default PlotItem self.plot_item = self.graph_widget.getPlotItem() # Set the maximum range and view range limits self.max_scale = max_scale self.decimal_points = ".4f" self.default_scale = default_scale self.plot_item.setRange(xRange=(0, self.default_scale), yRange=(0, self.default_scale), padding=0) self.plot_item.getViewBox().setLimits(xMin=0, xMax=self.max_scale, yMin=0, yMax=self.max_scale) # Set the labels self.plot_item.setLabels(left='Y', bottom='X') # Plot all lines initially self.plot_initialize() def clear(self) -> None: """ Clears the ui and re-initialises the plot with the reference line """ self.update_list_model() self.plot() def sizeHint(self): return QSize(300, 480) def plot(self) -> list[dict[str, tuple]]: """ Plot the line graph based on the data lines in the model. :return: None """ selected_lines = self.plot_initialize() display_filters = self.get_display_filters() for line in selected_lines: for display_filter in display_filters: if display_filter in line: color, x_scaled, y_scaled = self.extract_scaled_data(line[display_filter]) self.plot_item.plot( x_scaled, y_scaled, pen=pg.mkPen(color=color, width=3), symbol='o', symbolSize=5) self.plot_labels(x_scaled, y_scaled) return selected_lines def extract_scaled_data(self, line) -> Tuple[List[float], List[float], List[float]]: """ Extracts the colour and scaled positional data from the line Args: line: the line we want to extract the data from Returns: a tuple of the colour, x scaled, and y scaled data """ color, x_pos, y_pos = self.get_positions_and_color_from_data(line) x_scaled, y_scaled = self.get_scaled_positions(x_pos, y_pos) return color, x_scaled, y_scaled @staticmethod def get_positions_and_color_from_data(line) -> Tuple[List[float], List[float], List[float]]: """ Gets the positions and colour from the data Args: line: the data representing the line we want to draw Returns: A tuple of the colour, x positions, and y positions """ x_pos, y_pos = line["plot_data"] color = line["line_colour"] return color, x_pos, y_pos def get_scaled_positions(self, x_pos: List[float], y_pos: List[float]) -> Tuple[List[float], List[float]]: """ Scales the positions to the max scale Args: x_pos: the x positions to scale y_pos: the y positions to scale Returns: A tuple of the scaled x positions and scaled y positions """ x_scaled = [x * self.max_scale for x in x_pos] y_scaled = [y * self.max_scale for y in y_pos] return x_scaled, y_scaled def plot_labels(self, x_positions, y_positions) -> None: """ For each data point, we create a new TextItem, position it at the data point's location, and add it to the PlotItem. The TextItem displays the data point's coordinates as a tooltip. Args: x_positions: the list of positions in x we want to place labels y_positions: the list of positions in y we want to place labels """ if self.display_plot_labels: for position in zip(x_positions, y_positions): text = (f"({position[0] / self.max_scale:{self.decimal_points}}," f" {position[1] / self.max_scale:{self.decimal_points}})") text_item = pg.TextItem(text, color=[0, 0, 0]) text_item.setPos(*position) self.plot_item.addItem(text_item) def plot_initialize(self) -> List[Dict[str, Tuple]]: """ Clears the plot and plots the reference line, and prepares the selected lines for plotting Returns: A list of the data ready for plotting based on the selected items in the list_view """ self.plot_item.clear() # Plot the reference line if self.plot_reference: self.plot_reference_line() # Get selected lines selected_indexes = self.list_view.selectedIndexes() selected_lines = [self._model.get_data()[index.data()] for index in selected_indexes] return selected_lines def plot_reference_line(self): """ Plot a white line that runs from 0 to 1 and is always present. :return: None """ self.plot_item.plot([0, self.max_scale], [0, self.max_scale], pen=pg.mkPen(color=[255, 255, 255])) def update_list_model(self) -> None: """ Updates the list widget model with the names of the curves stored in the data model """ self.list_model.setStringList(list(self._model.get_data().keys())) def keyPressEvent(self, event): """ Handle key press events. :param event: The key event. :type event: QKeyEvent :return: None """ if event.key() == Qt.Key_F: # On 'F' key press, fit the view to display all curves self.plot_item.getViewBox().autoRange() def export_plot(self, filename: str) -> None: """ Export the plot to an image file. :param filename: The name of the file to export the plot to. :type filename: str :return: None """ exporters.ImageExporter(self.graph_widget.plotItem).export(filename) class EotfAnalysisView(LineGraphView): """ A view for plotting the EOTF of the LED walls, which we are calibrating, we want to see as linear a line as possible """ def __init__(self, model: LineGraphModel, max_scale: int = constants.PQ.PQ_MAX_NITS, default_scale: int = 1200, plot_reference: bool = True, display_plot_labels: bool = False) -> None: super().__init__(model, max_scale, default_scale, plot_reference, display_plot_labels) self.red_check = QCheckBox('Red') self.green_check = QCheckBox('Green') self.blue_check = QCheckBox('Blue') # Set default state to checked self.red_check.setChecked(True) self.green_check.setChecked(True) self.blue_check.setChecked(True) # Connect checkboxes to the plot method self.red_check.stateChanged.connect(self.plot) self.green_check.stateChanged.connect(self.plot) self.blue_check.stateChanged.connect(self.plot) # Add checkboxes to horizontal layout layout = QHBoxLayout() layout.addWidget(self.red_check) layout.addWidget(self.green_check) layout.addWidget(self.blue_check) self.target_display_checkbox.setVisible(False) self.main_layout.addLayout(layout) def plot(self) -> List[Dict[str, Tuple]]: """ Plot the line graph based on the data lines in the model. :return: None """ selected_lines = self.plot_initialize() display_filters = self.get_display_filters() for item in selected_lines: for display_filter in display_filters: if display_filter not in item: continue if self.red_check.isChecked(): self._plot_channel(item[display_filter]["red"]) if self.green_check.isChecked(): self._plot_channel(item[display_filter]["green"]) if self.blue_check.isChecked(): self._plot_channel(item[display_filter]["blue"]) return selected_lines def _plot_channel(self, line): color, x_pos, y_pos = self.get_positions_and_color_from_data(line) x_scaled = [x * 100 for x in x_pos] y_scaled = [y * 100 for y in y_pos] self.plot_item.plot(x_scaled, y_scaled, pen=pg.mkPen(color=color, width=3), symbol='o', symbolSize=5) self.plot_labels(x_scaled, y_scaled) class BaseController: """ A Base Controller To Hold Common Functions Across All The Controllers """ @staticmethod def get_display_name_and_results(led_wall, pre_calibration) -> (str, dict): """ Helper function to get the display name and results for the LED wall based on if we are doing pre-calibration or post-calibration Args: led_wall: The LED wall we want to get the results for pre_calibration: Whether we want to get the pre-calibration results or the calibration results Returns: The display name and results for the given led wall based on if we are doing pre-calibration or calibration """ if not led_wall.processing_results: return None, None if not pre_calibration: if not led_wall.processing_results.calibration_results: return None, None name = constants.DisplayFilters.POST_CAL results = led_wall.processing_results.calibration_results else: if not led_wall.processing_results.pre_calibration_results: return None, None name = constants.DisplayFilters.PRE_CAL results = led_wall.processing_results.pre_calibration_results return name, results class LineGraphController(BaseController): """ Class: LineGraphController This class represents the controller for a line graph. It coordinates the model and the view. """ def __init__(self, model: LineGraphModel, view: LineGraphView) -> None: """ Initialize the LineGraphController instance. :param model: The data model for the line graph. :type model: LineGraphModel :param view: The view for the line graph. :type view: LineGraphView """ super().__init__() self._model = model self._view = view # Connect the list view's selection changed signal to the view's plot method self._view.list_view.selectionModel().selectionChanged.connect(self._view.plot) # Connect the model's data_changed signal to the view's plot method self._model.data_changed.connect(self._view.plot) self._model.data_changed.connect(self._view.update_list_model) @Slot() def led_wall_selection_changed(self, wall_names): """ Allows us to connect a signal to the controller which in forms us that the LED wall selection has changed. This is provided as a list of led wall names which we then mirror the selection in the bar chart list view. Args: wall_names: names of the LED walls, which have been selected """ selection_model = self._view.list_view.selectionModel() selection_model.clearSelection() indexes = range(self._view.list_model.rowCount()) for index in reversed(indexes): name = self._view.list_model.data(self._view.list_model.index(index), Qt.DisplayRole) for wall_name in wall_names: if wall_name in name: selection_model.select(self._view.list_model.index(index), QItemSelectionModel.Select) def clear_project_settings(self) -> None: """ Clears all the data from the model, and resets the view """ self._model.clear_data() self._view.clear() @staticmethod def get_target_colourspace_for_led_wall(led_wall): """ For the given led wall we get the colour space for the walls target gamut Args: led_wall: Returns: """ return utils.get_target_colourspace_for_led_wall(led_wall) class EOFTAnalysisController(LineGraphController): """ Controller for the EOTF analysis view, which contains specialised functionality for the EOTF analysis model """ def update_model_with_results(self, led_wall: LedWallSettings, pre_calibration=False) -> None: """ Updates the model with the results from the LED wall, to display the EOTF analysis for the wall. We plot the inverse of x & y because the lut values are doing the opposite of what the screen is doing Args: led_wall: the LED wall, which we are updating the model with the results from pre_calibration: Whether we want to get the pre-calibration results or the calibration results """ results_name, results = self.get_display_name_and_results(led_wall, pre_calibration) if not results_name or not results: return plot_data_red_y = results[Results.REFERENCE_EOTF_RAMP] plot_data_green_y = results[Results.REFERENCE_EOTF_RAMP] plot_data_blue_y = results[Results.REFERENCE_EOTF_RAMP] data_source = Results.POST_EOTF_RAMPS if pre_calibration: data_source = Results.PRE_EOTF_RAMPS plot_data_red_x = [max(0, primary[0]) for primary in results[data_source]] plot_data_green_x = [max(0, primary[1]) for primary in results[data_source]] plot_data_blue_x = [max(0, primary[2]) for primary in results[data_source]] data_lines = self._model.get_data() if led_wall.name not in data_lines: data_lines[led_wall.name] = {} if results_name not in data_lines[led_wall.name]: data_lines[led_wall.name][results_name] = {} # We plot the inverse of the data data_lines[led_wall.name][f"{results_name}"]["red"] = { "line_colour": [255, 0, 0], "plot_data": (plot_data_red_y, plot_data_red_x) } data_lines[led_wall.name][f"{results_name}"]["green"] = { "line_colour": [0, 255, 0], "plot_data": (plot_data_green_y, plot_data_green_x) } data_lines[led_wall.name][f"{results_name}"]["blue"] = { "line_colour": [0, 0, 255], "plot_data": (plot_data_blue_y, plot_data_blue_x) } self._model.set_data(data_lines)