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
8 changes: 7 additions & 1 deletion src/zedprofiler/featurization/colocalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = []
Expand Down
8 changes: 7 additions & 1 deletion src/zedprofiler/featurization/granularity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/zedprofiler/featurization/intensity.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/zedprofiler/featurization/neighbors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion src/zedprofiler/featurization/texture.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
30 changes: 30 additions & 0 deletions tests/featurization/test_colocalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions tests/featurization/test_granularity.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from types import SimpleNamespace
from typing import ClassVar

import numpy as np
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/featurization/test_intensity.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from types import SimpleNamespace

import numpy as np
import pandas as pd
import pytest
Expand Down Expand Up @@ -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.

Expand Down
26 changes: 26 additions & 0 deletions tests/featurization/test_neighbors.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from types import SimpleNamespace

import numpy as np
import pandas as pd
import pytest
Expand Down Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/featurization/test_texture.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

from types import SimpleNamespace

import numpy as np
import pandas as pd
import pytest
Expand Down Expand Up @@ -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.

Expand Down
Loading