diff --git a/src/spatialdata_plot/pl/_color.py b/src/spatialdata_plot/pl/_color.py index adefb606..bb670199 100644 --- a/src/spatialdata_plot/pl/_color.py +++ b/src/spatialdata_plot/pl/_color.py @@ -2,6 +2,7 @@ from __future__ import annotations +import warnings from collections.abc import Mapping, Sequence from copy import copy from dataclasses import dataclass, replace @@ -312,6 +313,29 @@ def _set_outline( ) +def _resolve_outline_toggle( + outline: bool | None, + outline_alpha: float | int | tuple[float | int, float | int] | None, + detail_params_set: bool, +) -> float | int | tuple[float | int, float | int] | None: + """Map the tri-state ``outline`` toggle onto an effective ``outline_alpha``. + + ``None`` leaves it unchanged; ``True`` forces on (1.0 if unset/zero); ``False`` forces off and warns on conflict. + """ + if outline is None: + return outline_alpha + alpha_is_zero = outline_alpha is None or bool(np.all(np.asarray(outline_alpha, dtype=float) == 0.0)) + if outline: + return 1.0 if alpha_is_zero else outline_alpha + if detail_params_set or not alpha_is_zero: + warnings.warn( + "`outline=False` overrides the `outline_*` parameters you set; no outline will be drawn.", + UserWarning, + stacklevel=3, + ) + return 0.0 + + def _get_colors_for_categorical_obs( categories: Sequence[str | int], palette: ListedColormap | str | list[str] | None = None, diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 00af0628..a1c0cab0 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -34,6 +34,7 @@ _maybe_set_colors, _next_palette_colors, _prepare_cmap_norm, + _resolve_outline_toggle, _set_outline, ) from spatialdata_plot.pl._validate import ( @@ -325,6 +326,7 @@ def render_shapes( groups: list[str] | str | None = None, palette: dict[str, str] | list[str] | str | None = None, na_color: ColorLike | None = "default", + outline: bool | None = None, outline_width: float | int | tuple[float | int, float | int] | None = None, outline_color: ColorLike | tuple[ColorLike] | None = None, outline_alpha: float | int | tuple[float | int, float | int] | None = None, @@ -388,6 +390,10 @@ def render_shapes( elements are hidden. Pass any explicit color (e.g. ``"lightgray"``) to show them in that color instead. Accepts a named color (``"red"``), a hex string (``"#000000ff"``), or an RGB/RGBA list (``[1.0, 0.0, 0.0, 1.0]``). Pass ``None`` to make NA values fully transparent. + outline : bool | None, default None + Convenience on/off switch. ``None`` infers visibility from the ``outline_*`` params; + ``True`` forces an outline on (defaults fill in any unset width/color/alpha); ``False`` + forces it off, overriding and warning about any ``outline_*`` you set. outline_width : float | int | tuple[float | int, float | int], optional Width of the border. If 2 values are given (tuple), 2 borders are shown with these widths (outer & inner). If `outline_color` and/or `outline_alpha` are used to indicate that one/two outlines should be drawn, the @@ -466,6 +472,9 @@ def render_shapes( """ if as_points: _validate_as_points_size(size) + outline_alpha = _resolve_outline_toggle( + outline, outline_alpha, outline_width is not None or outline_color is not None + ) panel_param_dicts = _expand_color_panels( self._sdata, color, @@ -968,6 +977,7 @@ def render_labels( cmap: Colormap | str | None = None, norm: Normalize | None = None, na_color: ColorLike | None = "default", + outline: bool | None = None, outline_alpha: float | int = 0.0, fill_alpha: float | int | None = None, outline_color: ColorLike | None = None, @@ -1029,6 +1039,10 @@ def render_labels( labels are hidden. Pass any explicit color (e.g. ``"lightgray"``) to show them in that color instead. Accepts a named color (``"red"``), a hex string (``"#000000ff"``), or an RGB/RGBA list (``[1.0, 0.0, 0.0, 1.0]``). Pass ``None`` to make NA values fully transparent. + outline : bool | None, default None + Convenience on/off switch; thickness is set by ``contour_px``. ``None`` infers visibility from + ``outline_color``/``outline_alpha``; ``True`` forces on (alpha defaults to 1.0); ``False`` + forces it off, overriding and warning about any params you set. outline_alpha : float | int, default 0.0 Alpha value for the outline of the labels. Invisible by default. fill_alpha : float | int | None, optional @@ -1078,6 +1092,7 @@ def render_labels( """ if as_points: _validate_as_points_size(size) + outline_alpha = cast("float | int", _resolve_outline_toggle(outline, outline_alpha, outline_color is not None)) panel_param_dicts = _expand_color_panels( self._sdata, color, diff --git a/tests/_images/Labels_outline_toggle_false_suppresses_outline.png b/tests/_images/Labels_outline_toggle_false_suppresses_outline.png new file mode 100644 index 00000000..a05180da Binary files /dev/null and b/tests/_images/Labels_outline_toggle_false_suppresses_outline.png differ diff --git a/tests/_images/Labels_outline_toggle_true_draws_outline.png b/tests/_images/Labels_outline_toggle_true_draws_outline.png new file mode 100644 index 00000000..2e9ab8cb Binary files /dev/null and b/tests/_images/Labels_outline_toggle_true_draws_outline.png differ diff --git a/tests/_images/Shapes_outline_toggle_false_suppresses_outline.png b/tests/_images/Shapes_outline_toggle_false_suppresses_outline.png new file mode 100644 index 00000000..e599f9d9 Binary files /dev/null and b/tests/_images/Shapes_outline_toggle_false_suppresses_outline.png differ diff --git a/tests/_images/Shapes_outline_toggle_true_draws_outline.png b/tests/_images/Shapes_outline_toggle_true_draws_outline.png new file mode 100644 index 00000000..55ee5e49 Binary files /dev/null and b/tests/_images/Shapes_outline_toggle_true_draws_outline.png differ diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index 3e8160dc..30c2bdbc 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -340,6 +340,15 @@ def test_plot_can_control_label_outline(self, sdata_blobs: SpatialData): contour_px=15, ).pl.show() + def test_plot_outline_toggle_true_draws_outline(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_labels("blobs_labels", outline=True, fill_alpha=0.0, contour_px=15).pl.show() + + def test_plot_outline_toggle_false_suppresses_outline(self, sdata_blobs: SpatialData): + with pytest.warns(UserWarning, match="outline=False"): + sdata_blobs.pl.render_labels( + "blobs_labels", outline=False, outline_color="red", outline_alpha=1.0, contour_px=15 + ).pl.show() + def test_plot_can_control_label_infill(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_labels( "blobs_labels", @@ -921,3 +930,20 @@ def test_resolve_as_points_method_threshold_and_fallback(): # explicit matplotlib always matplotlib; empty always matplotlib assert _resolve_as_points_method(rp_mpl, n=10**9, allow_datashader=True) == "matplotlib" assert _resolve_as_points_method(rp_auto, n=0, allow_datashader=True) == "matplotlib" + + +# Regression tests for #748: the `outline` bool convenience toggle on render_labels. +def test_outline_toggle_true_draws_outline(sdata_blobs: SpatialData): + on = sdata_blobs.pl.render_labels("blobs_labels", outline=True, contour_px=15) + assert list(on.plotting_tree.values())[-1].outline_alpha == 1.0 + + +def test_outline_toggle_none_preserves_default_off(sdata_blobs: SpatialData): + off = sdata_blobs.pl.render_labels("blobs_labels") + assert list(off.plotting_tree.values())[-1].outline_alpha == 0.0 + + +def test_outline_toggle_false_forces_off_and_warns(sdata_blobs: SpatialData): + with pytest.warns(UserWarning, match="outline=False"): + out = sdata_blobs.pl.render_labels("blobs_labels", outline=False, outline_color="red") + assert list(out.plotting_tree.values())[-1].outline_alpha == 0.0 diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index ef67ec43..9041c902 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -108,6 +108,15 @@ def test_plot_can_render_polygons(self, sdata_blobs: SpatialData): def test_plot_can_render_polygons_with_outline(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_shapes(element="blobs_polygons", outline_alpha=1).pl.show() + def test_plot_outline_toggle_true_draws_outline(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_shapes(element="blobs_polygons", outline=True).pl.show() + + def test_plot_outline_toggle_false_suppresses_outline(self, sdata_blobs: SpatialData): + with pytest.warns(UserWarning, match="outline=False"): + sdata_blobs.pl.render_shapes( + element="blobs_polygons", outline=False, outline_color="red", outline_alpha=1 + ).pl.show() + def test_plot_can_render_polygons_with_str_colored_outline(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_shapes(element="blobs_polygons", outline_alpha=1, outline_color="red").pl.show() @@ -2005,3 +2014,26 @@ def test_scale_geometries_matches_affinity_scale(): expected = np.array([affinity.scale(g, xfact=scale, yfact=scale) for g in geoms], dtype=object) result = _scale_geometries(arr, scale) assert all(shapely.equals_exact(a, b, tolerance=1e-9) for a, b in zip(expected, result, strict=True)) + + +# Regression tests for #748: the `outline` bool convenience toggle on render_shapes. +def test_outline_toggle_true_draws_outline_like_alpha(sdata_blobs: SpatialData): + on = sdata_blobs.pl.render_shapes("blobs_polygons", outline=True) + explicit = sdata_blobs.pl.render_shapes("blobs_polygons", outline_alpha=1.0) + on_alpha = list(on.plotting_tree.values())[-1].outline_alpha + explicit_alpha = list(explicit.plotting_tree.values())[-1].outline_alpha + assert on_alpha[0] > 0 + assert on_alpha == explicit_alpha + + +def test_outline_toggle_none_preserves_inference(sdata_blobs: SpatialData): + off = sdata_blobs.pl.render_shapes("blobs_polygons") + inferred = sdata_blobs.pl.render_shapes("blobs_polygons", outline_alpha=1.0) + assert list(off.plotting_tree.values())[-1].outline_alpha[0] == 0 + assert list(inferred.plotting_tree.values())[-1].outline_alpha[0] > 0 + + +def test_outline_toggle_false_forces_off_and_warns(sdata_blobs: SpatialData): + with pytest.warns(UserWarning, match="outline=False"): + out = sdata_blobs.pl.render_shapes("blobs_polygons", outline=False, outline_color="red") + assert list(out.plotting_tree.values())[-1].outline_alpha[0] == 0