Repository: rasterio/rasterio

Issue:
Add extract command to rio
Use case would be to extract a raster using features:

`> rio  extract  myraster.tif   myfeatures.geojson  output.tif`

Default would be to window the raster by the outer bounds of the features, and fill everything outside features with appropriate nodata.

I could also see adding an option for extracting using a bounding box in world coordinates of raster:
`>rio extract --bbox -10,-10,10,10  myraster.tif  output.tif`

We may want to consider harmonizing flow of inputs between this and #187 



Discussion:
@brendan-ward yes!

A few more tests and a PR will be forthcoming.

Sneak peak:

```
Usage: rio extract [OPTIONS] INPUTS... OUTPUT

  Extract pixels from all bands of a raster using features.  Features are
  assumed to be in the same coordinate reference system as the input raster.

  GeoJSON must be the first input file or provided from stdin:

  > rio extract features.json input.tif output.tif

  > rio extract input.tif output.tif < features.json

  If the output raster exists, it will be completely overwritten with the
  results of this operation.

  The result is always equal to or within the bounds of the input raster.

  --crop and --invert options are mutually exclusive.

Options:
  -f, --format, --driver TEXT  Output format driver
  --all_touched                Use all pixels touched by features for
                               extraction, otherwise use only pixels whose
                               center is within the polygon or that are
                               selected by Brezenhams line algorithm
  --crop                       Crop output raster to the extent of the
                               geometries
  --invert                     Extract all pixels not covered by features.
                               Ignored if using --crop
  --help                       Show this message and exit.
```

I didn't add the bounding box case yet (may be a later PR), and I chose to go without cropping by default, since the primary goal was to retain the inputs intact, just masked based on features.

