11from __future__ import annotations
22
3+ from numbers import Integral
34from typing import TYPE_CHECKING
45
56import numpy as np
67from dask .array import Array as DaskArray
78from dask .dataframe import DataFrame as DaskDataFrame
89from geopandas import GeoDataFrame
9- from shapely import Point
10+ from shapely import Point , box
1011from xarray import DataArray , DataTree
1112
1213if TYPE_CHECKING :
1314 import datashader as ds
15+ import pandas as pd
1416
1517from spatialdata ._core .operations ._utils import _parse_element
1618from spatialdata ._core .operations .transform import transform
4648VALUES_COLUMN = "__values_column"
4749
4850
51+ def _filter_points_for_tile (
52+ data : pd .DataFrame ,
53+ * ,
54+ x_range : tuple [Number , Number ],
55+ y_range : tuple [Number , Number ],
56+ include_x_max : bool ,
57+ include_y_max : bool ,
58+ ) -> pd .DataFrame :
59+ x_upper_bound = data ["x" ] <= x_range [1 ] if include_x_max else data ["x" ] < x_range [1 ]
60+ y_upper_bound = data ["y" ] <= y_range [1 ] if include_y_max else data ["y" ] < y_range [1 ]
61+ return data [(data ["x" ] >= x_range [0 ]) & x_upper_bound & (data ["y" ] >= y_range [0 ]) & y_upper_bound ]
62+
63+
64+ def _rasterize_tile (
65+ partitions : list [pd .DataFrame | GeoDataFrame ],
66+ * ,
67+ is_shapes : bool ,
68+ shape_positions : np .ndarray | None ,
69+ plot_height : int ,
70+ plot_width : int ,
71+ x_range : tuple [Number , Number ],
72+ y_range : tuple [Number , Number ],
73+ agg_func : ds .reductions .Reduction ,
74+ crop : tuple [slice , ...],
75+ empty_shape : tuple [int , ...],
76+ empty_dtype : np .dtype ,
77+ empty_fill_value : Number ,
78+ ) -> np .ndarray :
79+ import datashader as ds
80+ import pandas as pd
81+
82+ canvas = ds .Canvas (plot_height = plot_height , plot_width = plot_width , x_range = x_range , y_range = y_range )
83+ if is_shapes :
84+ assert len (partitions ) == 1
85+ data = partitions [0 ]
86+ assert isinstance (data , GeoDataFrame )
87+ assert shape_positions is not None
88+ visible_data = data .iloc [shape_positions ].copy ()
89+ if len (visible_data ) == 0 :
90+ return np .full (empty_shape , empty_fill_value , dtype = empty_dtype )[crop ]
91+ aggregate = canvas .polygons (visible_data , "geometry" , agg = agg_func )
92+ else :
93+ data = pd .concat (partitions )
94+ aggregate = canvas .points (data , x = "x" , y = "y" , agg = agg_func )
95+ return np .asarray (aggregate .data )[crop ]
96+
97+
98+ def _rasterize_tiled (
99+ data : DaskDataFrame | GeoDataFrame ,
100+ * ,
101+ is_shapes : bool ,
102+ plot_height : int ,
103+ plot_width : int ,
104+ x_range : tuple [Number , Number ],
105+ y_range : tuple [Number , Number ],
106+ agg_func : ds .reductions .Reduction ,
107+ tile_size : int ,
108+ ) -> DataArray :
109+ import dask
110+ import dask .array as da
111+ import datashader as ds
112+
113+ sample = data .iloc [:1 ].copy () if is_shapes else data ._meta
114+ sample_canvas = ds .Canvas (plot_height = 1 , plot_width = 1 , x_range = x_range , y_range = y_range )
115+ if is_shapes :
116+ sample_aggregate = sample_canvas .polygons (sample , "geometry" , agg = agg_func )
117+ else :
118+ sample_aggregate = sample_canvas .points (sample , x = "x" , y = "y" , agg = agg_func )
119+ y_axis = sample_aggregate .dims .index ("y" )
120+ x_axis = sample_aggregate .dims .index ("x" )
121+ sample_shape = list (sample_aggregate .shape )
122+ empty_fill_value = 0 if sample_aggregate .dtype .kind in "uib" else np .nan
123+
124+ partitions = list (data .to_delayed ()) if isinstance (data , DaskDataFrame ) else [dask .delayed (data , pure = True )]
125+ spatial_index = data .sindex if is_shapes else None
126+ x_scale = (x_range [1 ] - x_range [0 ]) / plot_width
127+ y_scale = (y_range [1 ] - y_range [0 ]) / plot_height
128+ rows = []
129+ for y_start in range (0 , plot_height , tile_size ):
130+ y_stop = min (y_start + tile_size , plot_height )
131+ row = []
132+ for x_start in range (0 , plot_width , tile_size ):
133+ x_stop = min (x_start + tile_size , plot_width )
134+ render_x_start = max (0 , x_start - 1 ) if is_shapes else x_start
135+ render_x_stop = min (plot_width , x_stop + 1 ) if is_shapes else x_stop
136+ render_y_start = max (0 , y_start - 1 ) if is_shapes else y_start
137+ render_y_stop = min (plot_height , y_stop + 1 ) if is_shapes else y_stop
138+ tile_x_range = (
139+ x_range [0 ] + render_x_start * x_scale ,
140+ x_range [0 ] + render_x_stop * x_scale ,
141+ )
142+ tile_y_range = (
143+ y_range [0 ] + render_y_start * y_scale ,
144+ y_range [0 ] + render_y_stop * y_scale ,
145+ )
146+ crop = [slice (None )] * sample_aggregate .ndim
147+ crop [y_axis ] = slice (
148+ y_start - render_y_start ,
149+ y_stop - render_y_start ,
150+ )
151+ crop [x_axis ] = slice (
152+ x_start - render_x_start ,
153+ x_stop - render_x_start ,
154+ )
155+ render_shape = sample_shape .copy ()
156+ render_shape [y_axis ] = render_y_stop - render_y_start
157+ render_shape [x_axis ] = render_x_stop - render_x_start
158+ tile_partitions = partitions
159+ shape_positions = None
160+ if is_shapes :
161+ assert spatial_index is not None
162+ shape_positions = np .sort (
163+ spatial_index .query (
164+ box (tile_x_range [0 ], tile_y_range [0 ], tile_x_range [1 ], tile_y_range [1 ]),
165+ predicate = "intersects" ,
166+ )
167+ )
168+ if not is_shapes :
169+ tile_partitions = [
170+ dask .delayed (_filter_points_for_tile , pure = True )(
171+ partition ,
172+ x_range = tile_x_range ,
173+ y_range = tile_y_range ,
174+ include_x_max = x_stop == plot_width ,
175+ include_y_max = y_stop == plot_height ,
176+ )
177+ for partition in partitions
178+ ]
179+ tile = dask .delayed (_rasterize_tile , pure = True )(
180+ tile_partitions ,
181+ is_shapes = is_shapes ,
182+ shape_positions = shape_positions ,
183+ plot_height = render_y_stop - render_y_start ,
184+ plot_width = render_x_stop - render_x_start ,
185+ x_range = tile_x_range ,
186+ y_range = tile_y_range ,
187+ agg_func = agg_func ,
188+ crop = tuple (crop ),
189+ empty_shape = tuple (render_shape ),
190+ empty_dtype = sample_aggregate .dtype ,
191+ empty_fill_value = empty_fill_value ,
192+ )
193+ shape = sample_shape .copy ()
194+ shape [y_axis ] = y_stop - y_start
195+ shape [x_axis ] = x_stop - x_start
196+ row .append (da .from_delayed (tile , shape = tuple (shape ), dtype = sample_aggregate .dtype ))
197+ rows .append (da .concatenate (row , axis = x_axis ))
198+ aggregate = da .concatenate (rows , axis = y_axis )
199+
200+ coords = {
201+ "y" : y_range [0 ] + (np .arange (plot_height ) + 0.5 ) * y_scale ,
202+ "x" : x_range [0 ] + (np .arange (plot_width ) + 0.5 ) * x_scale ,
203+ }
204+ for dim in sample_aggregate .dims :
205+ if dim not in coords :
206+ coords [dim ] = sample_aggregate .coords [dim ].values
207+ return DataArray (
208+ aggregate ,
209+ coords = coords ,
210+ dims = sample_aggregate .dims ,
211+ name = sample_aggregate .name ,
212+ attrs = sample_aggregate .attrs ,
213+ )
214+
215+
49216def _compute_target_dimensions (
50217 spatial_axes : tuple [str , ...],
51218 min_coordinate : list [Number ] | ArrayLike ,
@@ -167,9 +334,9 @@ def rasterize(
167334 value_key : str | None = None ,
168335 table_name : str | None = None ,
169336 return_regions_as_labels : bool = False ,
170- # extra arguments only for shapes and points
171337 agg_func : str | ds .reductions .Reduction | None = None ,
172338 return_single_channel : bool | None = None ,
339+ tile_size : int | None = None ,
173340) -> SpatialData | DataArray :
174341 """
175342 Rasterize a `SpatialData` object or a `SpatialElement` (image, labels, points, shapes).
@@ -230,6 +397,9 @@ def rasterize(
230397 return_single_channel
231398 Only used when rasterizing points and shapes and when `value_key` refers to a categorical column. If `False`,
232399 each category will be rasterized in a separate channel.
400+ tile_size
401+ Maximum size, in pixels, of each spatial output chunk. For points and shapes, this controls the Datashader
402+ canvas size. For images and labels, this controls the Dask output chunks.
233403
234404 Returns
235405 -------
@@ -276,6 +446,9 @@ def rasterize(
276446 - for shapes, each pixel gets a single index among the ones of the shapes that intersect it (the index of the
277447 shapes is interpreted as a categorical column and then the `first` function is used).
278448 """
449+ if tile_size is not None and (isinstance (tile_size , bool ) or not isinstance (tile_size , Integral ) or tile_size <= 0 ):
450+ raise ValueError ("tile_size must be a positive integer." )
451+
279452 if isinstance (data , SpatialData ):
280453 if sdata is not None :
281454 raise ValueError ("When data is a SpatialData object, sdata must be None." )
@@ -303,6 +476,7 @@ def rasterize(
303476 sdata = data ,
304477 return_regions_as_labels = return_regions_as_labels ,
305478 return_single_channel = return_single_channel if element_type in ("points" , "shapes" ) else None ,
479+ tile_size = tile_size ,
306480 )
307481 new_name = f"{ name } _rasterized_{ element_type } "
308482 model = get_model (rasterized )
@@ -331,6 +505,7 @@ def rasterize(
331505 target_width = target_width ,
332506 target_height = target_height ,
333507 target_depth = target_depth ,
508+ tile_size = tile_size ,
334509 )
335510 transformations = get_transformation (rasterized , get_all = True )
336511 assert isinstance (transformations , dict )
@@ -368,6 +543,7 @@ def rasterize(
368543 return_regions_as_labels = return_regions_as_labels ,
369544 agg_func = agg_func ,
370545 return_single_channel = return_single_channel ,
546+ tile_size = tile_size ,
371547 )
372548 raise ValueError (f"Unsupported model { model } ." )
373549
@@ -509,6 +685,7 @@ def rasterize_images_labels(
509685 target_width : float | None = None ,
510686 target_height : float | None = None ,
511687 target_depth : float | None = None ,
688+ tile_size : int | None = None ,
512689) -> DataArray :
513690 import dask_image .ndinterp
514691
@@ -580,6 +757,14 @@ def rasterize_images_labels(
580757 if f is not None :
581758 output_shape_ .append (int (f ))
582759 output_shape = tuple (output_shape_ )
760+ output_chunks = None
761+ if tile_size is not None :
762+ output_chunks = tuple (
763+ min (tile_size , output_shape [i ])
764+ if ax in spatial_axes
765+ else min (xdata .data .chunksize [xdata .get_axis_num (ax )], output_shape [i ])
766+ for i , ax in enumerate (dims )
767+ )
583768
584769 # get kwargs and schema
585770 schema = get_model (data )
@@ -596,6 +781,7 @@ def rasterize_images_labels(
596781 xdata .data ,
597782 matrix = matrix ,
598783 output_shape = output_shape ,
784+ output_chunks = output_chunks ,
599785 ** kwargs ,
600786 )
601787 assert isinstance (transformed_dask , DaskArray )
@@ -630,6 +816,7 @@ def rasterize_shapes_points(
630816 return_regions_as_labels : bool = False ,
631817 agg_func : str | ds .reductions .Reduction | None = None ,
632818 return_single_channel : bool | None = None ,
819+ tile_size : int | None = None ,
633820) -> DataArray :
634821 import datashader as ds
635822
@@ -653,9 +840,8 @@ def rasterize_shapes_points(
653840 data = data [columns ]
654841
655842 plot_width , plot_height = int (target_width ), int (target_height )
656- y_range = [min_coordinate [axes .index ("y" )], max_coordinate [axes .index ("y" )]]
657- x_range = [min_coordinate [axes .index ("x" )], max_coordinate [axes .index ("x" )]]
658-
843+ y_range = (min_coordinate [axes .index ("y" )], max_coordinate [axes .index ("y" )])
844+ x_range = (min_coordinate [axes .index ("x" )], max_coordinate [axes .index ("x" )])
659845 t = get_transformation (data , target_coordinate_system )
660846 if not isinstance (t , Identity ):
661847 data = transform (data , to_coordinate_system = target_coordinate_system )
@@ -701,13 +887,26 @@ def rasterize_shapes_points(
701887
702888 agg_func = getattr (ds , agg_func )(column = value_key )
703889
704- cnv = ds .Canvas (plot_height = plot_height , plot_width = plot_width , x_range = x_range , y_range = y_range )
705-
706- if isinstance (data , GeoDataFrame ):
890+ is_shapes = isinstance (data , GeoDataFrame )
891+ if is_shapes :
707892 data = to_polygons (data )
708- agg = cnv .polygons (data , "geometry" , agg = agg_func )
893+ if tile_size is None :
894+ cnv = ds .Canvas (plot_height = plot_height , plot_width = plot_width , x_range = x_range , y_range = y_range )
895+ if is_shapes :
896+ agg = cnv .polygons (data , "geometry" , agg = agg_func )
897+ else :
898+ agg = cnv .points (data , x = "x" , y = "y" , agg = agg_func )
709899 else :
710- agg = cnv .points (data , x = "x" , y = "y" , agg = agg_func )
900+ agg = _rasterize_tiled (
901+ data ,
902+ is_shapes = is_shapes ,
903+ plot_height = plot_height ,
904+ plot_width = plot_width ,
905+ x_range = x_range ,
906+ y_range = y_range ,
907+ agg_func = agg_func ,
908+ tile_size = int (tile_size ),
909+ )
711910
712911 if label_index_to_category is not None and isinstance (agg_func , ds .first ):
713912 agg .attrs ["label_index_to_category" ] = label_index_to_category
0 commit comments