Repository: rasterio/rasterio

Issue:
rasterio.features.is_valid_geom disagrees with geopandas/shapely is_valid geometry attribute
## Expected behavior and actual behavior.
This is a feature request.

I'd expect rasterio to accept shapely/geopandas geometry features, but it looks like rasterio.features takes only geojson like objects. I have a geodataframe of geometries of type `shapely.geometry.multipolygon.MultiPolygon`, in `shp_frame`.  I am trying to burn to a raster like so 

```
            shapes = (
                (geom, value)
                for geom, value in zip(shp_frame.geometry, shp_frame.ID)
            )
            burned = features.rasterize(
                shapes=shapes,
                fill=0,
                out_shape=(meta['height'],meta['width']),
                transform=out.transform,
                default_value=1,
            )
```

but the following occurs because `geom` is not a geojson

```
>>> is_valid_geom(shp_frame.iloc[0]['geometry'])
False
```
whereas the geometry is actually valid

```
>>> shp_frame.iloc[638]['geometry'].is_valid
True
```
if I use the shapely object's `__geo_interface__` method this makes rasterio happy, but it's unintuitive

```
>>> is_valid_geom(shp_frame.iloc[0]['geometry'].__geo_interface__)
True
```

## Steps to reproduce the problem.

Since this isn't a bug, I hope the above description is sufficient but I can provide an example if anyone wants it for testing.

## Operating system

For example: Ubuntu 16.04

## Rasterio version and provenance

My rasterio version - 1.0.22 installed with conda



Discussion:
@rbavery thanks for the report. I think this is a bug in is_valid_geom. features.rasterize should accept shapely geometries on account of this line: https://github.com/mapbox/rasterio/blob/master/rasterio/features.py#L261. I'll look into it.