@sgillies Along the way, I had to use the same cropping of a window to valid ranges of rows / columns in the raster (e.g., same as `read` with `boundless=False`.  Does it make sense in the `_base.pyx::DatasetReader.window()` method to have an optional `crop` parameter (or name of your choosing), where if true, it returns a window within the range of rows / columns in the raster, rather than possibly outside?  Then we could reuse this internally for the `boundless=False` case as well as in other applications like `extract` (specifically with the `--crop` option here)?

@brendan-ward a `crop` or `boundless` keyword sounds good. I'd prefer to reuse the existing concept (boundlessness) if possible.

I'm not quite clear on how much this is like or unlike aspects of gdal_translate and gdalwarp (w/cutlines). Can we write the intention as: "crop and mask a raster dataset using shapes from GeoJSON?"

@sgillies default for `window()` would be `boundless=True` to match existing functionality.  Whis is opposite the default for `boundless` in the `read()` method.  Are you OK with that?

This interface is similar to using `-cutline` in gdalwarp, and `--crop` option here is similar in intent to `-crop_to_cutline` option, but there is no reprojection going on here, and gdalwarp is not the first place I'd expect folks to look for masking / extraction only functionality.

`--bbox` (if we add it) plus `--crop` would produce results similar to gdal_translate with `-projwin` option, and probably doesn't need to be implemented here.  I'll have to think of use cases where `--bbox` option without `--crop` adds value here

I'll work on restating the intention to make it clear why one would want to use this instead.

Consistency in the defaults is super important, for sure. I'd be down to making `window()` crop by default, ie, `boundless=False` if that helps.


Resolving patch:
diff --git a/rasterio/features.py b/rasterio/features.py
--- a/rasterio/features.py
+++ b/rasterio/features.py
@@ -22,6 +22,49 @@ def emit(self, record):
 log.addHandler(NullHandler())
 
 
+def geometry_mask(
+        geometries,
+        out_shape,
+        transform,
+        all_touched=False,
+        invert=False):
+    """Create a mask from shapes.  By default, mask is intended for use as a
+    numpy mask, where pixels that overlap shapes are False.
+
+    Parameters
+    ----------
+    geometries : iterable over geometries (GeoJSON-like objects)
+    out_shape : tuple or list
+        Shape of output numpy ndarray.
+    transform : Affine transformation object
+        Transformation from pixel coordinates of `image` to the
+        coordinate system of the input `shapes`. See the `transform`
+        property of dataset objects.
+    all_touched : boolean, optional
+        If True, all pixels touched by geometries will be burned in.  If
+        false, only pixels whose center is within the polygon or that
+        are selected by Bresenham's line algorithm will be burned in.
+    invert: boolean, optional
+        If True, mask will be True for pixels that overlap shapes.
+        False by default.
+
+    Returns
+    -------
+    out : numpy ndarray of type 'bool'
+        Result
+    """
+
+    fill, mask_value = (0, 1) if invert else (1, 0)
+
+    return rasterize(
+        geometries,
+        out_shape=out_shape,
+        transform=transform,
+        all_touched=all_touched,
+        fill=fill,
+        default_value=mask_value).astype('bool')
+
+
 def shapes(image, mask=None, connectivity=4, transform=IDENTITY):
     """
     Return a generator of (polygon, value) for each each set of adjacent pixels
@@ -187,7 +230,7 @@ def rasterize(
     Parameters
     ----------
     shapes : iterable of (geometry, value) pairs or iterable over
-        geometries `geometry` can either be an object that implements
+        geometries. `geometry` can either be an object that implements
         the geo interface or GeoJSON-like object.
     out_shape : tuple or list
         Shape of output numpy ndarray.
@@ -205,11 +248,11 @@ def rasterize(
     all_touched : boolean, optional
         If True, all pixels touched by geometries will be burned in.  If
         false, only pixels whose center is within the polygon or that
-        are selected by brezenhams line algorithm will be burned in.
+        are selected by Bresenham's line algorithm will be burned in.
     default_value : int or float, optional
         Used as value for all geometries, if not provided in `shapes`.
     dtype : rasterio or numpy data type, optional
-        Used as data type for results, if `output` is not provided.
+        Used as data type for results, if `out` is not provided.
 
     Returns
     -------
diff --git a/rasterio/rio/features.py b/rasterio/rio/features.py
--- a/rasterio/rio/features.py
+++ b/rasterio/rio/features.py
@@ -1,9 +1,9 @@
-import functools
 import json
 import logging
-from math import ceil, floor
+from math import ceil
 import os
 import sys
+import shutil
 
 import click
 from cligj import (
@@ -20,6 +20,138 @@
 logger = logging.getLogger('rio')
 
 
+# Mask command
+@cli.command(short_help='Mask in raster using features.')
+@click.argument('INPUT', type=click.Path(exists=True, resolve_path=True))
+@click.argument('OUTPUT', type=click.Path(resolve_path=True))
+@click.option('-j', '--geojson-mask', 'geojson_mask',
+              type=click.Path(), default=None,
+              help='GeoJSON file to use for masking raster.  Use "-" to read '
+                   'from stdin.  If not provided, original raster will be '
+                   'returned')
+@format_opt
+@click.option('-a', '--all', 'all_touched', is_flag=True, default=False,
+              help='Use all pixels touched by features for masking, '
+                   'otherwise use only pixels whose center is within the '
+                   'polygon or that are selected by Bresenhams line algorithm')
+@click.option('--crop', is_flag=True, default=False,
+              help='Crop output raster to the extent of the geometries. '
+                   'GeoJSON must overlap input raster to use --crop')
+@click.option('-i', '--invert', is_flag=True, default=False,
+              help='Inverts the mask, so that areas covered by features are'
+                   'masked out and areas not covered are retained.  Ignored '
+                   'if using --crop')
+@click.pass_context
+def mask(
+        ctx,
+        input,
+        output,
+        geojson_mask,
+        driver,
+        all_touched,
+        crop,
+        invert):
+
+    """Masks in raster using GeoJSON features (masks out all areas not covered
+    by features), and optionally crops the output raster to the extent of the
+    features.  Features are assumed to be in the same coordinate reference
+    system as the input raster.
+
+    GeoJSON must be the first input file or provided from stdin:
+
+    > rio mask input.tif output.tif --geojson-mask features.json
+
+    > rio mask input.tif output.tif --geojson-mask - < features.json
+
+    If the output raster exists, it will be completely overwritten with the
+    results of this operation.
+
+    The result is always equal to or within the bounds of the input raster.
+
+    --crop and --invert options are mutually exclusive.
+
+    --crop option is not valid if features are completely outside extent of
+    input raster.
+    """
+
+    from rasterio.features import geometry_mask
+    from rasterio.features import bounds as calculate_bounds
+
+    verbosity = (ctx.obj and ctx.obj.get('verbosity')) or 1
+
+    if geojson_mask is None:
+        click.echo('No GeoJSON provided, INPUT will be copied to OUTPUT',
+                   err=True)
+        shutil.copy(input, output)
+        return
+
+    if crop and invert:
+        click.echo('Invert option ignored when using --crop', err=True)
+        invert = False
+
+    with rasterio.drivers(CPL_DEBUG=verbosity > 2):
+        try:
+            geojson = json.loads(click.open_file(geojson_mask).read())
+        except ValueError:
+            raise click.BadParameter('GeoJSON could not be read from  '
+                                     '--geojson-mask or stdin',
+                                     param_hint='--geojson-mask')
+
+        if 'features' in geojson:
+            geometries = (f['geometry'] for f in geojson['features'])
+        elif 'geometry' in geojson:
+            geometries = (geojson['geometry'], )
+        else:
+            raise click.BadParameter('Invalid GeoJSON', param=input,
+                                     param_hint='input')
+        bounds = geojson.get('bbox', calculate_bounds(geojson))
+
+        with rasterio.open(input) as src:
+            disjoint_bounds = _disjoint_bounds(bounds, src.bounds)
+
+            if crop:
+                if disjoint_bounds:
+                    raise click.BadParameter('not allowed for GeoJSON outside '
+                                             'the extent of the input raster',
+                                             param=crop, param_hint='--crop')
+
+                window = src.window(*bounds)
+                transform = src.window_transform(window)
+                (r1, r2), (c1, c2) = window
+                mask_shape = (r2 - r1, c2 - c1)
+            else:
+                if disjoint_bounds:
+                    click.echo('GeoJSON outside bounds of existing output '
+                               'raster. Are they in different coordinate '
+                               'reference systems?',
+                               err=True)
+
+                window = None
+                transform = src.affine
+                mask_shape = src.shape
+
+            mask = geometry_mask(
+                geometries,
+                out_shape=mask_shape,
+                transform=transform,
+                all_touched=all_touched,
+                invert=invert)
+
+            meta = src.meta.copy()
+            meta.update({
+                'driver': driver,
+                'height': mask.shape[0],
+                'width': mask.shape[1],
+                'transform': transform
+            })
+
+            with rasterio.open(output, 'w', **meta) as out:
+                for bidx in range(1, src.count + 1):
+                    img = src.read(bidx, masked=True, window=window)
+                    img.mask = img.mask | mask
+                    out.write_band(bidx, img.filled(src.nodatavals[bidx-1]))
+
+
 # Shapes command.
 @cli.command(short_help="Write shapes extracted from bands or masks.")
 @click.argument('input', type=click.Path(exists=True))
@@ -204,23 +336,23 @@ def __call__(self):
               help='Output bounds: left, bottom, right, top.')
 @click.option('--dimensions', nargs=2, type=int, default=None,
               help='Output dataset width, height in number of pixels.')
-@click.option('--res', multiple=True, type=float, default=None,
+@click.option('-r', '--res', multiple=True, type=float, default=None,
               help='Output dataset resolution in units of coordinate '
               '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=None,
+@click.option('--src-crs', '--src_crs', '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')
-@click.option('--all_touched', is_flag=True, default=False)
-@click.option('--default_value', type=float, default=1, help='Default value '
-              'for rasterized pixels')
+@click.option('-a', '--all', '--all_touched', 'all_touched', is_flag=True,
+              default=False, help='Rasterize all pixels touched by features')
+@click.option('--default-value', '--default_value', 'default_value',
+              type=float, default=1, help='Default value for rasterized pixels')
 @click.option('--fill', type=float, default=0,
-              help='Fill value for allpixels '
-              'not overlapping features.  Will be evaluated as NoData pixels '
-              'for output.  Default: 0')
+              help='Fill value for all pixels not overlapping features.  Will '
+              'be evaluated as NoData pixels for output.  Default: 0')
 @click.option('--property', type=str, default=None, help='Property in '
               'GeoJSON features to use for rasterized values.  Any features '
               'that lack this property will be given --default_value instead.')
@@ -243,7 +375,7 @@ 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 unless --src_crs is provided.
+    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.
@@ -252,7 +384,7 @@ def rasterize(
     If a template raster is provided using the --like option, the affine
     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.
+    --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.
@@ -303,12 +435,6 @@ def feature_value(feature):
                 return feature['properties'].get(property, default_value)
             return default_value
 
-        def disjoint_bounds(bounds1, bounds2):
-            return (bounds1[0] > bounds2[2] or
-                    bounds1[2] < bounds2[0] or
-                    bounds1[1] > bounds2[3] or
-                    bounds1[3] < bounds2[1])
-
         geojson = json.loads(input.read())
         if 'features' in geojson:
             geometries = []
@@ -329,7 +455,7 @@ def disjoint_bounds(bounds1, bounds2):
                                              'existing output raster',
                                              param='input', param_hint='input')
 
-                if disjoint_bounds(geojson_bounds, out.bounds):
+                if _disjoint_bounds(geojson_bounds, out.bounds):
                     click.echo("GeoJSON outside bounds of existing output "
                                "raster. Are they in different coordinate "
                                "reference systems?",
@@ -363,7 +489,7 @@ def disjoint_bounds(bounds1, bounds2):
                                              '--like raster',
                                              param='input', param_hint='input')
 
-                if disjoint_bounds(geojson_bounds, template_ds.bounds):
+                if _disjoint_bounds(geojson_bounds, template_ds.bounds):
                     click.echo("GeoJSON outside bounds of --like raster. "
                                "Are they in different coordinate reference "
                                "systems?",
@@ -436,3 +562,8 @@ def disjoint_bounds(bounds1, bounds2):
 
             with rasterio.open(output, 'w', **kwargs) as out:
                 out.write_band(1, result)
+
+
+def _disjoint_bounds(bounds1, bounds2):
+    return (bounds1[0] > bounds2[2] or bounds1[2] < bounds2[0] or
+            bounds1[1] > bounds2[3] or bounds1[3] < bounds2[1])
\ No newline at end of file
diff --git a/rasterio/rio/main.py b/rasterio/rio/main.py
--- a/rasterio/rio/main.py
+++ b/rasterio/rio/main.py
@@ -3,7 +3,7 @@
 from rasterio.rio.calc import calc
 from rasterio.rio.cli import cli
 from rasterio.rio.bands import stack
-from rasterio.rio.features import shapes, rasterize
+from rasterio.rio.features import mask, shapes, rasterize
 from rasterio.rio.info import env, info
 from rasterio.rio.merge import merge
 from rasterio.rio.rio import bounds, insp, transform


Test patch:
diff --git a/tests/test_features_rasterize.py b/tests/test_features_rasterize.py
--- a/tests/test_features_rasterize.py
+++ b/tests/test_features_rasterize.py
@@ -4,7 +4,7 @@
 import pytest
 
 import rasterio
-from rasterio.features import shapes, rasterize
+from rasterio.features import shapes, rasterize, geometry_mask
 
 
 logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
@@ -159,3 +159,23 @@ def test_rasterize_geometries_symmetric():
         s = shapes(truth, transform=transform)
         result = rasterize(s, out_shape=(rows, cols), transform=transform)
         assert numpy.array_equal(result, truth)
+
+
+def test_geometry_mask():
+    rows = cols = 10
+    transform = (1.0, 0.0, 0.0, 0.0, -1.0, 0.0)
+    truth = numpy.zeros((rows, cols), dtype=rasterio.bool_)
+    truth[2:5, 2:5] = True
+    with rasterio.drivers():
+        s = shapes((truth * 10).astype(rasterio.ubyte), transform=transform)
+        # Strip out values returned from shapes, and only keep first shape
+        geoms = [next(s)[0]]
+
+        # Regular mask should be the inverse of truth raster
+        mask = geometry_mask(geoms, out_shape=(rows, cols), transform=transform)
+        assert numpy.array_equal(mask, numpy.invert(truth))
+
+        # Inverted mask should be the same as the truth raster
+        mask = geometry_mask(geoms, out_shape=(rows, cols), transform=transform,
+                             invert=True)
+        assert numpy.array_equal(mask, truth)
\ No newline at end of file
diff --git a/tests/test_indexing.py b/tests/test_indexing.py
--- a/tests/test_indexing.py
+++ b/tests/test_indexing.py
@@ -20,8 +20,7 @@ def test_window_no_exception():
     with rasterio.open('tests/data/RGB.byte.tif') as src:
         left, bottom, right, top = src.bounds
         left -= 1000.0
-        eps = 1.0e-8
-        assert src.window(left, bottom, right, top) == (
+        assert src.window(left, bottom, right, top, boundless=True) == (
                 (0, src.height), (-4, src.width))
 
 
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
@@ -2,9 +2,7 @@
 import os
 import re
 import sys
-
-import click
-from click.testing import CliRunner
+import numpy
 
 import rasterio
 from rasterio.rio import features
@@ -31,6 +29,24 @@
     "type": "Feature"
 }"""
 
+TEST_MERC_FEATURES = """{
+  "geometry": {
+      "coordinates": [
+          [
+              [-11858134, 4808920],
+              [-11868134, 4804143],
+              [-11853357, 4804143],
+              [-11840000, 4812000],
+              [-11858134, 4808920]
+          ]
+      ],
+      "type": "Polygon"
+  },
+  "properties": {
+      "val": 10
+  },
+  "type": "Feature"
+}"""
 
 # > rio shapes tests/data/shade.tif --mask --sampling 500 --projected --precision 0
 TEST_MERC_FEATURECOLLECTION = """{
@@ -78,11 +94,165 @@
 }"""
 
 
-def test_err():
-    runner = CliRunner()
+def test_mask(runner, tmpdir):
+    output = str(tmpdir.join('test.tif'))
+
+    with rasterio.open('tests/data/shade.tif') as src:
+        src_data = src.read(1, masked=True)
+
+        result = runner.invoke(
+            features.mask,
+            ['tests/data/shade.tif', output, '--geojson-mask', '-'],
+            input=TEST_MERC_FEATURES
+        )
+        assert result.exit_code == 0
+        assert os.path.exists(output)
+
+        masked_count = 0
+        with rasterio.open(output) as out:
+            assert out.count == src.count
+            assert out.shape == src.shape
+            out_data = out.read(1, masked=True)
+
+            # Make sure that pixels with 0 value were converted to mask
+            masked_count = (src_data == 0).sum() - (out_data == 0).sum()
+            assert masked_count == 79743
+            assert out_data.mask.sum() - src_data.mask.sum() == masked_count
+
+        # Test using --all-touched option
+        result = runner.invoke(
+            features.mask,
+            [
+                'tests/data/shade.tif', output,
+                '--all',
+                '--geojson-mask', '-'
+            ],
+            input=TEST_MERC_FEATURES
+        )
+        assert result.exit_code == 0
+        with rasterio.open(output) as out:
+            out_data = out.read(1, masked=True)
+
+            # Make sure that more pixels with 0 value were converted to mask
+            masked_count2 = (src_data == 0).sum() - (out_data == 0).sum()
+            assert masked_count2 > 0 and masked_count > masked_count2
+            assert out_data.mask.sum() - src_data.mask.sum() == masked_count2
+
+        # Test using --invert option
+        result = runner.invoke(
+            features.mask,
+            [
+                'tests/data/shade.tif', output,
+                '--invert',
+                '--geojson-mask', '-'
+            ],
+            input=TEST_MERC_FEATURES
+        )
+        assert result.exit_code == 0
+        with rasterio.open(output) as out:
+            out_data = out.read(1, masked=True)
+            # Areas that were masked when not inverted should now be 0
+            assert (out_data == 0).sum() == masked_count
+
+    # Test with feature collection
     result = runner.invoke(
-        features.shapes, ['tests/data/shade.tif', '--bidx', '4'])
-    assert result.exit_code == 1
+        features.mask,
+        ['tests/data/shade.tif', output, '--geojson-mask', '-'],
+        input=TEST_MERC_FEATURECOLLECTION
+    )
+    assert result.exit_code == 0
+
+    # Missing GeoJSON should make copy of input to output
+    output2 = str(tmpdir.join('test2.tif'))
+    result = runner.invoke(
+        features.mask,
+        ['tests/data/shade.tif', output2]
+    )
+    assert result.exit_code == 0
+    assert os.path.exists(output2)
+    with rasterio.open('tests/data/shade.tif') as src:
+        with rasterio.open(output2) as out:
+            src_data = src.read(1, masked=True)
+            out_data = out.read(1, masked=True)
+            assert numpy.array_equal(src_data, out_data)
+
+    # Invalid JSON should fail
+    result = runner.invoke(
+        features.mask,
+        ['tests/data/shade.tif', output, '--geojson-mask', '-'],
+        input='{bogus: value}'
+    )
+    assert result.exit_code == 2
+    assert 'GeoJSON could not be read' in result.output
+
+    result = runner.invoke(
+        features.mask,
+        ['tests/data/shade.tif', output, '--geojson-mask', '-'],
+        input='{"bogus": "value"}'
+    )
+    assert result.exit_code == 2
+    assert 'Invalid GeoJSON' in result.output
+
+
+def test_mask_crop(runner, tmpdir):
+    output = str(tmpdir.join('test.tif'))
+
+    with rasterio.open('tests/data/shade.tif') as src:
+
+        result = runner.invoke(
+            features.mask,
+            [
+                'tests/data/shade.tif', output,
+                '--crop',
+                '--geojson-mask', '-'
+            ],
+            input=TEST_MERC_FEATURES
+        )
+        assert result.exit_code == 0
+        assert os.path.exists(output)
+        with rasterio.open(output) as out:
+            assert out.shape[1] == src.shape[1]
+            assert out.shape[0] < src.shape[0]
+            assert out.shape[0] == 823
+
+    # Adding invert option after crop should be ignored
+    result = runner.invoke(
+        features.mask,
+        [
+            'tests/data/shade.tif', output,
+            '--crop',
+            '--invert',
+            '--geojson-mask', '-'
+        ],
+        input=TEST_MERC_FEATURES
+    )
+    assert result.exit_code == 0
+    assert 'Invert option ignored' in result.output
+
+
+def test_mask_out_of_bounds(runner, tmpdir):
+    output = str(tmpdir.join('test.tif'))
+    # Crop with out of bounds raster should
+    result = runner.invoke(
+        features.mask,
+        ['tests/data/shade.tif', output, '--geojson-mask', '-'],
+        input=TEST_FEATURES
+    )
+    assert result.exit_code == 0
+    assert 'outside bounds' in result.output
+
+    # Crop with out of bounds raster should fail
+    result = runner.invoke(
+        features.mask,
+        [
+            'tests/data/shade.tif', output,
+            '--crop',
+            '--geojson-mask', '-'
+        ],
+        input=TEST_FEATURES
+    )
+    assert result.exit_code == 2
+    assert 'not allowed' in result.output
 
 
 def test_shapes(runner):
