-
Notifications
You must be signed in to change notification settings - Fork 6
Add WatershedLungspace class #418
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
Changes from 23 commits
c5bca1f
e4ff11d
ddffe25
287a1ad
9e33fb0
3248b61
27905b3
5aa5e0c
dfc27bf
7291afd
5734478
cac298c
d43f8ff
ca952fb
1fc168b
f24510b
bdb9b1c
513f28b
23cb2bb
69f5c2b
c69dea0
5bac9b0
a67d7fe
4e0ff70
dff9811
207321f
251e48d
48b3dc9
d6e6cb4
17a8530
90a8b34
672e47d
7e82552
95af7b4
b3aa395
c85340e
f2fb3a6
0ae5e52
6eacd1b
fedebb7
8a1812a
9d69897
a9042fe
1f0aaa6
dd072d5
1c56417
8abfd6c
1b434c3
93c9a7e
51b42ef
3daeba8
b909af1
650c5f2
9c33751
f45fc39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| ::: eitprocessing.roi.watershed.WatershedLungspace |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -48,12 +48,14 @@ | |
| import sys | ||
| import warnings | ||
| from dataclasses import KW_ONLY, InitVar, asdict, dataclass, field, replace | ||
| from typing import TYPE_CHECKING, ClassVar, Literal, TypeVar, cast | ||
| from typing import TYPE_CHECKING, ClassVar, Literal, NoReturn, TypeVar, cast | ||
|
|
||
| import numpy as np | ||
| from numpy import typing as npt | ||
| from typing_extensions import Self | ||
|
|
||
| from eitprocessing.utils import make_capture | ||
|
|
||
| if TYPE_CHECKING: | ||
| from collections.abc import Callable, Sequence | ||
|
|
||
|
|
@@ -64,7 +66,7 @@ | |
| PixelMapT = TypeVar("PixelMapT", bound="PixelMap") | ||
|
|
||
|
|
||
| @dataclass(frozen=True, init=False) | ||
| @dataclass(frozen=True) | ||
| class PixelMap: | ||
| """Map representing a single value for each pixel. | ||
|
|
||
|
|
@@ -112,13 +114,15 @@ def __init__( | |
| f"{self.__class__.__name__} initialized with all NaN values. " | ||
| "This may lead to unexpected behavior in plotting or analysis.", | ||
| UserWarning, | ||
| stacklevel=2, | ||
| ) | ||
|
|
||
| if not self.allow_negative_values and not suppress_negative_warning and np.any(values < 0): | ||
| warnings.warn( | ||
| f"{self.__class__.__name__} initialized with negative values, but `allow_negative_values` is False. " | ||
| "This may lead to unexpected behavior in plotting or analysis.", | ||
| UserWarning, | ||
| stacklevel=2, | ||
| ) | ||
|
|
||
| values.flags.writeable = False # Make the values array immutable | ||
|
|
@@ -263,14 +267,18 @@ def _check_normalization_reference(reference_: float) -> None: | |
| ) | ||
| raise exc | ||
| if reference_ < 0: | ||
| warnings.warn("Normalization by a negative number may lead to unexpected results.", UserWarning) | ||
| warnings.warn( | ||
| "Normalization by a negative number may lead to unexpected results.", UserWarning, stacklevel=2 | ||
| ) | ||
|
|
||
| def create_mask_from_threshold( | ||
| self, | ||
| threshold: float, | ||
| *, | ||
| comparator: Callable = np.greater_equal, | ||
| absolute: bool = False, | ||
| use_magnitude: bool = False, | ||
| fraction_of_max: bool = False, | ||
| captures: dict | None = None, | ||
| ) -> PixelMask: | ||
| """Create a pixel mask from the pixel map based on threshold values. | ||
|
|
||
|
|
@@ -280,14 +288,21 @@ def create_mask_from_threshold( | |
| 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, absolute values are compared to the threshold. | ||
| If `use_magnitude` is True, absolute values are compared to the threshold. | ||
|
|
||
| If `fraction_of_max` is True, the threshold is interpreted as a fraction of the maximum value in the map. For | ||
| example, a threshold of 0.2 with `fraction_of_max=True` will create a mask where values are at least 20% of the | ||
| maximum value. | ||
|
|
||
| The shape of the pixel mask is the same as the shape of the pixel map. | ||
|
|
||
| Args: | ||
| threshold (float): The threshold value. | ||
| threshold (float): The threshold value or fraction, depending on `fraction_of_max` argument. | ||
| 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. | ||
| use_magnitude (bool): If True, apply the threshold to the absolute values of the pixel map. | ||
| fraction_of_max (bool): If True, interpret threshold as a fraction of the maximum value. | ||
| captures (dict | None): | ||
| An optional dictionary to capture intermediate results. If None, no captures are made. | ||
|
|
||
| Returns: | ||
| PixelMask: | ||
|
|
@@ -298,14 +313,17 @@ def create_mask_from_threshold( | |
|
|
||
| Examples: | ||
| >>> pm = PixelMap([[0.1, 0.5, 0.9]]) | ||
| >>> mask = pm.create_mask_from_threshold(0.5) | ||
| >>> mask = pm.create_mask_from_threshold(0.5) # Absolute threshold of 0.5 | ||
| PixelMask(mask=array([[nan, 1., 1.]])) | ||
|
|
||
| >>> mask = pm.create_mask_from_threshold(0.5, fraction_of_max=True) # 50% of max value (=0.45) | ||
| 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]])) | ||
| """ | ||
| capture = make_capture(captures) | ||
|
|
||
| if not isinstance(threshold, (float, np.floating, int, np.integer)): | ||
| msg = "`threshold` must be a number." | ||
| raise TypeError(msg) | ||
|
|
@@ -316,8 +334,22 @@ def create_mask_from_threshold( | |
|
|
||
| from eitprocessing.roi import PixelMask | ||
|
|
||
| compare_values = np.abs(self.values) if absolute else self.values | ||
| mask_values = comparator(compare_values, threshold) | ||
| compare_values = np.abs(self.values) if use_magnitude else self.values | ||
|
|
||
| if fraction_of_max: | ||
| # Convert the threshold to an absolute value | ||
| max_val = np.nanmax(compare_values) | ||
|
|
||
| if np.isnan(max_val): | ||
| msg = ("All values in the pixel map are NaN. Cannot compute fraction of maximum value.",) | ||
| raise ValueError(msg) | ||
|
|
||
| actual_threshold = threshold * max_val | ||
| else: | ||
| actual_threshold = threshold | ||
| capture("actual threshold", actual_threshold) | ||
|
|
||
| mask_values = comparator(compare_values, actual_threshold) | ||
| return PixelMask(mask_values) | ||
|
|
||
| @property | ||
|
|
@@ -419,7 +451,7 @@ def __mul__(self, other: npt.ArrayLike | float | PixelMap) -> PixelMap: | |
| def __truediv__(self, other: npt.ArrayLike | float | PixelMap) -> PixelMap: | ||
| other_values = self._validate_other(other) | ||
| if isinstance(other_values, np.ndarray) and 0 in other_values: | ||
| warnings.warn("Dividing by 0 will result in `np.nan` value.", UserWarning) | ||
| warnings.warn("Dividing by 0 will result in `np.nan` value.", UserWarning, stacklevel=2) | ||
|
|
||
| invalid = np.isnan(self.values) | np.isnan(other_values) | (other_values == 0) | ||
| new_values = np.divide(self.values, other_values, where=~invalid) | ||
|
|
@@ -431,7 +463,7 @@ def __truediv__(self, other: npt.ArrayLike | float | PixelMap) -> PixelMap: | |
| def __rtruediv__(self, other: npt.ArrayLike | float | PixelMap) -> PixelMap: | ||
| other_values = self._validate_other(other) | ||
| if 0 in self.values: | ||
| warnings.warn("Dividing by 0 will result in `np.nan` value.", UserWarning) | ||
| warnings.warn("Dividing by 0 will result in `np.nan` value.", UserWarning, stacklevel=2) | ||
| invalid = np.isnan(self.values) | np.isnan(other_values) | (self.values == 0) | ||
| new_values = np.divide(other_values, self.values, where=~invalid) | ||
| new_values[invalid] = np.nan | ||
|
|
@@ -490,6 +522,38 @@ def _get_values(map_: npt.ArrayLike | PixelMap) -> np.ndarray: | |
| aggregated_values = aggregator(stacked, axis=0) | ||
| return cls(values=aggregated_values, **return_attrs) | ||
|
|
||
| def to_boolean_array(self) -> np.ndarray: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should this method also have a keep_zeros = True option?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I made it |
||
| """Convert the pixel map values to a boolean array. | ||
|
|
||
| NaN values are replaced with False, and the resulting array is cast to boolean. | ||
|
|
||
| Returns: | ||
| np.ndarray: A 2D numpy array of booleans, where NaN values are replaced with False. | ||
| """ | ||
| return self.to_nan_nan_array(0, bool) | ||
|
|
||
| def to_integer_array(self) -> np.ndarray: | ||
| """Convert the pixel map values to an integer array. | ||
|
|
||
| NaN values are replaced with 0, and the resulting array is cast to integers. | ||
|
|
||
| Returns: | ||
| np.ndarray: A 2D numpy array of integers, where NaN values are replaced with 0. | ||
| """ | ||
| return self.to_non_nan_array(0, int) | ||
|
|
||
| def to_non_nan_array(self, fill_value: float = 0.0, dtype: type | np.dtype = float) -> np.ndarray: | ||
| """Convert the pixel map values to a numpy array, replacing NaN with a fill value. | ||
|
|
||
| Args: | ||
| fill_value (float): The value to replace NaN values with. Defaults to 0.0. | ||
| dtype (type | np.dtype): The data type of the resulting array. Defaults to float. | ||
|
|
||
| Returns: | ||
| np.ndarray: A 2D numpy array with NaN values replaced by `fill_value` and cast to the specified `dtype`. | ||
| """ | ||
| return np.nan_to_num(self.values, nan=fill_value).astype(dtype) | ||
|
|
||
|
|
||
| @dataclass(frozen=True, init=False) | ||
| class TIVMap(PixelMap): | ||
|
|
@@ -547,3 +611,24 @@ class SignedPendelluftMap(PixelMap): | |
| inflation starts), while negative values indicate pixels that have late inflation (after the global inflation | ||
| starts). | ||
| """ | ||
|
|
||
|
|
||
| @dataclass(frozen=True, init=False) | ||
| class IntegerMap(PixelMap): | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe add the IntegerMap and PixelMask plotting examples to the test_pixelmap_imshow notebook as well?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added a visual test. Also, I adapted how an IntegerMap works, since the implementation was a bit hacky. Now, a PixelMap has a dtype argument, making it possible to change how values are casted on init. Also, there was no unit test for integer maps. I added those. |
||
| """Pixel map with integer values. | ||
|
|
||
| This class is a wrapper around PixelMap that ensures the values are integers. It is useful for pixel maps that | ||
| represent labels or discrete values. | ||
| """ | ||
|
|
||
| def __init__(self, values: npt.ArrayLike, **kwargs): | ||
| values_ = np.asarray(values, dtype=int) | ||
| super().__init__(values=values_, **kwargs) | ||
| values_ = values_.astype(int) | ||
| values_.flags.writeable = False # Make the values array immutable | ||
| object.__setattr__(self, "values", values_) | ||
|
|
||
| def normalize(self, *args, **kwargs) -> NoReturn: | ||
| """Normalization is not supported for IntegerPixelMap.""" | ||
| msg = "Normalization is not supported for IntegerPixelMap." | ||
| raise NotImplementedError(msg) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is this for summing impedance within a ROI? Is it necessary to have a separate calculate_global_impedance then? Or I would remove the word "included" in the docstring for calculate_global_impedance since that suggests that there is some sort of selection as well.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The idea is that
get_summed_impedancewill replace the function ofcalculate_global_impedance. However, for now,calculate_global_impedanceis used to calculate the global impedance of the raw signal. The idea is to markcalculate_global_impedanceas deprecated in a future version, and remove it altogether in a later version.Do you think we should already mark it deprecated in this PR?It is not a good idea to make it deprecated in this PR, because it wil result in a DeprecationWarning every time you load EIT data.