# Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
from __future__ import annotations
from math import degrees
from time import sleep
from typing import TYPE_CHECKING
from collections.abc import Callable
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QLabel, QPushButton, QSizePolicy
from pyqtgraph import ROI, ImageItem, ImageView, ViewBox
from pyqtgraph.GraphicsScene.mouseEvents import HoverEvent
from mantidimaging.core.utility.close_enough_point import CloseEnoughPoint
from mantidimaging.core.utility.histogram import set_histogram_log_scale
from mantidimaging.core.utility.sensible_roi import SensibleROI
from mantidimaging.gui.widgets.auto_colour_menu.auto_color_menu import AutoColorMenu
from mantidimaging.gui.widgets.mi_image_view.presenter import MIImagePresenter
from mantidimaging.gui.widgets.bad_data_overlay.bad_data_overlay import BadDataOverlay
if TYPE_CHECKING:
from pyqtgraph import HistogramLUTItem
import numpy as np
from mantidimaging.core.utility.data_containers import ProjectionAngles
[docs]
class UnrotateablePlotROI(ROI):
"""
Like PlotROI but does not add a rotation handle.
"""
def __init__(self):
ROI.__init__(self, pos=[0, 0])
self.addScaleHandle([1, 1], [0, 0])
[docs]
def clip(value, lower, upper):
return lower if value < lower else upper if value > upper else value
# ImageView objects cannot always be safely garbage collected. To prevent this
# we need keep a reference to the dead objects.
graveyard = []
[docs]
class MIImageView(ImageView, BadDataOverlay, AutoColorMenu):
details: QLabel
roiString = None
imageItem: ImageItem
_angles: ProjectionAngles | None = None
roi_changed_callback: Callable[[SensibleROI], None] | None = None
def __init__(self,
parent=None,
name="ImageView",
view=None,
imageItem=None,
levelMode='mono',
detailsSpanAllCols=False,
*args):
super().__init__(parent, name, view, imageItem, levelMode, *args)
graveyard.append(self)
self.presenter = MIImagePresenter()
self.details = QLabel("", self.ui.layoutWidget)
self.details.setStyleSheet("QLabel { color : white; background-color: black}")
if detailsSpanAllCols:
self.ui.gridLayout.addWidget(self.details, 1, 0, 1, 3)
self.ui.gridLayout.setColumnStretch(0, 8)
self.ui.gridLayout.setColumnStretch(1, 1)
self.ui.gridLayout.setColumnStretch(2, 1)
else:
self.ui.gridLayout.addWidget(self.details, 1, 0, 1, 1)
# Hide the norm button as it allows for manual data changes and we don't want users to do that unrecorded.
self.ui.menuBtn.hide()
# Construct and add the left and right buttons for the stack
self.shifting_through_images = False
self.button_stack_left = QPushButton()
self.button_stack_left.setText("<")
self.button_stack_left.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.button_stack_left.setMaximumSize(40, 40)
self.button_stack_left.pressed.connect(lambda: self.toggle_jumping_frame(-1))
self.button_stack_left.released.connect(lambda: self.toggle_jumping_frame())
self.button_stack_right = QPushButton()
self.button_stack_right.setText(">")
self.button_stack_right.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
self.button_stack_right.setMaximumSize(40, 40)
self.button_stack_right.pressed.connect(lambda: self.toggle_jumping_frame(1))
self.button_stack_right.released.connect(lambda: self.toggle_jumping_frame())
self.vertical_layout = QHBoxLayout()
self.vertical_layout.addWidget(self.button_stack_left)
self.vertical_layout.addWidget(self.button_stack_right)
self.ui.gridLayout.addLayout(self.vertical_layout, 1, 2, 1, 1)
self.imageItem.hoverEvent = self.image_hover_event
# disconnect the ROI recalculation on every move
self.roi.sigRegionChanged.disconnect(self.roiChanged)
self.view.removeItem(self.roi)
self.roi = UnrotateablePlotROI()
self.roi.setZValue(30)
# make ROI red
self.roi.setPen((255, 0, 0))
self.view.addItem(self.roi)
self.roi.hide()
self.roi.sigRegionChangeFinished.connect(self.roiChanged)
self.extend_roi_plot_mouse_press_handler()
self.imageItem.setAutoDownsample(False)
self._last_mouse_hover_location = CloseEnoughPoint([0, 0])
self.imageItem.sigImageChanged.connect(self._refresh_message)
self.imageItem.sigImageChanged.connect(self.set_log_scale)
self.add_auto_color_menu_action(self)
# Work around for https://github.com/mantidproject/mantidimaging/issues/565
self.scene.contextMenu = [item for item in self.scene.contextMenu if "export" not in item.text().lower()]
@property
def histogram(self) -> HistogramLUTItem:
return self.ui.histogram.item
@property
def image_data(self) -> np.ndarray:
return self.image
@property
def image_item(self) -> ImageItem:
return self.imageItem
@property
def viewbox(self) -> ViewBox:
return self.view
@property
def angles(self) -> ProjectionAngles | None:
return self._angles
@angles.setter
def angles(self, angles: ProjectionAngles | None):
self._angles = angles
self._update_message(self._last_mouse_hover_location)
[docs]
def setImage(self, image: np.ndarray, *args, **kwargs):
dimensions_changed = self.image_data is None or self.image_data.shape != image.shape
if image.ndim == 3:
# For a 3 dimensional image, we need to specify which axes we are providing and their indices in the
# array's shape attribute
# If we don't do this then it is interpreted incorrectly for very small images by ImageView.setImage
# Note that, for our purposes, the t axis corresponds to angle data
kwargs['axes'] = kwargs.get('axes', {'t': 0, 'x': 2, 'y': 1, 'c': None})
ImageView.setImage(self, image, *args, **kwargs)
self.check_for_bad_data()
if dimensions_changed:
self.set_roi(self.default_roi())
self.angles = None
[docs]
def toggle_jumping_frame(self, images_to_jump_by=None):
if not self.shifting_through_images and images_to_jump_by is not None:
self.shifting_through_images = True
else:
self.shifting_through_images = False
while self.shifting_through_images:
self.jumpFrames(images_to_jump_by)
sleep(0.02)
QApplication.processEvents()
def _refresh_message(self):
try:
self._update_message(self._last_mouse_hover_location)
except IndexError:
# this happens after the image is cropped, and the last location
# is outside of the new bounds. To prevent this happening again just reset back to 0, 0
self._last_mouse_hover_location = CloseEnoughPoint([0, 0])
[docs]
def roiChanged(self):
"""
Re-implements the roiChanged function to expect only 3D data,
and uses a faster mean calculation on the ROI view of the data,
instead of the full sized data.
"""
# if the data isn't 3D the following code can't handle it correctly
# so defer back to the original implementation which can handle 2D (any maybe ND)
# more sensibly, albeit slower
if self.image.ndim != 3:
return super().roiChanged()
roi = self._update_roi_region_avg()
if self.roi_changed_callback and roi is not None:
self.roi_changed_callback(roi)
self._refresh_message()
def _update_roi_region_avg(self) -> SensibleROI | None:
if self.image.ndim != 3:
return None
roi_pos, roi_size = self.get_roi()
# image indices are in order [Z, X, Y]
left, right = roi_pos.x, roi_pos.x + roi_size.x
top, bottom = roi_pos.y, roi_pos.y + roi_size.y
if self.roi.isVisible():
z_value = int(self.timeLine.value())
mean_val = self.image[z_value, top:bottom, left:right].mean()
self.roiString = f"({left}, {top}, {right}, {bottom}) | " \
f"region avg={mean_val:.6f}"
if self.ui.roiBtn.isChecked():
data = self.image[:, top:bottom, left:right].mean(axis=(1, 2))
if len(self.roiCurves) == 0:
self.roiCurves.append(self.ui.roiPlot.plot())
self.roiCurves[0].setData(y=data, x=self.tVals)
if self.roi.isVisible() or self.ui.roiBtn.isChecked():
return SensibleROI(left, top, right, bottom)
else:
return None
[docs]
def roiClicked(self):
# When ROI area is hidden with the button, clear the message
if not self.ui.roiBtn.isChecked() and hasattr(self, "_last_mouse_hover_location"):
self.roiString = None
self._refresh_message()
super().roiClicked()
[docs]
def extend_roi_plot_mouse_press_handler(self):
original_handler = self.ui.roiPlot.mousePressEvent
def extended_handler(ev):
if ev.button() == Qt.MouseButton.LeftButton:
self.set_timeline_to_tick_nearest(ev.x())
original_handler(ev)
self.ui.roiPlot.mousePressEvent = lambda ev: extended_handler(ev)
[docs]
def get_roi(self) -> tuple[CloseEnoughPoint, CloseEnoughPoint]:
return self.presenter.get_roi(self.image,
roi_pos=CloseEnoughPoint(self.roi.pos()),
roi_size=CloseEnoughPoint(self.roi.size()))
[docs]
def image_hover_event(self, event: HoverEvent):
if event.exit:
return
pt = CloseEnoughPoint(event.pos())
self._last_mouse_hover_location = pt
self._update_message(pt)
def _update_message(self, pt):
# event holds the coordinates in column-major coordinate
# while the data is in row-major coordinate, hence why
# the data access below is [y, x]
if self.image.ndim == 3:
x = clip(pt.x, 0, self.image.shape[2] - 1)
y = clip(pt.y, 0, self.image.shape[1] - 1)
value = self.image[self.currentIndex, y, x]
msg = f"x={y}, y={x}, z={self.currentIndex}, value={value :.6f}"
if self.angles:
angle = degrees(self.angles.value[self.currentIndex])
msg += f" | angle = {angle:.2f}"
else:
x = clip(pt.x, 0, self.image.shape[1] - 1)
y = clip(pt.y, 0, self.image.shape[0] - 1)
value = self.image[y, x]
msg = f"x={y}, y={x}, value={value}"
if self.roiString is not None:
msg += f" | roi = {self.roiString}"
self.details.setText(msg)
[docs]
def set_timeline_to_tick_nearest(self, x_pos_clicked):
x_axis = self.getRoiPlot().getAxis('bottom')
view_range = self.getRoiPlot().viewRange()[0]
nearest = self.presenter.get_nearest_timeline_tick(x_pos_clicked, x_axis, view_range)
self.timeLine.setValue(nearest)
[docs]
def set_selected_image(self, image_index: int):
self.timeLine.setValue(image_index)
[docs]
def set_log_scale(self):
set_histogram_log_scale(self.getHistogramWidget().item)
[docs]
def close(self):
self.roi_changed_callback = None
super().close()
[docs]
def set_roi(self, coords: list[int]):
roi = SensibleROI.from_list(coords)
self.roi.setPos(roi.left, roi.top, update=False)
# Keep default update=True for setSize otherwise the scale handle can become detached from the ROI box
self.roi.setSize([roi.width, roi.height])
self.roiChanged()
self._refresh_message()
[docs]
def default_roi(self):
# Recommend an ROI that covers the top left quadrant
# However set min dimensions to avoid an ROI that is so small it's difficult to work with
min_size = 20
roi_width = max(round(self.image_data.shape[2] / 2), min_size)
roi_height = max(round(self.image_data.shape[1] / 2), min_size)
return [0, 0, roi_width, roi_height]