diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index d6216341..73bc100e 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -640,6 +640,7 @@ def render_labels( na_color: ColorLike | None = "default", outline_alpha: float | int = 0.0, fill_alpha: float | int = 0.4, + outline_color: ColorLike | tuple[ColorLike] | None = None, scale: str | None = None, colorbar: bool | str | None = "auto", colorbar_params: dict[str, object] | None = None, @@ -688,6 +689,11 @@ def render_labels( Alpha value for the outline of the labels. Invisible by default. fill_alpha : float | int, default 0.4 Alpha value for the fill of the labels. + outline_color : ColorLike | tuple[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 a tuple of colors is + given, the first color is used for the outline and the second color for the fill. If None, the outline color + is set to "black". 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 @@ -727,6 +733,7 @@ def render_labels( na_color=na_color, norm=norm, outline_alpha=outline_alpha, + outline_color=outline_color, palette=palette, scale=scale, colorbar=colorbar, @@ -753,6 +760,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 2d607fe9..aefb6b59 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -1337,7 +1337,9 @@ def _render_labels( else: assert color_source_vector is None - 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=None + ) -> matplotlib.image.AxesImage: labels = _map_color_seg( seg=label.values, cell_id=instance_id, @@ -1347,6 +1349,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) seg_erosionpx=seg_erosionpx, seg_boundaries=seg_boundaries, na_color=render_params.cmap_params.na_color, + outline_color=outline_color, ) _cax = ax.imshow( @@ -1378,6 +1381,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=render_params.outline_color, ) alpha_to_decorate_ax = render_params.outline_alpha @@ -1391,6 +1395,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=render_params.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 4936468f..b90788c5 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -284,6 +284,7 @@ class LabelsRenderParams: outline: bool = False palette: ListedColormap | list[str] | None = None outline_alpha: float = 1.0 + outline_color: ColorLike | tuple[ColorLike] | 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 11d5d10d..926f6468 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -13,6 +13,7 @@ import dask import datashader as ds import matplotlib +import matplotlib.colors as mcolors import matplotlib.patches as mpatches import matplotlib.path as mpath import matplotlib.pyplot as plt @@ -54,8 +55,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, @@ -1143,6 +1143,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) @@ -1181,7 +1182,7 @@ 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 seg_im: ArrayLike = label2rgb( label=val_im, @@ -1194,8 +1195,33 @@ 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 + + # Binary boundary mask + boundary_mask = seg.astype(bool) + + # Ensure seg_im is float in 0-1 and has 3 channels + seg_float = seg_im.astype(float) + if seg_float.ndim == 2: + seg_float = np.stack([seg_float] * 3, axis=-1) # H x W x 3 + + # Add alpha channel from val_im (preserve original mask) + alpha_channel = (val_im > 0).astype(float) + seg_float = np.dstack((seg_float, alpha_channel)) # H x W x 4 + + # Convert outline_color to RGBA + if outline_color is None: + outline_rgba = (0, 0, 0, 1.0) # default black + elif isinstance(outline_color, str): + outline_rgba = mcolors.to_rgba(outline_color) # named color or hex string + else: + # assume it's your Color object + outline_rgba = mcolors.to_rgba(outline_color.get_hex_with_alpha()) + + # Apply outline color to boundary pixels, but keep original alpha from val_im + seg_float[boundary_mask, :3] = outline_rgba[:3] # RGB + seg_float[boundary_mask, 3] = alpha_channel[boundary_mask] * outline_rgba[3] # scale alpha + + return seg_float # H x W x 4, valid RGBA if len(val_im.shape) != len(seg_im.shape): val_im = np.expand_dims((val_im > 0).astype(int), axis=-1) @@ -2413,6 +2439,7 @@ def _validate_label_render_params( na_color: ColorLike | None, norm: Normalize | None, outline_alpha: float | int, + outline_color: ColorLike | tuple[ColorLike] | None, scale: str | None, table_name: str | None, table_layer: str | None, @@ -2429,6 +2456,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, @@ -2451,6 +2479,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"]