From 8717705d90c9407ce0f5600f08fc5e136bdee8b6 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Mon, 23 Mar 2026 23:54:03 +0100 Subject: [PATCH 1/3] Warn when `groups` values don't match `color` categories (#455) When `groups` contains values absent from the `color` column's categories, emit a warning so users understand why the plot is empty. Also add a defensive empty-input guard in `_get_collection_shape` to prevent a `KeyError: 'geometry'` if an empty DataFrame ever reaches PatchCollection construction. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/spatialdata_plot/pl/render.py | 15 +++++++++++++++ src/spatialdata_plot/pl/utils.py | 3 +++ 2 files changed, 18 insertions(+) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 9c74cd83..e2e59020 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -302,6 +302,14 @@ def _render_shapes( # Only show non-matching elements if the user explicitly sets na_color. _na = render_params.cmap_params.na_color if groups is not None and values_are_categorical and (_na.default_color_set or _na.alpha == "00"): + assert color_source_vector is not None # guaranteed by values_are_categorical + _groups_list = [groups] if isinstance(groups, str) else groups + _missing = set(_groups_list) - set(color_source_vector.categories) + if _missing: + logger.warning( + f"Groups {sorted(_missing)} not found in the values of '{col_for_color}'. " + "The `groups` parameter filters values of the `color` column." + ) keep, color_source_vector, color_vector = _filter_groups_transparent_na( groups, color_source_vector, color_vector ) @@ -754,6 +762,13 @@ def _render_points( # Only show non-matching elements if the user explicitly sets na_color. _na = render_params.cmap_params.na_color if groups is not None and color_source_vector is not None and (_na.default_color_set or _na.alpha == "00"): + _groups_list = [groups] if isinstance(groups, str) else groups + _missing = set(_groups_list) - set(color_source_vector.categories) + if _missing: + logger.warning( + f"Groups {sorted(_missing)} not found in the values of '{col_for_color}'. " + "The `groups` parameter filters values of the `color` column." + ) keep, color_source_vector, color_vector = _filter_groups_transparent_na( groups, color_source_vector, color_vector ) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 11787509..af10a041 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -553,6 +553,9 @@ def _create_patches( shapes_df, fill_c.tolist(), outline_c.tolist() if hasattr(outline_c, "tolist") else outline_c, s ) + if patches.empty: + return PatchCollection([], **kwargs) + return PatchCollection( patches["geometry"].values.tolist(), snap=False, From 2f4af2cb6aaa2426e6746de72a616be231232355 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 24 Mar 2026 00:07:52 +0100 Subject: [PATCH 2/3] Address review: deduplicate warning, add tests, cover labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract `_warn_missing_groups` helper and call it from `_filter_groups_transparent_na` (shapes/points) and inline in labels, removing duplicated logic from both call sites. - Distinguish "none matched" (likely wrong column) from "some missing" (likely typo) with different warning messages. - Replace assert with explicit `color_source_vector is not None` guard in the shapes condition, matching the points pattern. - Add 4 tests (shapes + points × all-missing + partial-missing) using `logger_warns` to lock the warning behavior. - Drop `**kwargs` from empty `PatchCollection` fallback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/spatialdata_plot/pl/render.py | 48 +++++++++++++++++++------------ src/spatialdata_plot/pl/utils.py | 2 +- tests/pl/test_render_points.py | 19 ++++++++++++ tests/pl/test_render_shapes.py | 18 ++++++++++++ 4 files changed, 68 insertions(+), 19 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index e2e59020..9cc4c770 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -108,16 +108,42 @@ def _reparse_points( ) +def _warn_missing_groups( + groups: str | list[str], + color_source_vector: pd.Categorical, + col_for_color: str | None = None, +) -> None: + """Warn when ``groups`` contains values absent from the color column's categories.""" + groups_list = [groups] if isinstance(groups, str) else list(groups) + missing = set(groups_list) - set(color_source_vector.categories) + if not missing: + return + col_label = f" '{col_for_color}'" if col_for_color else "" + if missing == set(groups_list): + logger.warning( + f"None of the requested groups {sorted(missing)} were found in column{col_label}. " + "This usually means `groups` refers to values from a different column than `color`. " + "The `groups` parameter selects categories of the column specified via `color`." + ) + else: + logger.warning( + f"Groups {sorted(missing)} were not found in column{col_label} and will be ignored. " + f"Available categories: {sorted(color_source_vector.categories)}." + ) + + def _filter_groups_transparent_na( groups: str | list[str], color_source_vector: pd.Categorical, color_vector: pd.Series | np.ndarray | list[str], + col_for_color: str | None = None, ) -> tuple[np.ndarray, pd.Categorical, np.ndarray]: """Return a boolean mask and filtered color vectors for groups filtering. Used when ``na_color=None`` (fully transparent) so that non-matching elements are removed entirely instead of rendered invisibly. """ + _warn_missing_groups(groups, color_source_vector, col_for_color) keep = color_source_vector.isin(groups) filtered_csv = color_source_vector[keep] filtered_cv = np.asarray(color_vector)[keep] @@ -301,17 +327,9 @@ def _render_shapes( # When groups are specified, filter out non-matching elements by default. # Only show non-matching elements if the user explicitly sets na_color. _na = render_params.cmap_params.na_color - if groups is not None and values_are_categorical and (_na.default_color_set or _na.alpha == "00"): - assert color_source_vector is not None # guaranteed by values_are_categorical - _groups_list = [groups] if isinstance(groups, str) else groups - _missing = set(_groups_list) - set(color_source_vector.categories) - if _missing: - logger.warning( - f"Groups {sorted(_missing)} not found in the values of '{col_for_color}'. " - "The `groups` parameter filters values of the `color` column." - ) + if groups is not None and color_source_vector is not None and (_na.default_color_set or _na.alpha == "00"): keep, color_source_vector, color_vector = _filter_groups_transparent_na( - groups, color_source_vector, color_vector + groups, color_source_vector, color_vector, col_for_color=col_for_color ) shapes = shapes[keep].reset_index(drop=True) if len(shapes) == 0: @@ -762,15 +780,8 @@ def _render_points( # Only show non-matching elements if the user explicitly sets na_color. _na = render_params.cmap_params.na_color if groups is not None and color_source_vector is not None and (_na.default_color_set or _na.alpha == "00"): - _groups_list = [groups] if isinstance(groups, str) else groups - _missing = set(_groups_list) - set(color_source_vector.categories) - if _missing: - logger.warning( - f"Groups {sorted(_missing)} not found in the values of '{col_for_color}'. " - "The `groups` parameter filters values of the `color` column." - ) keep, color_source_vector, color_vector = _filter_groups_transparent_na( - groups, color_source_vector, color_vector + groups, color_source_vector, color_vector, col_for_color=col_for_color ) n_points = int(keep.sum()) if n_points == 0: @@ -1322,6 +1333,7 @@ def _render_labels( and color_source_vector is not None and (_na.default_color_set or _na.alpha == "00") ): + _warn_missing_groups(groups, color_source_vector, col_for_color) keep_vec = color_source_vector.isin(groups) matching_ids = instance_id[keep_vec] keep_mask = np.isin(label.values, matching_ids) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index af10a041..3fbb1743 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -554,7 +554,7 @@ def _create_patches( ) if patches.empty: - return PatchCollection([], **kwargs) + return PatchCollection([]) return PatchCollection( patches["geometry"].values.tolist(), diff --git a/tests/pl/test_render_points.py b/tests/pl/test_render_points.py index b5b29728..3b33b446 100644 --- a/tests/pl/test_render_points.py +++ b/tests/pl/test_render_points.py @@ -22,6 +22,7 @@ from spatialdata.transformations._utils import _set_transformations import spatialdata_plot # noqa: F401 +from spatialdata_plot._logging import logger, logger_warns from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over, get_standard_RNG sc.pl.set_rcParams_defaults() @@ -607,6 +608,24 @@ def test_groups_na_color_none_no_match_points(sdata_blobs: SpatialData): ).pl.show() +def test_groups_warns_when_no_groups_match_points(sdata_blobs: SpatialData, caplog): + """When none of the groups match color categories, a warning should be emitted.""" + sdata_blobs["blobs_points"]["cat_color"] = pd.Series(["a", "b", "c", "a"] * 50, dtype="category") + with logger_warns(caplog, logger, match="None of the requested groups"): + sdata_blobs.pl.render_points( + "blobs_points", color="cat_color", groups=["nonexistent"], na_color=None, size=30 + ).pl.show() + + +def test_groups_warns_when_some_groups_missing_points(sdata_blobs: SpatialData, caplog): + """When some groups match but others don't, a warning should list the missing ones.""" + sdata_blobs["blobs_points"]["cat_color"] = pd.Series(["a", "b", "c", "a"] * 50, dtype="category") + with logger_warns(caplog, logger, match="were not found in column"): + sdata_blobs.pl.render_points( + "blobs_points", color="cat_color", groups=["a", "nonexistent"], na_color=None, size=30 + ).pl.show() + + def test_raises_when_table_does_not_annotate_element(sdata_blobs: SpatialData): # Work on an independent copy since we mutate tables sdata_blobs_local = deepcopy(sdata_blobs) diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 5701fedf..5f4679d1 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -1016,6 +1016,24 @@ def test_groups_na_color_none_no_match_shapes(sdata_blobs: SpatialData): sdata_blobs.pl.render_shapes("blobs_polygons", color="cat_color", groups=["nonexistent"], na_color=None).pl.show() +def test_groups_warns_when_no_groups_match(sdata_blobs: SpatialData, caplog): + """When none of the groups match color categories, a warning should be emitted.""" + sdata_blobs["blobs_polygons"]["cat_color"] = pd.Series(["a", "b", "a", "b", "a"], dtype="category") + with logger_warns(caplog, logger, match="None of the requested groups"): + sdata_blobs.pl.render_shapes( + "blobs_polygons", color="cat_color", groups=["nonexistent"], na_color=None + ).pl.show() + + +def test_groups_warns_when_some_groups_missing(sdata_blobs: SpatialData, caplog): + """When some groups match but others don't, a warning should list the missing ones.""" + sdata_blobs["blobs_polygons"]["cat_color"] = pd.Series(["a", "b", "a", "b", "a"], dtype="category") + with logger_warns(caplog, logger, match="were not found in column"): + sdata_blobs.pl.render_shapes( + "blobs_polygons", color="cat_color", groups=["a", "nonexistent"], na_color=None + ).pl.show() + + def test_plot_can_handle_nan_values_in_color_data(sdata_blobs: SpatialData, caplog): """Test that NaN values in color data are handled gracefully and logged.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) From f15f0e013be3d99746d952b7ae76835947b5a3f3 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 24 Mar 2026 00:15:05 +0100 Subject: [PATCH 3/3] Simplify: decouple warning from filter, fix edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move _warn_missing_groups call before the na_color guard so the warning fires regardless of na_color (not just when na_color is transparent). - Remove col_for_color param from _filter_groups_transparent_na (warning is no longer its responsibility). - Fix fallback column label: "in column." → "in the color column." - Guard sorted() with try/except for non-sortable category types. - Deduplicate set(groups_list) into a single groups_set variable. - Parametrize tests over na_color=[None, "red"] to cover both paths. - Add labels warning test. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/spatialdata_plot/pl/render.py | 37 +++++++++++++++++++++---------- tests/pl/test_render_labels.py | 19 ++++++++++++++++ tests/pl/test_render_points.py | 16 +++++++------ tests/pl/test_render_shapes.py | 16 +++++++------ 4 files changed, 62 insertions(+), 26 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 9cc4c770..d45888a6 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -114,21 +114,28 @@ def _warn_missing_groups( col_for_color: str | None = None, ) -> None: """Warn when ``groups`` contains values absent from the color column's categories.""" - groups_list = [groups] if isinstance(groups, str) else list(groups) - missing = set(groups_list) - set(color_source_vector.categories) + groups_set = {groups} if isinstance(groups, str) else set(groups) + missing = groups_set - set(color_source_vector.categories) if not missing: return - col_label = f" '{col_for_color}'" if col_for_color else "" - if missing == set(groups_list): + col_label = f" '{col_for_color}'" if col_for_color else " the color column" + try: + missing_str = str(sorted(missing)) + except TypeError: + missing_str = str(list(missing)) + if missing == groups_set: logger.warning( - f"None of the requested groups {sorted(missing)} were found in column{col_label}. " + f"None of the requested groups {missing_str} were found in{col_label}. " "This usually means `groups` refers to values from a different column than `color`. " "The `groups` parameter selects categories of the column specified via `color`." ) else: + try: + cats_str = str(sorted(color_source_vector.categories)) + except TypeError: + cats_str = str(list(color_source_vector.categories)) logger.warning( - f"Groups {sorted(missing)} were not found in column{col_label} and will be ignored. " - f"Available categories: {sorted(color_source_vector.categories)}." + f"Groups {missing_str} were not found in{col_label} and will be ignored. Available categories: {cats_str}." ) @@ -136,14 +143,12 @@ def _filter_groups_transparent_na( groups: str | list[str], color_source_vector: pd.Categorical, color_vector: pd.Series | np.ndarray | list[str], - col_for_color: str | None = None, ) -> tuple[np.ndarray, pd.Categorical, np.ndarray]: """Return a boolean mask and filtered color vectors for groups filtering. Used when ``na_color=None`` (fully transparent) so that non-matching elements are removed entirely instead of rendered invisibly. """ - _warn_missing_groups(groups, color_source_vector, col_for_color) keep = color_source_vector.isin(groups) filtered_csv = color_source_vector[keep] filtered_cv = np.asarray(color_vector)[keep] @@ -324,12 +329,15 @@ def _render_shapes( values_are_categorical = color_source_vector is not None + if groups is not None and color_source_vector is not None: + _warn_missing_groups(groups, color_source_vector, col_for_color) + # When groups are specified, filter out non-matching elements by default. # Only show non-matching elements if the user explicitly sets na_color. _na = render_params.cmap_params.na_color if groups is not None and color_source_vector is not None and (_na.default_color_set or _na.alpha == "00"): keep, color_source_vector, color_vector = _filter_groups_transparent_na( - groups, color_source_vector, color_vector, col_for_color=col_for_color + groups, color_source_vector, color_vector ) shapes = shapes[keep].reset_index(drop=True) if len(shapes) == 0: @@ -776,12 +784,15 @@ def _render_points( if added_color_from_table and col_for_color is not None: _reparse_points(sdata_filt, element, points_pd_with_color, transformation_in_cs, coordinate_system) + if groups is not None and color_source_vector is not None: + _warn_missing_groups(groups, color_source_vector, col_for_color) + # When groups are specified, filter out non-matching elements by default. # Only show non-matching elements if the user explicitly sets na_color. _na = render_params.cmap_params.na_color if groups is not None and color_source_vector is not None and (_na.default_color_set or _na.alpha == "00"): keep, color_source_vector, color_vector = _filter_groups_transparent_na( - groups, color_source_vector, color_vector, col_for_color=col_for_color + groups, color_source_vector, color_vector ) n_points = int(keep.sum()) if n_points == 0: @@ -1324,6 +1335,9 @@ def _render_labels( else: assert color_source_vector is None + if groups is not None and color_source_vector is not None: + _warn_missing_groups(groups, color_source_vector, col_for_color) + # When groups are specified, zero out non-matching label IDs so they render as background. # Only show non-matching labels if the user explicitly sets na_color. _na = render_params.cmap_params.na_color @@ -1333,7 +1347,6 @@ def _render_labels( and color_source_vector is not None and (_na.default_color_set or _na.alpha == "00") ): - _warn_missing_groups(groups, color_source_vector, col_for_color) keep_vec = color_source_vector.isin(groups) matching_ids = instance_id[keep_vec] keep_mask = np.isin(label.values, matching_ids) diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 1dd6af8f..ab7c158d 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -12,6 +12,7 @@ from spatialdata.models import Labels2DModel, TableModel import spatialdata_plot # noqa: F401 +from spatialdata_plot._logging import logger, logger_warns from tests.conftest import DPI, PlotTester, PlotTesterMeta, _viridis_with_under_over, get_standard_RNG sc.pl.set_rcParams_defaults() @@ -428,3 +429,21 @@ def test_raises_when_table_does_not_annotate_element(sdata_blobs: SpatialData): color="channel_0_sum", table_name="other_table", ).pl.show() + + +def test_groups_warns_when_no_groups_match_labels(sdata_blobs: SpatialData, caplog): + """Warning fires when no groups match label color categories.""" + labels_name = "blobs_labels" + instances = get_element_instances(sdata_blobs[labels_name]) + n_obs = len(instances) + adata = AnnData(np.zeros((n_obs, 1))) + adata.obs["instance_id"] = instances.values + adata.obs["cat"] = pd.Categorical(["a", "b"] * (n_obs // 2) + ["a"] * (n_obs % 2)) + adata.obs["region"] = labels_name + sdata_blobs["label_table"] = TableModel.parse( + adata=adata, region_key="region", instance_key="instance_id", region=labels_name + ) + with logger_warns(caplog, logger, match="None of the requested groups"): + sdata_blobs.pl.render_labels( + labels_name, color="cat", groups=["nonexistent"], table_name="label_table", na_color=None + ).pl.show() diff --git a/tests/pl/test_render_points.py b/tests/pl/test_render_points.py index 3b33b446..bc321b17 100644 --- a/tests/pl/test_render_points.py +++ b/tests/pl/test_render_points.py @@ -608,21 +608,23 @@ def test_groups_na_color_none_no_match_points(sdata_blobs: SpatialData): ).pl.show() -def test_groups_warns_when_no_groups_match_points(sdata_blobs: SpatialData, caplog): - """When none of the groups match color categories, a warning should be emitted.""" +@pytest.mark.parametrize("na_color", [None, "red"]) +def test_groups_warns_when_no_groups_match_points(sdata_blobs: SpatialData, caplog, na_color): + """Warning fires regardless of na_color when no groups match.""" sdata_blobs["blobs_points"]["cat_color"] = pd.Series(["a", "b", "c", "a"] * 50, dtype="category") with logger_warns(caplog, logger, match="None of the requested groups"): sdata_blobs.pl.render_points( - "blobs_points", color="cat_color", groups=["nonexistent"], na_color=None, size=30 + "blobs_points", color="cat_color", groups=["nonexistent"], na_color=na_color, size=30 ).pl.show() -def test_groups_warns_when_some_groups_missing_points(sdata_blobs: SpatialData, caplog): - """When some groups match but others don't, a warning should list the missing ones.""" +@pytest.mark.parametrize("na_color", [None, "red"]) +def test_groups_warns_when_some_groups_missing_points(sdata_blobs: SpatialData, caplog, na_color): + """Warning fires regardless of na_color when some groups are missing.""" sdata_blobs["blobs_points"]["cat_color"] = pd.Series(["a", "b", "c", "a"] * 50, dtype="category") - with logger_warns(caplog, logger, match="were not found in column"): + with logger_warns(caplog, logger, match="were not found in"): sdata_blobs.pl.render_points( - "blobs_points", color="cat_color", groups=["a", "nonexistent"], na_color=None, size=30 + "blobs_points", color="cat_color", groups=["a", "nonexistent"], na_color=na_color, size=30 ).pl.show() diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 5f4679d1..3b7fb716 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -1016,21 +1016,23 @@ def test_groups_na_color_none_no_match_shapes(sdata_blobs: SpatialData): sdata_blobs.pl.render_shapes("blobs_polygons", color="cat_color", groups=["nonexistent"], na_color=None).pl.show() -def test_groups_warns_when_no_groups_match(sdata_blobs: SpatialData, caplog): - """When none of the groups match color categories, a warning should be emitted.""" +@pytest.mark.parametrize("na_color", [None, "red"]) +def test_groups_warns_when_no_groups_match(sdata_blobs: SpatialData, caplog, na_color): + """Warning fires regardless of na_color when no groups match.""" sdata_blobs["blobs_polygons"]["cat_color"] = pd.Series(["a", "b", "a", "b", "a"], dtype="category") with logger_warns(caplog, logger, match="None of the requested groups"): sdata_blobs.pl.render_shapes( - "blobs_polygons", color="cat_color", groups=["nonexistent"], na_color=None + "blobs_polygons", color="cat_color", groups=["nonexistent"], na_color=na_color ).pl.show() -def test_groups_warns_when_some_groups_missing(sdata_blobs: SpatialData, caplog): - """When some groups match but others don't, a warning should list the missing ones.""" +@pytest.mark.parametrize("na_color", [None, "red"]) +def test_groups_warns_when_some_groups_missing(sdata_blobs: SpatialData, caplog, na_color): + """Warning fires regardless of na_color when some groups are missing.""" sdata_blobs["blobs_polygons"]["cat_color"] = pd.Series(["a", "b", "a", "b", "a"], dtype="category") - with logger_warns(caplog, logger, match="were not found in column"): + with logger_warns(caplog, logger, match="were not found in"): sdata_blobs.pl.render_shapes( - "blobs_polygons", color="cat_color", groups=["a", "nonexistent"], na_color=None + "blobs_polygons", color="cat_color", groups=["a", "nonexistent"], na_color=na_color ).pl.show()