-
Notifications
You must be signed in to change notification settings - Fork 6
Add PixelMask class #399
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
Merged
Merged
Add PixelMask class #399
Changes from 8 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
9f1a08f
Add Mask class
psomhorst c9d20f8
Add temporary PixelMap dummy class
psomhorst a35b551
Rename Mask to PixelMask and update
psomhorst eaa2251
Add tests for PixelMask
psomhorst 7426c1f
Revert "Add temporary PixelMap dummy class"
psomhorst 9982393
Add PixelMask PixelMap test
psomhorst b1c526c
Replace PixelMap.threshold function with create_mask_from_treshold()
psomhorst 80a57e0
Fix typo
psomhorst 0d16ada
Fix naming and documentation based on suggestions by JF
psomhorst abf0ee0
Fix for Python 3.10 compatibility issue
psomhorst f50583c
Add warning when non-boolean 0-values are converted to NaN
psomhorst e566d12
Ignore version-specific code in coverage reports
psomhorst File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| """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 lung 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; | ||
| - `RIGHT_LUNG_MASK` includes only the first 16 columns; | ||
| - `LEFT_LUNG_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 | ||
| from dataclasses import InitVar, dataclass, field | ||
| from dataclasses import replace as dataclass_replace | ||
| from typing import Self, TypeVar, overload | ||
|
|
||
| import numpy as np | ||
|
|
||
| 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 a nested list. At initialization, the mask is converted to a numpy array. | ||
|
psomhorst marked this conversation as resolved.
Outdated
|
||
|
|
||
| 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 + RIGHT_LUNG_MASK == QUADRANT_1_MASK | ||
|
psomhorst marked this conversation as resolved.
Outdated
|
||
| True # quadrant 1 is the ventral part of the right lung | ||
| >>> assert DORSAL_MASK * LEFT_LUNG_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) | ||
|
|
||
| def __init__(self, mask: list | np.ndarray, keep_zeros: bool = False, suppress_value_range_error: bool = False): | ||
| 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): | ||
|
psomhorst marked this conversation as resolved.
|
||
| 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: | ||
| 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[*([np.newaxis] * (data.ndim - 2)), ...] | ||
| 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 | ||
|
|
||
| RIGHT_LUNG_MASK = PixelMask(np.concat([np.ones((32, 16)), np.zeros((32, 16))], axis=1)) | ||
| LEFT_LUNG_MASK = PixelMask(np.concat([np.zeros((32, 16)), np.ones((32, 16))], axis=1)) | ||
|
|
||
| QUADRANT_1_MASK = VENTRAL_MASK * RIGHT_LUNG_MASK | ||
| QUADRANT_2_MASK = VENTRAL_MASK * LEFT_LUNG_MASK | ||
| QUADRANT_3_MASK = DORSAL_MASK * RIGHT_LUNG_MASK | ||
| QUADRANT_4_MASK = DORSAL_MASK * LEFT_LUNG_MASK | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.