Repository: rasterio/rasterio

Issue:
Extract data bounds (excluding NODATA) from raster
I have a need for auto-cropping rasters that have large amounts of `NODATA` around the periphery.  To support that, it seems like we could use a general function similar to `bounds`, e.g., `data_bounds` which would return the minimum bounding rectangle of non `NODATA` pixels.

I looked for prior works here and in scipy and didn't see anything obvious.

@sgillies any issues with adding this here?



Discussion:
@brendan-ward if I understand the usecase you described, you can do the above with:

`rio shapes --bidx 4 --as-mask <file>`

However, this is a per-pixel representation, so you'd need to do some simplification of the geometry if you want an explicit "four corner" rectangle.

@dnomadb I was thinking we could do this directly using numpy from data read from a given band (and perhaps unioned across bands), and not even need to touch gdal functions (e.g., used for `shapes`).

Found a good example to start from at the bottom of [this](http://gis.stackexchange.com/questions/45159/finding-minimum-bounding-extent-of-given-pixel-value-within-raster)

```
def get_data_window(arr):
    if len(not arr.shape) == 2:
        raise ValueError('get_data_window requires a 2D array')

    if not hasattr(arr, 'mask'):
        return (0, arr.shape[-2], arr.shape[-1], 0)

    data_rows = numpy.where(arr.mask.min(axis=-1) == False)[0]
    top = data_rows[0]
    bottom = data_rows[-1]
    data_cols = numpy.where(arr.mask.min(axis=-2) == False)[0]
    left = data_cols[0]
    right = data_cols[-1]

    return (left, bottom, right, top)
```

A few questions:
1) where to put this?  I was originally thinking in `_base.pyx` alongside `bounds`, as `data_window` and `data_bounds`, but it relies on reading the data first, and relies on numpy which is deliberately excluded from `_base.pyx`.  This could go into `_io.pyx` which has numpy.
2) how do we want to deal with multiple bands?  I was thinking we could run this for each, and union the results.  Or we could leave this up to the user (as per function above).

@brendan-ward I'm :+1: on a data_bounds in _io.pyx. I think the bounds that covers the data extent of all bands would be most obvious thing.

I've been using this technique to quickly "cinch" down arrays to the valid data extent:

Single mask band (rows, cols):

```
idxs = np.where(alpha != 0)
alpha = alpha[idxs[0].min(): idxs[0].max() + 1, idxs[1].min(): idxs[1].max()]
```

3 band with nodata (depth, rows, cols):

```
ndvs = (0, 0, 0)
idxs = np.where(np.all(np.rollaxis(rgb, 0, 3) != ndvs, axis=2))
rgb = rgb[:, idxs[0].min(): idxs[0].max() + 1, idxs[1].min(): idxs[1].max() + 1]
```


Resolving patch:
diff --git a/rasterio/__init__.py b/rasterio/__init__.py
--- a/rasterio/__init__.py
+++ b/rasterio/__init__.py
@@ -167,3 +167,83 @@ def pad(array, transform, pad_width, mode=None, **kwargs):
     padded_trans[2] -= pad_width*padded_trans[0]
     padded_trans[5] -= pad_width*padded_trans[4]
     return padded_array, Affine(*padded_trans[:6])
+
+
+def get_data_window(arr, nodata=None):
+    """
+    Returns a window for the non-nodata pixels within the input array.
+
+    Parameters
+    ----------
+    arr: numpy ndarray, <= 3 dimensions
+    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.
+
+    Returns
+    -------
+    ((row_start, row_stop), (col_start, col_stop))
+
+    """
+
+    from rasterio._io import get_data_window
+    return get_data_window(arr, nodata)
+
+
+def window_union(windows):
+    """
+    Union windows and return the outermost extent they cover.
+
+    Parameters
+    ----------
+    windows: list-like of window objects
+        ((row_start, row_stop), (col_start, col_stop))
+
+    Returns
+    -------
+    ((row_start, row_stop), (col_start, col_stop))
+    """
+
+    from rasterio._io import window_union
+    return window_union(windows)
+
+
+def window_intersection(windows):
+    """
+    Intersect windows and return the innermost extent they cover.
+
+    Will raise ValueError if windows do not intersect.
+
+    Parameters
+    ----------
+    windows: list-like of window objects
+        ((row_start, row_stop), (col_start, col_stop))
+
+    Returns
+    -------
+    ((row_start, row_stop), (col_start, col_stop))
+    """
+
+    from rasterio._io import window_intersection
+    return window_intersection(windows)
+
+
+def windows_intersect(windows):
+    """
+    Test if windows intersect.
+
+    Parameters
+    ----------
+    windows: list-like of window objects
+        ((row_start, row_stop), (col_start, col_stop))
+
+    Returns
+    -------
+    boolean:
+        True if all windows intersect.
+    """
+
+    from rasterio._io import windows_intersect
+    return windows_intersect(windows)


