Repository: rasterio/rasterio

Issue:
Rio reproject introduces 0 values
I have a dataset with floating point values between 1.04 and 38.32

When I reproject it from WGS 84 
![rcp85_ensemble__0](https://cloud.githubusercontent.com/assets/3375604/7330888/e53d1f94-eaad-11e4-8ea5-9655814d620a.png)

to something that involves some warping outside the original extent (e.g., UTM Zone 10N)
![rcp85_ensemble__0](https://cloud.githubusercontent.com/assets/3375604/7330902/94d9fb84-eaae-11e4-899c-350c310d78e2.png)

Areas outside the original extent are filled with 0 (black above), regardless of what I pass in as additional `kwargs` to `reproject`  (e.g., `INIT_DEST=100`).

Obviously, if 0 is not in my original value range, this is easy, but often 0 is a meaningful data value.

My instinct is that we are not setting [destination nodata values](http://www.gdal.org/structGDALWarpOptions.html#af39f33665c4eb2b22b89372c6bae009a) properly.  I'll investigate a fix.

We'll definitely want to sort this out for #264 



Discussion:

Resolving patch:
diff --git a/rasterio/warp.py b/rasterio/warp.py
--- a/rasterio/warp.py
+++ b/rasterio/warp.py
@@ -1,5 +1,9 @@
 """Raster warping and reprojection"""
 
+from affine import Affine
+from math import ceil
+import numpy as np
+
 from rasterio._base import _transform
 from rasterio._warp import _transform_geom, _reproject, RESAMPLING
 from rasterio.transform import guard_transform
@@ -77,18 +81,88 @@ def transform_geom(
         precision)
 
 
+def transform_bounds(left, bottom, right, top, src_crs, dst_crs, densify_pts=21):
+    """
+    Transforms bounds from src_crs to dst_crs, optionally densifying the edges
+    (to account for nonlinear transformations along these edges) and extracting
+    the outermost bounds.
+
+    Note: this does not account for the antimeridian.
+
+    Parameters
+    ----------
+    left, bottom, right, top: float
+        Bounding coordinates in src_crs, from the bounds property of a raster.
+    src_crs: dict
+        Source coordinate reference system, in rasterio dict format.
+        Example: {'init': 'EPSG:4326'}
+    dst_crs: dict
+        Target coordinate reference system.
+    densify_pts: uint, optional
+        Number of points to add to each edge to account for nonlinear
+        edges produced by the transform process.  Large numbers will produce
+        worse performance.  Default: 21 (gdal default).
+
+    Returns
+    -------
+    left, bottom, right, top: float
+        Outermost coordinates in target coordinate reference system.
+    """
+
+    if densify_pts < 0:
+        raise ValueError('densify parameter must be >= 0')
+
+    in_xs = []
+    in_ys = []
+
+    if densify_pts > 0:
+        densify_factor = 1.0 / float(densify_pts + 1)
+
+        # Add points along outer edges.
+        for x in (left, right):
+            in_xs.extend([x] * (densify_pts + 2))
+            in_ys.extend(
+                bottom + np.arange(0, densify_pts + 2, dtype=np.float32)
+                * ((top - bottom) * densify_factor)
+            )
+
+        for y in (bottom, top):
+            in_xs.extend(
+                left + np.arange(1, densify_pts + 1, dtype=np.float32)
+                * ((right - left) * densify_factor)
+            )
+            in_ys.extend([y] * densify_pts)
+
+    else:
+        in_xs = [left, left, right, right]
+        in_ys = [bottom, top, bottom, top]
+
+    xs, ys = transform(src_crs, dst_crs, in_xs, in_ys)
+    return (min(xs), min(ys), max(xs), max(ys))
+
+
 def reproject(
         source,
         destination,
         src_transform=None,
         src_crs=None,
+        src_nodata=None,
         dst_transform=None,
         dst_crs=None,
+        dst_nodata=None,
         resampling=RESAMPLING.nearest,
         **kwargs):
     """
     Reproject a source raster to a destination raster.
 
+    If the source and destination are ndarrays, coordinate reference
+    system definitions and affine transformation parameters are required
+    for reprojection.
+
+    If the source and destination are rasterio Bands, shorthand for
+    bands of datasets on disk, the coordinate reference systems and
+    transforms will be read from the appropriate datasets.
+
     Parameters
     ------------
     source: ndarray or rasterio Band
@@ -103,12 +177,22 @@ def reproject(
         Required if source and destination are ndarrays.
         Will be derived from source if it is a rasterio Band.
         Example: {'init': 'EPSG:4326'}
+    src_nodata: int or float, optional
+        The source nodata value.  Pixels with this value will not be used
+        for interpolation.  If not set, it will be default to the
+        nodata value of the source image if a masked ndarray or rasterio band,
+        if available.  Must be provided if dst_nodata is not None.
     dst_transform: affine transform object, optional
         Target affine transformation.  Required if source and destination
         are ndarrays.  Will be derived from target if it is a rasterio Band.
     dst_crs: dict, optional
         Target coordinate reference system.  Required if source and destination
         are ndarrays.  Will be derived from target if it is a rasterio Band.
+    dst_nodata: int or float, optional
+        The nodata value used to initialize the destination; it will remain
+        in all areas not covered by the reprojected source.  Defaults to the
+        nodata value of the destination image (if set), the value of
+        src_nodata, or 0 (GDAL default).
     resampling: int
         Resampling method to use.  One of the following:
             RESAMPLING.nearest,
@@ -137,7 +221,81 @@ def reproject(
         destination,
         src_transform,
         src_crs,
+        src_nodata,
         dst_transform,
         dst_crs,
+        dst_nodata,
         resampling,
         **kwargs)
+
+
+def calculate_default_transform(
+        left,
+        bottom,
+        right,
+        top,
+        width,
+        height,
+        src_crs,
+        dst_crs,
+        resolution=None,
+        densify_pts=21):
+    """
+    Transforms bounds to destination coordinate system, calculates resolution
+    if not provided, and returns destination transform and dimensions.
+    Intended to be used to calculate parameters for reproject function.
+
+    Destination transform is anchored from the left, top coordinate.
+
+    Destination width and height are calculated from the number of pixels on
+    each dimension required to fit the destination bounds.
+
+    If resolution is not provided, it is calculated using a weighted average
+    of the relative sizes of source width and height compared to the transformed
+    bounds (pixels are assumed to be square).
+
+
+    Parameters
+    ----------
+    left, bottom, right, top: float
+        Bounding coordinates in src_crs, from the bounds property of a raster.
+    src_crs: dict
+        Source coordinate reference system, in rasterio dict format.
+        Example: {'init': 'EPSG:4326'}
+    dst_crs: dict
+        Target coordinate reference system.
+    resolution: tuple (x resolution, y resolution) or float, optional
+        Target resolution, in units of target coordinate reference system.
+    densify_pts: uint, optional
+        Number of points to add to each edge to account for nonlinear
+        edges produced by the transform process.  Large numbers will produce
+        worse performance.  Default: 21 (gdal default).
+
+    Returns
+    -------
+    tuple of destination affine transform, width, and height
+    """
+
+    xmin, ymin, xmax, ymax = transform_bounds(
+        left, bottom, right, top, src_crs, dst_crs, densify_pts)
+
+    x_dif = xmax - xmin
+    y_dif = ymax - ymin
+    size = float(width + height)
+
+    if resolution is None:
+        # TODO: compare to gdalwarp default
+        avg_resolution = (
+            (x_dif / float(width)) * (float(width) / size) +
+            (y_dif / float(height)) * (float(height) / size)
+        )
+        resolution = (avg_resolution, avg_resolution)
+
+    elif not isinstance(resolution, (tuple, list)):
+        resolution = (resolution, resolution)
+
+    dst_affine = Affine(resolution[0], 0, xmin, 0, -resolution[1], ymax)
+    dst_width = max(int(ceil(x_dif / resolution[0])), 1)
+    dst_height = max(int(ceil(y_dif / resolution[1])), 1)
+
+    return dst_affine, dst_width, dst_height
\ No newline at end of file


Test patch:
diff --git a/tests/test_warp.py b/tests/test_warp.py
--- a/tests/test_warp.py
+++ b/tests/test_warp.py
@@ -1,13 +1,14 @@
 
 import logging
-import subprocess
 import sys
-
+import pytest
 from affine import Affine
 import numpy
 
 import rasterio
-from rasterio.warp import reproject, RESAMPLING, transform_geom, transform
+from rasterio.warp import (
+    reproject, RESAMPLING, transform_geom, transform, transform_bounds,
+    calculate_default_transform)
 
 
 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
@@ -16,6 +17,37 @@
 DST_TRANSFORM = Affine.from_gdal(-8789636.708, 300.0, 0.0, 2943560.235, 0.0, -300.0)
 
 
+class ReprojectParams(object):
+    """ Class to assist testing reprojection by encapsulating parameters """
+    def __init__(self, left, bottom, right, top, width, height, src_crs,
+                 dst_crs):
+        self.width = width
+        self.height = height
+        src_res = float(right - left) / float(width)
+        self.src_transform = Affine(src_res, 0, left, 0, -src_res, top)
+        self.src_crs = src_crs
+        self.dst_crs = dst_crs
+
+        with rasterio.drivers():
+            dt, dw, dh = calculate_default_transform(
+                left, bottom, right, top, width, height, src_crs, dst_crs)
+            self.dst_transform = dt
+            self.dst_width = dw
+            self.dst_height = dh
+
+
+def default_reproject_params():
+    return ReprojectParams(
+        left=-120,
+        bottom=30,
+        right=-80,
+        top=70,
+        width=80,
+        height=80,
+        src_crs={'init': 'EPSG:4326'},
+        dst_crs={'init': 'EPSG:32610'})
+
+
 def test_transform():
     """2D and 3D"""
     WGS84_crs = {'init': 'EPSG:4326'}
@@ -31,6 +63,128 @@ def test_transform():
     assert numpy.allclose(numpy.array(UTM33_result), numpy.array(UTM33_points))
 
 
+def test_transform_bounds():
+    with rasterio.drivers():
+        with rasterio.open('tests/data/RGB.byte.tif') as src:
+            l, b, r, t = src.bounds
+            assert numpy.allclose(
+                transform_bounds(l, b, r, t, src.crs, {'init': 'EPSG:4326'}),
+                (
+                    -78.95864996545055, 23.564991210854686,
+                    -76.57492370013823, 25.550873767433984
+                )
+            )
+
+
+def test_transform_bounds_densify():
+    # This transform is non-linear along the edges, so densification produces
+    # a different result than otherwise
+    src_crs = {'init': 'EPSG:4326'}
+    dst_crs = {'init': 'EPSG:32610'}
+    assert numpy.allclose(
+        transform_bounds(
+            -120, 40, -80, 64,
+            src_crs,
+            dst_crs,
+            densify_pts=0
+        ),
+        (
+            646695.227266598, 4432069.056898901,
+            4201818.984205882, 7807592.187464975
+        )
+    )
+
+    assert numpy.allclose(
+        transform_bounds(
+            -120, 40, -80, 64,
+            src_crs,
+            dst_crs,
+            densify_pts=100
+        ),
+        (
+            646695.2272665979, 4432069.056898901,
+            4201818.984205882, 7807592.187464977
+        )
+    )
+
+
+def test_transform_bounds_no_change():
+    """ Make sure that going from and to the same crs causes no change """
+    with rasterio.drivers():
+        with rasterio.open('tests/data/RGB.byte.tif') as src:
+            l, b, r, t = src.bounds
+            assert numpy.allclose(
+                transform_bounds(l, b, r, t, src.crs, src.crs),
+                src.bounds
+            )
+
+
+def test_transform_bounds_densify_out_of_bounds():
+    with pytest.raises(ValueError):
+        transform_bounds(
+            -120, 40, -80, 64,
+            {'init': 'EPSG:4326'},
+            {'init': 'EPSG:32610'},
+            densify_pts=-10
+        )
+
+
+def test_calculate_default_transform():
+    target_transform = Affine(
+        0.0028956983577810586, 0.0, -78.95864996545055,
+        0.0, -0.0028956983577810586, 25.550873767433984
+    )
+    with rasterio.drivers():
+        with rasterio.open('tests/data/RGB.byte.tif') as src:
+            l, b, r, t = src.bounds
+            wgs84_crs = {'init': 'EPSG:4326'}
+            dst_transform, width, height = calculate_default_transform(
+                l, b, r, t, src.width, src.height, src.crs, wgs84_crs)
+
+            assert dst_transform.almost_equals(target_transform)
+            assert width == 824
+            assert height == 686
+
+
+def test_calculate_default_transform_single_resolution():
+    with rasterio.drivers():
+        with rasterio.open('tests/data/RGB.byte.tif') as src:
+            l, b, r, t = src.bounds
+            target_resolution = 0.1
+            target_transform = Affine(
+                target_resolution, 0.0, -78.95864996545055,
+                0.0, -target_resolution, 25.550873767433984
+            )
+            dst_transform, width, height = calculate_default_transform(
+                l, b, r, t, src.width, src.height, src.crs,
+                {'init': 'EPSG:4326'}, resolution=target_resolution
+            )
+
+            assert dst_transform.almost_equals(target_transform)
+            assert width == 24
+            assert height == 20
+
+
+def test_calculate_default_transform_multiple_resolutions():
+    with rasterio.drivers():
+        with rasterio.open('tests/data/RGB.byte.tif') as src:
+            l, b, r, t = src.bounds
+            target_resolution = (0.2, 0.1)
+            target_transform = Affine(
+                target_resolution[0], 0.0, -78.95864996545055,
+                0.0, -target_resolution[1], 25.550873767433984
+            )
+
+            dst_transform, width, height = calculate_default_transform(
+                l, b, r, t, src.width, src.height, src.crs,
+                {'init': 'EPSG:4326'}, resolution=target_resolution
+            )
+
+            assert dst_transform.almost_equals(target_transform)
+            assert width == 12
+            assert height == 20
+
+
 def test_reproject_ndarray():
     with rasterio.drivers():
         with rasterio.open('tests/data/RGB.byte.tif') as src:
@@ -98,6 +252,122 @@ def test_reproject_out_of_bounds():
         assert not out.any()
 
 
+def test_reproject_nodata():
+    params = default_reproject_params()
+    nodata = 215
+
+    with rasterio.drivers():
+        source = numpy.ones((params.width, params.height), dtype=numpy.uint8)
+        out = numpy.zeros((params.dst_width, params.dst_height),
+                          dtype=source.dtype)
+        out.fill(120)  # Fill with arbitrary value
+
+        reproject(
+            source,
+            out,
+            src_transform=params.src_transform,
+            src_crs=params.src_crs,
+            src_nodata=nodata,
+            dst_transform=params.dst_transform,
+            dst_crs=params.dst_crs,
+            dst_nodata=nodata
+        )
+
+        assert (out == 1).sum() == 4461
+        assert (out == nodata).sum() == (params.dst_width *
+                                         params.dst_height - 4461)
+
+
+def test_reproject_dst_nodata_default():
+    """
+    If nodata is not provided, destination will be filled with 0
+    instead of nodata
+    """
+
+    params = default_reproject_params()
+
+    with rasterio.drivers():
+        source = numpy.ones((params.width, params.height), dtype=numpy.uint8)
+        out = numpy.zeros((params.dst_width, params.dst_height),
+                          dtype=source.dtype)
+        out.fill(120)  # Fill with arbitrary value
+
+        reproject(
+            source,
+            out,
+            src_transform=params.src_transform,
+            src_crs=params.src_crs,
+            dst_transform=params.dst_transform,
+            dst_crs=params.dst_crs
+        )
+
+        assert (out == 1).sum() == 4461
+        assert (out == 0).sum() == (params.dst_width *
+                                    params.dst_height - 4461)
+
+
+def test_reproject_invalid_dst_nodata():
+    """ dst_nodata must be in value range of data type """
+    params = default_reproject_params()
+
+    with rasterio.drivers():
+        source = numpy.ones((params.width, params.height), dtype=numpy.uint8)
+        out = source.copy()
+
+        with pytest.raises(ValueError):
+            reproject(
+                source,
+                out,
+                src_transform=params.src_transform,
+                src_crs=params.src_crs,
+                src_nodata=0,
+                dst_transform=params.dst_transform,
+                dst_crs=params.dst_crs,
+                dst_nodata=999999999
+            )
+
+
+def test_reproject_missing_src_nodata():
+    """ src_nodata is required if dst_nodata is not None """
+    params = default_reproject_params()
+
+    with rasterio.drivers():
+        source = numpy.ones((params.width, params.height), dtype=numpy.uint8)
+        out = source.copy()
+
+        with pytest.raises(ValueError):
+            reproject(
+                source,
+                out,
+                src_transform=params.src_transform,
+                src_crs=params.src_crs,
+                dst_transform=params.dst_transform,
+                dst_crs=params.dst_crs,
+                dst_nodata=215
+            )
+
+
+def test_reproject_invalid_src_nodata():
+    """ src_nodata must be in range for data type """
+    params = default_reproject_params()
+
+    with rasterio.drivers():
+        source = numpy.ones((params.width, params.height), dtype=numpy.uint8)
+        out = source.copy()
+
+        with pytest.raises(ValueError):
+            reproject(
+                source,
+                out,
+                src_transform=params.src_transform,
+                src_crs=params.src_crs,
+                src_nodata=999999999,
+                dst_transform=params.dst_transform,
+                dst_crs=params.dst_crs,
+                dst_nodata=215
+            )
+
+
 def test_reproject_multi():
     """Ndarry to ndarray"""
     with rasterio.drivers():