From c31d2e9c1eac87253d8038983aa861d9081436a3 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Fri, 27 Feb 2026 23:09:20 +0100 Subject: [PATCH 1/4] Add `col_for_color` to labels, enabling literal color values like `color='red'` Labels now use the same color/col_for_color split as shapes and points, so `render_labels(color="red")` is correctly recognized as a literal color instead of being treated as a column name. Fixes #470 and #478. Co-Authored-By: Claude Opus 4.6 --- src/spatialdata_plot/pl/basic.py | 10 ++++----- src/spatialdata_plot/pl/render.py | 16 ++++++++------ src/spatialdata_plot/pl/render_params.py | 3 ++- src/spatialdata_plot/pl/utils.py | 27 +++++++++++++----------- tests/pl/test_render_labels.py | 3 +++ 5 files changed, 34 insertions(+), 25 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 5337420d..c1ba2c5f 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -749,6 +749,7 @@ def render_labels( sdata.plotting_tree[f"{n_steps + 1}_render_labels"] = LabelsRenderParams( element=element, color=param_values["color"], + col_for_color=param_values["col_for_color"], groups=param_values["groups"], contour_px=param_values["contour_px"], cmap_params=cmap_params, @@ -1130,14 +1131,13 @@ def _draw_colorbar( if wanted_labels_on_this_cs: table = params_copy.table_name - if table is not None: - assert isinstance(params_copy.color, str) - colors = sc.get.obs_df(sdata[table], [params_copy.color]) - if isinstance(colors[params_copy.color].dtype, pd.CategoricalDtype): + if table is not None and params_copy.col_for_color is not None: + colors = sc.get.obs_df(sdata[table], [params_copy.col_for_color]) + if isinstance(colors[params_copy.col_for_color].dtype, pd.CategoricalDtype): _maybe_set_colors( source=sdata[table], target=sdata[table], - key=params_copy.color, + key=params_copy.col_for_color, palette=params_copy.palette, ) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 5334e287..539c43f9 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1265,7 +1265,7 @@ def _render_labels( table_name = render_params.table_name table_layer = render_params.table_layer palette = render_params.palette - color = render_params.color + col_for_color = render_params.col_for_color groups = render_params.groups scale = render_params.scale @@ -1314,23 +1314,25 @@ def _render_labels( _, trans_data = _prepare_transformation(label, coordinate_system, ax) + na_color = render_params.color if render_params.color else render_params.cmap_params.na_color color_source_vector, color_vector, categorical = _set_color_source_vec( sdata=sdata_filt, element=label, element_name=element, - value_to_plot=color, + value_to_plot=col_for_color, groups=groups, palette=palette, - na_color=render_params.cmap_params.na_color, + na_color=na_color, cmap_params=render_params.cmap_params, table_name=table_name, table_layer=table_layer, + render_type="labels", coordinate_system=coordinate_system, ) # rasterize could have removed labels from label # only problematic if color is specified - if rasterize and color is not None: + if rasterize and col_for_color is not None: labels_in_rasterized_image = np.unique(label.values) mask = np.isin(instance_id, labels_in_rasterized_image) instance_id = instance_id[mask] @@ -1408,7 +1410,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) colorbar_requested = _should_request_colorbar( render_params.colorbar, has_mappable=cax is not None, - is_continuous=color is not None and color_source_vector is None and not categorical, + is_continuous=col_for_color is not None and color_source_vector is None and not categorical, ) _ = _decorate_axs( @@ -1416,7 +1418,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) cax=cax, fig_params=fig_params, adata=table, - value_to_plot=color, + value_to_plot=col_for_color, color_source_vector=color_source_vector, color_vector=color_vector, palette=palette, @@ -1432,7 +1434,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) colorbar_requests=colorbar_requests, colorbar_label=_resolve_colorbar_label( render_params.colorbar_params, - color if isinstance(color, str) else None, + col_for_color if isinstance(col_for_color, str) else None, ), scalebar_dx=scalebar_params.scalebar_dx, scalebar_units=scalebar_params.scalebar_units, diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index 4936468f..a108e131 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -278,7 +278,8 @@ class LabelsRenderParams: cmap_params: CmapParams element: str - color: str | None = None + color: Color | None = None + col_for_color: str | None = None groups: str | list[str] | None = None contour_px: int | None = None outline: bool = False diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index ae55a9c8..ca4dce82 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -981,7 +981,7 @@ def _set_color_source_vec( alpha: float = 1.0, table_name: str | None = None, table_layer: str | None = None, - render_type: Literal["points"] | None = None, + render_type: Literal["points", "labels"] | None = None, coordinate_system: str | None = None, ) -> tuple[ArrayLike | pd.Series | None, ArrayLike, bool]: if value_to_plot is None and element is not None: @@ -1454,7 +1454,7 @@ def _get_categorical_color_mapping( alpha: float = 1, groups: list[str] | str | None = None, palette: list[str] | str | None = None, - render_type: Literal["points"] | None = None, + render_type: Literal["points", "labels"] | None = None, ) -> Mapping[str, str]: if not isinstance(color_source_vector, Categorical): raise TypeError(f"Expected `categories` to be a `Categorical`, but got {type(color_source_vector).__name__}") @@ -2145,7 +2145,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st }: if not isinstance(color, str | tuple | list): raise TypeError("Parameter 'color' must be a string or a tuple/list of floats.") - if element_type in {"shapes", "points"}: + if element_type in {"shapes", "points", "labels"}: if _is_color_like(color): logger.info("Value for parameter 'color' appears to be a color, using it as such.") param_dict["col_for_color"] = None @@ -2153,7 +2153,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if param_dict["color"].alpha_is_user_defined(): if element_type == "points" and param_dict.get("alpha") is None: param_dict["alpha"] = param_dict["color"].get_alpha_as_float() - elif element_type == "shapes" and param_dict.get("fill_alpha") is None: + elif element_type in {"shapes", "labels"} and param_dict.get("fill_alpha") is None: param_dict["fill_alpha"] = param_dict["color"].get_alpha_as_float() else: logger.info( @@ -2165,7 +2165,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st param_dict["color"] = None else: raise ValueError(f"{color} is not a valid RGB(A) array and therefore can't be used as 'color' value.") - elif "color" in param_dict and element_type != "labels": + elif "color" in param_dict and element_type != "images": param_dict["col_for_color"] = None outline_width = param_dict.get("outline_width") @@ -2462,15 +2462,18 @@ def _validate_label_render_params( element_params[el]["table_layer"] = param_dict["table_layer"] element_params[el]["table_name"] = None - element_params[el]["color"] = None - color = param_dict["color"] - if color is not None: - color, table_name = _validate_col_for_column_table(sdata, el, color, param_dict["table_name"], labels=True) + element_params[el]["color"] = param_dict["color"] # literal Color or None + element_params[el]["col_for_color"] = None + if (col_for_color := param_dict["col_for_color"]) is not None: + col_for_color, table_name = _validate_col_for_column_table( + sdata, el, col_for_color, param_dict["table_name"], labels=True + ) element_params[el]["table_name"] = table_name - element_params[el]["color"] = color + element_params[el]["col_for_color"] = col_for_color - element_params[el]["palette"] = param_dict["palette"] if element_params[el]["table_name"] is not None else None - element_params[el]["groups"] = param_dict["groups"] if element_params[el]["table_name"] is not None else None + has_col = element_params[el]["col_for_color"] is not None + element_params[el]["palette"] = param_dict["palette"] if has_col else None + element_params[el]["groups"] = param_dict["groups"] if has_col else None element_params[el]["colorbar"] = param_dict["colorbar"] element_params[el]["colorbar_params"] = param_dict["colorbar_params"] diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index a585d4eb..7000d077 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -84,6 +84,9 @@ def test_plot_can_stack_render_labels(self, sdata_blobs: SpatialData): .pl.show() ) + def test_plot_can_color_by_color_name(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_labels("blobs_labels", color="red").pl.show() + def test_plot_can_color_labels_by_continuous_variable(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_labels("blobs_labels", color="channel_0_sum").pl.show() From 3ed1763b6b947672b50caa14da4dbc909f626c18 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Thu, 5 Mar 2026 22:46:47 +0100 Subject: [PATCH 2/4] Fix na_color consistency in _render_labels - Use `is not None` instead of truthiness check, matching shapes/points - Pass overridden na_color to _map_color_seg so labels not in the table respect the literal color when one is set Co-Authored-By: Claude Opus 4.6 --- src/spatialdata_plot/pl/render.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 539c43f9..26b2d6c8 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1314,7 +1314,7 @@ def _render_labels( _, trans_data = _prepare_transformation(label, coordinate_system, ax) - na_color = render_params.color if render_params.color else render_params.cmap_params.na_color + na_color = render_params.color if render_params.color is not None else render_params.cmap_params.na_color color_source_vector, color_vector, categorical = _set_color_source_vec( sdata=sdata_filt, element=label, @@ -1353,7 +1353,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) cmap_params=render_params.cmap_params, seg_erosionpx=seg_erosionpx, seg_boundaries=seg_boundaries, - na_color=render_params.cmap_params.na_color, + na_color=na_color, ) _cax = ax.imshow( From 6fe3f594b6fc89c7c3b4eb6ac4007b7376fa9a5d Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Thu, 5 Mar 2026 23:17:40 +0100 Subject: [PATCH 3/4] Warn when 'groups' is ignored due to literal color When color is a literal value (not a column name), the groups parameter has no effect. Added a warning for labels, points, and shapes so users aren't silently confused. Co-Authored-By: Claude Opus 4.6 --- src/spatialdata_plot/pl/utils.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index ca4dce82..bffe7b92 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -2473,6 +2473,8 @@ def _validate_label_render_params( has_col = element_params[el]["col_for_color"] is not None element_params[el]["palette"] = param_dict["palette"] if has_col else None + if not has_col and param_dict["groups"] is not None: + logger.warning("Parameter 'groups' is ignored when 'color' is a literal color, not a column name.") element_params[el]["groups"] = param_dict["groups"] if has_col else None element_params[el]["colorbar"] = param_dict["colorbar"] element_params[el]["colorbar_params"] = param_dict["colorbar_params"] @@ -2541,6 +2543,8 @@ def _validate_points_render_params( element_params[el]["col_for_color"] = col_for_color element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None + if param_dict["col_for_color"] is None and param_dict["groups"] is not None: + logger.warning("Parameter 'groups' is ignored when 'color' is a literal color, not a column name.") element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["ds_reduction"] = param_dict["ds_reduction"] element_params[el]["colorbar"] = param_dict["colorbar"] @@ -2625,6 +2629,8 @@ def _validate_shape_render_params( element_params[el]["col_for_color"] = col_for_color element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None + if param_dict["col_for_color"] is None and param_dict["groups"] is not None: + logger.warning("Parameter 'groups' is ignored when 'color' is a literal color, not a column name.") element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["method"] = param_dict["method"] element_params[el]["ds_reduction"] = param_dict["ds_reduction"] From b17fad510db7c5b40fadbb3d33d8aa896f2520db Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Fri, 6 Mar 2026 04:13:57 +0100 Subject: [PATCH 4/4] Extract warning constant and align na_color override with points - Extract duplicated groups-ignored warning string to _GROUPS_IGNORED_WARNING - Add col_for_color guard to na_color override, matching the points pattern Co-Authored-By: Claude Opus 4.6 --- src/spatialdata_plot/pl/render.py | 6 +++++- src/spatialdata_plot/pl/utils.py | 8 +++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 26b2d6c8..6cc748bc 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1314,7 +1314,11 @@ def _render_labels( _, trans_data = _prepare_transformation(label, coordinate_system, ax) - na_color = render_params.color if render_params.color is not None else render_params.cmap_params.na_color + na_color = ( + render_params.color + if col_for_color is None and render_params.color is not None + else render_params.cmap_params.na_color + ) color_source_vector, color_vector, categorical = _set_color_source_vec( sdata=sdata_filt, element=label, diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index bffe7b92..88981f68 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -94,6 +94,8 @@ # once https://github.com/scverse/spatialdata/pull/689/ is in a release ColorLike = tuple[float, ...] | list[float] | str +_GROUPS_IGNORED_WARNING = "Parameter 'groups' is ignored when 'color' is a literal color, not a column name." + def _extract_scalar_value(value: Any, default: float = 0.0) -> float: """ @@ -2474,7 +2476,7 @@ def _validate_label_render_params( has_col = element_params[el]["col_for_color"] is not None element_params[el]["palette"] = param_dict["palette"] if has_col else None if not has_col and param_dict["groups"] is not None: - logger.warning("Parameter 'groups' is ignored when 'color' is a literal color, not a column name.") + logger.warning(_GROUPS_IGNORED_WARNING) element_params[el]["groups"] = param_dict["groups"] if has_col else None element_params[el]["colorbar"] = param_dict["colorbar"] element_params[el]["colorbar_params"] = param_dict["colorbar_params"] @@ -2544,7 +2546,7 @@ def _validate_points_render_params( element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None if param_dict["col_for_color"] is None and param_dict["groups"] is not None: - logger.warning("Parameter 'groups' is ignored when 'color' is a literal color, not a column name.") + logger.warning(_GROUPS_IGNORED_WARNING) element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["ds_reduction"] = param_dict["ds_reduction"] element_params[el]["colorbar"] = param_dict["colorbar"] @@ -2630,7 +2632,7 @@ def _validate_shape_render_params( element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None if param_dict["col_for_color"] is None and param_dict["groups"] is not None: - logger.warning("Parameter 'groups' is ignored when 'color' is a literal color, not a column name.") + logger.warning(_GROUPS_IGNORED_WARNING) element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["method"] = param_dict["method"] element_params[el]["ds_reduction"] = param_dict["ds_reduction"]