Skip to content

Commit dc30c4c

Browse files
committed
Fix get_area_slices under-crop for cropped geostationary cross-CRS areas
1 parent d0cecf5 commit dc30c4c

3 files changed

Lines changed: 422 additions & 25 deletions

File tree

pyresample/future/geometry/_subset.py

Lines changed: 192 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,60 @@
55
from typing import TYPE_CHECKING, Any
66

77
import numpy as np
8+
from pyproj import Proj
89

910
# this caching module imports the geometries so this subset module
1011
# must be imported inside functions in the geometry modules if needed
1112
# to avoid circular dependencies
1213
from pyresample._caching import cache_to_json_if
1314
from pyresample.boundary import Boundary
14-
from pyresample.geometry import get_geostationary_bounding_box_in_lonlats, logger
15+
from pyresample.geometry import (
16+
DEFAULT_AREA_SLICE_SAMPLE_STEPS,
17+
get_geostationary_bounding_box_in_lonlats,
18+
logger,
19+
)
1520
from pyresample.utils import check_slice_orientation
1621

1722
if TYPE_CHECKING:
1823
from pyresample import AreaDefinition
1924

2025

26+
MAX_POINTS_PER_CHUNK = 600_000
27+
28+
2129
@cache_to_json_if("cache_geometry_slices")
2230
def get_area_slices(
2331
src_area: AreaDefinition,
2432
area_to_cover: AreaDefinition,
2533
shape_divisible_by: int | None,
34+
sample_steps: int | None = DEFAULT_AREA_SLICE_SAMPLE_STEPS,
35+
sample_grid: bool = False,
2636
) -> tuple[slice, slice]:
27-
"""Compute the slice to read based on an `area_to_cover`."""
37+
"""Compute the slice to read based on an `area_to_cover`.
38+
39+
For geostationary source areas in cross-projection mode:
40+
- ``sample_steps`` >= 2 and ``sample_grid=False`` samples edge points only.
41+
- ``sample_steps`` >= 2 and ``sample_grid=True`` samples an interior grid.
42+
- ``sample_steps`` <= 0 or ``None`` samples all destination points.
43+
- ``sample_steps`` == 1 raises ``ValueError``.
44+
"""
2845
if not _is_area_like(src_area):
2946
raise NotImplementedError(f"Only AreaDefinitions are supported, not {type(src_area)}")
3047
if not _is_area_like(area_to_cover):
3148
raise NotImplementedError(f"Only AreaDefinitions are supported, not {type(area_to_cover)}")
3249

