diff --git a/docs/release_notes/next/feature-1945-export_binned_rits_data b/docs/release_notes/next/feature-1945-export_binned_rits_data new file mode 100644 index 00000000000..f56e2c94b6d --- /dev/null +++ b/docs/release_notes/next/feature-1945-export_binned_rits_data @@ -0,0 +1 @@ +#1945 : Export Binned RITS Formatted Data within Spectrum Viewer. diff --git a/mantidimaging/gui/windows/spectrum_viewer/model.py b/mantidimaging/gui/windows/spectrum_viewer/model.py index e83f6567bc9..0bf32f614bc 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/model.py +++ b/mantidimaging/gui/windows/spectrum_viewer/model.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Optional import numpy as np +from math import ceil from logging import getLogger from mantidimaging.core.data import ImageStack @@ -60,6 +61,8 @@ def __init__(self, presenter: 'SpectrumViewerWindowPresenter'): self._roi_id_counter = 0 self._roi_ranges = {} self.special_roi_list = [ROI_ALL] + self.bin_size: int = 10 + self.step_size: int = 1 def roi_name_generator(self) -> str: """ @@ -131,7 +134,21 @@ def get_averaged_image(self) -> Optional['np.ndarray']: return None @staticmethod - def get_stack_spectrum(stack: ImageStack, roi: SensibleROI): + def get_stack_spectrum(stack: Optional[ImageStack], roi: SensibleROI): + """ + Computes the mean spectrum of the given image stack within the specified region of interest (ROI). + If the image stack is None, an empty numpy array is returned. + Parameters: + stack (Optional[ImageStack]): The image stack to compute the spectrum from. + It can be None, in which case an empty array is returned. + roi (SensibleROI): The region of interest within the image stack. + It is a tuple or list of four integers specifying the left, top, right, and bottom coordinates. + Returns: + numpy.ndarray: The mean spectrum of the image stack within the ROI. + It is a 1D array where each element is the mean of the corresponding layer of the stack within the ROI. + """ + if stack is None: + return np.array([]) left, top, right, bottom = roi roi_data = stack.data[:, top:bottom, left:right] return roi_data.mean(axis=(1, 2)) @@ -151,11 +168,13 @@ def normalise_issue(self) -> str: return "Stack shapes must match" return "" - def get_spectrum(self, roi_name: str, mode: SpecType) -> 'np.ndarray': + def get_spectrum(self, roi: str | SensibleROI, mode: SpecType) -> 'np.ndarray': if self._stack is None: return np.array([]) - roi = self.get_roi(roi_name) + if isinstance(roi, str): + roi = self.get_roi(roi) + if mode == SpecType.SAMPLE: return self.get_stack_spectrum(self._stack, roi) @@ -171,7 +190,7 @@ def get_spectrum(self, roi_name: str, mode: SpecType) -> 'np.ndarray': roi_norm_spectrum = self.get_stack_spectrum(self._normalise_stack, roi) return np.divide(roi_spectrum, roi_norm_spectrum, out=np.zeros_like(roi_spectrum), where=roi_norm_spectrum != 0) - def get_transmission_error_standard_dev(self, roi_name: str) -> np.ndarray: + def get_transmission_error_standard_dev(self, roi: SensibleROI) -> np.ndarray: """ Get the transmission error standard deviation for a given roi @param: roi_name The roi name @@ -179,13 +198,13 @@ def get_transmission_error_standard_dev(self, roi_name: str) -> np.ndarray: """ if self._stack is None or self._normalise_stack is None: raise RuntimeError("Sample and open beam must be selected") - left, top, right, bottom = self.get_roi(roi_name) + left, top, right, bottom = roi sample = self._stack.data[:, top:bottom, left:right] open_beam = self._normalise_stack.data[:, top:bottom, left:right] safe_divide = np.divide(sample, open_beam, out=np.zeros_like(sample), where=open_beam != 0) return np.std(safe_divide, axis=(1, 2)) - def get_transmission_error_propagated(self, roi_name: str) -> np.ndarray: + def get_transmission_error_propagated(self, roi: SensibleROI) -> np.ndarray: """ Get the transmission error using propagation of sqrt(n) error for a given roi @param: roi_name The roi name @@ -193,7 +212,6 @@ def get_transmission_error_propagated(self, roi_name: str) -> np.ndarray: """ if self._stack is None or self._normalise_stack is None: raise RuntimeError("Sample and open beam must be selected") - roi = self.get_roi(roi_name) sample = self.get_stack_spectrum_summed(self._stack, roi) open_beam = self.get_stack_spectrum_summed(self._normalise_stack, roi) error = np.sqrt(sample / open_beam**2 + sample**2 / open_beam**3) @@ -237,35 +255,109 @@ def save_csv(self, path: Path, normalized: bool) -> None: csv_output.write(outfile) self.save_roi_coords(self.get_roi_coords_filename(path)) - def save_rits(self, path: Path, normalized: bool, error_mode: ErrorMode) -> None: + def save_single_rits_spectrum(self, path: Path, error_mode: ErrorMode) -> None: """ - Saves the spectrum for one ROI to a RITS file. + Saves the spectrum for the RITS ROI to a RITS file. @param path: The path to save the CSV file to. @param normalized: Whether to save the normalized spectrum. @param error_mode: Which version (standard deviation or propagated) of the error to use in the RITS export """ + self.save_rits_roi(path, error_mode, self.get_roi(ROI_RITS)) + + def save_rits_roi(self, path: Path, error_mode: ErrorMode, roi: SensibleROI) -> None: + """ + Saves the spectrum for one ROI to a RITS file. + + @param path: The path to save the CSV file to. + @param error_mode: Which version (standard deviation or propagated) of the error to use in the RITS export + """ if self._stack is None: raise ValueError("No stack selected") - if not normalized or self._normalise_stack is None: - raise ValueError("Normalisation must be enabled, and a normalise stack must be selected") + if self._normalise_stack is None: + raise ValueError("A normalise stack must be selected") tof = self.get_stack_time_of_flight() if tof is None: raise ValueError("No Time of Flights for sample. Make sure spectra log has been loaded") tof *= 1e6 # RITS expects ToF in μs - transmission = self.get_spectrum(ROI_RITS, SpecType.SAMPLE_NORMED) + transmission = self.get_spectrum(roi, SpecType.SAMPLE_NORMED) if error_mode == ErrorMode.STANDARD_DEVIATION: - transmission_error = self.get_transmission_error_standard_dev(ROI_RITS) + transmission_error = self.get_transmission_error_standard_dev(roi) elif error_mode == ErrorMode.PROPAGATED: - transmission_error = self.get_transmission_error_propagated(ROI_RITS) + transmission_error = self.get_transmission_error_propagated(roi) else: raise ValueError("Invalid error_mode given") self.export_spectrum_to_rits(path, tof, transmission, transmission_error) + def validate_bin_and_step_size(self, roi, bin_size: int, step_size: int) -> None: + """ + Validates the bin size and step size for saving RITS images. + This method checks the following conditions: + - Both bin size and step size must be greater than 0. + - Bin size must be larger than or equal to step size. + - Both bin size and step size must be less than or equal to the smallest dimension of the ROI. + If any of these conditions are not met, a ValueError is raised. + Parameters: + roi: The region of interest (ROI) to which the bin size and step size should be compared. + bin_size (int): The size of the bins to be validated. + step_size (int): The size of the steps to be validated. + Raises: + ValueError: If any of the validation conditions are not met. + """ + if bin_size and step_size < 1: + raise ValueError("Both bin size and step size must be greater than 0") + if bin_size <= step_size: + raise ValueError("Bin size must be larger than or equal to step size") + if bin_size and step_size > min(roi.width, roi.height): + raise ValueError("Both bin size and step size must be less than or equal to the ROI size") + + def save_rits_images(self, directory: Path, error_mode: ErrorMode, bin_size, step) -> None: + """ + Saves multiple Region of Interest (ROI) images to RITS files. + + This method divides the ROI into multiple sub-regions of size 'bin_size' and saves each sub-region + as a separate RITS image. + The sub-regions are created by sliding a window of size 'bin_size' across the ROI with a step size of 'step'. + + During each iteration on a given axis by the step size, a check is made to see if + the sub_roi has reached the end of the ROI on that axis and if so, the iteration for that axis is stopped. + + + Parameters: + directory (Path): The directory where the RITS images will be saved. If None, no images will be saved. + normalised (bool): If True, the images will be normalised. + error_mode (ErrorMode): The error mode to use when saving the images. + bin_size (int): The size of the sub-regions. + step (int): The step size to use when sliding the window across the ROI. + + Returns: + None + """ + + roi = self.get_roi(ROI_RITS) + left, top, right, bottom = roi + x_iterations = min(ceil((right - left) / step), ceil((right - left - bin_size) / step) + 1) + y_iterations = min(ceil((bottom - top) / step), ceil((bottom - top - bin_size) / step) + 1) + + self.validate_bin_and_step_size(roi, bin_size, step) + for y in range(y_iterations): + sub_top = top + y * step + sub_bottom = min(sub_top + bin_size, bottom) + for x in range(x_iterations): + sub_left = left + x * step + sub_right = min(sub_left + bin_size, right) + sub_roi = SensibleROI.from_list([sub_left, sub_top, sub_right, sub_bottom]) + path = directory / f"rits_image_{x}_{y}.dat" + self.save_rits_roi(path, error_mode, sub_roi) + if sub_right == right: + break + if sub_bottom == bottom: + break + def get_stack_time_of_flight(self) -> np.array | None: if self._stack is None or self._stack.log_file is None: return None diff --git a/mantidimaging/gui/windows/spectrum_viewer/presenter.py b/mantidimaging/gui/windows/spectrum_viewer/presenter.py index f7018fed1e1..6586ecddea1 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/presenter.py +++ b/mantidimaging/gui/windows/spectrum_viewer/presenter.py @@ -171,14 +171,22 @@ def handle_rits_export(self) -> None: """ Handle the export of the current spectrum to a RITS file format """ - path = self.view.get_rits_export_filename() - if path is None: - LOG.debug("No path selected, aborting export") - return - if path.suffix != ".dat": - path = path.with_suffix(".dat") error_mode = ErrorMode.get_by_value(self.view.transmission_error_mode) - self.model.save_rits(path, self.spectrum_mode == SpecType.SAMPLE_NORMED, error_mode) + + if self.view.image_output_mode == "2D Binned": + path = self.view.get_rits_export_directory() + if path is None: + LOG.debug("No path selected, aborting export") + return + self.model.save_rits_images(path, error_mode, self.view.bin_size, self.view.bin_step) + else: + path = self.view.get_rits_export_filename() + if path is None: + LOG.debug("No path selected, aborting export") + return + if path and path.suffix != ".dat": + path = path.with_suffix(".dat") + self.model.save_single_rits_spectrum(path, error_mode) def handle_enable_normalised(self, enabled: bool) -> None: if enabled: diff --git a/mantidimaging/gui/windows/spectrum_viewer/test/model_test.py b/mantidimaging/gui/windows/spectrum_viewer/test/model_test.py index e2384d9fe82..aac80cdf7c5 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/test/model_test.py +++ b/mantidimaging/gui/windows/spectrum_viewer/test/model_test.py @@ -5,6 +5,7 @@ from pathlib import Path, PurePath from unittest import mock import io +import math import numpy as np import numpy.testing as npt @@ -195,7 +196,7 @@ def test_save_rits_dat(self): mock_stream, mock_path = self._make_mock_path_stream() with mock.patch.object(self.model, "save_roi_coords"): - self.model.save_rits(mock_path, True, ErrorMode.STANDARD_DEVIATION) + self.model.save_rits_roi(mock_path, ErrorMode.STANDARD_DEVIATION, self.model.get_roi("rits_roi")) mock_path.open.assert_called_once_with("w") self.assertIn("0.0\t0.0\t0.0", mock_stream.captured[0]) @@ -213,7 +214,7 @@ def test_save_rits_roi_dat(self): mock_stream, mock_path = self._make_mock_path_stream() with mock.patch.object(self.model, "save_roi_coords"): - self.model.save_rits(mock_path, True, ErrorMode.STANDARD_DEVIATION) + self.model.save_rits_roi(mock_path, ErrorMode.STANDARD_DEVIATION, self.model.get_roi("rits_roi")) mock_path.open.assert_called_once_with("w") self.assertIn("0.0\t0.0\t0.0", mock_stream.captured[0]) @@ -237,7 +238,7 @@ def test_save_rits_data_errors(self, _, error_mode, expected_error): mock_stream, mock_path = self._make_mock_path_stream() with mock.patch.object(self.model, "save_roi_coords"): with mock.patch.object(self.model, "export_spectrum_to_rits") as mock_export: - self.model.save_rits(mock_path, True, error_mode) + self.model.save_rits_roi(mock_path, error_mode, self.model.get_roi("rits_roi")) calculated_errors = mock_export.call_args[0][3] np.testing.assert_allclose(expected_error, calculated_errors, atol=1e-4) @@ -250,7 +251,7 @@ def test_invalid_error_mode_rits(self): mock_stream, mock_path = self._make_mock_path_stream() with mock.patch.object(self.model, "save_roi_coords"): - self.assertRaises(ValueError, self.model.save_rits, mock_path, True, None) + self.assertRaises(ValueError, self.model.save_rits_roi, mock_path, None, self.model.get_roi("rits_roi")) mock_path.open.assert_not_called() def test_save_rits_no_norm_err(self): @@ -262,7 +263,13 @@ def test_save_rits_no_norm_err(self): mock_stream, mock_path = self._make_mock_path_stream() with mock.patch.object(self.model, "save_roi_coords"): - self.assertRaises(ValueError, self.model.save_rits, mock_path, False, ErrorMode.STANDARD_DEVIATION) + self.assertRaises( + ValueError, + self.model.save_rits_roi, + mock_path, + ErrorMode.STANDARD_DEVIATION, + self.model.get_roi("rits_roi"), + ) mock_path.open.assert_not_called() def test_save_rits_no_tof_err(self): @@ -274,7 +281,13 @@ def test_save_rits_no_tof_err(self): mock_stream, mock_path = self._make_mock_path_stream() with mock.patch.object(self.model, "save_roi_coords"): - self.assertRaises(ValueError, self.model.save_rits, mock_path, True, ErrorMode.STANDARD_DEVIATION) + self.assertRaises( + ValueError, + self.model.save_rits_roi, + mock_path, + ErrorMode.STANDARD_DEVIATION, + self.model.get_roi("rits_roi"), + ) mock_path.open.assert_not_called() def test_WHEN_save_csv_called_THEN_save_roi_coords_called_WITH_correct_args(self): @@ -411,3 +424,78 @@ def test_error_modes(self): self.assertEqual(ErrorMode.get_by_value("Standard Deviation"), ErrorMode.STANDARD_DEVIATION) self.assertEqual(ErrorMode.get_by_value("Propagated"), ErrorMode.PROPAGATED) self.assertRaises(ValueError, ErrorMode.get_by_value, "") + + @parameterized.expand([ + ("larger_than_1", 1, 0, ValueError), # bin_size and step_size < 1 + ("bin_less_than_or_equal_to_step", 1, 2, ValueError), # bin_size <= step_size + ("less_than_roi", 10, 10, ValueError), # bin_size and step_size > min(roi.width, roi.height) + ("valid", 2, 1, None), # valid case + ]) + def test_validate_bin_and_step_size(self, _, bin_size, step_size, expected_exception): + roi = SensibleROI.from_list([0, 0, 5, 5]) + if expected_exception: + with self.assertRaises(expected_exception): + self.model.validate_bin_and_step_size(roi, bin_size, step_size) + else: + try: + self.model.validate_bin_and_step_size(roi, bin_size, step_size) + except ValueError: + self.fail("validate_bin_and_step_size() raised ValueError unexpectedly!") + + @parameterized.expand([ + (["5x5_bin_2_step_1", 5, 2, 1]), + (["5x5_bin_2_step_2", 5, 3, 2]), + (["7x7_bin_2_step_3", 7, 4, 1]), + ]) + @mock.patch.object(SpectrumViewerWindowModel, "save_rits_roi") + def test_save_rits_images_write_correct_number_of_files(self, _, roi_size, bin_size, step, mock_save_rits_roi): + stack, _ = self._set_sample_stack(with_tof=True) + norm = ImageStack(np.full([10, 11, 12], 2)) + stack.data[:, :, :5] *= 2 + self.model.set_new_roi("rits_roi") + self.model.set_roi("rits_roi", SensibleROI.from_list([0, 0, roi_size, roi_size])) + self.model.set_normalise_stack(norm) + roi = self.model.get_roi("rits_roi") + Mx, My = roi.width, roi.height + x_iterations = min(math.ceil(Mx / step), math.ceil((Mx - bin_size) / step) + 1) + y_iterations = min(math.ceil(My / step), math.ceil((My - bin_size) / step) + 1) + expected_number_of_calls = x_iterations * y_iterations + + _, mock_path = self._make_mock_path_stream() + with mock.patch.object(self.model, "save_roi_coords"): + self.model.save_rits_images(mock_path, ErrorMode.STANDARD_DEVIATION, bin_size, step) + self.assertEqual(mock_save_rits_roi.call_count, expected_number_of_calls) + + @mock.patch.object(SpectrumViewerWindowModel, "save_rits_roi") + def test_save_single_rits_spectrum(self, mock_save_rits_roi): + stack, _ = self._set_sample_stack(with_tof=True) + norm = ImageStack(np.full([10, 11, 12], 2)) + stack.data[:, :, :5] *= 2 + self.model.set_new_roi("rits_roi") + self.model.set_roi("rits_roi", SensibleROI.from_list([0, 0, 5, 5])) + self.model.set_normalise_stack(norm) + + _, mock_path = self._make_mock_path_stream() + with mock.patch.object(self.model, "save_roi_coords"): + self.model.save_single_rits_spectrum(mock_path, ErrorMode.STANDARD_DEVIATION) + mock_save_rits_roi.assert_called_once() + + @mock.patch.object(SpectrumViewerWindowModel, "export_spectrum_to_rits") + def test_save_rits_correct_transmision(self, mock_save_rits_roi): + stack, spectrum = self._set_sample_stack(with_tof=True) + norm = ImageStack(np.full([10, 11, 12], 2)) + for i in range(10): + stack.data[:, :, i] *= i + self.model.set_new_roi("rits_roi") + self.model.set_roi("rits_roi", SensibleROI.from_list([1, 0, 6, 4])) + self.model.set_normalise_stack(norm) + mock_path = mock.create_autospec(Path) + + self.model.save_rits_images(mock_path, ErrorMode.STANDARD_DEVIATION, 3, 1) + + self.assertEqual(6, len(mock_save_rits_roi.call_args_list)) + expected_means = [1, 1.5, 2, 1, 1.5, 2] # running average of [1, 2, 3, 4, 5], divided by 2 for normalisation + for call, expected_mean in zip(mock_save_rits_roi.call_args_list, expected_means, strict=True): + transmission = call[0][2] + expected_transmission = spectrum * expected_mean + npt.assert_array_equal(expected_transmission, transmission) diff --git a/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py b/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py index f9efd66fbfc..f7eda080585 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py +++ b/mantidimaging/gui/windows/spectrum_viewer/test/presenter_test.py @@ -189,17 +189,19 @@ def test_handle_export_csv(self, path_name: str, mock_save_csv: mock.Mock): mock_save_csv.assert_called_once_with(Path("/fake/path.csv"), False) @parameterized.expand(["/fake/path", "/fake/path.dat"]) - @mock.patch("mantidimaging.gui.windows.spectrum_viewer.model.SpectrumViewerWindowModel.save_rits") - def test_handle_rits_export(self, path_name: str, mock_save_rits: mock.Mock): + @mock.patch("mantidimaging.gui.windows.spectrum_viewer.model.SpectrumViewerWindowModel.save_rits_roi") + def test_handle_rits_export(self, path_name: str, mock_save_rits_roi: mock.Mock): self.view.get_rits_export_filename = mock.Mock(return_value=Path(path_name)) self.view.transmission_error_mode = "Standard Deviation" + self.presenter.model.set_new_roi("rits_roi") self.presenter.model.set_stack(generate_images()) self.presenter.handle_rits_export() self.view.get_rits_export_filename.assert_called_once() - mock_save_rits.assert_called_once_with(Path("/fake/path.dat"), False, ErrorMode.STANDARD_DEVIATION) + mock_save_rits_roi.assert_called_once_with(Path("/fake/path.dat"), ErrorMode.STANDARD_DEVIATION, + self.presenter.model.get_roi("rits_roi")) def test_WHEN_do_add_roi_called_THEN_new_roi_added(self): self.presenter.model.set_stack(generate_images()) diff --git a/mantidimaging/gui/windows/spectrum_viewer/view.py b/mantidimaging/gui/windows/spectrum_viewer/view.py index 829a7d30135..f541ca3a772 100644 --- a/mantidimaging/gui/windows/spectrum_viewer/view.py +++ b/mantidimaging/gui/windows/spectrum_viewer/view.py @@ -1,6 +1,7 @@ # Copyright (C) 2023 ISIS Rutherford Appleton Laboratory UKRI # SPDX - License - Identifier: GPL-3.0-or-later from __future__ import annotations + from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -198,7 +199,17 @@ def get_csv_filename(self) -> Optional[Path]: else: return None - def get_rits_export_filename(self) -> Optional[Path]: + def get_rits_export_directory(self) -> Path | None: + """ + Get the path to save the RITS file too + """ + path = QFileDialog.getExistingDirectory(self, "Select Directory", "", QFileDialog.ShowDirsOnly) + if path: + return Path(path) + else: + return None + + def get_rits_export_filename(self) -> Path | None: """ Get the path to save the RITS file too """ @@ -322,11 +333,11 @@ def image_output_mode(self) -> str: @property def bin_size(self) -> int: - return self.bin_size_spinbox.value() + return self.bin_size_spinBox.value() @property def bin_step(self) -> int: - return self.bin_step_spinbox.value() + return self.bin_step_spinBox.value() def set_binning_visibility(self) -> None: hide_binning = self.image_output_mode != "2D Binned"