Repository: rasterio/rasterio

Issue:
ENH: Antimeridian support for transform_bounds
Recently added to pyproj.Transformer in master (https://github.com/pyproj4/pyproj/pull/810; https://github.com/pyproj4/pyproj/pull/817; https://github.com/pyproj4/pyproj/pull/818):

- min/max functions [ref](https://github.com/pyproj4/pyproj/blob/cccc897a2186766c3cb9040cf80ba1e450b1b298/pyproj/_transformer.pyx#L324-L494)
- _transform_bounds [ref](https://github.com/pyproj4/pyproj/blob/cccc897a2186766c3cb9040cf80ba1e450b1b298/pyproj/_transformer.pyx#L938-L1065)
- test [ref](https://github.com/pyproj4/pyproj/blob/cccc897a2186766c3cb9040cf80ba1e450b1b298/test/test_transformer.py#L1323-L1338)

Any interest in having similar logic ported to `rasterio.warp.transform_bounds`?


Discussion:
@snowman2 I can't think of how GDAL would allow a dataset to have bounds (the coordinates at the 4 image corners) that cross the antimeridian, but as a general purpose utility this would be welcome.
I have run into satellite tiles that cross the antimeridian. The source projection allows it, but when getting the bounds in geographic coordinates, the antimeridian causes troubles.

Resolving patch:
diff --git a/rasterio/warp.py b/rasterio/warp.py
--- a/rasterio/warp.py
+++ b/rasterio/warp.py
@@ -14,7 +14,12 @@
     from rasterio.env import GDALVersion, ensure_env, require_gdal_version
     from rasterio.errors import GDALBehaviorChangeException, TransformError
     from rasterio.transform import array_bounds
-    from rasterio._warp import _calculate_default_transform, _reproject, _transform_geom
+    from rasterio._warp import (
+        _calculate_default_transform,
+        _reproject,
+        _transform_bounds,
+        _transform_geom,
+    )
 
 # Gauss (7) is not supported for warp
 SUPPORTED_RESAMPLING = [r for r in Resampling if r.value < 7]
@@ -131,7 +136,7 @@ def transform_bounds(
     Optionally densifying the edges (to account for nonlinear transformations
     along these edges) and extracting the outermost bounds.
 
-    Note: this does not account for the antimeridian.
+    Note: antimeridian support added in version 1.3.0
 
     Parameters
     ----------
@@ -152,36 +157,15 @@ def transform_bounds(
     left, bottom, right, top: float
         Outermost coordinates in target coordinate reference system.
     """
-    if densify_pts < 0:
-        raise ValueError('densify parameter must be >= 0')
-
-    in_xs = []
-    in_ys = []
-
-    if densify_pts > 0:
-        densify_factor = 1.0 / float(densify_pts + 1)
-
-        # Add points along outer edges.
-        for x in (left, right):
-            in_xs.extend([x] * (densify_pts + 2))
-            in_ys.extend(
-                bottom + np.arange(0, densify_pts + 2, dtype=np.float64) *
-                ((top - bottom) * densify_factor)
-            )
-
-        for y in (bottom, top):
-            in_xs.extend(
-                left + np.arange(1, densify_pts + 1, dtype=np.float64) *
-                ((right - left) * densify_factor)
-            )
-            in_ys.extend([y] * densify_pts)
-
-    else:
-        in_xs = [left, left, right, right]
-        in_ys = [bottom, top, bottom, top]
-
-    xs, ys = transform(src_crs, dst_crs, in_xs, in_ys)
-    return (min(xs), min(ys), max(xs), max(ys))
+    return _transform_bounds(
+        src_crs,
+        dst_crs,
+        left,
+        bottom,
+        right,
+        top,
+        densify_pts,
+    )
 
 
 @ensure_env


Test patch:
diff --git a/tests/test_warp_transform.py b/tests/test_warp_transform.py
--- a/tests/test_warp_transform.py
+++ b/tests/test_warp_transform.py
@@ -1,7 +1,9 @@
 import os
 import logging
 
+import numpy
 import pytest
+from numpy.testing import assert_almost_equal
 
 import rasterio
 from rasterio._warp import _calculate_default_transform
@@ -10,6 +12,7 @@
 from rasterio.errors import CRSError
 from rasterio.transform import from_bounds
 from rasterio.warp import calculate_default_transform, transform_bounds
+from tests.conftest import requires_gdal3
 
 log = logging.getLogger(__name__)
 
@@ -179,6 +182,81 @@ def test_transform_bounds_identity():
     assert transform_bounds("+init=epsg:3857", "+init=epsg:3857", *bounds) == bounds
 
 
+def test_transform_bounds_densify_out_of_bounds():
+    with pytest.raises(ValueError):
+        transform_bounds(
+            "EPSG:4326",
+            "+proj=laea +lat_0=45 +lon_0=-100 +x_0=0 +y_0=0 "
+            "+a=6370997 +b=6370997 +units=m +no_defs",
+            -120,
+             40,
+            -80,
+             64,
+             densify_pts=-1,
+        )
+
+
+def test_transform_bounds_densify_out_of_bounds__geographic_output():
+    with pytest.raises(ValueError):
+        transform_bounds(
+            "+proj=laea +lat_0=45 +lon_0=-100 +x_0=0 +y_0=0 "
+            "+a=6370997 +b=6370997 +units=m +no_defs",
+            "EPSG:4326",
+            -120,
+             40,
+            -80,
+             64,
+             densify_pts=-1,
+        )
+
+def test_transform_bounds__antimeridian():
+    transformed_bounds = transform_bounds(
+        "EPSG:4167", "EPSG:3851", 160.6, -55.95, -171.2, -25.88,
+    )
+    assert_almost_equal(
+        transformed_bounds,
+        (1722483.900174921, 5228058.6143420935, 4624385.494808555, 8692574.544944234),
+    )
+    inverse_transformed_bounds = transform_bounds(
+        "EPSG:3851", "EPSG:4167", *transformed_bounds,
+    )
+    assert_almost_equal(
+        inverse_transformed_bounds,
+        (153.2799922, -56.7471249, -162.1813873, -24.6148194),
+    )
+
+
+def test_transform_bounds__beyond_global_bounds():
+    assert_almost_equal(
+        transform_bounds(
+            "EPSG:6933",
+            "EPSG:4326",
+            -17367531.3203125,
+            -7314541.19921875,
+            17367531.3203125,
+            7314541.19921875,
+        ),
+        (-180, -85.0445994113099, 180, 85.0445994113099),
+    )
+
+
+@requires_gdal3
+def test_transform_bounds__ignore_inf():
+    assert not numpy.isinf(
+        transform_bounds(
+            "EPSG:4326", "ESRI:102036", -180.0, -90.0, 180.0, 0.0,
+        )
+    ).any()
+
+
+def test_transform_bounds__noop_geographic():
+    bounds = (19.57, 35.14, -168.97, 81.91)
+    assert_almost_equal(
+        transform_bounds("EPSG:4284", "EPSG:4284", *bounds),
+        bounds,
+    )
+
+
 def test_issue1131():
     """Confirm that we don't run out of memory"""
     transform, w, h = calculate_default_transform(CRS.from_epsg(4326), CRS.from_epsg(3857), 455880, 454450, 13.0460235139, 42.6925552354, 13.2511695428, 42.8970561511)