diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 8af4e177..7948d03c 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -643,6 +643,7 @@ def render_labels( na_color: ColorLike | None = "default", outline_alpha: float | int = 0.0, fill_alpha: float | int | None = None, + outline_color: ColorLike | None = None, scale: str | None = None, colorbar: bool | str | None = "auto", colorbar_params: dict[str, object] | None = None, @@ -694,6 +695,11 @@ def render_labels( fill_alpha : float | int | None, optional Alpha value for the fill of the labels. By default, it is set to 0.4 or, if a color is given that implies an alpha, that value is used for `fill_alpha`. + outline_color : ColorLike | None + Color of the outline of the labels. Can either be a named color ("red"), a hex representation + ("#000000") or a list of floats that represent RGB/RGBA values (1.0, 0.0, 0.0, 1.0). If ``None``, + the outline inherits from the ``color`` parameter when it is a literal color, or uses data-driven + per-label colors when ``color`` refers to a column. scale : str | None Influences the resolution of the rendering. Possibilities for setting this parameter: 1) None (default). The image is rasterized to fit the canvas size. For multiscale images, the best scale @@ -733,6 +739,7 @@ def render_labels( na_color=na_color, norm=norm, outline_alpha=outline_alpha, + outline_color=outline_color, palette=palette, scale=scale, colorbar=colorbar, @@ -760,6 +767,7 @@ def render_labels( cmap_params=cmap_params, palette=param_values["palette"], outline_alpha=param_values["outline_alpha"], + outline_color=param_values["outline_color"], fill_alpha=param_values["fill_alpha"], transfunc=kwargs.get("transfunc"), scale=param_values["scale"], diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index b1cb321d..cf5ab0e8 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1309,7 +1309,12 @@ def _render_labels( if isinstance(color_vector.dtype, pd.CategoricalDtype): color_vector = color_vector.remove_unused_categories() - def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) -> matplotlib.image.AxesImage: + def _draw_labels( + seg_erosionpx: int | None, + seg_boundaries: bool, + alpha: float, + outline_color: Color | None = None, + ) -> matplotlib.image.AxesImage: labels = _map_color_seg( seg=label.values, cell_id=instance_id, @@ -1319,6 +1324,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) seg_erosionpx=seg_erosionpx, seg_boundaries=seg_boundaries, na_color=na_color, + outline_color=outline_color, ) _cax = ax.imshow( @@ -1334,6 +1340,14 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) cax = ax.add_image(_cax) return cax # noqa: RET504 + # When color is a literal (col_for_color is None) and no explicit outline_color, + # use the literal color for outlines so they are visible (e.g., color='white' on + # a dark background). When color is data-driven, outlines inherit the per-label + # colors from label2rgb (outline_color stays None). + effective_outline_color = render_params.outline_color + if effective_outline_color is None and col_for_color is None and render_params.color is not None: + effective_outline_color = render_params.color + # default case: no contour, just fill # since contour_px is passed to skimage.morphology.erosion to create the contour, # any border thickness is only within the label, not outside. Therefore, the case @@ -1350,6 +1364,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) seg_erosionpx=render_params.contour_px, seg_boundaries=True, alpha=render_params.outline_alpha, + outline_color=effective_outline_color, ) alpha_to_decorate_ax = render_params.outline_alpha @@ -1363,6 +1378,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) seg_erosionpx=render_params.contour_px, seg_boundaries=True, alpha=render_params.outline_alpha, + outline_color=effective_outline_color, ) # pass the less-transparent _cax for the legend diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index a108e131..24ba1d4f 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -285,6 +285,7 @@ class LabelsRenderParams: outline: bool = False palette: ListedColormap | list[str] | None = None outline_alpha: float = 1.0 + outline_color: Color | None = None fill_alpha: float = 0.4 transfunc: Callable[[float], float] | None = None scale: str | None = None diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 402a2191..11787509 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -54,8 +54,7 @@ from scipy.spatial import ConvexHull from shapely.errors import GEOSException from skimage.color import label2rgb -from skimage.morphology import erosion, square -from skimage.segmentation import find_boundaries +from skimage.morphology import erosion, footprint_rectangle from skimage.util import map_array from spatialdata import ( SpatialData, @@ -1203,6 +1202,7 @@ def _map_color_seg( na_color: Color, seg_erosionpx: int | None = None, seg_boundaries: bool = False, + outline_color: Color | None = None, ) -> ArrayLike: cell_id = np.array(cell_id) @@ -1244,7 +1244,16 @@ def _map_color_seg( cols = cmap_params.cmap(cmap_params.norm(color_vector)) if seg_erosionpx is not None: - val_im[val_im == erosion(val_im, square(seg_erosionpx))] = 0 + val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0 + + if seg_boundaries and outline_color is not None: + # Uniform outline color requested: skip label2rgb, build RGBA directly + outline_rgba = colors.to_rgba(outline_color.get_hex_with_alpha()) + outline_mask = val_im > 0 + rgba = np.zeros((*val_im.shape, 4), dtype=float) + rgba[outline_mask, :3] = outline_rgba[:3] + rgba[outline_mask, 3] = outline_rgba[3] + return rgba seg_im: ArrayLike = label2rgb( label=val_im, @@ -1255,10 +1264,10 @@ def _map_color_seg( ) if seg_boundaries: - if seg.shape[0] == 1: - seg = np.squeeze(seg, axis=0) - seg_bound: ArrayLike = np.clip(seg_im - find_boundaries(seg)[:, :, None], 0, 1) - return np.dstack((seg_bound, np.where(val_im > 0, 1, 0))) # add transparency here + # Data-driven outline: use seg_im colors on the eroded ring, transparent elsewhere + outline_mask = val_im > 0 + alpha_channel = outline_mask.astype(float) + return np.dstack((seg_im, alpha_channel)) if len(val_im.shape) != len(seg_im.shape): val_im = np.expand_dims((val_im > 0).astype(int), axis=-1) @@ -2509,6 +2518,7 @@ def _validate_label_render_params( na_color: ColorLike | None, norm: Normalize | None, outline_alpha: float | int, + outline_color: ColorLike | None, scale: str | None, table_name: str | None, table_layer: str | None, @@ -2525,6 +2535,7 @@ def _validate_label_render_params( "color": color, "na_color": na_color, "outline_alpha": outline_alpha, + "outline_color": outline_color, "cmap": cmap, "norm": norm, "scale": scale, @@ -2547,6 +2558,7 @@ def _validate_label_render_params( element_params[el]["fill_alpha"] = param_dict["fill_alpha"] element_params[el]["scale"] = param_dict["scale"] element_params[el]["outline_alpha"] = param_dict["outline_alpha"] + element_params[el]["outline_color"] = param_dict["outline_color"] element_params[el]["contour_px"] = param_dict["contour_px"] element_params[el]["table_layer"] = param_dict["table_layer"] diff --git a/tests/_images/Labels_can_control_label_outline.png b/tests/_images/Labels_can_control_label_outline.png index 70fd5685..b8773818 100644 Binary files a/tests/_images/Labels_can_control_label_outline.png and b/tests/_images/Labels_can_control_label_outline.png differ diff --git a/tests/_images/Labels_can_render_outline_color.png b/tests/_images/Labels_can_render_outline_color.png new file mode 100644 index 00000000..e8587204 Binary files /dev/null and b/tests/_images/Labels_can_render_outline_color.png differ diff --git a/tests/_images/Labels_can_render_outline_with_fill.png b/tests/_images/Labels_can_render_outline_with_fill.png new file mode 100644 index 00000000..59b7938a Binary files /dev/null and b/tests/_images/Labels_can_render_outline_with_fill.png differ diff --git a/tests/_images/Labels_can_stack_render_labels.png b/tests/_images/Labels_can_stack_render_labels.png index 5856caa1..4b77937f 100644 Binary files a/tests/_images/Labels_can_stack_render_labels.png and b/tests/_images/Labels_can_stack_render_labels.png differ diff --git a/tests/_images/Labels_label_colorbar_uses_alpha_of_less_transparent_infill.png b/tests/_images/Labels_label_colorbar_uses_alpha_of_less_transparent_infill.png index 518d406e..cab6937f 100644 Binary files a/tests/_images/Labels_label_colorbar_uses_alpha_of_less_transparent_infill.png and b/tests/_images/Labels_label_colorbar_uses_alpha_of_less_transparent_infill.png differ diff --git a/tests/_images/Labels_outline_inherits_literal_color.png b/tests/_images/Labels_outline_inherits_literal_color.png new file mode 100644 index 00000000..fd1fe389 Binary files /dev/null and b/tests/_images/Labels_outline_inherits_literal_color.png differ diff --git a/tests/_images/Labels_outline_uses_data_driven_colors.png b/tests/_images/Labels_outline_uses_data_driven_colors.png new file mode 100644 index 00000000..2f624c7b Binary files /dev/null and b/tests/_images/Labels_outline_uses_data_driven_colors.png differ diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index c5fbdd43..1dd6af8f 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -97,6 +97,28 @@ def test_plot_can_color_by_hex_with_alpha(self, sdata_blobs: SpatialData): def test_plot_alpha_overwrites_opacity_from_color(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_labels("blobs_labels", color=[0.5, 0.5, 1.0, 0.5], fill_alpha=1.0).pl.show() + def test_plot_can_render_outline_color(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_labels( + "blobs_labels", outline_alpha=1, fill_alpha=0, outline_color="red", contour_px=10 + ).pl.show() + + def test_plot_can_render_outline_with_fill(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_labels( + "blobs_labels", outline_alpha=1, fill_alpha=0.3, outline_color="blue", contour_px=10 + ).pl.show() + + def test_plot_outline_inherits_literal_color(self, sdata_blobs: SpatialData): + """Literal color= should be used for outlines when outline_color is not set (#462).""" + sdata_blobs.pl.render_labels( + "blobs_labels", color="white", outline_alpha=1, fill_alpha=0, contour_px=10 + ).pl.show() + + def test_plot_outline_uses_data_driven_colors(self, sdata_blobs: SpatialData): + """Data-driven color should produce per-label outline colors when outline_color is None.""" + sdata_blobs.pl.render_labels( + "blobs_labels", color="channel_0_sum", outline_alpha=1, fill_alpha=0, contour_px=10 + ).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()