Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
25 changes: 24 additions & 1 deletion src/tracksdata/nodes/_mask.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from collections.abc import Sequence
from functools import cached_property, lru_cache
from typing import Any
from typing import TYPE_CHECKING, Any

import blosc2
import numpy as np
import skimage.morphology as morph
from numpy.typing import ArrayLike, NDArray
from skimage.measure import regionprops
Comment thread
JoOkuma marked this conversation as resolved.

if TYPE_CHECKING:
from skimage.measure._regionprops import RegionProperties

from tracksdata.constants import DEFAULT_ATTR_KEYS
from tracksdata.functional._iou import fast_intersection_with_bbox, fast_iou_with_bbox
Expand Down Expand Up @@ -366,6 +370,25 @@ def move(
if image_shape is not None:
self._crop_overhang(image_shape)

def regionprops(self) -> "RegionProperties":
"""
Compute scikit-image regionprops for this mask.

The computation is aware of the mask bounding box, so coordinate-based
properties (e.g. centroid, coords) are returned in absolute
image coordinates.
"""
props = regionprops(
self._mask.astype(np.uint16),
cache=True,
offset=tuple(self._bbox[: self._mask.ndim]),
)

if len(props) != 1:
raise ValueError("Expected a single region in mask to compute regionprops.")

return props[0]

@cached_property
def size(self) -> int:
"""
Expand Down
41 changes: 41 additions & 0 deletions src/tracksdata/nodes/_test/test_mask.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,47 @@ def test_mask_init() -> None:
assert np.array_equal(mask._bbox, bbox)


def test_mask_regionprops_bbox_aware() -> None:
"""Regionprops should return absolute coordinates using the bbox offset."""
mask_array = np.array([[False, True], [True, False]], dtype=bool)
bbox = np.array([5, 10, 7, 12])

props = Mask(mask_array, bbox).regionprops()

assert props.area == 2
np.testing.assert_allclose(props.centroid, np.array([5.5, 10.5]))

coords = np.array(sorted(map(tuple, props.coords.tolist())))
expected_coords = np.array(sorted([(5, 11), (6, 10)]))
np.testing.assert_array_equal(coords, expected_coords)


def test_mask_regionprops_bbox_aware_3d() -> None:
"""Regionprops should handle 3D masks and preserve absolute coordinates."""
mask_array = np.zeros((2, 2, 2), dtype=bool)
mask_array[0, 0, 1] = True
mask_array[1, 1, 0] = True
bbox = np.array([3, 4, 5, 5, 6, 7])

props = Mask(mask_array, bbox).regionprops()

assert props.area == 2
np.testing.assert_allclose(props.centroid, np.array([3.5, 4.5, 5.5]))

coords = np.array(sorted(map(tuple, props.coords.tolist())))
expected_coords = np.array(sorted([(3, 4, 6), (4, 5, 5)]))
np.testing.assert_array_equal(coords, expected_coords)


def test_mask_regionprops_empty() -> None:
"""Regionprops should raise for empty masks."""
mask_array = np.zeros((2, 2), dtype=bool)
bbox = np.array([0, 0, 2, 2])

with pytest.raises(ValueError, match="single region"):
_ = Mask(mask_array, bbox).regionprops()


def test_mask_getstate_setstate() -> None:
"""Test Mask serialization and deserialization."""
mask_array = np.array([[True, False], [False, True]], dtype=bool)
Expand Down
Loading