From a1788c8a374e8e4dd646f54f7ecf6c9efe760dad Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 17 Jan 2024 20:10:52 +0100 Subject: [PATCH] Updated render_shapes doc (#199) * Updated render_shapes doc * Updated render_shapes * halfway points * Updated type hints and handling * Fixed color behaviour * Changed source of logger; fixed type in test * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Fixed typos in documentation; minor correction to type checks * Fixed test * Added tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- src/spatialdata_plot/pl/basic.py | 717 ++++++++++++++---- src/spatialdata_plot/pl/render.py | 92 +-- src/spatialdata_plot/pl/render_params.py | 2 + src/spatialdata_plot/pl/utils.py | 30 +- ...color_recognises_actual_color_as_color.png | Bin 0 -> 6749 bytes ...color_recognises_actual_color_as_color.png | Bin 0 -> 6081 bytes tests/pl/test_render_points.py | 3 + tests/pl/test_render_shapes.py | 3 + 8 files changed, 633 insertions(+), 214 deletions(-) create mode 100644 tests/_images/Points_color_recognises_actual_color_as_color.png create mode 100644 tests/_images/Shapes_color_recognises_actual_color_as_color.png diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 9fef8546..0f18c5d7 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -2,9 +2,8 @@ import sys from collections import OrderedDict -from collections.abc import Sequence from pathlib import Path -from typing import Any +from typing import Any, Union import matplotlib.pyplot as plt import numpy as np @@ -13,6 +12,7 @@ from anndata import AnnData from dask.dataframe.core import DataFrame as DaskDataFrame from geopandas import GeoDataFrame +from matplotlib import colors from matplotlib.axes import Axes from matplotlib.colors import Colormap, Normalize from matplotlib.figure import Figure @@ -20,9 +20,11 @@ from pandas.api.types import is_categorical_dtype from spatial_image import SpatialImage from spatialdata._core.data_extent import get_extent +from spatialdata._core.query.relational_query import _locate_value from spatialdata.transformations.operations import get_transformation from spatialdata_plot._accessor import register_spatial_data_accessor +from spatialdata_plot._logging import logger from spatialdata_plot.pl.render import ( _render_images, _render_labels, @@ -51,6 +53,8 @@ ) from spatialdata_plot.pp.utils import _verify_plotting_tree +ColorLike = Union[tuple[float, ...], str] + @register_spatial_data_accessor("pl") class PlotAccessor: @@ -85,33 +89,33 @@ def __init__(self, sdata: sd.SpatialData) -> None: def _copy( self, - images: None | dict[str, SpatialImage | MultiscaleSpatialImage] = None, - labels: None | dict[str, SpatialImage | MultiscaleSpatialImage] = None, - points: None | dict[str, DaskDataFrame] = None, - shapes: None | dict[str, GeoDataFrame] = None, - table: None | AnnData = None, + images: dict[str, SpatialImage | MultiscaleSpatialImage] | None = None, + labels: dict[str, SpatialImage | MultiscaleSpatialImage] | None = None, + points: dict[str, DaskDataFrame] | None = None, + shapes: dict[str, GeoDataFrame] | None = None, + table: AnnData | None = None, ) -> sd.SpatialData: """Copy the current `SpatialData` object, optionally modifying some of its attributes. Parameters ---------- - images : + images : dict[str, SpatialImage | MultiscaleSpatialImage] | None, optional A dictionary containing image data to replace the images in the original `SpatialData` object, or `None` to keep the original images. Defaults to `None`. - labels : + labels : dict[str, SpatialImage | MultiscaleSpatialImage] | None, optional A dictionary containing label data to replace the labels in the original `SpatialData` object, or `None` to keep the original labels. Defaults to `None`. - points : + points : dict[str, DaskDataFrame] | None, optional A dictionary containing point data to replace the points in the original `SpatialData` object, or `None` to keep the original points. Defaults to `None`. - shapes : + shapes : dict[str, GeoDataFrame] | None, optional A dictionary containing shape data to replace the shapes in the original `SpatialData` object, or `None` to keep the original shapes. Defaults to `None`. - table : + table : AnnData | None, optional A dictionary or `AnnData` object containing table data to replace the table in the original `SpatialData` object, or `None` to keep the original table. Defaults to `None`. @@ -142,20 +146,20 @@ def _copy( def render_shapes( self, - elements: str | list[str] | None = None, + elements: list[str] | str | None = None, color: str | None = None, - groups: str | Sequence[str] | None = None, - scale: float = 1.0, + fill_alpha: float | int = 1.0, + groups: list[str] | str | None = None, + palette: list[str] | str | None = None, + na_color: ColorLike | None = "lightgrey", outline: bool = False, - outline_width: float = 1.5, + outline_width: float | int = 1.5, outline_color: str | list[float] = "#000000ff", + outline_alpha: float | int = 1.0, layer: str | None = None, - palette: str | list[str] | None = None, cmap: Colormap | str | None = None, norm: bool | Normalize = False, - na_color: str | tuple[float, ...] | None = "lightgrey", - outline_alpha: float = 1.0, - fill_alpha: float = 1.0, + scale: float | int = 1.0, **kwargs: Any, ) -> sd.SpatialData: """ @@ -163,50 +167,156 @@ def render_shapes( Parameters ---------- - elements - The name of the shapes element(s) to render. If `None`, all - shapes element in the `SpatialData` object will be used. - color - Key for annotations in :attr:`anndata.AnnData.obs` or variables/genes. - groups - For discrete annotation in ``color``, select which values - to plot (other values are set to NAs). - scale - Value to scale circles, if present. - outline - If `True`, a thin border around points/shapes is plotted. - outline_width + elements : list[str] | str | None, optional + The name(s) of the shapes element(s) to render. If `None`, all shapes + elements in the `SpatialData` object will be used. + color : Colorlike | str | None, optional + Can either be a color-like or a key in :attr:`sdata.table.obs`. The latter + can be used to color by categorical or continuous variables. + fill_alpha : float | int, default 1.0 + Alpha value for the fill of shapes. + groups : list[str] | str | None, optional + When using `color` and the key represents discrete labels, `groups` + can be used to show only a subset of them. Other values are set to NA. + palette : list[str] | str | None, optional + Palette for discrete annotations. List of valid color names that should be + used for the categories. Must match the number of groups. + na_color : str | list[float] | None, default "lightgrey" + 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 : bool, default False + If `True`, a border around the shape elements is plotted. + outline_width : float | int, default 1.5 Width of the border. - outline_color - Color of the border. - layer + outline_color : str | list[float], default "#000000ff" + Color of the border. 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). + outline_alpha : float | int, default 1.0 + Alpha value for the outline of shapes. + layer : str | None, optional Key in :attr:`anndata.AnnData.layers` or `None` for :attr:`anndata.AnnData.X`. - palette - Palette for discrete annotations. List of valid color names that should be used - for the categories (all or as specified by `groups`). For a single category, - a valid color name can be given as string. - cmap - Colormap for continuous annotations, see :class:`matplotlib.colors.Colormap`. - If no palette is given and `color` refers to a categorical, the colors are - sampled from this colormap. - norm - Colormap normalization for continuous annotations, see :class:`matplotlib.colors.Normalize`. - na_color - Color to be used for NAs values, if present. - alpha - Alpha value for the shapes. - kwargs + cmap : Colormap | str | None, optional + Colormap for discrete or continuous annotations using 'color', see :class:`matplotlib.colors.Colormap`. + norm : bool | Normalize, default False + Colormap normalization for continuous annotations. + scale : float | int, default 1.0 + Value to scale circles, if present. + **kwargs : Any Additional arguments to be passed to cmap and norm. Notes ----- - Empty geometries will be removed at the time of plotting. - An ``outline_width`` of 0.0 leads to no border being plotted. + - 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. Returns ------- - None + sd.SpatialData + The modified SpatialData object with the rendered shapes. """ + if elements is not None: + if not isinstance(elements, (list, str)): + raise TypeError("Parameter 'elements' must be a string or a list of strings.") + + elements = [elements] if isinstance(elements, str) else elements + if any(e not in self._sdata.shapes for e in elements): + raise ValueError( + "Not all specificed elements were found, available elements are: '" + + "', '".join(self._sdata.shapes.keys()) + + "'" + ) + + if color is None: + col_for_color = None + + elif colors.is_color_like(color): + logger.info("Value for parameter 'color' appears to be a color, using it as such.") + color = color + col_for_color = None + + else: + if not isinstance(color, str): + raise TypeError( + "Parameter 'color' must be a string indicating which color " + + "in sdata.table to use for coloring the shapes." + ) + col_for_color = color + color = None + + # we're not enforcing the existence of 'color' here since it might + # exist for one element in sdata.shapes, but not the others. + # Gets validated in _set_color_source_vec() + + if not isinstance(fill_alpha, (float, int)): + raise TypeError("Parameter 'fill_alpha' must be numeric.") + + if fill_alpha < 0: + raise ValueError("Parameter 'fill_alpha' cannot be negative.") + + if groups is not None: + if not isinstance(groups, (list, str)): + raise TypeError("Parameter 'groups' must be a string or a list of strings.") + groups = [groups] if isinstance(groups, str) else groups + + if palette is not None: + if groups is None: + raise ValueError("When specifying 'palette', 'groups' must also be specified.") + + if not isinstance(palette, (list, str)): + raise TypeError("Parameter 'palette' must be a string or a list of strings.") + + palette = [palette] if isinstance(palette, str) else palette + + if len(groups) != len(palette): + raise ValueError("The length of 'palette' and 'groups' must be the same.") + + if not colors.is_color_like(na_color): + raise TypeError("Parameter 'na_color' must be color-like.") + + if not isinstance(outline, bool): + raise TypeError("Parameter 'outline' must be a True or False.") + + if not isinstance(outline_width, (float, int)): + raise TypeError("Parameter 'outline_width' must be numeric.") + + if outline_width < 0: + raise ValueError("Parameter 'outline_width' cannot be negative.") + + if not colors.is_color_like(outline_color): + raise TypeError("Parameter 'outline_color' must be color-like.") + + if not isinstance(outline_alpha, (float, int)): + raise TypeError("Parameter 'outline_alpha' must be numeric.") + + if outline_alpha < 0: + raise ValueError("Parameter 'outline_alpha' cannot be negative.") + + if layer is not None and not isinstance(layer, str): + raise TypeError("Parameter 'layer' must be a string.") + + if layer is not None and layer not in self._sdata.table.layers: + raise ValueError( + f"Could not find layer '{layer}', available layers are: '" + + "', '".join(self._sdata.table.layers.keys()) + + "'" + ) + + if cmap is not None and not isinstance(cmap, (str, Colormap)): + raise TypeError("Parameter 'cmap' must be a mpl.Colormap or the name of one.") + + if norm is not None and not isinstance(norm, (bool, Normalize)): + raise TypeError("Parameter 'norm' must be a boolean or a mpl.Normalize.") + + if not isinstance(scale, (float, int)): + raise TypeError("Parameter 'scale' must be numeric.") + + if scale < 0: + raise ValueError("Parameter 'scale' must be a positive number.") + sdata = self._copy() sdata = _verify_plotting_tree(sdata) n_steps = len(sdata.plotting_tree.keys()) @@ -222,6 +332,7 @@ def render_shapes( sdata.plotting_tree[f"{n_steps+1}_render_shapes"] = ShapesRenderParams( elements=elements, color=color, + col_for_color=col_for_color, groups=groups, scale=scale, outline_params=outline_params, @@ -237,15 +348,15 @@ def render_shapes( def render_points( self, - elements: str | list[str] | None = None, + elements: list[str] | str | None = None, color: str | None = None, - groups: str | Sequence[str] | None = None, - size: float = 1.0, - palette: str | list[str] | None = None, + alpha: float | int = 1.0, + groups: list[str] | str | None = None, + palette: list[str] | str | None = None, + na_color: ColorLike | None = "lightgrey", cmap: Colormap | str | None = None, norm: None | Normalize = None, - na_color: str | tuple[float, ...] | None = (0.0, 0.0, 0.0, 0.0), - alpha: float = 1.0, + size: float | int = 1.0, **kwargs: Any, ) -> sd.SpatialData: """ @@ -253,37 +364,120 @@ def render_points( Parameters ---------- - elements - The name of the points element(s) to render. If `None`, all - shapes element in the `SpatialData` object will be used. - color - Key for annotations in :attr:`anndata.AnnData.obs` or variables/genes. - groups - For discrete annotation in ``color``, select which values - to plot (other values are set to NAs). - size - Value to scale points. - palette - Palette for discrete annotations. List of valid color names that should be used - for the categories (all or as specified by `groups`). For a single category, - a valid color name can be given as string. - cmap - Colormap for continuous annotations, see :class:`matplotlib.colors.Colormap`. - If no palette is given and `color` refers to a categorical, the colors are - sampled from this colormap. - norm - Colormap normalization for continuous annotations, see :class:`matplotlib.colors.Normalize`. - na_color - Color to be used for NAs values, if present. - alpha - Alpha value for the shapes. + elements : list[str] | str | None, optional + The name(s) of the points element(s) to render. If `None`, all points + elements in the `SpatialData` object will be used. + color : Colorlike | str | None, optional + Can either be a color-like or a key in :attr:`sdata.table.obs`. The latter + can be used to color by categorical or continuous variables. + alpha : float | int, default 1.0 + Alpha value for the points. + groups : list[str] | str | None, optional + When using `color` and the key represents discrete labels, `groups` + can be used to show only a subset of them. Other values are set to NA. + palette : list[str] | str | None, optional + Palette for discrete annotations. List of valid color names that should be + used for the categories. Must match the number of groups. + na_color : str | list[float] | None, default "lightgrey" + 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. + cmap : Colormap | str | None, optional + Colormap for discrete or continuous annotations using 'color', see + :class:`matplotlib.colors.Colormap`. If no palette is given and `color` + refers to a categorical, the colors are sampled from this colormap. + norm : bool | Normalize, default False + Colormap normalization for continuous annotations. + size : float | int, default 1.0 + Size of the points kwargs Additional arguments to be passed to cmap and norm. Returns ------- - None + sd.SpatialData + The modified SpatialData object with the rendered shapes. """ + if elements is not None: + if not isinstance(elements, (list, str)): + raise TypeError("Parameter 'elements' must be a string or a list of strings.") + + elements = [elements] if isinstance(elements, str) else elements + if any(e not in self._sdata.points for e in elements): + raise ValueError( + "Not all specificed elements were found, available elements are: '" + + "', '".join(self._sdata.points.keys()) + + "'" + ) + + if color is not None and not colors.is_color_like(color): + tmp_e = self._sdata.points if elements is None else elements + origins = [ + _locate_value(value_key=color, sdata=self._sdata, element_name=e) for e in tmp_e + ] # , element_name=element_name) + if not any(origins): + raise ValueError("The argument for 'color' is neither color-like nor in the data.") + + if color is None: + col_for_color = None + + elif colors.is_color_like(color): + logger.info("Value for parameter 'color' appears to be a color, using it as such.") + color = color + col_for_color = None + + else: + if not isinstance(color, str): + raise TypeError( + "Parameter 'color' must be a string indicating which color " + + "in sdata.table to use for coloring the shapes." + ) + col_for_color = color + color = None + + # we're not enforcing the existence of 'color' here since it might + # exist for one element in sdata.shapes, but not the others. + # Gets validated in _set_color_source_vec() + + if not isinstance(alpha, (float, int)): + raise TypeError("Parameter 'alpha' must be numeric.") + + if alpha < 0: + raise ValueError("Parameter 'alpha' cannot be negative.") + + if groups is not None: + if not isinstance(groups, (list, str)): + raise TypeError("Parameter 'groups' must be a string or a list of strings.") + groups = [groups] if isinstance(groups, str) else groups + + if palette is not None: + if groups is None: + raise ValueError("When specifying 'palette', 'groups' must also be specified.") + + if not isinstance(palette, (list, str)): + raise TypeError("Parameter 'palette' must be a string or a list of strings.") + + palette = [palette] if isinstance(palette, str) else palette + + if len(groups) != len(palette): + raise ValueError("The length of 'palette' and 'groups' must be the same.") + + if not colors.is_color_like(na_color): + raise TypeError("Parameter 'na_color' must be color-like.") + + if cmap is not None and not isinstance(cmap, (str, Colormap)): + raise TypeError("Parameter 'cmap' must be a mpl.Colormap or the name of one.") + + if norm is not None and not isinstance(norm, (bool, Normalize)): + raise TypeError("Parameter 'norm' must be a boolean or a mpl.Normalize.") + + if not isinstance(size, (float, int)): + raise TypeError("Parameter 'size' must be numeric.") + + if size < 0: + raise ValueError("Parameter 'size' must be a positive number.") + sdata = self._copy() sdata = _verify_plotting_tree(sdata) n_steps = len(sdata.plotting_tree.keys()) @@ -294,11 +488,11 @@ def render_points( na_color=na_color, # type: ignore[arg-type] **kwargs, ) - if isinstance(elements, str): - elements = [elements] + sdata.plotting_tree[f"{n_steps+1}_render_points"] = PointsRenderParams( elements=elements, color=color, + col_for_color=col_for_color, groups=groups, cmap_params=cmap_params, palette=palette, @@ -311,15 +505,15 @@ def render_points( def render_images( self, - elements: str | list[str] | None = None, - channel: list[str] | list[int] | int | str | None = None, - cmap: list[Colormap] | list[str] | Colormap | str | None = None, - norm: None | Normalize = None, - na_color: str | tuple[float, ...] | None = (0.0, 0.0, 0.0, 0.0), - palette: str | list[str] | None = None, - alpha: float = 1.0, - quantiles_for_norm: tuple[float | None, float | None] = (None, None), - scale: str | list[str] | None = None, + elements: list[str] | str | None = None, + channel: list[str] | list[int] | str | int | None = None, + cmap: list[Colormap] | Colormap | str | None = None, + norm: Normalize | None = None, + na_color: ColorLike | None = (0.0, 0.0, 0.0, 0.0), + palette: list[str] | str | None = None, + alpha: float | int = 1.0, + quantiles_for_norm: tuple[float | None, float | None] | None = None, + scale: list[str] | str | None = None, **kwargs: Any, ) -> sd.SpatialData: """ @@ -327,37 +521,105 @@ def render_images( Parameters ---------- - elements - The name of the image element(s) to render. If `None`, all - shapes elements in the `SpatialData` object will be used. - channel - To select which channel to plot (all by default). - cmap - Colormap for continuous annotations, see :class:`matplotlib.colors.Colormap`. - norm + elements : list[str] | str | None, optional + The name(s) of the image element(s) to render. If `None`, all image + elements in the `SpatialData` object will be used. If a string is provided, + it is converted into a single-element list. + channel : list[str] | list[int] | str | int | None, optional + To select specific channels to plot. Can be a single channel name/int or a + list of channel names/ints. If `None`, all channels will be used. + cmap : list[Colormap] | Colormap | str | None, optional + Colormap or list of colormaps for continuous annotations, see :class:`matplotlib.colors.Colormap`. + Each colormap applies to a corresponding channel. + norm : Normalize | None, optional Colormap normalization for continuous annotations, see :class:`matplotlib.colors.Normalize`. - na_color - Color to be used for NAs values, if present. - alpha - Alpha value for the shapes. - quantiles_for_norm - Tuple of (pmin, pmax) which will be used for quantile normalization. - scale - 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 - is selected before the rasterization step. - 2) Name of one of the scales in the multiscale image to be rendered. This scale is rendered as it is - (exception: a dpi is specified in `show()`. Then the image is rasterized to fit the canvas and dpi). - 3) "full": render the full image without rasterization. In the case of a multiscale image, the scale - with the highest resolution is selected. This can lead to long computing times for large images! - 4) List that is matched to the list of elements (can contain `None`, scale names or "full"). + Applies to all channels if set. + na_color : ColorLike | None, default (0.0, 0.0, 0.0, 0.0) + Color to be used for NA values. Accepts color-like values (string, hex, RGB(A)). + alpha : float | int, default 1.0 + Alpha value for the images. Must be a numeric between 0 and 1. + quantiles_for_norm : tuple[float | None, float | None] | None, optional + Optional pair of floats (pmin < pmax, 0-100) which will be used for quantile normalization. + scale : list[str] | str | None, optional + Influences the resolution of the rendering. Possibilities include: + 1) `None` (default): The image is rasterized to fit the canvas size. For + multiscale images, the best scale is selected before rasterization. + 2) A scale name: Renders the specified scale as-is (with adjustments for dpi + in `show()`). + 3) "full": Renders the full image without rasterization. In the case of + multiscale images, the highest resolution scale is selected. Note that + this may result in long computing times for large images. + 4) A list matching the list of elements. Can contain `None`, scale names, or + "full". Each scale applies to the corresponding element. kwargs - Additional arguments to be passed to cmap and norm. + Additional arguments to be passed to cmap, norm, and other rendering functions. Returns ------- - None + sd.SpatialData + The SpatialData object with the rendered images. """ + if elements is not None: + if not isinstance(elements, (list, str)): + raise TypeError("Parameter 'elements' must be a string or a list of strings.") + + elements = [elements] if isinstance(elements, str) else elements + if any(e not in self._sdata.images for e in elements): + raise ValueError( + "Not all specificed elements were found, available elements are: '" + + "', '".join(self._sdata.images.keys()) + + "'" + ) + + if channel is not None and not isinstance(channel, (list, str, int)): + raise TypeError("Parameter 'channel' must be a string, an integer, or a list of strings or integers.") + + if isinstance(channel, list) and not all(isinstance(c, (str, int)) for c in channel): + raise TypeError("Each item in 'channel' list must be a string or an integer.") + + if cmap is not None and not isinstance(cmap, (list, Colormap, str)): + raise TypeError("Parameter 'cmap' must be a string, a Colormap, or a list of these types.") + + if isinstance(cmap, list) and not all(isinstance(c, (Colormap, str)) for c in cmap): + raise TypeError("Each item in 'cmap' list must be a string or a Colormap.") + + if norm is not None and not isinstance(norm, Normalize): + raise TypeError("Parameter 'norm' must be of type Normalize.") + + if na_color is not None and not colors.is_color_like(na_color): + raise ValueError("Parameter 'na_color' must be color-like.") + + if palette is not None and not isinstance(palette, (list, str)): + raise TypeError("Parameter 'palette' must be a string or a list of strings.") + + if not isinstance(alpha, (float, int)): + raise TypeError("Parameter 'alpha' must be numeric.") + + if alpha < 0: + raise ValueError("Parameter 'alpha' cannot be negative.") + + if quantiles_for_norm is None: + quantiles_for_norm = (None, None) + elif not isinstance(quantiles_for_norm, (list, tuple)): + raise TypeError("Parameter 'quantiles_for_norm' must be a list or tuple of floats, or None.") + elif len(quantiles_for_norm) != 2: + raise ValueError("Parameter 'quantiles_for_norm' must contain exactly two elements.") + else: + if not all( + isinstance(p, (float, int, type(None))) and (p is None or 0 <= p <= 100) for p in quantiles_for_norm + ): + raise TypeError("Each item in 'quantiles_for_norm' must be a float or int within [0, 100], or None.") + + pmin, pmax = quantiles_for_norm + if pmin is not None and pmax is not None and pmin > pmax: + raise ValueError("The first number in 'quantiles_for_norm' must not be smaller than the second.") + + if scale is not None and not isinstance(scale, (list, str)): + raise TypeError("If specified, parameter 'scale' must be a string or a list of strings.") + + if isinstance(scale, list) and not all(isinstance(s, str) for s in scale): + raise TypeError("Each item in 'scale' list must be a string.") + sdata = self._copy() sdata = _verify_plotting_tree(sdata) n_steps = len(sdata.plotting_tree.keys()) @@ -398,19 +660,19 @@ def render_images( def render_labels( self, - elements: str | list[str] | None = None, + elements: list[str] | str | None = None, color: str | None = None, - groups: str | Sequence[str] | None = None, + groups: list[str] | str | None = None, contour_px: int = 3, outline: bool = False, layer: str | None = None, - palette: str | list[str] | None = None, + palette: list[str] | str | None = None, cmap: Colormap | str | None = None, - norm: None | Normalize = None, - na_color: str | tuple[float, ...] | None = (0.0, 0.0, 0.0, 0.0), - outline_alpha: float = 1.0, - fill_alpha: float = 0.3, - scale: str | list[str] | None = None, + norm: Normalize | None = None, + na_color: ColorLike | None = (0.0, 0.0, 0.0, 0.0), + outline_alpha: float | int = 1.0, + fill_alpha: float | int = 0.3, + scale: list[str] | str | None = None, **kwargs: Any, ) -> sd.SpatialData: """ @@ -418,32 +680,35 @@ def render_labels( Parameters ---------- - elements - The name of the labels element(s) to render. If `None`, all - labels elements in the `SpatialData` object will be used. - color + elements : list[str] | str | None, optional + The name(s) of the label element(s) to render. If `None`, all label + elements in the `SpatialData` object will be used. + color : str | None, optional Key for annotations in :attr:`anndata.AnnData.obs` or variables/genes. - groups - For discrete annotation in ``color``, select which values - to plot (other values are set to NAs). - contour_px + groups : list[str] | str | None, optional + When using `color` and the key represents discrete labels, `groups` + can be used to show only a subset of them. Other values are set to NA. + contour_px : int, default 3 Draw contour of specified width for each segment. If `None`, fills entire segment, see :func:`skimage.morphology.erosion`. - outline + outline : bool, default False Whether to plot boundaries around segmentation masks. - layer + layer : str | None, optional Key in :attr:`anndata.AnnData.layers` or `None` for :attr:`anndata.AnnData.X`. - palette - Palette for discrete annotations, see :class:`matplotlib.colors.Colormap`. - cmap + palette : list[str] | str | None, optional + Palette for discrete annotations. List of valid color names that should be + used for the categories. Must match the number of groups. + cmap : Colormap | str | None, optional Colormap for continuous annotations, see :class:`matplotlib.colors.Colormap`. - norm + norm : Normalize | None, optional Colormap normalization for continuous annotations, see :class:`matplotlib.colors.Normalize`. - na_color + na_color : ColorLike | None, optional Color to be used for NAs values, if present. - alpha - Alpha value for the labels. - scale + outline_alpha : float | int, default 1.0 + Alpha value for the outline of the labels. + fill_alpha : float | int, default 0.3 + Alpha value for the fill of the labels. + scale : list[str] | str | None, optional 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 is selected before the rasterization step. @@ -459,6 +724,66 @@ def render_labels( ------- None """ + if elements is not None: + if not isinstance(elements, (list, str)): + raise TypeError("Parameter 'elements' must be a string or a list of strings.") + + elements = [elements] if isinstance(elements, str) else elements + if any(e not in self._sdata.labels for e in elements): + raise ValueError( + "Not all specificed elements were found, available elements are: '" + + "', '".join(self._sdata.labels.keys()) + + "'" + ) + + if color is not None and not isinstance(color, str): + raise TypeError("Parameter 'color' must be a string.") + + if groups is not None: + if not isinstance(groups, (list, str)): + raise TypeError("Parameter 'groups' must be a string or a list of strings.") + groups = [groups] if isinstance(groups, str) else groups + if not all(isinstance(g, str) for g in groups): + raise TypeError("All items in 'groups' list must be strings.") + + if not isinstance(contour_px, int): + raise TypeError("Parameter 'contour_px' must be an integer.") + + if not isinstance(outline, bool): + raise TypeError("Parameter 'outline' must be a boolean.") + + if layer is not None and not isinstance(layer, str): + raise TypeError("Parameter 'layer' must be a string.") + + if palette is not None: + if not isinstance(palette, (list, str)): + raise TypeError("Parameter 'palette' must be a string or a list of strings.") + palette = [palette] if isinstance(palette, str) else palette + if not all(isinstance(p, str) for p in palette): + raise TypeError("All items in 'palette' list must be strings.") + + if cmap is not None and not isinstance(cmap, (Colormap, str)): + raise TypeError("Parameter 'cmap' must be a Colormap or a string.") + + if norm is not None and not isinstance(norm, Normalize): + raise TypeError("Parameter 'norm' must be of type Normalize.") + + if na_color is not None and not colors.is_color_like(na_color): + raise ValueError("Parameter 'na_color' must be color-like.") + + if not isinstance(outline_alpha, (float, int)): + raise TypeError("Parameter 'outline_alpha' must be numeric.") + + if not isinstance(fill_alpha, (float, int)): + raise TypeError("Parameter 'fill_alpha' must be numeric.") + + if scale is not None: + if not isinstance(scale, (list, str)): + raise TypeError("If specified, parameter 'scale' must be a string or a list of strings.") + scale = [scale] if isinstance(scale, str) else scale + if not all(isinstance(s, str) for s in scale): + raise TypeError("All items in 'scale' list must be strings.") + if ( color is not None and color not in self._sdata.table.obs.columns @@ -496,7 +821,7 @@ def render_labels( def show( self, - coordinate_systems: str | Sequence[str] | None = None, + coordinate_systems: list[str] | str | None = None, legend_fontsize: int | float | _FontSize | None = None, legend_fontweight: int | _FontWeight = "bold", legend_loc: str | None = "right margin", @@ -510,12 +835,12 @@ def show( figsize: tuple[float, float] | None = None, dpi: int | None = None, fig: Figure | None = None, - title: None | str | Sequence[str] = None, + title: list[str] | str | None = None, share_extent: bool = True, - pad_extent: int = 0, - ax: Axes | Sequence[Axes] | None = None, + pad_extent: int | float = 0, + ax: list[Axes] | Axes | None = None, return_ax: bool = False, - save: None | str | Path = None, + save: str | Path | None = None, ) -> sd.SpatialData: """ Plot the images in the SpatialData object. @@ -551,6 +876,84 @@ def show( raise TypeError( "Please specify what to plot using the 'render_*' functions before calling 'show()`." ) from e + + if coordinate_systems is not None and not isinstance(coordinate_systems, (list, str)): + raise TypeError("Parameter 'coordinate_systems' must be a string or a list of strings.") + + font_weights = ["light", "normal", "medium", "semibold", "bold", "heavy", "black"] + if legend_fontweight is not None and ( + not isinstance(legend_fontweight, (int, str)) + or (isinstance(legend_fontweight, str) and legend_fontweight not in font_weights) + ): + readable_font_weights = ", ".join(font_weights[:-1]) + ", or " + font_weights[-1] + raise TypeError( + "Parameter 'legend_fontweight' must be an integer or one of", + f"the following strings: {readable_font_weights}.", + ) + + font_sizes = ["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large"] + + if legend_fontsize is not None and ( + not isinstance(legend_fontsize, (int, float, str)) + or (isinstance(legend_fontsize, str) and legend_fontsize not in font_sizes) + ): + readable_font_sizes = ", ".join(font_sizes[:-1]) + ", or " + font_sizes[-1] + raise TypeError( + "Parameter 'legend_fontsize' must be an integer, a float, or ", + f"one of the following strings: {readable_font_sizes}.", + ) + + if legend_loc is not None and not isinstance(legend_loc, str): + raise TypeError("Parameter 'legend_loc' must be a string.") + + if legend_fontoutline is not None and not isinstance(legend_fontoutline, int): + raise TypeError("Parameter 'legend_fontoutline' must be an integer.") + + if not isinstance(na_in_legend, bool): + raise TypeError("Parameter 'na_in_legend' must be a boolean.") + + if not isinstance(colorbar, bool): + raise TypeError("Parameter 'colorbar' must be a boolean.") + + if wspace is not None and not isinstance(wspace, float): + raise TypeError("Parameter 'wspace' must be a float.") + + if not isinstance(hspace, float): + raise TypeError("Parameter 'hspace' must be a float.") + + if not isinstance(ncols, int): + raise TypeError("Parameter 'ncols' must be an integer.") + + if frameon is not None and not isinstance(frameon, bool): + raise TypeError("Parameter 'frameon' must be a boolean.") + + if figsize is not None and not isinstance(figsize, tuple): + raise TypeError("Parameter 'figsize' must be a tuple of two floats.") + + if dpi is not None and not isinstance(dpi, int): + raise TypeError("Parameter 'dpi' must be an integer.") + + if fig is not None and not isinstance(fig, Figure): + raise TypeError("Parameter 'fig' must be a matplotlib.figure.Figure.") + + if title is not None and not isinstance(title, (list, str)): + raise TypeError("Parameter 'title' must be a string or a list of strings.") + + if not isinstance(share_extent, bool): + raise TypeError("Parameter 'share_extent' must be a boolean.") + + if not isinstance(pad_extent, (int, float)): + raise TypeError("Parameter 'pad_extent' must be numeric.") + + if ax is not None and not isinstance(ax, (Axes, list)): + raise TypeError("Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes.") + + if not isinstance(return_ax, bool): + raise TypeError("Parameter 'return_ax' must be a boolean.") + + if save is not None and not isinstance(save, (str, Path)): + raise TypeError("Parameter 'save' must be a string or a pathlib.Path.") + sdata = self._copy() # Evaluate execution tree for plotting @@ -576,7 +979,7 @@ def show( # verify that rendering commands have been called before render_cmds.append((cmd, params)) - if len(render_cmds) == 0: + if not render_cmds: raise TypeError("Please specify what to plot using the 'render_*' functions before calling 'imshow()'.") if title is not None: @@ -697,7 +1100,7 @@ def show( if cs in set(get_transformation(sdata.images[image], get_all=True).keys()) ] wanted_elements.extend(wanted_images_on_this_cs) - if len(wanted_images_on_this_cs) > 0: + if wanted_images_on_this_cs: rasterize = (params.scale is None) or ( isinstance(params.scale, str) and params.scale != "full" @@ -723,7 +1126,7 @@ def show( if cs in set(get_transformation(sdata.shapes[shape], get_all=True).keys()) ] wanted_elements.extend(wanted_shapes_on_this_cs) - if len(wanted_shapes_on_this_cs) > 0: + if wanted_shapes_on_this_cs: _render_shapes( sdata=sdata, render_params=params, @@ -743,7 +1146,7 @@ def show( if cs in set(get_transformation(sdata.points[point], get_all=True).keys()) ] wanted_elements.extend(wanted_points_on_this_cs) - if len(wanted_points_on_this_cs) > 0: + if wanted_points_on_this_cs: _render_points( sdata=sdata, render_params=params, @@ -772,7 +1175,7 @@ def show( if cs in set(get_transformation(sdata.labels[label], get_all=True).keys()) ] wanted_elements.extend(wanted_labels_on_this_cs) - if len(wanted_labels_on_this_cs) > 0: + if wanted_labels_on_this_cs: rasterize = (params.scale is None) or ( isinstance(params.scale, str) and params.scale != "full" diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 529dbdcc..f3c57af9 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -82,7 +82,7 @@ def _render_shapes( for e in elements: shapes = sdata.shapes[e] - n_shapes = sum([len(s) for s in shapes]) + n_shapes = sum(len(s) for s in shapes) if sdata.table is None: table = AnnData(None, obs=pd.DataFrame(index=pd.Index(np.arange(n_shapes), dtype=str))) @@ -94,11 +94,11 @@ def _render_shapes( sdata=sdata_filt, element=sdata_filt.shapes[e], element_name=e, - value_to_plot=render_params.color, + value_to_plot=render_params.col_for_color, layer=render_params.layer, groups=render_params.groups, palette=render_params.palette, - na_color=render_params.cmap_params.na_color, + na_color=render_params.color or render_params.cmap_params.na_color, alpha=render_params.fill_alpha, cmap_params=render_params.cmap_params, ) @@ -162,14 +162,18 @@ def _render_shapes( len(set(color_vector)) == 1 and list(set(color_vector))[0] == to_hex(render_params.cmap_params.na_color) ): # necessary in case different shapes elements are annotated with one table - if color_source_vector is not None: + if color_source_vector is not None and render_params.col_for_color is not None: color_source_vector = color_source_vector.remove_unused_categories() + + # False if user specified color-like with 'color' parameter + colorbar = False if render_params.col_for_color is None else legend_params.colorbar + _ = _decorate_axs( ax=ax, cax=cax, fig_params=fig_params, adata=table, - value_to_plot=render_params.color, + value_to_plot=render_params.col_for_color, color_source_vector=color_source_vector, palette=palette, alpha=render_params.fill_alpha, @@ -179,7 +183,7 @@ def _render_shapes( legend_loc=legend_params.legend_loc, legend_fontoutline=legend_params.legend_fontoutline, na_in_legend=legend_params.na_in_legend, - colorbar=legend_params.colorbar, + colorbar=colorbar, scalebar_dx=scalebar_params.scalebar_dx, scalebar_units=scalebar_params.scalebar_units, ) @@ -194,12 +198,6 @@ def _render_points( scalebar_params: ScalebarParams, legend_params: LegendParams, ) -> None: - if render_params.groups is not None: - if isinstance(render_params.groups, str): - render_params.groups = [render_params.groups] - if not all(isinstance(g, str) for g in render_params.groups): - raise TypeError("All groups must be strings.") - elements = render_params.elements sdata_filt = sdata.filter_by_coordinate_system( @@ -214,43 +212,56 @@ def _render_points( for e in elements: points = sdata.points[e] + col_for_color = render_params.col_for_color + coords = ["x", "y"] - if render_params.color is not None: - color = [render_params.color] if isinstance(render_params.color, str) else render_params.color - coords.extend(color) + if col_for_color is not None: + if col_for_color not in points.columns: + # no error in case there are multiple elements, but onyl some have color key + msg = f"Color key '{col_for_color}' for element '{e}' not been found, using default colors." + logger.warning(msg) + else: + coords += [col_for_color] points = points[coords].compute() - if render_params.groups is not None: - points = points[points[color].isin(render_params.groups).values] - points[color[0]] = points[color[0]].cat.set_categories(render_params.groups) - points = dask.dataframe.from_pandas(points, npartitions=1) - sdata_filt.points[e] = PointsModel.parse(points, coordinates={"x": "x", "y": "y"}) - - point_df = points[coords].compute() + if render_params.groups is not None and col_for_color is not None: + points = points[points[col_for_color].isin(render_params.groups)] # we construct an anndata to hack the plotting functions adata = AnnData( - X=point_df[["x", "y"]].values, obs=point_df[coords].reset_index(), dtype=point_df[["x", "y"]].values.dtype + X=points[["x", "y"]].values, obs=points[coords].reset_index(), dtype=points[["x", "y"]].values.dtype ) - if render_params.color is not None: - cols = sc.get.obs_df(adata, render_params.color) + + # Convert back to dask dataframe to modify sdata + points = dask.dataframe.from_pandas(points, npartitions=1) + sdata_filt.points[e] = PointsModel.parse(points, coordinates={"x": "x", "y": "y"}) + + if render_params.col_for_color is not None: + cols = sc.get.obs_df(adata, render_params.col_for_color) # maybe set color based on type if is_categorical_dtype(cols): _maybe_set_colors( source=adata, target=adata, - key=render_params.color, + key=render_params.col_for_color, palette=render_params.palette, ) + # when user specified a single color, we overwrite na with it + default_color = ( + render_params.color + if render_params.col_for_color is None and render_params.color is not None + else render_params.cmap_params.na_color + ) + color_source_vector, color_vector, _ = _set_color_source_vec( sdata=sdata_filt, element=points, element_name=e, - value_to_plot=render_params.color, + value_to_plot=render_params.col_for_color, groups=render_params.groups, palette=render_params.palette, - na_color=render_params.cmap_params.na_color, + na_color=default_color, alpha=render_params.alpha, cmap_params=render_params.cmap_params, ) @@ -278,9 +289,7 @@ def _render_points( ) cax = ax.add_collection(_cax) - if not ( - len(set(color_vector)) == 1 and list(set(color_vector))[0] == to_hex(render_params.cmap_params.na_color) - ): + if len(set(color_vector)) != 1 or list(set(color_vector))[0] != to_hex(render_params.cmap_params.na_color): if color_source_vector is None: palette = ListedColormap(dict.fromkeys(color_vector)) else: @@ -291,7 +300,7 @@ def _render_points( cax=cax, fig_params=fig_params, adata=adata, - value_to_plot=render_params.color, + value_to_plot=render_params.col_for_color, color_source_vector=color_source_vector, palette=palette, alpha=render_params.alpha, @@ -629,8 +638,8 @@ def _render_labels( _cax = ax.imshow( labels_infill, rasterized=True, - cmap=render_params.cmap_params.cmap if not categorical else None, - norm=render_params.cmap_params.norm if not categorical else None, + cmap=None if categorical else render_params.cmap_params.cmap, + norm=None if categorical else render_params.cmap_params.norm, alpha=render_params.fill_alpha, origin="lower", ) @@ -652,14 +661,11 @@ def _render_labels( _cax = ax.imshow( labels_contour, rasterized=True, - cmap=render_params.cmap_params.cmap if not categorical else None, - norm=render_params.cmap_params.norm if not categorical else None, + cmap=None if categorical else render_params.cmap_params.cmap, + norm=None if categorical else render_params.cmap_params.norm, alpha=render_params.outline_alpha, origin="lower", ) - _cax.set_transform(trans_data) - cax = ax.add_image(_cax) - else: # Default: no alpha, contour = infill label = _map_color_seg( @@ -676,13 +682,13 @@ def _render_labels( _cax = ax.imshow( label, rasterized=True, - cmap=render_params.cmap_params.cmap if not categorical else None, - norm=render_params.cmap_params.norm if not categorical else None, + cmap=None if categorical else render_params.cmap_params.cmap, + norm=None if categorical else render_params.cmap_params.norm, alpha=render_params.fill_alpha, origin="lower", ) - _cax.set_transform(trans_data) - cax = ax.add_image(_cax) + _cax.set_transform(trans_data) + cax = ax.add_image(_cax) _ = _decorate_axs( ax=ax, diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index e82dfc22..8eca04bf 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -72,6 +72,7 @@ class ShapesRenderParams: outline_params: OutlineParams elements: str | Sequence[str] | None = None color: str | None = None + col_for_color: str | None = None groups: str | Sequence[str] | None = None contour_px: int | None = None layer: str | None = None @@ -89,6 +90,7 @@ class PointsRenderParams: cmap_params: CmapParams elements: str | Sequence[str] | None = None color: str | None = None + col_for_color: str | None = None groups: str | Sequence[str] | None = None palette: ListedColormap | str | None = None alpha: float = 1.0 diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 4d3c330e..37bb66f7 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -52,10 +52,10 @@ from spatial_image import SpatialImage from spatialdata._core.operations.rasterize import rasterize from spatialdata._core.query.relational_query import _locate_value, get_values -from spatialdata._logging import logger as logging from spatialdata._types import ArrayLike from spatialdata.models import Image2DModel, Labels2DModel, SpatialElement +from spatialdata_plot._logging import logger from spatialdata_plot.pl.render_params import ( CmapParams, FigParams, @@ -379,7 +379,7 @@ def _set_outline( if outline_width == 0.0: outline = False if outline_width < 0.0: - logging.warning(f"Negative line widths are not allowed, changing {outline_width} to {(-1)*outline_width}") + logger.warning(f"Negative line widths are not allowed, changing {outline_width} to {(-1)*outline_width}") outline_width *= -1 # the default black and white colors can be changed using the contour_config parameter @@ -561,7 +561,7 @@ def _get_colors_for_categorical_obs( palette = default_102 else: palette = ["grey" for _ in range(len_cat)] - logging.info("input has more than 103 categories. Uniform " "'grey' color will be used for all categories.") + logger.info("input has more than 103 categories. Uniform " "'grey' color will be used for all categories.") else: # raise error when user didn't provide the right number of colors in palette if isinstance(palette, list) and len(palette) != len(categories): @@ -623,7 +623,7 @@ def _set_color_source_vec( # numerical case, return early if not is_categorical_dtype(color_source_vector): if palette is not None: - logging.warning( + logger.warning( "Ignoring categorical palette which is given for a continuous variable. " "Consider using `cmap` to pass a ColorMap." ) @@ -651,7 +651,7 @@ def _set_color_source_vec( return color_source_vector, color_vector, True - logging.warning(f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors.") + logger.warning(f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors.") color = np.full(sdata.table.n_obs, to_hex(na_color)) return color, color, False @@ -723,7 +723,7 @@ def _get_palette( ) return {cat: to_hex(to_rgba(col)[:3]) for cat, col in zip(categories, palette)} except KeyError as e: - logging.warning(e) + logger.warning(e) return None len_cat = len(categories) @@ -737,7 +737,7 @@ def _get_palette( palette = default_102 else: palette = ["grey" for _ in range(len_cat)] - logging.info("input has more than 103 categories. Uniform " "'grey' color will be used for all categories.") + logger.info("input has more than 103 categories. Uniform " "'grey' color will be used for all categories.") return {cat: to_hex(to_rgba(col)[:3]) for cat, col in zip(categories, palette[:len_cat])} if isinstance(palette, str): @@ -904,9 +904,9 @@ def save_fig(fig: Figure, path: str | Path, make_dir: bool = True, ext: str = "p try: path.parent.mkdir(parents=True, exist_ok=True) except OSError as e: - logging.debug(f"Unable to create directory `{path.parent}`. Reason: `{e}`") + logger.debug(f"Unable to create directory `{path.parent}`. Reason: `{e}`") - logging.debug(f"Saving figure to `{path!r}`") + logger.debug(f"Saving figure to `{path!r}`") kwargs.setdefault("bbox_inches", "tight") kwargs.setdefault("transparent", True) @@ -1070,13 +1070,13 @@ def _mpl_ax_contains_elements(ax: Axes) -> bool: def _get_valid_cs( sdata: sd.SpatialData, - coordinate_systems: Sequence[str], + coordinate_systems: list[str], render_images: bool, render_labels: bool, render_points: bool, render_shapes: bool, elements: list[str], -) -> Sequence[str]: +) -> list[str]: """Get names of the valid coordinate systems. Valid cs are cs that contain elements to be rendered: @@ -1090,8 +1090,10 @@ def _get_valid_cs( cs_mapping = _get_coordinate_system_mapping(sdata) valid_cs = [] for cs in coordinate_systems: - if (len(elements) > 0 and any(e in elements for e in cs_mapping[cs])) or ( - len(elements) == 0 + if ( + elements + and any(e in elements for e in cs_mapping[cs]) + or not elements and ( (len(sdata.images.keys()) > 0 and render_images) or (len(sdata.labels.keys()) > 0 and render_labels) @@ -1101,7 +1103,7 @@ def _get_valid_cs( ): # not nice, but ruff wants it (SIM114) valid_cs.append(cs) else: - logging.info(f"Dropping coordinate system '{cs}' since it doesn't have relevant elements.") + logger.info(f"Dropping coordinate system '{cs}' since it doesn't have relevant elements.") return valid_cs diff --git a/tests/_images/Points_color_recognises_actual_color_as_color.png b/tests/_images/Points_color_recognises_actual_color_as_color.png new file mode 100644 index 0000000000000000000000000000000000000000..060423e050dbe6b7ea44a89c9cf9d4ffb284beab GIT binary patch literal 6749 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 literal 0 HcmV?d00001 diff --git a/tests/pl/test_render_points.py b/tests/pl/test_render_points.py index a1a55534..49c705c2 100644 --- a/tests/pl/test_render_points.py +++ b/tests/pl/test_render_points.py @@ -37,3 +37,6 @@ def test_plot_can_stack_render_points(self, sdata_blobs: SpatialData): .pl.render_points(elements="blobs_points", na_color="blue", size=10) .pl.show() ) + + def test_plot_color_recognises_actual_color_as_color(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_points(elements="blobs_points", color="red").pl.show() diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index c70e7ebf..52a44a83 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -261,3 +261,6 @@ def test_plot_can_stack_render_shapes(self, sdata_blobs: SpatialData): .pl.render_shapes(elements="blobs_polygons", na_color="blue", fill_alpha=0.5) .pl.show() ) + + def test_plot_color_recognises_actual_color_as_color(self, sdata_blobs: SpatialData): + (sdata_blobs.pl.render_shapes(elements="blobs_circles", color="red").pl.show())