The is_valid_geom function exists because GDAL's rasterizer can be a little crashy if geometries aren't correct.
Note that the meaning of shapely's [`is_valid`](https://shapely.readthedocs.io/en/stable/manual.html#object.is_valid) is different than rasterio's [`is_valid_geom`](https://github.com/mapbox/rasterio/blob/master/rasterio/features.py#L435-L520).  Shapely's method checks the topological correctness of the geometry, whereas rasterio's method checks that the geometry type is one of the expected types, and has the appropriate coordinate length or number of sub-geometries if a collection.  So - these shouldn't be treated as equivalent.

Also note: rasterio already uses `__geo_interface__` *before* internally calling `is_valid_geom` on the geometry: https://github.com/mapbox/rasterio/blob/master/rasterio/features.py#L264
(`is_valid_geom` wasn't expected to be called directly by users)

So in this case, it is not surprising that you need to do that yourself before calling `is_valid_geom`.  The docstring for `is_valid_geom` states expected inputs are GeoJSON or geometry collections, but perhaps we could do one of 2 things to make this easier for folks:
- add a bit more info to docstring to make it clear that shapely geometries won't work for this function.
- add a second attempt to unpack `__geo_interface__` at the top of `is_valid_geom`.

I suspect the error may be in the tuple of shapes / ids passed in, or possibly one of the geometries not being valid.

@rbavery would you mind running a simple check, to see if all your geometries in your data frame are valid?

```
shp_frame.geometry.apply(lambda g: is_valid_geom(g.__geo_interface__)).min()
```

If this is `True` then all your geometries are valid, and that isn't the underlying issue here.
@rbavery for future reference, a simpler way to create `shapes` in geopandas:

```
shapes = shp_frame[['geometry', 'ID']].values.tolist()
```

I noticed in trying to reproduce your issue that if I accidentally consumed from your `shapes` generator prior to calling `rasterize`, the generator was exhausted by the time I called `rasterize` and returned an error `ValueError: No valid geometry objects found for rasterize`.  That didn't happen to you, did it? 

Thanks for the quick and helpful response @brendan-ward and @sgillies , I figured out the issue, it's of my own making. When I ran 
```
shp_frame.geometry.apply(lambda g: is_valid_geom(g.__geo_interface__)).min()
shp_frame.loc[shp_frame.geometry.is_empty]
```
it looks like 6 of my geometries are empty after applying a negative buffer to my `shp_frame`.

After troubleshooting, I ended up solving the issue by filtering empty geometries out of my geodataframe like so 

```
shp_frame = shp_frame.loc[shp_frame.geometry.is_empty==False]
```
with no need call to the `__geo_interface__` attribute needed on my part.

so it looks like `is_valid_geom` is fine. Some of my confusion came from expecting to be able to run `is_valid_geom` on a shapely object and from shapely indicating that empty geometries can be valid, 

```
shp_frame.iloc[638]['geometry'].is_empty
>>> True
```

whereas in rasterio, 

```
is_valid_geom(shp_frame.iloc[638]['geometry'].__geo_interface__)
>>> False
```

I can make a pull request to update the is_valid_geom docstring?
@sgillies @rbavery would it be better to fail silently on empty geometries?  We could detect and ignore these as part of creating the geometries to `rasterize`, and limit some of our validation in `is_valid_geom` to non-empty geometries.

The tradeoff would be that if you had
```
shapes = (
(valid_geom, 1),
(empty_geom, 2),
(valid_geom, 3)
)
```
you might be surprised / confused as to why `2` is not present in your output raster, since at first glance, you have a geometry object for it (it's just empty).

We should still fail with an exception if there are no non-empty geometries to rasterize.
That sounds good to me. I think it would be helpful to have a printed warning indicating if any empty geoms went unrasterized, if they are being detected anyway. Then folks wouldn't be surprised.

Resolving patch:
diff --git a/rasterio/features.py b/rasterio/features.py
--- a/rasterio/features.py
+++ b/rasterio/features.py
@@ -175,6 +175,10 @@ def rasterize(
         dtype=None):
     """Return an image array with input geometries burned in.
 
+    Warnings will be raised for any invalid or empty geometries, and
+    an exception will be raised if there are no valid shapes
+    to rasterize.
+
     Parameters
     ----------
     shapes : iterable of (geometry, value) pairs or iterable over
@@ -260,25 +264,24 @@ def format_cast_error(param, dtype):
             value = default_value
         geom = getattr(geom, '__geo_interface__', None) or geom
 
-        # geom must be a valid GeoJSON geometry type and non-empty
-        if not is_valid_geom(geom):
-            raise ValueError(
-                'Invalid geometry object at index {0}'.format(index)
-            )
-
-        if geom['type'] == 'GeometryCollection':
-            # GeometryCollections need to be handled as individual parts to
-            # avoid holes in output:
-            # https://github.com/mapbox/rasterio/issues/1253.
-            # Only 1-level deep since GeoJSON spec discourages nested
-            # GeometryCollections
-            for part in geom['geometries']:
-                valid_shapes.append((part, value))
+        if is_valid_geom(geom):
+            shape_values.append(value)
 
-        else:
-            valid_shapes.append((geom, value))
+            if geom['type'] == 'GeometryCollection':
+                # GeometryCollections need to be handled as individual parts to
+                # avoid holes in output:
+                # https://github.com/mapbox/rasterio/issues/1253.
+                # Only 1-level deep since GeoJSON spec discourages nested
+                # GeometryCollections
+                for part in geom['geometries']:
+                    valid_shapes.append((part, value))
 
-        shape_values.append(value)
+            else:
+                valid_shapes.append((geom, value))
+        
+        else:
+            # invalid or empty geometries are skipped and raise a warning instead
+            warnings.warn('Invalid or empty shape at index {} will not be rasterized.'.format(index))
 
     if not valid_shapes:
         raise ValueError('No valid geometry objects found for rasterize')
@@ -435,7 +438,8 @@ def geometry_window(dataset, shapes, pad_x=0, pad_y=0, north_up=True,
 def is_valid_geom(geom):
     """
     Checks to see if geometry is a valid GeoJSON geometry type or
-    GeometryCollection.
+    GeometryCollection.  Geometry must be GeoJSON or implement the geo
+    interface.
 
     Geometries must be non-empty, and have at least x, y coordinates.
 
@@ -453,6 +457,8 @@ def is_valid_geom(geom):
     geom_types = {'Point', 'MultiPoint', 'LineString', 'LinearRing',
                   'MultiLineString', 'Polygon', 'MultiPolygon'}
 
+    geom = getattr(geom, '__geo_interface__', None) or geom
+
     if 'type' not in geom:
         return False
 


Test patch:
diff --git a/tests/test_features.py b/tests/test_features.py
--- a/tests/test_features.py
+++ b/tests/test_features.py
@@ -114,7 +114,7 @@ def test_geometry_invalid_geom():
                 out_shape=DEFAULT_SHAPE,
                 transform=Affine.identity())
 
-        assert 'Invalid geometry' in exc_info.value.args[0]
+        assert 'No valid geometry objects found for rasterize' in exc_info.value.args[0]
 
 
 def test_geometry_mask_invalid_shape(basic_geometry):
@@ -227,6 +227,11 @@ def test_geometry_window_no_overlap(path_rgb_byte_tif, basic_geometry):
             geometry_window(src, [basic_geometry], north_up=False)
 
 
+def test_is_valid_geo_interface(geojson_point):
+    """Properly formed Point object with geo interface is valid"""
+    assert is_valid_geom(MockGeoInterface(geojson_point))
+
+
 def test_is_valid_geom_point(geojson_point):
     """Properly formed GeoJSON Point is valid"""
     assert is_valid_geom(geojson_point)
@@ -500,6 +505,16 @@ def test_rasterize_invalid_geom():
             {'type': 'Invalid', 'coordinates': []}]}], out_shape=DEFAULT_SHAPE)
 
 
+def test_rasterize_skip_invalid_geom(geojson_polygon, basic_image_2x2):
+    """Rasterize operation should succeed for at least one valid geometry
+    and should skip any invalid or empty geometries with an error."""
+
+    with pytest.warns(UserWarning, match="Invalid or empty shape"):
+        out = rasterize([geojson_polygon, {'type': 'Polygon', 'coordinates': []}], out_shape=DEFAULT_SHAPE)
+
+    assert np.array_equal(out, basic_image_2x2)
+
+
 def test_rasterize_out_image(basic_geometry, basic_image_2x2):
     """Rasterize operation should succeed for an out image."""
     out = np.zeros(DEFAULT_SHAPE)
@@ -540,7 +555,7 @@ def test_rasterize_invalid_shapes():
     with pytest.raises(ValueError) as ex:
         rasterize([{'foo': 'bar'}], out_shape=DEFAULT_SHAPE)
 
-    assert 'Invalid geometry object' in str(ex.value)
+    assert 'No valid geometry objects found for rasterize' in str(ex.value)
 
 
 def test_rasterize_invalid_out_shape(basic_geometry):