Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
c5bca1f
Limit version of pytest
psomhorst Aug 18, 2025
e4ff11d
Set stack level and type of warnings
psomhorst Aug 18, 2025
ddffe25
Add subtraction to PixelMask class
psomhorst Aug 13, 2025
287a1ad
Add support for PixelMask plotting in PixelMap plotting
psomhorst Aug 18, 2025
9e33fb0
Add function to get summed impedance to EITData
psomhorst Aug 18, 2025
3248b61
Rename absolute argument to use_magnitude and add fraction_of_max arg…
psomhorst Aug 18, 2025
27905b3
Add captures to PixelMap.apply
psomhorst Aug 18, 2025
5aa5e0c
Add IntegerMap including PlotConfig
psomhorst Aug 18, 2025
dfc27bf
Add to_..._array convenience methods to PixelMap
psomhorst Aug 18, 2025
7291afd
Add default PixelMapPlotConfig to registry
psomhorst Aug 18, 2025
5734478
Update deprecated function name
psomhorst Aug 18, 2025
cac298c
Add PixelMask.values alias to .mask
psomhorst Aug 18, 2025
d43f8ff
Add PixelMask.shape property
psomhorst Aug 18, 2025
ca952fb
Add __eq__ method to check equivalence of PixelMasks
psomhorst Aug 18, 2025
1fc168b
Replace fixed default masks with generated masks, based on shape
psomhorst Aug 18, 2025
f24510b
Add contour and surface plots to PixelMap
psomhorst Aug 18, 2025
bdb9b1c
Update tests for PixelMasks
psomhorst Aug 18, 2025
513f28b
Update tests for PixelMap
psomhorst Aug 18, 2025
23cb2bb
Add option for simulated data to Vendor
psomhorst Aug 18, 2025
69f5c2b
Add WatershedLungspace class and accompanying tests
psomhorst Aug 18, 2025
c69dea0
Add notebook with Watershed demonstration
psomhorst Aug 18, 2025
5bac9b0
Add watershed and notebook to documentation
psomhorst Aug 18, 2025
a67d7fe
Update PixelMaskCollection tests to use real EITData
psomhorst Aug 18, 2025
4e0ff70
Improve IntegerMap with dtype class variable and lossless conversion …
psomhorst Aug 22, 2025
dff9811
Add test value conversion at init
psomhorst Aug 22, 2025
207321f
Update IntegerMap plot config
psomhorst Aug 22, 2025
251e48d
Update notebook to show integer map plotting
psomhorst Aug 22, 2025
48b3dc9
Update to_boolean_array to optionally keep zero values as True
psomhorst Aug 22, 2025
d6e6cb4
Update pixelmap tests
psomhorst Aug 22, 2025
17a8530
Add check for no markers inside TIV mask
psomhorst Aug 22, 2025
90a8b34
Fix issue where BreathDetection fails if no peaks are found
psomhorst Aug 22, 2025
672e47d
Raise error if no breaths are found in breath detection
psomhorst Aug 22, 2025
7e82552
Add test for watershed with no amplitude
psomhorst Aug 22, 2025
95af7b4
Add test for captures
psomhorst Aug 22, 2025
b3aa395
Fix langauge issues
psomhorst Aug 22, 2025
c85340e
Add TIVLungspace class
psomhorst Aug 25, 2025
f2fb3a6
Add tests for TIVLungspace class
psomhorst Aug 25, 2025
0ae5e52
Add TIV lungspace to docs
psomhorst Aug 25, 2025
6eacd1b
Use TIVLungspace in WatershedLungspace
psomhorst Aug 25, 2025
fedebb7
Add AmplitudeMap, AmplitudeMapPlotConfig
psomhorst Aug 25, 2025
8a1812a
Update notebook to show AmplitudeMap
psomhorst Aug 25, 2025
9d69897
Create separate AmplitudeLungspace class, and remove modes from TIVLu…
psomhorst Aug 25, 2025
a9042fe
Update documentation for AmplitudeLungspace
psomhorst Aug 25, 2025
1f0aaa6
Update Watershed to use AmplitudeLungspace
psomhorst Aug 25, 2025
dd072d5
Re-add warning for AmplitudeLungspace
psomhorst Aug 25, 2025
1c56417
Update tests for AmplitudeLungspace
psomhorst Aug 25, 2025
8abfd6c
Add comment about test coverage
psomhorst Aug 25, 2025
1b434c3
Add AmplitudeMap and IntegerMap to pixelmap docs
psomhorst Aug 25, 2025
93c9a7e
Fix typo
psomhorst Aug 25, 2025
51b42ef
Only consider pixels that are not all-NaN when aggregating
psomhorst Aug 25, 2025
3daeba8
Suppress zero warning when subtracting masks
psomhorst Aug 25, 2025
b909af1
Add exception when trying to normalize a all-NaN PixelMap
psomhorst Aug 25, 2025
650c5f2
Add explicit check for all-NaN values
psomhorst Aug 25, 2025
9c33751
Catch or prevent warnings in tests
psomhorst Aug 25, 2025
f45fc39
Test butterworth filters with apply instead of deprecated apply_filter
psomhorst Aug 25, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api/roi/watershed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: eitprocessing.roi.watershed.WatershedLungspace
2 changes: 1 addition & 1 deletion eitprocessing/datahandling/continuousdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def __post_init__(self) -> None:
"`sample_frequency` is set to `None`. This will not be supported in future versions. "
"Provide a sample frequency when creating a ContinuousData object."
)
warnings.warn(msg, DeprecationWarning)
warnings.warn(msg, DeprecationWarning, stacklevel=2)