@@ -91,6 +261,11 @@ def test_shapes(runner):
     assert result.output.count('"FeatureCollection"') == 1
     assert result.output.count('"Feature"') == 232
 
+    # Invalid band index should fail
+    result = runner.invoke(
+        features.shapes, ['tests/data/shade.tif', '--bidx', '4'])
+    assert result.exit_code == 1
+
 
 def test_shapes_sequence(runner):
     result = runner.invoke(features.shapes, ['tests/data/shade.tif', '--sequence'])
@@ -197,7 +372,7 @@ def test_rasterize_err(tmpdir, runner):
     # Test invalid CRS value
     result = runner.invoke(features.rasterize, [output,
                                                 '--res', 1,
-                                                '--src_crs', 'BOGUS'],
+                                                '--src-crs', 'BOGUS'],
                            input=TEST_MERC_FEATURECOLLECTION)
     assert result.exit_code == 2
 
@@ -249,12 +424,12 @@ def test_rasterize(tmpdir, runner):
         assert (data == 0).sum() == 55
         assert (data == 1).sum() == 145
 
-    # Test that src_crs is written into new output
+    # 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'
+                            '--src-crs', 'EPSG:3857'
                            ],
                            input=TEST_MERC_FEATURECOLLECTION)
     assert result.exit_code == 0
