# Copyright (C) 2021 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, QRectF
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
import numpy as np
if TYPE_CHECKING:
from pyqtgraph import HistogramLUTItem
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 | None:
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) -> None:
self._angles = angles
self._update_message(self._last_mouse_hover_location)
def _set_roi_max_bounds(self):
self.roi.maxBounds = QRectF(0, 0, self.image_data.shape[2], self.image_data.shape[1])
[docs]
def setImage(self, image: np.ndarray, *args, **kwargs):
"""
Set the image to be displayed in the widget
See :py:meth:`pyqtgraph.ImageView.setImage` for details of additional arguments
"""
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) -> 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) -> None:
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) -> None:
"""
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._set_roi_max_bounds()
self._refresh_message()
def _update_roi_region_avg(self) -> SensibleROI | None:
assert self.image is not 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) -> None:
# 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) -> None:
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) -> None:
if event.exit:
return
pt = CloseEnoughPoint(event.pos())
self._last_mouse_hover_location = pt
self._update_message(pt)
def _update_message(self, pt) -> None:
# 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] if self.image is not None else 0
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] if self.image is not None else 0
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) -> None:
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) -> None:
self.timeLine.setValue(image_index)
[docs]
def set_log_scale(self) -> None:
set_histogram_log_scale(self.getHistogramWidget().item)
[docs]
def close(self) -> None:
self.roi_changed_callback = None
super().close()
[docs]
def set_roi(self, coords: list[int] | None) -> None:
if coords is None:
return
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) -> None | list[int]:
# 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
if self.image_data is None:
return None
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]