Skip to content

Commit 7c0cc75

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

3 files changed

Lines changed: 477 additions & 24 deletions

File tree

pyresample/future/geometry/_subset.py

Lines changed: 201 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,35 +5,61 @@
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`` and ``sample_grid=False`` samples all edge points.
43+
- ``sample_steps`` <= 0 or ``None`` and ``sample_grid=True`` samples all destination points.
44+
- ``sample_steps`` == 1 raises ``ValueError``.
45+
"""
2846
if not _is_area_like(src_area):
2947
raise NotImplementedError(f"Only AreaDefinitions are supported, not {type(src_area)}")
3048
if not _is_area_like(area_to_cover):
3149
raise NotImplementedError(f"Only AreaDefinitions are supported, not {type(area_to_cover)}")
3250

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:
51+
normalized_sample_steps = _normalize_sample_steps(sample_steps)
52+
53+
# Intersection is only required for two different projections.
54+
src_crs_wkt = getattr(src_area, "crs_wkt", None)
55+
dst_crs_wkt = getattr(area_to_cover, "crs_wkt", None)
56+
if src_crs_wkt is not None and src_crs_wkt == dst_crs_wkt:
57+
proj_def_to_cover = src_crs_wkt
58+
else:
59+
proj_def_to_cover = area_to_cover.crs
60+
if proj_def_to_cover != src_area.crs:
61+
proj_def_to_cover = None
62+
if proj_def_to_cover is not None:
3763
logger.debug('Projections for data and slice areas are identical: %s',
3864
proj_def_to_cover)
3965
# Get slice parameters
@@ -45,6 +71,16 @@ def get_area_slices(
4571
y_slice = _ensure_integer_slice(y_slice)
4672
return x_slice, y_slice
4773

74+
if src_area.is_geostationary:
75+
coverage_slices = _get_covered_source_slices(
76+
src_area,
77+
area_to_cover,
78+
sample_steps=normalized_sample_steps,
79+
sample_grid=sample_grid,
80+
)
81+
if coverage_slices is not None:
82+
return _finalize_slices(src_area, coverage_slices[0], coverage_slices[1], shape_divisible_by)
83+
4884
data_boundary = _get_area_boundary(src_area)
4985
area_boundary = _get_area_boundary(area_to_cover)
5086
intersection = data_boundary.contour_poly.intersection(
@@ -57,6 +93,33 @@ def get_area_slices(
5793
np.rad2deg(intersection.lon), np.rad2deg(intersection.lat))
5894
x_slice = slice(np.ma.min(x), np.ma.max(x) + 1)
5995
y_slice = slice(np.ma.min(y), np.ma.max(y) + 1)
96+
97+
return _finalize_slices(src_area, x_slice, y_slice, shape_divisible_by)
98+
99+
100+
def _normalize_sample_steps(sample_steps: int | None):
101+
"""Normalize sampling config to sampled mode or ALL-mode fallback.
102+
103+
``None`` and values ``<= 0`` map to ``ALL`` mode, whose exact behavior is
104+
determined later by ``sample_grid``.
105+
A value of ``1`` is rejected because it does not provide meaningful sampled
106+
coverage for edge or grid modes.
107+
"""
108+
if sample_steps is None:
109+
return None
110+
try:
111+
normalized_sample_steps = int(sample_steps)
112+
except (TypeError, ValueError) as err:
113+
raise ValueError(f"sample_steps must be an integer or None, got {sample_steps!r}") from err
114+
if normalized_sample_steps <= 0:
115+
return None
116+
if normalized_sample_steps == 1:
117+
raise ValueError("sample_steps=1 is not supported; use <= 0/None for ALL mode or >= 2 for sampled modes.")
118+
return normalized_sample_steps
119+
120+
121+
def _finalize_slices(src_area: AreaDefinition, x_slice: slice, y_slice: slice, shape_divisible_by: int | None):
122+
"""Normalize slice bounds and apply orientation/divisibility rules."""
60123
x_slice = _ensure_integer_slice(x_slice)
61124
y_slice = _ensure_integer_slice(y_slice)
62125
if shape_divisible_by is not None:
@@ -105,6 +168,137 @@ def _get_area_boundary(area_to_cover: AreaDefinition) -> Boundary:
105168
raise NotImplementedError("Can't determine boundary of area to cover") from err
106169

107170

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

122316

123317
def _ensure_integer_slice(sli):
318+
"""Round slice bounds outward to conservatively preserve target coverage."""
124319
start = sli.start
125320
stop = sli.stop
126321
step = sli.step

pyresample/geometry.py

Lines changed: 34 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,32 @@ 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`` for ``ALL`` mode: all edge points when ``sample_grid=False``
2676+
and all destination pixels when ``sample_grid=True``.
2677+
sample_grid: If ``True``, sample an interior grid with ``sample_steps`` density.
2678+
If ``False``, sample edge points only.
2679+
"""
26562680
from .future.geometry._subset import get_area_slices
2657-
return get_area_slices(self, area_to_cover, shape_divisible_by)
2681+
return get_area_slices(
2682+
self,
2683+
area_to_cover,
2684+
shape_divisible_by,
2685+
sample_steps,
2686+
sample_grid,
2687+
)
26582688

26592689
def crop_around(self, other_area):
26602690
"""Crop this area around `other_area`."""

0 commit comments

Comments
 (0)