Repository: rasterio/rasterio

Issue:
Create a window class
Came up during discussion at the sprint.

Goals:
- present roughly similar interface as now
- consolidate related operations that are spread around rasterio in _base.pyx, windows.py, etc
- consider transform to / from bounding boxes, with an Affine. 



Discussion:
Love it. Having functionality trapped in IO base class methods is definitely problematic. I often find that when I need access to the window functions, I have in my scope an affine transform, an array, some bounds or coordinates but no open dataset.

This could be very simple now that we have the `windows` module: `Windows` would implement the sequence protocol so that we can index and unpack it as we do now and would have several other methods. One methods we haven't discussed could return a sequence of slices, allowing this type of usage:

```
arr = src.read(1)
idx = Window(0, 100, 0, 100).asindex()
sub = arr[idx]
```

I think we should hold off on a feature freeze until we do this. Okay @perrygeo @brendan-ward?


Resolving patch:
diff --git a/rasterio/windows.py b/rasterio/windows.py
--- a/rasterio/windows.py
+++ b/rasterio/windows.py
@@ -1,6 +1,7 @@
 """Window utilities and related functions.
 
-A window is a 2D ndarray indexer in the form of a tuple:
+A window is an instance of Window or a 2D ndarray indexer in the form
+of a tuple:
 
     ((row_start, row_stop), (col_start, col_stop))
 """
@@ -8,6 +9,7 @@
 import collections
 import functools
 import math
+from operator import itemgetter
 
 from affine import Affine
 import numpy as np
@@ -29,8 +31,7 @@ def wrapper(*args, **kwargs):
 
 
 def get_data_window(arr, nodata=None):
-    """
-    Returns a window for the non-nodata pixels within the input array.
+    """Window covering the input array's valid data pixels.
 
     Parameters
     ----------
@@ -38,13 +39,12 @@ def get_data_window(arr, nodata=None):
     nodata: number
         If None, will either return a full window if arr is not a masked
         array, or will use the mask to determine non-nodata pixels.
-        If provided, it must be a number within the valid range of the dtype
-        of the input array.
+        If provided, it must be a number within the valid range of the
+        dtype of the input array.
 
     Returns
     -------
-    window: tuple
-        ((row_start, row_stop), (col_start, col_stop))
+    Window
     """
 
     num_dims = len(arr.shape)
@@ -62,8 +62,7 @@ def get_data_window(arr, nodata=None):
         data_rows, data_cols = np.where(arr.mask == False)
     else:
         data_rows, data_cols = np.where(
-            np.any(np.rollaxis(arr.mask, 0, 3) == False, axis=2)
-        )
+            np.any(np.rollaxis(arr.mask, 0, 3) == False, axis=2))
 
     if data_rows.size:
         row_range = (data_rows.min(), data_rows.max() + 1)
@@ -75,7 +74,7 @@ def get_data_window(arr, nodata=None):
     else:
         col_range = (0, 0)
 
-    return (row_range, col_range)
+    return Window.from_ranges(row_range, col_range)
 
 
 @iter_args
@@ -85,61 +84,56 @@ def union(*windows):
 
     Parameters
     ----------
-    windows: list-like of window objects
-        ((row_start, row_stop), (col_start, col_stop))
+    windows: sequence
+        One or more Windows or window tuples.
 
     Returns
     -------
-    ((row_start, row_stop), (col_start, col_stop))
+    Window
     """
 
     stacked = np.dstack(windows)
-    return (
+    return Window.from_ranges(
         (stacked[0, 0].min(), stacked[0, 1].max()),
-        (stacked[1, 0].min(), stacked[1, 1]. max())
-    )
+        (stacked[1, 0].min(), stacked[1, 1]. max()))
 
 
 @iter_args
 def intersection(*windows):
-    """
-    Intersect windows and return the innermost extent they cover.
+    """Innermost extent of window intersections.
 
     Will raise ValueError if windows do not intersect.
 
     Parameters
     ----------
-    windows: list-like of window objects
-        ((row_start, row_stop), (col_start, col_stop))
+    windows: sequence
+        One or more Windows or window tuples.
 
     Returns
     -------
