diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 2dd9019f..a712c80b 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -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, @@ -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 @@ -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 ------- diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 52297005..59c4d090 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -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, @@ -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: @@ -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), @@ -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, @@ -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: @@ -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, @@ -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, ) @@ -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) @@ -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( diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index b44175c3..9e85a0e3 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -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 diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index a2e8f767..e7d833e7 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -330,6 +330,8 @@ def _get_collection_shape( render_params: ShapesRenderParams, fill_alpha: None | float = None, outline_alpha: None | float = None, + outline_color: None | str | list[float] = "white", + linewidth: float = 0.0, **kwargs: Any, ) -> PatchCollection: """ @@ -342,6 +344,8 @@ def _get_collection_shape( - norm: Normalization for the color map. - fill_alpha (float, optional): Opacity for the fill color. - outline_alpha (float, optional): Opacity for the outline. + - outline_color (optional): Color for the outline. + - linewidth (float, optional): Width for the outline. - **kwargs: Additional keyword arguments. Returns @@ -380,11 +384,11 @@ def _get_collection_shape( c = cmap(norm(c)) fill_c = ColorConverter().to_rgba_array(c) - fill_c[..., -1] *= render_params.fill_alpha + fill_c[..., -1] *= fill_alpha if render_params.outline_params.outline: - outline_c = ColorConverter().to_rgba_array(render_params.outline_params.outline_color) - outline_c[..., -1] = render_params.outline_alpha + outline_c = ColorConverter().to_rgba_array(outline_color) + outline_c[..., -1] = outline_alpha outline_c = outline_c.tolist() else: outline_c = [None] @@ -414,7 +418,8 @@ def _assign_fill_and_outline_to_row( def _process_polygon(row: pd.Series, s: float) -> dict[str, Any]: coords = np.array(row["geometry"].exterior.coords) centroid = np.mean(coords, axis=0) - scaled_coords = (centroid + (coords - centroid) * s).tolist() + scaled_vectors = (coords - centroid) * s + scaled_coords = (centroid + scaled_vectors).tolist() return { **row.to_dict(), "geometry": mpatches.Polygon(scaled_coords, closed=True), @@ -459,7 +464,7 @@ def _create_patches(shapes_df: GeoDataFrame, fill_c: list[Any], outline_c: list[ return PatchCollection( patches["geometry"].values.tolist(), snap=False, - lw=render_params.outline_params.linewidth, + lw=linewidth, facecolor=patches["fill_c"], edgecolor=None if all(outline is None for outline in outline_c) else outline_c, **kwargs, @@ -543,27 +548,79 @@ def _prepare_cmap_norm( def _set_outline( outline: bool = False, - outline_width: float = 1.5, - outline_color: str | list[float] = "#0000000ff", # black, white + outline_width: int | float | tuple[float | int, float | int] = 1.5, + outline_color: str | list[float] | tuple[str | list[float], str | list[float]] = "#000000ff", # black, white **kwargs: Any, ) -> OutlineParams: - if not isinstance(outline_width, int | float): - raise TypeError(f"Invalid type of `outline_width`: {type(outline_width)}, expected `int` or `float`.") - if outline_width == 0.0: - outline = False - if outline_width < 0.0: - logger.warning(f"Negative line widths are not allowed, changing {outline_width} to {(-1) * outline_width}") - outline_width *= -1 + """Create OutlineParams object for shapes, including possibility of double outline.""" + # make sure outline_width and outline_color both have length 2 + if isinstance(outline_width, tuple): + if len(outline_width) == 1: + # interpreted as outer outline + if isinstance(outline_color, tuple) and len(outline_color) >= 2: + outline_width = (outline_width[0], 0.5) # default inner outline width + else: + outline_width = (outline_width[0], 0.0) + elif len(outline_width) > 2: + # only using first to positions + logger.warning( + f"Tuple of length {len(outline_width)} was passed for outline_width, only first two positions are used" + " since more than 2 outlines are not supported!" + ) + outline_width = (outline_width[0], outline_width[1]) + if isinstance(outline_color, str | list): + # inner outline default color: white + outline_color = (outline_color, "#ffffffff") + if isinstance(outline_color, tuple): + if (len(outline_color) == 4 or len(outline_color) == 3) and all(isinstance(i, float) for i in outline_color): + # may be an RGBA color tuple + outline_color = (outline_color, "#ffffffff") + elif len(outline_color) == 1: + # interpreted as outer outline + outline_color = (outline_color[0], "#ffffffff") + elif len(outline_color) > 2: + # only using first to positions + logger.warning( + f"Tuple of length {len(outline_color)} was passed for outline_color, only first two positions are used" + " since more than 2 outlines are not supported!" + ) + outline_color = (outline_color[0], outline_color[1]) + if isinstance(outline_width, int | float): + # inner outline default width: 0.5 + outline_width = (outline_width, 0.5) + else: + # single outline + assert isinstance(outline_width, int | float), "outline_width is not int | float" # shut up mypy + outline_width = (outline_width, 0.0) + outline_color = (outline_color, "white") + + assert isinstance(outline_color, tuple), "outline_color is not a tuple" # shut up mypy + assert isinstance(outline_width, tuple), "outline_width is not a tuple" + + for ow in outline_width: + if not isinstance(ow, int | float): + raise TypeError(f"Invalid type of `outline_width`: {type(ow)}, expected `int` or `float`.") # the default black and white colors can be changed using the contour_config parameter - if len(outline_color) in {3, 4} and all(isinstance(c, float) for c in outline_color): - outline_color = matplotlib.colors.to_hex(outline_color) + if len(outline_color[0]) in {3, 4} and all(isinstance(c, float) for c in outline_color[0]): + outline_color = (matplotlib.colors.to_hex(outline_color[0]), outline_color[1]) + if len(outline_color[1]) in {3, 4} and all(isinstance(c, float) for c in outline_color[1]): + outline_color = (outline_color[0], matplotlib.colors.to_hex(outline_color[1])) + + # handle possible linewidths of 0.0 + if np.all(np.array(outline_width) == 0.0): + outline = False + elif outline_width[0] == 0.0: + # only inner outline => is treated as outer outline + outline_width = (outline_width[1], 0.0) + outline_color = (outline_color[1], "white") + inner_outline = outline_width[1] > 0.0 if outline: kwargs.pop("edgecolor", None) # remove edge from kwargs if present kwargs.pop("alpha", None) # remove alpha from kwargs if present - return OutlineParams(outline, outline_color, outline_width) + return OutlineParams(outline, outline_color[0], outline_width[0], inner_outline, outline_color[1], outline_width[1]) def _get_subplots(num_images: int, ncols: int = 4, width: int = 4, height: int = 3) -> plt.Figure | plt.Axes: @@ -1619,9 +1676,17 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st param_dict["col_for_color"] = None if outline_width := param_dict.get("outline_width"): - if not isinstance(outline_width, float | int): - raise TypeError("Parameter 'outline_width' must be numeric.") - if outline_width < 0: + # outline_width only exists for shapes at the moment + if isinstance(outline_width, tuple): + for ow in outline_width: + if isinstance(ow, float | int): + if ow < 0: + raise ValueError("Parameter 'outline_width' cannot contain negative values.") + else: + raise TypeError("Parameter 'outline_width' must contain only numerics when it is a tuple.") + elif not isinstance(outline_width, float | int): + raise TypeError("Parameter 'outline_width' must be numeric or a tuple of two numerics.") + if isinstance(outline_width, float | int) and outline_width < 0: raise ValueError("Parameter 'outline_width' cannot be negative.") if (outline_alpha := param_dict.get("outline_alpha")) and ( @@ -1912,8 +1977,8 @@ def _validate_shape_render_params( palette: list[str] | str | None, color: list[str] | str | None, na_color: ColorLike | None, - outline_width: float | int, - outline_color: str | list[float], + outline_width: float | int | tuple[float | int, float | int], + outline_color: str | list[float] | tuple[str | list[float], str | list[float]], outline_alpha: float | int, cmap: list[Colormap | str] | Colormap | str | None, norm: Normalize | None, @@ -2086,7 +2151,7 @@ def _validate_image_render_params( def _get_wanted_render_elements( sdata: SpatialData, sdata_wanted_elements: list[str], - params: (ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams), + params: ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams, cs: str, element_type: Literal["images", "labels", "points", "shapes"], ) -> tuple[list[str], list[str], bool]: @@ -2243,7 +2308,7 @@ def _create_image_from_datashader_result( def _datashader_aggregate_with_function( - reduction: (Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None), + reduction: Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None, cvs: Canvas, spatial_element: GeoDataFrame | dask.dataframe.core.DataFrame, col_for_color: str | None, @@ -2307,7 +2372,7 @@ def _datashader_aggregate_with_function( def _datshader_get_how_kw_for_spread( - reduction: (Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None), + reduction: Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None, ) -> str: # Get the best input for the how argument of ds.tf.spread(), needed for numerical values reduction = reduction or "sum" @@ -2350,15 +2415,15 @@ def _prepare_transformation( def _get_datashader_trans_matrix_of_single_element( trans: Identity | Scale | Affine | MapAxis | Translation, -) -> npt.NDArray[Any]: +) -> ArrayLike: flip_matrix = np.array([[1, 0, 0], [0, -1, 0], [0, 0, 1]]) - tm: npt.NDArray[Any] = trans.to_affine_matrix(("x", "y"), ("x", "y")) + tm: ArrayLike = trans.to_affine_matrix(("x", "y"), ("x", "y")) if isinstance(trans, Identity): return np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) if isinstance(trans, (Scale | Affine)): # idea: "flip the y-axis", apply transformation, flip back - flip_and_transform: npt.NDArray[Any] = flip_matrix @ tm @ flip_matrix + flip_and_transform: ArrayLike = flip_matrix @ tm @ flip_matrix return flip_and_transform if isinstance(trans, MapAxis): # no flipping needed @@ -2369,7 +2434,7 @@ def _get_datashader_trans_matrix_of_single_element( def _get_transformation_matrix_for_datashader( trans: Scale | Identity | Affine | MapAxis | Translation | SDSequence, -) -> npt.NDArray[Any]: +) -> ArrayLike: """Get the affine matrix needed to transform shapes for rendering with datashader.""" if isinstance(trans, SDSequence): tm = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) @@ -2478,3 +2543,67 @@ def _hex_no_alpha(hex: str) -> str: return "#" + hex_digits[:6] raise ValueError("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'") + + +def _convert_alpha_to_datashader_range(alpha: float) -> float: + """Convert alpha from the range [0, 1] to the range [0, 255] used in datashader.""" + # prevent a value of 255, bc that led to fully colored test plots instead of just colored points/shapes + return min([254, alpha * 255]) + + +class ColorHandler: + """Validate, parse and store a single color.""" + + color_hex: str | None + + def __init__(self, color: ColorLike | list[float] | None): + """Validate input and store as hex color. + + `color` can be + - str: hex representation, named color or "default" + - None (values won't be shown) + - tuple[float, ...] | list[float]: RGB(A) array (all values within [0, 1]!) + """ + if color is None: + self.color_hex = None + elif isinstance(color, str): + # already hex + if color.startswith("#"): + if len(color) not in [7, 9]: + raise ValueError("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'") + self.color_hex = color.lower() + if len(self.color_hex) == 7: + self.color_hex += "ff" # add alpha of 1.0 if needed + if not all(c in "0123456789abcdef" for c in self.color_hex[1:]): + raise ValueError("Invalid hex color: contains non-hex characters") + # "default" should refer to lightgray + elif color == "default": + self.color_hex = to_hex("lightgray", keep_alpha=True) + # named color + else: + # matplotlib raises ValueError in case of invalid color name + self.color_hex = to_hex(color, keep_alpha=True) + elif isinstance(color, list[float] | tuple[float, ...]): + if len(color) < 3: + raise ValueError(f"Color `{color}` can't be interpreted as RGB(A) array, needs at least 3 values.") + # get first 3-4 values + r, g, b = color[0], color[1], color[2] + a = 1.0 if len(color) == 3 else color[3] + if any(np.array([r, g, b, a]) > 1) or any(np.array([r, g, b, a]) < 0): + raise ValueError(f"Invalid color `{color}`, all values in RGB(A) array must be within [0.0, 1.0].") + self.color_hex = colors.rgb2hex((r, g, b, a), keep_alpha=True) + else: + raise TypeError( + f"Invalid type `{type(color)}` for a color, expecting str | None | tuple[float, ...] | list[float]." + ) + + def get_hex(self) -> str | None: + """Get color value as '#RRGGBBAA'.""" + return self.color_hex + + def get_hex_without_alpha(self) -> str | None: + """Get color value as '#RRGGBB'.""" + if isinstance(self.color_hex, str): + # return hex after stripping the last 2 positions (=alpha) + return self.color_hex[0:7] + return self.color_hex diff --git a/tests/_images/Shapes_can_render_circles_with_colored_outline.png b/tests/_images/Shapes_can_render_circles_with_colored_outline.png index 23c0e75f..43510653 100644 Binary files a/tests/_images/Shapes_can_render_circles_with_colored_outline.png and b/tests/_images/Shapes_can_render_circles_with_colored_outline.png differ diff --git a/tests/_images/Shapes_can_render_circles_with_default_outline_width.png b/tests/_images/Shapes_can_render_circles_with_default_outline_width.png index 2d0b11c6..eb6aafab 100644 Binary files a/tests/_images/Shapes_can_render_circles_with_default_outline_width.png and b/tests/_images/Shapes_can_render_circles_with_default_outline_width.png differ diff --git a/tests/_images/Shapes_can_render_circles_with_outline.png b/tests/_images/Shapes_can_render_circles_with_outline.png index 2d0b11c6..eb6aafab 100644 Binary files a/tests/_images/Shapes_can_render_circles_with_outline.png and b/tests/_images/Shapes_can_render_circles_with_outline.png differ diff --git a/tests/_images/Shapes_can_render_circles_with_specified_outline_width.png b/tests/_images/Shapes_can_render_circles_with_specified_outline_width.png index c3d6e271..7b31504f 100644 Binary files a/tests/_images/Shapes_can_render_circles_with_specified_outline_width.png and b/tests/_images/Shapes_can_render_circles_with_specified_outline_width.png differ diff --git a/tests/_images/Shapes_can_render_polygons_with_outline.png b/tests/_images/Shapes_can_render_polygons_with_outline.png index 12cfde7b..ef938f9d 100644 Binary files a/tests/_images/Shapes_can_render_polygons_with_outline.png and b/tests/_images/Shapes_can_render_polygons_with_outline.png differ diff --git a/tests/_images/Shapes_can_render_polygons_with_rgb_colored_outline.png b/tests/_images/Shapes_can_render_polygons_with_rgb_colored_outline.png index 7d02401b..a3800d4c 100644 Binary files a/tests/_images/Shapes_can_render_polygons_with_rgb_colored_outline.png and b/tests/_images/Shapes_can_render_polygons_with_rgb_colored_outline.png differ diff --git a/tests/_images/Shapes_can_render_polygons_with_rgba_colored_outline.png b/tests/_images/Shapes_can_render_polygons_with_rgba_colored_outline.png index ffc5e422..6d268c2b 100644 Binary files a/tests/_images/Shapes_can_render_polygons_with_rgba_colored_outline.png and b/tests/_images/Shapes_can_render_polygons_with_rgba_colored_outline.png differ diff --git a/tests/_images/Shapes_can_render_polygons_with_str_colored_outline.png b/tests/_images/Shapes_can_render_polygons_with_str_colored_outline.png index a43b1027..920fdc7b 100644 Binary files a/tests/_images/Shapes_can_render_polygons_with_str_colored_outline.png and b/tests/_images/Shapes_can_render_polygons_with_str_colored_outline.png differ diff --git a/tests/_images/Shapes_can_render_shapes_with_colored_double_outline.png b/tests/_images/Shapes_can_render_shapes_with_colored_double_outline.png new file mode 100644 index 00000000..d1381b97 Binary files /dev/null and b/tests/_images/Shapes_can_render_shapes_with_colored_double_outline.png differ diff --git a/tests/_images/Shapes_can_render_shapes_with_double_outline.png b/tests/_images/Shapes_can_render_shapes_with_double_outline.png new file mode 100644 index 00000000..2a01f8e5 Binary files /dev/null and b/tests/_images/Shapes_can_render_shapes_with_double_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_render_shapes_with_colored_double_outline.png b/tests/_images/Shapes_datashader_can_render_shapes_with_colored_double_outline.png new file mode 100644 index 00000000..36ce6f7b Binary files /dev/null and b/tests/_images/Shapes_datashader_can_render_shapes_with_colored_double_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_render_shapes_with_double_outline.png b/tests/_images/Shapes_datashader_can_render_shapes_with_double_outline.png new file mode 100644 index 00000000..fc7d0270 Binary files /dev/null and b/tests/_images/Shapes_datashader_can_render_shapes_with_double_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_render_with_colored_outline.png b/tests/_images/Shapes_datashader_can_render_with_colored_outline.png index d4db8bd6..ea8944fc 100644 Binary files a/tests/_images/Shapes_datashader_can_render_with_colored_outline.png and b/tests/_images/Shapes_datashader_can_render_with_colored_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_render_with_diff_width_outline.png b/tests/_images/Shapes_datashader_can_render_with_diff_width_outline.png index d0f2d5e5..5f374369 100644 Binary files a/tests/_images/Shapes_datashader_can_render_with_diff_width_outline.png and b/tests/_images/Shapes_datashader_can_render_with_diff_width_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_render_with_outline.png b/tests/_images/Shapes_datashader_can_render_with_outline.png index cf017519..c4d0b5c0 100644 Binary files a/tests/_images/Shapes_datashader_can_render_with_outline.png and b/tests/_images/Shapes_datashader_can_render_with_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_render_with_rgb_colored_outline.png b/tests/_images/Shapes_datashader_can_render_with_rgb_colored_outline.png index ca512872..6c6dde54 100644 Binary files a/tests/_images/Shapes_datashader_can_render_with_rgb_colored_outline.png and b/tests/_images/Shapes_datashader_can_render_with_rgb_colored_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_render_with_rgba_colored_outline.png b/tests/_images/Shapes_datashader_can_render_with_rgba_colored_outline.png index 6fb9c127..d79d8072 100644 Binary files a/tests/_images/Shapes_datashader_can_render_with_rgba_colored_outline.png and b/tests/_images/Shapes_datashader_can_render_with_rgba_colored_outline.png differ diff --git a/tests/_images/Shapes_datashader_can_transform_circles.png b/tests/_images/Shapes_datashader_can_transform_circles.png index 60cde073..49659e0d 100644 Binary files a/tests/_images/Shapes_datashader_can_transform_circles.png and b/tests/_images/Shapes_datashader_can_transform_circles.png differ diff --git a/tests/_images/Shapes_datashader_can_transform_multipolygons.png b/tests/_images/Shapes_datashader_can_transform_multipolygons.png index 09e56c63..03fde2cf 100644 Binary files a/tests/_images/Shapes_datashader_can_transform_multipolygons.png and b/tests/_images/Shapes_datashader_can_transform_multipolygons.png differ diff --git a/tests/_images/Shapes_datashader_can_transform_polygons.png b/tests/_images/Shapes_datashader_can_transform_polygons.png index fb2552ff..f58a9bd4 100644 Binary files a/tests/_images/Shapes_datashader_can_transform_polygons.png and b/tests/_images/Shapes_datashader_can_transform_polygons.png differ diff --git a/tests/_images/Utils_set_outline_accepts_str_or_float_or_list_thereof.png b/tests/_images/Utils_set_outline_accepts_str_or_float_or_list_thereof.png index 3bc09956..673a4ce1 100644 Binary files a/tests/_images/Utils_set_outline_accepts_str_or_float_or_list_thereof.png and b/tests/_images/Utils_set_outline_accepts_str_or_float_or_list_thereof.png differ diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 953eb843..0e8b9d53 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -562,3 +562,25 @@ def test_plot_can_annotate_shapes_with_table_layer(self, sdata_blobs: SpatialDat sdata_blobs["circle_table"].layers["normalized"] = RNG.random((nrows, ncols)) sdata_blobs.pl.render_shapes("blobs_circles", color="feature0", table_layer="normalized").pl.show() + + def test_plot_can_render_shapes_with_double_outline(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_shapes("blobs_circles", outline_alpha=1.0, outline_width=(10.0, 5.0)).pl.show() + + def test_plot_can_render_shapes_with_colored_double_outline(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_shapes( + "blobs_polygons", outline_alpha=1.0, outline_width=(10.0, 5.0), outline_color=("purple", "orange") + ).pl.show() + + def test_plot_datashader_can_render_shapes_with_double_outline(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_shapes( + "blobs_circles", outline_alpha=1.0, outline_width=(10.0, 5.0), method="datashader" + ).pl.show() + + def test_plot_datashader_can_render_shapes_with_colored_double_outline(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_shapes( + "blobs_polygons", + outline_alpha=1.0, + outline_width=(10.0, 5.0), + outline_color=("purple", "orange"), + method="datashader", + ).pl.show()