|
| 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 |
0 commit comments