-    ((row_start, row_stop), (col_start, col_stop))
+    Window
     """
-
     if not intersect(windows):
         raise ValueError('windows do not intersect')
 
     stacked = np.dstack(windows)
-    return (
+    return Window.from_ranges(
         (stacked[0, 0].max(), stacked[0, 1].min()),
-        (stacked[1, 0].max(), stacked[1, 1]. min())
-    )
+        (stacked[1, 0].max(), stacked[1, 1]. min()))
 
 
 @iter_args
 def intersect(*windows):
-    """
-    Test if windows intersect.
+    """Test if all given windows intersect.
 
     Parameters
     ----------
-    windows: list-like of window objects
-        ((row_start, row_stop), (col_start, col_stop))
+    windows: sequence
+        One or more Windows or window tuples.
 
     Returns
     -------
-    boolean:
+    bool
         True if all windows intersect.
     """
 
@@ -166,40 +160,32 @@ def from_bounds(left, bottom, right, top, transform,
 
     Parameters
     ----------
-    left : float
-        Left (west) bounding coordinate
-    bottom : float
-        Bottom (south) bounding coordinate
-    right : float
-        Right (east) bounding coordinate
-    top : float
-        Top (north) bounding coordinate
+    left, bottom, right, top : float
+        Left (west), bottom (south), right (east), and top (north)
+        bounding coordinates.
     transform : Affine
-        Affine transform matrix
-    height : int
-        Number of rows
-    width : int
-        Number of columns
+        Affine transform matrix.
+    height, width : int
+        Number of rows and columns of the window.
     boundless : boolean, optional
-        If boundless is False, window is limited
-        to extent of this dataset.
+        If True, the output window's size may exceed the given height
+        and width.
     precision : int, optional
-        float precision
+        Number of decimal points of precision when computing inverse
+        transform.
 
     Returns
     -------
-    window: tuple
-        ((row_start, row_stop), (col_start, col_stop))
-        corresponding to the bounding coordinates
+    Window
+        A new Window
     """
-
     window_start = rowcol(
         transform, left, top, op=math.floor, precision=precision)
 
     window_stop = rowcol(
         transform, right, bottom, op=math.ceil, precision=precision)
 
-    window = tuple(zip(window_start, window_stop))
+    window = Window.from_ranges(*tuple(zip(window_start, window_stop)))
 
     if boundless:
         return window
@@ -210,18 +196,18 @@ def from_bounds(left, bottom, right, top, transform,
 
 
 def transform(window, transform):
-    """Get the affine transform for a dataset window.
+    """Construct an affine transform matrix relative to a window.
 
     Parameters
     ----------
-    window: tuple
-        Dataset window tuple
+    window : a Window or window tuple
+        The input window.
     transform: Affine
-        The affine transform matrix for the entire dataset
+        an affine transform matrix.
 
     Returns
     -------
-    transform: Affine
+    Affine
         The affine transform matrix for the given window
     """
     (r, _), (c, _) = window
@@ -229,37 +215,66 @@ def transform(window, transform):
 
 
 def bounds(window, transform):
-    """Get the bounds of a window
+    """Get the spatial bounds of a window.
 
     Parameters
     ----------
-    window: tuple
-        Dataset window tuple
+    window : a Window or window tuple
+        The input window.
     transform: Affine
-        The affine transform matrix for the entire dataset
+        an affine transform matrix.
 
     Returns
     -------
-    bounds : tuple
-        x_min, y_min, x_max, y_max for the given window
+    x_min, y_min, x_max, y_max : float
+        A tuple of spatial coordinate bounding values.
     """
-    ((row_min, row_max), (col_min, col_max)) = window
+    (row_min, row_max), (col_min, col_max) = window
     x_min, y_min = transform * (col_min, row_max)
     x_max, y_max = transform * (col_max, row_min)
     return x_min, y_min, x_max, y_max
 
 
 def crop(window, height, width):
-    """Returns a window cropped to fall within height and width."""
+    """Crops a window to given height and width.
+
+    Parameters
+    ----------
+    window : a Window or window tuple
+        The input window.
+    height, width : int
+        The number of rows and cols in the cropped window.
+
+    Returns
+    -------
+    Window
+        A new Window object.
+    """
     (r_start, r_stop), (c_start, c_stop) = window
-    return (
+    return Window.from_ranges(
         (min(max(r_start, 0), height), max(0, min(r_stop, height))),
         (min(max(c_start, 0), width), max(0, min(c_stop, width))))
 
 
 def evaluate(window, height, width):
-    """Evaluates a window tuple that might contain negative values
-    in the context of a raster height and width."""
+    """Evaluates a window tuple that may contain relative index values.
+
+    The height and width of the array the window targets is the context
+    for evaluation.
+
+    Parameters
+    ----------
+    window : a Window or window tuple
+        The input window.
+    height, width : int
+        The number of rows or columns in the array that the window
+        targets.
+
+    Returns
+    -------
+    Window
+        A new Window object with absolute index values.
+    """
     try:
         r, c = window
         assert len(r) == 2
@@ -293,20 +308,182 @@ def evaluate(window, height, width):
     if not c_stop >= c_start:
         raise ValueError(
             "invalid window: col range (%d, %d)" % (c_start, c_stop))
-    return (r_start, r_stop), (c_start, c_stop)
+    return Window.from_ranges((r_start, r_stop), (c_start, c_stop))
 
 
 def shape(window, height=-1, width=-1):
-    """Returns shape of a window.
+    """The shape of a window.
 
     height and width arguments are optional if there are no negative
     values in the window.
+
+    Parameters
+    ----------
+    window : a Window or window tuple
+        The input window.
+    height, width : int, optional
+        The number of rows or columns in the array that the window
+        targets.
+
+    Returns
+    -------
+    num_rows, num_cols
+        The number of rows and columns of the window.
     """
     (a, b), (c, d) = evaluate(window, height, width)
-    return b-a, d-c
+    return (b - a, d - c)
 
 
 def window_index(window):
-    # "window_" is necessary here to redundancy to disambiguate
-    # from transform.get_index and src.index
+    """Construct a pair of slice objects for ndarray indexing
+
+    Parameters
+    ----------
+    window : a Window or window tuple
+        The input window.
+
+    Returns
+    -------
+    row_slice, col_slice: slice
+        A pair of slices in row, column order
+    """
     return tuple(slice(*w) for w in window)
+
+
+class Window(tuple):
+    """Windows are rectangular subsets of rasters.
+    
+    This class abstracts the 2-tuples mentioned in the module docstring
+    and adds methods and new constructors.
+
+    Attributes
+    ----------
+    col_off
+    num_cols
+    row_off
+    num_rows
+    """
+
+    __slots__ = ()
+    _fields = ('col_off', 'row_off', 'num_cols', 'num_rows')
+
+    def __new__(cls, col_off=0, row_off=0, num_cols=0, num_rows=0):
+        """Create new instance of Window."""
+        return tuple.__new__(
+            cls,
+            ((row_off, row_off + num_rows), (col_off, col_off + num_cols)))
+
+    def __repr__(self):
+        """Return a nicely formatted representation string"""
+        return (
+            "Window(col_off={self.col_off}, row_off={self.row_off}, "
+            "num_cols={self.num_cols}, num_rows={self.num_rows})").format(
+                self=self)
+
+    def __getnewargs__(self):
+        'Return self as a plain tuple.  Used by copy and pickle.'
+        return self.flatten()
+
+    def flatten(self):
+        """A flattened form of the window.
+
+        Returns
+        -------
+        col_off, row_off, num_cols, num_rows: int
+            Window offsets and lengths.
+        """
+        return (self[1][0], self[0][0],
+                self[1][1] - self[1][0], self[0][1] - self[0][0])
+
+    def todict(self):
+        """A mapping of field names and values.
+
+        Returns
+        -------
+        dict
+        """
+        return collections.OrderedDict(zip(self._fields, self.flatten()))
+
+    def toslices(self):
+        """Slice objects for use as an ndarray indexer.
+
+        Returns
+        -------
+        row_slice, col_slice: slice
+            A pair of slices in row, column order
+        """
+        return tuple(slice(*rng) for rng in self)
+
+    @classmethod
+    def from_ranges(cls, row_range, col_range):
+        """Construct a Window from row and column range tuples.
+
+        Parameters
+        ----------
+        row_range, col_range: tuple
+            2-tuples containing start, stop indexes.
+
+        Returns
+        -------
+        Window
+        """
+        return cls(col_range[0], row_range[0],
+                   col_range[1] - col_range[0],
+                   row_range[1] - row_range[0])
+
+    @classmethod
+    def from_offlen(cls, col_off, row_off, num_cols, num_rows):
+        """Contruct a Window from offsets and lengths.
+
+        Parameters
+        ----------
+        col_off, row_off: int
+            Column and row offsets.
+        num_cols, num_rows : int
+            Lengths (width and height) of the window.
+
+        Returns
+        -------
+        Window
+        """
+        return cls(col_off, row_off, num_cols, num_rows)
+
+    @property
+    def col_off(self):
+        """Column (x) offset of the window.
+
+        Returns
+        -------
+        int
+        """
+        return self[1][0]
+
+    @property
+    def num_cols(self):
+        """Number of cols in the window.
+
+        Returns
+        -------
+        int
+        """
+        return self[1][1] - self[1][0]
+
+    @property
+    def row_off(self):
+        """Row (y) offset of the window.
+
+        Returns
+        -------
+        int
+        """
+        return self[0][0]
+
+    @property
+    def num_rows(self):
+        """Number of rows in the window.
+
+        Returns
+        -------
+        int
+        """
+        return self[0][1] - self[0][0]


Test patch:
diff --git a/tests/test_windows.py b/tests/test_windows.py
--- a/tests/test_windows.py
+++ b/tests/test_windows.py
@@ -1,14 +1,21 @@
-import rasterio
+from copy import copy
+import logging
+import sys
 
+from affine import Affine
 import numpy as np
 import pytest
 
+import rasterio
 from rasterio.windows import (
-    from_bounds, bounds, transform, evaluate, window_index, shape)
+    from_bounds, bounds, transform, evaluate, window_index, shape, Window,
+    intersect, intersection, get_data_window, union)
 
 
 EPS = 1.0e-8
 
+logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
+
 
 def test_window_function():
     with rasterio.open('tests/data/RGB.byte.tif') as src:
@@ -17,24 +24,24 @@ def test_window_function():
         height = src.height
         width = src.width
         assert from_bounds(
-            left+EPS, bottom+EPS, right-EPS, top-EPS, src.transform,
+            left + EPS, bottom + EPS, right - EPS, top - EPS, src.transform,
             height, width) == ((0, height), (0, width))
         assert from_bounds(
-            left, top-400, left+400, top, src.transform,
+            left, top - 400, left + 400, top, src.transform,
             height, width) == ((0, 2), (0, 2))
         assert from_bounds(
-            left, top-2*dy-EPS, left+2*dx-EPS, top, src.transform,
+            left, top - 2 * dy - EPS, left + 2 * dx - EPS, top, src.transform,
             height, width) == ((0, 2), (0, 2))
 
         # bounds cropped
         assert from_bounds(
-            left-2*dx, top-2*dy, left+2*dx, top+2*dy, src.transform,
-            height, width) == ((0, 2), (0, 2))
+            left - 2 * dx, top - 2 * dy, left + 2 * dx, top + 2 * dy,
+            src.transform, height, width) == ((0, 2), (0, 2))
 
         # boundless
         assert from_bounds(
-            left-2*dx, top-2*dy, left+2*dx, top+2*dy, src.transform,
-            boundless=True) == ((-2, 2), (-2, 2))
+            left - 2 * dx, top - 2 * dy, left + 2 * dx, top + 2 * dy,
+            src.transform, boundless=True) == ((-2, 2), (-2, 2))
 
 
 def test_window_function_valuerror():
