Repository: scikit-image/scikit-image

Issue:
local_maxima should return boolean array
Basically the output of `local_maxima` (and `local_minima`) should return a boolean array. Right now the returned dtype uses uint8 (1s and 0s) which can't be used to index numpy arrays as a mask. See this discussion thread in https://github.com/scikit-image/scikit-image/pull/3245#discussion_r255344610.

What is the correct depreciation strategy here?

cc @jni 


Discussion:
I think this can be considered a bug, and should simply be fixed.

Resolving patch:
diff --git a/skimage/morphology/extrema.py b/skimage/morphology/extrema.py
--- a/skimage/morphology/extrema.py
+++ b/skimage/morphology/extrema.py
@@ -317,7 +317,7 @@ def _resolve_neighborhood(selem, connectivity, ndim):
         selem = ndi.generate_binary_structure(ndim, connectivity)
     else:
         # Validate custom structured element
-        selem = np.asarray(selem, dtype=bool)
+        selem = np.asarray(selem, dtype=np.bool)
         # Must specify neighbors for all dimensions
         if selem.ndim != ndim:
             raise ValueError(
@@ -345,8 +345,9 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
         An n-dimensional array.
     selem : ndarray, optional
         A structuring element used to determine the neighborhood of each
-        evaluated pixel. It must contain only 1's and 0's, have the same number
-        of dimensions as `image`. If not given, all adjacent pixels are
+        evaluated pixel (``True`` denotes a connected pixel). It must be a
+        boolean array and have the same number of dimensions as `image`. If
+        neither `selem` nor `connectivity` are given, all adjacent pixels are
         considered as part of the neighborhood.
     connectivity : int, optional
         A number used to determine the neighborhood of each evaluated pixel.
@@ -356,18 +357,17 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
     indices : bool, optional
         If True, the output will be a tuple of one-dimensional arrays
         representing the indices of local maxima in each dimension. If False,
-        the output will be an array of 0's and 1's with the same shape as
-        `image`.
+        the output will be a boolean array with the same shape as `image`.
     allow_borders : bool, optional
         If true, plateaus that touch the image border are valid maxima.
 
     Returns
     -------
     maxima : ndarray or tuple[ndarray]
-        If `indices` is false, an array with the same shape as `image` is
-        returned with 1's indicating the position of local maxima
-        (0 otherwise). If `indices` is true, a tuple of one-dimensional arrays
-        containing the coordinates (indices) of all found maxima.
+        If `indices` is false, a boolean array with the same shape as `image`
+        is returned with ``True`` indicating the position of local maxima
+        (``False`` otherwise). If `indices` is true, a tuple of one-dimensional
+        arrays containing the coordinates (indices) of all found maxima.
 
     Warns
     -----
@@ -415,28 +415,28 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
     connectivity):
 
     >>> local_maxima(image)
-    array([[0, 0, 0, 0, 0, 0, 0],
-           [0, 1, 1, 0, 0, 0, 0],
-           [0, 1, 1, 0, 0, 0, 0],
-           [1, 0, 0, 0, 0, 0, 1]], dtype=uint8)
+    array([[False, False, False, False, False, False, False],
+           [False,  True,  True, False, False, False, False],
+           [False,  True,  True, False, False, False, False],
+           [ True, False, False, False, False, False,  True]], dtype=bool)
     >>> local_maxima(image, indices=True)
     (array([1, 1, 2, 2, 3, 3]), array([1, 2, 1, 2, 0, 6]))
 
     Find local maxima without comparing to diagonal pixels (connectivity 1):
 
     >>> local_maxima(image, connectivity=1)
