# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations
import uuid
from logging import getLogger
from typing import TYPE_CHECKING
from uuid import UUID
import numpy
from PyQt5.QtWidgets import (QAbstractItemView, QComboBox, QDoubleSpinBox, QInputDialog, QPushButton, QSpinBox,
QVBoxLayout, QWidget, QTextEdit, QLabel, QApplication, QStyle, QCheckBox)
from PyQt5.QtCore import QSignalBlocker
from mantidimaging.core.data import ImageStack
from mantidimaging.core.net.help_pages import SECTION_USER_GUIDE, open_help_webpage
from mantidimaging.core.utility.cuda_check import CudaChecker
from mantidimaging.core.utility.data_containers import Degrees, ReconstructionParameters, ScalarCoR, Slope
from mantidimaging.gui.mvp_base import BaseMainWindowView
from mantidimaging.gui.utility.qt_helpers import INPUT_DIALOG_FLAGS
from mantidimaging.gui.widgets import RemovableRowTableView
from mantidimaging.gui.widgets.palette_changer.view import PaletteChangerView
from mantidimaging.gui.widgets.dataset_selector import DatasetSelectorWidgetView
from mantidimaging.gui.windows.recon.image_view import ReconImagesView
from mantidimaging.gui.windows.recon.point_table_model import Column, CorTiltPointQtModel
from mantidimaging.gui.windows.recon.presenter import AutoCorMethod
from mantidimaging.gui.windows.recon.presenter import Notifications as PresN
from mantidimaging.gui.windows.recon.presenter import ReconstructWindowPresenter
if TYPE_CHECKING:
from mantidimaging.gui.windows.main import MainWindowView # noqa:F401 # pragma: no cover
LOG = getLogger(__name__)
[docs]
class ReconstructWindowView(BaseMainWindowView):
tableView: RemovableRowTableView
imageLayout: QVBoxLayout
inputTab: QWidget
calculateCors: QPushButton
resultTab: QWidget
addBtn: QPushButton
refineCorBtn: QPushButton
refineIterationsBtn: QPushButton
clearAllBtn: QPushButton
removeBtn: QPushButton
correlateBtn: QPushButton
minimiseBtn: QPushButton
corHelpButton: QPushButton
reconTab: QWidget
# part of the Reconstruct tab
algorithmName: QComboBox
filterName: QComboBox
numIter: QSpinBox
maxProjAngle: QDoubleSpinBox
pixelSize: QDoubleSpinBox
alphaSpinBox: QDoubleSpinBox
nonNegativeCheckBox: QCheckBox
stochasticCheckBox: QCheckBox
subsetsSpinBox: QSpinBox
resultCor: QDoubleSpinBox
resultTilt: QDoubleSpinBox
resultSlope: QDoubleSpinBox
reconstructVolume: QPushButton
reconstructSlice: QPushButton
lbhc_enabled: QCheckBox
lbhc_a0: QDoubleSpinBox
lbhc_a1: QDoubleSpinBox
lbhc_a2: QDoubleSpinBox
lbhc_a3: QDoubleSpinBox
statusMessageTextEdit: QTextEdit
messageIcon: QLabel
changeColourPaletteButton: QPushButton
change_colour_palette_dialog: PaletteChangerView | None = None
stackSelector: DatasetSelectorWidgetView
def __init__(self, main_window: MainWindowView):
super().__init__(None, 'gui/ui/recon_window.ui')
self.main_window = main_window
self.presenter = ReconstructWindowPresenter(self, main_window)
self.algorithmName.insertItem(1, "FBP_CUDA")
self.algorithmName.insertItem(2, "SIRT_CUDA")
self.algorithmName.insertItem(3, "CIL: PDHG-TV")
self.algorithmName.setCurrentIndex(1)
if not CudaChecker().cuda_is_present():
self.algorithmName.model().item(1).setEnabled(False)
self.algorithmName.model().item(2).setEnabled(False)
self.algorithmName.model().item(3).setEnabled(False)
self.algorithmName.setCurrentIndex(0)
self.algorithmName.setEnabled(True)
self.update_recon_hist_needed = False
self.stackSelector.presenter.show_stacks = True
self.stackSelector.stack_selected_uuid.connect(self.presenter.set_stack_uuid)
# Handle preview image selection
self.previewProjectionIndex.valueChanged[int].connect(self.presenter.set_preview_projection_idx)
self.previewSliceIndex.valueChanged[int].connect(self.presenter.set_preview_slice_idx)
self.image_view = ReconImagesView(self)
self.imageLayout.addWidget(self.image_view)
self.image_view.sigSliceIndexChanged.connect(self.presenter.set_preview_slice_idx)
# Point table
self.tableView.horizontalHeader().setStretchLastSection(True)
self.tableView.setSelectionBehavior(QAbstractItemView.SelectRows)
self.tableView.setSelectionMode(QAbstractItemView.SingleSelection)
self.cor_table_model.rowsInserted.connect(self.on_table_row_count_change) # type: ignore
self.cor_table_model.rowsRemoved.connect(self.on_table_row_count_change) # type: ignore
self.cor_table_model.modelReset.connect(self.on_table_row_count_change) # type: ignore
# Update previews when data in table changes
def on_data_change(tl, br, _):
# Should we auto fit on data change?
if self.tableView.model().num_points >= 2:
self.presenter.notify(PresN.COR_FIT)
self.presenter.notify(PresN.UPDATE_PROJECTION)
if tl == br and tl.column() == Column.CENTRE_OF_ROTATION.value:
mdl = self.tableView.model()
slice_idx = mdl.data(mdl.index(tl.row(), Column.SLICE_INDEX.value))
self.presenter.notify(PresN.RECONSTRUCT_USER_CLICK, slice_idx)
self.cor_table_model.dataChanged.connect(on_data_change)
self.clearAllBtn.clicked.connect(lambda: self.presenter.notify(PresN.CLEAR_ALL_CORS))
self.removeBtn.clicked.connect(lambda: self.presenter.notify(PresN.REMOVE_SELECTED_COR))
self.addBtn.clicked.connect(lambda: self.presenter.notify(PresN.ADD_COR))
self.refineCorBtn.clicked.connect(lambda: self.presenter.notify(PresN.REFINE_COR))
self.refineIterationsBtn.clicked.connect(lambda: self.presenter.notify(PresN.REFINE_ITERS))
self.calculateCors.clicked.connect(lambda: self.presenter.notify(PresN.CALCULATE_CORS_FROM_MANUAL_TILT))
self.reconstructVolume.clicked.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_VOLUME))
self.reconstructSlice.clicked.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_STACK_SLICE))
self.correlateBtn.clicked.connect(lambda: self.presenter.notify(PresN.AUTO_FIND_COR_CORRELATE))
self.minimiseBtn.clicked.connect(lambda: self.presenter.notify(PresN.AUTO_FIND_COR_MINIMISE))
self.changeColourPaletteButton.clicked.connect(self.on_change_colour_palette)
def on_row_change(item, _):
"""
Handles setting preview slice index from the currently selected row
in the manual COR table.
"""
if item.isValid():
row_data = item.model().point(item.row())
slice_idx = row_data.slice_index
cor = row_data.cor
self.presenter.set_row(item.row())
self.presenter.set_last_cor(cor)
self.presenter.set_preview_slice_idx(slice_idx)
self.image_view.slice_line.setPos(slice_idx)
self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE)
# Only allow buttons which act on selected row to be clicked when a valid
# row is selected
for button in [self.refineCorBtn, self.removeBtn]:
button.setEnabled(item.isValid())
self.tableView.selectionModel().currentRowChanged.connect(on_row_change) # type: ignore
# Update initial UI state
self.on_table_row_count_change()
self.stackSelector.subscribe_to_main_window(main_window)
self.stackSelector.stack_selected_uuid.connect(self.check_stack_for_invalid_180_deg_proj)
self.stackSelector.select_eligible_stack()
self.maxProjAngle.valueChanged.connect(
lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE)) # type: ignore
self.algorithmName.currentTextChanged.connect(
lambda: self.presenter.notify(PresN.ALGORITHM_CHANGED)) # type: ignore
self.presenter.notify(PresN.ALGORITHM_CHANGED)
self.filterName.currentTextChanged.connect(
lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE)) # type: ignore
self.numIter.valueChanged.connect(
lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE)) # type: ignore
self.pixelSize.valueChanged.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
self.alphaSpinBox.valueChanged.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
self.nonNegativeCheckBox.stateChanged.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
self.stochasticCheckBox.stateChanged.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
self.subsetsSpinBox.valueChanged.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
self.regPercentSpinBox.valueChanged.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
self.reconHelpButton.clicked.connect(lambda: self.open_help_webpage("reconstructions/index"))
self.corHelpButton.clicked.connect(lambda: self.open_help_webpage("reconstructions/center_of_rotation"))
self.stochasticCheckBox.stateChanged.connect(self.subsetsSpinBox.setEnabled)
self.stochasticCheckBox.stateChanged.connect(self.subsetsLabel.setEnabled)
self.stochasticCheckBox.stateChanged.connect(self.regPercentSpinBox.setEnabled)
self.stochasticCheckBox.stateChanged.connect(self.regPercentLabel.setEnabled)
self.previewAutoUpdate.stateChanged.connect(self.handle_auto_update_preview_selection)
self.updatePreviewButton.clicked.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_USER_CLICK))
for spinbox in [self.lbhc_a0, self.lbhc_a1, self.lbhc_a2, self.lbhc_a3]:
spinbox.valueChanged.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
self.lbhc_enabled.toggled.connect(spinbox.setEnabled)
self.lbhc_enabled.toggled.connect(lambda: self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE))
[docs]
def showEvent(self, e):
super().showEvent(e)
if self.presenter.stack_selection_change_pending:
self.presenter.set_stack_uuid(self.stackSelector.current())
self.presenter.stack_selection_change_pending = False
self.presenter.stack_changed_pending = False
elif self.presenter.stack_changed_pending:
self.presenter.handle_stack_changed()
self.presenter.stack_changed_pending = False
self.activateWindow()
[docs]
def closeEvent(self, e):
if self.presenter.recon_is_running:
e.ignore()
else:
self.hide()
[docs]
def check_stack_for_invalid_180_deg_proj(self, uuid: UUID):
try:
selected_images = self.main_window.get_images_from_stack_uuid(uuid)
except KeyError:
# Likely due to stack no longer existing, e.g. when all stacks closed
LOG.debug("UUID did not match open stack")
return
if selected_images.has_proj180deg() and \
not self.presenter.proj_180_degree_shape_matches_images(selected_images):
self.show_error_dialog(
"The shapes of the selected stack and it's 180 degree projections do not match! This is "
"going to cause an error when calculating the COR. Fix the shape before continuing!")
[docs]
def remove_selected_cor(self) -> None:
return self.tableView.removeSelectedRows()
[docs]
def clear_cor_table(self) -> None:
return self.cor_table_model.removeAllRows()
[docs]
def cleanup(self) -> None:
self.stackSelector.unsubscribe_from_main_window()
self.image_view.cleanup()
self.main_window.recon = None
@property
def cor_table_model(self) -> CorTiltPointQtModel:
if self.tableView.model() is None:
mdl = CorTiltPointQtModel(self.tableView)
self.tableView.setModel(mdl)
return self.tableView.model() # type: ignore
[docs]
def set_results(self, cor: ScalarCoR, tilt: Degrees, slope: Slope) -> None:
"""
Sets the numerical COR and tilt angle results.
"""
self.rotation_centre = cor.value
self.tilt = tilt.value
self.slope = slope.value
self.image_view.set_tilt(tilt)
[docs]
def update_projection(self, image_data, preview_slice_index: int, tilt_angle: Degrees | None):
"""
Updates the preview projection image and associated annotations.
Region of interest sets the bounds of the image axes, it does not
resize the data itself. The advantage of this is that the coordinates
do not change.
:param image_data: Projection image data (single/2D image)
:param preview_slice_index: Y coordinate at which to draw the preview
slice indicator
:param tilt_angle: Angle of the tilt line
"""
with QSignalBlocker(self.previewSliceIndex):
self.previewSliceIndex.setValue(preview_slice_index)
self.image_view.update_projection(image_data, preview_slice_index, tilt_angle)
[docs]
def update_sinogram(self, image_data):
self.image_view.update_sinogram(image_data)
[docs]
def is_auto_update_preview(self) -> bool:
return self.previewAutoUpdate.isChecked()
[docs]
def handle_auto_update_preview_selection(self):
if self.previewAutoUpdate.isChecked():
self.presenter.notify(PresN.RECONSTRUCT_PREVIEW_SLICE)
[docs]
def update_recon_preview(self, image_data: numpy.ndarray, reset_roi: bool = False):
"""
Updates the reconstruction preview image with new data.
"""
# Plot image
self.image_view.update_recon(image_data, reset_roi)
if self.update_recon_hist_needed:
self.image_view.update_recon_hist()
self.update_recon_hist_needed = False
[docs]
def reset_recon_and_sino_previews(self):
"""
Resets the recon and sinogram preview images, forcing a complete redraw next time they
are updated.
"""
self.image_view.clear_recon()
self.image_view.clear_sinogram()
[docs]
def reset_recon_line_profile(self) -> None:
self.image_view.clear_recon_line_profile()
[docs]
def reset_projection_preview(self) -> None:
self.image_view.clear_projection()
[docs]
def reset_slice_and_tilt(self, slice_index: int) -> None:
self.image_view.reset_slice_and_tilt(slice_index)
[docs]
def on_table_row_count_change(self, _=None, __=None):
"""
Called when rows have been added or removed from the point table.
Used to conditionally enable UI controls that depend on a certain
amount of entries in the table.
"""
# Disable clear buttons when there are no rows in the table
empty = self.tableView.model().empty
self.removeBtn.setEnabled(not empty)
self.clearAllBtn.setEnabled(not empty)
[docs]
def add_cor_table_row(self, row: int, slice_index: int, cor: float):
"""
Adds a row to the manual COR table with a specified slice index.
"""
self.cor_table_model.appendNewRow(row, slice_index, cor)
self.tableView.selectRow(row)
[docs]
def get_cor_table_selected_rows(self) -> list[int]:
rows = self.tableView.selectionModel().selectedRows()
return [row.row() for row in rows]
@property
def rotation_centre(self) -> float:
return self.resultCor.value()
@rotation_centre.setter
def rotation_centre(self, value: float) -> None:
self.resultCor.setValue(value)
@property
def tilt(self) -> float:
return self.resultTilt.value()
@tilt.setter
def tilt(self, value: float) -> None:
self.resultTilt.setValue(value)
@property
def slope(self) -> float:
return self.resultSlope.value()
@slope.setter
def slope(self, value: float) -> None:
self.resultSlope.setValue(value)
@property
def max_proj_angle(self) -> float:
return self.maxProjAngle.value()
@property
def algorithm_name(self) -> str:
return self.algorithmName.currentText()
@property
def filter_name(self) -> str:
return self.filterName.currentText()
@property
def num_iter(self) -> int:
return self.numIter.value()
@num_iter.setter
def num_iter(self, iters: int) -> None:
self.numIter.setValue(iters)
@property
def pixel_size(self) -> float:
return self.pixelSize.value()
@pixel_size.setter
def pixel_size(self, value: int) -> None:
with QSignalBlocker(self.pixelSize):
self.pixelSize.setValue(value)
@property
def alpha(self) -> float:
return self.alphaSpinBox.value()
@property
def gamma(self) -> float:
return 1
@property
def non_negative(self) -> bool:
return self.nonNegativeCheckBox.isChecked()
@property
def stochastic(self) -> bool:
return self.stochasticCheckBox.isChecked()
@property
def projections_per_subset(self) -> int:
return self.subsetsSpinBox.value()
@property
def regularisation_percent(self) -> int:
return self.regPercentSpinBox.value()
@property
def regulariser(self) -> str:
return "TV"
@property
def beam_hardening_coefs(self) -> list[float] | None:
if not self.lbhc_enabled.isChecked():
return None
params = []
for spinbox in [self.lbhc_a0, self.lbhc_a1, self.lbhc_a2, self.lbhc_a3]:
params.append(spinbox.value())
if any(params):
return params
else:
return None
[docs]
def recon_params(self) -> ReconstructionParameters:
return ReconstructionParameters(algorithm=self.algorithm_name,
filter_name=self.filter_name,
num_iter=self.num_iter,
cor=ScalarCoR(self.rotation_centre),
tilt=Degrees(self.tilt),
pixel_size=self.pixel_size,
alpha=self.alpha,
gamma=self.gamma,
non_negative=self.non_negative,
stochastic=self.stochastic,
projections_per_subset=self.projections_per_subset,
max_projection_angle=self.max_proj_angle,
regularisation_percent=self.regularisation_percent,
regulariser=self.regulariser,
beam_hardening_coefs=self.beam_hardening_coefs)
[docs]
def set_table_point(self, idx, slice_idx, cor):
# reset_results=False stops the resetting of the data model on
# changing a point from here - otherwise calculating the CoR
# for each slice from tilt ends up resetting the model afterwards
# (while it's containing the correct results)
# Manual CoR typed by the user will still reset it, as that is
# handled as an internal Qt event in the model
self.cor_table_model.set_point(idx, slice_idx, cor, reset_results=False)
[docs]
def show_recon_volume(self, data: ImageStack, stack_id: uuid.UUID) -> None:
self.main_window.add_recon_to_dataset(data, stack_id)
[docs]
def get_stack(self, uuid) -> ImageStack | None:
if uuid is not None:
return self.main_window.get_stack(uuid)
return None
[docs]
def hide_tilt(self) -> None:
self.image_view.hide_tilt()
[docs]
def get_number_of_cors(self) -> int | None:
num, accepted = QInputDialog.getInt(self,
"Number of slices",
"On how many slices to run the automatic CoR finding?",
value=6,
min=2,
max=30,
step=1,
flags=INPUT_DIALOG_FLAGS)
if accepted:
return num
else:
return None
[docs]
def get_auto_cor_method(self) -> AutoCorMethod:
current = self.autoFindMethod.currentText()
if current == "Correlation":
return AutoCorMethod.CORRELATION
else:
return AutoCorMethod.MINIMISATION_SQUARE_SUM
[docs]
def open_help_webpage(self, page: str) -> None:
try:
open_help_webpage(SECTION_USER_GUIDE, page)
except RuntimeError as err:
self.show_error_dialog(str(err))
[docs]
def change_refine_iterations(self) -> None:
self.refineIterationsBtn.setEnabled(self.algorithm_name == "SIRT_CUDA")
[docs]
def on_change_colour_palette(self) -> None:
"""
Opens the Palette Changer window when the "Auto" button has been clicked.
"""
self.change_colour_palette_dialog = PaletteChangerView(self,
self.image_view.imageview_recon.histogram,
self.image_view.imageview_recon.image_data,
recon_mode=True)
self.change_colour_palette_dialog.show()
[docs]
def show_status_message(self, msg: str) -> None:
"""
Shows a status message indicating that zero/negative/NaN pixels were found in the stack. If the msg argument is
empty then this is taken to mean that no such pixels were found, so the warning message and icon are cleared.
:param msg: The status message.
"""
self.statusMessageTextEdit.setText(msg)
if msg:
self.messageIcon.setPixmap(QApplication.style().standardPixmap(QStyle.SP_MessageBoxCritical))
else:
self.messageIcon.clear()
[docs]
def set_max_projection_index(self, max_index: int) -> None:
self.previewProjectionIndex.setMaximum(max_index)
[docs]
def set_max_slice_index(self, max_index: int) -> None:
self.previewSliceIndex.setMaximum(max_index)