if (lv := len(self.values)) != (lt := len(self.time)):
msg = f"The number of time points ({lt}) does not match the number of values ({lv})."
Expand Down
42 changes: 38 additions & 4 deletions eitprocessing/datahandling/eitdata.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
from __future__ import annotations

import warnings
from dataclasses import dataclass, field
from dataclasses import InitVar, dataclass, field
from enum import auto
from pathlib import Path
from typing import TYPE_CHECKING, TypeVar
from typing import TYPE_CHECKING, Any, TypeVar

import numpy as np
from strenum import LowercaseStrEnum
from strenum import LowercaseStrEnum # TODO: EOL 3.10: replace with native StrEnum

from eitprocessing.datahandling import DataContainer
from eitprocessing.datahandling.continuousdata import ContinuousData
from eitprocessing.datahandling.mixins.slicing import SelectByTime

if TYPE_CHECKING:
Expand Down Expand Up @@ -49,8 +50,9 @@ class is meant to hold data from (part of) a singular continuous measurement.
description: str = field(default="", compare=False, repr=False)
name: str | None = field(default=None, compare=False, repr=False)
pixel_impedance: np.ndarray = field(repr=False, kw_only=True)
suppress_simulated_warning: InitVar[bool] = False

def __post_init__(self):
def __post_init__(self, suppress_simulated_warning: bool) -> None:
if not self.label:
self.label = f"{self.__class__.__name__}_{id(self)}"

Expand All @@ -64,12 +66,21 @@ def __post_init__(self):
msg = f"The number of time points ({lt}) does not match the number of pixel impedance values ({lv})."
raise ValueError(msg)

if not suppress_simulated_warning and self.vendor == Vendor.SIMULATED:
warnings.warn(
"The simulated vendor is used for testing purposes. "
"It is not a real vendor and should not be used in production code.",
UserWarning,
stacklevel=2,
)

@property
def framerate(self) -> float:
"""Deprecated alias to `sample_frequency`."""
warnings.warn(
"The `framerate` attribute has been deprecated. Use `sample_frequency` instead.",
DeprecationWarning,
stacklevel=2,
)
return self.sample_frequency

