Repository: scikit-image/scikit-image

Issue:
BUG: ValueError for input [1, 1] in morphology.local_maxima when [0, 0] is expected
The error can be reproduced with all development versions of scikit-image that include 912d9d2 and the following snippet:
```python
>>> from skimage.morphology import local_maxima
>>> local_maxima([1, 1], allow_borders=False)
```
This raises 
```
----> 1 local_maxima([1, 1], allow_borders=False)

~/Projects/skimage/skimage/morphology/extrema.py in local_maxima(image, selem, connectivity, indices, allow_borders)
    441     selem = _resolve_neighborhood(selem, connectivity, image.ndim)
    442     neighbor_offsets = _offsets_to_raveled_neighbors(
--> 443         image.shape, selem, center=((1,) * image.ndim))
    444 
    445     # Array of flags used to store the state of each pixel during evaluation.

~/Projects/skimage/skimage/morphology/watershed.py in _offsets_to_raveled_neighbors(image_shape, structure, center)
    148     structure[tuple(center)] = 0  # Ignore the center; it's not a neighbor
    149     connection_indices = np.transpose(np.nonzero(structure))
--> 150     offsets = (np.ravel_multi_index(connection_indices.T, image_shape) -
    151                np.ravel_multi_index(center, image_shape))
    152     squared_distances = np.sum((connection_indices - center) ** 2, axis=1)

ValueError: invalid entry in coordinates array
```
but should return `array([0, 0])`.

Until me or someone else finds the time to implement a fix and regression test this issue acts as a reminder.

Discovered in #3255.


Discussion:
Can reproduce, and any dimension with length less than 3 will trigger this behavior.

Also affects `flood_fill`.

Resolving patch:
diff --git a/skimage/morphology/extrema.py b/skimage/morphology/extrema.py
--- a/skimage/morphology/extrema.py
+++ b/skimage/morphology/extrema.py
@@ -16,6 +16,7 @@
 from scipy import ndimage as ndi
 
 from ..util import dtype_limits, invert, crop
+from .._shared.utils import warn
 from . import greyreconstruct
 from .watershed import _offsets_to_raveled_neighbors
 from ._extrema_cy import _local_maxima
@@ -319,11 +320,13 @@ def _resolve_neighborhood(selem, connectivity, ndim):
         selem = np.asarray(selem, dtype=bool)
         # Must specify neighbors for all dimensions
         if selem.ndim != ndim:
-            raise ValueError("selem and image must have the same number of "
-                             "dimensions")
+            raise ValueError(
+                "structuring element and image must have the same number of "
+                "dimensions"
+            )
         # Must only specify direct neighbors
         if any(s != 3 for s in selem.shape):
-            raise ValueError("dimension size in selem is not 3")
+            raise ValueError("dimension size in structuring element is not 3")
 
     return selem
 
@@ -351,9 +354,10 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
         equal to `connectivity` are considered neighbors. Ignored if
         `selem` is not None.
     indices : bool, optional
