Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

1945 binning #2017

Merged
merged 12 commits into from
Feb 5, 2024
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
#1945 : Export Binned RITS Formatted Data within Spectrum Viewer.
120 changes: 106 additions & 14 deletions mantidimaging/gui/windows/spectrum_viewer/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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))
Expand All @@ -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)

Expand All @@ -171,29 +190,28 @@ 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
@return: a numpy array representing the standard deviation of the transmission
"""
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
@return: a numpy array representing the error of the transmission
"""
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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 15 additions & 7 deletions mantidimaging/gui/windows/spectrum_viewer/presenter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
100 changes: 94 additions & 6 deletions mantidimaging/gui/windows/spectrum_viewer/test/model_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Expand All @@ -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])
Expand All @@ -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)
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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):
Expand Down Expand Up @@ -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)
Loading
Loading