Repository: rasterio/rasterio

Issue:
Off-by-one error when merging with bounds
## Expected behavior and actual behavior.

This is very similar to https://github.com/mapbox/rasterio/issues/2048, but we are still getting the following error for some AOIs when we are trying to merge with bounds:

```python
Traceback (most recent call last):
  File "...\rasterio_issue.py", line 16, in <module>
    aux_array, aux_transform = rasterio.merge.merge(datasets=ds, bounds=aoi.bounds)
  File "...\lib\site-packages\rasterio\merge.py", line 344, in merge
    copyto(region, temp, region_mask, temp_mask, index=idx, roff=roff, coff=coff)
  File "...\lib\site-packages\rasterio\merge.py", line 25, in copy_first
    np.copyto(merged_data, new_data, where=mask, casting="unsafe")
  File "<__array_function__ internals>", line 5, in copyto
ValueError: could not broadcast input array from shape (1,218,432) into shape (1,217,432)
```

This might be related to rounding or in one of the transformations, but I could not figure it out.

## Steps to reproduce the problem.

```python
import rasterio.merge
from shapely import wkt

aoi = wkt.loads(r"POLYGON((11.09 47.94, 11.06 48.01, 11.12 48.11, 11.18 48.11, 11.18 47.94, 11.09 47.94))")

with rasterio.Env(
    AWS_NO_SIGN_REQUEST=True,
):
    ds = [
        rasterio.open(i)
        for i in [
            "/vsis3/copernicus-dem-30m/Copernicus_DSM_COG_10_N47_00_E011_00_DEM/Copernicus_DSM_COG_10_N47_00_E011_00_DEM.tif",
            "/vsis3/copernicus-dem-30m/Copernicus_DSM_COG_10_N48_00_E011_00_DEM/Copernicus_DSM_COG_10_N48_00_E011_00_DEM.tif",
        ]
    ]
    aux_array, aux_transform = rasterio.merge.merge(datasets=ds, bounds=aoi.bounds)
```

If the AOI is slightly different it is lileky not to happen.

## Operating system

For example: Windows 10 & Ubuntu 18.04.

## Rasterio version and provenance

version `1.2.4` either installed via conda-forge with miniconda or pypi with poetry.



Discussion:
Some more debugging info (from rasterio 1.2.4):
```
# dataset[0]
src_window = Window(col_off=216.5, row_off=0.0, width=432.0, height=217.5)
dst_window = Window(col_off=0.0, row_off=395.5, width=432.0, height=217.5)

# dataset[1]
src_window = Window(col_off=216.5, row_off=3204.5, width=432.0, height=395.5)
dst_window = Window(col_off=0.0, row_off=0.0, width=432.0, height=395.5)

dest.shape = (1, 613, 432)
```
Interestingly, 396 + 218 = 614 rows while `dest` only has 613 rows.
Interesting tidbit about `round()` that might be relevant here[1]:
> For the built-in types supporting round(), values are rounded to the closest multiple of 10 to the power minus ndigits; if two multiples are equally close, rounding is done toward the even choice

```python
>>> round(217.5)
218
>>> round(218.5)
218
```
This is behavior that changed between Python 2.x and Python 3.x

[1] https://docs.python.org/3.8/library/functions.html#round

Resolving patch:
diff --git a/rasterio/__init__.py b/rasterio/__init__.py
--- a/rasterio/__init__.py
+++ b/rasterio/__init__.py
@@ -40,7 +40,7 @@
 import rasterio.path
 
 __all__ = ['band', 'open', 'pad', 'Env']
-__version__ = "1.2.4"
+__version__ = "1.2.5dev"
 __gdal_version__ = gdal_version()
 
 # Rasterio attaches NullHandler to the 'rasterio' logger and its
diff --git a/rasterio/merge.py b/rasterio/merge.py
--- a/rasterio/merge.py
+++ b/rasterio/merge.py
@@ -70,6 +70,7 @@ def merge(
     output_count=None,
     resampling=Resampling.nearest,
     method="first",
+    target_aligned_pixels=False,
     dst_path=None,
     dst_kwds=None,
 ):
@@ -140,6 +141,10 @@ def function(merged_data, new_data, merged_mask, new_mask, index=None, roff=None
             coff: int
                 column offset in base array
 
+    target_aligned_pixels : bool, optional
+        Whether to adjust output image bounds so that pixel coordinates
+        are integer multiples of pixel size, matching the ``-tap``
+        options of GDAL utilities.  Default: False.
     dst_path : str or Pathlike, optional
         Path of output dataset
     dst_kwds : dict, optional
@@ -217,10 +222,6 @@ def nullcontext(obj):
             ys.extend([bottom, top])
         dst_w, dst_s, dst_e, dst_n = min(xs), min(ys), max(xs), max(ys)
 
-    logger.debug("Output bounds: %r", (dst_w, dst_s, dst_e, dst_n))
-    output_transform = Affine.translation(dst_w, dst_n)
-    logger.debug("Output transform, before scaling: %r", output_transform)
-
     # Resolution/pixel size
     if not res:
         res = first_res
@@ -228,18 +229,19 @@ def nullcontext(obj):
         res = (res, res)
     elif len(res) == 1:
         res = (res[0], res[0])
-    output_transform *= Affine.scale(res[0], -res[1])
-    logger.debug("Output transform, after scaling: %r", output_transform)
+
+    if target_aligned_pixels:
+        dst_w = math.floor(dst_w / res[0]) * res[0]
+        dst_e = math.ceil(dst_e / res[0]) * res[0]
+        dst_s = math.floor(dst_s / res[1]) * res[1]
+        dst_n = math.ceil(dst_n / res[1]) * res[1]
 
     # Compute output array shape. We guarantee it will cover the output
     # bounds completely
-    output_width = int(math.ceil((dst_e - dst_w) / res[0]))
-    output_height = int(math.ceil((dst_n - dst_s) / res[1]))
+    output_width = int(round((dst_e - dst_w) / res[0]))
+    output_height = int(round((dst_n - dst_s) / res[1]))
 
-    # Adjust bounds to fit
-    dst_e, dst_s = output_transform * (output_width, output_height)
-    logger.debug("Output width: %d, height: %d", output_width, output_height)
-    logger.debug("Adjusted bounds: %r", (dst_w, dst_s, dst_e, dst_n))
+    output_transform = Affine.translation(dst_w, dst_n) * Affine.scale(res[0], -res[1])
 
     if dtype is not None:
         dt = dtype
@@ -314,13 +316,17 @@ def nullcontext(obj):
             )
 
             # 4. Read data in source window into temp
