diff --git a/.github/actions/publish-package/action.yml b/.github/actions/publish-package/action.yml index 66d2f12bf6f..475708cbad4 100644 --- a/.github/actions/publish-package/action.yml +++ b/.github/actions/publish-package/action.yml @@ -31,7 +31,7 @@ runs: conda config --set always_yes yes --set changeps1 no # Install build requirements # We can't use the makefile target for this because the CONDA_ACTIVATE command is incompatible with GitHub Actions Windows runners - conda create -n build-env --yes boa anaconda-client conda-verify + conda create -n build-env --yes boa anaconda-client conda activate build-env # Configure the conda channels conda config --env $(cat environment.yml | sed -ne '/channels:/,/dependencies:/{//!p}' | grep '^ -' | sed 's/ - / --append channels /g' | tr -d '\n') diff --git a/.github/workflows/cos7_testing.yml b/.github/workflows/rocky_testing.yml similarity index 77% rename from .github/workflows/cos7_testing.yml rename to .github/workflows/rocky_testing.yml index 1e0edd2c610..3a02001b8a0 100644 --- a/.github/workflows/cos7_testing.yml +++ b/.github/workflows/rocky_testing.yml @@ -1,4 +1,4 @@ -name: Testing with CentOS 7 docker +name: Testing with Rocky docker on: push: @@ -16,42 +16,42 @@ concurrency: jobs: test: - # It is hosted on Ubuntu but the docker image is built on CentOS 7 + # It is hosted on Ubuntu but the docker image is built on Rocky runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Pre-load docker image - run: docker pull ghcr.io/mantidproject/mantidimaging:centos7 + run: docker pull ghcr.io/mantidproject/mantidimaging:rocky8 - name: List versions uses: ./.github/actions/test with: command: python --version; conda list ; pip list - label: centos7 + label: rocky8 - name: yapf uses: ./.github/actions/test with: command: yapf --parallel --diff --recursive . - label: centos7 + label: rocky8 - name: ruff uses: ./.github/actions/test with: command: ruff check . - label: centos7 + label: rocky8 - name: mypy uses: ./.github/actions/test with: command: mypy --ignore-missing-imports mantidimaging - label: centos7 + label: rocky8 - name: pytest timeout-minutes: 5 uses: ./.github/actions/test with: command: xvfb-run pytest -n auto -o log_cli=true --ignore=mantidimaging/eyes_tests --durations=10 - label: centos7 + label: rocky8 diff --git a/conda/meta.yaml b/conda/meta.yaml index cd084782ecc..3a9668a813e 100644 --- a/conda/meta.yaml +++ b/conda/meta.yaml @@ -13,7 +13,7 @@ source: requirements: build: - python=3.12.* - - setuptools=62.* + - setuptools=72.* run: - python=3.12.* - pip diff --git a/docker/Makefile b/docker/Makefile deleted file mode 100644 index 5b31b8e1c0b..00000000000 --- a/docker/Makefile +++ /dev/null @@ -1,24 +0,0 @@ -image_label := $$(if [ -z "$$IMAGE_LABEL" ]; then echo travis-ci-$$(git rev-parse --short HEAD); else echo "$$IMAGE_LABEL"; fi) - -all: centos7-base ubuntu18-base - docker tag mantidproject/mantidimaging:centos7-base mantidproject/mantidimaging:base - docker build --no-cache -f Dockerfile -t mantidproject/mantidimaging:centos7 .. - - docker tag mantidproject/mantidimaging:ubuntu18-base mantidproject/mantidimaging:base - docker build --no-cache -f Dockerfile -t mantidproject/mantidimaging:ubuntu18 .. - docker tag mantidproject/mantidimaging:ubuntu18 mantidproject/mantidimaging:latest - -push: - docker push mantidproject/mantidimaging:centos7 - docker push mantidproject/mantidimaging:ubuntu18 - docker push mantidproject/mantidimaging:latest - -centos7-base: - docker build -f CentOS7_base.Dockerfile -t mantidproject/mantidimaging:centos7-base .. - docker push mantidproject/mantidimaging:centos7-base - -ubuntu18-base: - docker build -f Ubuntu18_base.Dockerfile -t mantidproject/mantidimaging:ubuntu18-base .. - docker push mantidproject/mantidimaging:ubuntu18-base - docker tag mantidproject/mantidimaging:ubuntu18-base mantidproject/mantidimaging:base - docker push mantidproject/mantidimaging:base diff --git a/docs/installation.rst b/docs/installation.rst index 4443b255894..8a70d9cf91a 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -17,7 +17,7 @@ Requirements ------------ Operating system - - Linux. Tested on Ubuntu 18.04, 20.04, 22.04 and CentOS 7 + - Linux. Tested on Ubuntu 18.04, 20.04, 22.04 and Rocky 8 - Windows. Tested on Windows 10 GPU diff --git a/docs/release_notes/next/feature-2273-live-view-img-timestamp b/docs/release_notes/next/feature-2273-live-view-img-timestamp new file mode 100644 index 00000000000..dd6202b87ab --- /dev/null +++ b/docs/release_notes/next/feature-2273-live-view-img-timestamp @@ -0,0 +1 @@ +#2273: Display the modified image timestamp within the live viewer to allow for easier determination of what scan is being displayed within the live viewer. diff --git a/docs/release_notes/next/fix-2285-crop-coord-stack-bounds b/docs/release_notes/next/fix-2285-crop-coord-stack-bounds new file mode 100644 index 00000000000..0c8d77d2837 --- /dev/null +++ b/docs/release_notes/next/fix-2285-crop-coord-stack-bounds @@ -0,0 +1 @@ +#2285: Resolve operations windows crop co-ordinates ROI size from being larger than stack bounds. Additionally resolve ROI crop co-ordinate dialog window persistence if parent window closed. \ No newline at end of file diff --git a/mantidimaging/eyes_tests/live_viewer_window_test.py b/mantidimaging/eyes_tests/live_viewer_window_test.py index 81de5ccdeaf..7d9b49bea06 100644 --- a/mantidimaging/eyes_tests/live_viewer_window_test.py +++ b/mantidimaging/eyes_tests/live_viewer_window_test.py @@ -1,21 +1,27 @@ # Copyright (C) 2024 ISIS Rutherford Appleton Laboratory UKRI # SPDX - License - Identifier: GPL-3.0-or-later -from __future__ import annotations +from __future__ import annotations +from typing import TYPE_CHECKING from unittest import mock - import numpy as np - +import os from mantidimaging.core.operations.loader import load_filter_packages from mantidimaging.gui.windows.live_viewer.model import Image_Data from mantidimaging.test_helpers.unit_test_helper import FakeFSTestCase from pathlib import Path - from mantidimaging.eyes_tests.base_eyes import BaseEyesTest +if TYPE_CHECKING: + import time # noqa: F401 + class LiveViewerWindowTest(FakeFSTestCase, BaseEyesTest): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.initial_time = 4000.0 + @classmethod def setUpClass(cls) -> None: super().setUpClass() @@ -30,26 +36,30 @@ def setUp(self) -> None: def _generate_image(self): image = np.zeros((10, 10)) image[5, :] = np.arange(10) + os.utime(self.live_directory, (10, self.initial_time)) + self.initial_time += 1000 return image def _make_simple_dir(self, directory: Path): file_list = [directory / f"abc_{i:06d}.tif" for i in range(5)] - if not directory.exists(): - self.fs.create_dir(directory) - + increment = 0 for file in file_list: self.fs.create_file(file) + os.utime(file, (10, self.initial_time + increment)) + increment += 1000 return file_list @mock.patch('mantidimaging.gui.windows.live_viewer.model.ImageWatcher') - def test_live_view_opens_without_data(self, _mock_image_watcher): + @mock.patch("time.time", return_value=4000.0) + def test_live_view_opens_without_data(self, _mock_time, _mock_image_watcher): self.imaging.show_live_viewer(self.live_directory) self.check_target(widget=self.imaging.live_viewer) @mock.patch('mantidimaging.gui.windows.live_viewer.presenter.LiveViewerWindowPresenter.load_image') @mock.patch('mantidimaging.gui.windows.live_viewer.model.ImageWatcher') - def test_live_view_opens_with_data(self, _mock_image_watcher, mock_load_image): + @mock.patch("time.time", return_value=4000.0) + def test_live_view_opens_with_data(self, _mock_time, _mock_image_watcher, mock_load_image): file_list = self._make_simple_dir(self.live_directory) image_list = [Image_Data(path) for path in file_list] mock_load_image.return_value = self._generate_image() @@ -59,7 +69,8 @@ def test_live_view_opens_with_data(self, _mock_image_watcher, mock_load_image): @mock.patch('mantidimaging.gui.windows.live_viewer.presenter.LiveViewerWindowPresenter.load_image') @mock.patch('mantidimaging.gui.windows.live_viewer.model.ImageWatcher') - def test_live_view_opens_with_bad_data(self, _mock_image_watcher, mock_load_image): + @mock.patch("time.time", return_value=4000.0) + def test_live_view_opens_with_bad_data(self, _mock_time, _mock_image_watcher, mock_load_image): file_list = self._make_simple_dir(self.live_directory) image_list = [Image_Data(path) for path in file_list] mock_load_image.side_effect = ValueError @@ -69,7 +80,8 @@ def test_live_view_opens_with_bad_data(self, _mock_image_watcher, mock_load_imag @mock.patch('mantidimaging.gui.windows.live_viewer.presenter.LiveViewerWindowPresenter.load_image') @mock.patch('mantidimaging.gui.windows.live_viewer.model.ImageWatcher') - def test_rotate_operation_rotates_image(self, _mock_image_watcher, mock_load_image): + @mock.patch("time.time", return_value=4000.0) + def test_rotate_operation_rotates_image(self, _mock_time, _mock_image_watcher, mock_load_image): file_list = self._make_simple_dir(self.live_directory) image_list = [Image_Data(path) for path in file_list] mock_load_image.return_value = self._generate_image() diff --git a/mantidimaging/gui/test/gui_system_operations_test.py b/mantidimaging/gui/test/gui_system_operations_test.py index 80403dc6a89..a7b7ade8570 100644 --- a/mantidimaging/gui/test/gui_system_operations_test.py +++ b/mantidimaging/gui/test/gui_system_operations_test.py @@ -49,7 +49,6 @@ ALLOWED_ERRORS = [ 'Negative values found in result preview for slice 0.', 'Flat-fielding completed. Slices containing negative values in IMAT_Flower_Tomo_000000: all slices.', - 'Error applying filter for preview: could not broadcast input array from shape (1,80,80) into shape (1,90,90)' ] diff --git a/mantidimaging/gui/widgets/mi_image_view/view.py b/mantidimaging/gui/widgets/mi_image_view/view.py index 0dc1e1805c0..904f9fe395e 100644 --- a/mantidimaging/gui/widgets/mi_image_view/view.py +++ b/mantidimaging/gui/widgets/mi_image_view/view.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING from collections.abc import Callable -from PyQt5.QtCore import Qt +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 @@ -148,6 +148,9 @@ 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]) + 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: @@ -195,6 +198,7 @@ def roiChanged(self) -> None: 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: diff --git a/mantidimaging/gui/widgets/roi_selector/view.py b/mantidimaging/gui/widgets/roi_selector/view.py index 390a7cb6665..8a271b05834 100644 --- a/mantidimaging/gui/widgets/roi_selector/view.py +++ b/mantidimaging/gui/widgets/roi_selector/view.py @@ -69,3 +69,7 @@ def toggle_average_images(self) -> None: self.roi_view_averaged = not self.roi_view_averaged self.roi_view.roi.show() self.roi_view.ui.roiPlot.hide() + + def closeEvent(self, event) -> None: + self.roi_view.close() + event.accept() diff --git a/mantidimaging/gui/windows/live_viewer/model.py b/mantidimaging/gui/windows/live_viewer/model.py index 5b5a304134b..19fbebf1e55 100644 --- a/mantidimaging/gui/windows/live_viewer/model.py +++ b/mantidimaging/gui/windows/live_viewer/model.py @@ -91,12 +91,19 @@ def image_modified_time(self) -> float: """Return the image modified time""" return self._stat.st_mtime + def set_delayed_array(self) -> None: if self.image_path.suffix.lower() in [".tif", ".tiff"]: self.delayed_array = dask_image.imread.imread(self.image_path)[0] elif self.image_path.suffix.lower() == ".fits": self.delayed_array = dask.delayed(fits.open)(self.image_path)[0].data + @property + def image_modified_time_stamp(self) -> str: + """Return the image modified time as a string""" + return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(self.image_modified_time)) + + class SubDirectory: diff --git a/mantidimaging/gui/windows/live_viewer/presenter.py b/mantidimaging/gui/windows/live_viewer/presenter.py index 02b2fc37ec6..791883a3eca 100644 --- a/mantidimaging/gui/windows/live_viewer/presenter.py +++ b/mantidimaging/gui/windows/live_viewer/presenter.py @@ -76,7 +76,8 @@ def select_image(self, index: int) -> None: if not self.model.images: return self.selected_image = self.model.images[index] - self.view.label_active_filename.setText(self.selected_image.image_name) + image_timestamp = self.selected_image.image_modified_time_stamp + self.view.label_active_filename.setText(f"{self.selected_image.image_name} - {image_timestamp}") self.display_image(self.selected_image) diff --git a/mantidimaging/gui/windows/operations/presenter.py b/mantidimaging/gui/windows/operations/presenter.py index 21293e4f538..0952a983d01 100644 --- a/mantidimaging/gui/windows/operations/presenter.py +++ b/mantidimaging/gui/windows/operations/presenter.py @@ -284,14 +284,9 @@ def _post_filter(self, updated_stacks: list[ImageStack], task): if np.any(stack.data < 0): negative_stacks.append(stack) - if self.view.roi_view is not None: - self.view.roi_view.close() - self.view.roi_view = None - self.applying_to_all = False - self.do_update_previews() - if task.error is not None: + if task.error: # task failed, show why self.view.show_error_dialog(f"Operation failed: {task.error}") elif use_new_data: @@ -312,6 +307,7 @@ def _post_filter(self, updated_stacks: list[ImageStack], task): self.view.filter_applied.emit() self._set_apply_buttons_enabled(self.prev_apply_single_state, self.prev_apply_all_state) self.filter_is_running = False + self.do_update_previews() def _do_apply_filter(self, apply_to: list[ImageStack]): self.filter_is_running = True @@ -454,11 +450,8 @@ def init_roi_field(self, roi_field: QLineEdit): if self.stack is None: return - larger = np.greater(self.stack.data[0].shape, (200, 200)) - if all(larger): - return - x = min(self.stack.data[0].shape[0], 200) - y = min(self.stack.data[0].shape[1], 200) + x = self.stack.data.shape[1] // 2 + y = self.stack.data.shape[2] // 2 crop_string = ", ".join(["0", "0", str(y), str(x)]) roi_field.setText(crop_string) diff --git a/mantidimaging/gui/windows/operations/test/presenter_test.py b/mantidimaging/gui/windows/operations/test/presenter_test.py index a40eb7e970c..10f35e2e6d6 100644 --- a/mantidimaging/gui/windows/operations/test/presenter_test.py +++ b/mantidimaging/gui/windows/operations/test/presenter_test.py @@ -448,14 +448,14 @@ def test_init_roi_field_does_nothing_when_stack_is_none(self): self.presenter.init_roi_field(mock_roi_field) mock_roi_field.setText.assert_not_called() - def test_init_roi_field_does_nothing_when_image_is_greater_than_200_by_200(self): + def test_init_roi_field_called_with_smaller_values_if_image_is_greater_than_200_by_200(self): mock_roi_field = mock.Mock() self.presenter.stack = mock.Mock() self.presenter.stack.data = np.ones((2, 201, 201)) self.presenter.init_roi_field(mock_roi_field) - mock_roi_field.setText.assert_not_called() + mock_roi_field.setText.assert_called_once_with("0, 0, 100, 100") - @parameterized.expand([(190, 201, "0, 0, 200, 190"), (201, 80, "0, 0, 80, 200"), (200, 200, "0, 0, 200, 200")]) + @parameterized.expand([(190, 201, "0, 0, 100, 95"), (201, 80, "0, 0, 40, 100"), (200, 200, "0, 0, 100, 100")]) def test_set_text_called_when_image_not_greater_than_200_by_200(self, shape_x, shape_y, expected): mock_roi_field = mock.Mock() self.presenter.stack = mock.Mock() diff --git a/mantidimaging/gui/windows/operations/view.py b/mantidimaging/gui/windows/operations/view.py index 8cf045b6244..a9490479ab1 100644 --- a/mantidimaging/gui/windows/operations/view.py +++ b/mantidimaging/gui/windows/operations/view.py @@ -64,6 +64,7 @@ def __init__(self, main_window: MainWindowView): self.main_window = main_window self.presenter = FiltersWindowPresenter(self, main_window) self.roi_view = None + self.roi_selector_dialog: ROISelectorView | None = None self.roi_view_averaged = False self.splitter.setSizes([200, 9999]) self.splitter.setStretchFactor(0, 1) @@ -122,8 +123,9 @@ def closeEvent(self, e): def cleanup(self): self.stackSelector.unsubscribe_from_main_window() if self.roi_view is not None: - self.roi_view.close() self.roi_view = None + self.roi_selector_dialog.close() + self.presenter.set_stack(None) self.auto_update_triggered.disconnect() self.main_window.filters = None @@ -255,16 +257,17 @@ def roi_changed_callback(callback): except ValueError: roi_values = None - window = ROISelectorView(self, self.presenter.stack, self.presenter.model.preview_image_idx, roi_values, - roi_changed_callback) + self.roi_selector_dialog = ROISelectorView(self, self.presenter.stack, self.presenter.model.preview_image_idx, + roi_values, roi_changed_callback) def close_event(event): roi_field.setEnabled(True) roi_button.setEnabled(True) event.accept() - window.closeEvent = functools.partial(close_event) - window.show() + self.roi_view = self.roi_selector_dialog.roi_view + self.roi_selector_dialog.closeEvent = functools.partial(close_event) + self.roi_selector_dialog.show() def toggle_filters_section(self): if self.collapseToggleButton.text() == "<<":