# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, TypedDict
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QCheckBox, QVBoxLayout, QFileDialog, QPushButton, QLabel, QAbstractItemView, QHeaderView, \
QTabWidget, QComboBox, QSpinBox, QTableWidget, QTableWidgetItem, QGroupBox, QActionGroup, QAction, QDoubleSpinBox
from PyQt5.QtCore import QSignalBlocker, Qt
from mantidimaging.core.utility import finder
from mantidimaging.gui.mvp_base import BaseMainWindowView
from mantidimaging.gui.widgets.dataset_selector import DatasetSelectorWidgetView
from .model import ROI_RITS, ToFUnitMode
from .presenter import SpectrumViewerWindowPresenter, ExportMode
from mantidimaging.gui.widgets import RemovableRowTableView
from .spectrum_widget import SpectrumWidget
from mantidimaging.gui.windows.spectrum_viewer.roi_table_model import TableModel
from mantidimaging.core.utility.sensible_roi import SensibleROI
import numpy as np
if TYPE_CHECKING:
from mantidimaging.gui.windows.main import MainWindowView # noqa:F401 # pragma: no cover
from uuid import UUID
[docs]
class AllowedModesTypedDict(TypedDict):
mode: ToFUnitMode
label: str
[docs]
class SpectrumViewerWindowView(BaseMainWindowView):
tableView: RemovableRowTableView
sampleStackSelector: DatasetSelectorWidgetView
normaliseStackSelector: DatasetSelectorWidgetView
normaliseCheckBox: QCheckBox
imageLayout: QVBoxLayout
exportButton: QPushButton
exportTabs: QTabWidget
normaliseErrorIcon: QLabel
_current_dataset_id: UUID | None
normalise_error_issue: str = ""
image_output_mode_combobox: QComboBox
transmission_error_mode_combobox: QComboBox
bin_size_spinBox: QSpinBox
bin_step_spinBox: QSpinBox
roiPropertiesTableWidget: QTableWidget
roiPropertiesGroupBox: QGroupBox
last_clicked_roi: str
spectrum_widget: SpectrumWidget
number_roi_properties_procced: int = 0
tofPropertiesGroupBox: QGroupBox
flightPathSpinBox: QDoubleSpinBox
timeDelaySpinBox: QDoubleSpinBox
def __init__(self, main_window: MainWindowView):
super().__init__(None, 'gui/ui/spectrum_viewer.ui')
self.main_window = main_window
icon_path = finder.ROOT_PATH + "/gui/ui/images/exclamation-triangle-red.png"
self.normalise_error_icon_pixmap = QPixmap(icon_path)
self.selected_row: int = 0
self.current_roi: str = ""
self.selected_row_data: list | None = None
self.roiPropertiesSpinBoxes: dict[str, QSpinBox] = {}
self.roiPropertiesLabels: dict[str, QLabel] = {}
self.old_table_names: list = []
self.presenter = SpectrumViewerWindowPresenter(self, main_window)
self.spectrum_widget = SpectrumWidget()
self.spectrum = self.spectrum_widget.spectrum_plot_widget
self.imageLayout.addWidget(self.spectrum_widget)
self.spectrum.range_changed.connect(self.presenter.handle_range_slide_moved)
self.spectrum_widget.roi_clicked.connect(self.presenter.handle_roi_clicked)
self.spectrum_widget.roi_changed.connect(self.presenter.handle_roi_moved)
self.spectrum_widget.roiColorChangeRequested.connect(self.presenter.change_roi_colour)
self.spectrum_right_click_menu = self.spectrum_widget.spectrum_plot_widget.spectrum.vb.menu
self.units_menu = self.spectrum_right_click_menu.addMenu("Units")
self.tof_mode_select_group = QActionGroup(self)
self.allowed_modes: dict[str, AllowedModesTypedDict] = {
"Image Index": {
"mode": ToFUnitMode.IMAGE_NUMBER,
"label": "Image index"
},
"Wavelength": {
"mode": ToFUnitMode.WAVELENGTH,
"label": "Neutron Wavelength (\u212B)"
},
"Energy": {
"mode": ToFUnitMode.ENERGY,
"label": "Neutron Energy (MeV)"
},
"Time of Flight (\u03BCs)": {
"mode": ToFUnitMode.TOF_US,
"label": "Time of Flight (\u03BCs)"
}
}
for mode in self.allowed_modes.keys():
action = QAction(mode, self.tof_mode_select_group)
action.setCheckable(True)
action.setObjectName(mode)
self.units_menu.addAction(action)
action.triggered.connect(self.presenter.handle_tof_unit_change)
if mode == "Image Index":
action.setChecked(True)
if self.presenter.model.tof_data is None:
self.tof_mode_select_group.setEnabled(False)
self._current_dataset_id = None
self.sampleStackSelector.stack_selected_uuid.connect(self.presenter.handle_sample_change)
self.sampleStackSelector.stack_selected_uuid.connect(self.presenter.handle_button_enabled)
self.normaliseStackSelector.stack_selected_uuid.connect(self.presenter.handle_normalise_stack_change)
self.normaliseStackSelector.stack_selected_uuid.connect(self.presenter.handle_button_enabled)
self.normaliseCheckBox.stateChanged.connect(self.normaliseStackSelector.setEnabled)
self.normaliseCheckBox.stateChanged.connect(self.presenter.handle_enable_normalised)
self.normaliseCheckBox.stateChanged.connect(self.presenter.handle_button_enabled)
self.exportTabs.currentChanged.connect(self.presenter.handle_export_tab_change)
self.image_output_mode_combobox.currentTextChanged.connect(self.set_binning_visibility)
self.set_binning_visibility()
# ROI action buttons
self.addBtn.clicked.connect(self.set_new_roi)
self.removeBtn.clicked.connect(self.remove_roi)
self._configure_dropdown(self.sampleStackSelector)
self._configure_dropdown(self.normaliseStackSelector)
self.sampleStackSelector.select_eligible_stack()
self.try_to_select_relevant_normalise_stack("Flat")
self.presenter.handle_tof_unit_change()
self.exportButton.clicked.connect(self.presenter.handle_export_csv)
self.exportButtonRITS.clicked.connect(self.presenter.handle_rits_export)
# Point table
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tableView.setSelectionMode(QAbstractItemView.SingleSelection)
self.tableView.setAlternatingRowColors(True)
self.tableView.clicked.connect(self.handle_table_click)
# Roi Prop table
self.roi_table_properties = ["Top", "Bottom", "Left", "Right"]
self.roi_table_properties_secondary = ["Width", "Height"]
self.roiPropertiesTableWidget.setColumnCount(3)
self.roiPropertiesTableWidget.setRowCount(3)
self.roiPropertiesTableWidget.setColumnWidth(0, 80)
self.roiPropertiesTableWidget.setColumnWidth(1, 50)
self.roiPropertiesTableWidget.setColumnWidth(2, 50)
for prop in self.roi_table_properties:
spin_box = QSpinBox()
if prop == "Top" or prop == "Bottom":
spin_box.setMaximum(self.spectrum_widget.image.image_data.shape[0])
if prop == "Left" or prop == "Right":
spin_box.setMaximum(self.spectrum_widget.image.image_data.shape[1])
spin_box.valueChanged.connect(self.adjust_roi)
self.roiPropertiesSpinBoxes[prop] = spin_box
for prop in self.roi_table_properties_secondary:
label = QLabel()
self.roiPropertiesLabels[prop] = label
self.roiPropertiesTableWidget.horizontalHeader().hide()
self.roiPropertiesTableWidget.verticalHeader().hide()
self.roiPropertiesTableWidget.setShowGrid(False)
roiPropertiesTableText = ["x1, x2", "y1, y2", "Size"]
self.roiPropertiesTableTextDict = {}
for text in roiPropertiesTableText:
item = QTableWidgetItem(text)
item.setFlags(Qt.ItemIsSelectable)
self.roiPropertiesTableTextDict[text] = item
self.roiPropertiesTableWidget.setItem(0, 0, self.roiPropertiesTableTextDict["x1, x2"])
self.roiPropertiesTableWidget.setCellWidget(0, 1, self.roiPropertiesSpinBoxes["Left"])
self.roiPropertiesTableWidget.setCellWidget(0, 2, self.roiPropertiesSpinBoxes["Right"])
self.roiPropertiesTableWidget.setItem(1, 0, self.roiPropertiesTableTextDict["y1, y2"])
self.roiPropertiesTableWidget.setCellWidget(1, 1, self.roiPropertiesSpinBoxes["Top"])
self.roiPropertiesTableWidget.setCellWidget(1, 2, self.roiPropertiesSpinBoxes["Bottom"])
self.roiPropertiesTableWidget.setItem(2, 0, self.roiPropertiesTableTextDict["Size"])
self.roiPropertiesTableWidget.setCellWidget(2, 1, self.roiPropertiesLabels["Width"])
self.roiPropertiesTableWidget.setCellWidget(2, 2, self.roiPropertiesLabels["Height"])
self.spectrum_widget.roi_changed.connect(self.set_roi_properties)
_ = self.roi_table_model # Initialise model
self.current_roi = self.last_clicked_roi = self.roi_table_model.roi_names()[0]
self.set_roi_properties()
self.flightPathSpinBox.setMinimum(0)
self.flightPathSpinBox.setMaximum(1e10)
self.flightPathSpinBox.setValue(56)
self.flightPathSpinBox.setSuffix(" m")
self.timeDelaySpinBox.setMaximum(1e10)
self.timeDelaySpinBox.setSuffix(" \u03BCs")
self.flightPathSpinBox.valueChanged.connect(self.presenter.handle_flight_path_change)
self.timeDelaySpinBox.valueChanged.connect(self.presenter.handle_time_delay_change)
def on_row_change(item, _) -> None:
"""
Handle cell change in table view and update selected ROI and
toggle visibility of action buttons
@param item: item in table
"""
selected_row_data = self.roi_table_model.row_data(item.row())
self.selected_row = item.row()
self.current_roi = selected_row_data[0]
self.set_roi_properties()
self.tableView.selectionModel().currentRowChanged.connect(on_row_change)
def on_data_in_table_change() -> None:
"""
Check if an ROI name has changed in the table or if the visibility of an ROI has changed.
If the ROI name has changed, update the ROI name in the spectrum widget.
If the visibility of an ROI has changed, update the visibility of the ROI in the spectrum widget.
"""
selected_row_data = self.roi_table_model.row_data(self.selected_row)
if selected_row_data[0].lower() not in ["", " ", "all"] and selected_row_data[0] != self.current_roi:
if selected_row_data[0] in self.presenter.get_roi_names():
selected_row_data[0] = self.old_table_names[self.selected_row]
self.current_roi = selected_row_data[0]
self.last_clicked_roi = self.current_roi
else:
self.presenter.rename_roi(self.current_roi, selected_row_data[0])
self.current_roi = selected_row_data[0]
self.last_clicked_roi = self.current_roi
self.set_roi_properties()
else:
selected_row_data[0] = self.old_table_names[self.selected_row]
self.set_old_table_names()
self.on_visibility_change()
return
self.roi_table_model.dataChanged.connect(on_data_in_table_change)
header = self.tableView.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.Stretch)
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
[docs]
def show(self):
super().show()
self.activateWindow()
[docs]
def cleanup(self):
self.sampleStackSelector.unsubscribe_from_main_window()
self.normaliseStackSelector.unsubscribe_from_main_window()
self.main_window.spectrum_viewer = None
[docs]
def on_visibility_change(self) -> None:
"""
When the visibility of an ROI is changed, update the visibility of the ROI in the spectrum widget
"""
if self.presenter.export_mode == ExportMode.ROI_MODE:
if self.current_roi in self.old_table_names and self.last_clicked_roi in self.old_table_names:
pass
elif self.current_roi == ROI_RITS and self.last_clicked_roi in self.old_table_names:
self.current_roi = self.last_clicked_roi
elif self.current_roi == ROI_RITS and self.last_clicked_roi not in self.old_table_names:
self.current_roi = self.roi_table_model.row_data(self.selected_row)[0]
else:
self.last_clicked_roi = self.current_roi
if self.roi_table_model.rowCount() == 0:
self.disable_roi_properties()
if not self.roi_table_model.rowCount() == 0:
self.set_roi_properties()
for roi_name, _, roi_visible in self.roi_table_model:
if roi_visible is False:
self.set_roi_alpha(0, roi_name)
else:
self.set_roi_alpha(255, roi_name)
self.presenter.redraw_spectrum(roi_name)
else:
for roi_name, _, _ in self.roi_table_model:
self.set_roi_alpha(0, roi_name)
if self.presenter.export_mode == ExportMode.IMAGE_MODE:
self.set_roi_alpha(255, ROI_RITS)
self.presenter.redraw_spectrum(ROI_RITS)
if self.current_roi != ROI_RITS:
self.last_clicked_roi = self.current_roi
self.current_roi = ROI_RITS
for _, spinbox in self.roiPropertiesSpinBoxes.items():
spinbox.setEnabled(True)
self.set_roi_properties()
else:
self.set_roi_alpha(0, ROI_RITS)
@property
def roi_table_model(self) -> TableModel:
if self.tableView.model() is None:
mdl = TableModel()
self.tableView.setModel(mdl)
return self.tableView.model()
@property
def current_dataset_id(self) -> UUID | None:
return self._current_dataset_id
@current_dataset_id.setter
def current_dataset_id(self, uuid: UUID | None) -> None:
self._current_dataset_id = uuid
def _configure_dropdown(self, selector: DatasetSelectorWidgetView) -> None:
selector.presenter.show_stacks = True
selector.subscribe_to_main_window(self.main_window)
[docs]
def try_to_select_relevant_normalise_stack(self, name: str) -> None:
self.normaliseStackSelector.try_to_select_relevant_stack(name)
[docs]
def get_normalise_stack(self) -> UUID | None:
return self.normaliseStackSelector.current()
[docs]
def get_csv_filename(self) -> Path | None:
path = QFileDialog.getSaveFileName(self, "Save CSV file", "", "CSV file (*.csv)")[0]
if path:
return Path(path)
else:
return None
[docs]
def get_rits_export_directory(self) -> Path | None:
"""
Get the path to save the RITS file too
"""
path = QFileDialog.getExistingDirectory(self, "Select Directory", "", QFileDialog.ShowDirsOnly)
if path:
return Path(path)
else:
return None
[docs]
def get_rits_export_filename(self) -> Path | None:
"""
Get the path to save the RITS file too
"""
path = QFileDialog.getSaveFileName(self, "Save DAT file", "", "DAT file (*.dat)")[0]
if path:
return Path(path)
else:
return None
[docs]
def set_image(self, image_data: np.ndarray, autoLevels: bool = True):
self.spectrum_widget.image.setImage(image_data, autoLevels=autoLevels)
[docs]
def set_spectrum(self, name: str, spectrum_data: np.ndarray):
"""
Try to set the spectrum data for a given ROI assuming the
roi may not exist in the spectrum widget yet depending on when method is called
"""
self.spectrum_widget.spectrum_data_dict[name] = spectrum_data
self.spectrum_widget.spectrum.clearPlots()
self.show_visible_spectrums()
[docs]
def clear(self) -> None:
self.spectrum_widget.spectrum_data_dict = {}
self.spectrum_widget.image.setImage(np.zeros((1, 1)))
self.spectrum_widget.spectrum.clearPlots()
[docs]
def auto_range_image(self):
self.spectrum_widget.image.vb.autoRange()
[docs]
def set_normalise_error(self, norm_issue: str):
self.normalise_error_issue = norm_issue
self.display_normalise_error()
[docs]
def display_normalise_error(self):
if self.normalise_error_issue and self.normalisation_enabled():
self.normaliseErrorIcon.setPixmap(self.normalise_error_icon_pixmap)
self.normaliseErrorIcon.setToolTip(self.normalise_error_issue)
else:
self.normaliseErrorIcon.setPixmap(QPixmap())
self.normaliseErrorIcon.setToolTip("")
[docs]
def normalisation_enabled(self):
return self.normaliseCheckBox.isChecked()
[docs]
def set_new_roi(self) -> None:
"""
Set a new ROI on the image
"""
self.presenter.do_add_roi()
for _, spinbox in self.roiPropertiesSpinBoxes.items():
if not spinbox.isEnabled():
spinbox.setEnabled(True)
self.set_roi_properties()
[docs]
def handle_table_click(self, index):
if index.isValid() and index.column() == 1:
roi_name = self.roi_table_model.index(index.row(), 0).data()
self.set_spectum_roi_color(roi_name)
[docs]
def set_spectum_roi_color(self, roi_name: str) -> None:
spectrum_roi = self.spectrum_widget.roi_dict[roi_name]
spectrum_roi.change_color_action.trigger()
[docs]
def update_roi_color(self, roi_name: str, new_color: tuple) -> None:
"""
Finds ROI by name in table and updates colour.
@param roi_name: Name of the ROI to update.
@param new_color: The new color for the ROI in (R, G, B) format.
"""
row = self.find_row_for_roi(roi_name)
if row is not None:
self.roi_table_model.update_color(row, new_color)
[docs]
def find_row_for_roi(self, roi_name: str) -> int | None:
"""
Returns row index for ROI name, or None if not found.
@param roi_name: Name ROI find.
@return: Row index ROI or None.
"""
for row in range(self.roi_table_model.rowCount()):
if self.roi_table_model.index(row, 0).data() == roi_name:
return row
return None
[docs]
def set_roi_alpha(self, alpha: float, roi_name: str) -> None:
"""
Set the alpha value for the selected ROI and update the spectrum to reflect the change.
A check is made on the spectrum to see if it exists as it may not have been created yet.
@param alpha: The alpha value
"""
self.spectrum_widget.set_roi_alpha(roi_name, alpha)
if alpha == 0:
self.spectrum_widget.spectrum_data_dict[roi_name] = None
self.spectrum_widget.spectrum.clearPlots()
self.spectrum_widget.spectrum.update()
self.show_visible_spectrums()
[docs]
def show_visible_spectrums(self):
for key, value in self.spectrum_widget.spectrum_data_dict.items():
if value is not None and key in self.spectrum_widget.roi_dict:
self.spectrum_widget.spectrum.plot(self.presenter.model.tof_data,
value,
name=key,
pen=self.spectrum_widget.roi_dict[key].colour)
[docs]
def add_roi_table_row(self, name: str, colour: tuple[int, int, int]):
"""
Add a new row to the ROI table
@param name: The name of the ROI
@param colour: The colour of the ROI
"""
self.roi_table_model.appendNewRow(name, colour, True)
self.selected_row = self.roi_table_model.rowCount() - 1
self.tableView.selectRow(self.selected_row)
self.current_roi = name
self.removeBtn.setEnabled(True)
self.set_old_table_names()
[docs]
def remove_roi(self) -> None:
"""
Clear the selected ROI in the table view
"""
selected_row = self.roi_table_model.row_data(self.selected_row)
if selected_row:
self.roi_table_model.remove_row(self.selected_row)
self.presenter.do_remove_roi(selected_row[0])
self.spectrum_widget.spectrum_data_dict.pop(selected_row[0])
self.spectrum_widget.spectrum.removeItem(selected_row[0])
self.presenter.handle_roi_moved()
self.selected_row = 0
self.tableView.selectRow(0)
if self.roi_table_model.rowCount() == 0:
self.removeBtn.setEnabled(False)
self.disable_roi_properties()
else:
self.set_old_table_names()
self.current_roi = self.roi_table_model.row_data(self.selected_row)[0]
self.set_roi_properties()
[docs]
def clear_all_rois(self) -> None:
"""
Clear all ROIs from the table view
"""
self.roi_table_model.clear_table()
self.spectrum_widget.spectrum_data_dict = {}
self.spectrum_widget.spectrum.clearPlots()
self.removeBtn.setEnabled(False)
self.disable_roi_properties()
@property
def transmission_error_mode(self) -> str:
return self.transmission_error_mode_combobox.currentText()
@property
def image_output_mode(self) -> str:
return self.image_output_mode_combobox.currentText()
@property
def bin_size(self) -> int:
return self.bin_size_spinBox.value()
@property
def bin_step(self) -> int:
return self.bin_step_spinBox.value()
[docs]
def set_binning_visibility(self) -> None:
hide_binning = self.image_output_mode != "2D Binned"
self.bin_size_label.setHidden(hide_binning)
self.bin_size_spinBox.setHidden(hide_binning)
self.bin_step_label.setHidden(hide_binning)
self.bin_step_spinBox.setHidden(hide_binning)
[docs]
def set_roi_properties(self) -> None:
if self.presenter.export_mode == ExportMode.IMAGE_MODE:
self.current_roi = ROI_RITS
if self.current_roi not in self.presenter.model.get_list_of_roi_names() or not self.roiPropertiesSpinBoxes:
return
else:
current_roi = self.presenter.model.get_roi(self.current_roi)
self.roiPropertiesGroupBox.setTitle(f"Roi Properties: {self.current_roi}")
roi_iter_order = ["Left", "Top", "Right", "Bottom"]
for row, pos in enumerate(current_roi):
with QSignalBlocker(self.roiPropertiesSpinBoxes[roi_iter_order[row]]):
self.roiPropertiesSpinBoxes[roi_iter_order[row]].setValue(pos)
self.set_roi_spinbox_ranges()
self.presenter.redraw_spectrum(self.current_roi)
self.roiPropertiesLabels["Width"].setText(str(current_roi.width))
self.roiPropertiesLabels["Height"].setText(str(current_roi.height))
for spinbox in self.roiPropertiesSpinBoxes.values():
spinbox.setEnabled(True)
[docs]
def adjust_roi(self) -> None:
roi_iter_order = ["Left", "Top", "Right", "Bottom"]
new_points = [self.roiPropertiesSpinBoxes[prop].value() for prop in roi_iter_order]
new_roi = SensibleROI().from_list(new_points)
self.presenter.model.set_roi(self.current_roi, new_roi)
self.spectrum_widget.adjust_roi(new_roi, self.current_roi)
[docs]
def set_roi_spinbox_ranges(self):
self.roiPropertiesSpinBoxes["Left"].setMaximum(self.roiPropertiesSpinBoxes["Right"].value() - 1)
self.roiPropertiesSpinBoxes["Right"].setMinimum(self.roiPropertiesSpinBoxes["Left"].value() + 1)
self.roiPropertiesSpinBoxes["Top"].setMaximum(self.roiPropertiesSpinBoxes["Bottom"].value() - 1)
self.roiPropertiesSpinBoxes["Bottom"].setMinimum(self.roiPropertiesSpinBoxes["Top"].value() + 1)
[docs]
def disable_roi_properties(self):
self.roiPropertiesGroupBox.setTitle("Roi Properties: None selected")
self.last_clicked_roi = "roi"
for _, spinbox in self.roiPropertiesSpinBoxes.items():
with QSignalBlocker(spinbox):
spinbox.setMinimum(0)
spinbox.setValue(0)
spinbox.setDisabled(True)
for _, label in self.roiPropertiesLabels.items():
label.setText("0")
[docs]
def get_roi_properties_spinboxes(self):
return self.roiPropertiesSpinBoxes
[docs]
def set_old_table_names(self):
self.old_table_names = self.presenter.get_roi_names()
if 'all' in self.old_table_names:
self.old_table_names.remove('all')
if 'rits_roi' in self.old_table_names:
self.old_table_names.remove('rits_roi')