Skip to content

Commit 8b92b4f

Browse files
committed
Add scatter-point transform_points() for boundary estimation (#1057)
1 parent dda84ed commit 8b92b4f

File tree

2 files changed

+263
-0
lines changed

2 files changed

+263
-0
lines changed

xrspatial/reproject/_projections.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2099,6 +2099,209 @@ def try_numba_transform(src_crs, tgt_crs, chunk_bounds, chunk_shape):
20992099
return None
21002100

21012101

2102+
def transform_points(src_crs, tgt_crs, xs, ys):
2103+
"""Transform scatter points from *src_crs* to *tgt_crs* using Numba kernels.
2104+
2105+
Parameters
2106+
----------
2107+
src_crs, tgt_crs : CRS-like
2108+
Source and target coordinate reference systems (pyproj CRS or lite CRS).
2109+
xs, ys : array-like
2110+
1-D arrays of x and y coordinates in *src_crs*.
2111+
2112+
Returns
2113+
-------
2114+
(tx, ty) : tuple of numpy arrays, or None
2115+
Transformed coordinates in *tgt_crs*, or ``None`` if no fast path
2116+
exists for this CRS pair.
2117+
2118+
Notes
2119+
-----
2120+
Intentional omissions (fall back to pyproj for these):
2121+
2122+
* No datum-shift wrapping -- metre-level error is sub-pixel for the
2123+
boundary-estimation use case this function targets.
2124+
* Sinusoidal and Generic Transverse Mercator are not covered here;
2125+
those projections are dispatched via ``to_dict()['proj']`` which
2126+
requires a full pyproj CRS.
2127+
"""
2128+
src_epsg = _get_epsg(src_crs)
2129+
tgt_epsg = _get_epsg(tgt_crs)
2130+
if src_epsg is None and tgt_epsg is None:
2131+
return None
2132+
2133+
src_is_geo = _is_supported_geographic(src_epsg)
2134+
tgt_is_geo = _is_supported_geographic(tgt_epsg)
2135+
if not src_is_geo and not tgt_is_geo:
2136+
return None
2137+
2138+
xs = np.asarray(xs, dtype=np.float64).ravel()
2139+
ys = np.asarray(ys, dtype=np.float64).ravel()
2140+
n = xs.shape[0]
2141+
tx = np.empty(n, dtype=np.float64)
2142+
ty = np.empty(n, dtype=np.float64)
2143+
2144+
# --- Geographic -> Web Mercator (3857) ---
2145+
if src_is_geo and tgt_epsg == 3857:
2146+
merc_forward(xs, ys, tx, ty)
2147+
return tx, ty
2148+
2149+
if src_epsg == 3857 and tgt_is_geo:
2150+
merc_inverse(xs, ys, tx, ty)
2151+
return tx, ty
2152+
2153+
# --- Geographic -> UTM ---
2154+
if src_is_geo:
2155+
utm = _utm_params(tgt_epsg)
2156+
if utm is not None:
2157+
lon0, k0, fe, fn = utm
2158+
Qn = k0 * _A_RECT
2159+
tmerc_forward(xs, ys, tx, ty,
2160+
lon0, k0, fe, fn, Qn, _ALPHA, _CBG)
2161+
return tx, ty
2162+
2163+
# --- UTM -> Geographic ---
2164+
utm_src = _utm_params(src_epsg)
2165+
if utm_src is not None and tgt_is_geo:
2166+
lon0, k0, fe, fn = utm_src
2167+
Qn = k0 * _A_RECT
2168+
tmerc_inverse(xs, ys, tx, ty,
2169+
lon0, k0, fe, fn, Qn, _BETA, _CGB)
2170+
return tx, ty
2171+
2172+
# --- Geographic -> Ellipsoidal Mercator (3395) ---
2173+
if src_is_geo and tgt_epsg == 3395:
2174+
emerc_forward(xs, ys, tx, ty, 1.0, _WGS84_E)
2175+
return tx, ty
2176+
2177+
if src_epsg == 3395 and tgt_is_geo:
2178+
emerc_inverse(xs, ys, tx, ty, 1.0, _WGS84_E)
2179+
return tx, ty
2180+
2181+
# --- Geographic -> LCC ---
2182+
if src_is_geo:
2183+
params = _lcc_params(tgt_crs)
2184+
if params is not None:
2185+
lon0, nn, c, rho0, k0, fe, fn, to_m = params
2186+
lcc_forward(xs, ys, tx, ty,
2187+
lon0, nn, c, rho0, k0, fe, fn, _WGS84_E, _WGS84_A)
2188+
if to_m != 1.0:
2189+
tx /= to_m
2190+
ty /= to_m
2191+
return tx, ty
2192+
2193+
# --- LCC -> Geographic ---
2194+
if tgt_is_geo:
2195+
params = _lcc_params(src_crs)
2196+
if params is not None:
2197+
lon0, nn, c, rho0, k0, fe, fn, to_m = params
2198+
# lcc_inverse does NOT take a to_m param; pre-multiply if needed
2199+
if to_m != 1.0:
2200+
xs = xs * to_m
2201+
ys = ys * to_m
2202+
lcc_inverse(xs, ys, tx, ty,
2203+
lon0, nn, c, rho0, k0, fe, fn, _WGS84_E, _WGS84_A)
2204+
return tx, ty
2205+
2206+
# --- Geographic -> AEA ---
2207+
if src_is_geo:
2208+
params = _aea_params(tgt_crs)
2209+
if params is not None:
2210+
lon0, nn, C, rho0, fe, fn = params
2211+
aea_forward(xs, ys, tx, ty,
2212+
lon0, nn, C, rho0, fe, fn,
2213+
_WGS84_E, _WGS84_A)
2214+
return tx, ty
2215+
2216+
# --- AEA -> Geographic ---
2217+
if tgt_is_geo:
2218+
params = _aea_params(src_crs)
2219+
if params is not None:
2220+
lon0, nn, C, rho0, fe, fn = params
2221+
aea_inverse(xs, ys, tx, ty,
2222+
lon0, nn, C, rho0, fe, fn,
2223+
_WGS84_E, _WGS84_A, _QP, _APA)
2224+
return tx, ty
2225+
2226+
# --- Geographic -> CEA ---
2227+
if src_is_geo:
2228+
params = _cea_params(tgt_crs)
2229+
if params is not None:
2230+
lon0, k0, fe, fn = params
2231+
cea_forward(xs, ys, tx, ty,
2232+
lon0, k0, fe, fn,
2233+
_WGS84_E, _WGS84_A, _QP)
2234+
return tx, ty
2235+
2236+
# --- CEA -> Geographic ---
2237+
if tgt_is_geo:
2238+
params = _cea_params(src_crs)
2239+
if params is not None:
2240+
lon0, k0, fe, fn = params
2241+
cea_inverse(xs, ys, tx, ty,
2242+
lon0, k0, fe, fn,
2243+
_WGS84_E, _WGS84_A, _QP, _APA)
2244+
return tx, ty
2245+
2246+
# --- Geographic -> LAEA ---
2247+
if src_is_geo:
2248+
params = _laea_params(tgt_crs)
2249+
if params is not None:
2250+
lon0, lat0, sinb1, cosb1, dd, xmf, ymf, rq, qp, fe, fn, mode = params
2251+
laea_forward(xs, ys, tx, ty,
2252+
lon0, sinb1, cosb1, xmf, ymf, rq, qp,
2253+
fe, fn, _WGS84_E, _WGS84_A, _WGS84_E2, mode)
2254+
return tx, ty
2255+
2256+
# --- LAEA -> Geographic ---
2257+
if tgt_is_geo:
2258+
params = _laea_params(src_crs)
2259+
if params is not None:
2260+
lon0, lat0, sinb1, cosb1, dd, xmf, ymf, rq, qp, fe, fn, mode = params
2261+
laea_inverse(xs, ys, tx, ty,
2262+
lon0, sinb1, cosb1, xmf, ymf, rq, qp,
2263+
fe, fn, _WGS84_E, _WGS84_A, _WGS84_E2, mode, _APA)
2264+
return tx, ty
2265+
2266+
# --- Geographic -> Polar Stereographic ---
2267+
if src_is_geo:
2268+
params = _stere_params(tgt_crs)
2269+
if params is not None:
2270+
lon0, k0, akm1, fe, fn, is_south = params
2271+
stere_forward(xs, ys, tx, ty,
2272+
lon0, akm1, fe, fn, _WGS84_E, is_south)
2273+
return tx, ty
2274+
2275+
# --- Polar Stereographic -> Geographic ---
2276+
if tgt_is_geo:
2277+
params = _stere_params(src_crs)
2278+
if params is not None:
2279+
lon0, k0, akm1, fe, fn, is_south = params
2280+
stere_inverse(xs, ys, tx, ty,
2281+
lon0, akm1, fe, fn, _WGS84_E, is_south)
2282+
return tx, ty
2283+
2284+
# --- Geographic -> Oblique Stereographic ---
2285+
if src_is_geo:
2286+
params = _sterea_params(tgt_crs)
2287+
if params is not None:
2288+
lon0, sinc0, cosc0, R2, C, K, ratexp, fe, fn, e = params
2289+
sterea_forward(xs, ys, tx, ty,
2290+
lon0, sinc0, cosc0, R2, C, K, ratexp, fe, fn, e)
2291+
return tx, ty
2292+
2293+
# --- Oblique Stereographic -> Geographic ---
2294+
if tgt_is_geo:
2295+
params = _sterea_params(src_crs)
2296+
if params is not None:
2297+
lon0, sinc0, cosc0, R2, C, K, ratexp, fe, fn, e = params
2298+
sterea_inverse(xs, ys, tx, ty,
2299+
lon0, sinc0, cosc0, R2, C, K, ratexp, fe, fn, e)
2300+
return tx, ty
2301+
2302+
return None
2303+
2304+
21022305
# Wrap try_numba_transform with datum shift support
21032306
_try_numba_transform_inner = try_numba_transform
21042307

xrspatial/tests/test_lite_crs.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,66 @@ def test_crs_from_wkt_lite(self):
232232
]
233233

