11from __future__ import annotations
22
3+ import math
34import os
45import warnings
56from collections import OrderedDict
5152from scanpy .plotting ._tools .scatterplots import _add_categorical_legend
5253from scanpy .plotting ._utils import add_colors_for_categorical_sample_annotation
5354from scanpy .plotting .palettes import default_20 , default_28 , default_102
55+ from scipy .spatial import ConvexHull
5456from skimage .color import label2rgb
5557from skimage .morphology import erosion , square
5658from skimage .segmentation import find_boundaries
@@ -1818,6 +1820,14 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st
18181820 if size < 0 :
18191821 raise ValueError ("Parameter 'size' must be a positive number." )
18201822
1823+ if element_type == "shapes" and (shape := param_dict .get ("shape" )) is not None :
1824+ if not isinstance (shape , str ):
1825+ raise TypeError ("Parameter 'shape' must be a String from ['circle', 'hex', 'square'] if not None." )
1826+ if shape not in ["circle" , "hex" , "square" ]:
1827+ raise ValueError (
1828+ f"'{ shape } ' is not supported for 'shape', please choose from[None, 'circle', 'hex', 'square']."
1829+ )
1830+
18211831 table_name = param_dict .get ("table_name" )
18221832 table_layer = param_dict .get ("table_layer" )
18231833 if table_name and not isinstance (param_dict ["table_name" ], str ):
@@ -2030,6 +2040,7 @@ def _validate_shape_render_params(
20302040 scale : float | int ,
20312041 table_name : str | None ,
20322042 table_layer : str | None ,
2043+ shape : Literal ["circle" , "hex" , "square" ] | None ,
20332044 method : str | None ,
20342045 ds_reduction : str | None ,
20352046) -> dict [str , dict [str , Any ]]:
@@ -2049,6 +2060,7 @@ def _validate_shape_render_params(
20492060 "scale" : scale ,
20502061 "table_name" : table_name ,
20512062 "table_layer" : table_layer ,
2063+ "shape" : shape ,
20522064 "method" : method ,
20532065 "ds_reduction" : ds_reduction ,
20542066 }
@@ -2069,6 +2081,7 @@ def _validate_shape_render_params(
20692081 element_params [el ]["norm" ] = param_dict ["norm" ]
20702082 element_params [el ]["scale" ] = param_dict ["scale" ]
20712083 element_params [el ]["table_layer" ] = param_dict ["table_layer" ]
2084+ element_params [el ]["shape" ] = param_dict ["shape" ]
20722085
20732086 element_params [el ]["color" ] = param_dict ["color" ]
20742087
@@ -2487,6 +2500,39 @@ def _prepare_transformation(
24872500 return trans , trans_data
24882501
24892502
2503+ def _get_datashader_trans_matrix_of_single_element (
2504+ trans : Identity | Scale | Affine | MapAxis | Translation ,
2505+ ) -> ArrayLike :
2506+ flip_matrix = np .array ([[1 , 0 , 0 ], [0 , - 1 , 0 ], [0 , 0 , 1 ]])
2507+ tm : ArrayLike = trans .to_affine_matrix (("x" , "y" ), ("x" , "y" ))
2508+
2509+ if isinstance (trans , Identity ):
2510+ return np .array ([[1 , 0 , 0 ], [0 , 1 , 0 ], [0 , 0 , 1 ]])
2511+ if isinstance (trans , (Scale | Affine )):
2512+ # idea: "flip the y-axis", apply transformation, flip back
2513+ flip_and_transform : ArrayLike = flip_matrix @ tm @ flip_matrix
2514+ return flip_and_transform
2515+ if isinstance (trans , MapAxis ):
2516+ # no flipping needed
2517+ return tm
2518+ # for a Translation, we need the transposed transformation matrix
2519+ tm_T = tm .T
2520+ assert isinstance (tm_T , np .ndarray )
2521+ return tm_T
2522+
2523+
2524+ def _get_transformation_matrix_for_datashader (
2525+ trans : Scale | Identity | Affine | MapAxis | Translation | SDSequence ,
2526+ ) -> ArrayLike :
2527+ """Get the affine matrix needed to transform shapes for rendering with datashader."""
2528+ if isinstance (trans , SDSequence ):
2529+ tm = np .array ([[1 , 0 , 0 ], [0 , 1 , 0 ], [0 , 0 , 1 ]])
2530+ for x in trans .transformations :
2531+ tm = tm @ _get_datashader_trans_matrix_of_single_element (x )
2532+ return tm
2533+ return _get_datashader_trans_matrix_of_single_element (trans )
2534+
2535+
24902536def _datashader_map_aggregate_to_color (
24912537 agg : DataArray ,
24922538 cmap : str | list [str ] | ListedColormap ,
@@ -2588,6 +2634,124 @@ def _hex_no_alpha(hex: str) -> str:
25882634 raise ValueError ("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'" )
25892635
25902636
2637+ def _convert_shapes (
2638+ shapes : GeoDataFrame , target_shape : str , max_extent : float , warn_above_extent_fraction : float = 0.5
2639+ ) -> GeoDataFrame :
2640+ """Convert the shapes stored in a GeoDataFrame (geometry column) to the target_shape."""
2641+ # NOTE: possible follow-up: when converting equally sized shapes to hex, automatically scale resulting hexagons
2642+ # so that they are perfectly adjacent to each other
2643+
2644+ if warn_above_extent_fraction < 0.0 or warn_above_extent_fraction > 1.0 :
2645+ warn_above_extent_fraction = 0.5 # set to default if the value is outside [0, 1]
2646+ warn_shape_size = False
2647+
2648+ # define individual conversion methods
2649+ def _circle_to_hexagon (center : shapely .Point , radius : float ) -> tuple [shapely .Polygon , None ]:
2650+ vertices = [
2651+ (center .x + radius * math .cos (math .radians (angle )), center .y + radius * math .sin (math .radians (angle )))
2652+ for angle in range (0 , 360 , 60 )
2653+ ]
2654+ return shapely .Polygon (vertices ), None
2655+
2656+ def _circle_to_square (center : shapely .Point , radius : float ) -> tuple [shapely .Polygon , None ]:
2657+ vertices = [
2658+ (center .x + radius * math .cos (math .radians (angle )), center .y + radius * math .sin (math .radians (angle )))
2659+ for angle in range (45 , 360 , 90 )
2660+ ]
2661+ return shapely .Polygon (vertices ), None
2662+
2663+ def _circle_to_circle (center : shapely .Point , radius : float ) -> tuple [shapely .Point , float ]:
2664+ return center , radius
2665+
2666+ def _polygon_to_hexagon (polygon : shapely .Polygon ) -> tuple [shapely .Polygon , None ]:
2667+ center , radius = _polygon_to_circle (polygon )
2668+ return _circle_to_hexagon (center , radius )
2669+
2670+ def _polygon_to_square (polygon : shapely .Polygon ) -> tuple [shapely .Polygon , None ]:
2671+ center , radius = _polygon_to_circle (polygon )
2672+ return _circle_to_square (center , radius )
2673+
2674+ def _polygon_to_circle (polygon : shapely .Polygon ) -> tuple [shapely .Point , float ]:
2675+ coords = np .array (polygon .exterior .coords )
2676+ circle_points = coords [ConvexHull (coords ).vertices ]
2677+ center = np .mean (circle_points , axis = 0 )
2678+ radius = max (float (np .linalg .norm (p - center )) for p in circle_points )
2679+ assert isinstance (radius , float ) # shut up mypy
2680+ if 2 * radius > max_extent * warn_above_extent_fraction :
2681+ nonlocal warn_shape_size
2682+ warn_shape_size = True
2683+ return shapely .Point (center ), radius
2684+
2685+ def _multipolygon_to_hexagon (multipolygon : shapely .MultiPolygon ) -> tuple [shapely .Polygon , None ]:
2686+ center , radius = _multipolygon_to_circle (multipolygon )
2687+ return _circle_to_hexagon (center , radius )
2688+
2689+ def _multipolygon_to_square (multipolygon : shapely .MultiPolygon ) -> tuple [shapely .Polygon , None ]:
2690+ center , radius = _multipolygon_to_circle (multipolygon )
2691+ return _circle_to_square (center , radius )
2692+
2693+ def _multipolygon_to_circle (multipolygon : shapely .MultiPolygon ) -> tuple [shapely .Point , float ]:
2694+ coords = []
2695+ for polygon in multipolygon .geoms :
2696+ coords .extend (polygon .exterior .coords )
2697+ points = np .array (coords )
2698+ circle_points = points [ConvexHull (points ).vertices ]
2699+ center = np .mean (circle_points , axis = 0 )
2700+ radius = max (float (np .linalg .norm (p - center )) for p in circle_points )
2701+ assert isinstance (radius , float ) # shut up mypy
2702+ if 2 * radius > max_extent * warn_above_extent_fraction :
2703+ nonlocal warn_shape_size
2704+ warn_shape_size = True
2705+ return shapely .Point (center ), radius
2706+
2707+ # define dict with all conversion methods
2708+ if target_shape == "circle" :
2709+ conversion_methods = {
2710+ "Point" : _circle_to_circle ,
2711+ "Polygon" : _polygon_to_circle ,
2712+ "Multipolygon" : _multipolygon_to_circle ,
2713+ }
2714+ pass
2715+ elif target_shape == "hex" :
2716+ conversion_methods = {
2717+ "Point" : _circle_to_hexagon ,
2718+ "Polygon" : _polygon_to_hexagon ,
2719+ "Multipolygon" : _multipolygon_to_hexagon ,
2720+ }
2721+ else :
2722+ conversion_methods = {
2723+ "Point" : _circle_to_square ,
2724+ "Polygon" : _polygon_to_square ,
2725+ "Multipolygon" : _multipolygon_to_square ,
2726+ }
2727+
2728+ # convert every shape
2729+ for i in range (shapes .shape [0 ]):
2730+ if shapes ["geometry" ][i ].type == "Point" :
2731+ converted , radius = conversion_methods ["Point" ](shapes ["geometry" ][i ], shapes ["radius" ][i ]) # type: ignore
2732+ elif shapes ["geometry" ][i ].type == "Polygon" :
2733+ converted , radius = conversion_methods ["Polygon" ](shapes ["geometry" ][i ]) # type: ignore
2734+ elif shapes ["geometry" ][i ].type == "MultiPolygon" :
2735+ converted , radius = conversion_methods ["Multipolygon" ](shapes ["geometry" ][i ]) # type: ignore
2736+ else :
2737+ error_type = shapes ["geometry" ][i ].type
2738+ raise ValueError (f"Converting shape { error_type } to { target_shape } is not supported." )
2739+ shapes ["geometry" ][i ] = converted
2740+ if radius is not None :
2741+ if "radius" not in shapes .columns :
2742+ shapes ["radius" ] = np .nan
2743+ shapes ["radius" ][i ] = radius
2744+
2745+ if warn_shape_size :
2746+ logger .info (
2747+ f"When converting the shapes, the size of at least one target shape extends "
2748+ f"{ warn_above_extent_fraction * 100 } % of the original total bound of the shapes. The conversion"
2749+ " might not give satisfying results in this scenario."
2750+ )
2751+
2752+ return shapes
2753+
2754+
25912755def _convert_alpha_to_datashader_range (alpha : float ) -> float :
25922756 """Convert alpha from the range [0, 1] to the range [0, 255] used in datashader."""
25932757 # prevent a value of 255, bc that led to fully colored test plots instead of just colored points/shapes
0 commit comments