Test patch:
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -1,4 +1,13 @@
+import numpy
+import pytest
+
 import rasterio
+from rasterio import (
+    get_data_window, window_intersection, window_union, windows_intersect
+)
+
+
+DATA_WINDOW = ((3, 5), (2, 6))
 
 
 def test_index():
@@ -67,3 +76,119 @@ def bound_covers(bounds1, bounds2):
         win = src.window(*bounds)
         bounds_calc = list(src.window_bounds(win))
         assert bound_covers(bounds_calc, bounds)
+
+
+@pytest.fixture
+def data():
+    data = numpy.zeros((10, 10), dtype='uint8')
+    data[slice(*DATA_WINDOW[0]), slice(*DATA_WINDOW[1])] = 1
+    return data
+
+
+def test_data_window_unmasked(data):
+    window = get_data_window(data)
+    assert window == ((0, data.shape[0]), (0, data.shape[1]))
+
+
+def test_data_window_masked(data):
+    data = numpy.ma.masked_array(data, data == 0)
+    window = get_data_window(data)
+    assert window == DATA_WINDOW
+
+
+def test_data_window_nodata(data):
+    window = get_data_window(data, nodata=0)
+    assert window == DATA_WINDOW
+
+    window = get_data_window(numpy.ones_like(data), nodata=0)
+    assert window == ((0, data.shape[0]), (0, data.shape[1]))
+
+
+def test_data_window_nodata_disjunct():
+    data = numpy.zeros((3, 10, 10), dtype='uint8')
+    data[0, :4, 1:4] = 1
+    data[1, 2:5, 2:8] = 1
+    data[2, 1:6, 1:6] = 1
+    window = get_data_window(data, nodata=0)
+    assert window == ((0, 6), (1, 8))
+
+
+def test_data_window_empty_result():
+    data = numpy.zeros((3, 10, 10), dtype='uint8')
+    window = get_data_window(data, nodata=0)
+    assert window == ((0, 0), (0, 0))
+
+
+def test_data_window_masked_file():
+    with rasterio.open('tests/data/RGB.byte.tif') as src:
+        window = get_data_window(src.read(1, masked=True))
+        assert window == ((3, 714), (13, 770))
+
+        window = get_data_window(src.read(masked=True))
+        assert window == ((3, 714), (13, 770))
+
+
+def test_window_union():
+    assert window_union([
+        ((0, 6), (3, 6)),
+        ((2, 4), (1, 5))
+    ]) == ((0, 6), (1, 6))
+
+
+def test_window_intersection():
+    assert window_intersection([
+        ((0, 6), (3, 6)),
+        ((2, 4), (1, 5))
+    ]) == ((2, 4), (3, 5))
+
+    assert window_intersection([
+        ((0, 6), (3, 6)),
+        ((6, 10), (1, 5))
+    ]) == ((6, 6), (3, 5))
+
+    assert window_intersection([
+        ((0, 6), (3, 6)),
+        ((2, 4), (1, 5)),
+        ((3, 6), (0, 6))
+    ]) == ((3, 4), (3, 5))
+
+
+def test_window_intersection_disjunct():
+    with pytest.raises(ValueError):
+        window_intersection([
+            ((0, 6), (3, 6)),
+            ((100, 200), (0, 12)),
+            ((7, 12), (7, 12))
+        ])
+
+
+def test_windows_intersect():
+    assert windows_intersect([
+        ((0, 6), (3, 6)),
+        ((2, 4), (1, 5))
+    ]) == True
+
+    assert windows_intersect([
+        ((0, 6), (3, 6)),
+        ((2, 4), (1, 5)),
+        ((3, 6), (0, 6))
+    ]) == True
+
+
+def test_windows_intersect_disjunct():
+    assert windows_intersect([
+        ((0, 6), (3, 6)),
+        ((10, 20), (0, 6))
+    ]) == False
+
+    assert windows_intersect([
+        ((0, 6), (3, 6)),
+        ((2, 4), (1, 5)),
+        ((5, 6), (0, 6))
+    ]) == False
+
+    assert windows_intersect([
+        ((0, 6), (3, 6)),
+        ((2, 4), (1, 3)),
+        ((3, 6), (4, 6))
+    ]) == False
\ No newline at end of file