@@ -43,7 +50,8 @@ def test_window_function_valuerror():
 
         with pytest.raises(ValueError):
             # No height or width
-            from_bounds(left+EPS, bottom+EPS, right-EPS, top-EPS, src.transform)
+            from_bounds(left + EPS, bottom + EPS, right - EPS, top - EPS,
+                        src.transform)
 
 
 def test_window_transform_function():
@@ -86,6 +94,7 @@ def test_eval_window_bad_structure(window):
     (((10, 5), (0, 5)), 10, 10),
     (((0, 5), (10, 5)), 10, 10))
 
+
 @pytest.mark.parametrize("params", bad_params)
 def test_eval_window_invalid_dims(params):
     with pytest.raises(ValueError):
@@ -135,3 +144,151 @@ def test_shape_negative():
     assert shape(((-10, None), (-10, None)), 100, 90) == (10, 10)
     assert shape(((~0, None), (~0, None)), 100, 90) == (1, 1)
     assert shape(((None, ~0), (None, ~0)), 100, 90) == (99, 89)
+
+
+def test_window_class_constructor():
+    """Construct a Window from offsets, height, and width"""
+    window = Window(row_off=0, col_off=1, num_rows=100, num_cols=200)
+    assert window == ((0, 100), (1, 201))
+
+
+def test_window_class_constructor_positional():
+    """Construct a Window using positional parameters"""
+    window = Window(1, 0, 200, 100)
+    assert window == ((0, 100), (1, 201))
+
+
+def test_window_class_attrs():
+    """Test Window attributes"""
+    window = Window(row_off=0, col_off=1, num_rows=100, num_cols=200)
+    assert window.col_off == 1
+    assert window.row_off == 0
+    assert window.num_cols == 200
+    assert window.num_rows == 100
+
+
+def test_window_class_repr():
+    """Test Window respresentation"""
+    window = Window(row_off=0, col_off=1, num_rows=100, num_cols=200)
+    assert repr(window) == 'Window(col_off=1, row_off=0, num_cols=200, num_rows=100)'
+    assert eval(repr(window)) == ((0, 100), (1, 201))
+
+
+def test_window_class_copy():
+    """Test Window copying"""
+    window = Window(row_off=0, col_off=1, num_rows=100, num_cols=200)
+    assert copy(window) == ((0, 100), (1, 201))
+
+
+def test_window_class_todict():
+    """Test Window.todict"""
+    window = Window(row_off=0, col_off=1, num_rows=100, num_cols=200)
+    assert window.todict() == {
+        'col_off': 1, 'num_cols': 200, 'num_rows': 100, 'row_off': 0}
+
+
+def test_window_class_toslices():
+    """Test Window.toslices"""
+    window = Window(row_off=0, col_off=1, num_rows=100, num_cols=200)
+    yslice, xslice = window.toslices()
+    assert yslice.start == 0
+    assert yslice.stop == 100
+    assert xslice.start == 1
+    assert xslice.stop == 201
+
+
+def test_window_class_intersects():
+    """Windows intersect"""
+    assert intersect(Window(0, 0, 10, 10), Window(8, 8, 10, 10))
+
+
+def test_window_class_intersects_list():
+    """A list of Windows intersect"""
+    assert intersect([Window(0, 0, 10, 10), Window(8, 8, 10, 10)])
+
+
+def test_window_class_nonintersects():
+    """Windows do not intersect"""
+    assert not intersect(Window(0, 0, 10, 10), Window(10, 10, 10, 10))
+
+
+def test_window_from_ranges():
+    """from_ranges classmethod works."""
+    assert Window.from_ranges((0, 1), (2, 3)) == ((0, 1), (2, 3))
+
+
+def test_window_from_offlen():
+    """from_offlen classmethod works."""
+    assert Window.from_offlen(2, 0, 1, 1) == ((0, 1), (2, 3))
+
+
+def test_read_with_window_class():
+    """Reading subset with Window class works"""
+    with rasterio.open('tests/data/RGB.byte.tif') as src:
+        subset = src.read(1, window=Window(0, 0, 10, 10))
+        assert subset.shape == (10, 10)
+
+
+def test_data_window_invalid_arr_dims():
+    """An array of more than 3 dimensions is invalid."""
+    arr = np.ones((3,3,3,3))
+    with pytest.raises(ValueError):
+        get_data_window(arr)
+
+
+def test_data_window_full():
+    """Get window of entirely valid data array."""
+    arr = np.ones((3,3))
+    window = get_data_window(arr)
+    assert window == ((0, 3), (0, 3))
+
+
+def test_data_window_nodata():
+    """Get window of arr with nodata."""
+    arr = np.ones((3, 3))
+    arr[0, :] = 0
+    window = get_data_window(arr, nodata=0)
+    assert window == ((1, 3), (0, 3))
+
+
+def test_data_window_novalid():
+    """Get window of arr with nodata."""
+    arr = np.ones((3, 3))
+    arr[:, :] = 0
+    window = get_data_window(arr, nodata=0)
+    assert window == ((0, 0), (0, 0))
+
+
+def test_data_window_maskedarray():
+    """Get window of masked arr."""
+    arr = np.ones((3, 3))
+    arr[0, :] = 0
+    arr = np.ma.masked_array(arr, arr == 0)
+    window = get_data_window(arr)
+    assert window == ((1, 3), (0, 3))
+
+
+def test_data_window_nodata_3d():
+    """Get window of 3d arr with nodata."""
+    arr = np.ones((3, 3, 3))
+    arr[:, 0, :] = 0
+    window = get_data_window(arr, nodata=0)
+    assert window == ((1, 3), (0, 3))
+
+
+def test_window_union():
+    """Window union works."""
+    window = union(Window(0, 0, 1, 1), Window(1, 1, 2, 2))
+    assert window == ((0, 3), (0, 3))
+
+
+def test_no_intersection():
+    """Non intersecting windows raises error."""
+    with pytest.raises(ValueError):
+        intersection(Window(0, 0, 1, 1), Window(1, 1, 2, 2))
+
+
+def test_intersection():
+    """Window intersection works."""
+    window = intersection(Window(0, 0, 10, 10), Window(8, 8, 12, 12))
+    assert window == ((8, 10), (8, 10))