From 51c2cc261b14c3515eaf72468ae811bf5661d2ce Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 29 Aug 2023 16:51:02 +0200 Subject: [PATCH 01/11] merge --- src/spatialdata_plot/pl/basic.py | 39 +- src/spatialdata_plot/pl/render.py | 458 ++++++++--------------- src/spatialdata_plot/pl/render_params.py | 122 ++++++ src/spatialdata_plot/pl/utils.py | 252 ++++++++----- 4 files changed, 458 insertions(+), 413 deletions(-) create mode 100644 src/spatialdata_plot/pl/render_params.py diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 20c49d38..b3888d5a 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -23,20 +23,22 @@ from spatialdata_plot._accessor import register_spatial_data_accessor from spatialdata_plot.pl.render import ( - ImageRenderParams, - LabelsRenderParams, - PointsRenderParams, - ShapesRenderParams, _render_images, _render_labels, _render_points, _render_shapes, ) -from spatialdata_plot.pl.utils import ( +from spatialdata_plot.pl.render_params import ( CmapParams, + ImageRenderParams, + LabelsRenderParams, LegendParams, + PointsRenderParams, + ShapesRenderParams, _FontSize, _FontWeight, +) +from spatialdata_plot.pl.utils import ( _get_cs_contents, _get_extent, _maybe_set_colors, @@ -147,7 +149,6 @@ def render_shapes( outline: bool = False, outline_width: float = 1.5, outline_color: str | list[float] = "#000000ff", - alt_var: str | None = None, layer: str | None = None, palette: ListedColormap | str | None = None, cmap: Colormap | str | None = None, @@ -178,8 +179,6 @@ def render_shapes( Width of the border. outline_color Color of the border. - alt_var - Which column to use in :attr:`anndata.AnnData.var` to select alternative ``var_name``. layer Key in :attr:`anndata.AnnData.layers` or `None` for :attr:`anndata.AnnData.X`. palette @@ -219,7 +218,6 @@ def render_shapes( color=color, groups=groups, outline_params=outline_params, - alt_var=alt_var, layer=layer, cmap_params=cmap_params, palette=palette, @@ -381,7 +379,6 @@ def render_labels( groups: str | Sequence[str] | None = None, contour_px: int = 3, outline: bool = False, - alt_var: str | None = None, layer: str | None = None, palette: ListedColormap | str | None = None, cmap: Colormap | str | None = None, @@ -409,8 +406,6 @@ def render_labels( entire segment, see :func:`skimage.morphology.erosion`. outline Whether to plot boundaries around segmentation masks. - alt_var - Which column to use in :attr:`anndata.AnnData.var` to select alternative ``var_name``. layer Key in :attr:`anndata.AnnData.layers` or `None` for :attr:`anndata.AnnData.X`. palette @@ -452,7 +447,6 @@ def render_labels( groups=groups, contour_px=contour_px, outline=outline, - alt_var=alt_var, layer=layer, cmap_params=cmap_params, palette=palette, @@ -667,15 +661,15 @@ def show( # extent=extent[cs], ) elif cmd == "render_shapes" and cs_contents.query(f"cs == '{cs}'")["has_shapes"][0]: - if sdata.table is not None and isinstance(params.color, str): - colors = sc.get.obs_df(sdata.table, params.color) - if is_categorical_dtype(colors): - _maybe_set_colors( - source=sdata.table, - target=sdata.table, - key=params.color, - palette=params.palette, - ) + # if sdata.table is not None and isinstance(params.color, str): + # colors = sc.get.obs_df(sdata.table, params.color) + # if is_categorical_dtype(colors): + # _maybe_set_colors( + # source=sdata.table, + # target=sdata.table, + # key=params.color, + # palette=params.palette, + # ) _render_shapes( sdata=sdata, render_params=params, @@ -728,6 +722,7 @@ def show( else: t = cs ax.set_title(t) + ax.set_aspect("equal") if any( [ diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index a08016ff..f5ceac11 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2,9 +2,7 @@ from collections.abc import Sequence from copy import copy -from dataclasses import dataclass -from functools import partial -from typing import Any, Callable, Union +from typing import Union import geopandas as gpd import matplotlib @@ -14,11 +12,7 @@ import spatial_image import spatialdata as sd from anndata import AnnData -from geopandas import GeoDataFrame -from matplotlib import colors -from matplotlib.collections import PatchCollection -from matplotlib.colors import ColorConverter, ListedColormap, Normalize -from matplotlib.patches import Circle, Polygon +from matplotlib.colors import ListedColormap, Normalize from pandas.api.types import is_categorical_dtype from scanpy._settings import settings as sc_settings from spatialdata.models import ( @@ -27,44 +21,29 @@ ) from spatialdata_plot._logging import logger -from spatialdata_plot.pl.utils import ( - CmapParams, +from spatialdata_plot.pl.render_params import ( FigParams, + ImageRenderParams, + LabelsRenderParams, LegendParams, - OutlineParams, + PointsRenderParams, ScalebarParams, + ShapesRenderParams, +) +from spatialdata_plot.pl.utils import ( _decorate_axs, + _get_collection_shape, _get_colors_for_categorical_obs, _get_linear_colormap, - _make_patch_from_multipolygon, _map_color_seg, _maybe_set_colors, _normalize, _set_color_source_vec, + to_hex, ) from spatialdata_plot.pp.utils import _get_instance_key, _get_region_key _Normalize = Union[Normalize, Sequence[Normalize]] -to_hex = partial(colors.to_hex, keep_alpha=True) - - -@dataclass -class ShapesRenderParams: - """Labels render parameters..""" - - cmap_params: CmapParams - outline_params: OutlineParams - elements: str | Sequence[str] | None = None - color: str | None = None - groups: str | Sequence[str] | None = None - contour_px: int | None = None - alt_var: str | None = None - layer: str | None = None - palette: ListedColormap | str | None = None - outline_alpha: float = 1.0 - fill_alpha: float = 0.3 - size: float = 1.0 - transfunc: Callable[[float], float] | None = None def _render_shapes( @@ -88,190 +67,83 @@ def _render_shapes( if elements is None: elements = list(sdata_filt.shapes.keys()) - shapes = [sdata.shapes[e] for e in elements] - 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))) - else: - table = sdata.table[sdata.table.obs[_get_region_key(sdata)].isin(elements)] - - # get color vector (categorical or continuous) - color_source_vector, color_vector, _ = _set_color_source_vec( - adata=table, - value_to_plot=render_params.color, - alt_var=render_params.alt_var, - layer=render_params.layer, - groups=render_params.groups, - palette=render_params.palette, - na_color=render_params.cmap_params.na_color, - alpha=render_params.fill_alpha, - ) - - # color_source_vector is None when the values aren't categorical - if color_source_vector is None and render_params.transfunc is not None: - color_vector = render_params.transfunc(color_vector) - - def _get_collection_shape( - shapes: list[GeoDataFrame], - c: Any, - s: float, - norm: Any, - fill_alpha: None | float = None, - outline_alpha: None | float = None, - **kwargs: Any, - ) -> PatchCollection: - """ - Get a PatchCollection for rendering given geometries with specified colors and outlines. - - Args: - - shapes (list[GeoDataFrame]): List of geometrical shapes. - - c: Color parameter. - - s (float): Size of the shape. - - norm: Normalization for the color map. - - fill_alpha (float, optional): Opacity for the fill color. - - outline_alpha (float, optional): Opacity for the outline. - - **kwargs: Additional keyword arguments. - - Returns - ------- - - PatchCollection: Collection of patches for rendering. - """ - cmap = kwargs["cmap"] - - try: - # fails when numeric - fill_c = ColorConverter().to_rgba_array(c) - except ValueError: - if norm is None: - c = cmap(c) - else: - norm = colors.Normalize(vmin=min(c), vmax=max(c)) - c = cmap(norm(c)) - - fill_c = ColorConverter().to_rgba_array(c) - fill_c[..., -1] = render_params.fill_alpha + for e in elements: + # shapes = [sdata.shapes[e] for e in elements] + shapes = sdata.shapes[e] + n_shapes = sum([len(s) for s in shapes]) - 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 = outline_c.tolist() + if sdata.table is None: + table = AnnData(None, obs=pd.DataFrame(index=pd.Index(np.arange(n_shapes), dtype=str))) else: - outline_c = [None] - outline_c = outline_c * fill_c.shape[0] - - shapes_df = pd.DataFrame(shapes, copy=True) - - # remove empty points/polygons - shapes_df = shapes_df[shapes_df["geometry"].apply(lambda geom: not geom.is_empty)] - - rows = [] + table = sdata.table[sdata.table.obs[_get_region_key(sdata)].isin([e])] - def assign_fill_and_outline_to_row( - shapes: list[GeoDataFrame], fill_c: list[Any], outline_c: list[Any], row: pd.Series, idx: int - ) -> None: - if len(shapes) > 1 and len(fill_c) == 1: - row["fill_c"] = fill_c - row["outline_c"] = outline_c - else: - row["fill_c"] = fill_c[idx] - row["outline_c"] = outline_c[idx] - - # Match colors to the geometry, potentially expanding the row in case of - # multipolygons - for idx, row in shapes_df.iterrows(): - geom = row["geometry"] - if geom.geom_type == "Polygon": - row = row.to_dict() - row["geometry"] = Polygon(geom.exterior.coords, closed=True) - assign_fill_and_outline_to_row(shapes, fill_c, outline_c, row, idx) - rows.append(row) - - elif geom.geom_type == "MultiPolygon": - mp = _make_patch_from_multipolygon(geom) - for _, m in enumerate(mp): - mp_copy = row.to_dict() - mp_copy["geometry"] = m - assign_fill_and_outline_to_row(shapes, fill_c, outline_c, mp_copy, idx) - rows.append(mp_copy) - - elif geom.geom_type == "Point": - row = row.to_dict() - row["geometry"] = Circle((geom.x, geom.y), radius=row["radius"]) - assign_fill_and_outline_to_row(shapes, fill_c, outline_c, row, idx) - rows.append(row) - - patches = pd.DataFrame(rows) - - return PatchCollection( - patches["geometry"].values.tolist(), - snap=False, - lw=render_params.outline_params.linewidth, - facecolor=patches["fill_c"], - edgecolor=None if all(outline is None for outline in outline_c) else outline_c, - **kwargs, + # get color vector (categorical or continuous) + color_source_vector, color_vector, _ = _set_color_source_vec( + sdata=sdata_filt, + element=sdata_filt.shapes[e], + element_name=e, + value_to_plot=render_params.color, + layer=render_params.layer, + groups=render_params.groups, + palette=render_params.palette, + na_color=render_params.cmap_params.na_color, + alpha=render_params.fill_alpha, ) - norm = copy(render_params.cmap_params.norm) - - if len(color_vector) == 0: - color_vector = [render_params.cmap_params.na_color] - - shapes = pd.concat(shapes, ignore_index=True) - shapes = gpd.GeoDataFrame(shapes, geometry="geometry") - _cax = _get_collection_shape( - shapes=shapes, - s=render_params.size, - c=color_vector, - rasterized=sc_settings._vector_friendly, - cmap=render_params.cmap_params.cmap, - norm=norm, - fill_alpha=render_params.fill_alpha, - outline_alpha=render_params.outline_alpha - # **kwargs, - ) - - cax = ax.add_collection(_cax) - - # Using dict.fromkeys here since set returns in arbitrary order - palette = ListedColormap(dict.fromkeys(color_vector)) if render_params.palette is None else render_params.palette - - _ = _decorate_axs( - ax=ax, - cax=cax, - fig_params=fig_params, - adata=table, - value_to_plot=render_params.color, - color_source_vector=color_source_vector, - palette=palette, - alpha=render_params.fill_alpha, - na_color=render_params.cmap_params.na_color, - legend_fontsize=legend_params.legend_fontsize, - legend_fontweight=legend_params.legend_fontweight, - legend_loc=legend_params.legend_loc, - legend_fontoutline=legend_params.legend_fontoutline, - na_in_legend=legend_params.na_in_legend, - colorbar=legend_params.colorbar, - scalebar_dx=scalebar_params.scalebar_dx, - scalebar_units=scalebar_params.scalebar_units, - # scalebar_kwargs=scalebar_params.scalebar_kwargs, - ) - ax.set_aspect("equal") - ax.invert_yaxis() + # color_source_vector is None when the values aren't categorical + if color_source_vector is None and render_params.transfunc is not None: + color_vector = render_params.transfunc(color_vector) + + norm = copy(render_params.cmap_params.norm) + + if len(color_vector) == 0: + color_vector = [render_params.cmap_params.na_color] + + shapes = gpd.GeoDataFrame(shapes, geometry="geometry") + _cax = _get_collection_shape( + shapes=shapes, + s=render_params.size, + c=color_vector, + render_params=render_params, + rasterized=sc_settings._vector_friendly, + cmap=render_params.cmap_params.cmap, + norm=norm, + fill_alpha=render_params.fill_alpha, + outline_alpha=render_params.outline_alpha + # **kwargs, + ) + cax = ax.add_collection(_cax) -@dataclass -class PointsRenderParams: - """Points render parameters..""" + # Using dict.fromkeys here since set returns in arbitrary order + palette = ( + ListedColormap(dict.fromkeys(color_vector)) if render_params.palette is None else render_params.palette + ) - cmap_params: CmapParams - elements: str | Sequence[str] | None = None - color: str | None = None - groups: str | Sequence[str] | None = None - palette: ListedColormap | str | None = None - alpha: float = 1.0 - size: float = 1.0 - transfunc: Callable[[float], float] | None = None + # print(len(set(color_vector)) == 1) + # print(set(color_source_vector[0]) == to_hex(render_params.cmap_params.na_color)) + if not ( + len(set(color_vector)) == 1 and list(set(color_vector))[0] == to_hex(render_params.cmap_params.na_color) + ): + _ = _decorate_axs( + ax=ax, + cax=cax, + fig_params=fig_params, + adata=table, + value_to_plot=render_params.color, + color_source_vector=color_source_vector, + palette=palette, + alpha=render_params.fill_alpha, + na_color=render_params.cmap_params.na_color, + legend_fontsize=legend_params.legend_fontsize, + legend_fontweight=legend_params.legend_fontweight, + legend_loc=legend_params.legend_loc, + legend_fontoutline=legend_params.legend_fontoutline, + na_in_legend=legend_params.na_in_legend, + colorbar=legend_params.colorbar, + scalebar_dx=scalebar_params.scalebar_dx, + scalebar_units=scalebar_params.scalebar_units, + ) def _render_points( @@ -295,89 +167,80 @@ def _render_points( if elements is None: elements = list(sdata_filt.points.keys()) - points = [sdata.points[e] for e in elements] + for e in elements: + points = sdata.points[e] + 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) - 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) + point_df = points[coords].compute() - point_df = pd.concat([point[coords].compute() for point in points], axis=0) + # 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 + ) + if render_params.color is not None: + cols = sc.get.obs_df(adata, render_params.color) + # maybe set color based on type + if is_categorical_dtype(cols): + _maybe_set_colors( + source=adata, + target=adata, + key=render_params.color, + palette=render_params.palette, + ) + # print(p) + color_source_vector, color_vector, _ = _set_color_source_vec( + sdata=sdata_filt, + element=points, + element_name=e, + value_to_plot=render_params.color, + groups=render_params.groups, + palette=render_params.palette, + na_color=render_params.cmap_params.na_color, + alpha=render_params.alpha, + ) - # 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 - ) - if render_params.color is not None: - cols = sc.get.obs_df(adata, render_params.color) - # maybe set color based on type - if is_categorical_dtype(cols): - _maybe_set_colors( - source=adata, - target=adata, - key=render_params.color, + # color_source_vector is None when the values aren't categorical + if color_source_vector is None and render_params.transfunc is not None: + color_vector = render_params.transfunc(color_vector) + + norm = copy(render_params.cmap_params.norm) + _cax = ax.scatter( + adata[:, 0].X.flatten(), + adata[:, 1].X.flatten(), + s=render_params.size, + c=color_vector, + rasterized=sc_settings._vector_friendly, + cmap=render_params.cmap_params.cmap, + norm=norm, + alpha=render_params.alpha, + # **kwargs, + ) + 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) + ): + _ = _decorate_axs( + ax=ax, + cax=cax, + fig_params=fig_params, + adata=adata, + value_to_plot=render_params.color, + color_source_vector=color_source_vector, palette=render_params.palette, + alpha=render_params.alpha, + na_color=render_params.cmap_params.na_color, + legend_fontsize=legend_params.legend_fontsize, + legend_fontweight=legend_params.legend_fontweight, + legend_loc=legend_params.legend_loc, + legend_fontoutline=legend_params.legend_fontoutline, + na_in_legend=legend_params.na_in_legend, + colorbar=legend_params.colorbar, + scalebar_dx=scalebar_params.scalebar_dx, + scalebar_units=scalebar_params.scalebar_units, ) - color_source_vector, color_vector, _ = _set_color_source_vec( - adata=adata, - value_to_plot=render_params.color, - groups=render_params.groups, - palette=render_params.palette, - na_color=render_params.cmap_params.na_color, - alpha=render_params.alpha, - ) - - # color_source_vector is None when the values aren't categorical - if color_source_vector is None and render_params.transfunc is not None: - color_vector = render_params.transfunc(color_vector) - - norm = copy(render_params.cmap_params.norm) - _cax = ax.scatter( - adata[:, 0].X.flatten(), - adata[:, 1].X.flatten(), - s=render_params.size, - c=color_vector, - rasterized=sc_settings._vector_friendly, - cmap=render_params.cmap_params.cmap, - norm=norm, - alpha=render_params.alpha, - # **kwargs, - ) - cax = ax.add_collection(_cax) - _ = _decorate_axs( - ax=ax, - cax=cax, - fig_params=fig_params, - adata=adata, - value_to_plot=render_params.color, - color_source_vector=color_source_vector, - palette=render_params.palette, - alpha=render_params.alpha, - na_color=render_params.cmap_params.na_color, - legend_fontsize=legend_params.legend_fontsize, - legend_fontweight=legend_params.legend_fontweight, - legend_loc=legend_params.legend_loc, - legend_fontoutline=legend_params.legend_fontoutline, - na_in_legend=legend_params.na_in_legend, - colorbar=legend_params.colorbar, - scalebar_dx=scalebar_params.scalebar_dx, - scalebar_units=scalebar_params.scalebar_units, - # scalebar_kwargs=scalebar_params.scalebar_kwargs, - ) - ax.set_aspect("equal") - ax.invert_yaxis() - - -@dataclass -class ImageRenderParams: - """Labels render parameters..""" - - cmap_params: list[CmapParams] | CmapParams - elements: str | Sequence[str] | None = None - channel: list[str] | list[int] | int | str | None = None - palette: ListedColormap | str | None = None - alpha: float = 1.0 - quantiles_for_norm: tuple[float | None, float | None] = (3.0, 99.8) # defaults from CSBDeep def _render_images( @@ -537,24 +400,6 @@ def _render_images( raise ValueError("If 'palette' is provided, 'cmap' must be None.") -@dataclass -class LabelsRenderParams: - """Labels render parameters..""" - - cmap_params: CmapParams - elements: str | Sequence[str] | None = None - color: str | None = None - groups: str | Sequence[str] | None = None - contour_px: int | None = None - outline: bool = False - alt_var: str | None = None - layer: str | None = None - palette: ListedColormap | str | None = None - outline_alpha: float = 1.0 - fill_alpha: float = 0.4 - transfunc: Callable[[float], float] | None = None - - def _render_labels( sdata: sd.SpatialData, render_params: LabelsRenderParams, @@ -597,9 +442,10 @@ def _render_labels( # get color vector (categorical or continuous) color_source_vector, color_vector, categorical = _set_color_source_vec( - adata=table, + sdata=sdata_filt, + element=sdata_filt.labels[label_key], + element_name=label_key, value_to_plot=render_params.color, - alt_var=render_params.alt_var, layer=render_params.layer, groups=render_params.groups, palette=render_params.palette, diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py new file mode 100644 index 00000000..3951dea0 --- /dev/null +++ b/src/spatialdata_plot/pl/render_params.py @@ -0,0 +1,122 @@ +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Literal + +from matplotlib.axes import Axes +from matplotlib.colors import Colormap, ListedColormap, Normalize +from matplotlib.figure import Figure + +_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"] +_FontSize = Literal["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large"] + + +@dataclass +class CmapParams: + """Cmap params.""" + + cmap: Colormap + norm: Normalize + na_color: str | tuple[float, ...] = (0.0, 0.0, 0.0, 0.0) + + +@dataclass +class FigParams: + """Figure params.""" + + fig: Figure + ax: Axes + num_panels: int + axs: Sequence[Axes] | None = None + title: str | Sequence[str] | None = None + ax_labels: Sequence[str] | None = None + frameon: bool | None = None + + +@dataclass +class OutlineParams: + """Cmap params.""" + + outline: bool + outline_color: str | list[float] + linewidth: float + + +@dataclass +class LegendParams: + """Legend params.""" + + legend_fontsize: int | float | _FontSize | None = None + legend_fontweight: int | _FontWeight = "bold" + legend_loc: str | None = "right margin" + legend_fontoutline: int | None = None + na_in_legend: bool = True + colorbar: bool = True + + +@dataclass +class ScalebarParams: + """Scalebar params.""" + + scalebar_dx: Sequence[float] | None = None + scalebar_units: Sequence[str] | None = None + + +@dataclass +class ShapesRenderParams: + """Labels render parameters..""" + + cmap_params: CmapParams + outline_params: OutlineParams + elements: str | Sequence[str] | None = None + color: str | None = None + groups: str | Sequence[str] | None = None + contour_px: int | None = None + layer: str | None = None + palette: ListedColormap | str | None = None + outline_alpha: float = 1.0 + fill_alpha: float = 0.3 + size: float = 1.0 + transfunc: Callable[[float], float] | None = None + + +@dataclass +class PointsRenderParams: + """Points render parameters..""" + + cmap_params: CmapParams + elements: str | Sequence[str] | None = None + color: str | None = None + groups: str | Sequence[str] | None = None + palette: ListedColormap | str | None = None + alpha: float = 1.0 + size: float = 1.0 + transfunc: Callable[[float], float] | None = None + + +@dataclass +class ImageRenderParams: + """Labels render parameters..""" + + cmap_params: list[CmapParams] | CmapParams + elements: str | Sequence[str] | None = None + channel: list[str] | list[int] | int | str | None = None + palette: ListedColormap | str | None = None + alpha: float = 1.0 + quantiles_for_norm: tuple[float | None, float | None] = (3.0, 99.8) # defaults from CSBDeep + + +@dataclass +class LabelsRenderParams: + """Labels render parameters..""" + + cmap_params: CmapParams + elements: str | Sequence[str] | None = None + color: str | None = None + groups: str | Sequence[str] | None = None + contour_px: int | None = None + outline: bool = False + layer: str | None = None + palette: ListedColormap | str | None = None + outline_alpha: float = 1.0 + fill_alpha: float = 0.4 + transfunc: Callable[[float], float] | None = None diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 8678d5c0..9c499107 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -3,7 +3,6 @@ import os from collections.abc import Iterable, Mapping, Sequence from copy import copy -from dataclasses import dataclass from functools import partial from pathlib import Path from types import MappingProxyType @@ -11,6 +10,7 @@ import matplotlib import matplotlib.patches as mpatches +import matplotlib.patches as mplp import matplotlib.path as mpath import matplotlib.pyplot as plt import multiscale_spatial_image as msi @@ -22,10 +22,19 @@ import xarray as xr from anndata import AnnData from cycler import Cycler, cycler +from geopandas import GeoDataFrame from matplotlib import colors, patheffects, rcParams from matplotlib.axes import Axes from matplotlib.collections import PatchCollection -from matplotlib.colors import Colormap, LinearSegmentedColormap, ListedColormap, Normalize, TwoSlopeNorm, to_rgba +from matplotlib.colors import ( + ColorConverter, + Colormap, + LinearSegmentedColormap, + ListedColormap, + Normalize, + TwoSlopeNorm, + to_rgba, +) from matplotlib.figure import Figure from matplotlib.gridspec import GridSpec from matplotlib_scalebar.scalebar import ScaleBar @@ -40,40 +49,26 @@ from skimage.segmentation import find_boundaries from skimage.util import map_array from spatialdata import transform +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 +from spatialdata.models import Image2DModel, Labels2DModel, SpatialElement from spatialdata.transformations import get_transformation +from spatialdata_plot.pl.render_params import ( + CmapParams, + FigParams, + OutlineParams, + ScalebarParams, + ShapesRenderParams, + _FontSize, + _FontWeight, +) from spatialdata_plot.pp.utils import _get_coordinate_system_mapping -_FontWeight = Literal["light", "normal", "medium", "semibold", "bold", "heavy", "black"] -_FontSize = Literal["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large"] - to_hex = partial(colors.to_hex, keep_alpha=True) -@dataclass -class FigParams: - """Figure params.""" - - fig: Figure - ax: Axes - num_panels: int - axs: Sequence[Axes] | None = None - title: str | Sequence[str] | None = None - ax_labels: Sequence[str] | None = None - frameon: bool | None = None - - -@dataclass -class ScalebarParams: - """Scalebar params.""" - - scalebar_dx: Sequence[float] | None = None - scalebar_units: Sequence[str] | None = None - - def _prepare_params_plot( # this param is inferred when `pl.show`` is called num_panels: int, @@ -174,6 +169,108 @@ def _get_cs_contents(sdata: sd.SpatialData) -> pd.DataFrame: return cs_contents +def _get_collection_shape( + shapes: list[GeoDataFrame], + c: Any, + s: float, + norm: Any, + render_params: ShapesRenderParams, + fill_alpha: None | float = None, + outline_alpha: None | float = None, + **kwargs: Any, +) -> PatchCollection: + """ + Get a PatchCollection for rendering given geometries with specified colors and outlines. + + Args: + - shapes (list[GeoDataFrame]): List of geometrical shapes. + - c: Color parameter. + - s (float): Size of the shape. + - norm: Normalization for the color map. + - fill_alpha (float, optional): Opacity for the fill color. + - outline_alpha (float, optional): Opacity for the outline. + - **kwargs: Additional keyword arguments. + + Returns + ------- + - PatchCollection: Collection of patches for rendering. + """ + cmap = kwargs["cmap"] + + try: + # fails when numeric + fill_c = ColorConverter().to_rgba_array(c) + except ValueError: + if norm is None: + c = cmap(c) + else: + norm = colors.Normalize(vmin=min(c), vmax=max(c)) + c = cmap(norm(c)) + + fill_c = ColorConverter().to_rgba_array(c) + fill_c[..., -1] = render_params.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 = outline_c.tolist() + else: + outline_c = [None] + outline_c = outline_c * fill_c.shape[0] + + shapes_df = pd.DataFrame(shapes, copy=True) + + # remove empty points/polygons + shapes_df = shapes_df[shapes_df["geometry"].apply(lambda geom: not geom.is_empty)] + + rows = [] + + def assign_fill_and_outline_to_row( + shapes: list[GeoDataFrame], fill_c: list[Any], outline_c: list[Any], row: pd.Series, idx: int + ) -> None: + if len(shapes) > 1 and len(fill_c) == 1: + row["fill_c"] = fill_c + row["outline_c"] = outline_c + else: + row["fill_c"] = fill_c[idx] + row["outline_c"] = outline_c[idx] + + # Match colors to the geometry, potentially expanding the row in case of + # multipolygons + for idx, row in shapes_df.iterrows(): + geom = row["geometry"] + if geom.geom_type == "Polygon": + row = row.to_dict() + row["geometry"] = mplp.Polygon(geom.exterior.coords, closed=True) + assign_fill_and_outline_to_row(shapes, fill_c, outline_c, row, idx) + rows.append(row) + + elif geom.geom_type == "MultiPolygon": + mp = _make_patch_from_multipolygon(geom) + for _, m in enumerate(mp): + mp_copy = row.to_dict() + mp_copy["geometry"] = m + assign_fill_and_outline_to_row(shapes, fill_c, outline_c, mp_copy, idx) + rows.append(mp_copy) + + elif geom.geom_type == "Point": + row = row.to_dict() + row["geometry"] = mplp.Circle((geom.x, geom.y), radius=row["radius"]) + assign_fill_and_outline_to_row(shapes, fill_c, outline_c, row, idx) + rows.append(row) + + patches = pd.DataFrame(rows) + + return PatchCollection( + patches["geometry"].values.tolist(), + snap=False, + lw=render_params.outline_params.linewidth, + facecolor=patches["fill_c"], + edgecolor=None if all(outline is None for outline in outline_c) else outline_c, + **kwargs, + ) + + def _get_extent( sdata: sd.SpatialData, coordinate_systems: Sequence[str] | str | None = None, @@ -456,15 +553,6 @@ def _get_scalebar( return _scalebar_dx, _scalebar_units -@dataclass -class CmapParams: - """Cmap params.""" - - cmap: Colormap - norm: Normalize - na_color: str | tuple[float, ...] = (0.0, 0.0, 0.0, 0.0) - - def _prepare_cmap_norm( cmap: Colormap | str | None = None, norm: Normalize | Sequence[Normalize] | None = None, @@ -487,15 +575,6 @@ def _prepare_cmap_norm( return CmapParams(cmap, norm, na_color) -@dataclass -class OutlineParams: - """Cmap params.""" - - outline: bool - outline_color: str | list[float] - linewidth: float - - def _set_outline( size: float, outline: bool = False, @@ -714,10 +793,10 @@ def _get_colors_for_categorical_obs( def _set_color_source_vec( - adata: AnnData, + sdata: sd.SpatialData, + element: SpatialElement | None, value_to_plot: str | None, - use_raw: bool | None = None, - alt_var: str | None = None, + element_name: list[str] | str | None = None, layer: str | None = None, groups: Sequence[str] | str | None = None, palette: ListedColormap | str | None = None, @@ -725,39 +804,54 @@ def _set_color_source_vec( alpha: float = 1.0, ) -> tuple[ArrayLike | pd.Series | None, ArrayLike, bool]: if value_to_plot is None: - color = np.full(adata.n_obs, to_hex(na_color)) + color = np.full(len(element), to_hex(na_color)) # type: ignore[arg-type] return color, color, False - if alt_var is not None and value_to_plot not in adata.obs and value_to_plot not in adata.var_names: - value_to_plot = adata.var_names[adata.var[alt_var] == value_to_plot][0] - if use_raw and value_to_plot not in adata.obs: - color_source_vector = adata.raw.obs_vector(value_to_plot) - else: - color_source_vector = adata.obs_vector(value_to_plot, layer=layer) + # Figure out where to get the color from + origins = _locate_value(value_key=value_to_plot, sdata=sdata, element_name=element_name) + if len(origins) > 1: + raise ValueError( + f"Color key '{value_to_plot}' for element '{element_name}' been found in multiple locations: {origins}." + ) - if not is_categorical_dtype(color_source_vector): - return None, color_source_vector, False + if len(origins) == 1: + vals = get_values(value_key=value_to_plot, sdata=sdata, element_name=element_name) + color_source_vector = vals[value_to_plot] - color_source_vector = pd.Categorical(color_source_vector) # convert, e.g., `pd.Series` - categories = color_source_vector.categories + # if all([isinstance(x, str) for x in color_source_vector]): + # raise TypeError( + # f"Color key '{value_to_plot}' for element '{element_name}' has string values, " + # f"but should be numerical or categorical." + # ) - if groups is not None: - color_source_vector = color_source_vector.remove_categories(categories.difference(groups)) + # numerical case, return early + if not is_categorical_dtype(color_source_vector): + return None, color_source_vector, False - color_map = dict(zip(categories, _get_colors_for_categorical_obs(categories))) - # color_map = _get_palette( - # adata=adata, cluster_key=value_to_plot, categories=categories, palette=palette, alpha=alpha - # ) - if color_map is None: - raise ValueError("Unable to create color palette.") + color_source_vector = pd.Categorical(color_source_vector) # convert, e.g., `pd.Series` + categories = color_source_vector.categories - # do not rename categories, as colors need not be unique - color_vector = color_source_vector.map(color_map) - if color_vector.isna().any(): - color_vector = color_vector.add_categories([to_hex(na_color)]) - color_vector = color_vector.fillna(to_hex(na_color)) + if groups is not None: + color_source_vector = color_source_vector.remove_categories(categories.difference(groups)) - return color_source_vector, color_vector, True + color_map = dict(zip(categories, _get_colors_for_categorical_obs(categories))) + # color_map = _get_palette( + # adata=adata, cluster_key=value_to_plot, categories=categories, palette=palette, alpha=alpha + # ) + if color_map is None: + raise ValueError("Unable to create color palette.") + + # do not rename categories, as colors need not be unique + color_vector = color_source_vector.map(color_map) + if color_vector.isna().any(): + color_vector = color_vector.add_categories([to_hex(na_color)]) + color_vector = color_vector.fillna(to_hex(na_color)) + + 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.") + color = np.full(sdata.table.n_obs, to_hex(na_color)) + return color, color, False def _map_color_seg( @@ -871,18 +965,6 @@ def _maybe_set_colors( add_colors_for_categorical_sample_annotation(target, key=key, force_update_colors=True, palette=palette) -@dataclass -class LegendParams: - """Legend params.""" - - legend_fontsize: int | float | _FontSize | None = None - legend_fontweight: int | _FontWeight = "bold" - legend_loc: str | None = "right margin" - legend_fontoutline: int | None = None - na_in_legend: bool = True - colorbar: bool = True - - def _decorate_axs( ax: Axes, cax: PatchCollection, From cf8017d6a71d23333011ca5dbad0c8bd9eb46a37 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 29 Aug 2023 17:35:37 +0200 Subject: [PATCH 02/11] Added future annotations --- src/spatialdata_plot/pl/render_params.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index 3951dea0..4eb8f136 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Callable, Sequence from dataclasses import dataclass from typing import Literal From e31a18783277a08854d97fa676772e5fbb3fddde Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 29 Aug 2023 17:37:50 +0200 Subject: [PATCH 03/11] fixed merge conflict --- src/spatialdata_plot/pl/basic.py | 2 +- src/spatialdata_plot/pl/render_params.py | 2 +- src/spatialdata_plot/pl/utils.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index b3888d5a..439d1dca 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -304,7 +304,7 @@ def render_images( na_color: str | tuple[float, ...] | None = (0.0, 0.0, 0.0, 0.0), palette: ListedColormap | str | None = None, alpha: float = 1.0, - quantiles_for_norm: tuple[float | None, float | None] = (3.0, 99.8), # defaults from CSBDeep + quantiles_for_norm: tuple[float | None, float | None] = (None, None), **kwargs: Any, ) -> sd.SpatialData: """ diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index 4eb8f136..ac78eeb6 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -104,7 +104,7 @@ class ImageRenderParams: channel: list[str] | list[int] | int | str | None = None palette: ListedColormap | str | None = None alpha: float = 1.0 - quantiles_for_norm: tuple[float | None, float | None] = (3.0, 99.8) # defaults from CSBDeep + quantiles_for_norm: tuple[float | None, float | None] = (None, None) @dataclass diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 9c499107..32324597 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -692,8 +692,8 @@ def _get_hex_colors_for_continous_values(values: pd.Series, cmap_name: str = "vi def _normalize( img: xr.DataArray, - pmin: float | None = 3.0, - pmax: float | None = 99.8, + pmin: float | None = None, + pmax: float | None = None, eps: float = 1e-20, clip: bool = False, name: str = "normed", From 1b0fc60b66bf15888b61a26d6db4833adfd04c7a Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 30 Aug 2023 18:40:47 +0200 Subject: [PATCH 04/11] Fixed test, removed soon-to-be-deprecated tests --- CHANGELOG.md | 4 +++- tests/pl/test_get_extent.py | 12 ------------ tests/pl/test_render_shapes.py | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e75c071..ceb42d9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning][]. [keep a changelog]: https://keepachangelog.com/en/1.0.0/ [semantic versioning]: https://semver.org/spec/v2.0.0.html -## [0.0.5] -tbd +## [0.1.0] - tbd ### Added @@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning][]. ### Fixed - Legend order is now deterministic (#143) +- Images no longer normalised by default (#150) + ## [0.0.4] - 2023-08-11 diff --git a/tests/pl/test_get_extent.py b/tests/pl/test_get_extent.py index ad82453b..58b664d2 100644 --- a/tests/pl/test_get_extent.py +++ b/tests/pl/test_get_extent.py @@ -42,15 +42,3 @@ def test_plot_extent_of_img_is_correct_after_spatial_query(self, sdata_blobs: Sp axes=["x", "y"], min_coordinate=[100, 100], max_coordinate=[400, 400], target_coordinate_system="global" ) cropped_blobs.pl.render_images().pl.show() - - def test_plot_extent_of_polygons_is_correct_after_spatial_query(self, sdata_blobs: SpatialData): - cropped_blobs = sdata_blobs.pp.get_elements(["blobs_polygons"]).query.bounding_box( - axes=["x", "y"], min_coordinate=[100, 100], max_coordinate=[400, 400], target_coordinate_system="global" - ) - cropped_blobs.pl.render_shapes().pl.show() - - def test_plot_extent_of_polygons_on_img_is_correct_after_spatial_query(self, sdata_blobs: SpatialData): - cropped_blobs = sdata_blobs.pp.get_elements(["blobs_image", "blobs_polygons"]).query.bounding_box( - axes=["x", "y"], min_coordinate=[100, 100], max_coordinate=[400, 400], target_coordinate_system="global" - ) - cropped_blobs.pl.render_images().pl.render_shapes().pl.show() diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 5f9ea0c1..2692770d 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -85,7 +85,7 @@ def _make_multi(): sdata = SpatialData(shapes={"p": _make_multi()}) adata = anndata.AnnData(pd.DataFrame({"p": ["hole", "overlap", "square", "circle"]})) adata.obs.loc[:, "region"] = "p" - adata.obs.loc[:, "val"] = [1, 2, 3, 4] + adata.obs.loc[:, "val"] = [0, 1, 2, 3] table = TableModel.parse(adata, region="p", region_key="region", instance_key="val") sdata.table = table sdata.pl.render_shapes(color="val", outline=True, fill_alpha=0.3).pl.show() From b6760fa57da3b35368e1b6956e8a27fa44797e28 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:44:24 +0000 Subject: [PATCH 05/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ceb42d9c..cdcc4c1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ and this project adheres to [Semantic Versioning][]. - Legend order is now deterministic (#143) - Images no longer normalised by default (#150) - ## [0.0.4] - 2023-08-11 ### Fixed From 9990c02f39b7665a445dac61e6ca85e0a50de366 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 30 Aug 2023 18:52:36 +0200 Subject: [PATCH 06/11] Added test --- src/spatialdata_plot/pl/basic.py | 9 --------- .../Shapes_can_color_from_geodataframe.png | Bin 0 -> 7797 bytes tests/pl/test_render_shapes.py | 9 +++++++++ 3 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 tests/_images/Shapes_can_color_from_geodataframe.png diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 439d1dca..795e5ec7 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -661,15 +661,6 @@ def show( # extent=extent[cs], ) elif cmd == "render_shapes" and cs_contents.query(f"cs == '{cs}'")["has_shapes"][0]: - # if sdata.table is not None and isinstance(params.color, str): - # colors = sc.get.obs_df(sdata.table, params.color) - # if is_categorical_dtype(colors): - # _maybe_set_colors( - # source=sdata.table, - # target=sdata.table, - # key=params.color, - # palette=params.palette, - # ) _render_shapes( sdata=sdata, render_params=params, diff --git a/tests/_images/Shapes_can_color_from_geodataframe.png b/tests/_images/Shapes_can_color_from_geodataframe.png new file mode 100644 index 0000000000000000000000000000000000000000..c9718063be71cc8b02093a8d046e031d06036ddf GIT binary patch literal 7797 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 literal 0 HcmV?d00001 diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 2692770d..2076132f 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -89,3 +89,12 @@ def _make_multi(): table = TableModel.parse(adata, region="p", region_key="region", instance_key="val") sdata.table = table sdata.pl.render_shapes(color="val", outline=True, fill_alpha=0.3).pl.show() + + def test_plot_can_color_from_geodataframe(self, sdata_blobs: SpatialData): + + blob = sdata_blobs + blob.shapes["blobs_polygons"]["value"] = [1, 10, 1, 20, 1] + blob.pl.render_shapes( + elements="blobs_polygons", + color="value", + ).pl.show() From 97a2767bc74c8a4ff6c7e9e6233b89bcf7bc63e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Aug 2023 16:54:24 +0000 Subject: [PATCH 07/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/pl/test_render_shapes.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 2076132f..e183cea7 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -89,9 +89,8 @@ def _make_multi(): table = TableModel.parse(adata, region="p", region_key="region", instance_key="val") sdata.table = table sdata.pl.render_shapes(color="val", outline=True, fill_alpha=0.3).pl.show() - + def test_plot_can_color_from_geodataframe(self, sdata_blobs: SpatialData): - blob = sdata_blobs blob.shapes["blobs_polygons"]["value"] = [1, 10, 1, 20, 1] blob.pl.render_shapes( From dfefa2f7221ace0203cea073e9e85e9e20b67ba2 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 30 Aug 2023 18:58:06 +0200 Subject: [PATCH 08/11] Added test for normalisation --- tests/_images/Images_can_normalize_image.png | Bin 0 -> 44800 bytes tests/pl/test_render_images.py | 5 +++++ 2 files changed, 5 insertions(+) create mode 100644 tests/_images/Images_can_normalize_image.png diff --git a/tests/_images/Images_can_normalize_image.png b/tests/_images/Images_can_normalize_image.png new file mode 100644 index 0000000000000000000000000000000000000000..d83f40340a4e9b38d8e95b19d94dea7c16cbbc7f GIT binary patch literal 44800 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 literal 0 HcmV?d00001 diff --git a/tests/pl/test_render_images.py b/tests/pl/test_render_images.py index 20acfbde..3ef3d01d 100644 --- a/tests/pl/test_render_images.py +++ b/tests/pl/test_render_images.py @@ -46,3 +46,8 @@ def test_plot_can_pass_cmap_to_each_channel(self, sdata_blobs: SpatialData): sdata_blobs.pl.render_images( elements="blobs_image", channel=[0, 1, 2], cmap=["Reds", "Greens", "Blues"] ).pl.show() + + def test_plot_can_normalize_image(self, sdata_blobs: SpatialData): + sdata_blobs.pl.render_images( + elements="blobs_image", quantiles_for_norm=(5, 90) + ).pl.show() From 61fce8c6ec9ed104f9a5df5b40b583a84ec1ee30 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 30 Aug 2023 19:15:26 +0200 Subject: [PATCH 09/11] Moved argument to correct place --- src/spatialdata_plot/pl/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 32324597..69fc7b84 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -576,7 +576,6 @@ def _prepare_cmap_norm( def _set_outline( - size: float, outline: bool = False, outline_width: float = 1.5, outline_color: str | list[float] = "#0000000ff", # black, white From ea18f3fbaacba042fd488f91af0553ba5d2b1009 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Aug 2023 17:19:57 +0000 Subject: [PATCH 10/11] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/pl/test_render_images.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/pl/test_render_images.py b/tests/pl/test_render_images.py index 3ef3d01d..6189e189 100644 --- a/tests/pl/test_render_images.py +++ b/tests/pl/test_render_images.py @@ -48,6 +48,4 @@ def test_plot_can_pass_cmap_to_each_channel(self, sdata_blobs: SpatialData): ).pl.show() def test_plot_can_normalize_image(self, sdata_blobs: SpatialData): - sdata_blobs.pl.render_images( - elements="blobs_image", quantiles_for_norm=(5, 90) - ).pl.show() + sdata_blobs.pl.render_images(elements="blobs_image", quantiles_for_norm=(5, 90)).pl.show() From 79e3a9260b28fa89b28982f60cb340c9af533102 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Wed, 30 Aug 2023 20:05:04 +0200 Subject: [PATCH 11/11] Added shape scaling --- CHANGELOG.md | 1 + src/spatialdata_plot/pl/basic.py | 7 ++++--- src/spatialdata_plot/pl/render.py | 2 +- src/spatialdata_plot/pl/render_params.py | 2 +- src/spatialdata_plot/pl/utils.py | 21 +++++++++++++++------ tests/_images/Shapes_can_scale_shapes.png | Bin 0 -> 4885 bytes tests/pl/test_render_shapes.py | 3 +++ 7 files changed, 25 insertions(+), 11 deletions(-) create mode 100644 tests/_images/Shapes_can_scale_shapes.png diff --git a/CHANGELOG.md b/CHANGELOG.md index cdcc4c1c..4e2cd300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning][]. ### Added - Multipolygons are now handled correctly (#93) +- Can now scale shapes (#152) ### Fixed diff --git a/src/spatialdata_plot/pl/basic.py b/src/spatialdata_plot/pl/basic.py index 795e5ec7..f09425b5 100644 --- a/src/spatialdata_plot/pl/basic.py +++ b/src/spatialdata_plot/pl/basic.py @@ -145,7 +145,7 @@ def render_shapes( elements: str | list[str] | None = None, color: str | None = None, groups: str | Sequence[str] | None = None, - size: float = 1.0, + scale: float = 1.0, outline: bool = False, outline_width: float = 1.5, outline_color: str | list[float] = "#000000ff", @@ -171,7 +171,7 @@ def render_shapes( groups For discrete annotation in ``color``, select which values to plot (other values are set to NAs). - size + scale Value to scale circles, if present. outline If `True`, a thin border around points/shapes is plotted. @@ -212,11 +212,12 @@ def render_shapes( na_color=na_color, # type: ignore[arg-type] **kwargs, ) - outline_params = _set_outline(size, outline, outline_width, outline_color) + outline_params = _set_outline(outline, outline_width, outline_color) sdata.plotting_tree[f"{n_steps+1}_render_shapes"] = ShapesRenderParams( elements=elements, color=color, groups=groups, + scale=scale, outline_params=outline_params, layer=layer, cmap_params=cmap_params, diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index f5ceac11..6e264532 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -102,7 +102,7 @@ def _render_shapes( shapes = gpd.GeoDataFrame(shapes, geometry="geometry") _cax = _get_collection_shape( shapes=shapes, - s=render_params.size, + s=render_params.scale, c=color_vector, render_params=render_params, rasterized=sc_settings._vector_friendly, diff --git a/src/spatialdata_plot/pl/render_params.py b/src/spatialdata_plot/pl/render_params.py index ac78eeb6..9294dc2d 100644 --- a/src/spatialdata_plot/pl/render_params.py +++ b/src/spatialdata_plot/pl/render_params.py @@ -77,7 +77,7 @@ class ShapesRenderParams: palette: ListedColormap | str | None = None outline_alpha: float = 1.0 fill_alpha: float = 0.3 - size: float = 1.0 + scale: float = 1.0 transfunc: Callable[[float], float] | None = None diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 69fc7b84..d60e53a1 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -185,7 +185,7 @@ def _get_collection_shape( Args: - shapes (list[GeoDataFrame]): List of geometrical shapes. - c: Color parameter. - - s (float): Size of the shape. + - s (float): Scale of the shape. - norm: Normalization for the color map. - fill_alpha (float, optional): Opacity for the fill color. - outline_alpha (float, optional): Opacity for the outline. @@ -241,21 +241,30 @@ def assign_fill_and_outline_to_row( geom = row["geometry"] if geom.geom_type == "Polygon": row = row.to_dict() - row["geometry"] = mplp.Polygon(geom.exterior.coords, closed=True) + coords = np.array(geom.exterior.coords) + centroid = np.mean(coords, axis=0) + scaled_coords = [(centroid + (np.array(coord) - centroid) * s).tolist() for coord in geom.exterior.coords] + row["geometry"] = mplp.Polygon(scaled_coords, closed=True) assign_fill_and_outline_to_row(shapes, fill_c, outline_c, row, idx) rows.append(row) elif geom.geom_type == "MultiPolygon": - mp = _make_patch_from_multipolygon(geom) - for _, m in enumerate(mp): + # mp = _make_patch_from_multipolygon(geom) + for polygon in geom.geoms: mp_copy = row.to_dict() - mp_copy["geometry"] = m + coords = np.array(polygon.exterior.coords) + centroid = np.mean(coords, axis=0) + scaled_coords = [(centroid + (coord - centroid) * s).tolist() for coord in coords] + mp_copy["geometry"] = mplp.Polygon(scaled_coords, closed=True) assign_fill_and_outline_to_row(shapes, fill_c, outline_c, mp_copy, idx) rows.append(mp_copy) elif geom.geom_type == "Point": row = row.to_dict() - row["geometry"] = mplp.Circle((geom.x, geom.y), radius=row["radius"]) + scaled_radius = row["radius"] * s + row["geometry"] = mplp.Circle( + (geom.x, geom.y), radius=scaled_radius + ) # Circle is always scaled from its center assign_fill_and_outline_to_row(shapes, fill_c, outline_c, row, idx) rows.append(row) diff --git a/tests/_images/Shapes_can_scale_shapes.png b/tests/_images/Shapes_can_scale_shapes.png new file mode 100644 index 0000000000000000000000000000000000000000..8a1cf605fd1a2ceaccc7e5c5b329082a730a9fe5 GIT binary patch literal 4885 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