Skip to content

Commit aaf8367

Browse files
committed
added a few more classify methods and experimental geodesic slope and aspect
1 parent 62919a0 commit aaf8367

File tree

7 files changed

+1299
-84
lines changed

7 files changed

+1299
-84
lines changed

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,10 +137,15 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
137137

138138
| Name | NumPy xr.DataArray | Dask xr.DataArray | CuPy GPU xr.DataArray | Dask GPU xr.DataArray |
139139
|:----------:|:----------------------:|:--------------------:|:-------------------:|:------:|
140+
| [Box Plot](xrspatial/classify.py) |✅️ ||||
140141
| [Equal Interval](xrspatial/classify.py) |✅️ ||||
142+
| [Head/Tail Breaks](xrspatial/classify.py) |✅️ ||||
143+
| [Maximum Breaks](xrspatial/classify.py) |✅️ ||||
141144
| [Natural Breaks](xrspatial/classify.py) |✅️ ||||
142-
| [Reclassify](xrspatial/classify.py) |✅️ ||||
145+
| [Percentiles](xrspatial/classify.py) |✅️ ||||
143146
| [Quantile](xrspatial/classify.py) |✅️ ||||
147+
| [Reclassify](xrspatial/classify.py) |✅️ ||||
148+
| [Std Mean](xrspatial/classify.py) |✅️ ||||
144149

145150
-------
146151

xrspatial/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
from xrspatial.aspect import aspect # noqa
22
from xrspatial.bump import bump # noqa
33
from xrspatial.classify import binary # noqa
4+
from xrspatial.classify import box_plot # noqa
5+
from xrspatial.classify import head_tail_breaks # noqa
6+
from xrspatial.classify import maximum_breaks # noqa
7+
from xrspatial.classify import percentiles # noqa
8+
from xrspatial.classify import std_mean # noqa
49
from xrspatial.diagnostics import diagnose # noqa
510
from xrspatial.classify import equal_interval # noqa
611
from xrspatial.classify import natural_breaks # noqa

xrspatial/aspect.py

Lines changed: 173 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,29 @@
1515
from numba import cuda
1616

1717
from xrspatial.utils import ArrayTypeFunctionMapping
18+
from xrspatial.utils import Z_UNITS
19+
from xrspatial.utils import _extract_latlon_coords
1820
from xrspatial.utils import cuda_args
1921
from xrspatial.utils import ngjit
2022

23+
24+
def _geodesic_cuda_dims(shape):
25+
"""Smaller thread block for register-heavy geodesic kernels."""
26+
tpb = (16, 16)
27+
bpg = (
28+
(shape[0] + tpb[0] - 1) // tpb[0],
29+
(shape[1] + tpb[1] - 1) // tpb[1],
30+
)
31+
return bpg, tpb
32+
33+
from xrspatial.geodesic import (
34+
INV_2R,
35+
WGS84_A2,
36+
WGS84_B2,
37+
_cpu_geodesic_aspect,
38+
_run_gpu_geodesic_aspect,
39+
)
40+
2141
# 3rd-party
2242
try:
2343
import cupy
@@ -28,6 +48,10 @@ class cupy(object):
2848
RADIAN = 180 / np.pi
2949

3050

51+
# =====================================================================
52+
# Planar backend functions (unchanged)
53+
# =====================================================================
54+
3155
@ngjit
3256
def _run_numpy(data: np.ndarray):
3357
data = data.astype(np.float32)
@@ -140,8 +164,116 @@ def _run_dask_cupy(data: da.Array) -> da.Array:
140164
return out
141165

142166

