Skip to content

Commit 378974f

Browse files
MeyerBenderpre-commit-ci[bot]timtreisclaude
authored
Feature/outline color for labels (#525)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Tim Treis <tim.treis@stud.uni-heidelberg.de> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 886f484 commit 378974f

12 files changed

Lines changed: 67 additions & 8 deletions

src/spatialdata_plot/pl/basic.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ def render_labels(
643643
na_color: ColorLike | None = "default",
644644
outline_alpha: float | int = 0.0,
645645
fill_alpha: float | int | None = None,
646+
outline_color: ColorLike | None = None,
646647
scale: str | None = None,
647648
colorbar: bool | str | None = "auto",
648649
colorbar_params: dict[str, object] | None = None,
@@ -694,6 +695,11 @@ def render_labels(
694695
fill_alpha : float | int | None, optional
695696
Alpha value for the fill of the labels. By default, it is set to 0.4 or, if a color is given that implies
696697
an alpha, that value is used for `fill_alpha`.
698+
outline_color : ColorLike | None
699+
Color of the outline of the labels. Can either be a named color ("red"), a hex representation
700+
("#000000") or a list of floats that represent RGB/RGBA values (1.0, 0.0, 0.0, 1.0). If ``None``,
701+
the outline inherits from the ``color`` parameter when it is a literal color, or uses data-driven
702+
per-label colors when ``color`` refers to a column.
697703
scale : str | None
698704
Influences the resolution of the rendering. Possibilities for setting this parameter:
699705
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(
733739
na_color=na_color,
734740
norm=norm,
735741
outline_alpha=outline_alpha,
742+
outline_color=outline_color,
736743
palette=palette,
737744
scale=scale,
738745
colorbar=colorbar,
@@ -760,6 +767,7 @@ def render_labels(
760767
cmap_params=cmap_params,
761768
palette=param_values["palette"],
762769
outline_alpha=param_values["outline_alpha"],
770+
outline_color=param_values["outline_color"],
763771
fill_alpha=param_values["fill_alpha"],
764772
transfunc=kwargs.get("transfunc"),
765773
scale=param_values["scale"],

src/spatialdata_plot/pl/render.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1309,7 +1309,12 @@ def _render_labels(
13091309
if isinstance(color_vector.dtype, pd.CategoricalDtype):
13101310
color_vector = color_vector.remove_unused_categories()
13111311

1312-
def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) -> matplotlib.image.AxesImage:
1312+
def _draw_labels(
1313+
seg_erosionpx: int | None,
1314+
seg_boundaries: bool,
1315+
alpha: float,
1316+
outline_color: Color | None = None,
1317+
) -> matplotlib.image.AxesImage:
13131318
labels = _map_color_seg(
13141319
seg=label.values,
13151320
cell_id=instance_id,
@@ -1319,6 +1324,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float)
13191324
seg_erosionpx=seg_erosionpx,
13201325
seg_boundaries=seg_boundaries,
13211326
na_color=na_color,
1327+
outline_color=outline_color,
13221328
)
13231329

13241330
_cax = ax.imshow(
@@ -1334,6 +1340,14 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float)
13341340
cax = ax.add_image(_cax)
13351341
return cax # noqa: RET504
13361342

1343+
# When color is a literal (col_for_color is None) and no explicit outline_color,
1344+
# use the literal color for outlines so they are visible (e.g., color='white' on
1345+
# a dark background). When color is data-driven, outlines inherit the per-label
1346+
# colors from label2rgb (outline_color stays None).
1347+
effective_outline_color = render_params.outline_color
1348+
if effective_outline_color is None and col_for_color is None and render_params.color is not None:
1349+
effective_outline_color = render_params.color
1350+
13371351
# default case: no contour, just fill
13381352
# since contour_px is passed to skimage.morphology.erosion to create the contour,
13391353
# 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)
13501364
seg_erosionpx=render_params.contour_px,
13511365
seg_boundaries=True,
13521366
alpha=render_params.outline_alpha,
1367+
outline_color=effective_outline_color,
13531368
)
13541369
alpha_to_decorate_ax = render_params.outline_alpha
13551370

@@ -1363,6 +1378,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float)
13631378
seg_erosionpx=render_params.contour_px,
13641379
seg_boundaries=True,
13651380
alpha=render_params.outline_alpha,
1381+
outline_color=effective_outline_color,
13661382
)
13671383

13681384
# pass the less-transparent _cax for the legend

src/spatialdata_plot/pl/render_params.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ class LabelsRenderParams:
285285
outline: bool = False
286286
palette: ListedColormap | list[str] | None = None
287287
outline_alpha: float = 1.0
288+
outline_color: Color | None = None
288289
fill_alpha: float = 0.4
289290
transfunc: Callable[[float], float] | None = None
290291
scale: str | None = None

src/spatialdata_plot/pl/utils.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,7 @@
5454
from scipy.spatial import ConvexHull
5555
from shapely.errors import GEOSException
5656
from skimage.color import label2rgb
57-
from skimage.morphology import erosion, square
58-
from skimage.segmentation import find_boundaries
57+
from skimage.morphology import erosion, footprint_rectangle
5958
from skimage.util import map_array
6059
from spatialdata import (
6160
SpatialData,
@@ -1203,6 +1202,7 @@ def _map_color_seg(
12031202
na_color: Color,
12041203
seg_erosionpx: int | None = None,
12051204
seg_boundaries: bool = False,
1205+
outline_color: Color | None = None,
12061206
) -> ArrayLike:
12071207
cell_id = np.array(cell_id)
12081208

@@ -1244,7 +1244,16 @@ def _map_color_seg(
12441244
cols = cmap_params.cmap(cmap_params.norm(color_vector))
12451245

12461246
if seg_erosionpx is not None:
1247-
val_im[val_im == erosion(val_im, square(seg_erosionpx))] = 0
1247+
val_im[val_im == erosion(val_im, footprint_rectangle((seg_erosionpx, seg_erosionpx)))] = 0
1248+
1249+
if seg_boundaries and outline_color is not None:
1250+
# Uniform outline color requested: skip label2rgb, build RGBA directly
1251+
outline_rgba = colors.to_rgba(outline_color.get_hex_with_alpha())
1252+
outline_mask = val_im > 0
1253+
rgba = np.zeros((*val_im.shape, 4), dtype=float)
1254+
rgba[outline_mask, :3] = outline_rgba[:3]
1255+
rgba[outline_mask, 3] = outline_rgba[3]
1256+
return rgba
12481257

12491258
seg_im: ArrayLike = label2rgb(
12501259
label=val_im,
@@ -1255,10 +1264,10 @@ def _map_color_seg(
12551264
)
12561265

12571266
if seg_boundaries:
1258-
if seg.shape[0] == 1:
1259-
seg = np.squeeze(seg, axis=0)
1260-
seg_bound: ArrayLike = np.clip(seg_im - find_boundaries(seg)[:, :, None], 0, 1)
1261-
return np.dstack((seg_bound, np.where(val_im > 0, 1, 0))) # add transparency here
1267+
# Data-driven outline: use seg_im colors on the eroded ring, transparent elsewhere
1268+
outline_mask = val_im > 0
1269+
alpha_channel = outline_mask.astype(float)
1270+
return np.dstack((seg_im, alpha_channel))
12621271

12631272
if len(val_im.shape) != len(seg_im.shape):
12641273
val_im = np.expand_dims((val_im > 0).astype(int), axis=-1)
@@ -2509,6 +2518,7 @@ def _validate_label_render_params(
25092518
na_color: ColorLike | None,
25102519
norm: Normalize | None,
25112520
outline_alpha: float | int,
2521+
outline_color: ColorLike | None,
25122522
scale: str | None,
25132523
table_name: str | None,
25142524
table_layer: str | None,
@@ -2525,6 +2535,7 @@ def _validate_label_render_params(
25252535
"color": color,
25262536
"na_color": na_color,
25272537
"outline_alpha": outline_alpha,
2538+
"outline_color": outline_color,
25282539
"cmap": cmap,
25292540
"norm": norm,
25302541
"scale": scale,
@@ -2547,6 +2558,7 @@ def _validate_label_render_params(
25472558
element_params[el]["fill_alpha"] = param_dict["fill_alpha"]
25482559
element_params[el]["scale"] = param_dict["scale"]
25492560
element_params[el]["outline_alpha"] = param_dict["outline_alpha"]
2561+
element_params[el]["outline_color"] = param_dict["outline_color"]
25502562
element_params[el]["contour_px"] = param_dict["contour_px"]
25512563
element_params[el]["table_layer"] = param_dict["table_layer"]
25522564

-3.76 KB
Loading
62.7 KB
Loading
65.5 KB
Loading
-3.8 KB
Loading
-4.33 KB
Loading
53.1 KB
Loading

0 commit comments

Comments
 (0)