Skip to content

Commit d35cd69

Browse files
authored
rasterize: honor like.y orientation for ascending-y templates (#2170) (#2181)
* rasterize: honor like.y orientation when burning into ascending-y templates (#2170) The rasterizer always burns row 0 = ymax (top-down image convention), but the output reused like.y verbatim. When like.y was ascending, result.sel(y=...) lined up against the wrong rows because the burned array still pointed the wrong way. Detect orientation once in _extract_grid_from_like, carry the flag through to the coord-assignment site, and flip the burned array on axis 0 right before assigning the template's coords. Works the same way for numpy, cupy, dask+numpy, and dask+cupy outputs. Closes #2170 * rasterize: address review findings on like.y orientation fix (#2170) - Convert _extract_grid_from_like to return a _LikeGrid NamedTuple instead of a 9-tuple. Positional unpacking was fragile. - Document the monotonicity assumption on the y_ascending detection and the same-class assumption for x being ascending. - Tighten test_numpy_explicit_bounds_skips_flip to lock the exact coord centres instead of only checking the order. Refs #2181.
1 parent 7843292 commit d35cd69

2 files changed

Lines changed: 239 additions & 13 deletions

File tree

xrspatial/rasterize.py

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from __future__ import annotations
1313

1414
import warnings
15-
from typing import Optional, Tuple, Union
15+
from typing import NamedTuple, Optional, Tuple, Union
1616

1717
import numpy as np
1818
import shapely
@@ -1934,11 +1934,11 @@ def _check_uniform_axis(axis_name, coords, expected_step):
19341934
catch, so they pass trivially.
19351935
19361936
The comparison is on ``abs(diff)`` so the validation does not care
1937-
whether the axis is ascending or descending -- a sibling change is
1938-
expected to allow ascending-y ``like`` inputs, and gating on the
1939-
sign here would block that work. ``np.allclose`` is used (rather
1940-
than strict equality) because affine-transform-derived coords drift
1941-
by a few ulps in practice.
1937+
whether the axis is ascending or descending -- ascending-y ``like``
1938+
inputs are supported by the orientation flip in ``rasterize``, and
1939+
gating on the sign here would block that. ``np.allclose`` is used
1940+
(rather than strict equality) because affine-transform-derived
1941+
coords drift by a few ulps in practice.
19421942
"""
19431943
if coords.size < 3:
19441944
return
@@ -1975,6 +1975,24 @@ def _check_uniform_axis(axis_name, coords, expected_step):
19751975
)
19761976

19771977

1978+
class _LikeGrid(NamedTuple):
1979+
"""Grid attributes extracted from a template DataArray.
1980+
1981+
Returned by :func:`_extract_grid_from_like`. Using a named tuple
1982+
instead of a bare tuple keeps the call site readable as more
1983+
template-derived attributes accrete over time.
1984+
"""
1985+
width: int
1986+
height: int
1987+
bounds: Tuple[float, float, float, float]
1988+
dtype: np.dtype
1989+
x_coord: xr.DataArray
1990+
y_coord: xr.DataArray
1991+
extra_coords: dict
1992+
attrs: dict
1993+
y_ascending: bool
1994+
1995+
19781996
def _extract_grid_from_like(like):
19791997
"""Extract width, height, bounds, dtype from a template DataArray.
19801998
@@ -2025,6 +2043,17 @@ def _extract_grid_from_like(like):
20252043
ymin = float(np.min(y)) - py / 2
20262044
ymax = float(np.max(y)) + py / 2
20272045

2046+
# Detect y-axis orientation. The rasterizer always burns with row 0
2047+
# at ymax (standard image convention), so if the template's y is
2048+
# ascending (low-to-high), the burned rows have to be flipped before
2049+
# we hand back the coords or downstream coord-aware ops line up
2050+
# against the wrong rows. Only the first and last samples are
2051+
# inspected; the ``_check_uniform_axis`` call above has already
2052+
# rejected non-monotonic or duplicate-valued y coords, so this is
2053+
# safe. The x-axis is assumed ascending; descending-x templates
2054+
# would hit the same bug class and are not supported here.
2055+
y_ascending = height > 1 and float(y[-1]) > float(y[0])
2056+
20282057
# Carry through any non-dim coords (e.g. rioxarray's ``spatial_ref``
20292058
# CRS coord). The y/x dim coords are returned separately because the
20302059
# caller decides whether to reuse them (bit-identical grid) or build
@@ -2033,9 +2062,17 @@ def _extract_grid_from_like(like):
20332062
k: v for k, v in like.coords.items() if k not in ('x', 'y')
20342063
}
20352064

2036-
return (width, height, (xmin, ymin, xmax, ymax), dt,
2037-
like.coords['x'], like.coords['y'],
2038-
extra_coords, dict(like.attrs))
2065+
return _LikeGrid(
2066+
width=width,
2067+
height=height,
2068+
bounds=(xmin, ymin, xmax, ymax),
2069+
dtype=dt,
2070+
x_coord=like.coords['x'],
2071+
y_coord=like.coords['y'],
2072+
extra_coords=extra_coords,
2073+
attrs=dict(like.attrs),
2074+
y_ascending=y_ascending,
2075+
)
20392076

20402077

20412078
# ---------------------------------------------------------------------------
@@ -2120,7 +2157,12 @@ def rasterize(
21202157
Must have uniformly spaced ``x`` and ``y`` dim coords -- the
21212158
rasterizer only writes to a regular grid, so a non-uniform
21222159
``like`` is rejected with ``ValueError`` rather than silently
2123-
producing pixel labels that don't match the data.
2160+
producing pixel labels that don't match the data. Both
2161+
descending (top-down, ymax first) and ascending (bottom-up,
2162+
ymin first) y coords are supported -- the burned rows are
2163+
flipped to match so ``result.sel(y=...)`` lines up with the
2164+
geometry in world coordinates either way, and ``result.y``
2165+
always equals ``like.y`` exactly.
21242166
merge : str or callable, default 'last'
21252167
How to combine values when geometries overlap.
21262168
@@ -2212,11 +2254,19 @@ def rasterize(
22122254
like_x_coord = like_y_coord = None
22132255
like_extra_coords = {}
22142256
like_attrs = None
2257+
like_y_ascending = False
22152258
bounds_explicit = bounds is not None
22162259
if like is not None:
2217-
(like_width, like_height, like_bounds, like_dtype,
2218-
like_x_coord, like_y_coord, like_extra_coords, like_attrs) = \
2219-
_extract_grid_from_like(like)
2260+
grid = _extract_grid_from_like(like)
2261+
like_width = grid.width
2262+
like_height = grid.height
2263+
like_bounds = grid.bounds
2264+
like_dtype = grid.dtype
2265+
like_x_coord = grid.x_coord
2266+
like_y_coord = grid.y_coord
2267+
like_extra_coords = grid.extra_coords
2268+
like_attrs = grid.attrs
2269+
like_y_ascending = grid.y_ascending
22202270

22212271
# Parse input geometries
22222272
geom_list, props_array, inferred_bounds = _parse_input(
@@ -2353,6 +2403,14 @@ def rasterize(
23532403
if reuse_like_coords:
23542404
x_coords = like_x_coord
23552405
y_coords = like_y_coord
2406+
# The rasterizer always burns with row 0 = ymax (top-down image
2407+
# convention). If the template's y axis is ascending, the rows
2408+
# have to be flipped along axis 0 before assigning the template's
2409+
# coords so world-y selection still lines up with the geometry.
2410+
# Works for numpy, cupy, dask+numpy, and dask+cupy alike -- they
2411+
# all expose the same slicing semantics on axis 0.
2412+
if like_y_ascending:
2413+
out = out[::-1, :]
23562414
else:
23572415
px = (xmax - xmin) / final_width
23582416
py = (ymax - ymin) / final_height

xrspatial/tests/test_rasterize.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2087,6 +2087,174 @@ def test_roundtrip_through_from_wkb(self):
20872087
assert original.equals(copy)
20882088

20892089

2090+
# ---------------------------------------------------------------------------
2091+
# Issue #2170 -- rasterize(like=...) y-axis orientation
2092+
#
2093+
# The rasterizer always burns with row 0 = ymax (top-down image
2094+
# convention). When the template's y axis is ascending, the burned
2095+
# array has to be flipped so result.sel(y=...) lines up with the
2096+
# geometry in world coordinates, and result.y still equals like.y.
2097+
# ---------------------------------------------------------------------------
2098+
2099+
2100+
def _like_2170(y_ascending, width=4, height=4):
2101+
"""Build a 4x4 like-grid with the requested y orientation."""
2102+
x = np.linspace(0.5, width - 0.5, width)
2103+
if y_ascending:
2104+
y = np.linspace(0.5, height - 0.5, height)
2105+
else:
2106+
y = np.linspace(height - 0.5, 0.5, height)
2107+
return xr.DataArray(
2108+
np.zeros((height, width), dtype=np.float64),
2109+
dims=['y', 'x'],
2110+
coords={'y': y, 'x': x},
2111+
)
2112+
2113+
2114+
class TestLikeYOrientation2170:
2115+
"""Burning into an ascending-y like must agree with descending-y by
2116+
world coordinate, and output.y must round-trip like.y exactly."""
2117+
2118+
def test_numpy_ascending_matches_descending_by_world_y(self):
2119+
# Box in lower-left corner of the world grid -- with descending y
2120+
# this lands in the bottom row, with ascending y it lands in the
2121+
# top row, but result.sel(y=0.5) must return 1.0 in both cases.
2122+
geom = [(box(0, 0, 1, 1), 1.0)]
2123+
r_desc = rasterize(geom, like=_like_2170(False), fill=0)
2124+
r_asc = rasterize(geom, like=_like_2170(True), fill=0)
2125+
2126+
# like.y is preserved verbatim
2127+
np.testing.assert_array_equal(r_desc.y.values, [3.5, 2.5, 1.5, 0.5])
2128+
np.testing.assert_array_equal(r_asc.y.values, [0.5, 1.5, 2.5, 3.5])
2129+
2130+
# World-coord selection agrees
2131+
for yw in [0.5, 1.5, 2.5, 3.5]:
2132+
for xw in [0.5, 1.5, 2.5, 3.5]:
2133+
a = float(r_desc.sel(y=yw, x=xw).item())
2134+
b = float(r_asc.sel(y=yw, x=xw).item())
2135+
assert a == b, f"mismatch at world (y={yw}, x={xw}): " \
2136+
f"desc={a}, asc={b}"
2137+
2138+
# The burned cell is at the lower-left corner of the world grid
2139+
assert float(r_desc.sel(y=0.5, x=0.5).item()) == 1.0
2140+
assert float(r_asc.sel(y=0.5, x=0.5).item()) == 1.0
2141+
# And nowhere near the top row
2142+
assert float(r_desc.sel(y=3.5, x=0.5).item()) == 0.0
2143+
assert float(r_asc.sel(y=3.5, x=0.5).item()) == 0.0
2144+
2145+
def test_numpy_output_array_matches_like_orientation(self):
2146+
"""Row 0 of the output must correspond to like.y[0] in world
2147+
coords, no matter which way y points."""
2148+
geom = [(box(0, 0, 1, 1), 1.0)]
2149+
r_desc = rasterize(geom, like=_like_2170(False), fill=0)
2150+
r_asc = rasterize(geom, like=_like_2170(True), fill=0)
2151+
# Descending: row 0 is the top row (y=3.5), so all zeros there.
2152+
# Last row is y=0.5 with the burned 1.
2153+
assert r_desc.values[-1, 0] == 1.0
2154+
assert r_desc.values[0, 0] == 0.0
2155+
# Ascending: row 0 is the bottom row (y=0.5), so the burned 1
2156+
# has to be there.
2157+
assert r_asc.values[0, 0] == 1.0
2158+
assert r_asc.values[-1, 0] == 0.0
2159+
2160+
def test_numpy_round_trip_with_xr_align(self):
2161+
"""output.y must equal like.y exactly so xr.align still works."""
2162+
geom = [(box(0, 0, 1, 1), 1.0)]
2163+
for ascending in (True, False):
2164+
like = _like_2170(ascending)
2165+
result = rasterize(geom, like=like, fill=0)
2166+
np.testing.assert_array_equal(result.y.values, like.y.values)
2167+
np.testing.assert_array_equal(result.x.values, like.x.values)
2168+
# xr.align is the actual downstream operation this protects
2169+
aligned_result, aligned_like = xr.align(result, like)
2170+
assert aligned_result.sizes == result.sizes
2171+
2172+
def test_numpy_points_respect_orientation(self):
2173+
"""Same check with a point geometry rather than a polygon."""
2174+
from shapely.geometry import Point
2175+
geom = [(Point(0.5, 0.5), 7.0)]
2176+
r_desc = rasterize(geom, like=_like_2170(False), fill=0)
2177+
r_asc = rasterize(geom, like=_like_2170(True), fill=0)
2178+
assert float(r_desc.sel(y=0.5, x=0.5).item()) == 7.0
2179+
assert float(r_asc.sel(y=0.5, x=0.5).item()) == 7.0
2180+
2181+
def test_numpy_lines_respect_orientation(self):
2182+
"""Same check with a line geometry along the bottom edge."""
2183+
from shapely.geometry import LineString
2184+
geom = [(LineString([(0.5, 0.5), (3.5, 0.5)]), 5.0)]
2185+
r_desc = rasterize(geom, like=_like_2170(False), fill=0)
2186+
r_asc = rasterize(geom, like=_like_2170(True), fill=0)
2187+
for xw in [0.5, 1.5, 2.5, 3.5]:
2188+
assert float(r_desc.sel(y=0.5, x=xw).item()) == 5.0
2189+
assert float(r_asc.sel(y=0.5, x=xw).item()) == 5.0
2190+
2191+
@skip_no_dask
2192+
def test_dask_numpy_ascending_matches_descending(self):
2193+
geom = [(box(0, 0, 1, 1), 1.0)]
2194+
r_desc = rasterize(
2195+
geom, like=_like_2170(False), fill=0, chunks=2).compute()
2196+
r_asc = rasterize(
2197+
geom, like=_like_2170(True), fill=0, chunks=2).compute()
2198+
np.testing.assert_array_equal(r_desc.y.values, [3.5, 2.5, 1.5, 0.5])
2199+
np.testing.assert_array_equal(r_asc.y.values, [0.5, 1.5, 2.5, 3.5])
2200+
for yw in [0.5, 1.5, 2.5, 3.5]:
2201+
a = float(r_desc.sel(y=yw, x=0.5).item())
2202+
b = float(r_asc.sel(y=yw, x=0.5).item())
2203+
assert a == b
2204+
assert float(r_desc.sel(y=0.5, x=0.5).item()) == 1.0
2205+
assert float(r_asc.sel(y=0.5, x=0.5).item()) == 1.0
2206+
2207+
@skip_no_cuda
2208+
def test_cupy_ascending_matches_descending(self):
2209+
geom = [(box(0, 0, 1, 1), 1.0)]
2210+
r_desc = rasterize(geom, like=_like_2170(False), fill=0, use_cuda=True)
2211+
r_asc = rasterize(geom, like=_like_2170(True), fill=0, use_cuda=True)
2212+
# CuPy DataArrays expose .data.get() per project notes
2213+
desc_vals = r_desc.data.get() if hasattr(r_desc.data, 'get') \
2214+
else r_desc.values
2215+
asc_vals = r_asc.data.get() if hasattr(r_asc.data, 'get') \
2216+
else r_asc.values
2217+
np.testing.assert_array_equal(r_desc.y.values, [3.5, 2.5, 1.5, 0.5])
2218+
np.testing.assert_array_equal(r_asc.y.values, [0.5, 1.5, 2.5, 3.5])
2219+
# descending: burned row is last; ascending: burned row is first
2220+
assert desc_vals[-1, 0] == 1.0
2221+
assert asc_vals[0, 0] == 1.0
2222+
2223+
@skip_no_cuda
2224+
@skip_no_dask
2225+
def test_dask_cupy_ascending_matches_descending(self):
2226+
geom = [(box(0, 0, 1, 1), 1.0)]
2227+
r_desc = rasterize(
2228+
geom, like=_like_2170(False), fill=0,
2229+
use_cuda=True, chunks=2).compute()
2230+
r_asc = rasterize(
2231+
geom, like=_like_2170(True), fill=0,
2232+
use_cuda=True, chunks=2).compute()
2233+
desc_vals = r_desc.data.get() if hasattr(r_desc.data, 'get') \
2234+
else r_desc.values
2235+
asc_vals = r_asc.data.get() if hasattr(r_asc.data, 'get') \
2236+
else r_asc.values
2237+
assert desc_vals[-1, 0] == 1.0
2238+
assert asc_vals[0, 0] == 1.0
2239+
2240+
def test_numpy_explicit_bounds_skips_flip(self):
2241+
"""When bounds are passed explicitly, the orientation flip path
2242+
is bypassed (caller has full control of the output grid)."""
2243+
like = _like_2170(True)
2244+
geom = [(box(0, 0, 1, 1), 1.0)]
2245+
result = rasterize(geom, like=like, bounds=(0, 0, 4, 4), fill=0)
2246+
# With explicit bounds, output coords are rebuilt descending,
2247+
# which is the documented behaviour for any resized output.
2248+
# Lock the exact coord centres so an off-by-one in the rebuild
2249+
# path would surface here instead of silently passing.
2250+
np.testing.assert_array_equal(
2251+
result.y.values, np.array([3.5, 2.5, 1.5, 0.5]))
2252+
np.testing.assert_array_equal(
2253+
result.x.values, np.array([0.5, 1.5, 2.5, 3.5]))
2254+
# And world-coord selection still works correctly.
2255+
assert float(result.sel(y=0.5, x=0.5, method='nearest').item()) == 1.0
2256+
2257+
20902258
# ---------------------------------------------------------------------------
20912259
# Issue #2168: reject non-uniformly spaced `like` grids
20922260
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)