@@ -285,7 +460,7 @@ def test_rasterize_existing_output(tmpdir, runner):
         "type": "Feature"
     }"""
 
-    result = runner.invoke(features.rasterize, [output, '--default_value', 2],
+    result = runner.invoke(features.rasterize, [output, '--default-value', 2],
                            input=geojson)
 
     with rasterio.open(output) as out:
@@ -295,11 +470,11 @@ 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
+    # 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'
+                            '--src-crs', 'EPSG:4269'
                             ], input=TEST_FEATURES)
     assert result.exit_code == 2
 
@@ -323,12 +498,12 @@ def test_rasterize_like(tmpdir, runner):
                            [output, '--like', str(tmpdir.join('foo.tif'))], input=TEST_FEATURES)
     assert result.exit_code == 2
 
-    # Test that src_crs different than --like raster crs breaks
+    # 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'],
+                            '--src-crs', 'EPSG:4326'],
                            input=TEST_FEATURES)
     assert result.exit_code == 2
 
@@ -340,7 +515,7 @@ def test_rasterize_property_value(tmpdir, runner):
                            [output,
                             '--res', 1000,
                             '--property', 'val',
-                            '--src_crs', 'EPSG:3857'
+                            '--src-crs', 'EPSG:3857'
                            ],
                            input=TEST_MERC_FEATURECOLLECTION)
     assert result.exit_code == 0