diff --git a/src/tracksdata/nodes/_regionprops.py b/src/tracksdata/nodes/_regionprops.py index 22d69e32..c73a8716 100644 --- a/src/tracksdata/nodes/_regionprops.py +++ b/src/tracksdata/nodes/_regionprops.py @@ -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]) diff --git a/src/tracksdata/nodes/_test/test_regionprops.py b/src/tracksdata/nodes/_test/test_regionprops.py index a8a509ac..abc94ea4 100644 --- a/src/tracksdata/nodes/_test/test_regionprops.py +++ b/src/tracksdata/nodes/_test/test_regionprops.py @@ -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 @@ -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 @@ -102,10 +100,8 @@ 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) @@ -113,16 +109,16 @@ def test_regionprops_add_nodes_3d() -> None: 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 @@ -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) @@ -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 @@ -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 @@ -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] @@ -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 @@ -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 @@ -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) @@ -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 @@ -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 @@ -289,22 +289,20 @@ 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 @@ -312,22 +310,20 @@ def test_regionprops_spacing() -> None: 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: