Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
90b7c40
Create first draft for ROILabeller object
JulietteFrancovich Aug 8, 2025
5f9c45a
Added region label
JulietteFrancovich Aug 11, 2025
a4ecea0
Return PixelMask and rename class
JulietteFrancovich Aug 11, 2025
d10ae23
Add docstring
JulietteFrancovich Aug 11, 2025
e023b93
Add tests
JulietteFrancovich Aug 11, 2025
6151157
Update for more flexibility with connectivity
JulietteFrancovich Aug 11, 2025
7e7bc9a
Update parameter name for minimum ROI size
JulietteFrancovich Aug 11, 2025
35dc2ac
Update parameter name
JulietteFrancovich Aug 11, 2025
73ae1d0
Rename class
JulietteFrancovich Aug 11, 2025
20dee08
Update connectivity arguments and documentation
JulietteFrancovich Aug 11, 2025
a45cc0b
Change to frozen dataclass and update connectivity parsing
JulietteFrancovich Aug 11, 2025
a9ba31e
Change warning to exception
JulietteFrancovich Aug 11, 2025
b5cceea
Change selection_region to apply
JulietteFrancovich Aug 11, 2025
9b75147
Improve apply method docstring
JulietteFrancovich Aug 11, 2025
2d6f260
Solve ambiguous truth value error in parsing
JulietteFrancovich Aug 11, 2025
250560b
Update tests
JulietteFrancovich Aug 11, 2025
1aa0536
Create first draft for ROILabeller object
JulietteFrancovich Aug 8, 2025
cb2607d
Added region label
JulietteFrancovich Aug 11, 2025
f87dbbb
Return PixelMask and rename class
JulietteFrancovich Aug 11, 2025
d74d547
Add docstring
JulietteFrancovich Aug 11, 2025
04b3664
Add tests
JulietteFrancovich Aug 11, 2025
95d0fc8
Update for more flexibility with connectivity
JulietteFrancovich Aug 11, 2025
5fbd7dd
Update parameter name for minimum ROI size
JulietteFrancovich Aug 11, 2025
68d46b9
Update parameter name
JulietteFrancovich Aug 11, 2025
efbe471
Rename class
JulietteFrancovich Aug 11, 2025
d8a4b98
Update connectivity arguments and documentation
JulietteFrancovich Aug 11, 2025
58b01da
Change to frozen dataclass and update connectivity parsing
JulietteFrancovich Aug 11, 2025
5e54268
Change warning to exception
JulietteFrancovich Aug 11, 2025
c9fd4e3
Change selection_region to apply
JulietteFrancovich Aug 11, 2025
c9ab1f2
Improve apply method docstring
JulietteFrancovich Aug 11, 2025
a5e850a
Solve ambiguous truth value error in parsing
JulietteFrancovich Aug 11, 2025
cbc1164
Update tests
JulietteFrancovich Aug 11, 2025
4a17af0
Add FilterROIBySize to documentation.
psomhorst Aug 11, 2025
b7a280d
Update docstring
JulietteFrancovich Aug 11, 2025
e8c97c9
Update connectivity inputs
JulietteFrancovich Aug 11, 2025
a4841ce
add default min_region_size
JulietteFrancovich Aug 11, 2025
9e13a67
Update scipy.ndimage import
JulietteFrancovich Aug 11, 2025
3debff0
Update module name
JulietteFrancovich Aug 11, 2025
cdf67da
Changed filename for mkdocs
JulietteFrancovich Aug 11, 2025
206b489
Change PixelMaskCollection to allow empty collection
JulietteFrancovich Aug 12, 2025
69e76b0
Update tests for empty collection
JulietteFrancovich Aug 12, 2025
3d3344f
Update init validation function
JulietteFrancovich Aug 12, 2025
b8c362f
Update to work with empty mask collection
JulietteFrancovich Aug 12, 2025
373810a
Update PixelMaskCollection to allow no masks argument
psomhorst Aug 12, 2025
965b162
Fix testing values with all-nan array
psomhorst Aug 12, 2025
02546db
Create emtpy PixelMaskCollection without argument
psomhorst Aug 12, 2025
b5489d8
Remove unnecessary suppress_value_range_error
JulietteFrancovich Aug 13, 2025
e2142ce
Commit suggestion from review comments
JulietteFrancovich Aug 13, 2025
8cce970
Add suggestion from review comments
JulietteFrancovich Aug 13, 2025
1c1cd82
Change long error message to error message and note
JulietteFrancovich Aug 13, 2025
eab1993
Update tests to remove unneccessary code
JulietteFrancovich Aug 13, 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/filterbysize.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: eitprocessing.roi.filter_by_size
5 changes: 3 additions & 2 deletions eitprocessing/roi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,15 @@ def __init__(
msg = f"Mask should be a 2D array, not {mask.ndim}D."
raise ValueError(msg)

if (not suppress_all_nan_warning) and np.all(np.isnan(mask)):
all_nan = np.all(np.isnan(mask))
if (not suppress_all_nan_warning) and all_nan:
warnings.warn(
"Mask contains only NaN values. This will create in all-NaN results when applied.",
UserWarning,
stacklevel=2,
)

if (not suppress_value_range_error) and (np.nanmax(mask) > 1 or np.nanmin(mask) < 0):
if (not all_nan) and (not suppress_value_range_error) and (np.nanmax(mask) > 1 or np.nanmin(mask) < 0):
msg = "One or more mask values fall outside the range 0 to 1."
exc = ValueError(msg)
if sys.version_info >= (3, 11):
Expand Down
106 changes: 106 additions & 0 deletions eitprocessing/roi/filter_by_size.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import sys
from dataclasses import dataclass
from typing import Literal

import numpy as np
import scipy.ndimage as ndi

from eitprocessing.roi import PixelMask
from eitprocessing.roi.pixelmaskcollection import PixelMaskCollection

DEFAULT_MIN_REGION_SIZE = 10


@dataclass(frozen=True, kw_only=True)
class FilterROIBySize:
"""Class for labeling and selecting connected regions in a PixelMask.

This dataclass identifies and labels regions of interest (ROIs) in a PixelMask.
You can specify the minimum region size and the connectivity structure.

Connectivity:
For 2D images, connectivity determines which pixels are considered neighbors when labeling regions.
- 1-connectivity (also called 4-connectivity in image processing):
Only directly adjacent pixels (up, down, left, right) are considered neighbors.
- 2-connectivity (also called 8-connectivity in image processing):
Both directly adjacent and diagonal pixels are considered neighbors.

The default value is 1-connectivity.

If a custom array is provided, it must be a boolean or integer array specifying the neighborhood structure.
See the documentation for `scipy.ndimage.label`:
https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.label.html


Args:
min_region_size (int): Minimum number of pixels in a region for it to be considered an ROI. Defaults to 10.
connectivity (Literal[1, 2] | np.ndarray): Connectivity type or custom array. Defaults to 1.
"""

min_region_size: int = DEFAULT_MIN_REGION_SIZE
connectivity: Literal[1, 2] | np.ndarray = 1

def __post_init__(self):
object.__setattr__(
self, "connectivity", self._parse_connectivity(self.connectivity)
) # required with frozen objects

def _parse_connectivity(self, connectivity: int | np.ndarray) -> np.ndarray:
match connectivity:
case np.ndarray():
return connectivity
case 1 | 2:
return ndi.generate_binary_structure(2, connectivity)
case int(): # another integer
msg = f"Unsupported connectivity value: {connectivity}. Must be 1, 2 or a custom structure array."
raise ValueError(msg)
case _:
msg = f"Unsupported connectivity type: {type(connectivity)}. Must be an integer or numpy array."
raise ValueError(msg)

def apply(self, mask: PixelMask) -> PixelMask:
"""Identify connected regions in a PixelMask, filter them by size, and return a combined mask.

This method:

1. Converts the input PixelMask into a binary representation where all non-NaN values
are treated as part of a region and NaNs are excluded.
2. Labels connected components using the specified connectivity structure.
3. Keeps only those connected regions whose pixel count is greater than or equal
to `self.min_region_size`.
4. Combines the remaining regions into a single PixelMask.

Args:
mask (PixelMask):
Input mask where non-NaN pixels are considered valid region pixels.
NaNs are treated as excluded/background.

Returns:
PixelMask:
A new PixelMask representing the union of all regions that meet the
`min_region_size` criterion.

Raises:
RuntimeError:
If no connected regions meet the size threshold (e.g., mask is empty,
all regions are too small, or connectivity is too restrictive).
"""
binary_array = ~np.isnan(mask.mask)
labeled_array, num_features = ndi.label(binary_array, structure=self.connectivity)
mask_collection = PixelMaskCollection()
for region_label in range(1, num_features + 1):
region = labeled_array == region_label
if np.sum(region) >= self.min_region_size:
mask_collection = mask_collection.add(PixelMask(region))

if not mask_collection.masks:
msg = "No regions found above min_region_size threshold."
exc = RuntimeError(msg)
if sys.version_info >= (3, 11):
exc.add_note(
"This can occur if your input mask is empty, all regions are too small,"
" or your connectivity is too restrictive."
)
raise exc

return mask_collection.combine()
31 changes: 26 additions & 5 deletions eitprocessing/roi/pixelmaskcollection.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,49 +58,60 @@ class PixelMaskCollection:

def __init__(
self,
masks: dict[Hashable, PixelMask] | frozendict[Hashable, PixelMask] | list[PixelMask],
masks: dict[Hashable, PixelMask] | frozendict[Hashable, PixelMask] | list[PixelMask] | None = None,
*,
label: str | None = None,
):
object.__setattr__(self, "masks", self._validate_and_convert_input_masks(masks))
object.__setattr__(self, "label", label)

def _validate_and_convert_input_masks(
self, masks: dict[Hashable, PixelMask] | frozendict[Hashable, PixelMask] | list[PixelMask]
self, masks: dict[Hashable, PixelMask] | frozendict[Hashable, PixelMask] | list[PixelMask] | None
) -> frozendict[Hashable, PixelMask]:
"""Validate the input masks and convert to a frozendict.

The input masks are valid if:
- The input is empty (allowed — empty collection).
- The input is a list with all labelled or all anonymous PixelMask instances.
- The input is a dictionary where each PixelMask instance's label matches the key.
"""
# Check for type before checking for length, e.g., and empty string should raise a TypeError, not ValueError
if masks is None:
return frozendict()

# Check for type first
if not isinstance(masks, (list, dict, frozendict)):
msg = f"Expected a list or a dictionary, got {type(masks)}."
raise TypeError(msg)

# Allow empty collections
if not masks:
msg = "A PixelMaskCollection should contain at least one mask."
raise ValueError(msg)
return frozendict()

# Convert list -> dict if needed
if isinstance(masks, list):
masks = self._convert_list_to_dict(masks)

# Validate PixelMask instances
if any(not isinstance(mask, PixelMask) for mask in masks.values()):
msg = "All items must be instances of PixelMask."
raise TypeError(msg)

# Label consistency checks
all_none_labels = all(mask.label is None for mask in masks.values())
all_str_labels = all(isinstance(mask.label, str) for mask in masks.values())

if not (all_none_labels or all_str_labels):
msg = "Cannot mix labelled and anonymous masks in a collection."
raise ValueError(msg)

if all_none_labels and set(masks.keys()) != set(range(len(masks))):
msg = "Anonymous masks should be indexed with consecutive integers starting from 0."
raise ValueError(msg)

if all_str_labels and any(mask.label != key for key, mask in masks.items()):
msg = "Keys should match the masks' label."
raise KeyError(msg)

return frozendict(masks)

def _convert_list_to_dict(self, masks: list[PixelMask]) -> dict[Hashable, PixelMask]:
Expand Down Expand Up @@ -218,8 +229,13 @@ def combine(self, method: Literal["sum", "product"] = "sum", label: str | None =
PixelMask: A new `PixelMask` instance representing the combined mask.

Raises:
ValueError: If the PixelMaskCollection is empty.
ValueError: If an unsupported method is provided.
"""
if not self.masks:
msg = "Cannot combine masks: the PixelMaskCollection is empty."
raise ValueError(msg)

if method not in ("sum", "product"):
msg = f"Unsupported method: {method}. Use 'sum' or 'product'."
raise ValueError(msg)
Expand Down Expand Up @@ -271,12 +287,17 @@ def apply(self, data, *, label_format: str | None = None, **kwargs):
A dictionary mapping each mask's key (label or index) to the resulting masked data.

Raises:
ValueError: If the PixelMaskCollection is empty.
ValueError: If a label is passed as a keyword argument.
ValueError:
If label_format or additional keyword arguments are provided when the input data is a numpy array.
ValueError: If provided label format does not contain '{mask_label}'.
TypeError: If provided data is not an array, EITData, or PixelMap.
"""
if not self.masks:
msg = "Cannot apply masks: the PixelMaskCollection is empty."
raise ValueError(msg)

if "label" in kwargs:
msg = "Cannot pass 'label' as a keyword argument to `apply()`. Use 'label_format' instead."
raise ValueError(msg)
Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ nav:
- api/filters.md
- Regions of Interest:
- Regions of Interest: api/roi/roi.md
- FilterROIBySize: api/roi/filterbysize.md
- PixelMaskCollection: api/roi/pixelmaskcollection.md
- api/parameters.md
- api/categories.md
Expand Down
25 changes: 14 additions & 11 deletions tests/test_pixelmask_collection.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,17 +320,20 @@ def test_apply_with_extra_kwargs_on_array_raises(labelled_boolean_mask: Callable
_ = collection.apply(array, sample_frequency="test")


def test_empty_collection_raises():
with pytest.raises(ValueError, match="A PixelMaskCollection should contain at least one mask."):
_ = PixelMaskCollection([])

with pytest.raises(ValueError, match="A PixelMaskCollection should contain at least one mask."):
_ = PixelMaskCollection({})

with pytest.raises(
TypeError, match=r"PixelMaskCollection.__init__\(\) missing 1 required positional argument: 'masks'"
):
_ = PixelMaskCollection()
def test_empty_collection_behavior():
# Allow emtpy collection initialization
_ = PixelMaskCollection() # No masks provided
_ = PixelMaskCollection([]) # Empty list
_ = PixelMaskCollection({}) # Empty dict

collection = PixelMaskCollection()
# combine() should raise ValueError
with pytest.raises(ValueError, match="Cannot combine masks: the PixelMaskCollection is empty."):
collection.combine()

# apply() should raise ValueError
with pytest.raises(ValueError, match="Cannot apply masks: the PixelMaskCollection is empty."):
collection.apply(np.zeros((2, 2))) # apply to dummy data


def test_add_labelled_mask(labelled_boolean_mask: Callable):
Expand Down
Loading