Repository: rasterio/rasterio

Issue:
Calculation of raster bounds is incorrect for rotated rasters
## Expected behavior and actual behavior.

Expected: Rasterio computes correct raster bounds using functions like ~`ds.bounds~` and `rasterio.transform.array_bounds` (edit: the first one does work properly - the maths is implemented differently).

Actual: Rasterio computes the bounding box based on the 'top left' and 'bottom right' corners of the *pixel array*, which do not represent the projected bounds.

## Steps to reproduce the problem.

```
from affine import Affine

transform = Affine(6.302162394202463, 6.152691784443768, 444048.6420808117,
       4.143808265940849, -3.2637657016189103, 5417729.777950762)
height, width = 1280, 1024

# Compute the coordinates of each corner of the array
transform * (0, 0)  # Top left of pixel array
# (444048, 5417729)

transform * (width, 0)  # Top right of pixel array
# (450502, 5421973)

ds.transform * (0, height)  # Bottom left of pixel array
# (451924, 5413552)

ds.transform * (width, height)  # Bottom right of pixel array
# (458377, 5417795)

# Min, max x coordinate = west, east = (444048, 458377)
# Min, max y coordinate = south, north = (5414387, 5421973)

# Ask rasterio for the bounds
rasterio.transform.array_bounds(height, width, transform)
# Expected: (444048, 5414387, 458377, 5421973)
# Actual: (444048, 5417795, 458377, 5417729)
# Y values are computed incorrectly
# South and North values are flipped (south > north)
```

Illustration of a raster with this transform and dimensions (from QGIS), with the orange box showing the bounding box that rasterio is computing:
![image](https://user-images.githubusercontent.com/28623784/224695378-2415c4e2-8a04-40f2-bcc6-b25844bd64b4.png)

For my case, querying the four corners and taking min/max of the x and y values works. However, I don't know:
a) If that works for all transforms
b) Whether there's a simpler way of using the affine to get them without querying four corners


#### Environment Information
```
rasterio info:
  rasterio: 1.3.6
      GDAL: 3.5.3
      PROJ: 9.0.1
      GEOS: 3.11.1
 PROJ DATA: /home/danielevans/.cache/pypoetry/virtualenvs/georeferencing-validation-w0lP6d6Z-py3.10/lib/python3.10/site-packages/rasterio/proj_data
 GDAL DATA: /home/danielevans/.cache/pypoetry/virtualenvs/georeferencing-validation-w0lP6d6Z-py3.10/lib/python3.10/site-packages/rasterio/gdal_data

System:
    python: 3.10.6 (main, Nov 14 2022, 16:10:14) [GCC 11.3.0]
executable: /home/danielevans/.cache/pypoetry/virtualenvs/georeferencing-validation-w0lP6d6Z-py3.10/bin/python
   machine: Linux-5.15.90.1-microsoft-standard-WSL2-x86_64-with-glibc2.35

Python deps:
    affine: 2.4.0
     attrs: 22.2.0
   certifi: 2022.12.07
     click: 8.1.3
     cligj: 0.7.2
    cython: None
     numpy: 1.24.2
    snuggs: 1.4.7
click-plugins: None
setuptools: 67.5.1
```


## Installation Method
1.3.6 wheel via Pip on Linux