167+
# =====================================================================
168+
# Geodesic backend functions
169+
# =====================================================================
170+
171+
def _run_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
172+
stacked = np.stack([
173+
data.astype(np.float64),
174+
lat_2d,
175+
lon_2d,
176+
], axis=0)
177+
return _cpu_geodesic_aspect(stacked, a2, b2, z_factor)
178+
179+
180+
def _run_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
181+
lat_2d_gpu = cupy.asarray(lat_2d, dtype=cupy.float64)
182+
lon_2d_gpu = cupy.asarray(lon_2d, dtype=cupy.float64)
183+
stacked = cupy.stack([
184+
data.astype(cupy.float64),
185+
lat_2d_gpu,
186+
lon_2d_gpu,
187+
], axis=0)
188+
189+
H, W = data.shape
190+
out = cupy.full((H, W), cupy.nan, dtype=cupy.float32)
191+
192+
a2_arr = cupy.array([a2], dtype=cupy.float64)
193+
b2_arr = cupy.array([b2], dtype=cupy.float64)
194+
zf_arr = cupy.array([z_factor], dtype=cupy.float64)
195+
inv_2r_arr = cupy.array([INV_2R], dtype=cupy.float64)
196+
197+
griddim, blockdim = _geodesic_cuda_dims((H, W))
198+
_run_gpu_geodesic_aspect[griddim, blockdim](stacked, a2_arr, b2_arr, zf_arr, inv_2r_arr, out)
199+
return out
200+
201+
202+
def _dask_geodesic_aspect_chunk(stacked_chunk, a2, b2, z_factor):
203+
"""Returns (3, h, w) to preserve shape for map_overlap."""
204+
result_2d = _cpu_geodesic_aspect(stacked_chunk, a2, b2, z_factor)
205+
out = np.empty_like(stacked_chunk, dtype=np.float32)
206+
out[0] = result_2d
207+
out[1] = 0.0
208+
out[2] = 0.0
209+
return out
210+
211+
212+
def _dask_geodesic_aspect_chunk_cupy(stacked_chunk, a2, b2, z_factor):
213+
H, W = stacked_chunk.shape[1], stacked_chunk.shape[2]
214+
result_2d = cupy.full((H, W), cupy.nan, dtype=cupy.float32)
215+
216+
a2_arr = cupy.array([a2], dtype=cupy.float64)
217+
b2_arr = cupy.array([b2], dtype=cupy.float64)
218+
zf_arr = cupy.array([z_factor], dtype=cupy.float64)
219+
inv_2r_arr = cupy.array([INV_2R], dtype=cupy.float64)
220+
221+
griddim, blockdim = _geodesic_cuda_dims((H, W))
222+
_run_gpu_geodesic_aspect[griddim, blockdim](stacked_chunk, a2_arr, b2_arr, zf_arr, inv_2r_arr, result_2d)
223+
224+
out = cupy.zeros_like(stacked_chunk, dtype=cupy.float32)
225+
out[0] = result_2d
226+
return out
227+
228+
229+
def _run_dask_numpy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
230+
lat_dask = da.from_array(lat_2d, chunks=data.chunksize)
231+
lon_dask = da.from_array(lon_2d, chunks=data.chunksize)
232+
stacked = da.stack([
233+
data.astype(np.float64),
234+
lat_dask,
235+
lon_dask,
236+
], axis=0).rechunk({0: 3})
237+
238+
_func = partial(_dask_geodesic_aspect_chunk, a2=a2, b2=b2, z_factor=z_factor)
239+
out = stacked.map_overlap(
240+
_func,
241+
depth=(0, 1, 1),
242+
boundary=np.nan,
243+
meta=np.array((), dtype=np.float32),
244+
)
245+
return out[0]
246+
247+
248+
def _run_dask_cupy_geodesic(data, lat_2d, lon_2d, a2, b2, z_factor):
249+
lat_dask = da.from_array(cupy.asarray(lat_2d, dtype=cupy.float64),
250+
chunks=data.chunksize)
251+
lon_dask = da.from_array(cupy.asarray(lon_2d, dtype=cupy.float64),
252+
chunks=data.chunksize)
253+
stacked = da.stack([
254+
data.astype(cupy.float64),
255+
lat_dask,
256+
lon_dask,
257+
], axis=0).rechunk({0: 3})
258+
259+
_func = partial(_dask_geodesic_aspect_chunk_cupy, a2=a2, b2=b2, z_factor=z_factor)
260+
out = stacked.map_overlap(
261+
_func,
262+
depth=(0, 1, 1),
263+
boundary=cupy.nan,
264+
meta=cupy.array((), dtype=cupy.float32),
265+
)
266+
return out[0]
267+
268+
269+
# =====================================================================
270+
# Public API
271+
# =====================================================================
272+
143273
def aspect(agg: xr.DataArray,
144-
name: Optional[str] = 'aspect') -> xr.DataArray:
274+
name: Optional[str] = 'aspect',
275+
method: str = 'planar',
276+
z_unit: str = 'meter') -> xr.DataArray:
145277
"""
146278
Calculates the aspect value of an elevation aggregate.
147279
@@ -169,6 +301,15 @@ def aspect(agg: xr.DataArray,
169301
of elevation values.
170302
name : str, default='aspect'
171303
Name of ouput DataArray.
304+
method : str, default='planar'
305+
``'planar'`` uses the classic Horn algorithm with uniform cell size.
306+
``'geodesic'`` converts cells to Earth-Centered Earth-Fixed (ECEF)
307+
coordinates and fits a 3D plane, yielding accurate results for
308+
geographic (lat/lon) coordinate systems.
309+
z_unit : str, default='meter'
310+
Unit of the elevation values. Only used when ``method='geodesic'``.
311+
Accepted values: ``'meter'``, ``'foot'``, ``'kilometer'``, ``'mile'``
312+
(and common aliases).
172313
173314
Returns
174315
-------
@@ -198,81 +339,40 @@ def aspect(agg: xr.DataArray,
198339
[1, 5, 0, 5, 5]
199340
], dtype=np.float32)
200341
>>> raster = xr.DataArray(data, dims=['y', 'x'], name='raster')
201-
>>> print(raster)
202-
<xarray.DataArray 'raster' (y: 6, x: 5)>
203-
array([[1., 1., 1., 1., 1.],
204-
[1., 1., 1., 2., 0.],
205-
[1., 1., 1., 0., 0.],
206-
[4., 4., 9., 2., 4.],
207-
[1., 5., 0., 1., 4.],
208-
[1., 5., 0., 5., 5.]])
209-
Dimensions without coordinates: y, x
210342
>>> aspect_agg = aspect(raster)
211-
>>> print(aspect_agg)
212-
<xarray.DataArray 'aspect' (y: 6, x: 5)>
213-
array([[ nan, nan , nan , nan , nan],
214-
[ nan, -1. , 225. , 135. , nan],
215-
[ nan, 343.61045967, 8.97262661, 33.69006753, nan],
216-
[ nan, 307.87498365, 71.56505118, 54.46232221, nan],
217-
[ nan, 191.30993247, 144.46232221, 255.96375653, nan],
218-
[ nan, nan , nan , nan , nan]])
219-
Dimensions without coordinates: y, x
220-
221-
Aspect works with Dask with NumPy backed xarray DataArray
222-
.. sourcecode:: python
223-
224-
>>> import dask.array as da
225-
>>> data_da = da.from_array(data, chunks=(3, 3))
226-
>>> raster_da = xr.DataArray(data_da, dims=['y', 'x'], name='raster_da')
227-
>>> print(raster_da)
228-
<xarray.DataArray 'raster' (y: 6, x: 5)>
229-
dask.array<array, shape=(6, 5), dtype=int64, chunksize=(3, 3), chunktype=numpy.ndarray>
230-
Dimensions without coordinates: y, x
231-
>>> aspect_da = aspect(raster_da)
232-
>>> print(aspect_da)
233-
<xarray.DataArray 'aspect' (y: 6, x: 5)>
234-
dask.array<_trim, shape=(6, 5), dtype=float32, chunksize=(3, 3), chunktype=numpy.ndarray>
235-
Dimensions without coordinates: y, x
236-
>>> print(aspect_da.compute()) # compute the results
237-
<xarray.DataArray 'aspect' (y: 6, x: 5)>
238-
array([[ nan, nan , nan , nan , nan],
239-
[ nan, -1. , 225. , 135. , nan],
240-
[ nan, 343.61045967, 8.97262661, 33.69006753, nan],
241-
[ nan, 307.87498365, 71.56505118, 54.46232221, nan],
242-
[ nan, 191.30993247, 144.46232221, 255.96375653, nan],
243-
[ nan, nan , nan , nan , nan]])
244-
Dimensions without coordinates: y, x
245-
246-
Aspect works with CuPy backed xarray DataArray.
247-
Make sure you have a GPU and CuPy installed to run this example.
248-
.. sourcecode:: python
249-
250-
>>> import cupy
251-
>>> data_cupy = cupy.asarray(data)
252-
>>> raster_cupy = xr.DataArray(data_cupy, dims=['y', 'x'])
253-
>>> aspect_cupy = aspect(raster_cupy)
254-
>>> print(type(aspect_cupy.data))
255-
<class 'cupy.core.core.ndarray'>
256-
>>> print(aspect_cupy)
257-
<xarray.DataArray 'aspect' (y: 6, x: 5)>
258-
array([[ nan, nan, nan, nan, nan],
259-
[ nan, -1., 225., 135., nan],
260-
[ nan, 343.61047, 8.972626, 33.690067, nan],
261-
[ nan, 307.87497, 71.56505 , 54.462322, nan],
262-
[ nan, 191.30994, 144.46233 , 255.96376, nan],
263-
[ nan, nan, nan, nan, nan]],
264-
dtype=float32)
265-
Dimensions without coordinates: y, x
266343
"""
267344