33-
# Intersection only required for two different projections
34-
proj_def_to_cover = area_to_cover.crs
35-
proj_def = src_area.crs
36-
if proj_def_to_cover == proj_def:
50+
normalized_sample_steps = _normalize_sample_steps(sample_steps)
51+
52+
# Intersection is only required for two different projections.
53+
src_crs_wkt = getattr(src_area, "crs_wkt", None)
54+
dst_crs_wkt = getattr(area_to_cover, "crs_wkt", None)
55+
if src_crs_wkt is not None and src_crs_wkt == dst_crs_wkt:
56+
proj_def_to_cover = src_crs_wkt
57+
else:
58+
proj_def_to_cover = area_to_cover.crs
59+
if proj_def_to_cover != src_area.crs:
60+
proj_def_to_cover = None
61+
if proj_def_to_cover is not None:
3762
logger.debug('Projections for data and slice areas are identical: %s',
3863
proj_def_to_cover)
3964
# Get slice parameters
@@ -45,6 +70,16 @@ def get_area_slices(
4570
y_slice = _ensure_integer_slice(y_slice)
4671
return x_slice, y_slice
4772

73+
if src_area.is_geostationary:
74+
coverage_slices = _get_covered_source_slices(
75+
src_area,
76+
area_to_cover,
77+
sample_steps=normalized_sample_steps,
78+
sample_grid=sample_grid,
79+
)
80+
if coverage_slices is not None:
81+
return _finalize_slices(src_area, coverage_slices[0], coverage_slices[1], shape_divisible_by)
82+
4883
data_boundary = _get_area_boundary(src_area)
4984
area_boundary = _get_area_boundary(area_to_cover)
5085
intersection = data_boundary.contour_poly.intersection(
@@ -57,6 +92,32 @@ def get_area_slices(
5792
np.rad2deg(intersection.lon), np.rad2deg(intersection.lat))
5893
x_slice = slice(np.ma.min(x), np.ma.max(x) + 1)
5994
y_slice = slice(np.ma.min(y), np.ma.max(y) + 1)
95+
96+
return _finalize_slices(src_area, x_slice, y_slice, shape_divisible_by)
97+
98+
99+
def _normalize_sample_steps(sample_steps: int | None):
100+
"""Normalize sampling config to sampled mode or dense fallback.
101+
102+
``None`` and values ``<= 0`` map to dense destination sampling.
103+
A value of ``1`` is rejected because it does not provide meaningful sampled
104+
coverage for edge or grid modes.
105+
"""
106+
if sample_steps is None:
107+
return None
108+
try:
109+
normalized_sample_steps = int(sample_steps)
110+
except (TypeError, ValueError) as err:
111+
raise ValueError(f"sample_steps must be an integer or None, got {sample_steps!r}") from err
112+
if normalized_sample_steps <= 0:
113+
return None
114+
if normalized_sample_steps == 1:
115+
raise ValueError("sample_steps=1 is not supported; use <= 0/None for dense or >= 2 for sampled modes.")
116+
return normalized_sample_steps
117+
118+
119+
def _finalize_slices(src_area: AreaDefinition, x_slice: slice, y_slice: slice, shape_divisible_by: int | None):
120+
"""Normalize slice bounds and apply orientation/divisibility rules."""
60121
x_slice = _ensure_integer_slice(x_slice)
61122
y_slice = _ensure_integer_slice(y_slice)
62123
if shape_divisible_by is not None:
@@ -105,6 +166,130 @@ def _get_area_boundary(area_to_cover: AreaDefinition) -> Boundary:
105166
raise NotImplementedError("Can't determine boundary of area to cover") from err
106167

107168

169+
def _get_covered_source_slices(
170+
src_area: AreaDefinition,
171+
area_to_cover: AreaDefinition,
172+
sample_steps: int | None,
173+
sample_grid: bool,
174+
):
175+
"""Estimate covering source slices from sampled destination points.
176+
177+
Returns ``None`` when sampled points do not produce any valid source
178+
coverage, allowing the caller to fall back to boundary intersection.
179+
"""
180+
min_col = None
181+
max_col = None
182+
min_row = None
183+
max_row = None
184+
try:
185+
src_proj = Proj(src_area.crs)
186+
for destination_lons, destination_lats in _iter_destination_lonlat_samples(
187+
area_to_cover=area_to_cover,
188+
sample_steps=sample_steps,
189+
sample_grid=sample_grid,
190+
):
191+
source_xs, source_ys = src_proj(
192+
destination_lons,
193+
destination_lats,
194+
)
195+
source_cols, source_rows = src_area.get_array_indices_from_projection_coordinates(
196+
source_xs,
197+
source_ys,
198+
)
199+
valid = ~np.ma.getmaskarray(source_cols) & ~np.ma.getmaskarray(source_rows)
200+
if not valid.any():
201+
continue
202+
chunk_cols = np.ma.getdata(source_cols)[valid]
203+
chunk_rows = np.ma.getdata(source_rows)[valid]
204+
chunk_min_col = int(chunk_cols.min())
205+
chunk_max_col = int(chunk_cols.max())
206+
chunk_min_row = int(chunk_rows.min())
207+
chunk_max_row = int(chunk_rows.max())
208+
min_col = chunk_min_col if min_col is None else min(min_col, chunk_min_col)
209+
max_col = chunk_max_col if max_col is None else max(max_col, chunk_max_col)
210+
min_row = chunk_min_row if min_row is None else min(min_row, chunk_min_row)
211+
max_row = chunk_max_row if max_row is None else max(max_row, chunk_max_row)
212+
except (RuntimeError, TypeError, ValueError):
213+
logger.debug("Failed to estimate covered source slices from sampled destination points.", exc_info=True)
214+
return None
215+
if min_col is None or min_row is None or max_col is None or max_row is None:
216+
return None
217+
col_start = max(0, min_col)
218+
col_stop = min(src_area.width, max_col + 1)
219+
row_start = max(0, min_row)
220+
row_stop = min(src_area.height, max_row + 1)
221+
if col_start >= col_stop or row_start >= row_stop:
222+
return None
223+
return slice(col_start, col_stop), slice(row_start, row_stop)
224+
225+
226+
def _iter_destination_lonlat_samples(
227+
area_to_cover: AreaDefinition,
228+
sample_steps: int | None,
229+
sample_grid: bool,
230+
):
231+
"""Yield destination lon/lat samples for dense, grid, or edge mode."""
232+
if sample_steps is None:
233+
yield from _iter_dense_lonlat_samples(area_to_cover)
234+
return
235+
if sample_grid:
236+
yield _get_grid_lonlat_samples(area_to_cover, sample_steps)
237+
return
238+
yield _get_edge_lonlat_samples(area_to_cover, sample_steps)
239+
240+
241+
def _iter_dense_lonlat_samples(area_to_cover: AreaDefinition):
242+
"""Yield full destination lon/lat coverage in row chunks."""
243+
row_block_size = max(1, MAX_POINTS_PER_CHUNK // area_to_cover.width)
244+
for row_start in range(0, area_to_cover.height, row_block_size):
245+
row_stop = min(area_to_cover.height, row_start + row_block_size)
246+
yield area_to_cover.get_lonlats(
247+
data_slice=(slice(row_start, row_stop), slice(None)),
248+
dtype=np.float32,
249+
)
250+
251+
252+
def _get_grid_lonlat_samples(area_to_cover: AreaDefinition, sample_steps: int):
253+
"""Return one evenly spaced interior destination sample grid."""
254+
sample_rows = _get_sample_indices(area_to_cover.height, sample_steps)
255+
sample_cols = _get_sample_indices(area_to_cover.width, sample_steps)
256+
return area_to_cover.get_lonlats(
257+
data_slice=(sample_rows[:, None], sample_cols[None, :]),
258+
dtype=np.float32,
259+
)
260+
261+
262+
def _get_edge_lonlat_samples(area_to_cover: AreaDefinition, sample_steps: int):
263+
"""Return perimeter destination samples for edge-only sampling mode."""
264+
if area_to_cover.is_geostationary:
265+
# Use limb-aware geostationary boundary sampling instead of raw array
266+
# corners/edges. Projection corners may be off-earth and can under-cover.
267+
return get_geostationary_bounding_box_in_lonlats(
268+
area_to_cover,
269+
nb_points=max(4, sample_steps * 4),
270+
)
271+
sample_rows = _get_sample_indices(area_to_cover.height, sample_steps)
272+
sample_cols = _get_sample_indices(area_to_cover.width, sample_steps)
273+
top_rows = np.zeros(sample_cols.size, dtype=np.int64)
274+
top_cols = sample_cols
275+
right_rows = sample_rows[1:]
276+
right_cols = np.full(right_rows.size, area_to_cover.width - 1, dtype=np.int64)
277+
bottom_cols = sample_cols[-2::-1]
278+
bottom_rows = np.full(bottom_cols.size, area_to_cover.height - 1, dtype=np.int64)
279+
left_rows = sample_rows[-2:0:-1]
280+
left_cols = np.zeros(left_rows.size, dtype=np.int64)
281+
edge_rows = np.concatenate((top_rows, right_rows, bottom_rows, left_rows))
282+
edge_cols = np.concatenate((top_cols, right_cols, bottom_cols, left_cols))
283+
return area_to_cover.get_lonlats(data_slice=(edge_rows, edge_cols), dtype=np.float32)
284+
285+
286+
def _get_sample_indices(axis_size: int, sample_steps: int):
287+
"""Return evenly spaced integer sample indices including both endpoints."""
288+
if sample_steps >= axis_size:
289+
return np.arange(axis_size, dtype=np.int64)
290+
return (np.arange(sample_steps, dtype=np.int64) * (axis_size - 1)) // (sample_steps - 1)
291+
292+
108293
def _make_slice_divisible(sli, max_size, factor=2):
109294
"""Make the given slice even in size."""
110295
rem = (sli.stop - sli.start) % factor
@@ -121,6 +306,7 @@ def _make_slice_divisible(sli, max_size, factor=2):
121306

122307

123308
def _ensure_integer_slice(sli):
309+
"""Round slice bounds outward to conservatively preserve target coverage."""
124310
start = sli.start
125311
stop = sli.stop
126312
step = sli.step

pyresample/geometry.py

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@
6868

6969
logger = getLogger(__name__)
7070

71+
DEFAULT_AREA_SLICE_SAMPLE_STEPS = 21
72+
7173

7274
class DimensionError(ValueError):
7375
"""Wrap ValueError."""
@@ -619,7 +621,13 @@ def overlap_rate(self, other):
619621
inter_area = get_polygon_area(self.intersection(other))
620622
return inter_area / other_area
621623

622-
def get_area_slices(self, area_to_cover):
624+
def get_area_slices(
625+
self,
626+
area_to_cover,
627+
shape_divisible_by=None,
628+
sample_steps=DEFAULT_AREA_SLICE_SAMPLE_STEPS,
629+
sample_grid=False,
630+
):
623631
"""Compute the slice to read based on an `area_to_cover`."""
624632
raise NotImplementedError
625633

@@ -2651,10 +2659,31 @@ def proj4_string(self):
26512659
"instead.", DeprecationWarning, stacklevel=2)
26522660
return proj4_dict_to_str(self.proj_dict)
26532661

2654-
def get_area_slices(self, area_to_cover, shape_divisible_by=None):
2655-
"""Compute the slice to read based on an `area_to_cover`."""
2662+
def get_area_slices(
2663+
self,
2664+
area_to_cover,
2665+
shape_divisible_by=None,
2666+
sample_steps=DEFAULT_AREA_SLICE_SAMPLE_STEPS,
2667+
sample_grid=False,
2668+
):
2669+
"""Compute the slice to read based on an `area_to_cover`.
2670+
2671+
Args:
2672+
area_to_cover: Destination area to crop around.
2673+
shape_divisible_by: Optional factor to make the resulting shape divisible by.
2674+
sample_steps: Number of edge/grid samples used for geostationary cross-projection coverage checks.
2675+
Use ``None`` or ``<= 0`` to sample all destination pixels.
2676+
sample_grid: If ``True``, sample an interior grid with ``sample_steps`` density.
2677+
If ``False``, sample edge points only.
2678+
"""
26562679
from .future.geometry._subset import get_area_slices
2657-
return get_area_slices(self, area_to_cover, shape_divisible_by)
2680+
return get_area_slices(
2681+
self,
2682+
area_to_cover,
2683+
shape_divisible_by,
2684+
sample_steps,
2685+
sample_grid,
2686+
)
26582687

26592688
def crop_around(self, other_area):
26602689
"""Crop this area around `other_area`."""

0 commit comments

Comments
 (0)