-        If True, the output will be an array representing indices of local
-        maxima. If False, the output will be an array of 0's and 1's with the
-        same shape as `image`.
+        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`.
     allow_borders : bool, optional
         If true, plateaus that touch the image border are valid maxima.
 
@@ -365,6 +369,12 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
         (0 otherwise). If `indices` is true, a tuple of one-dimensional arrays
         containing the coordinates (indices) of all found maxima.
 
+    Warns
+    -----
+    UserWarning
+        If `allow_borders` is false and any dimension of the given `image` is
+        shorter than 3 samples, maxima can't exist and a warning is shown.
+
     See Also
     --------
     skimage.morphology.local_minima
@@ -431,31 +441,45 @@ def local_maxima(image, selem=None, connectivity=None, indices=False,
     image = np.asarray(image, order="C")
     if image.size == 0:
         # Return early for empty input
-        return np.array([], dtype=(np.intp if indices else np.uint8))
+        if indices:
+            # 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)
 
     if allow_borders:
         # Ensure that local maxima are always at least one smaller sample away
         # from the image border
         image = _fast_pad(image, image.min())
 
-    selem = _resolve_neighborhood(selem, connectivity, image.ndim)
-    neighbor_offsets = _offsets_to_raveled_neighbors(
-        image.shape, selem, center=((1,) * image.ndim))
-
     # Array of flags used to store the state of each pixel during evaluation.
     # See _extrema_cy.pyx for their meaning
     flags = np.zeros(image.shape, dtype=np.uint8)
     _set_edge_values_inplace(flags, value=3)
 
-    try:
-        _local_maxima(image.ravel(), flags.ravel(), neighbor_offsets)
-    except TypeError:
-        if image.dtype == np.float16:
-            # Provide the user with clearer error message
-            raise TypeError("dtype of `image` is float16 which is not "
-                            "supported, try upcasting to float32")
-        else:
-            raise
+    if any(s < 3 for s in image.shape):
+        # Warn and skip if any dimension is smaller than 3
+        # -> no maxima can exist & structuring element can't be applied
+        warn(
+            "maxima can't exist for an image with any dimension smaller 3 "
+            "if borders aren't allowed",
+            stacklevel=3
+        )
+    else:
+        selem = _resolve_neighborhood(selem, connectivity, image.ndim)
+        neighbor_offsets = _offsets_to_raveled_neighbors(
+            image.shape, selem, center=((1,) * image.ndim)
+        )
+
+        try:
+            _local_maxima(image.ravel(), flags.ravel(), neighbor_offsets)
+        except TypeError:
+            if image.dtype == np.float16:
+                # Provide the user with clearer error message
+                raise TypeError("dtype of `image` is float16 which is not "
+                                "supported, try upcasting to float32")
+            else:
+                raise  # Otherwise raise original message
 
     if allow_borders:
         # Revert padding performed at the beginning of the function
@@ -493,9 +517,10 @@ def local_minima(image, selem=None, connectivity=None, indices=False,
         equal to `connectivity` are considered neighbors. Ignored if
         `selem` is not None.
     indices : bool, optional
-        If True, the output will be an array representing indices of local
-        minima. If False, the output will be an array of 0's and 1's with the
-        same shape as `image`.
+        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`.
     allow_borders : bool, optional
         If true, plateaus that touch the image border are valid minima.
 


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
@@ -3,7 +3,7 @@
 
 import numpy as np
 from numpy.testing import assert_equal
-from pytest import raises
+from pytest import raises, warns
 
 from skimage.morphology import extrema
 
@@ -212,13 +212,24 @@ class TestLocalMaxima(unittest.TestCase):
 
     def test_empty(self):
         """Test result with empty image."""
-        result = extrema.local_maxima(np.array([]), indices=False)
+        result = extrema.local_maxima(np.array([[]]), indices=False)
         assert result.size == 0
         assert result.dtype == np.uint8
+        assert result.shape == (1, 0)
 
         result = extrema.local_maxima(np.array([]), indices=True)
-        assert result.size == 0
-        assert result.dtype == np.intp
+        assert isinstance(result, tuple)
+        assert len(result) == 1
+        assert result[0].size == 0
+        assert result[0].dtype == np.intp
+
+        result = extrema.local_maxima(np.array([[]]), indices=True)
+        assert isinstance(result, tuple)
+        assert len(result) == 2
+        assert result[0].size == 0
+        assert result[0].dtype == np.intp
+        assert result[1].size == 0
+        assert result[1].dtype == np.intp
 
     def test_dtypes(self):
         """Test results with default configuration for all supported dtypes."""
@@ -439,6 +450,33 @@ def test_exceptions(self):
         with raises(TypeError, match="float16 which is not supported"):
             extrema.local_maxima(np.empty(1, dtype=np.float16))
 
+    def test_small_array(self):
+        """Test output for arrays with dimension smaller 3.
+
+        If any dimension of an array is smaller than 3 and `allow_borders` is
+        false a structuring element, which has at least 3 elements in each
+        dimension, can't be applied. This is an implementation detail so
+        `local_maxima` should still return valid output (see gh-3261).
+
+        If `allow_borders` is true the array is padded internally and there is
+        no problem.
+        """
+        warning_msg = "maxima can't exist .* any dimension smaller 3 .*"
+        x = np.array([0, 1])
+        extrema.local_maxima(x, allow_borders=True)  # no warning
+        with warns(UserWarning, match=warning_msg):
+            result = extrema.local_maxima(x, allow_borders=False)
+        assert_equal(result, [0, 0])
+        assert result.dtype == np.uint8
+
+        x = np.array([[1, 2], [2, 2]])
+        extrema.local_maxima(x, allow_borders=True, indices=True)  # no warning
+        with warns(UserWarning, match=warning_msg):
+            result = extrema.local_maxima(x, allow_borders=False, indices=True)
+        assert_equal(result, np.zeros((2, 0), dtype=np.intp))
+        assert result[0].dtype == np.intp
+        assert result[1].dtype == np.intp
+
 
 if __name__ == "__main__":
     np.testing.run_module_suite()