-            src_window = src_window.round_shape(pixel_precision=0)
-            dst_window = dst_window.round_shape(pixel_precision=0)
-            trows, tcols = dst_window.height, dst_window.width
-            temp_shape = (src_count, trows, tcols)
-            temp = src.read(
+            src_window_rnd_shp = src_window.round_shape(pixel_precision=0)
+            dst_window_rnd_shp = dst_window.round_shape(pixel_precision=0)
+            dst_window_rnd_off = dst_window_rnd_shp.round_offsets(pixel_precision=0)
+            temp_height, temp_width = (
+                dst_window_rnd_off.height,
+                dst_window_rnd_off.width,
+            )
+            temp_shape = (src_count, temp_height, temp_width)
+            temp_src = src.read(
                 out_shape=temp_shape,
-                window=src_window,
+                window=src_window_rnd_shp,
                 boundless=False,
                 masked=True,
                 indexes=indexes,
@@ -328,9 +334,11 @@ def nullcontext(obj):
             )
 
         # 5. Copy elements of temp into dest
-        dst_window = dst_window.round_offsets(pixel_precision=0)
-        roff, coff = dst_window.row_off, dst_window.col_off
-        region = dest[:, roff:roff + trows, coff:coff + tcols]
+        roff, coff = (
+            max(0, dst_window_rnd_off.row_off),
+            max(0, dst_window_rnd_off.col_off),
+        )
+        region = dest[:, roff : roff + temp_height, coff : coff + temp_width]
 
         if math.isnan(nodataval):
             region_mask = np.isnan(region)
@@ -339,8 +347,9 @@ def nullcontext(obj):
         else:
             region_mask = region == nodataval
 
+        # Ensure common shape, resolving issue #2202.
+        temp = temp_src[:, : region.shape[1], : region.shape[2]]
         temp_mask = np.ma.getmask(temp)
-
         copyto(region, temp, region_mask, temp_mask, index=idx, roff=roff, coff=coff)
 
     if dst_path is None:


Test patch:
diff --git a/tests/test_merge.py b/tests/test_merge.py
--- a/tests/test_merge.py
+++ b/tests/test_merge.py
@@ -1,5 +1,8 @@
 """Tests of rasterio.merge"""
 
+import boto3
+from hypothesis import given, settings
+from hypothesis.strategies import floats
 import numpy
 import pytest
 
@@ -58,7 +61,7 @@ def test_issue2163():
     with rasterio.open("tests/data/float_raster_with_nodata.tif") as src:
         data = src.read()
         result, transform = merge([src])
-        assert numpy.allclose(data, result)
+        assert numpy.allclose(data, result[:, : data.shape[1], : data.shape[2]])
 
 
 def test_unsafe_casting():
@@ -66,3 +69,38 @@ def test_unsafe_casting():
     with rasterio.open("tests/data/float_raster_with_nodata.tif") as src:
         result, transform = merge([src], dtype="uint8", nodata=0.0)
         assert not result.any()  # this is why it's called "unsafe".
+
+
+@pytest.mark.skipif(
+    not (boto3.Session()._session.get_credentials()),
+    reason="S3 raster access requires credentials",
+)
+@pytest.mark.network
+@pytest.mark.slow
+@settings(deadline=None, max_examples=5)
+@given(
+    dx=floats(min_value=-0.05, max_value=0.05),
+    dy=floats(min_value=-0.05, max_value=0.05),
+)
+def test_issue2202(dx, dy):
+    import rasterio.merge
+    from shapely import wkt
+    from shapely.affinity import translate
+
+    aoi = wkt.loads(
+        r"POLYGON((11.09 47.94, 11.06 48.01, 11.12 48.11, 11.18 48.11, 11.18 47.94, 11.09 47.94))"
+    )
+    aoi = translate(aoi, dx, dy)
+
+    with rasterio.Env(AWS_NO_SIGN_REQUEST=True,):
+        ds = [
+            rasterio.open(i)
+            for i in [
+                "/vsis3/copernicus-dem-30m/Copernicus_DSM_COG_10_N47_00_E011_00_DEM/Copernicus_DSM_COG_10_N47_00_E011_00_DEM.tif",
+                "/vsis3/copernicus-dem-30m/Copernicus_DSM_COG_10_N48_00_E011_00_DEM/Copernicus_DSM_COG_10_N48_00_E011_00_DEM.tif",
+            ]
+        ]
+        aux_array, aux_transform = rasterio.merge.merge(datasets=ds, bounds=aoi.bounds)
+        from rasterio.plot import show
+
+        show(aux_array)