Repository: rasterio/rasterio

Issue:
Conflicting meaning of negative offsets in `Window.from_bounds` vs `Window.from_slices`
Generally, throughout the `windows` code, negative indexes are interpreted "Python style" as offsets from the "end" of the window. From `to_slices`:

        Negative start indexes are evaluated relative to the lower right of the
        dataset: rows=(-2, None), cols=(-2, None) defines a window that is 2
        rows high and 2 columns wide starting from the bottom right.

However, in `Window.from_bounds`, if the bounds you give start to the west/north of the transform's origin, the window will end up with negative offsets. But these aren't given relative to the lower-right of the dataset; they're negative relative to the top-left.

```python
from rasterio import windows, transform
import numpy as np

t = transform.from_origin(0, 0, 1, 1)  # start at (0, 0) with 1m resolution
w = windows.from_bounds(-3, -3, -1, -1, t, precision=0.0)  # window should not intersect
# Window(col_off=-2.0, row_off=0.0, width=2.0, height=2.0)

data = np.arange(25).reshape(5, 5)
# array([[ 0,  1,  2,  3,  4],
#        [ 5,  6,  7,  8,  9],
#        [10, 11, 12, 13, 14],
#        [15, 16, 17, 18, 19],
#        [20, 21, 22, 23, 24]])
```
Our window and data should be laid out like this:
<img width="462" alt="window" src="https://user-images.githubusercontent.com/3309802/148901422-ea30e9fe-9b90-409e-83a6-3f2199a68330.png">

```python
w.toslices()
# (slice(1.0, 3.0, None), slice(-3.0, -1.0, None))
# ^ NumPy will interpret those negatives as relative to the end of the array!

# Slicing the array with this window returns data, even though they shouldn't overlap
data[windows.window_index(w)]
# array([[ 7,  8],
#        [12, 13]])
```

A solution here could be having `from_bounds` subtract the width/height from `row_off`/`col_off` if they're negative (make them even more negative), so they're correct when interpreted as offsets from the lower-right corner.

## Operating system

Mac OS X 11.6

## Rasterio version and provenance

`rasterio-1.2.10-cp39-cp39-macosx_10_9_x86_64.whl` installed from PyPI using pip 21.3.1.


Discussion:

Resolving patch:
diff --git a/rasterio/windows.py b/rasterio/windows.py
--- a/rasterio/windows.py
+++ b/rasterio/windows.py
@@ -478,13 +478,10 @@ def window_index(window, height=0, width=0):
     -------
     row_slice, col_slice: slice
         A pair of slices in row, column order
+
     """
     window = evaluate(window, height=height, width=width)
-
-    (row_start, row_stop), (col_start, col_stop) = window.toranges()
-    return (
-        slice(int(math.floor(row_start)), int(math.ceil(row_stop))),
-        slice(int(math.floor(col_start)), int(math.ceil(col_stop))))
+    return window.toslices()
 
 
 def round_window_to_full_blocks(window, block_shapes, height=0, width=0):
@@ -597,8 +594,23 @@ def toslices(self):
         -------
         row_slice, col_slice: slice
             A pair of slices in row, column order
+
         """
-        return tuple(slice(*rng) for rng in self.toranges())
+        (r0, r1), (c0, c1) = self.toranges()
+
+        if r0 < 0:
+            r0 = 0
+        if r1 < 0:
+            r1 = 0
+        if c0 < 0:
+            c0 = 0
+        if c1 < 0:
+            c1 = 0
+
+        return (
+            slice(int(math.floor(r0)), int(math.ceil(r1))),
+            slice(int(math.floor(c0)), int(math.ceil(c1))),
+        )
 
     @classmethod
     def from_slices(cls, rows, cols, height=-1, width=-1, boundless=False):


Test patch:
diff --git a/tests/test_windows.py b/tests/test_windows.py
--- a/tests/test_windows.py
+++ b/tests/test_windows.py
@@ -9,6 +9,7 @@
 from hypothesis.strategies import floats, integers
 
 import rasterio
+from rasterio.transform import from_origin
 from rasterio.errors import WindowError
 from rasterio.windows import (
     crop, from_bounds, bounds, transform, evaluate, window_index, shape,
@@ -34,8 +35,7 @@ def assert_window_almost_equals(a, b):
 
 
 def test_window_repr():
-    assert str(Window(0, 1, 4, 2)) == ('Window(col_off=0, row_off=1, width=4, '
-                                       'height=2)')
+    assert str(Window(0, 1, 4, 2)) == ('Window(col_off=0, row_off=1, width=4, height=2)')
 
 
 @settings(suppress_health_check=[HealthCheck.filter_too_much])
@@ -95,19 +95,25 @@ def test_window_toranges(col_off, row_off, width, height):
 
 
 @settings(suppress_health_check=[HealthCheck.filter_too_much])
-@given(col_off=F_OFF, row_off=F_OFF, width=F_LEN, height=F_LEN)
-def test_window_toslices(col_off, row_off, width, height):
-    """window.toslices() should match inputs"""
-
-    expected_slices = (slice(row_off, row_off + height),
-                       slice(col_off, col_off + width))
-
-    slices = Window(col_off, row_off, width, height).toslices()
-
-    assert np.allclose(
-        [(s.start, s.stop) for s in slices],
-        [(s.start, s.stop) for s in expected_slices]
-    )
+@given(
+    col_off=F_OFF,
+    row_off=F_OFF,
+    width=F_LEN,
+    height=F_LEN,
+    arr_width=I_LEN,
+    arr_height=I_LEN,
+)
+def test_window_toslices(col_off, row_off, width, height, arr_width, arr_height):
+    """window.toslices() should match inputs and be properly end indexed (see gh-2378)"""
+    row_slice, col_slice = Window(col_off, row_off, width, height).toslices()
+    assert isinstance(row_slice.start, int)
+    assert row_slice.start == int(math.floor(row_off)) or row_slice.start == 0
+    assert isinstance(row_slice.stop, int)
+    assert row_slice.stop == int(math.ceil(row_off + height)) or row_slice.stop == 0
+    assert isinstance(col_slice.start, int)
+    assert col_slice.start == int(math.floor(col_off)) or col_slice.start == 0
+    assert isinstance(col_slice.stop, int)
+    assert col_slice.stop == int(math.ceil(col_off + width)) or col_slice.stop == 0
 
 
 @settings(suppress_health_check=[HealthCheck.filter_too_much])
@@ -649,3 +655,13 @@ def test_union_boundless_above():
     assert uw.height == 4
     assert uw.width == 2
     assert uw.col_off == 0
+
+
+def test_nonintersecting_window_index():
+    """See gh-2378"""
+    t = from_origin(0, 0, 1, 1)
+    w = from_bounds(-3, -3, -1, -1, t)
+    data = np.arange(25).reshape(5, 5)
+    selection = data[window_index(w, height=5, width=5)]
+    assert selection.shape == (2, 0)
+    assert selection.flatten().tolist() == []