Expand Down Expand Up @@ -135,6 +146,28 @@ def _sliced_copy(
def __len__(self):
return self.pixel_impedance.shape[0]

def get_summed_impedance(self, *, return_label: str | None = None, **return_kwargs) -> ContinuousData:

Copy link
Copy Markdown
Contributor

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.

@psomhorst psomhorst Aug 22, 2025

Copy link
Copy Markdown
Contributor Author

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_impedance will replace the function of calculate_global_impedance. However, for now, calculate_global_impedance is used to calculate the global impedance of the raw signal. The idea is to mark calculate_global_impedance as 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.

"""Return a ContinuousData-object with the same time axis and summed pixel values over time.

Args:
return_label: The label of the returned object; defaults to 'summed <label>' where '<label>' is the label of
the current object.
**return_kwargs: Keyword arguments for the creation of the returned object.
"""
summed_impedance = np.nansum(self.pixel_impedance, axis=(1, 2))

if return_label is None:
return_label = f"summed {self.label}"

return_kwargs_: dict[str, Any] = {
"name": return_label,
"unit": "AU",
"category": "impedance",
"sample_frequency": self.sample_frequency,
} | return_kwargs

return ContinuousData(label=return_label, time=np.copy(self.time), values=summed_impedance, **return_kwargs_)

def calculate_global_impedance(self) -> np.ndarray:
"""Return the global impedance, i.e. the sum of all included pixels at each frame."""
return np.nansum(self.pixel_impedance, axis=(1, 2))
Expand All @@ -148,3 +181,4 @@ class Vendor(LowercaseStrEnum):
SENTEC = auto()
DRAGER = DRAEGER
DRÄGER = DRAEGER # noqa: PLC2401
SIMULATED = auto()
6 changes: 3 additions & 3 deletions eitprocessing/datahandling/loading/draeger.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def load_from_single_path(
f"the first frame selected ({first_frame}, total frames: "
f"{total_frames}).\n {n_frames} frames will be loaded."
)
warnings.warn(msg)
warnings.warn(msg, RuntimeWarning, stacklevel=2)

# We need to load 1 frame before first actual frame to check if there is an event marker. Data for the pre-first
# (dummy) frame will be removed from self at the end of this function.
Expand Down Expand Up @@ -194,7 +194,7 @@ def _estimate_sample_frequency(time: np.ndarray, sample_frequency: float | None)
f"Provided sample frequency ({sample_frequency}) does not match "
f"the estimated sample frequency ({estimated_sample_frequency})."
)
warnings.warn(msg, RuntimeWarning)
warnings.warn(msg, RuntimeWarning, stacklevel=2)

return sample_frequency

Expand Down Expand Up @@ -274,7 +274,7 @@ def _read_frame(
if ((previous_marker is not None) and (event_marker > previous_marker)) or (index == 0 and event_text):
events.append((frame_time, Event(event_marker, event_text)))
if timing_error:
warnings.warn("A timing error was encountered during loading.")
warnings.warn("A timing error was encountered during loading.", RuntimeWarning, stacklevel=2)
# TODO: expand on what timing errors are in some documentation.
if min_max_flag in (1, -1):
phases.append((frame_time, min_max_flag))
Expand Down
4 changes: 2 additions & 2 deletions eitprocessing/datahandling/loading/sentec.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ def load_from_single_path( # noqa: C901, PLR0912
"Sample frequency value found in file. "
f"The sample frequency value will be set to {sample_frequency:.2f}"
)
warnings.warn(msg)
warnings.warn(msg, RuntimeWarning, stacklevel=2)

else:
fh.seek(payload_size, os.SEEK_CUR)
Expand All @@ -108,7 +108,7 @@ def load_from_single_path( # noqa: C901, PLR0912
f"the first frame selected ({first_frame}, total frames: "
f"{index}).\n {n_frames} frames will be loaded."
)
warnings.warn(msg)
warnings.warn(msg, RuntimeWarning, stacklevel=2)

if not sample_frequency:
sample_frequency = SENTEC_SAMPLE_FREQUENCY
Expand Down
2 changes: 2 additions & 0 deletions eitprocessing/datahandling/loading/timpel.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ def load_from_single_path(
f"than the available number ({data.shape[0]}) of frames after "
f"the first frame selected ({first_frame}).\n"
f"{data.shape[0]} frames have been loaded.",
RuntimeWarning,
stacklevel=2,
)
nframes = data.shape[0]

Expand Down
4 changes: 2 additions & 2 deletions eitprocessing/datahandling/mixins/slicing.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def select_by_index(
object is attached.
"""
if start is None and end is None:
warnings.warn("No starting or end timepoint was selected.")
warnings.warn("No starting or end timepoint was selected.", UserWarning, stacklevel=2)
return self

start = start if start is not None else 0
Expand Down Expand Up @@ -144,7 +144,7 @@ def select_by_time( # noqa: D417
return copy.deepcopy(self)

if start_time is None and end_time is None:
warnings.warn("No starting or end timepoint was selected.")
warnings.warn("No starting or end timepoint was selected.", UserWarning, stacklevel=2)
return self

if not np.all(np.sort(self.time) == self.time):
Expand Down
113 changes: 99 additions & 14 deletions eitprocessing/datahandling/pixelmap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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:
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this method also have a keep_zeros = True option?

@psomhorst psomhorst Aug 22, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made it pm.to_boolean_array(zero=True) and to_non_nan_array(nan=...), both comparable to np.nan_to_num(nan=...).

"""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):
Expand Down Expand Up @@ -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):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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?

@psomhorst psomhorst Aug 22, 2025

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)
Loading