Skip to content
Draft
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
2 changes: 2 additions & 0 deletions src/tracksdata/nodes/_regionprops.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,8 @@ def add_nodes(

if "shape" not in graph.metadata:
graph.update_metadata(shape=labels.shape)
elif graph.metadata["shape"] != labels.shape:
raise ValueError("Stored shape is different from the provided labels shape.")

if t is None:
time_points = range(labels.shape[0])
Expand Down
136 changes: 66 additions & 70 deletions src/tracksdata/nodes/_test/test_regionprops.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from skimage.measure._regionprops import RegionProperties

from tracksdata.constants import DEFAULT_ATTR_KEYS
from tracksdata.graph import RustWorkXGraph
from tracksdata.graph import BaseGraph
from tracksdata.nodes import Mask, RegionPropsNodes
from tracksdata.options import get_options, options_context

Expand Down Expand Up @@ -68,25 +68,23 @@ def custom_prop(region: RegionProperties) -> float:
]


def test_regionprops_add_nodes_2d() -> None:
def test_regionprops_add_nodes_2d(graph_backend: BaseGraph) -> None:
"""Test adding nodes from 2D labels."""
graph = RustWorkXGraph()

# Create simple 2D labels
labels = np.array([[[1, 1, 0], [1, 0, 2], [0, 2, 2]]], dtype=np.int32)

extra_properties = SUPPORTED_PROPERTIES + SUPPORTED_PROPERTIES_2D
operator = RegionPropsNodes(extra_properties=extra_properties)
operator.add_nodes(graph, labels=labels)
operator.add_nodes(graph_backend, labels=labels)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that nodes were added
assert graph.num_nodes == 2 # Two regions (labels 1 and 2)
assert graph_backend.num_nodes == 2 # Two regions (labels 1 and 2)

# Check node attributes
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()
assert len(nodes_df) == 2
assert DEFAULT_ATTR_KEYS.T in nodes_df.columns
assert "y" in nodes_df.columns
Expand All @@ -102,27 +100,25 @@ def test_regionprops_add_nodes_2d() -> None:
assert areas == [3, 3]


def test_regionprops_add_nodes_3d() -> None:
def test_regionprops_add_nodes_3d(graph_backend: BaseGraph) -> None:
"""Test adding nodes from 3D labels."""
graph = RustWorkXGraph()

# Create simple 3D labels
labels = np.array([[[[1, 1, 0], [1, 0, 0], [0, 0, 0]]], [[[0, 0, 0], [2, 2, 0], [0, 0, 0]]]], dtype=np.int32)

assert labels.shape == (2, 1, 3, 3)

extra_properties = SUPPORTED_PROPERTIES
operator = RegionPropsNodes(extra_properties=extra_properties)
operator.add_nodes(graph, labels=labels)
operator.add_nodes(graph_backend, labels=labels)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that nodes were added
assert graph.num_nodes == 2 # Two regions
assert graph_backend.num_nodes == 2 # Two regions

# Check node attributes
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()
assert len(nodes_df) == 2
assert DEFAULT_ATTR_KEYS.T in nodes_df.columns
assert "z" in nodes_df.columns
Expand All @@ -132,10 +128,8 @@ def test_regionprops_add_nodes_3d() -> None:
assert DEFAULT_ATTR_KEYS.MASK in nodes_df.columns


def test_regionprops_add_nodes_with_intensity() -> None:
def test_regionprops_add_nodes_with_intensity(graph_backend: BaseGraph) -> None:
"""Test adding nodes with intensity image."""
graph = RustWorkXGraph()

# Create labels and intensity image
labels = np.array([[[1, 1, 0], [1, 0, 2], [0, 2, 2]]], dtype=np.int32)

Expand All @@ -148,13 +142,13 @@ def test_regionprops_add_nodes_with_intensity() -> None:
extra_properties = SUPPORTED_PROPERTIES + SUPPORTED_PROPERTIES_INTENSITY
operator = RegionPropsNodes(extra_properties=extra_properties)

