diff --git a/docs/release_notes/next/feature-1916-img_scroller_user_interface b/docs/release_notes/next/feature-1916-img_scroller_user_interface new file mode 100644 index 00000000000..7faef01b32d --- /dev/null +++ b/docs/release_notes/next/feature-1916-img_scroller_user_interface @@ -0,0 +1 @@ +#1916 : Image scroller for live view diff --git a/mantidimaging/gui/widgets/zslider/__init__.py b/mantidimaging/gui/widgets/zslider/__init__.py new file mode 100644 index 00000000000..61f7e25d38d --- /dev/null +++ b/mantidimaging/gui/widgets/zslider/__init__.py @@ -0,0 +1,3 @@ +# Copyright (C) 2023 ISIS Rutherford Appleton Laboratory UKRI +# SPDX - License - Identifier: GPL-3.0-or-later +from __future__ import annotations diff --git a/mantidimaging/gui/widgets/zslider/zslider.py b/mantidimaging/gui/widgets/zslider/zslider.py new file mode 100644 index 00000000000..cb8ce047f2a --- /dev/null +++ b/mantidimaging/gui/widgets/zslider/zslider.py @@ -0,0 +1,57 @@ +# Copyright (C) 2023 ISIS Rutherford Appleton Laboratory UKRI +# SPDX - License - Identifier: GPL-3.0-or-later +from __future__ import annotations + +from PyQt5.QtCore import pyqtSignal, Qt +from PyQt5.QtWidgets import QGraphicsSceneMouseEvent +from pyqtgraph import PlotItem, InfiniteLine + + +class ZSlider(PlotItem): + """ + A plot item to draw a z-axis slider mimicking the slider in PyQtGraph's ImageView + + This gives us flexibility to choose what happens when the user move through the z-axis. It can be combined with + the one or more :py:class:`~mantidimaging.gui.widgets.mi_mini_image_view.view.MIMiniImageView`'s in a + GraphicsLayoutWidget. It is used in the Operations window to choose the slice to preview a filter with, + and in the Live Viewer scroll through images. + + Emits a :code:`valueChanged` signal when the user moves the slider + """ + + z_line: InfiniteLine + valueChanged = pyqtSignal(int) + + def __init__(self) -> None: + super().__init__() + self.setFixedHeight(40) + self.hideAxis("left") + self.setXRange(0, 1) + self.setMouseEnabled(x=False, y=False) + self.hideButtons() + + self.z_line = InfiniteLine(0, movable=True) + self.z_line.setPen((255, 255, 0, 200)) + self.addItem(self.z_line) + + self.z_line.sigPositionChanged.connect(self.value_changed) + + def set_range(self, min: int, max: int) -> None: + self.z_line.setValue(min) + self.setXRange(min, max) + self.z_line.setBounds([min, max]) + + def set_value(self, value: int) -> None: + self.z_line.setValue(value) + + def value_changed(self) -> None: + self.valueChanged.emit(int(self.z_line.value())) + + def mousePressEvent(self, ev: 'QGraphicsSceneMouseEvent') -> None: + """ + Adjusts built in behaviour to allow user to click anywhere on the line to jump there. + """ + if ev.button() == Qt.MouseButton.LeftButton: + x = round(self.vb.mapSceneToView(ev.scenePos()).x()) + self.set_value(x) + super().mousePressEvent(ev) diff --git a/mantidimaging/gui/windows/live_viewer/live_view_widget.py b/mantidimaging/gui/windows/live_viewer/live_view_widget.py index 91a5caf5178..5bed05fd790 100644 --- a/mantidimaging/gui/windows/live_viewer/live_view_widget.py +++ b/mantidimaging/gui/windows/live_viewer/live_view_widget.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from pyqtgraph import GraphicsLayoutWidget from mantidimaging.gui.widgets.mi_mini_image_view.view import MIMiniImageView +from mantidimaging.gui.widgets.zslider.zslider import ZSlider if TYPE_CHECKING: import numpy as np @@ -22,6 +23,10 @@ def __init__(self) -> None: self.image = MIMiniImageView(name="Projection") self.addItem(self.image, 0, 0) + self.nextRow() + + self.z_slider = ZSlider() + self.addItem(self.z_slider) def show_image(self, image: np.ndarray) -> None: """ diff --git a/mantidimaging/gui/windows/live_viewer/model.py b/mantidimaging/gui/windows/live_viewer/model.py index d26b5dff2fa..9d41dfd01d0 100644 --- a/mantidimaging/gui/windows/live_viewer/model.py +++ b/mantidimaging/gui/windows/live_viewer/model.py @@ -72,6 +72,8 @@ class LiveViewerWindowModel: presenter for the spectrum viewer window path : Path path to dataset + images : list + list of images in directory """ def __init__(self, presenter: 'LiveViewerWindowPresenter'): @@ -87,6 +89,7 @@ def __init__(self, presenter: 'LiveViewerWindowPresenter'): self.presenter = presenter self._dataset_path: Path | None = None self.image_watcher: ImageWatcher | None = None + self.images: list[Image_Data] = [] @property def path(self) -> Path | None: @@ -98,7 +101,6 @@ def path(self, path: Path) -> None: self.image_watcher = ImageWatcher(path) self.image_watcher.image_changed.connect(self._handle_image_changed_in_list) self.image_watcher.find_images() - self.image_watcher.get_images() def _handle_image_changed_in_list(self, image_files: list[Image_Data]) -> None: """ @@ -108,11 +110,8 @@ def _handle_image_changed_in_list(self, image_files: list[Image_Data]) -> None: :param image_files: list of image files """ - if not image_files: - self.presenter.handle_deleted() - self.presenter.update_image([]) - else: - self.presenter.update_image(image_files) + self.images = image_files + self.presenter.update_image_list(image_files) class ImageWatcher(QObject): @@ -127,21 +126,15 @@ class ImageWatcher(QObject): path to directory to watch watcher : QFileSystemWatcher file system watcher to watch directory - images : list - list of images in directory image_changed : pyqtSignal signal emitted when an image is added or removed Methods ------- find_images() - Find all the images in the directory and emit the - image_changed signal for the last modified image. + Find all the images in the directory sort_images_by_modified_time(images) Sort the images by modified time. - find_last_modified_image() - Find the last modified image in the directory and - emit the image_changed signal. """ image_changed = pyqtSignal(list) # Signal emitted when an image is added or removed @@ -160,16 +153,25 @@ def __init__(self, directory: Path): self.watcher = QFileSystemWatcher() self.watcher.directoryChanged.connect(self._handle_directory_change) self.watcher.addPath(str(self.directory)) - self.images: list[Image_Data] = [] - def find_images(self) -> None: + def find_images(self) -> list[Image_Data]: """ - Find all the images in the directory and emit the - image_changed signal for the last modified image. + Find all the images in the directory. """ - self.images = self._get_image_files() + image_files = [] + for file_path in Path(self.directory).iterdir(): + if self._is_image_file(file_path.name): + try: + image_obj = Image_Data(file_path) + if image_obj.image_size > 45: + image_files.append(image_obj) + except FileNotFoundError: + continue + + return image_files - def sort_images_by_modified_time(self, images: list[Image_Data]) -> list[Image_Data]: + @staticmethod + def sort_images_by_modified_time(images: list[Image_Data]) -> list[Image_Data]: """ Sort the images by modified time. @@ -178,35 +180,18 @@ def sort_images_by_modified_time(self, images: list[Image_Data]) -> list[Image_D """ return sorted(images, key=lambda x: x.image_modified_time) - def get_images(self) -> list[Image_Data]: - """Return the sorted images""" - return self.images - def _handle_directory_change(self, directory: str) -> None: """ Handle a directory change event. Update the list of images to reflect directory changes and emit the image_changed signal - for the last modified image. + with the sorted image list. :param directory: directory that has changed """ - try: - self.find_images() - self.image_changed.emit(self.images) - except FileNotFoundError: - self.image_changed.emit([]) - def _get_image_files(self) -> list[Image_Data]: - image_files = [] - for file_path in Path(self.directory).iterdir(): - if self._is_image_file(file_path.name): - try: - image_obj = Image_Data(file_path) - if image_obj.image_size > 45: - image_files.append(image_obj) - except FileNotFoundError: - continue - return self.sort_images_by_modified_time(image_files) + images = self.find_images() + images = self.sort_images_by_modified_time(images) + self.image_changed.emit(images) @staticmethod def _is_image_file(file_name: str) -> bool: diff --git a/mantidimaging/gui/windows/live_viewer/presenter.py b/mantidimaging/gui/windows/live_viewer/presenter.py index 8cda33cef65..71d69c63581 100644 --- a/mantidimaging/gui/windows/live_viewer/presenter.py +++ b/mantidimaging/gui/windows/live_viewer/presenter.py @@ -48,19 +48,29 @@ def handle_deleted(self) -> None: """Handle the deletion of the image.""" self.view.remove_image() self.clear_label() + self.view.live_viewer.z_slider.set_range(0, 1) - def update_image(self, images_list: list[Image_Data]) -> None: + def update_image_list(self, images_list: list[Image_Data]) -> None: """Update the image in the view.""" if not images_list: - self.view.remove_image() + self.handle_deleted() + return + + self.view.live_viewer.z_slider.set_range(0, len(images_list) - 1) + self.view.set_image_index(len(images_list) - 1) + + def select_image(self, index: int) -> None: + if not self.model.images: return - latest_image = images_list[-1] + selected_image = self.model.images[index] + self.view.label_active_filename.setText(selected_image.image_name) + try: - with tifffile.TiffFile(latest_image.image_path) as tif: + with tifffile.TiffFile(selected_image.image_path) as tif: image_data = tif.asarray() except (IOError, KeyError, ValueError, DeflateError) as error: - logger.error("%s reading image: %s: %s", type(error).__name__, latest_image.image_path, error) + logger.error("%s reading image: %s: %s", type(error).__name__, selected_image.image_path, error) + self.view.remove_image() return self.view.show_most_recent_image(image_data) - self.view.label_active_filename.setText(latest_image.image_name) diff --git a/mantidimaging/gui/windows/live_viewer/view.py b/mantidimaging/gui/windows/live_viewer/view.py index a21541750c7..aec0de7237e 100644 --- a/mantidimaging/gui/windows/live_viewer/view.py +++ b/mantidimaging/gui/windows/live_viewer/view.py @@ -36,6 +36,8 @@ def __init__(self, main_window: 'MainWindowView', live_dir_path: Path) -> None: # reposition to the right of the main window to be visible when launched from cli self.move(self.main_window.x() + self.main_window.width(), self.main_window.y()) + self.live_viewer.z_slider.valueChanged.connect(self.presenter.select_image) + def show_most_recent_image(self, image: np.ndarray) -> None: """ Show the most recently modified image in the image view. @@ -50,3 +52,6 @@ def watch_directory(self) -> None: def remove_image(self) -> None: """Remove the image from the view.""" self.live_viewer.handle_deleted() + + def set_image_index(self, index: int) -> None: + self.live_viewer.z_slider.set_value(index) diff --git a/mantidimaging/gui/windows/operations/filter_previews.py b/mantidimaging/gui/windows/operations/filter_previews.py index 5cbdca8f535..e9c60cba28c 100644 --- a/mantidimaging/gui/windows/operations/filter_previews.py +++ b/mantidimaging/gui/windows/operations/filter_previews.py @@ -3,19 +3,16 @@ from __future__ import annotations from logging import getLogger -from typing import TYPE_CHECKING import numpy as np -from PyQt5.QtCore import Qt, QPoint, QRect, pyqtSignal +from PyQt5.QtCore import QPoint, QRect from PyQt5.QtGui import QGuiApplication, QResizeEvent -from pyqtgraph import ColorMap, GraphicsLayoutWidget, ImageItem, LegendItem, PlotItem, InfiniteLine +from pyqtgraph import ColorMap, GraphicsLayoutWidget, ImageItem, LegendItem, PlotItem from pyqtgraph.graphicsItems.GraphicsLayout import GraphicsLayout from mantidimaging.core.utility.histogram import set_histogram_log_scale from mantidimaging.gui.widgets.mi_mini_image_view.view import MIMiniImageView - -if TYPE_CHECKING: - from PyQt5.QtWidgets import QGraphicsSceneMouseEvent +from mantidimaging.gui.widgets.zslider.zslider import ZSlider LOG = getLogger(__name__) @@ -32,42 +29,6 @@ def _data_valid_for_histogram(data) -> bool: return data is not None and any(d is not None for d in data) -class ZSlider(PlotItem): - z_line: InfiniteLine - valueChanged = pyqtSignal(int) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.setFixedHeight(40) - self.hideAxis("left") - self.setXRange(0, 1) - self.setMouseEnabled(x=False, y=False) - self.hideButtons() - - self.z_line = InfiniteLine(0, movable=True) - self.z_line.setPen((255, 255, 0, 200)) - self.addItem(self.z_line) - - self.z_line.sigPositionChanged.connect(self.value_changed) - - def set_range(self, min: int, max: int): - self.z_line.setValue(min) - self.setXRange(min, max) - self.z_line.setBounds([min, max]) - - def set_value(self, value: int): - self.z_line.setValue(value) - - def value_changed(self): - self.valueChanged.emit(int(self.z_line.value())) - - def mousePressEvent(self, ev: 'QGraphicsSceneMouseEvent'): - if ev.button() == Qt.MouseButton.LeftButton: - x = round(self.vb.mapSceneToView(ev.scenePos()).x()) - self.set_value(x) - super().mousePressEvent(ev) - - class FilterPreviews(GraphicsLayoutWidget): histogram: PlotItem z_slider: ZSlider