Discussion:
Happy to pop in a PR for this if works-for-any-transform is actually the desired behaviour (not just north-up east-right) - given the lack of a warning in the docs, I assumed the intention is that it works for any raster.
In fact, I found that the section "Using Affine transformation matrix" in the official tutorial is wrong, transform.xy(row,col) is actually approximately equal to transform*(col+0.5,row+0.5),transform.rowcol(x,y) is not equal to ~transform*(y,x)-(0.5,0.5). The relationship between them is actually as follows:
![屏幕截图 2023-03-13 233306](https://user-images.githubusercontent.com/34577583/224750268-0c162ebc-2374-45fd-91f4-6d6108f85f16.png)
Please correct this mistake.
### Environment
```bash
rasterio info:
  rasterio: 1.3.6
      GDAL: 3.6.2
      PROJ: 9.1.1
      GEOS: 3.11.1
 PROJ DATA: /mnt/data/miniforge3/envs/geo/share/proj
 GDAL DATA: /mnt/data/miniforge3/envs/geo/share/gdal

System:
    python: 3.9.16 | packaged by conda-forge | (main, Feb  1 2023, 21:39:03)  [GCC 11.3.0]
executable: /mnt/data/miniforge3/envs/geo/bin/python
   machine: Linux-5.19.0-32-generic-x86_64-with-glibc2.35

Python deps:
    affine: 2.4.0
     attrs: 22.2.0
   certifi: 2022.12.07
     click: 8.1.3
     cligj: 0.7.2
    cython: None
     numpy: 1.24.2
    snuggs: 1.4.7
click-plugins: None
setuptools: 67.4.0
```

PRs with fixes to the issues identified are welcome.
[transfome.py#434](https://github.com/rasterio/rasterio/blob/9a711f1f0639c4bec1f0877e9bd3e5c86d30d2c7/rasterio/transform.py#L434) reveals the reason for the situation I described earlier.
@snowman2 
It is necessary to consider `transformer.xy()` and `transformer.rowcol()` as recommended methods

Resolving patch:
diff --git a/rasterio/transform.py b/rasterio/transform.py
--- a/rasterio/transform.py
+++ b/rasterio/transform.py
@@ -196,9 +196,19 @@ def array_bounds(height, width, transform):
     its height, width, and an affine transform.
 
     """
-    w, n = transform.xoff, transform.yoff
-    e, s = transform * (width, height)
-    return w, s, e, n
+    a, b, c, d, e, f, _, _, _ = transform
+    if b == d == 0:
+        west, south, east, north = c, f + e * height, c + a * width, f
+    else:
+        c0x, c0y = c, f
+        c1x, c1y = transform * (0, height)
+        c2x, c2y = transform * (width, height)
+        c3x, c3y = transform * (width, 0)
+        xs = (c0x, c1x, c2x, c3x)
+        ys = (c0y, c1y, c2y, c3y)
+        west, south, east, north = min(xs), min(ys), max(xs), max(ys)
+
+    return west, south, east, north
 
 
 def xy(transform, rows, cols, zs=None, offset='center', **rpc_options):


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -11,15 +11,17 @@
 
 import affine
 import boto3
-from click.testing import CliRunner
-import pytest
 import numpy as np
+import pytest
+from click.testing import CliRunner
 
 import rasterio
+import rasterio.env
+from rasterio.coords import BoundingBox
 from rasterio.crs import CRS
 from rasterio.enums import ColorInterp
 from rasterio.env import GDALVersion
-
+from affine import Affine
 
 DEFAULT_SHAPE = (10, 10)
 
@@ -384,10 +386,6 @@ def basic_image_file(tmpdir, basic_image):
     string
         Filename of test raster file
     """
-
-    from affine import Affine
-    import rasterio
-
     image = basic_image
 
     outfilename = str(tmpdir.join('basic_image.tif'))
@@ -419,10 +417,6 @@ def pixelated_image_file(tmpdir, pixelated_image):
     string
         Filename of test raster file
     """
-
-    from affine import Affine
-    import rasterio
-
     image = pixelated_image
 
     outfilename = str(tmpdir.join('pixelated_image.tif'))
@@ -454,10 +448,6 @@ def rotated_image_file(tmpdir, pixelated_image):
     string
         Filename of test raster file
     """
-
-    from affine import Affine
-    import rasterio
-
     image = 128 * np.ones((1000, 2000), dtype=np.uint8)
 
     rotated_transform = Affine(-0.05, 0.07, 481060,
@@ -481,10 +471,42 @@ def rotated_image_file(tmpdir, pixelated_image):
     return outfilename
 
 
+@pytest.fixture()
+def image_file_with_custom_size_and_transform(tmpdir):
+    """
+    A basic raster file with a 10x10 array containing a
+    caller supplied transform.
+
+    Returns
+    -------
+
+    string
+        Filename of test raster file
+    """
+    def inner(width, height, transform):
+        image = np.zeros((height, width))
+
+        outfilename = str(tmpdir.join('basic_image.tif'))
+        kwargs = {
+            "crs": CRS({'init': 'epsg:4326'}),
+            "transform": transform,
+            "count": 1,
+            "dtype": rasterio.uint8,
+            "driver": "GTiff",
+            "width": image.shape[1],
+            "height": image.shape[0],
+            "nodata": None
+        }
+        with rasterio.open(outfilename, 'w', **kwargs) as out:
+            out.write(image, indexes=1)
+
+        return outfilename
+
+    return inner
+
+
 @pytest.fixture(scope='function')
 def gdalenv(request):
-    import rasterio.env
-
     def fin():
         if rasterio.env.local._env:
             rasterio.env.delenv()
@@ -650,3 +672,17 @@ def __init__(self, geojson):
     gdal_version.at_least('3.5'),
     reason="Requires GDAL before 3.5",
 )
+
+
+def assert_bounding_box_equal(expected, actual, tolerance=1e-4):
+    if isinstance(expected, tuple):
+        expected = BoundingBox(*expected)
+    if isinstance(actual, tuple):
+        actual = BoundingBox(*actual)
+
+    left = abs(expected.left - actual.left)
+    bottom = abs(expected.bottom - actual.bottom)
+    right = abs(expected.right - actual.right)
+    top = abs(expected.top - actual.top)
+
+    assert all(diff < tolerance for diff in [left, bottom, right, top]), f"{expected} differs from {actual}"
diff --git a/tests/test_dataset.py b/tests/test_dataset.py
--- a/tests/test_dataset.py
+++ b/tests/test_dataset.py
@@ -1,16 +1,20 @@
 """High level tests for Rasterio's ``GDALDataset`` abstractions."""
 
 
+import math
 from pathlib import Path
 from unittest.mock import MagicMock
 
 import pytest
 
 import rasterio
+from rasterio.coords import BoundingBox
 from rasterio.enums import Compression
-from rasterio.errors import RasterioIOError, DatasetAttributeError
+from rasterio.errors import DatasetAttributeError, RasterioIOError
 from rasterio.transform import Affine
 
+from .conftest import assert_bounding_box_equal
+
 
 def test_files(data):
     tif = Path(data).joinpath('RGB.byte.tif')
@@ -88,3 +92,22 @@ def test_creation_untiled_blockysize(tmp_path, blockysize):
         assert not dataset.is_tiled
         assert dataset.profile["blockysize"] == min(blockysize, 61)
         assert dataset.block_shapes[0][0] == min(blockysize, 61)
+
+
+@pytest.mark.parametrize(
+    ["width", "height", "transform", "expected_bounds"],
+    [
+        pytest.param(2, 2, Affine.identity(), BoundingBox(0.0, 2.0, 2.0, 0.0), id="Identity transform"),
+        pytest.param(2, 2, Affine.scale(1, -1), BoundingBox(0.0, -2.0, 2.0, 0.0), id="North-up transform"),
+        pytest.param(2, 2, Affine.translation(2, 2) * Affine.scale(1, -1), BoundingBox(2.0, 0.0, 4.0, 2.0), id="Translated transform"),
+        pytest.param(2, 2, Affine.scale(4) * Affine.scale(1, -1), BoundingBox(0.0, -8.0, 8.0, 0.0), id="Scaled transform"),
+        pytest.param(2, 2, Affine.rotation(90) * Affine.scale(1, -1), BoundingBox(0.0, 0.0, 2.0, 2.0), id="90 degree rotated transform"),
+        pytest.param(2, 2, Affine.rotation(45) * Affine.scale(1, -1), BoundingBox(0.0, -math.sqrt(2), 2 * math.sqrt(2), math.sqrt(2)), id="45 degree rotated transform"),
+        pytest.param(2, 2, Affine.scale(4, 1) * Affine.scale(1, -1), BoundingBox(0, -2.0, 8.0, 0.0), id="Rectangular pixel transform"),
+        pytest.param(6, 2, Affine.scale(1, -1), BoundingBox(0, -2.0, 6.0, 0.0), id="Differing width and height"),
+    ]
+)
+def test_bounds(width, height, transform, expected_bounds, image_file_with_custom_size_and_transform):
+    filepath = image_file_with_custom_size_and_transform(width, height, transform)
+    with rasterio.open(filepath) as dataset:
+        assert_bounding_box_equal(expected_bounds, dataset.bounds)
diff --git a/tests/test_transform.py b/tests/test_transform.py
--- a/tests/test_transform.py
+++ b/tests/test_transform.py
@@ -1,32 +1,28 @@
 """Tests of the transform module."""
 
-from array import array
 import logging
-
-from affine import Affine
-import pytest
+import math
+from array import array
 
 import numpy
+import pytest
+from affine import Affine
 
 import rasterio
 from rasterio import transform
-from rasterio.transform import (
-    get_transformer,
-    xy, 
-    rowcol,
-    AffineTransformer,
-    GCPTransformer,
-    RPCTransformer
-)
-from rasterio.errors import TransformError
 from rasterio.control import GroundControlPoint
+from rasterio.errors import TransformError
+from rasterio.transform import (AffineTransformer, GCPTransformer,
+                                RPCTransformer, get_transformer, rowcol, xy)
+
+from .conftest import assert_bounding_box_equal
 
 
 def gcps():
     return [
-        GroundControlPoint(row=11521.5, col=0.5, x=-123.6185142817931, y=48.99561141948625, z=89.13533782958984, id='217', info=''), 
-        GroundControlPoint(row=11521.5, col=7448.5, x=-122.8802747777599, y=48.91210259315549, z=89.13533782958984, id='234', info=''), 
-        GroundControlPoint(row=0.5, col=0.5, x=-123.4809665720148, y=49.52809729106944, z=89.13533782958984, id='1', info=''), 
+        GroundControlPoint(row=11521.5, col=0.5, x=-123.6185142817931, y=48.99561141948625, z=89.13533782958984, id='217', info=''),
+        GroundControlPoint(row=11521.5, col=7448.5, x=-122.8802747777599, y=48.91210259315549, z=89.13533782958984, id='234', info=''),
+        GroundControlPoint(row=0.5, col=0.5, x=-123.4809665720148, y=49.52809729106944, z=89.13533782958984, id='1', info=''),
         GroundControlPoint(row=0.5, col=7448.5, x=-122.7345733674704, y=49.44455878004666, z=89.13533782958984, id='18', info='')
     ]
 
@@ -73,6 +69,24 @@ def test_array_bounds():
     assert (w, s, e, n) == transform.array_bounds(height, width, tr)
 
 
+@pytest.mark.parametrize(
+    ["width", "height", "affine_transform", "expected_bounds"],
+    [
+        pytest.param(2, 2, Affine.identity(), (0.0, 2.0, 2.0, 0.0), id="Identity transform"),
+        pytest.param(2, 2, Affine.scale(1, -1), (0.0, -2.0, 2.0, 0.0), id="North-up transform"),
+        pytest.param(2, 2, Affine.translation(2, 2) * Affine.scale(1, -1), (2.0, 0.0, 4.0, 2.0), id="Translated transform"),
+        pytest.param(2, 2, Affine.scale(4) * Affine.scale(1, -1), (0.0, -8.0, 8.0, 0.0), id="Scaled transform"),
+        pytest.param(2, 2, Affine.rotation(90) * Affine.scale(1, -1), (0.0, 0.0, 2.0, 2.0), id="90 degree rotated transform"),
+        pytest.param(2, 2, Affine.rotation(45) * Affine.scale(1, -1), (0.0, -math.sqrt(2), 2 * math.sqrt(2), math.sqrt(2)), id="45 degree rotated transform"),
+        pytest.param(2, 2, Affine.scale(4, 1) * Affine.scale(1, -1), (0, -2.0, 8.0, 0.0), id="Rectangular pixel transform"),
+        pytest.param(6, 2, Affine.scale(1, -1), (0, -2.0, 6.0, 0.0), id="Differing width and height"),
+    ]
+)
+def test_array_bounds_from_transforms(width, height, affine_transform, expected_bounds):
+    actual_bounds = transform.array_bounds(height, width, affine_transform)
+    assert_bounding_box_equal(expected_bounds, actual_bounds)
+
+
 def test_window_bounds():
     with rasterio.open('tests/data/RGB.byte.tif') as src:
 
@@ -256,7 +270,7 @@ def test_rowcol_input(xs, ys, exp_rowcol):
         (
             'tests/data/RGB.byte.gcp.vrt',
             'gcps',
-            [(-123.40736757459366, 49.52003804469494), (-123.478928146875, 49.5280898698975), (-123.4886516975216, 49.491531881517595), (-123.41709112524026, 49.48348005631504), (-123.40736757459366, 49.52003804469494)], 
+            [(-123.40736757459366, 49.52003804469494), (-123.478928146875, 49.5280898698975), (-123.4886516975216, 49.491531881517595), (-123.41709112524026, 49.48348005631504), (-123.40736757459366, 49.52003804469494)],
             [(0, 718), (0, 0), (791, 0), (791, 718), (0, 718)]
         ),
         (
@@ -308,16 +322,16 @@ def test_from_gcps():
         assert not transform.tastes_like_gdal(aff)
 
 @pytest.mark.parametrize(
-    'transformer_cls,transform', 
+    'transformer_cls,transform',
     [
-        (GCPTransformer,gcps()), 
+        (GCPTransformer,gcps()),
         (RPCTransformer,rpcs())
     ]
 )
 def test_transformer_open_closed(transformer_cls, transform):
     # open or closed does not matter for pure Python AffineTransformer
     with transformer_cls(transform) as transformer:
-        assert not transformer.closed 
+        assert not transformer.closed
     assert transformer.closed
     with pytest.raises(ValueError):
         transformer.xy(0, 0)