Repository: scikit-image/scikit-image

Issue:
Provide a nodata parameter to measure.find_contours
## Provide a nodata parameter to measure.find_contours
At the moment, contours found by `skimage.measure.find_contours` which intersect the array edge are left open and others are closed ([docs](http://scikit-image.org/docs/stable/api/skimage.measure.html#skimage.measure.find_contours)).

Where the data of interest is masked but the array can't be cropped to this data (e.g. the area of interest is rotated or an unusual shape) and surrounding 'pixels' are set to NaN, contours are closed where they meet the NaNs. There's an [illustration in this question](https://gis.stackexchange.com/questions/278516/how-could-i-avoid-extracting-incorrectly-closed-contours-from-a-raster-with-angl) on Stack Exchange.

Could a `nodata` parameter (e.g. `nodata=np.NaN` or `nodata=-9999`) be provided to `measure.find_contours` which treats the `nodata` value in the same way as the edge of the array, returning an open contour when it leaves the data of interest and meets a `nodata` pixel?


Discussion:
This functionality is implemented [here](https://github.com/scikit-image/scikit-image/blob/master/skimage/measure/_find_contours_cy.pyx), if you want to take a look at adding the requested feature.
My experience is not that the contours are closed where they meet the `NaN`s, but they are messed up entirely there - a `NaN` value causes the linear interpolation function `_get_fraction` to return `NaN`, resulting in the contours having `NaN` coordinates. I'm working on a patch.

Resolving patch:
diff --git a/skimage/measure/_find_contours.py b/skimage/measure/_find_contours.py
--- a/skimage/measure/_find_contours.py
+++ b/skimage/measure/_find_contours.py
@@ -7,7 +7,8 @@
 
 
 def find_contours(array, level,
-                  fully_connected='low', positive_orientation='low'):
+                  fully_connected='low', positive_orientation='low',
+                  mask=None):
     """Find iso-valued contours in a 2D array for a given level value.
 
     Uses the "marching squares" method to compute a the iso-valued contours of
@@ -30,6 +31,10 @@ def find_contours(array, level,
          contours will wind counter- clockwise around elements below the
          iso-value. Alternately, this means that low-valued elements are always
          on the left of the contour. (See below for details.)
+    mask : 2D ndarray of bool, or None
+        A boolean mask, True where we want to draw contours.
+        Note that NaN values are always excluded from the considered region (
+        i.e. mask=False is forced where where array is NaN).
 
     Returns
     -------
@@ -58,7 +63,8 @@ def find_contours(array, level,
     with the 'fully_connected' parameter.
 
     Output contours are not guaranteed to be closed: contours which intersect
-    the array edge will be left open. All other contours will be closed. (The
+    the array edge or a masked-off region (either where mask is False or where
+    array is NaN) will be left open. All other contours will be closed. (The
     closed-ness of a contours can be tested by checking whether the beginning
     point is the same as the end point.)
 
@@ -111,12 +117,20 @@ def find_contours(array, level,
     if array.ndim != 2:
         raise ValueError('Only 2D arrays are supported.')
     level = float(level)
+    if mask is not None:
+        if not np.can_cast(mask.dtype, bool, casting='safe'):
+            raise TypeError('Parameter "mask" must be a binary array.')
+        mask = mask.view(np.uint8)
+        if mask.shape != array.shape:
+            raise ValueError('Parameters "array" and "mask"'
+                             ' must have same shape.')
     if (fully_connected not in _param_options or
        positive_orientation not in _param_options):
         raise ValueError('Parameters "fully_connected" and'
         ' "positive_orientation" must be either "high" or "low".')
     point_list = _find_contours_cy.iterate_and_store(array, level,
-                                                     fully_connected == 'high')
+                                                     fully_connected == 'high',
+                                                     mask=mask)
     contours = _assemble_contours(_take_2(point_list))
     if positive_orientation == 'high':
         contours = [c[::-1] for c in contours]


Test patch:
diff --git a/skimage/measure/tests/test_find_contours.py b/skimage/measure/tests/test_find_contours.py
--- a/skimage/measure/tests/test_find_contours.py
+++ b/skimage/measure/tests/test_find_contours.py
@@ -3,6 +3,7 @@
 
 from skimage._shared import testing
 from skimage._shared.testing import assert_array_equal
+from pytest import raises
 
 
 a = np.ones((8, 8), dtype=np.float32)
@@ -45,6 +46,66 @@ def test_binary():
     assert_array_equal(contours[0][::-1], ref)
 
 
+# target contour for mask tests
+mask_contour = [
+    [6. ,  0.5],
+    [5. ,  0.5],
+    [4. ,  0.5],
+    [3. ,  0.5],
+    [2. ,  0.5],
+    [1. ,  0.5],
+    [0.5,  1. ],
+    [0.5,  2. ],
+    [0.5,  3. ],
+    [0.5,  4. ],
+    [0.5,  5. ],
+    [0.5,  6. ],
+    [1. ,  6.5],
+    [1.5,  6. ],
+    [1.5,  5. ],
+    [1.5,  4. ],
+    [1.5,  3. ],
+    [1.5,  2. ],
+    [2. ,  1.5],
+    [3. ,  1.5],
+    [4. ,  1.5],
+    [5. ,  1.5],
+    [6. ,  1.5],
+]
+
+mask = np.ones((8, 8), dtype=bool)
+# Some missing data that should result in a hole in the contour:
+mask[7, 0:3] = False
+
+
+def test_nodata():
+    # Test missing data via NaNs in input array
+    b = np.copy(a)
+    b[~mask] = np.nan
+    contours = find_contours(b, 0.5, positive_orientation='high')
+    assert len(contours) == 1
+    assert_array_equal(contours[0], mask_contour)
+
+
+def test_mask():
+    # Test missing data via explicit masking
+    contours = find_contours(a, 0.5, positive_orientation='high', mask=mask)
+    assert len(contours) == 1
+    assert_array_equal(contours[0], mask_contour)
+
+
+def test_mask_shape():
+    bad_mask = np.ones((8, 7), dtype=bool)
+    with raises(ValueError, match='shape'):
+        find_contours(a, 0, mask=bad_mask)
+
+
+def test_mask_dtype():
+    bad_mask = np.ones((8,8), dtype=np.uint8)
+    with raises(TypeError, match='binary'):
+        find_contours(a, 0, mask=bad_mask)
+
+
 def test_float():
     contours = find_contours(r, 0.5)
     assert len(contours) == 1