268-
mapper = ArrayTypeFunctionMapping(
269-
numpy_func=_run_numpy,
270-
dask_func=_run_dask_numpy,
271-
cupy_func=_run_cupy,
272-
dask_cupy_func=_run_dask_cupy,
273-
)
274-
275-
out = mapper(agg)(agg.data)
345+
if method not in ('planar', 'geodesic'):
346+
raise ValueError(
347+
f"method must be 'planar' or 'geodesic', got {method!r}"
348+
)
349+
350+
if method == 'planar':
351+
mapper = ArrayTypeFunctionMapping(
352+
numpy_func=_run_numpy,
353+
dask_func=_run_dask_numpy,
354+
cupy_func=_run_cupy,
355+
dask_cupy_func=_run_dask_cupy,
356+
)
357+
out = mapper(agg)(agg.data)
358+
359+
else: # geodesic
360+
if z_unit not in Z_UNITS:
361+
raise ValueError(
362+
f"z_unit must be one of {sorted(set(Z_UNITS.values()), key=str)}, "
363+
f"got {z_unit!r}"
364+
)
365+
z_factor = Z_UNITS[z_unit]
366+
367+
lat_2d, lon_2d = _extract_latlon_coords(agg)
368+
369+
mapper = ArrayTypeFunctionMapping(
370+
numpy_func=_run_numpy_geodesic,
371+
cupy_func=_run_cupy_geodesic,
372+
dask_func=_run_dask_numpy_geodesic,
373+
dask_cupy_func=_run_dask_cupy_geodesic,
374+
)
375+
out = mapper(agg)(agg.data, lat_2d, lon_2d, WGS84_A2, WGS84_B2, z_factor)
276376

277377
return xr.DataArray(out,
278378
name=name,

0 commit comments

Comments
 (0)