1
1
from __future__ import annotations
2
2
3
+ import math
3
4
import os
4
5
import warnings
5
6
from collections import OrderedDict
51
52
from scanpy .plotting ._tools .scatterplots import _add_categorical_legend
52
53
from scanpy .plotting ._utils import add_colors_for_categorical_sample_annotation
53
54
from scanpy .plotting .palettes import default_20 , default_28 , default_102
55
+ from scipy .spatial import ConvexHull
54
56
from skimage .color import label2rgb
55
57
from skimage .morphology import erosion , square
56
58
from skimage .segmentation import find_boundaries
@@ -1709,6 +1711,14 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st
1709
1711
if size < 0 :
1710
1712
raise ValueError ("Parameter 'size' must be a positive number." )
1711
1713
1714
+ if element_type == "shapes" and (shape := param_dict .get ("shape" )) is not None :
1715
+ if not isinstance (shape , str ):
1716
+ raise TypeError ("Parameter 'shape' must be a String from ['circle', 'hex', 'square'] if not None." )
1717
+ if shape not in ["circle" , "hex" , "square" ]:
1718
+ raise ValueError (
1719
+ f"'{ shape } ' is not supported for 'shape', please choose from[None, 'circle', 'hex', 'square']."
1720
+ )
1721
+
1712
1722
table_name = param_dict .get ("table_name" )
1713
1723
table_layer = param_dict .get ("table_layer" )
1714
1724
if table_name and not isinstance (param_dict ["table_name" ], str ):
@@ -1920,6 +1930,7 @@ def _validate_shape_render_params(
1920
1930
scale : float | int ,
1921
1931
table_name : str | None ,
1922
1932
table_layer : str | None ,
1933
+ shape : Literal ["circle" , "hex" , "square" ] | None ,
1923
1934
method : str | None ,
1924
1935
ds_reduction : str | None ,
1925
1936
) -> dict [str , dict [str , Any ]]:
@@ -1939,6 +1950,7 @@ def _validate_shape_render_params(
1939
1950
"scale" : scale ,
1940
1951
"table_name" : table_name ,
1941
1952
"table_layer" : table_layer ,
1953
+ "shape" : shape ,
1942
1954
"method" : method ,
1943
1955
"ds_reduction" : ds_reduction ,
1944
1956
}
@@ -1959,6 +1971,7 @@ def _validate_shape_render_params(
1959
1971
element_params [el ]["norm" ] = param_dict ["norm" ]
1960
1972
element_params [el ]["scale" ] = param_dict ["scale" ]
1961
1973
element_params [el ]["table_layer" ] = param_dict ["table_layer" ]
1974
+ element_params [el ]["shape" ] = param_dict ["shape" ]
1962
1975
1963
1976
element_params [el ]["color" ] = param_dict ["color" ]
1964
1977
@@ -2086,7 +2099,7 @@ def _validate_image_render_params(
2086
2099
def _get_wanted_render_elements (
2087
2100
sdata : SpatialData ,
2088
2101
sdata_wanted_elements : list [str ],
2089
- params : ( ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams ) ,
2102
+ params : ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams ,
2090
2103
cs : str ,
2091
2104
element_type : Literal ["images" , "labels" , "points" , "shapes" ],
2092
2105
) -> tuple [list [str ], list [str ], bool ]:
@@ -2243,7 +2256,7 @@ def _create_image_from_datashader_result(
2243
2256
2244
2257
2245
2258
def _datashader_aggregate_with_function (
2246
- reduction : ( Literal ["sum" , "mean" , "any" , "count" , "std" , "var" , "max" , "min" ] | None ) ,
2259
+ reduction : Literal ["sum" , "mean" , "any" , "count" , "std" , "var" , "max" , "min" ] | None ,
2247
2260
cvs : Canvas ,
2248
2261
spatial_element : GeoDataFrame | dask .dataframe .core .DataFrame ,
2249
2262
col_for_color : str | None ,
@@ -2307,7 +2320,7 @@ def _datashader_aggregate_with_function(
2307
2320
2308
2321
2309
2322
def _datshader_get_how_kw_for_spread (
2310
- reduction : ( Literal ["sum" , "mean" , "any" , "count" , "std" , "var" , "max" , "min" ] | None ) ,
2323
+ reduction : Literal ["sum" , "mean" , "any" , "count" , "std" , "var" , "max" , "min" ] | None ,
2311
2324
) -> str :
2312
2325
# Get the best input for the how argument of ds.tf.spread(), needed for numerical values
2313
2326
reduction = reduction or "sum"
@@ -2478,3 +2491,100 @@ def _hex_no_alpha(hex: str) -> str:
2478
2491
return "#" + hex_digits [:6 ]
2479
2492
2480
2493
raise ValueError ("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'" )
2494
+
2495
+
2496
+ def _convert_shapes (shapes : GeoDataFrame , target_shape : str ) -> GeoDataFrame :
2497
+ """Convert the shapes stored in a GeoDataFrame (geometry column) to the target_shape."""
2498
+
2499
+ # define individual conversion methods
2500
+ def _circle_to_hexagon (center : shapely .Point , radius : float ) -> tuple [shapely .Polygon , None ]:
2501
+ vertices = [
2502
+ (center .x + radius * math .cos (math .radians (angle )), center .y + radius * math .sin (math .radians (angle )))
2503
+ for angle in range (0 , 360 , 60 )
2504
+ ]
2505
+ return shapely .Polygon (vertices ), None
2506
+
2507
+ def _circle_to_square (center : shapely .Point , radius : float ) -> tuple [shapely .Polygon , None ]:
2508
+ vertices = [
2509
+ (center .x + radius * math .cos (math .radians (angle )), center .y + radius * math .sin (math .radians (angle )))
2510
+ for angle in range (45 , 360 , 90 )
2511
+ ]
2512
+ return shapely .Polygon (vertices ), None
2513
+
2514
+ def _circle_to_circle (center : shapely .Point , radius : float ) -> tuple [shapely .Point , float ]:
2515
+ return center , radius
2516
+
2517
+ def _polygon_to_hexagon (polygon : shapely .Polygon ) -> tuple [shapely .Polygon , None ]:
2518
+ center , radius = _polygon_to_circle (polygon )
2519
+ return _circle_to_hexagon (center , radius )
2520
+
2521
+ def _polygon_to_square (polygon : shapely .Polygon ) -> tuple [shapely .Polygon , None ]:
2522
+ center , radius = _polygon_to_circle (polygon )
2523
+ return _circle_to_square (center , radius )
2524
+
2525
+ def _polygon_to_circle (polygon : shapely .Polygon ) -> tuple [shapely .Point , float ]:
2526
+ coords = np .array (polygon .exterior .coords )
2527
+ circle_points = coords [ConvexHull (coords ).vertices ]
2528
+ center = np .mean (circle_points , axis = 0 )
2529
+ radius = max (np .linalg .norm (p - center ) for p in circle_points )
2530
+ assert isinstance (radius , float ) # shut up mypy
2531
+ return shapely .Point (center ), radius
2532
+
2533
+ def _multipolygon_to_hexagon (multipolygon : shapely .MultiPolygon ) -> tuple [shapely .Polygon , None ]:
2534
+ center , radius = _multipolygon_to_circle (multipolygon )
2535
+ return _circle_to_hexagon (center , radius )
2536
+
2537
+ def _multipolygon_to_square (multipolygon : shapely .MultiPolygon ) -> tuple [shapely .Polygon , None ]:
2538
+ center , radius = _multipolygon_to_circle (multipolygon )
2539
+ return _circle_to_square (center , radius )
2540
+
2541
+ def _multipolygon_to_circle (multipolygon : shapely .MultiPolygon ) -> tuple [shapely .Point , float ]:
2542
+ coords = []
2543
+ for polygon in multipolygon .geoms :
2544
+ coords .extend (polygon .exterior .coords )
2545
+ points = np .array (coords )
2546
+ circle_points = points [ConvexHull (points ).vertices ]
2547
+ center = np .mean (circle_points , axis = 0 )
2548
+ radius = max (np .linalg .norm (p - center ) for p in circle_points )
2549
+ assert isinstance (radius , float ) # shut up mypy
2550
+ return shapely .Point (center ), radius
2551
+
2552
+ # define dict with all conversion methods
2553
+ if target_shape == "circle" :
2554
+ conversion_methods = {
2555
+ "Point" : _circle_to_circle ,
2556
+ "Polygon" : _polygon_to_circle ,
2557
+ "Multipolygon" : _multipolygon_to_circle ,
2558
+ }
2559
+ pass
2560
+ elif target_shape == "hex" :
2561
+ conversion_methods = {
2562
+ "Point" : _circle_to_hexagon ,
2563
+ "Polygon" : _polygon_to_hexagon ,
2564
+ "Multipolygon" : _multipolygon_to_hexagon ,
2565
+ }
2566
+ else :
2567
+ conversion_methods = {
2568
+ "Point" : _circle_to_square ,
2569
+ "Polygon" : _polygon_to_square ,
2570
+ "Multipolygon" : _multipolygon_to_square ,
2571
+ }
2572
+
2573
+ # convert every shape
2574
+ for i in range (shapes .shape [0 ]):
2575
+ if shapes ["geometry" ][i ].type == "Point" :
2576
+ converted , radius = conversion_methods ["Point" ](shapes ["geometry" ][i ], shapes ["radius" ][i ]) # type: ignore
2577
+ elif shapes ["geometry" ][i ].type == "Polygon" :
2578
+ converted , radius = conversion_methods ["Polygon" ](shapes ["geometry" ][i ]) # type: ignore
2579
+ elif shapes ["geometry" ][i ].type == "MultiPolygon" :
2580
+ converted , radius = conversion_methods ["Multipolygon" ](shapes ["geometry" ][i ]) # type: ignore
2581
+ else :
2582
+ error_type = shapes ["geometry" ][i ].type
2583
+ raise ValueError (f"Converting shape { error_type } to { target_shape } is not supported." )
2584
+ shapes ["geometry" ][i ] = converted
2585
+ if radius is not None :
2586
+ if "radius" not in shapes .columns :
2587
+ shapes ["radius" ] = np .nan
2588
+ shapes ["radius" ][i ] = radius
2589
+
2590
+ return shapes
0 commit comments