Repository: rasterio/rasterio

Issue:
Allow no-op for mismatch between input and --like file in rio-rasterize 
Consider the following pipeline in which shade.tif is an EPSG:3857 dataset:

```
$ rio shapes tests/data/shade.tif \
| rio rasterize --like tests/data/shade.tif /tmp/foo.tif
```

rio-shapes extracts the features and, since `--geographic` is the default, writes CRS84 GeoJSON. The GeoJSON is passed to rio-rasterize, and is nowhere within the bounds of the output dataset. A blank dataset results. We're currently raising `BadArgument` when the input and output bounds are disjoint, which helps, but introduces a new problem.

The problem: sometimes a blank dataset might be an acceptable or desirable result. The input GeoJSON and output file might have the same CRS, but simply don't overlap. Can we add an `--ignore-errors` (or `--no-warnings`, see next paragraph) option to rio-rasterize that would permit a no-op instead of raising an exception?

I'm also second guessing my suggestion to raise an exception. How about a warning, @brendan-ward?



Discussion:
Sure, a warning sounds fine too.  At least until we start writing rasters to stdout or similar chaining as you suggested in #188 

But perhaps the bigger issue here is that there is a CRS mismatch between the shapes and the existing output or `--like` raster which in this case happens to manifest itself as a bounds mismatch.

I'll need to look into how we can create a CRS comparison function based on OGR.  The way this keeps coming up I think we need to cover this case as well.


Resolving patch:
diff --git a/rasterio/rio/features.py b/rasterio/rio/features.py
--- a/rasterio/rio/features.py
+++ b/rasterio/rio/features.py
@@ -165,7 +165,7 @@ def __call__(self):
               'reference system. Pixels assumed to be square if this option is '
               'used once, otherwise use: '
               '--res pixel_width --res pixel_height')
