diff --git a/src/spatialdata_plot/pl/_validate.py b/src/spatialdata_plot/pl/_validate.py index 61e8f639..a979ad02 100644 --- a/src/spatialdata_plot/pl/_validate.py +++ b/src/spatialdata_plot/pl/_validate.py @@ -96,6 +96,8 @@ def _validate_show_parameters( dpi: int | None, fig: Figure | None, title: list[str] | str | None, + xlabel: str | None, + ylabel: str | None, pad_extent: int | float, ax: list[Axes] | Axes | None, return_ax: bool, @@ -183,6 +185,10 @@ def _validate_show_parameters( if title is not None and not isinstance(title, list | str): raise TypeError("Parameter 'title' must be a string or a list of strings.") + for _name, _val in (("xlabel", xlabel), ("ylabel", ylabel)): + if _val is not None and not isinstance(_val, str): + raise TypeError(f"Parameter '{_name}' must be a string or None.") + if not isinstance(pad_extent, int | float): raise TypeError("Parameter 'pad_extent' must be numeric.") diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index a755ccfc..b910671c 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -1315,6 +1315,8 @@ def show( dpi: int | None = None, fig: Figure | None = None, title: list[str] | str | None = None, + xlabel: str | None = None, + ylabel: str | None = None, pad_extent: int | float = 0, ax: list[Axes] | Axes | None = None, return_ax: bool = False, @@ -1378,6 +1380,10 @@ def show( Title(s) for the plot. A single string is applied to all panels; a list must match the number of panels. If ``None``, each panel is titled with its coordinate system name, or, in multi-panel color mode, with its color key. + xlabel : str | None, default None + Label for the x axis, applied to every rendered panel. ``None`` leaves it unlabelled. + ylabel : str | None, default None + Label for the y axis, applied to every rendered panel. ``None`` leaves it unlabelled. pad_extent : int | float, default 0 Padding added around the computed spatial extent on all sides. ax : list[Axes] | Axes | None @@ -1440,6 +1446,8 @@ def show( dpi=dpi, fig=fig, title=title, + xlabel=xlabel, + ylabel=ylabel, pad_extent=pad_extent, ax=ax, return_ax=return_ax, @@ -1565,6 +1573,8 @@ def show( axis_channel_legend_entries=axis_channel_legend_entries, cs_row=cs_row, title=title, + xlabel=xlabel, + ylabel=ylabel, dpi=dpi, figsize=figsize, ) @@ -1982,15 +1992,18 @@ def _finalize_panel( ax: Axes, panel_idx: int, title: list[str] | None, + xlabel: str | None, + ylabel: str | None, panel_key: str | None, cs: str, frameon: bool | None, ) -> None: - """Set a panel's title, equal aspect ratio and frame visibility. + """Set a panel's title, axis labels, equal aspect ratio and frame visibility. With no explicit ``title`` the panel is labelled with its color key (multi-panel color mode) or its coordinate-system name; a single-element list applies to every panel, otherwise the - title at ``panel_idx`` is used. + title at ``panel_idx`` is used. ``xlabel``/``ylabel`` are applied to every panel; ``None`` + leaves the respective axis unlabelled. """ if title is None: t = panel_key if panel_key is not None else cs @@ -2000,6 +2013,10 @@ def _finalize_panel( # len(title) == num_panels is guaranteed by the up-front check in show(). t = title[panel_idx] ax.set_title(t) + if xlabel is not None: + ax.set_xlabel(xlabel) + if ylabel is not None: + ax.set_ylabel(ylabel) ax.set_aspect("equal") if frameon is False: ax.axis("off") @@ -2056,6 +2073,8 @@ def _render_panel( axis_channel_legend_entries: list[ChannelLegendEntry], cs_row: pd.Series, title: list[str] | None, + xlabel: str | None, + ylabel: str | None, dpi: int | None, figsize: tuple[float, float] | None, ) -> tuple[list[str], dict[str, bool]]: @@ -2124,5 +2143,5 @@ def _render_panel( _RENDERERS[cmd](**kwargs) # Panel finalization depends only on per-panel values, so run it once after the loop. - _finalize_panel(ax, panel_idx, title, panel_key, cs, fig_params.frameon) + _finalize_panel(ax, panel_idx, title, xlabel, ylabel, panel_key, cs, fig_params.frameon) return wanted_elements, wants diff --git a/tests/_images/Show_xlabel_ylabel.png b/tests/_images/Show_xlabel_ylabel.png new file mode 100644 index 00000000..662ea063 Binary files /dev/null and b/tests/_images/Show_xlabel_ylabel.png differ diff --git a/tests/pl/test_show.py b/tests/pl/test_show.py index 455c8661..1582560b 100644 --- a/tests/pl/test_show.py +++ b/tests/pl/test_show.py @@ -29,6 +29,10 @@ class TestShow(PlotTester, metaclass=PlotTesterMeta): def test_plot_pad_extent_adds_padding(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_images(element="blobs_image").pl.show(pad_extent=100) + def test_plot_xlabel_ylabel(self, sdata_blobs: SpatialData): + """Visual test: xlabel/ylabel label the axes (feature for #763).""" + sdata_blobs.pl.render_images(element="blobs_image").pl.show(xlabel="x (µm)", ylabel="y (µm)") + def test_plot_frameon_false_single_panel(self, sdata_blobs: SpatialData): """Visual test: frameon=False hides axes decorations on a single panel (regression for #204).""" sdata_blobs.pl.render_images(element="blobs_image").pl.show(frameon=False) @@ -172,6 +176,41 @@ def test_title_count_validation(sdata_blobs: SpatialData): plt.close("all") +def test_xlabel_ylabel(sdata_blobs: SpatialData): + """xlabel/ylabel set the axis labels on every panel; None keeps them empty (feature for #763).""" + base = sdata_blobs.pl.render_images(element="blobs_image") + + ax = base.pl.show(return_ax=True, show=False) # default: no labels + assert ax.get_xlabel() == "" and ax.get_ylabel() == "" + plt.close("all") + + ax = base.pl.show(xlabel="x (µm)", ylabel="y (µm)", return_ax=True, show=False) + assert ax.get_xlabel() == "x (µm)" and ax.get_ylabel() == "y (µm)" + plt.close("all") + + ax = base.pl.show(xlabel="µm", return_ax=True, show=False) # one axis only + assert ax.get_xlabel() == "µm" and ax.get_ylabel() == "" + plt.close("all") + + # broadcast to every panel of a multi-panel plot + set_transformation(sdata_blobs["blobs_image"], Identity(), "second_cs") + axs = sdata_blobs.pl.render_images(element="blobs_image").pl.show( + xlabel="µm", ylabel="µm", return_ax=True, show=False + ) + assert all(a.get_xlabel() == "µm" and a.get_ylabel() == "µm" for a in axs) + plt.close("all") + + +def test_xlabel_ylabel_validation(sdata_blobs: SpatialData): + """xlabel/ylabel must each be a string or None (feature for #763).""" + base = sdata_blobs.pl.render_images(element="blobs_image") + with pytest.raises(TypeError, match="xlabel"): + base.pl.show(xlabel=1, show=False) + with pytest.raises(TypeError, match="ylabel"): + base.pl.show(ylabel=("y",), show=False) + plt.close("all") + + def test_fig_parameter_warns_with_ax_list(sdata_blobs: SpatialData): """Passing fig= alongside a list of axes should also emit the deprecation (regression for #625).""" set_transformation(sdata_blobs["blobs_image"], Identity(), "second_cs")