diff --git a/src/zedprofiler/featurization/colocalization.py b/src/zedprofiler/featurization/colocalization.py index cecf219..b1570a4 100644 --- a/src/zedprofiler/featurization/colocalization.py +++ b/src/zedprofiler/featurization/colocalization.py @@ -659,7 +659,13 @@ def compute_colocalization( # noqa: C901, PLR0912 # Convert list of row-dicts into a dict-of-lists with stable ordering if not list_of_dfs: - return pandas.DataFrame() + return pandas.DataFrame( + { + "Metadata_Object_ObjectID": [], + "Metadata_Experiment_ImageSet": [], + "Metadata_Imaging_ImageID": [], + }, + ) # Collect other metric keys preserving first-seen ordering other_keys: list[str] = [] diff --git a/src/zedprofiler/featurization/granularity.py b/src/zedprofiler/featurization/granularity.py index 5daf1d4..feb304f 100644 --- a/src/zedprofiler/featurization/granularity.py +++ b/src/zedprofiler/featurization/granularity.py @@ -216,7 +216,13 @@ def compute_granularity( # noqa: C901, PLR0912, PLR0913, PLR0915 # Get original data if object_loader.image is None or object_loader.label_image is None: - return pandas.DataFrame() + return pandas.DataFrame( + { + "Metadata_Experiment_ImageSet": [], + "Metadata_Imaging_ImageID": [], + "Metadata_Object_ObjectID": [], + }, + ) original_pixels = object_loader.image original_labels = object_loader.label_image original_shape = original_pixels.shape diff --git a/src/zedprofiler/featurization/intensity.py b/src/zedprofiler/featurization/intensity.py index 855f3f7..898c557 100644 --- a/src/zedprofiler/featurization/intensity.py +++ b/src/zedprofiler/featurization/intensity.py @@ -54,7 +54,13 @@ def compute_intensity( # noqa: C901, PLR0915 """ if object_loader.label_image is None or object_loader.image is None: - return pandas.DataFrame() + return pandas.DataFrame( + { + "Metadata_Experiment_ImageSet": [], + "Metadata_Imaging_ImageID": [], + "Metadata_Object_ObjectID": [], + }, + ) image_object = object_loader.image label_object = object_loader.label_image labels = object_loader.object_ids diff --git a/src/zedprofiler/featurization/neighbors.py b/src/zedprofiler/featurization/neighbors.py index 6c1b403..26235d9 100644 --- a/src/zedprofiler/featurization/neighbors.py +++ b/src/zedprofiler/featurization/neighbors.py @@ -106,7 +106,13 @@ def compute_neighbors( """ if object_loader.label_image is None: - return pandas.DataFrame() + return pandas.DataFrame( + { + "Metadata_Object_ObjectID": [], + "NeighborsCountAdjacent": [], + f"NeighborsCountByDistance-{distance_threshold}": [], + }, + ) label_object = object_loader.label_image labels = object_loader.object_ids # set image global min and max coordinates diff --git a/src/zedprofiler/featurization/texture.py b/src/zedprofiler/featurization/texture.py index 30813b6..9b5c5d3 100644 --- a/src/zedprofiler/featurization/texture.py +++ b/src/zedprofiler/featurization/texture.py @@ -93,7 +93,13 @@ def compute_texture( # noqa: C901 """ if object_loader.label_image is None or object_loader.image is None: - return pandas.DataFrame() + return pandas.DataFrame( + { + "Metadata_Experiment_ImageSet": [], + "Metadata_Imaging_ImageID": [], + "Metadata_Object_ObjectID": [], + }, + ) label_object = object_loader.label_image labels = object_loader.object_ids feature_names = [ diff --git a/tests/featurization/test_colocalization.py b/tests/featurization/test_colocalization.py index 2fc91cf..63d3b76 100644 --- a/tests/featurization/test_colocalization.py +++ b/tests/featurization/test_colocalization.py @@ -64,6 +64,36 @@ def test_compute_colocalization_basic( assert any("Colocalization" in c for c in df.columns) +@pytest.mark.parametrize("shape,center", [((7, 7, 7), (3, 3, 3))]) +def test_zero_objects_returns_well_formed_empty_frame( + shape: tuple[int, int, int], + center: tuple[int, int, int], +) -> None: + """A degenerate loader with zero objects must not return a malformed frame. + + Before the fix, compute_colocalization returned a bare + ``pandas.DataFrame()`` with no columns at all when no object pairs were + found, which crashes any downstream merge that expects an ID column to + key on. + """ + imgset = ImageSetLoaderModel() + label, im1, im2 = make_pair(shape, center) + loader = TwoObjectLoaderModel( + image_set_loader=imgset, + compartment="Cell", + image1=im1, + image2=im2, + label_image=label, + object_ids=[], + ) + + df = compute_colocalization(loader, channel1="A", channel2="B") + + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + assert len(df) == 0 + + def test_linear_and_bisection_costes_thresholds_basic() -> None: # simple linear relationship between channels x = np.linspace(1.0, 100.0, 200) diff --git a/tests/featurization/test_granularity.py b/tests/featurization/test_granularity.py index 764d547..1424c9f 100644 --- a/tests/featurization/test_granularity.py +++ b/tests/featurization/test_granularity.py @@ -1,5 +1,6 @@ from __future__ import annotations +from types import SimpleNamespace from typing import ClassVar import numpy as np @@ -73,6 +74,32 @@ def test_compute_granularity_basic( assert "Metadata_Object_ObjectID" in df.columns +def test_none_image_returns_well_formed_empty_frame() -> None: + """A degenerate loader with no image must not return a malformed frame. + + ObjectLoader sets ``image`` (and ``label_image``) to None when its channel + (or compartment) is missing for a given image set. Before the fix, + compute_granularity returned a bare ``pandas.DataFrame()`` with no columns + at all in this case, which crashes any downstream merge that expects an + ID column to key on. + """ + imgset = SimpleNamespace(image_set_name="gran", image_id="gran") + loader = SimpleNamespace( + image=None, + label_image=None, + object_ids=[], + image_set_loader=imgset, + compartment="Cell", + channel="Ch1", + ) + + df = compute_granularity(loader, radius=1, granular_spectrum_length=4) + + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + assert len(df) == 0 + + def test_subsample_and_upsample_roundtrip() -> None: data = np.arange(27.0).reshape((3, 3, 3)) # subsample by factor 0.5 -> larger grid coords division diff --git a/tests/featurization/test_intensity.py b/tests/featurization/test_intensity.py index 20d6701..f264fce 100644 --- a/tests/featurization/test_intensity.py +++ b/tests/featurization/test_intensity.py @@ -1,5 +1,7 @@ from __future__ import annotations +from types import SimpleNamespace + import numpy as np import pandas as pd import pytest @@ -186,6 +188,32 @@ def test_compute_intensity_skips_phantom_object_id_without_bbox() -> None: assert returned_ids == [1] +def test_none_image_returns_well_formed_empty_frame() -> None: + """A degenerate loader with no image must not return a malformed frame. + + ObjectLoader sets ``image`` (and ``label_image``) to None when its channel + (or compartment) is missing for a given image set. Before the fix, + compute_intensity returned a bare ``pandas.DataFrame()`` with no columns + at all in this case, which crashes any downstream merge that expects an + ID column to key on. + """ + imgset = SimpleNamespace(image_set_name="intensity", image_id="intensity") + loader = SimpleNamespace( + image=None, + label_image=None, + object_ids=[], + image_set_loader=imgset, + compartment="Cell", + channel="Ch1", + ) + + df = compute_intensity(loader) + + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + assert len(df) == 0 + + def test_compute_intensity_handles_all_zero_intensity_object() -> None: """An object with no nonzero-intensity voxels yields NaN mass displacement. diff --git a/tests/featurization/test_neighbors.py b/tests/featurization/test_neighbors.py index 90e9b56..9763d9e 100644 --- a/tests/featurization/test_neighbors.py +++ b/tests/featurization/test_neighbors.py @@ -1,5 +1,7 @@ from __future__ import annotations +from types import SimpleNamespace + import numpy as np import pandas as pd import pytest @@ -76,6 +78,30 @@ def test_compute_neighbors_counts( assert "Metadata_Object_ObjectID" in df.columns +def test_none_label_image_returns_well_formed_empty_frame() -> None: + """A degenerate loader with no label image must not return a malformed frame. + + ObjectLoader sets ``label_image`` to None when its compartment is missing + for a given image set. Before the fix, compute_neighbors returned a bare + ``pandas.DataFrame()`` with no columns at all in this case, which crashes + any downstream merge that expects an ID column to key on. + """ + imgset = SimpleNamespace(image_set_name="neighbors", image_id="neighbors") + loader = SimpleNamespace( + label_image=None, + object_ids=[], + image_set_loader=imgset, + compartment="Cell", + channel="Ch1", + ) + + df = compute_neighbors(loader, distance_threshold=5, anisotropy_factor=1) + + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + assert len(df) == 0 + + def test_neighbors_expand_box_bounds() -> None: # current_min - expand_by < min_coor -> clipped to min_coor min_coord = 0 diff --git a/tests/featurization/test_texture.py b/tests/featurization/test_texture.py index 7f9c02e..759122e 100644 --- a/tests/featurization/test_texture.py +++ b/tests/featurization/test_texture.py @@ -1,5 +1,7 @@ from __future__ import annotations +from types import SimpleNamespace + import numpy as np import pandas as pd import pytest @@ -69,6 +71,32 @@ def test_compute_texture_basic( assert "Metadata_Object_ObjectID" in df.columns +def test_none_image_returns_well_formed_empty_frame() -> None: + """A degenerate loader with no image must not return a malformed frame. + + ObjectLoader sets ``image`` (and ``label_image``) to None when its channel + (or compartment) is missing for a given image set — a real, not + contrived, degenerate input. Before the fix, compute_texture returned a + bare ``pandas.DataFrame()`` with no columns at all in this case, which + crashes any downstream merge that expects an ID column to key on. + """ + imgset = SimpleNamespace(image_set_name="texture", image_id="texture") + loader = SimpleNamespace( + image=None, + label_image=None, + object_ids=[], + image_set_loader=imgset, + compartment="Cell", + channel="Ch1", + ) + + df = compute_texture(loader, distance=1, grayscale=256) + + assert isinstance(df, pd.DataFrame) + assert "Metadata_Object_ObjectID" in df.columns + assert len(df) == 0 + + def test_value_error_in_one_object_does_not_corrupt_others() -> None: """When mahotas raises ValueError for one object, other objects must be unaffected.