-@click.option('--src_crs', default='EPSG:4326',
+@click.option('--src_crs', default=None,
               help='Source coordinate reference system.  Limited to EPSG '
               'codes for now.  Used as output coordinate system if output does '
               'not exist or --like option is not used. Default: EPSG:4326')
@@ -197,24 +197,22 @@ def rasterize(
 
     If the output raster exists, rio-rasterize will rasterize feature values
     into all bands of that raster.  The GeoJSON is assumed to be in the same
-    coordinate reference system as the output.
+    coordinate reference system as the output unless --src_crs is provided.
 
     --default_value or property values when using --property must be using a
     data type valid for the data type of that raster.
 
 
     If a template raster is provided using the --like option, the affine
-    transform, coordinate reference system, and data type from that raster will
-    be used to create the output.
+    transform and data type from that raster will be used to create the output.
+    The GeoJSON is assumed to be in the same coordinate reference system unless
+    --src_crs is provided.
 
     --default_value or property values when using --property must be using a
     data type valid for the data type of that raster.
 
-    --driver, --bounds, --dimensions, --res, and --src_crs are ignored when
-    output exists or --like raster is provided
-
-    The GeoJSON must be within the bounds of the existing output or --like
-    raster, or an error will be raised.
+    --driver, --bounds, --dimensions, and --res are ignored when output exists
+    or --like raster is provided
 
 
     If the output does not exist and --like raster is not provided, the input
@@ -233,7 +231,7 @@ def rasterize(
     added in the future.
     """
 
-    import numpy as np
+    from rasterio._base import is_geographic_crs, is_same_crs
     from rasterio.features import rasterize
     from rasterio.features import bounds as calculate_bounds
 
@@ -242,6 +240,8 @@ def rasterize(
     files = list(files)
     output = files.pop()
     input = click.open_file(files.pop(0) if files else '-')
+    has_src_crs = src_crs is not None
+    src_crs = src_crs or 'EPSG:4326'
 
     # If values are actually meant to be integers, we need to cast them
     # as such or rasterize creates floating point outputs
@@ -278,10 +278,15 @@ def disjoint_bounds(bounds1, bounds2):
 
         if os.path.exists(output):
             with rasterio.open(output, 'r+') as out:
-                if disjoint_bounds(geojson_bounds, out.bounds):
-                    raise click.BadParameter('GeoJSON outside bounds of '
+                if has_src_crs and not is_same_crs(src_crs, out.crs):
+                    raise click.BadParameter('GeoJSON does not match crs of '
                                              'existing output raster',
-                                             param=input, param_hint='input')
+                                             param='input', param_hint='input')
+
+                if disjoint_bounds(geojson_bounds, out.bounds):
+                    warnings.warn('GeoJSON outside bounds of existing output '
+                                  'raster.  Are they in different coordinate '
+                                  'reference systems?')
 
                 meta = out.meta.copy()
 
@@ -305,10 +310,16 @@ def disjoint_bounds(bounds1, bounds2):
         else:
             if like is not None:
                 template_ds = rasterio.open(like)
+
+                if has_src_crs and not is_same_crs(src_crs, template_ds.crs):
+                    raise click.BadParameter('GeoJSON does not match crs of '
+                                             '--like raster',
+                                             param='input', param_hint='input')
+
                 if disjoint_bounds(geojson_bounds, template_ds.bounds):
-                    raise click.BadParameter('GeoJSON outside bounds of '
-                                             '--like raster', param=input,
-                                             param_hint='input')
+                    warnings.warn('GeoJSON outside bounds of --like raster. '
+                                  'Are they in different coordinate reference '
+                                  'systems?')
 
                 kwargs = template_ds.meta.copy()
                 kwargs['count'] = 1
@@ -317,11 +328,12 @@ def disjoint_bounds(bounds1, bounds2):
             else:
                 bounds = bounds or geojson_bounds
 
-                if src_crs == 'EPSG:4326':
+                if is_geographic_crs(src_crs):
                     if (bounds[0] < -180 or bounds[2] > 180 or
                         bounds[1] < -80 or bounds[3] > 80):
                         raise click.BadParameter(
-                            'Bounds are beyond the valid extent for EPSG:4326.',
+                            'Bounds are beyond the valid extent for geographic '
+                            'coordinates.',
                             ctx, param=bounds, param_hint='--bounds')
 
                 if dimensions:


Test patch:
diff --git a/tests/test_crs.py b/tests/test_crs.py
--- a/tests/test_crs.py
+++ b/tests/test_crs.py
@@ -5,6 +5,7 @@
 
 import rasterio
 from rasterio import crs
+from rasterio._base import is_geographic_crs, is_projected_crs, is_same_crs
 
 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
 
@@ -56,6 +57,15 @@ def test_write_3857(tmpdir):
     AUTHORITY["EPSG","3857"]]""" in info.decode('utf-8')
 
 
+def test_from_epsg():
+    crs_dict = crs.from_epsg(4326)
+    assert crs_dict['init'].lower() == 'epsg:4326'
+
+    # Test with invalid EPSG code
+    with pytest.raises(ValueError):
+        assert crs.from_epsg(0)
+
+
 def test_bare_parameters():
     """ Make sure that bare parameters (e.g., no_defs) are handled properly,
     even if they come in with key=True.  This covers interaction with pyproj,
@@ -64,3 +74,47 @@ def test_bare_parameters():
     # Example produced by pyproj
     crs_dict = crs.from_string('+lon_0=-95 +ellps=GRS80 +y_0=0 +no_defs=True +proj=lcc +x_0=0 +units=m +lat_2=77 +lat_1=49 +lat_0=0')
     assert crs_dict.get('no_defs', False) is True
+
+    crs_dict = crs.from_string('+lon_0=-95 +ellps=GRS80 +y_0=0 +no_defs=False +proj=lcc +x_0=0 +units=m +lat_2=77 +lat_1=49 +lat_0=0')
+    assert crs_dict.get('no_defs', True) is False
+
+
+def test_is_geographic():
+    assert is_geographic_crs({'init': 'EPSG:4326'}) is True
+    assert is_geographic_crs({'init': 'EPSG:3857'}) is False
+
+    wgs84_crs = crs.from_string('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs')
+    assert is_geographic_crs(wgs84_crs) is True
+
+    nad27_crs = crs.from_string('+proj=longlat +ellps=clrk66 +datum=NAD27 +no_defs')
+    assert is_geographic_crs(nad27_crs) is True
+
+    lcc_crs = crs.from_string('+lon_0=-95 +ellps=GRS80 +y_0=0 +no_defs=True +proj=lcc +x_0=0 +units=m +lat_2=77 +lat_1=49 +lat_0=0')
+    assert is_geographic_crs(lcc_crs) is False
+
+
+def test_is_projected():
+    assert is_projected_crs({'init': 'EPSG:3857'}) is True
+    assert is_projected_crs({'init': 'EPSG:4326'}) is False
+
+    lcc_crs = crs.from_string('+lon_0=-95 +ellps=GRS80 +y_0=0 +no_defs=True +proj=lcc +x_0=0 +units=m +lat_2=77 +lat_1=49 +lat_0=0')
+    assert is_projected_crs(lcc_crs) is True
+
+    wgs84_crs = crs.from_string('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs')
+    assert is_projected_crs(wgs84_crs) is False
+
+
+def test_is_same_crs():
+    crs1 = {'init': 'EPSG:4326'}
+    crs2 = {'init': 'EPSG:3857'}
+
+    assert is_same_crs(crs1, crs1) is True
+    assert is_same_crs(crs1, crs2) is False
+
+    wgs84_crs = crs.from_string('+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs')
+    assert is_same_crs(crs1, wgs84_crs) is True
+
+    # Make sure that same projection with different parameter are not equal
+    lcc_crs1 = crs.from_string('+lon_0=-95 +ellps=GRS80 +y_0=0 +no_defs=True +proj=lcc +x_0=0 +units=m +lat_2=77 +lat_1=49 +lat_0=0')
+    lcc_crs2 = crs.from_string('+lon_0=-95 +ellps=GRS80 +y_0=0 +no_defs=True +proj=lcc +x_0=0 +units=m +lat_2=77 +lat_1=45 +lat_0=0')
+    assert is_same_crs(lcc_crs1, lcc_crs2) is False
\ No newline at end of file
diff --git a/tests/test_rio_features.py b/tests/test_rio_features.py
--- a/tests/test_rio_features.py
+++ b/tests/test_rio_features.py
@@ -232,6 +232,18 @@ def test_rasterize(tmpdir, runner):
         assert (data == 0).sum() == 55
         assert (data == 1).sum() == 145
 
+    # Test that src_crs is written into new output
+    output = str(tmpdir.join('test4.tif'))
+    result = runner.invoke(features.rasterize,
+                           [output,
+                            '--dimensions', 20, 10,
+                            '--src_crs', 'EPSG:3857'
+                           ],
+                           input=TEST_MERC_FEATURECOLLECTION)
+    assert result.exit_code == 0
+    with rasterio.open(output) as out:
+        assert out.crs['init'].lower() == 'epsg:3857'
+
 
 def test_rasterize_existing_output(tmpdir, runner):
     output = str(tmpdir.join('test.tif'))
@@ -266,6 +278,14 @@ def test_rasterize_existing_output(tmpdir, runner):
         assert (data == 1).sum() == 125
         assert (data == 2).sum() == 20
 
+    # Confirm that a different src_crs is rejected, even if a geographic crs
+    result = runner.invoke(features.rasterize,
+                           [output,
+                            '--res', 0.5,
+                            '--src_crs', 'EPSG:4269'
+                            ], input=TEST_FEATURES)
+    assert result.exit_code == 2
+
 
 def test_rasterize_like(tmpdir, runner):
     output = str(tmpdir.join('test.tif'))
@@ -286,6 +306,15 @@ def test_rasterize_like(tmpdir, runner):
                            [output, '--like', 'foo.tif'], input=TEST_FEATURES)
     assert result.exit_code == 2
 
+    # Test that src_crs different than --like raster crs breaks
+    output = str(tmpdir.join('test3.tif'))
+    result = runner.invoke(features.rasterize,
+                           [output,
+                            '--like', 'tests/data/shade.tif',
+                            '--src_crs', 'EPSG:4326'],
+                           input=TEST_FEATURES)
+    assert result.exit_code == 2
+
 
 def test_rasterize_property_value(tmpdir, runner):
     # Test feature collection property values
@@ -319,6 +348,7 @@ def test_rasterize_property_value(tmpdir, runner):
         assert (data == 0).sum() == 55
         assert (data == 15).sum() == 145
 
+
 def test_rasterize_out_of_bounds(tmpdir, runner):
     output = str(tmpdir.join('test.tif'))
 
@@ -326,18 +356,14 @@ def test_rasterize_out_of_bounds(tmpdir, runner):
     result = runner.invoke(features.rasterize,
                            [output, '--like', 'tests/data/shade.tif'],
                            input=TEST_FEATURES)
-    assert result.exit_code == 2
-
-    # Test out of bounds of existing output raster (first have to create one)
-    result = runner.invoke(features.rasterize,
-                           [output,
-                            '--res', 1000,
-                            '--property', 'val',
-                            '--src_crs', 'EPSG:3857'
-                           ],
-                           input=TEST_MERC_FEATURECOLLECTION)
     assert result.exit_code == 0
+    assert 'outside bounds' in result.output
     assert os.path.exists(output)
+    with rasterio.open(output) as out:
+        data = out.read_band(1, masked=False)
+        assert data.sum() == 0
 
+    # Confirm that this does not fail when out of bounds for existing raster
     result = runner.invoke(features.rasterize, [output], input=TEST_FEATURES)
-    assert result.exit_code == 2
+    assert result.exit_code == 0
+    assert 'outside bounds' in result.output