operator.add_nodes(graph, labels=labels, intensity_image=intensity)
operator.add_nodes(graph_backend, labels=labels, intensity_image=intensity)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that nodes were added with intensity attributes
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()
assert "intensity_mean" in nodes_df.columns

# Check that mean intensities are calculated
Expand All @@ -166,10 +160,8 @@ def test_regionprops_add_nodes_with_intensity() -> None:


@pytest.mark.parametrize("n_workers", [1, 2])
def test_regionprops_add_nodes_timelapse(n_workers: int) -> None:
def test_regionprops_add_nodes_timelapse(graph_backend: BaseGraph, n_workers: int) -> None:
"""Test adding nodes from timelapse (t=None) with different worker counts."""
graph = RustWorkXGraph()

# Create timelapse labels (time x height x width)
labels = np.array([[[1, 1], [0, 0]], [[0, 2], [2, 2]]], dtype=np.int32) # t=0 # t=1

Expand All @@ -179,13 +171,13 @@ def test_regionprops_add_nodes_timelapse(n_workers: int) -> None:
operator = RegionPropsNodes(extra_properties=extra_properties)

with options_context(n_workers=n_workers):
operator.add_nodes(graph, labels=labels)
operator.add_nodes(graph_backend, labels=labels)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that nodes were added for both time points
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()
time_points = sorted(nodes_df[DEFAULT_ATTR_KEYS.T].unique())
assert time_points == [0, 1]

Expand All @@ -195,10 +187,8 @@ def test_regionprops_add_nodes_timelapse(n_workers: int) -> None:
assert len(nodes_at_t) == 1


def test_regionprops_add_nodes_timelapse_with_intensity() -> None:
def test_regionprops_add_nodes_timelapse_with_intensity(graph_backend: BaseGraph) -> None:
"""Test adding nodes from timelapse with intensity images."""
graph = RustWorkXGraph()

# Create timelapse labels and intensity
labels = np.array([[[1, 1], [0, 0]], [[0, 2], [2, 2]]], dtype=np.int32) # t=0 # t=1

Expand All @@ -207,13 +197,13 @@ def test_regionprops_add_nodes_timelapse_with_intensity() -> None:
extra_properties = SUPPORTED_PROPERTIES + SUPPORTED_PROPERTIES_2D + SUPPORTED_PROPERTIES_INTENSITY
operator = RegionPropsNodes(extra_properties=extra_properties)

operator.add_nodes(graph, labels=labels, intensity_image=intensity)
operator.add_nodes(graph_backend, labels=labels, intensity_image=intensity)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that nodes were added with intensity attributes
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()
assert "intensity_mean" in nodes_df.columns

# Check mean intensities for each time point
Expand All @@ -222,10 +212,24 @@ def test_regionprops_add_nodes_timelapse_with_intensity() -> None:
assert len(nodes_at_t) == 1


def test_regionprops_custom_properties() -> None:
"""Test with custom property functions."""
graph = RustWorkXGraph()
def test_regionprops_multichannel_intensity(graph_backend: BaseGraph) -> None:
"""Test that multichannel intensity images populate per-channel stats."""
labels = np.array([[[1, 0], [0, 2]]], dtype=np.int32)
# two channels, each with a different max for each label
intensity = np.array([[[[10, 100], [0, 0]], [[0, 0], [20, 200]]]], dtype=np.float32)

operator = RegionPropsNodes(extra_properties=["intensity_max"])
operator.add_nodes(graph_backend, labels=labels, intensity_image=intensity)

nodes_df = graph_backend.node_attrs()
max_values = sorted(nodes_df["intensity_max"].to_list(), key=lambda arr: arr[0])

assert np.allclose(max_values[0], [10, 100])
assert np.allclose(max_values[1], [20, 200])


