Skip to content

Commit 6d80ac6

Browse files
committed
feat: add opt-in rasterization tiles
1 parent 72250aa commit 6d80ac6

2 files changed

Lines changed: 286 additions & 10 deletions

File tree

src/spatialdata/_core/operations/rasterize.py

Lines changed: 209 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
11
from __future__ import annotations
22

3+
from numbers import Integral
34
from typing import TYPE_CHECKING
45

56
import numpy as np
67
from dask.array import Array as DaskArray
78
from dask.dataframe import DataFrame as DaskDataFrame
89
from geopandas import GeoDataFrame
9-
from shapely import Point
10+
from shapely import Point, box
1011
from xarray import DataArray, DataTree
1112

1213
if TYPE_CHECKING:
1314
import datashader as ds
15+
import pandas as pd
1416

1517
from spatialdata._core.operations._utils import _parse_element
1618
from spatialdata._core.operations.transform import transform
@@ -46,6 +48,171 @@
4648
VALUES_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+
49216
def _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

Comments
 (0)