Skip to content

make outline behavior more similar to squidpy #472

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions src/spatialdata_plot/pl/basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,8 @@ def render_shapes(
groups: list[str] | str | None = None,
palette: list[str] | str | None = None,
na_color: ColorLike | None = "default",
outline_width: float | int = 1.5,
outline_color: str | list[float] = "#000000",
outline_width: float | int | tuple[float | int, float | int] = 1.5,
outline_color: str | list[float] | tuple[str | list[float], str | list[float]] = "#000000",
outline_alpha: float | int = 0.0,
cmap: Colormap | str | None = None,
norm: Normalize | None = None,
Expand Down Expand Up @@ -207,13 +207,15 @@ def render_shapes(
Color to be used for NAs values, if present. Can either be a named color ("red"), a hex representation
("#000000ff") or a list of floats that represent RGB/RGBA values (1.0, 0.0, 0.0, 1.0). When None, the values
won't be shown.
outline_width : float | int, default 1.5
Width of the border.
outline_width : float | int | tuple[float | int, float | int], default 1.5
Width of the border. If 2 values are given (tuple), 2 borders are shown with these widths (outer & inner).
In that case, and if < 2 outline_colors are specified, the default color (`outline_color`, "white") is used.
outline_color : str | list[float], default "#000000"
Color of the border. 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 the hex representation includes alpha, e.g.
"#000000ff", the last two positions are ignored, since the alpha of the outlines is solely controlled by
`outline_alpha`.
`outline_alpha`. If 2 values are given (tuple), 2 borders are shown with these colors (outer & inner).
In that case, and if < 2 outline widths are specified, the default width (`outline_width`, 0.5) is used.
outline_alpha : float | int, default 0.0
Alpha value for the outline of shapes. Invisible by default.
cmap : Colormap | str | None, optional
Expand Down Expand Up @@ -247,6 +249,8 @@ def render_shapes(
- Empty geometries will be removed at the time of plotting.
- An `outline_width` of 0.0 leads to no border being plotted.
- When passing a color-like to 'color', this has precendence over the potential existence as a column name.
- If a double outline is rendered, the alpha of the inner and outer border cannot be set individually. The value
of `outline_alpha` is always applied to both.

Returns
-------
Expand Down
142 changes: 111 additions & 31 deletions src/spatialdata_plot/pl/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
)
from spatialdata_plot.pl.utils import (
_ax_show_and_transform,
_convert_alpha_to_datashader_range,
_create_image_from_datashader_result,
_datashader_aggregate_with_function,
_datashader_map_aggregate_to_color,
Expand Down Expand Up @@ -235,12 +236,20 @@ def _render_shapes(
else:
agg = cvs.polygons(transformed_element, geometry="geometry", agg=ds.count())
# render outlines if needed
if (render_outlines := render_params.outline_alpha) > 0:
render_outlines = render_params.outline_alpha > 0
if render_outlines:
agg_outlines = cvs.line(
transformed_element,
geometry="geometry",
line_width=render_params.outline_params.linewidth,
)
# if necessary, compute aggregate for inner outlines
if render_params.outline_params.inner_outline:
agg_inner_outlines = cvs.line(
transformed_element,
geometry="geometry",
line_width=render_params.outline_params.inner_outline_linewidth,
)

ds_span = None
if norm.vmin is not None or norm.vmax is not None:
Expand Down Expand Up @@ -275,8 +284,8 @@ def _render_shapes(
agg,
cmap=ds_cmap,
color_key=color_key,
min_alpha=np.min([254, render_params.fill_alpha * 255]),
) # prevent min_alpha == 255, bc that led to fully colored test plots instead of just colored points/shapes
min_alpha=_convert_alpha_to_datashader_range(render_params.fill_alpha),
)
elif aggregate_with_reduction is not None: # to shut up mypy
ds_cmap = render_params.cmap_params.cmap
# in case all elements have the same value X: we render them using cmap(0.0),
Expand All @@ -292,39 +301,45 @@ def _render_shapes(
ds_result = _datashader_map_aggregate_to_color(
agg,
cmap=ds_cmap,
min_alpha=np.min([254, render_params.fill_alpha * 255]),
min_alpha=_convert_alpha_to_datashader_range(render_params.fill_alpha),
span=ds_span,
clip=norm.clip,
) # prevent min_alpha == 255, bc that led to fully colored test plots instead of just colored points/shapes

# shade outlines if needed
outline_color = render_params.outline_params.outline_color
if isinstance(outline_color, str) and outline_color.startswith("#") and len(outline_color) == 9:
logger.info(
"alpha component of given RGBA value for outline color is discarded, because outline_alpha"
" takes precedent."
)
outline_color = outline_color[:-2]

# shade outlines if needed
if render_outlines:
# outer outlines
outline_color = render_params.outline_params.outline_color
if isinstance(outline_color, str) and outline_color.startswith("#") and len(outline_color) == 9:
logger.info(
"alpha component of given RGBA value for outline color is discarded, because outline_alpha"
" takes precedent."
)
outline_color = outline_color[:-2]
ds_outlines = ds.tf.shade(
agg_outlines,
cmap=outline_color,
min_alpha=np.min([254, render_params.outline_alpha * 255]),
min_alpha=_convert_alpha_to_datashader_range(render_params.outline_alpha),
how="linear",
) # prevent min_alpha == 255, bc that led to fully colored test plots instead of just colored points/shapes
)

rgba_image, trans_data = _create_image_from_datashader_result(ds_result, factor, ax)
_cax = _ax_show_and_transform(
rgba_image,
trans_data,
ax,
zorder=render_params.zorder,
alpha=render_params.fill_alpha,
extent=x_ext + y_ext,
)
# render outline image if needed
if render_outlines:
# inner outlines
if render_params.outline_params.inner_outline:
outline_color = render_params.outline_params.inner_outline_color
if isinstance(outline_color, str) and outline_color.startswith("#") and len(outline_color) == 9:
logger.info(
"alpha component of given RGBA value for outline color is discarded, because outline_alpha"
" takes precedent."
)
outline_color = outline_color[:-2]
ds_inner_outlines = ds.tf.shade(
agg_inner_outlines,
cmap=outline_color,
min_alpha=_convert_alpha_to_datashader_range(render_params.outline_alpha),
how="linear",
)

# render outline image(s)
rgba_image, trans_data = _create_image_from_datashader_result(ds_outlines, factor, ax)
_ax_show_and_transform(
rgba_image,
Expand All @@ -334,6 +349,26 @@ def _render_shapes(
alpha=render_params.outline_alpha,
extent=x_ext + y_ext,
)
if render_params.outline_params.inner_outline:
rgba_image, trans_data = _create_image_from_datashader_result(ds_inner_outlines, factor, ax)
_ax_show_and_transform(
rgba_image,
trans_data,
ax,
zorder=render_params.zorder,
alpha=render_params.outline_alpha,
extent=x_ext + y_ext,
)

rgba_image, trans_data = _create_image_from_datashader_result(ds_result, factor, ax)
_cax = _ax_show_and_transform(
rgba_image,
trans_data,
ax,
zorder=render_params.zorder,
alpha=render_params.fill_alpha,
extent=x_ext + y_ext,
)

cax = None
if aggregate_with_reduction is not None:
Expand All @@ -350,6 +385,51 @@ def _render_shapes(
)

elif method == "matplotlib":
# render outlines separately to ensure they are always underneath the shape
if render_params.outline_alpha > 0:
# 1) render outer outline
_cax = _get_collection_shape(
shapes=shapes,
s=render_params.scale,
c=np.array(["white"]), # hack, will be invisible bc fill_alpha=0
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha,
outline_color=render_params.outline_params.outline_color,
linewidth=render_params.outline_params.linewidth,
zorder=render_params.zorder,
# **kwargs,
)
cax = ax.add_collection(_cax)
# Transform the paths in PatchCollection
for path in _cax.get_paths():
path.vertices = trans.transform(path.vertices)

# 2) render inner outline if necessary
if render_params.outline_params.inner_outline:
_cax = _get_collection_shape(
shapes=shapes,
s=render_params.scale,
c=np.array(["white"]), # hack, will be invisible bc fill_alpha=0
render_params=render_params,
rasterized=sc_settings._vector_friendly,
cmap=None,
norm=None,
fill_alpha=0.0,
outline_alpha=render_params.outline_alpha,
outline_color=render_params.outline_params.inner_outline_color,
linewidth=render_params.outline_params.inner_outline_linewidth,
zorder=render_params.zorder,
# **kwargs,
)
cax = ax.add_collection(_cax)
# Transform the paths in PatchCollection
for path in _cax.get_paths():
path.vertices = trans.transform(path.vertices)

_cax = _get_collection_shape(
shapes=shapes,
s=render_params.scale,
Expand All @@ -359,7 +439,7 @@ def _render_shapes(
cmap=render_params.cmap_params.cmap,
norm=norm,
fill_alpha=render_params.fill_alpha,
outline_alpha=render_params.outline_alpha,
outline_alpha=0.0,
zorder=render_params.zorder,
# **kwargs,
)
Expand Down Expand Up @@ -652,8 +732,8 @@ def _render_points(
ds.tf.spread(agg, px=px),
cmap=color_vector[0],
color_key=color_key,
min_alpha=np.min([254, render_params.alpha * 255]),
) # prevent min_alpha == 255, bc that led to fully colored test plots instead of just colored points/shapes
min_alpha=_convert_alpha_to_datashader_range(render_params.alpha),
)
else:
spread_how = _datshader_get_how_kw_for_spread(render_params.ds_reduction)
agg = ds.tf.spread(agg, px=px, how=spread_how)
Expand All @@ -675,8 +755,8 @@ def _render_points(
cmap=ds_cmap,
span=ds_span,
clip=norm.clip,
min_alpha=np.min([254, render_params.alpha * 255]),
) # prevent min_alpha == 255, bc that led to fully colored test plots instead of just colored points/shapes
min_alpha=_convert_alpha_to_datashader_range(render_params.alpha),
)

rgba_image, trans_data = _create_image_from_datashader_result(ds_result, factor, ax)
_ax_show_and_transform(
Expand Down
3 changes: 3 additions & 0 deletions src/spatialdata_plot/pl/render_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ class OutlineParams:
outline: bool
outline_color: str | list[float]
linewidth: float
inner_outline: bool = False
inner_outline_color: str | list[float] = "#FFFFFF"
inner_outline_linewidth: float = 0.5


@dataclass
Expand Down
Loading
Loading