-    array([[0, 0, 0, 0, 0, 0, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [1, 0, 0, 0, 0, 0, 1]], dtype=uint8)
+    array([[False, False, False, False, False, False, False],
+           [False,  True,  True, False,  True,  True, False],
+           [False,  True,  True, False,  True,  True, False],
+           [ True, False, False, False, False, False,  True]], dtype=bool)
 
     and exclude maxima that border the image edge:
 
     >>> local_maxima(image, connectivity=1, allow_borders=False)
-    array([[0, 0, 0, 0, 0, 0, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
+    array([[False, False, False, False, False, False, False],
+           [False,  True,  True, False,  True,  True, False],
+           [False,  True,  True, False,  True,  True, False],
+           [False, False, False, False, False, False, False]], dtype=bool)
     """
     image = np.asarray(image, order="C")
     if image.size == 0:
@@ -445,7 +445,7 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
             # Make sure that output is a tuple of 1 empty array per dimension
             return np.nonzero(image)
         else:
-            return np.zeros(image.shape, dtype=np.uint8)
+            return np.zeros(image.shape, dtype=np.bool)
 
     if allow_borders:
         # Ensure that local maxima are always at least one smaller sample away
@@ -491,7 +491,7 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
     if indices:
         return np.nonzero(flags)
     else:
-        return flags
+        return flags.view(np.bool)
 
 
 def local_minima(image, selem=None, connectivity=None, indices=False,
@@ -508,8 +508,9 @@ def local_minima(image, selem=None, connectivity=None, indices=False,
         An n-dimensional array.
     selem : ndarray, optional
         A structuring element used to determine the neighborhood of each
-        evaluated pixel. It must contain only 1's and 0's, have the same number
-        of dimensions as `image`. If not given, all adjacent pixels are
+        evaluated pixel (``True`` denotes a connected pixel). It must be a
+        boolean array and have the same number of dimensions as `image`. If
+        neither `selem` nor `connectivity` are given, all adjacent pixels are
         considered as part of the neighborhood.
     connectivity : int, optional
         A number used to determine the neighborhood of each evaluated pixel.
@@ -519,18 +520,17 @@ def local_minima(image, selem=None, connectivity=None, indices=False,
     indices : bool, optional
         If True, the output will be a tuple of one-dimensional arrays
         representing the indices of local minima in each dimension. If False,
-        the output will be an array of 0's and 1's with the same shape as
-        `image`.
+        the output will be a boolean array with the same shape as `image`.
     allow_borders : bool, optional
         If true, plateaus that touch the image border are valid minima.
 
     Returns
     -------
     minima : ndarray or tuple[ndarray]
-        If `indices` is false, an array with the same shape as `image` is
-        returned with 1's indicating the position of local minima
-        (0 otherwise). If `indices` is true, a tuple of one-dimensional arrays
-        containing the coordinates (indices) of all found minima.
+        If `indices` is false, a boolean array with the same shape as `image`
+        is returned with ``True`` indicating the position of local minima
+        (``False`` otherwise). If `indices` is true, a tuple of one-dimensional
+        arrays containing the coordinates (indices) of all found minima.
 
     See Also
     --------
@@ -572,28 +572,28 @@ def local_minima(image, selem=None, connectivity=None, indices=False,
     connectivity):
 
     >>> local_minima(image)
-    array([[0, 0, 0, 0, 0, 0, 0],
-           [0, 1, 1, 0, 0, 0, 0],
-           [0, 1, 1, 0, 0, 0, 0],
-           [1, 0, 0, 0, 0, 0, 1]], dtype=uint8)
+    array([[False, False, False, False, False, False, False],
+           [False,  True,  True, False, False, False, False],
+           [False,  True,  True, False, False, False, False],
+           [ True, False, False, False, False, False,  True]], dtype=bool)
     >>> local_minima(image, indices=True)
     (array([1, 1, 2, 2, 3, 3]), array([1, 2, 1, 2, 0, 6]))
 
     Find local minima without comparing to diagonal pixels (connectivity 1):
 
     >>> local_minima(image, connectivity=1)
-    array([[0, 0, 0, 0, 0, 0, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [1, 0, 0, 0, 0, 0, 1]], dtype=uint8)
+    array([[False, False, False, False, False, False, False],
+           [False,  True,  True, False,  True,  True, False],
+           [False,  True,  True, False,  True,  True, False],
+           [ True, False, False, False, False, False,  True]], dtype=bool)
 
     and exclude minima that border the image edge:
 
     >>> local_minima(image, connectivity=1, allow_borders=False)
-    array([[0, 0, 0, 0, 0, 0, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [0, 1, 1, 0, 1, 1, 0],
-           [0, 0, 0, 0, 0, 0, 0]], dtype=uint8)
+    array([[False, False, False, False, False, False, False],
+           [False,  True,  True, False,  True,  True, False],
+           [False,  True,  True, False,  True,  True, False],
+           [False, False, False, False, False, False, False]], dtype=bool)
     """
     return local_maxima(
         image=invert(image),


Test patch:
diff --git a/skimage/morphology/tests/test_extrema.py b/skimage/morphology/tests/test_extrema.py
--- a/skimage/morphology/tests/test_extrema.py
+++ b/skimage/morphology/tests/test_extrema.py
@@ -197,7 +197,7 @@ class TestLocalMaxima(unittest.TestCase):
          [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
          [0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0],
          [0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]],
-        dtype=np.uint8
+        dtype=np.bool
     )
     # Connectivity 1 (cross), maxima can touch border
     expected_cross = np.array(
@@ -207,14 +207,14 @@ class TestLocalMaxima(unittest.TestCase):
          [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0],
          [0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0],
          [0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]],
-        dtype=np.uint8
+        dtype=np.bool
     )
 
     def test_empty(self):
         """Test result with empty image."""
         result = extrema.local_maxima(np.array([[]]), indices=False)
         assert result.size == 0
-        assert result.dtype == np.uint8
+        assert result.dtype == np.bool
         assert result.shape == (1, 0)
 
         result = extrema.local_maxima(np.array([]), indices=True)
@@ -235,6 +235,7 @@ def test_dtypes(self):
         """Test results with default configuration for all supported dtypes."""
         for dtype in self.supported_dtypes:
             result = extrema.local_maxima(self.image.astype(dtype))
+            assert result.dtype == np.bool
             assert_equal(result, self.expected_default)
 
     def test_dtypes_old(self):
@@ -266,40 +267,53 @@ def test_dtypes_old(self):
              [0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
              [0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
              [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
-            dtype=np.uint8
+            dtype=np.bool
         )
         for dtype in self.supported_dtypes:
             image = data.astype(dtype)
             result = extrema.local_maxima(image)
+            assert result.dtype == np.bool
             assert_equal(result, expected)
 
     def test_connectivity(self):
         """Test results if selem is a scalar."""
         # Connectivity 1: generates cross shaped structuring element
         result_conn1 = extrema.local_maxima(self.image, connectivity=1)
+        assert result_conn1.dtype == np.bool
         assert_equal(result_conn1, self.expected_cross)
 
         # Connectivity 2: generates square shaped structuring element
         result_conn2 = extrema.local_maxima(self.image, connectivity=2)
+        assert result_conn2.dtype == np.bool
         assert_equal(result_conn2, self.expected_default)
 
         # Connectivity 3: generates square shaped structuring element
         result_conn3 = extrema.local_maxima(self.image, connectivity=3)
+        assert result_conn3.dtype == np.bool
         assert_equal(result_conn3, self.expected_default)
 
     def test_selem(self):
-        """Test results if selem is an array."""
-        selem_cross = np.array([[0, 1, 0], [1, 1, 1], [0, 1, 0]])
+        """Test results if selem is given."""
+        selem_cross = np.array(
+            [[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.bool)
         result_selem_cross = extrema.local_maxima(
             self.image, selem=selem_cross)
+        assert result_selem_cross.dtype == np.bool
         assert_equal(result_selem_cross, self.expected_cross)
 
-        selem_square = np.ones((3, 3), dtype=np.uint8)
-        result_selem_square = extrema.local_maxima(
-            self.image, selem=selem_square)
-        assert_equal(result_selem_square, self.expected_default)
-
-        selem_x = np.array([[1, 0, 1], [0, 1, 0], [1, 0, 1]])
+        for selem in [
+            ((True,) * 3,) * 3,
+            np.ones((3, 3), dtype=np.float64),
+            np.ones((3, 3), dtype=np.uint8),
+            np.ones((3, 3), dtype=np.bool),
+        ]:
+            # Test different dtypes for selem which expects a boolean array but
+            # will accept and convert other types if possible
+            result_selem_square = extrema.local_maxima(self.image, selem=selem)
+            assert result_selem_square.dtype == np.bool
+            assert_equal(result_selem_square, self.expected_default)
+
+        selem_x = np.array([[1, 0, 1], [0, 1, 0], [1, 0, 1]], dtype=np.bool)
         expected_selem_x = np.array(
             [[1, 1, 0, 1, 0, 1, 0, 0, 1, 0, 0, 0, 0, 0, 0],
              [1, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
@@ -307,9 +321,10 @@ def test_selem(self):
              [0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0],
              [0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 0, 0],
              [0, 0, 1, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0]],
-            dtype=np.uint8
+            dtype=np.bool
         )
         result_selem_x = extrema.local_maxima(self.image, selem=selem_x)
+        assert result_selem_x.dtype == np.bool
         assert_equal(result_selem_x, expected_selem_x)
 
     def test_indices(self):
@@ -332,6 +347,7 @@ def test_allow_borders(self):
         # of interest
         result_with_boder = extrema.local_maxima(
             self.image, connectivity=1, allow_borders=True)
+        assert result_with_boder.dtype == np.bool
         assert_equal(result_with_boder, self.expected_cross)
 
         expected_without_border = np.array(
@@ -341,10 +357,11 @@ def test_allow_borders(self):
              [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 0],
              [0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0],
              [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
-            dtype=np.uint8
+            dtype=np.bool
         )
         result_without_border = extrema.local_maxima(
             self.image, connectivity=1, allow_borders=False)
+        assert result_with_boder.dtype == np.bool
         assert_equal(result_without_border, expected_without_border)
 
     def test_nd(self):
@@ -352,13 +369,14 @@ def test_nd(self):
         # One-dimension
         x_1d = np.array([1, 1, 0, 1, 2, 3, 0, 2, 1, 2, 0])
         expected_1d = np.array([1, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0],
-                               dtype=np.uint8)
+                               dtype=np.bool)
         result_1d = extrema.local_maxima(x_1d)
+        assert result_1d.dtype == np.bool
         assert_equal(result_1d, expected_1d)
 
         # 3-dimensions (adapted from old unit test)
         x_3d = np.zeros((8, 8, 8), dtype=np.uint8)
-        expected_3d = np.zeros((8, 8, 8), dtype=np.uint8)
+        expected_3d = np.zeros((8, 8, 8), dtype=np.bool)
         # first maximum: only one pixel
         x_3d[1, 1:3, 1:3] = 100
         x_3d[2, 2, 2] = 200
@@ -379,6 +397,7 @@ def test_nd(self):
         x_3d[7, 7, 7] = 255
         expected_3d[7, 7, 7] = 1
         result_3d = extrema.local_maxima(x_3d)
+        assert result_3d.dtype == np.bool
         assert_equal(result_3d, expected_3d)
 
     def test_constant(self):
@@ -389,9 +408,11 @@ def test_constant(self):
             const_image = const_image.astype(dtype)
             # test for local maxima
             result = extrema.local_maxima(const_image)
+            assert result.dtype == np.bool
             assert_equal(result, expected)
             # test for local minima
             result = extrema.local_minima(const_image)
+            assert result.dtype == np.bool
             assert_equal(result, expected)
 
     def test_extrema_float(self):
@@ -422,30 +443,36 @@ def test_extrema_float(self):
              [0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
              [0, 0, 1, 1, 0, 0, 0, 1, 1, 0],
              [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]],
-            dtype=np.uint8
+            dtype=np.bool
         )
 
         # Test for local maxima with automatic step calculation
-        out = extrema.local_maxima(image)
-        assert_equal(out, expected_result)
+        result = extrema.local_maxima(image)
+        assert result.dtype == np.bool
+        assert_equal(result, expected_result)
 
         # Test for local minima with automatic step calculation
-        out = extrema.local_minima(inverted_image)
-        assert_equal(out, expected_result)
+        result = extrema.local_minima(inverted_image)
+        assert result.dtype == np.bool
+        assert_equal(result, expected_result)
 
     def test_exceptions(self):
         """Test if input validation triggers correct exceptions."""
         # Mismatching number of dimensions
         with raises(ValueError, match="number of dimensions"):
-            extrema.local_maxima(self.image, selem=np.ones((3, 3, 3)))
+            extrema.local_maxima(
+                self.image, selem=np.ones((3, 3, 3), dtype=np.bool))
         with raises(ValueError, match="number of dimensions"):
-            extrema.local_maxima(self.image, selem=np.ones((3,)))
+            extrema.local_maxima(
+                self.image, selem=np.ones((3,), dtype=np.bool))
 
         # All dimensions in selem must be of size 3
         with raises(ValueError, match="dimension size"):
-            extrema.local_maxima(self.image, selem=np.ones((2, 3)))
+            extrema.local_maxima(
+                self.image, selem=np.ones((2, 3), dtype=np.bool))
         with raises(ValueError, match="dimension size"):
-            extrema.local_maxima(self.image, selem=np.ones((5, 5)))
+            extrema.local_maxima(
+                self.image, selem=np.ones((5, 5), dtype=np.bool))
 
         with raises(TypeError, match="float16 which is not supported"):
             extrema.local_maxima(np.empty(1, dtype=np.float16))
@@ -467,7 +494,7 @@ def test_small_array(self):
         with warns(UserWarning, match=warning_msg):
             result = extrema.local_maxima(x, allow_borders=False)
         assert_equal(result, [0, 0])
-        assert result.dtype == np.uint8
+        assert result.dtype == np.bool
 
         x = np.array([[1, 2], [2, 2]])
         extrema.local_maxima(x, allow_borders=True, indices=True)  # no warning