Repository: rasterio/rasterio

Issue:
Improved valid data footprint extraction for rio-shapes
There are two improvements related to the `--mask` option to make for 0.20:
1. Allow specification of a band to be used as the mask (reuse of `--bidx`)
2. Use the conjunction of all band masks to find the valid data footprint

Adding an option to write file names to the output GeoJSON is another improvement.

/cc @dnomadb 



Discussion:

Resolving patch:
diff --git a/rasterio/rio/features.py b/rasterio/rio/features.py
--- a/rasterio/rio/features.py
+++ b/rasterio/rio/features.py
@@ -23,7 +23,7 @@
 
 
 # Shapes command.
-@cli.command(short_help="Write the shapes of features.")
+@cli.command(short_help="Write shapes extracted from bands or masks.")
 @click.argument('input', type=click.Path(exists=True))
 @precision_opt
 @indent_opt
@@ -34,21 +34,49 @@
 @use_rs_opt
 @geojson_type_feature_opt(True)
 @geojson_type_bbox_opt(False)
-@click.option('--bands/--mask', default=True,
-              help="Extract shapes from one of the dataset bands or from "
-                   "its nodata mask")
-@click.option('--bidx', type=int, default=1,
-              help="Index of the source band")
+@click.option('--band/--mask', default=True,
+              help="Choose to extract from a band (the default) or a mask.")
+@click.option('--bidx', 'bandidx', type=int, default=None,
+              help="Index of the band or mask that is the source of shapes.")
 @click.option('--sampling', type=int, default=1,
-              help="Inverse of the sampling fraction")
+              help="Inverse of the sampling fraction; "
+                   "a value of 10 decimates.")
 @click.option('--with-nodata/--without-nodata', default=False,
               help="Include or do not include (the default) nodata regions.")
