diff --git a/eitprocessing/datahandling/pixelmap.py b/eitprocessing/datahandling/pixelmap.py index 20999104a..77f46e2bc 100644 --- a/eitprocessing/datahandling/pixelmap.py +++ b/eitprocessing/datahandling/pixelmap.py @@ -51,6 +51,8 @@ from matplotlib.axes import Axes from matplotlib.image import AxesImage + from eitprocessing.roi import PixelMask + ColorType = str | tuple[float, float, float] | tuple[float, float, float, float] | float | Colormap T = TypeVar("T", bound="PixelMap") @@ -327,46 +329,60 @@ def _check_normalization_reference(reference_: float) -> None: if reference_ < 0: warnings.warn("Normalization by a negative number may lead to unexpected results.", UserWarning) - def threshold( + def create_mask_from_threshold( self, - threshold: npt.ArrayLike, + threshold: float, *, comparator: Callable = np.greater_equal, absolute: bool = False, - keep_sign: bool = False, - fill_value: float = np.nan, - **return_attrs: dict | None, - ) -> Self: - """Threshold the pixel map values. + ) -> PixelMask: + """Create a pixel mask from the pixel map based on threshold values. - This method applies a threshold to the pixel map values, setting values that do not meet the threshold condition - to a specified fill value. The comparison is done using the provided comparator function (default is `>=`). + The values of the pixel map are compared to the threshold values. By default, the comparator is `>=` + (`np.greater_equal`), such that the resulting mask is 1.0 where the map values are at least the threshold + values, and NaN elsewhere. The comparator can be set to any comparison function, e.g.`np.less`, a function from + the `operator` module or custom function which takes pixel map values array and threshold as arguments, and + returns a boolean array with the same shape as the array. - If `absolute` is True, the threshold is applied to the absolute values of the pixel map. If `keep_sign` is True, - the sign of the original pixel values is retained when filling with the `fill_value`. Otherwise, the fill value - is applied uniformly. + If `absolute` is True, absolute values are compared to the threshold. - The `threshold` method returns a new instance of the same class with the modified values. Other attributes of - the returned object can be set using keyword arguments. + The shape of the pixel mask is the same as the shape of the pixel map. Args: threshold (float): The threshold value. comparator (Callable): A function that compares pixel values against the threshold. absolute (bool): If True, apply the threshold to the absolute values of the pixel map. - keep_sign (bool): If True, retain the sign of original values in the filled values. - fill_value (float): The value to set for pixels that do not meet the threshold condition. - **return_attrs (dict | None): Additional attributes to pass to the new PixelMap instance. Returns: - Self: A new object instance with the thresholded values. + PixelMask: + A PixelMask instance with values 1.0 where comparison is true, and NaN elsewhere. + + Raises: + TypeError: If `threshold` is not a float or `comparator` is not callable. + + Examples: + >>> pm = PixelMap([[0.1, 0.5, 0.9]]) + >>> mask = pm.create_mask_from_threshold(0.5) + PixelMask(mask=array([[nan, 1., 1.]])) + >>> mask.apply(pm) + PixelMap(values=array([[nan, 0.5, 0.9]]), ...) + + >>> mask = pm.create_mask_from_threshold(0.5, comparator=np.less) + PixelMask(mask=array([[ 1., nan, nan]])) """ - # TODO: add mode argument to allow for percentage/actual value thresholding - compare_values = np.abs(self.values) if absolute else self.values - sign = np.sign(self.values) if keep_sign else 1.0 - new_values = np.where(comparator(compare_values, threshold), self.values, fill_value * sign) + if not isinstance(threshold, (float, np.floating, int, np.integer)): + msg = "`threshold` must be a number." + raise TypeError(msg) + + if not callable(comparator): + msg = "`comparator` must be a callable function." + raise TypeError(msg) + + from eitprocessing.roi import PixelMask - return_attrs = return_attrs or {} - return replace(self, values=new_values, **return_attrs) + compare_values = np.abs(self.values) if absolute else self.values + mask_values = comparator(compare_values, threshold) + return PixelMask(mask_values) def imshow( self, diff --git a/eitprocessing/roi/__init__.py b/eitprocessing/roi/__init__.py new file mode 100644 index 000000000..5b18fb81f --- /dev/null +++ b/eitprocessing/roi/__init__.py @@ -0,0 +1,209 @@ +"""Region of Interest selection and pixel masking. + +This module contains tools for the selection of regions of interest and masking pixel data. The central class of this +module is `PixelMask`. Any type of region of interest selection results in a `PixelMask` object. A mask can be applied +to any pixel dataset (EITData, PixelMap) with the same shape. + +Several default masks have been predefined. NB: the right side of the patient is to the left side of the EIT image and +vice versa. + +- `VENTRAL_MASK` includes only the first 16 rows; +- `DORSAL_MASK` includes only the last 16 rows; +- `ANATOMICAL_RIGHT_MASK` includes only the first 16 columns; +- `ANATOMICAL_LEFT_MASK` includes only the last 16 columns; +- `QUADRANT_1_MASK` includes the top right quadrant; +- `QUADRANT_2_MASK` includes the top left quadrant; +- `QUADRANT_3_MASK` includes the bottom right quadrant; +- `QUADRANT_4_MASK` includes the bottom left quadrant; +- `LAYER_1_MASK` includes only the first 8 rows; +- `LAYER_2_MASK` includes only the second set of 8 rows; +- `LAYER_3_MASK` includes only the third set of 8 rows; +- `LAYER_4_MASK` includes only the last 8 rows. +""" + +import dataclasses +import sys +import warnings +from dataclasses import InitVar, dataclass, field +from dataclasses import replace as dataclass_replace +from typing import TypeVar, overload + +import numpy as np +from typing_extensions import Self + +from eitprocessing.datahandling.eitdata import EITData +from eitprocessing.datahandling.pixelmap import PixelMap + +T = TypeVar("T", np.ndarray, EITData, PixelMap) + + +@dataclass(frozen=True) +class PixelMask: + """Mask pixels by selecting or weighing them individually. + + A mask is a 2D array with a value for each pixel. Most often, this value is NaN (`np.nan`, 'not a number') or 1, and + less commonly a value between 0 and 1. NaN values indicate the pixel is not part of the region of interest, e.g., + falls outside the functional lung space, or is not part of the ventral region of the lung. A value of 1 indicates + the pixel is included in the region of interest. A value between 0 and 1 indicates that the pixel is part of the + region of interest, but is weighted, e.g., for a weighted summation of pixel values, or because the pixel is + considered part of multiple regions of interest. + + You can initialize a mask using an array or nested list. At initialization, the mask is converted to a floating + point numpy array. + + By default, 0-values are converted tot NaN. You can override this behaviour with `keep_zeros=True`. You can + therefore create a mask by supplying boolean values, where `True` indicates the pixel is part of the region of + interest (`True` equals 1), and `False` indicates it is not (`False` equals 0, and will be converted to NaN). + + Since masking is not intended for other operations, masking values that are negative or higher than 1 will result in + a `ValueError`. You can override this check with `suppress_value_range_error=True`. + + A mask can be applied to any pixel dataset, such as an `EITData` object or a `PixelMap` object. The mask is applied + to the last two dimensions of the data, which must match the shape of the mask. The mask is applied by multiplying + each pixel in the dataset by the corresponding masking value. Multiplication by NaN always results in NaN. + + Masks can be combined by either adding or multiplying them. Adding masks results in a mask that includes all pixels + that are in either mask. Multiplying masks results in a mask that includes only pixels that are in both masks. + + Example: + ```python + >>> assert VENTRAL_MASK * ANATOMICAL_RIGHT_MASK == QUADRANT_1_MASK + True # quadrant 1 is the ventral part of the right lung + >>> assert DORSAL_MASK * ANATOMICAL_LEFT_MASK == QUADRANT_4_MASK + True # quadrant 4 is the dorsal part of the left lung + ``` + + """ + + mask: np.ndarray + keep_zeros: InitVar[bool] = field(default=False, kw_only=True) + suppress_value_range_error: InitVar[bool] = field(default=False, kw_only=True) + suppress_zero_value_warning: InitVar[bool] = field(default=False, kw_only=True) + + def __init__( + self, + mask: list | np.ndarray, + keep_zeros: bool = False, + suppress_value_range_error: bool = False, + suppress_zero_conversion_warning: bool = False, + ): + is_boolean_mask = np.array(mask).dtype == bool + mask = np.array(mask, dtype=float) + + if mask.ndim != 2: # noqa: PLR2004 + msg = f"Mask should be a 2D array, not {mask.ndim}D." + raise ValueError(msg) + + if (not suppress_value_range_error) and (np.nanmax(mask) > 1 or np.nanmin(mask) < 0): + msg = "One or more mask values fall outside the range 0 to 1." + exc = ValueError(msg) + if sys.version_info >= (3, 11): + exc.add_note("Provided values should normally be a boolean value or a number from 0 to 1.") + exc.add_note( + "In case you need a mask with values outside this range, " + "provide `suppress_value_range_warning=True` when initializing a Mask." + ) + raise exc + + if (not keep_zeros) and np.any(mask == 0): + if (not is_boolean_mask) and not suppress_zero_conversion_warning: + msg = ( + "Mask contains 0 values, which will be converted to NaN. " + "If you want to keep 0 values, provide `keep_zeros=True` when initializing a Mask. " + "If you want to suppress this warning, provide `suppress_value_range_warning=True` " + "or provide boolean values as input (only for non-weighted masks)." + ) + warnings.warn(msg, UserWarning) + + mask[mask == 0] = np.nan + + mask.flags["WRITEABLE"] = False + object.__setattr__(self, "mask", mask) + + @overload + def apply(self, data: np.ndarray) -> np.ndarray: ... + + @overload + def apply(self, data: EITData, **kwargs) -> EITData: ... + + @overload + def apply(self, data: "PixelMap", **kwargs) -> "PixelMap": ... + + def apply(self, data, **kwargs): + """Apply pixel mask to data, returning a copy of the object with pixel values masked. + + Data can be a numpy array, an EITData object or PixelMap object. In case of an EITData object, the mask will be + applied to the `pixel_impedance` attribute. In case of a PixelMap, the mask will be applied to the `values` + attribute. + + The input data can have any dimension. The mask is applied to the last two dimensions. The size of the last two + dimensions must match the size of the dimensions of the mask, and will generally (but do not have to) have the + length 32. + + The function returns the same data type as `data`. In case of `EITData` or `PixelMap` data, the object will have + the provided label, or the original data label if none is provided. + """ + + def transform_and_mask(data: np.ndarray) -> np.ndarray: + """Transform the mask to ensure it has the correct shape for the given data, and apply the mask. + + The mask is transformed by adding new axes to the beginning of the array, such that the number of dimensions + match the number of dimensions of the data. The last two dimensions will contain the mask itself. This + allows the mask to be applied correctly, even if the data has more than two dimensions (e.g., a 3D array + with shape (time, channels, rows, cols)). + """ + if self.mask.shape[-2:] != data.shape[-2:]: + msg = ( + f"Data shape {data.shape} does not match Mask shape {self.mask.shape}. " + "The last two dimensions of the mask and data must match." + ) + raise ValueError(msg) + + mask = self.mask[tuple([np.newaxis] * (data.ndim - 2)) + (...,)] # noqa: RUF005 + # TODO: Fix line above when compatibility with Python 3.10 is no longer needed + + return data * mask + + match data: + case np.ndarray(): + return transform_and_mask(data) + case EITData(): + return dataclass_replace(data, pixel_impedance=transform_and_mask(data.pixel_impedance), **kwargs) + case PixelMap(): + return data.update(values=transform_and_mask(data.values), **kwargs) + case _: + msg = f"Data should be an array, or EITData or PixelMap object, not {type(data)}." + raise TypeError(msg) + + @property + def is_weighted(self) -> bool: + """Whether the mask multiplies any pixels with a number other than NaN or 1.""" + return not bool(np.all(np.isnan(self.mask) | (self.mask == 1.0))) + + def __mul__(self, other: Self) -> Self: + """Combine masks by multiplying masking values.""" + return dataclasses.replace(self, mask=self.mask * other.mask) + + def __add__(self, other: Self) -> Self: + """Combine masks by adding masking values. + + Values are clipped at 1, so that the resulting mask does not contain values higher than 1. + """ + return dataclasses.replace(self, mask=np.clip(np.nansum([self.mask, other.mask], axis=0), a_min=None, a_max=1)) + + +LAYER_1_MASK = PixelMask(np.concat([np.ones((8, 32)), np.zeros((24, 32))], axis=0)) +LAYER_2_MASK = PixelMask(np.concat([np.zeros((8, 32)), np.ones((8, 32)), np.zeros((16, 32))], axis=0)) +LAYER_3_MASK = PixelMask(np.concat([np.zeros((16, 32)), np.ones((8, 32)), np.zeros((8, 32))], axis=0)) +LAYER_4_MASK = PixelMask(np.concat([np.zeros((24, 32)), np.ones((8, 32))], axis=0)) + +VENTRAL_MASK = LAYER_1_MASK + LAYER_2_MASK +DORSAL_MASK = LAYER_3_MASK + LAYER_4_MASK + +ANATOMICAL_RIGHT_MASK = PixelMask(np.concat([np.ones((32, 16)), np.zeros((32, 16))], axis=1)) +ANATOMICAL_LEFT_MASK = PixelMask(np.concat([np.zeros((32, 16)), np.ones((32, 16))], axis=1)) + +QUADRANT_1_MASK = VENTRAL_MASK * ANATOMICAL_RIGHT_MASK +QUADRANT_2_MASK = VENTRAL_MASK * ANATOMICAL_LEFT_MASK +QUADRANT_3_MASK = DORSAL_MASK * ANATOMICAL_RIGHT_MASK +QUADRANT_4_MASK = DORSAL_MASK * ANATOMICAL_LEFT_MASK diff --git a/pyproject.toml b/pyproject.toml index 357208f5d..c71005871 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,10 @@ command_line = "-m pytest" omit = ["tests/*", "*/tests/*"] [tool.coverage.report] -exclude_lines = ["if TYPE_CHECKING:"] +exclude_lines = [ + "if TYPE_CHECKING:", + "if sys\\.version_info" +] [tool.setuptools.packages.find] include = ["eitprocessing*", "eitprocessing.*"] diff --git a/tests/test_pixelmap.py b/tests/test_pixelmap.py index 053e96e66..8d2378c87 100644 --- a/tests/test_pixelmap.py +++ b/tests/test_pixelmap.py @@ -135,29 +135,20 @@ def test_init_plot_parameters(): assert pm11.plot_parameters.colorbar_kwargs == {"key": "another value"} -def test_threshold(): - pm = PixelMap(np.reshape(np.arange(-50, 50), (10, 10))) - pm_threshold = pm.threshold(10) +def test_create_threshold_mask(): + pm = PixelMap([[-2, -1, 0, 1, 2]]) - assert type(pm) is type(pm_threshold) + mask = pm.create_mask_from_threshold(1) + assert np.array_equal(mask.mask, [[np.nan, np.nan, np.nan, 1.0, 1.0]], equal_nan=True) - pm = ODCLMap(np.reshape(np.arange(-50, 50), (10, 10))) - pm_threshold = pm.threshold(10) + mask = pm.create_mask_from_threshold(1, comparator=np.greater) + assert np.array_equal(mask.mask, [[np.nan, np.nan, np.nan, np.nan, 1.0]], equal_nan=True) - assert np.nanmin(pm_threshold.values) == 10 - non_nan_values = pm_threshold.values[~np.isnan(pm_threshold.values)] - assert np.all(non_nan_values >= 10) + mask = pm.create_mask_from_threshold(1, absolute=True) + assert np.array_equal(mask.mask, [[1.0, 1.0, np.nan, 1.0, 1.0]], equal_nan=True) - pm_abs_threshold = pm.threshold(10, absolute=True) - assert np.nanmin(pm_abs_threshold.values) == -50 - - non_nan_values = pm_abs_threshold.values[~np.isnan(pm_abs_threshold.values)] - assert np.array_equal(non_nan_values, np.concatenate([np.arange(-50, -9), np.arange(10, 50)])) - - pm_lower_threshold = pm.threshold(10, comparator=np.less) - assert np.nanmax(pm_lower_threshold.values) == 9 - non_nan_values = pm_lower_threshold.values[~np.isnan(pm_lower_threshold.values)] - assert np.array_equal(non_nan_values, np.arange(-50, 10)) + mask = pm.create_mask_from_threshold(1, comparator=np.less) + assert np.array_equal(mask.mask, [[1.0, 1.0, 1.0, np.nan, np.nan]], equal_nan=True) def test_convert(): diff --git a/tests/test_pixelmask.py b/tests/test_pixelmask.py new file mode 100644 index 000000000..7845b6d2b --- /dev/null +++ b/tests/test_pixelmask.py @@ -0,0 +1,310 @@ +import warnings + +import numpy as np +import pytest + +from eitprocessing.datahandling.pixelmap import PixelMap +from eitprocessing.datahandling.sequence import Sequence +from eitprocessing.roi import ( + ANATOMICAL_LEFT_MASK, + ANATOMICAL_RIGHT_MASK, + DORSAL_MASK, + LAYER_1_MASK, + LAYER_2_MASK, + LAYER_3_MASK, + LAYER_4_MASK, + QUADRANT_1_MASK, + QUADRANT_2_MASK, + QUADRANT_3_MASK, + QUADRANT_4_MASK, + VENTRAL_MASK, + PixelMask, +) + + +def test_pixelmask_init_with_boolean_array(): + values = np.random.default_rng().random((10, 10)) > 0.5 + assert np.all((values == True) | (values == False)) # noqa: E712 + + mask = PixelMask(values) + + assert np.all((np.isnan(mask.mask)) | (mask.mask == 1.0)) + assert np.array_equal(np.isnan(mask.mask), ~values) + assert np.array_equal(mask.mask == 1.0, values) + + +def test_pixelmask_init_with_integer_array(): + values = np.random.default_rng().integers(0, 2, (10, 10)) + mask = PixelMask(values) + + assert np.all((np.isnan(mask.mask)) | (mask.mask == 1.0)) + assert np.array_equal(np.isnan(mask.mask), values == 0) + assert np.array_equal(mask.mask == 1.0, values == 1) + + +def test_pixelmask_init_with_float_array_non_weighted(): + values = np.random.default_rng().random((10, 10)).round() + assert np.all((values == 0.0) | (values == 1.0)) + + mask = PixelMask(values) + + assert np.all((np.isnan(mask.mask)) | (mask.mask == 1.0)) + assert np.array_equal(np.isnan(mask.mask), values == 0.0) + assert np.array_equal(mask.mask == 1.0, values == 1.0) + + +def test_pixelmask_init_with_float_array_weighted(): + values = np.random.default_rng().random((10, 10)) + values[values < 0.5] = 0.0 + + mask = PixelMask(values) + + assert np.array_equal(np.isnan(mask.mask), values < 0.5) # NaN for values < 0.5 + assert np.array_equal(~np.isnan(mask.mask), values >= 0.5) # non-NaN for values >= 0.5 + assert np.array_equal(mask.mask[~np.isnan(mask.mask)], values[~np.isnan(mask.mask)]) # non-NaN values are unaltered + + +def test_pixelmask_init_with_list(): + values = [[1, 0, 1], [0, 1, 0]] + mask = PixelMask(values) + + assert np.array_equal(mask.mask, np.array([[1.0, np.nan, 1.0], [np.nan, 1.0, np.nan]]), equal_nan=True) + + +def test_pixelmask_init_keep_zeros_true(): + values = np.random.default_rng().random((10, 10)) + values[values < 0.5] = 0.0 + + mask = PixelMask(values, keep_zeros=True) + + assert np.array_equal(mask.mask == 0.0, values < 0.5) # NaN for values < 0.5 + assert np.array_equal(mask.mask[mask.mask >= 0.5], values[mask.mask >= 0.5]) # non-NaN values are unaltered + + +def test_pixelmask_warns_when_keep_zeros_false(): + values = np.random.default_rng().random((10, 10)) + values[values < 0.5] = 0.0 + + with pytest.warns(UserWarning, match="Mask contains 0 values, which will be converted to NaN"): + _ = PixelMask(values) + + +def test_pixelmask_does_not_warn_when_boolean_array_has_zeros(): + values = np.random.default_rng().random((10, 10)) + values = values > 0.5 # boolean array + + with warnings.catch_warnings(record=True) as w: + _ = PixelMask(values) # as boolean array + assert len(w) == 0 + + with pytest.warns(UserWarning, match="Mask contains 0 values, which will be converted to NaN"): + _ = PixelMask(values.astype(int)) # the same values, but as integer array + + +def test_pixelmask_init_invalid_dtype(): + with pytest.raises(ValueError, match="could not convert string to float"): + PixelMask([["string"]]) + + with pytest.raises(TypeError, match="float\\(\\) argument must be a string or a (real )?number"): + PixelMask([[lambda x: x]]) + + with pytest.raises(TypeError, match="float\\(\\) argument must be a string or a real number, not 'complex'"): + PixelMask([[complex(1, 2)]]) + + # is converted to float + PixelMask(np.array(np.random.default_rng().random((10, 10)), dtype="object")) + + +def test_pixelmask_init_values_outside_range(): + with pytest.raises(ValueError, match="One or more mask values fall outside the range 0 to 1"): + _ = PixelMask(np.array([[1.5, 0.2], [0.5, 0.8]])) + + with pytest.raises(ValueError, match="One or more mask values fall outside the range 0 to 1"): + _ = PixelMask(np.array([[0.5, -0.2], [0.5, 0.8]])) + + +def test_pixelmask_init_suppress_value_range_warning(): + _ = PixelMask(np.array([[1.5, 0.2], [0.5, 0.8]]), suppress_value_range_error=True) + _ = PixelMask(np.array([[0.5, -0.2], [0.5, 0.8]]), suppress_value_range_error=True) + + +def test_pixelmask_init_dimension_mismatch(): + with pytest.raises(ValueError, match="Mask should be a 2D array, not 3D"): + _ = PixelMask(np.ones((3, 3, 3))) + + with pytest.raises(ValueError, match="Mask should be a 2D array, not 1D"): + _ = PixelMask(np.ones((3,))) + + +def test_pixelmask_is_weighted_true(): + pm = PixelMask(np.random.default_rng().random((10, 10))) + assert pm.is_weighted + + +def test_pixelmask_is_weighted_false(): + pm = PixelMask(np.round(np.random.default_rng().random((10, 10)))) + assert not pm.is_weighted + + +def test_pixelmask_apply_numpy_array(): + pm = PixelMask([[0, 1], [1, 0]]) + data = np.array([[1, 2], [3, 4]]) + result = pm.apply(data) + assert np.array_equal(result, np.array([[np.nan, 2], [3, np.nan]]), equal_nan=True) + + pm = PixelMask([[0.1, 0.9], [0.2, 0.5]]) + data = np.array([[1, 2], [3, 4]]) + result = pm.apply(data) + assert np.allclose(result, np.array([[0.1, 1.8], [0.6, 2.0]])) + + +def test_pixelmask_apply_numpy_array_higher_dimensions(): + pm = PixelMask([[0, 1], [1, 0]]) + data = np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]) + result = pm.apply(data) + assert np.array_equal(result, np.array([[[np.nan, 2], [3, np.nan]], [[np.nan, 6], [7, np.nan]]]), equal_nan=True) + + +def test_pixelmask_apply_eitdata(draeger1: Sequence): + eit_data = draeger1.eit_data["raw"] + mask = PixelMask(np.full((32, 32), np.nan)) + masked_eit_data = mask.apply(eit_data) + + assert masked_eit_data.pixel_impedance.shape == eit_data.pixel_impedance.shape + assert np.all(np.isnan(masked_eit_data.pixel_impedance)) + + mask_values = np.full((32, 32), np.nan) + mask_values[10:23, 10:23] = 1.0 # let center pixels pass + mask = PixelMask(mask_values) + masked_eit_data = mask.apply(eit_data) + + assert np.array_equal(masked_eit_data.pixel_impedance[:, 10:23, 10:23], eit_data.pixel_impedance[:, 10:23, 10:23]) + assert np.all(np.isnan(masked_eit_data.pixel_impedance[:, :10, :])) + assert np.all(np.isnan(masked_eit_data.pixel_impedance[:, 23:, :])) + assert np.all(np.isnan(masked_eit_data.pixel_impedance[:, :, :10])) + assert np.all(np.isnan(masked_eit_data.pixel_impedance[:, :, 23:])) + + +def test_pixelmask_apply_pixelmap(): + pmap = PixelMap(np.random.default_rng().random((10, 10))) + mask = PixelMask(np.random.default_rng().random((10, 10)) > 0.5) # Mask with some values > 0.5 + masked_pmap = mask.apply(pmap) + + assert masked_pmap.shape == pmap.shape + assert np.all(np.isnan(masked_pmap.values[np.isnan(mask.mask)])) + assert np.array_equal(pmap.values[~np.isnan(mask.mask)], masked_pmap.values[~np.isnan(mask.mask)]) + + +def test_pixelmask_apply_invalid_type(): + with pytest.raises(TypeError, match="Data should be an array, or EITData or PixelMap object, not "): + PixelMask([[0]]).apply("invalid type") + + with pytest.raises(TypeError, match="Data should be an array, or EITData or PixelMap object, not "): + PixelMask([[1]]).apply([[1]]) + + +def test_pixelmask_apply_dimension_mismatch(): + pm = PixelMask(np.random.default_rng().random((10, 10))) + data = np.random.default_rng().random((10, 10, 10, 9)) + + with pytest.raises(ValueError, match="Data shape .* does not match Mask shape .*"): + pm.apply(data) + + +def test_pixelmask_multiply_masks(): + pm1 = PixelMask([[0, 0.1], [0.2, 0.3]], suppress_value_range_error=True) + pm2 = PixelMask([[0.1, 0.2], [0.3, 0.4]], suppress_value_range_error=True) + pm3 = pm1 * pm2 + assert np.allclose(pm3.mask, np.array([[np.nan, 0.02], [0.06, 0.12]]), equal_nan=True) + + +def test_pixelmask_add_masks(): + pm1 = PixelMask([[0, 0, 1, 1], [0.2, 0.3, 0, 0]]) + pm2 = PixelMask([[1, 0, 0, 1], [0, 0.5, 1, 0]]) + pm3 = pm1 + pm2 + assert np.array_equal(pm3.mask, np.array([[1, np.nan, 1, 1], [0.2, 0.8, 1, np.nan]]), equal_nan=True) + + +def test_predefined_layer_masks(): + assert np.all(LAYER_1_MASK.mask[:8, :] == 1.0) + assert np.all(np.isnan(LAYER_1_MASK.mask[8:, :])) + + assert np.all(LAYER_2_MASK.mask[8:16, :] == 1.0) + assert np.all(np.isnan(LAYER_2_MASK.mask[:8, :])) + assert np.all(np.isnan(LAYER_2_MASK.mask[16, :])) + + assert np.all(LAYER_3_MASK.mask[16:24, :] == 1.0) + assert np.all(np.isnan(LAYER_3_MASK.mask[:16, :])) + assert np.all(np.isnan(LAYER_3_MASK.mask[24, :])) + + assert np.all(LAYER_4_MASK.mask[24:, :] == 1.0) + assert np.all(np.isnan(LAYER_4_MASK.mask[:24, :])) + + assert np.array_equal((LAYER_1_MASK + LAYER_2_MASK + LAYER_3_MASK + LAYER_4_MASK).mask, np.ones((32, 32))) + assert np.array_equal( + (LAYER_1_MASK * LAYER_2_MASK * LAYER_3_MASK * LAYER_4_MASK).mask, np.full((32, 32), np.nan), equal_nan=True + ) + + +def test_predefined_ventral_dorsal_masks(): + assert np.all(VENTRAL_MASK.mask[:16, :] == 1.0) + assert np.all(np.isnan(VENTRAL_MASK.mask[16:, :])) + + assert np.all(DORSAL_MASK.mask[16:, :] == 1.0) + assert np.all(np.isnan(DORSAL_MASK.mask[:16, :])) + + assert np.array_equal(np.ones((32, 32)), (VENTRAL_MASK + DORSAL_MASK).mask) + assert np.array_equal(np.full((32, 32), np.nan), (VENTRAL_MASK * DORSAL_MASK).mask, equal_nan=True) + + +def test_predefined_left_right_masks(): + assert np.all(ANATOMICAL_RIGHT_MASK.mask[:, :16] == 1.0) + assert np.all(np.isnan(ANATOMICAL_RIGHT_MASK.mask[:, 16:])) + + assert np.all(ANATOMICAL_LEFT_MASK.mask[:, 16:] == 1.0) + assert np.all(np.isnan(ANATOMICAL_LEFT_MASK.mask[:, :16])) + + assert np.array_equal(np.ones((32, 32)), (ANATOMICAL_RIGHT_MASK + ANATOMICAL_LEFT_MASK).mask) + assert np.array_equal( + np.full((32, 32), np.nan), (ANATOMICAL_RIGHT_MASK * ANATOMICAL_LEFT_MASK).mask, equal_nan=True + ) + + +def test_predefined_quadrant_masks(): + assert np.all(QUADRANT_1_MASK.mask[:16, :16] == 1.0) + assert np.all(np.isnan(QUADRANT_1_MASK.mask[16:, :])) + assert np.all(np.isnan(QUADRANT_1_MASK.mask[:, 16:])) + + assert np.all(QUADRANT_2_MASK.mask[:16, 16:] == 1.0) + assert np.all(np.isnan(QUADRANT_2_MASK.mask[16:, :])) + assert np.all(np.isnan(QUADRANT_2_MASK.mask[:, :16])) + + assert np.all(QUADRANT_3_MASK.mask[16:, :16] == 1.0) + assert np.all(np.isnan(QUADRANT_3_MASK.mask[:16, :])) + assert np.all(np.isnan(QUADRANT_3_MASK.mask[:, 16:])) + + assert np.all(QUADRANT_4_MASK.mask[16:, 16:] == 1.0) + assert np.all(np.isnan(QUADRANT_4_MASK.mask[:16, :])) + assert np.all(np.isnan(QUADRANT_4_MASK.mask[:, :16])) + + assert np.array_equal( + (QUADRANT_1_MASK + QUADRANT_2_MASK + QUADRANT_3_MASK + QUADRANT_4_MASK).mask, np.ones((32, 32)) + ) + assert np.array_equal( + (QUADRANT_1_MASK * QUADRANT_2_MASK * QUADRANT_3_MASK * QUADRANT_4_MASK).mask, + np.full((32, 32), np.nan), + equal_nan=True, + ) + + +def test_pixelmask_immutability(): + pm = PixelMask([[0, 1], [1, 0]]) + original_mask = pm.mask.copy() + + with pytest.raises(ValueError, match="assignment destination is read-only"): + pm.mask[0, 0] = 2 # Attempt to modify the mask should raise an error + + with pytest.raises(AttributeError, match="cannot assign to field 'mask'"): + pm.mask = np.array([[1, 0], [0, 1]]) + + assert np.array_equal(pm.mask, original_mask, equal_nan=True) # Ensure the mask is unchanged