Skip to content

Commit 4f7e958

Browse files
authored
Merge pull request #399 from EIT-ALIVE/feature/mask_class
Add PixelMask class
2 parents 70ddd5a + e566d12 commit 4f7e958

5 files changed

Lines changed: 573 additions & 44 deletions

File tree

eitprocessing/datahandling/pixelmap.py

Lines changed: 40 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@
5151
from matplotlib.axes import Axes
5252
from matplotlib.image import AxesImage
5353

54+
from eitprocessing.roi import PixelMask
55+
5456
ColorType = str | tuple[float, float, float] | tuple[float, float, float, float] | float | Colormap
5557

5658
T = TypeVar("T", bound="PixelMap")
@@ -327,46 +329,60 @@ def _check_normalization_reference(reference_: float) -> None:
327329
if reference_ < 0:
328330
warnings.warn("Normalization by a negative number may lead to unexpected results.", UserWarning)
329331

330-
def threshold(
332+
def create_mask_from_threshold(
331333
self,
332-
threshold: npt.ArrayLike,
334+
threshold: float,
333335
*,
334336
comparator: Callable = np.greater_equal,
335337
absolute: bool = False,
336-
keep_sign: bool = False,
337-
fill_value: float = np.nan,
338-
**return_attrs: dict | None,
339-
) -> Self:
340-
"""Threshold the pixel map values.
338+
) -> PixelMask:
339+
"""Create a pixel mask from the pixel map based on threshold values.
341340
342-
This method applies a threshold to the pixel map values, setting values that do not meet the threshold condition
343-
to a specified fill value. The comparison is done using the provided comparator function (default is `>=`).
341+
The values of the pixel map are compared to the threshold values. By default, the comparator is `>=`
342+
(`np.greater_equal`), such that the resulting mask is 1.0 where the map values are at least the threshold
343+
values, and NaN elsewhere. The comparator can be set to any comparison function, e.g.`np.less`, a function from
344+
the `operator` module or custom function which takes pixel map values array and threshold as arguments, and
345+
returns a boolean array with the same shape as the array.
344346
345-
If `absolute` is True, the threshold is applied to the absolute values of the pixel map. If `keep_sign` is True,
346-
the sign of the original pixel values is retained when filling with the `fill_value`. Otherwise, the fill value
347-
is applied uniformly.
347+
If `absolute` is True, absolute values are compared to the threshold.
348348
349-
The `threshold` method returns a new instance of the same class with the modified values. Other attributes of
350-
the returned object can be set using keyword arguments.
349+
The shape of the pixel mask is the same as the shape of the pixel map.
351350
352351
Args:
353352
threshold (float): The threshold value.
354353
comparator (Callable): A function that compares pixel values against the threshold.
355354
absolute (bool): If True, apply the threshold to the absolute values of the pixel map.
356-
keep_sign (bool): If True, retain the sign of original values in the filled values.
357-
fill_value (float): The value to set for pixels that do not meet the threshold condition.
358-
**return_attrs (dict | None): Additional attributes to pass to the new PixelMap instance.
359355
360356
Returns:
361-
Self: A new object instance with the thresholded values.
357+
PixelMask:
358+
A PixelMask instance with values 1.0 where comparison is true, and NaN elsewhere.
359+
360+
Raises:
361+
TypeError: If `threshold` is not a float or `comparator` is not callable.
362+
363+
Examples:
364+
>>> pm = PixelMap([[0.1, 0.5, 0.9]])
365+
>>> mask = pm.create_mask_from_threshold(0.5)
366+
PixelMask(mask=array([[nan, 1., 1.]]))
367+
>>> mask.apply(pm)
368+
PixelMap(values=array([[nan, 0.5, 0.9]]), ...)
369+
370+
>>> mask = pm.create_mask_from_threshold(0.5, comparator=np.less)
371+
PixelMask(mask=array([[ 1., nan, nan]]))
362372
"""
363-
# TODO: add mode argument to allow for percentage/actual value thresholding
364-
compare_values = np.abs(self.values) if absolute else self.values
365-
sign = np.sign(self.values) if keep_sign else 1.0
366-
new_values = np.where(comparator(compare_values, threshold), self.values, fill_value * sign)
373+
if not isinstance(threshold, (float, np.floating, int, np.integer)):
374+
msg = "`threshold` must be a number."
375+
raise TypeError(msg)
376+
377+
if not callable(comparator):
378+
msg = "`comparator` must be a callable function."
379+
raise TypeError(msg)
380+
381+
from eitprocessing.roi import PixelMask
367382

368-
return_attrs = return_attrs or {}
369-
return replace(self, values=new_values, **return_attrs)
383+
compare_values = np.abs(self.values) if absolute else self.values
384+
mask_values = comparator(compare_values, threshold)
385+
return PixelMask(mask_values)
370386

371387
def imshow(
372388
self,

eitprocessing/roi/__init__.py

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Region of Interest selection and pixel masking.
2+
3+
This module contains tools for the selection of regions of interest and masking pixel data. The central class of this
4+
module is `PixelMask`. Any type of region of interest selection results in a `PixelMask` object. A mask can be applied
5+
to any pixel dataset (EITData, PixelMap) with the same shape.
6+
7+
Several default masks have been predefined. NB: the right side of the patient is to the left side of the EIT image and
8+
vice versa.
9+
10+
- `VENTRAL_MASK` includes only the first 16 rows;
11+
- `DORSAL_MASK` includes only the last 16 rows;
12+
- `ANATOMICAL_RIGHT_MASK` includes only the first 16 columns;
13+
- `ANATOMICAL_LEFT_MASK` includes only the last 16 columns;
14+
- `QUADRANT_1_MASK` includes the top right quadrant;
15+
- `QUADRANT_2_MASK` includes the top left quadrant;
16+
- `QUADRANT_3_MASK` includes the bottom right quadrant;
17+
- `QUADRANT_4_MASK` includes the bottom left quadrant;
18+
- `LAYER_1_MASK` includes only the first 8 rows;
19+
- `LAYER_2_MASK` includes only the second set of 8 rows;
20+
- `LAYER_3_MASK` includes only the third set of 8 rows;
21+
- `LAYER_4_MASK` includes only the last 8 rows.
22+
"""
23+
24+
import dataclasses
25+
import sys
26+
import warnings
27+
from dataclasses import InitVar, dataclass, field
28+
from dataclasses import replace as dataclass_replace
29+
from typing import TypeVar, overload
30+
31+
import numpy as np
32+
from typing_extensions import Self
33+
34+
from eitprocessing.datahandling.eitdata import EITData
35+
from eitprocessing.datahandling.pixelmap import PixelMap
36+
37+
T = TypeVar("T", np.ndarray, EITData, PixelMap)
38+
39+
40+
@dataclass(frozen=True)
41+
class PixelMask:
42+
"""Mask pixels by selecting or weighing them individually.
43+
44+
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
45+
less commonly a value between 0 and 1. NaN values indicate the pixel is not part of the region of interest, e.g.,
46+
falls outside the functional lung space, or is not part of the ventral region of the lung. A value of 1 indicates
47+
the pixel is included in the region of interest. A value between 0 and 1 indicates that the pixel is part of the
48+
region of interest, but is weighted, e.g., for a weighted summation of pixel values, or because the pixel is
49+
considered part of multiple regions of interest.
50+
51+
You can initialize a mask using an array or nested list. At initialization, the mask is converted to a floating
52+
point numpy array.
53+
54+
By default, 0-values are converted tot NaN. You can override this behaviour with `keep_zeros=True`. You can
55+
therefore create a mask by supplying boolean values, where `True` indicates the pixel is part of the region of
56+
interest (`True` equals 1), and `False` indicates it is not (`False` equals 0, and will be converted to NaN).
57+
58+
Since masking is not intended for other operations, masking values that are negative or higher than 1 will result in
59+
a `ValueError`. You can override this check with `suppress_value_range_error=True`.
60+
61+
A mask can be applied to any pixel dataset, such as an `EITData` object or a `PixelMap` object. The mask is applied
62+
to the last two dimensions of the data, which must match the shape of the mask. The mask is applied by multiplying
63+
each pixel in the dataset by the corresponding masking value. Multiplication by NaN always results in NaN.
64+
65+
Masks can be combined by either adding or multiplying them. Adding masks results in a mask that includes all pixels
66+
that are in either mask. Multiplying masks results in a mask that includes only pixels that are in both masks.
67+
68+
Example:
69+
```python
70+
>>> assert VENTRAL_MASK * ANATOMICAL_RIGHT_MASK == QUADRANT_1_MASK
71+
True # quadrant 1 is the ventral part of the right lung
72+
>>> assert DORSAL_MASK * ANATOMICAL_LEFT_MASK == QUADRANT_4_MASK
73+
True # quadrant 4 is the dorsal part of the left lung
74+
```
75+
76+
"""
77+
78+
mask: np.ndarray
79+
keep_zeros: InitVar[bool] = field(default=False, kw_only=True)
80+
suppress_value_range_error: InitVar[bool] = field(default=False, kw_only=True)
81+
suppress_zero_value_warning: InitVar[bool] = field(default=False, kw_only=True)
82+
83+
def __init__(
84+
self,
85+
mask: list | np.ndarray,
86+
keep_zeros: bool = False,
87+
suppress_value_range_error: bool = False,
88+
suppress_zero_conversion_warning: bool = False,
89+
):
90+
is_boolean_mask = np.array(mask).dtype == bool
91+
mask = np.array(mask, dtype=float)
92+
93+
if mask.ndim != 2: # noqa: PLR2004
94+
msg = f"Mask should be a 2D array, not {mask.ndim}D."
95+
raise ValueError(msg)
96+
97+
if (not suppress_value_range_error) and (np.nanmax(mask) > 1 or np.nanmin(mask) < 0):
98+
msg = "One or more mask values fall outside the range 0 to 1."
99+
exc = ValueError(msg)
100+
if sys.version_info >= (3, 11):
101+
exc.add_note("Provided values should normally be a boolean value or a number from 0 to 1.")
102+
exc.add_note(
103+
"In case you need a mask with values outside this range, "
104+
"provide `suppress_value_range_warning=True` when initializing a Mask."
105+
)
106+
raise exc
107+
108+
if (not keep_zeros) and np.any(mask == 0):
109+
if (not is_boolean_mask) and not suppress_zero_conversion_warning:
110+
msg = (
111+
"Mask contains 0 values, which will be converted to NaN. "
112+
"If you want to keep 0 values, provide `keep_zeros=True` when initializing a Mask. "
113+
"If you want to suppress this warning, provide `suppress_value_range_warning=True` "
114+
"or provide boolean values as input (only for non-weighted masks)."
115+
)
116+
warnings.warn(msg, UserWarning)
117+
118+
mask[mask == 0] = np.nan
119+
120+
mask.flags["WRITEABLE"] = False
121+
object.__setattr__(self, "mask", mask)
122+
123+
@overload
124+
def apply(self, data: np.ndarray) -> np.ndarray: ...
125+
126+
@overload
127+
def apply(self, data: EITData, **kwargs) -> EITData: ...
128+
129+
@overload
130+
def apply(self, data: "PixelMap", **kwargs) -> "PixelMap": ...
131+
132+
def apply(self, data, **kwargs):
133+
"""Apply pixel mask to data, returning a copy of the object with pixel values masked.
134+
135+
Data can be a numpy array, an EITData object or PixelMap object. In case of an EITData object, the mask will be
136+
applied to the `pixel_impedance` attribute. In case of a PixelMap, the mask will be applied to the `values`
137+
attribute.
138+
139+
The input data can have any dimension. The mask is applied to the last two dimensions. The size of the last two
140+
dimensions must match the size of the dimensions of the mask, and will generally (but do not have to) have the
141+
length 32.
142+
143+
The function returns the same data type as `data`. In case of `EITData` or `PixelMap` data, the object will have
144+
the provided label, or the original data label if none is provided.
145+
"""
146+
147+
def transform_and_mask(data: np.ndarray) -> np.ndarray:
148+
"""Transform the mask to ensure it has the correct shape for the given data, and apply the mask.
149+
150+
The mask is transformed by adding new axes to the beginning of the array, such that the number of dimensions
151+
match the number of dimensions of the data. The last two dimensions will contain the mask itself. This
152+
allows the mask to be applied correctly, even if the data has more than two dimensions (e.g., a 3D array
153+
with shape (time, channels, rows, cols)).
154+
"""
155+
if self.mask.shape[-2:] != data.shape[-2:]:
156+
msg = (
157+
f"Data shape {data.shape} does not match Mask shape {self.mask.shape}. "
158+
"The last two dimensions of the mask and data must match."
159+
)
160+
raise ValueError(msg)
161+
162+
mask = self.mask[tuple([np.newaxis] * (data.ndim - 2)) + (...,)] # noqa: RUF005
163+
# TODO: Fix line above when compatibility with Python 3.10 is no longer needed
164+
165+
return data * mask
166+
167+
match data:
168+
case np.ndarray():
169+
return transform_and_mask(data)
170+
case EITData():
171+
return dataclass_replace(data, pixel_impedance=transform_and_mask(data.pixel_impedance), **kwargs)
172+
case PixelMap():
173+
return data.update(values=transform_and_mask(data.values), **kwargs)
174+
case _:
175+
msg = f"Data should be an array, or EITData or PixelMap object, not {type(data)}."
176+
raise TypeError(msg)
177+
178+
@property
179+
def is_weighted(self) -> bool:
180+
"""Whether the mask multiplies any pixels with a number other than NaN or 1."""
181+
return not bool(np.all(np.isnan(self.mask) | (self.mask == 1.0)))
182+
183+
def __mul__(self, other: Self) -> Self:
184+
"""Combine masks by multiplying masking values."""
185+
return dataclasses.replace(self, mask=self.mask * other.mask)
186+
187+
def __add__(self, other: Self) -> Self:
188+
"""Combine masks by adding masking values.
189+
190+
Values are clipped at 1, so that the resulting mask does not contain values higher than 1.
191+
"""
192+
return dataclasses.replace(self, mask=np.clip(np.nansum([self.mask, other.mask], axis=0), a_min=None, a_max=1))
193+
194+
195+
LAYER_1_MASK = PixelMask(np.concat([np.ones((8, 32)), np.zeros((24, 32))], axis=0))
196+
LAYER_2_MASK = PixelMask(np.concat([np.zeros((8, 32)), np.ones((8, 32)), np.zeros((16, 32))], axis=0))
197+
LAYER_3_MASK = PixelMask(np.concat([np.zeros((16, 32)), np.ones((8, 32)), np.zeros((8, 32))], axis=0))
198+
LAYER_4_MASK = PixelMask(np.concat([np.zeros((24, 32)), np.ones((8, 32))], axis=0))
199+
200+
VENTRAL_MASK = LAYER_1_MASK + LAYER_2_MASK
201+
DORSAL_MASK = LAYER_3_MASK + LAYER_4_MASK
202+
203+
ANATOMICAL_RIGHT_MASK = PixelMask(np.concat([np.ones((32, 16)), np.zeros((32, 16))], axis=1))
204+
ANATOMICAL_LEFT_MASK = PixelMask(np.concat([np.zeros((32, 16)), np.ones((32, 16))], axis=1))
205+
206+
QUADRANT_1_MASK = VENTRAL_MASK * ANATOMICAL_RIGHT_MASK
207+
QUADRANT_2_MASK = VENTRAL_MASK * ANATOMICAL_LEFT_MASK
208+
QUADRANT_3_MASK = DORSAL_MASK * ANATOMICAL_RIGHT_MASK
209+
QUADRANT_4_MASK = DORSAL_MASK * ANATOMICAL_LEFT_MASK

pyproject.toml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,10 @@ command_line = "-m pytest"
9191
omit = ["tests/*", "*/tests/*"]
9292

9393
[tool.coverage.report]
94-
exclude_lines = ["if TYPE_CHECKING:"]
94+
exclude_lines = [
95+
"if TYPE_CHECKING:",
96+
"if sys\\.version_info"
97+
]
9598

9699
[tool.setuptools.packages.find]
97100
include = ["eitprocessing*", "eitprocessing.*"]

tests/test_pixelmap.py

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -135,29 +135,20 @@ def test_init_plot_parameters():
135135
assert pm11.plot_parameters.colorbar_kwargs == {"key": "another value"}
136136

137137

138-
def test_threshold():
139-
pm = PixelMap(np.reshape(np.arange(-50, 50), (10, 10)))
140-
pm_threshold = pm.threshold(10)
138+
def test_create_threshold_mask():
139+
pm = PixelMap([[-2, -1, 0, 1, 2]])
141140

142-
assert type(pm) is type(pm_threshold)
141+
mask = pm.create_mask_from_threshold(1)
142+
assert np.array_equal(mask.mask, [[np.nan, np.nan, np.nan, 1.0, 1.0]], equal_nan=True)
143143

144-
pm = ODCLMap(np.reshape(np.arange(-50, 50), (10, 10)))
145-
pm_threshold = pm.threshold(10)
144+
mask = pm.create_mask_from_threshold(1, comparator=np.greater)
145+
assert np.array_equal(mask.mask, [[np.nan, np.nan, np.nan, np.nan, 1.0]], equal_nan=True)
146146

147-
assert np.nanmin(pm_threshold.values) == 10
148-
non_nan_values = pm_threshold.values[~np.isnan(pm_threshold.values)]
149-
assert np.all(non_nan_values >= 10)
147+
mask = pm.create_mask_from_threshold(1, absolute=True)
148+
assert np.array_equal(mask.mask, [[1.0, 1.0, np.nan, 1.0, 1.0]], equal_nan=True)
150149

151-
pm_abs_threshold = pm.threshold(10, absolute=True)
152-
assert np.nanmin(pm_abs_threshold.values) == -50
153-
154-
non_nan_values = pm_abs_threshold.values[~np.isnan(pm_abs_threshold.values)]
155-
assert np.array_equal(non_nan_values, np.concatenate([np.arange(-50, -9), np.arange(10, 50)]))
156-
157-
pm_lower_threshold = pm.threshold(10, comparator=np.less)
158-
assert np.nanmax(pm_lower_threshold.values) == 9
159-
non_nan_values = pm_lower_threshold.values[~np.isnan(pm_lower_threshold.values)]
160-
assert np.array_equal(non_nan_values, np.arange(-50, 10))
150+
mask = pm.create_mask_from_threshold(1, comparator=np.less)
151+
assert np.array_equal(mask.mask, [[1.0, 1.0, 1.0, np.nan, np.nan]], equal_nan=True)
161152

162153

163154
def test_convert():

0 commit comments

Comments
 (0)