+@click.option('--as-mask/--not-as-mask', default=False,
+              help="Interpret a band as a mask and output only one class of "
+                   "valid data shapes.")
 @click.pass_context
 def shapes(
         ctx, input, precision, indent, compact, projection, sequence,
-        use_rs, geojson_type, bands, bidx, sampling, with_nodata):
-    """Writes features of a dataset out as GeoJSON. It's intended for
-    use with single-band rasters and reads from the first band.
+        use_rs, geojson_type, band, bandidx, sampling, with_nodata, as_mask):
+    """Extracts shapes from one band or mask of a dataset and writes
+    them out as GeoJSON. Unless otherwise specified, the shapes will be
+    transformed to WGS 84 coordinates.
+
+    The default action of this command is to extract shapes from the
+    first band of the input dataset. The shapes are polygons bounding
+    contiguous regions (or features) of the same raster value. This
+    command performs poorly for int16 or float type datasets.
+
+    Bands other than the first can be specified using the `--bidx`
+    option:
+
+      $ rio shapes --bidx 3 tests/data/RGB.byte.tif
+
+    The valid data footprint of a dataset's i-th band can be extracted
+    by using the `--mask` and `--bidx` options:
+
+      $ rio shapes --mask --bidx 1 tests/data/RGB.byte.tif
+
+    Omitting the `--bidx` option results in a footprint extracted from
+    the conjunction of all band masks. This is generally smaller than
+    any individual band's footprint.
+
+    A dataset band may be analyzed as though it were a binary mask with
+    the `--as-mask` option:
+
+      $ rio shapes --as-mask --bidx 1 tests/data/RGB.byte.tif
     """
     # These import numpy, which we don't want to do unless it's needed.
     import numpy
@@ -64,6 +92,8 @@ def shapes(
         dump_kwds['separators'] = (',', ':')
     stdout = click.get_text_stream('stdout')
 
+    bidx = 1 if bandidx is None and band else bandidx
+
     # This is the generator for (feature, bbox) pairs.
     class Collection(object):
 
@@ -78,11 +108,11 @@ def bbox(self):
         def __call__(self):
             with rasterio.open(input) as src:
                 img = None
-                nodata_mask = None
-                if bands:
+                msk = None
+                if band:
                     if sampling == 1:
                         img = src.read(bidx, masked=False)
-                        transform = src.transform
+                        transform = src.affine
                     # Decimate the band.
                     else:
                         img = numpy.zeros(
@@ -90,16 +120,28 @@ def __call__(self):
                             dtype=src.dtypes[src.indexes.index(bidx)])
                         img = src.read(bidx, img, masked=False)
                         transform = src.affine * Affine.scale(float(sampling))
-                if not bands or not with_nodata:
+                    if as_mask:
+                        tmp = numpy.ones_like(img, 'uint8') * 255
+                        tmp[img == 0] = 0
+                        img = tmp
+                        msk = tmp
+                if not band or not with_nodata:
                     if sampling == 1:
-                        nodata_mask = src.read_masks(bidx)
-                        transform = src.transform
+                        msk = src.read_masks(bidx)
+                        if bidx is None:
+                            msk = numpy.logical_or.reduce(msk).astype('uint8')
+                        transform = src.affine
                     # Decimate the mask.
                     else:
-                        nodata_mask = numpy.zeros(
-                            (src.height//sampling, src.width//sampling),
-                            dtype=numpy.uint8)
-                        nodata_mask = src.read_masks(bidx, nodata_mask)
+                        msk_shape = src.height//sampling, src.width//sampling
+                        if bidx is None:
+                            msk = numpy.zeros(
+                                (src.count,) + msk_shape, 'uint8')
+                        else:
+                            msk = numpy.zeros(msk_shape, 'uint8')
+                        msk = src.read_masks(bidx, msk)
+                        if bidx is None:
+                            msk = numpy.logical_or.reduce(msk).astype('uint8')
                         transform = src.affine * Affine.scale(float(sampling))
 
                 bounds = src.bounds
@@ -116,10 +158,11 @@ def __call__(self):
 
                 kwargs = {'transform': transform}
                 # Default is to exclude nodata features.
-                if nodata_mask is not None:
-                    kwargs['mask'] = (nodata_mask==255)
+                if msk is not None:
+                    kwargs['mask'] = msk #(msk > 0)
                 if img is None:
-                    img = nodata_mask
+                    img = msk
+
                 for g, i in rasterio.features.shapes(img, **kwargs):
                     if projection == 'geographic':
                         g = rasterio.warp.transform_geom(
@@ -129,15 +172,18 @@ def __call__(self):
                     yield {
                         'type': 'Feature',
                         'id': str(i),
-                        'properties': {'val': i},
+                        'properties': {
+                            'val': i, 'filename': os.path.basename(src.name)
+                        },
                         'bbox': [min(xs), min(ys), max(xs), max(ys)],
-                        'geometry': g }
+                        'geometry': g
+                    }
 
     if not sequence:
         geojson_type = 'collection'
 
     try:
-        with rasterio.drivers(CPL_DEBUG=verbosity>2):
+        with rasterio.drivers(CPL_DEBUG=(verbosity > 2)):
             write_features(
                 stdout, Collection(), sequence=sequence,
                 geojson_type=geojson_type, use_rs=use_rs,
@@ -162,17 +208,19 @@ def __call__(self):
               help='Output dataset width, height in number of pixels.')
 @click.option('--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: '
+              '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,
               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')
+              '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('--fill', type=float, default=0, help='Fill value for all 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')
 @click.option('--property', type=str, default=None, help='Property in '
@@ -330,10 +378,10 @@ def disjoint_bounds(bounds1, bounds2):
 
                 if is_geographic_crs(src_crs):
                     if (bounds[0] < -180 or bounds[2] > 180 or
-                        bounds[1] < -80 or bounds[3] > 80):
+                            bounds[1] < -80 or bounds[3] > 80):
                         raise click.BadParameter(
-                            'Bounds are beyond the valid extent for geographic '
-                            'coordinates.',
+                            "Bounds are beyond the valid extent for "
+                            "EPSG:4326.",
                             ctx, param=bounds, param_hint='--bounds')
 
                 if dimensions:
diff --git a/rasterio/rio/rio.py b/rasterio/rio/rio.py
--- a/rasterio/rio/rio.py
+++ b/rasterio/rio/rio.py
@@ -132,7 +132,9 @@ def __call__(self):
                             [xs[0], ys[1]],
                             [xs[0], ys[0]] ]]},
                     'properties': {
-                        'id': str(i), 'title': path} }
+                        'id': str(i),
+                        'title': path,
+                        'filename': os.path.basename(path)} }
 
                 self._xs.extend(bbox[::2])
                 self._ys.extend(bbox[1::2])


Test patch:
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
@@ -122,7 +122,7 @@ def test_shapes_indent(runner):
     result = runner.invoke(features.shapes, ['tests/data/shade.tif', '--indent', '2'])
     assert result.exit_code == 0
     assert result.output.count('"FeatureCollection"') == 1
-    assert result.output.count('\n') == 70139
+    assert result.output.count('\n') == 70371
 
 
 def test_shapes_compact(runner):
@@ -154,6 +154,23 @@ def test_shapes_mask(runner):
     result = runner.invoke(features.shapes, ['tests/data/RGB.byte.tif', '--mask'])
     assert result.exit_code == 0
     assert result.output.count('"FeatureCollection"') == 1
+    assert result.output.count('"Feature"') == 7
+
+
+def test_shapes_mask_decimated(runner):
+    result = runner.invoke(
+        features.shapes, 
+        ['tests/data/RGB.byte.tif', '--mask', '--sampling', '10'])
+    assert result.exit_code == 0
+    assert result.output.count('"FeatureCollection"') == 1
+    assert result.output.count('"Feature"') == 1
+
+
+def test_shapes_band1_as_mask(runner):
+    result = runner.invoke(features.shapes,
+        ['tests/data/RGB.byte.tif', '--band', '--bidx', '1', '--as-mask'])
+    assert result.exit_code == 0
+    assert result.output.count('"FeatureCollection"') == 1
     assert result.output.count('"Feature"') == 9
 
 
@@ -197,7 +214,7 @@ def test_rasterize(tmpdir, runner):
         assert out.count == 1
         assert out.meta['width'] == 20
         assert out.meta['height'] == 10
-        data = out.read_band(1, masked=False)
+        data = out.read(1, masked=False)
         assert (data == 0).sum() == 55
         assert (data == 1).sum() == 145
 
@@ -214,7 +231,7 @@ def test_rasterize(tmpdir, runner):
         assert out.count == 1
         assert out.meta['width'] == 40
         assert out.meta['height'] == 20
-        data = out.read_band(1, masked=False)
+        data = out.read(1, masked=False)
         assert (data == 0).sum() == 748
         assert (data == 1).sum() == 52
 
@@ -228,7 +245,7 @@ def test_rasterize(tmpdir, runner):
         assert out.count == 1
         assert out.meta['width'] == 20
         assert out.meta['height'] == 10
-        data = out.read_band(1, masked=False)
+        data = out.read(1, masked=False)
         assert (data == 0).sum() == 55
         assert (data == 1).sum() == 145
 
@@ -273,7 +290,7 @@ def test_rasterize_existing_output(tmpdir, runner):
 
     with rasterio.open(output) as out:
         assert out.count == 1
-        data = out.read_band(1, masked=False)
+        data = out.read(1, masked=False)
         assert (data == 0).sum() == 55
         assert (data == 1).sum() == 125
         assert (data == 2).sum() == 20
@@ -296,7 +313,7 @@ def test_rasterize_like(tmpdir, runner):
     assert os.path.exists(output)
     with rasterio.open(output) as out:
         assert out.count == 1
-        data = out.read_band(1, masked=False)
+        data = out.read(1, masked=False)
         assert (data == 0).sum() == 548576
         assert (data == 1).sum() == 500000
 
@@ -330,7 +347,7 @@ def test_rasterize_property_value(tmpdir, runner):
     assert os.path.exists(output)
     with rasterio.open(output) as out:
         assert out.count == 1
-        data = out.read_band(1, masked=False)
+        data = out.read(1, masked=False)
         assert (data == 0).sum() == 50
         assert (data == 2).sum() == 25
         assert (data == 3).sum() == 25
@@ -344,7 +361,7 @@ def test_rasterize_property_value(tmpdir, runner):
     assert os.path.exists(output)
     with rasterio.open(output) as out:
         assert out.count == 1
-        data = out.read_band(1, masked=False)
+        data = out.read(1, masked=False)
         assert (data == 0).sum() == 55
         assert (data == 15).sum() == 145