def test_regionprops_custom_properties(graph_backend: BaseGraph) -> None:
"""Test with custom property functions."""
# Create simple labels
labels = np.array([[[1, 1, 0], [1, 0, 0], [0, 0, 0]]], dtype=np.int32)

Expand All @@ -235,13 +239,13 @@ def double_area(region: RegionProperties) -> float:

operator = RegionPropsNodes(extra_properties=[double_area, "area"])

operator.add_nodes(graph, labels=labels, t=0)
operator.add_nodes(graph_backend, labels=labels, t=0)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that custom property was calculated
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()
assert "double_area" in nodes_df.columns
assert "area" in nodes_df.columns

Expand All @@ -251,35 +255,31 @@ def double_area(region: RegionProperties) -> float:
assert double_area_val == area * 2


def test_regionprops_invalid_dimensions() -> None:
def test_regionprops_invalid_dimensions(graph_backend: BaseGraph) -> None:
"""Test error handling for invalid label dimensions."""
graph = RustWorkXGraph()

# Create 2D labels (invalid)
labels = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.int32)

operator = RegionPropsNodes()

with pytest.raises(ValueError, match=r"`labels` must be 't \+ 2D' or 't \+ 3D'"):
operator.add_nodes(graph, labels=labels)
operator.add_nodes(graph_backend, labels=labels)


def test_regionprops_mask_creation() -> None:
def test_regionprops_mask_creation(graph_backend: BaseGraph) -> None:
"""Test that masks are properly created for regions."""
graph = RustWorkXGraph()

# Create simple labels
labels = np.array([[[1, 1, 0], [1, 0, 0], [0, 0, 2]]], dtype=np.int32)

operator = RegionPropsNodes()

operator.add_nodes(graph, labels=labels, t=0)
operator.add_nodes(graph_backend, labels=labels, t=0)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that masks were created
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()
masks = nodes_df[DEFAULT_ATTR_KEYS.MASK]

# All masks should be Mask objects
Expand All @@ -289,45 +289,41 @@ def test_regionprops_mask_creation() -> None:
assert mask._bbox is not None


def test_regionprops_spacing() -> None:
def test_regionprops_spacing(graph_backend: BaseGraph) -> None:
"""Test regionprops with custom spacing."""
graph = RustWorkXGraph()

# Create simple labels
labels = np.array([[[1, 1], [1, 1]]], dtype=np.int32)

operator = RegionPropsNodes(extra_properties=["area"], spacing=(2.0, 3.0)) # Custom spacing

operator.add_nodes(graph, labels=labels, t=0)
operator.add_nodes(graph_backend, labels=labels, t=0)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# Check that nodes were added (spacing affects internal calculations)
nodes_df = graph.node_attrs()
nodes_df = graph_backend.node_attrs()

assert len(nodes_df) == 1
assert "area" in nodes_df.columns
assert DEFAULT_ATTR_KEYS.MASK in nodes_df.columns
assert nodes_df[DEFAULT_ATTR_KEYS.BBOX].to_numpy().ndim == 2


def test_regionprops_empty_labels() -> None:
def test_regionprops_empty_labels(graph_backend: BaseGraph) -> None:
"""Test behavior with empty labels (no regions)."""
graph = RustWorkXGraph()

# Create labels with no regions
labels = np.zeros((1, 3, 3), dtype=np.int32)

operator = RegionPropsNodes()

operator.add_nodes(graph, labels=labels, t=0)
operator.add_nodes(graph_backend, labels=labels, t=0)

assert "shape" in graph.metadata
assert graph.metadata["shape"] == labels.shape
assert "shape" in graph_backend.metadata
assert graph_backend.metadata["shape"] == labels.shape

# No nodes should be added
assert graph.num_nodes == 0
assert graph_backend.num_nodes == 0


def test_regionprops_multiprocessing_isolation() -> None:
Expand Down
Loading