234234

235+
# -----------------------------------------------------------------------
236+
# transform_points
237+
# -----------------------------------------------------------------------
238+
import numpy as np
239+
from xrspatial.reproject._projections import transform_points
240+
241+
242+
class TestTransformPoints:
243+
def test_wgs84_to_web_mercator(self):
244+
xs = np.array([0.0, 10.0, -75.5])
245+
ys = np.array([0.0, 45.0, 40.0])
246+
src = pyproj.CRS.from_epsg(4326)
247+
tgt = pyproj.CRS.from_epsg(3857)
248+
result = transform_points(src, tgt, xs, ys)
249+
assert result is not None
250+
tx, ty = result
251+
# lon=0 lat=0 -> (0, 0) in Web Mercator
252+
assert abs(tx[0]) < 1.0
253+
assert abs(ty[0]) < 1.0
254+
# lon=10 -> positive easting
255+
assert tx[1] > 1e6
256+
257+
def test_wgs84_to_utm_zone32(self):
258+
# Central meridian of UTM zone 32 is 9 deg E
259+
xs = np.array([9.0])
260+
ys = np.array([0.0])
261+
src = pyproj.CRS.from_epsg(4326)
262+
tgt = pyproj.CRS.from_epsg(32632)
263+
result = transform_points(src, tgt, xs, ys)
264+
assert result is not None
265+
tx, ty = result
266+
# On the central meridian, easting should be 500000
267+
assert abs(tx[0] - 500000.0) < 1.0
268+
269+
def test_unsupported_pair_returns_none(self):
270+
# Two projected CRS -> no fast path
271+
src = pyproj.CRS.from_epsg(32632)
272+
tgt = pyproj.CRS.from_epsg(32633)
273+
result = transform_points(src, tgt, [500000], [0])
274+
assert result is None
275+
276+
def test_matches_pyproj_transformer(self):
277+
# WGS84 -> Albers Equal Area (EPSG:5070), 20 random points
278+
rng = np.random.RandomState(42)
279+
xs = rng.uniform(-120, -70, 20)
280+
ys = rng.uniform(25, 50, 20)
281+
src = pyproj.CRS.from_epsg(4326)
282+
tgt = pyproj.CRS.from_epsg(5070)
283+
result = transform_points(src, tgt, xs, ys)
284+
assert result is not None
285+
tx, ty = result
286+
# Compare against pyproj
287+
transformer = pyproj.Transformer.from_crs(src, tgt, always_xy=True)
288+
ref_x, ref_y = transformer.transform(xs, ys)
289+
# Tolerance is ~1 m because transform_points skips datum shifts
290+
# (metre-level error is sub-pixel for boundary estimation).
291+
np.testing.assert_allclose(tx, ref_x, atol=1.0)
292+
np.testing.assert_allclose(ty, ref_y, atol=1.0)
293+
294+
235295
class TestCRSMatchesPyproj:
236296
@pytest.mark.parametrize("epsg", _VALIDATE_CODES)
237297
def test_to_dict_proj_key_matches(self, epsg):

0 commit comments

Comments
 (0)