Repository: scipy/scipy

Issue:
ndimage.maximum_filter undocumented behaviour using footprint and mode sequence
<!-- 
Thank you for taking the time to report a SciPy issue.
Please describe the issue in detail, and for bug reports
fill in the fields below. You can delete the sections that 
don't apply to your issue. You can view the final output
by clicking the preview button above.
-->

My issue is about the function scipy.ndimage.maximum_filter and its behaviour while using both footprints and a sequence of modes. The current documentation on the site* doesn't mention it, but unless the footprint is seperable scipy silently assumes that the "mode" argument is a string and then raises an error because it isn't.

*link: https://docs.scipy.org/doc/scipy/reference/generated/scipy.ndimage.maximum_filter.html

### Reproducing code example:
<!-- 
If you place your code between the triple backticks below, 
it will be marked as a code block automatically 
-->


```
import numpy as np
from scipy.ndimage.filters import maximum_filter



arr = np.zeros([5, 5])
arr[2, 0] = 1



#use a small diamond - shaped filter
footprint = np.ones([3, 3])

#""" #uncomment this line to make footprint seperable
footprint[0, 0] = 0
footprint[0, 2] = 0
footprint[2, 0] = 0
footprint[2, 0] = 0
#"""


#no sequence - works
filt = maximum_filter(arr,
                      footprint=footprint,
                      mode='constant')


#sequence (even one whih should do the same) - doesn't work
filt = maximum_filter(arr,
                      footprint=footprint,
                      mode=('constant', 'constant'))
```

### Error message:
<!-- If any, paste the *full* error message inside a code block
as above (starting from line Traceback)
-->

```
Traceback (most recent call last):
  File "C:\Users\Sonicrs\Desktop\scipy_bug_tester.py", line 31, in <module>
    mode=('constant', 'constant'))
  File "C:\Users\Sonicrs\AppData\Local\Programs\Python\Python37\lib\site-packages\scipy\ndimage\filters.py", line 1097, in maximum_filter
    cval, origin, 0)
  File "C:\Users\Sonicrs\AppData\Local\Programs\Python\Python37\lib\site-packages\scipy\ndimage\filters.py", line 1020, in _min_or_max_filter
    mode = _ni_support._extend_mode_to_code(mode)
  File "C:\Users\Sonicrs\AppData\Local\Programs\Python\Python37\lib\site-packages\scipy\ndimage\_ni_support.py", line 52, in _extend_mode_to_code
    raise RuntimeError('boundary mode not supported')
RuntimeError: boundary mode not supported
```

### Scipy/Numpy/Python version information:
```
1.2.0 1.15.4 sys.version_info(major=3, minor=7, micro=0, releaselevel='final', serial=0)
```



Discussion:

Resolving patch:
diff --git a/scipy/ndimage/_ni_support.py b/scipy/ndimage/_ni_support.py
--- a/scipy/ndimage/_ni_support.py
+++ b/scipy/ndimage/_ni_support.py
@@ -30,6 +30,7 @@
 
 from __future__ import division, print_function, absolute_import
 
+from collections.abc import Iterable
 import numpy
 
 
@@ -56,7 +57,7 @@ def _normalize_sequence(input, rank):
     check if its length is equal to the length of array.
     """
     is_str = isinstance(input, str)
-    if hasattr(input, '__iter__') and not is_str:
+    if not is_str and isinstance(input, Iterable):
         normalized = list(input)
         if len(normalized) != rank:
             err = "sequence argument must have length equal to input rank"
diff --git a/scipy/ndimage/filters.py b/scipy/ndimage/filters.py
--- a/scipy/ndimage/filters.py
+++ b/scipy/ndimage/filters.py
@@ -29,6 +29,7 @@
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 from __future__ import division, print_function, absolute_import
+from collections.abc import Iterable
 import warnings
 import numbers
 import numpy
@@ -618,6 +619,8 @@ def _correlate_or_convolve(input, weights, output, mode, cval, origin,
     if not weights.flags.contiguous:
         weights = weights.copy()
     output = _ni_support._get_output(output, input)
+    if not isinstance(mode, str) and isinstance(mode, Iterable):
+        raise RuntimeError("A sequence of modes is not supported")
     mode = _ni_support._extend_mode_to_code(mode)
     _nd_image.correlate(input, weights, output, mode, cval, origins)
     return output
@@ -637,7 +640,7 @@ def correlate(input, weights, output=None, mode='reflect', cval=0.0,
     weights : ndarray
         array of weights, same number of dimensions as input
     %(output)s
-    %(mode_multiple)s
+    %(mode)s
     %(cval)s
     %(origin_multiple)s
 
@@ -663,7 +666,7 @@ def convolve(input, weights, output=None, mode='reflect', cval=0.0,
     weights : array_like
         Array of weights, same number of dimensions as input
     %(output)s
-    %(mode_multiple)s
+    %(mode)s
     cval : scalar, optional
         Value to fill past edges of input if `mode` is 'constant'. Default
         is 0.0
@@ -1027,6 +1030,10 @@ def _min_or_max_filter(input, size, footprint, structure, output, mode,
                 raise RuntimeError('structure array has incorrect shape')
             if not structure.flags.contiguous:
                 structure = structure.copy()
+        if not isinstance(mode, str) and isinstance(mode, Iterable):
+            raise RuntimeError(
+                "A sequence of modes is not supported for non-separable "
+                "footprints")
         mode = _ni_support._extend_mode_to_code(mode)
         _nd_image.min_or_max_filter(input, footprint, structure, output,
                                     mode, cval, origins, minimum)
@@ -1052,6 +1059,11 @@ def minimum_filter(input, size=None, footprint=None, output=None,
     minimum_filter : ndarray
         Filtered array. Has the same shape as `input`.
 
+    Notes
+    -----
+    A sequence of modes (one per axis) is only supported when the footprint is
+    separable. Otherwise, a single mode string must be provided.
+
     Examples
     --------
     >>> from scipy import ndimage, misc
@@ -1089,6 +1101,11 @@ def maximum_filter(input, size=None, footprint=None, output=None,
     maximum_filter : ndarray
         Filtered array. Has the same shape as `input`.
 
+    Notes
+    -----
+    A sequence of modes (one per axis) is only supported when the footprint is
+    separable. Otherwise, a single mode string must be provided.
+
     Examples
     --------
     >>> from scipy import ndimage, misc
@@ -1156,6 +1173,10 @@ def _rank_filter(input, rank, size=None, footprint=None, output=None,
                               origins)
     else:
         output = _ni_support._get_output(output, input)
+        if not isinstance(mode, str) and isinstance(mode, Iterable):
+            raise RuntimeError(
+                "A sequence of modes is not supported by non-separable rank "
+                "filters")
         mode = _ni_support._extend_mode_to_code(mode)
         _nd_image.rank_filter(input, rank, footprint, output, mode, cval,
                               origins)
@@ -1175,7 +1196,7 @@ def rank_filter(input, rank, size=None, footprint=None, output=None,
         indicates the largest element.
     %(size_foot)s
     %(output)s
-    %(mode_multiple)s
+    %(mode)s
     %(cval)s
     %(origin_multiple)s
 
@@ -1214,7 +1235,7 @@ def median_filter(input, size=None, footprint=None, output=None,
     %(input)s
     %(size_foot)s
     %(output)s
-    %(mode_multiple)s
+    %(mode)s
     %(cval)s
     %(origin_multiple)s
 
@@ -1254,7 +1275,7 @@ def percentile_filter(input, percentile, size=None, footprint=None,
         percentile = -20 equals percentile = 80
     %(size_foot)s
     %(output)s
-    %(mode_multiple)s
+    %(mode)s
     %(cval)s
     %(origin_multiple)s
 
@@ -1381,7 +1402,7 @@ def generic_filter(input, function, size=None, footprint=None,
         Function to apply at each element.
     %(size_foot)s
     %(output)s
-    %(mode_multiple)s
+    %(mode)s
     %(cval)s
     %(origin_multiple)s
     %(extra_arguments)s


Test patch:
diff --git a/scipy/ndimage/tests/test_ndimage.py b/scipy/ndimage/tests/test_ndimage.py
--- a/scipy/ndimage/tests/test_ndimage.py
+++ b/scipy/ndimage/tests/test_ndimage.py
@@ -318,6 +318,14 @@ def test_correlate18(self):
             assert_array_almost_equal([[2, 3, 5], [5, 6, 8]], output)
             assert_equal(output.dtype.type, numpy.float32)
 
+    def test_correlate_mode_sequence(self):
+        kernel = numpy.ones((2, 2))
+        array = numpy.ones((3, 3), float)
+        with assert_raises(RuntimeError):
+            ndimage.correlate(array, kernel, mode=['nearest', 'reflect'])
+        with assert_raises(RuntimeError):
+            ndimage.convolve(array, kernel, mode=['nearest', 'reflect'])
+
     def test_correlate19(self):
         kernel = numpy.array([[1, 0],
                               [0, 1]])
@@ -747,6 +755,10 @@ def test_minimum_filter06(self):
         assert_array_almost_equal([[2, 2, 1, 1, 1],
                                    [2, 2, 1, 1, 1],
                                    [5, 3, 3, 1, 1]], output)
+        # separable footprint should allow mode sequence
+        output2 = ndimage.minimum_filter(array, footprint=footprint,
+                                         mode=['reflect', 'reflect'])
+        assert_array_almost_equal(output2, output)
 
     def test_minimum_filter07(self):
         array = numpy.array([[3, 2, 5, 1, 4],
@@ -757,6 +769,9 @@ def test_minimum_filter07(self):
         assert_array_almost_equal([[2, 2, 1, 1, 1],
                                    [2, 3, 1, 3, 1],
                                    [5, 5, 3, 3, 1]], output)
+        with assert_raises(RuntimeError):
+            ndimage.minimum_filter(array, footprint=footprint,
+                                   mode=['reflect', 'constant'])
 
     def test_minimum_filter08(self):
         array = numpy.array([[3, 2, 5, 1, 4],
@@ -822,6 +837,10 @@ def test_maximum_filter06(self):
         assert_array_almost_equal([[3, 5, 5, 5, 4],
                                    [7, 9, 9, 9, 5],
                                    [8, 9, 9, 9, 7]], output)
+        # separable footprint should allow mode sequence
+        output2 = ndimage.maximum_filter(array, footprint=footprint,
+                                         mode=['reflect', 'reflect'])
+        assert_array_almost_equal(output2, output)
 
     def test_maximum_filter07(self):
         array = numpy.array([[3, 2, 5, 1, 4],
@@ -832,6 +851,10 @@ def test_maximum_filter07(self):
         assert_array_almost_equal([[3, 5, 5, 5, 4],
                                    [7, 7, 9, 9, 5],
                                    [7, 9, 8, 9, 7]], output)
+        # non-separable footprint should not allow mode sequence
+        with assert_raises(RuntimeError):
+            ndimage.maximum_filter(array, footprint=footprint,
+                                   mode=['reflect', 'reflect'])
 
     def test_maximum_filter08(self):
         array = numpy.array([[3, 2, 5, 1, 4],
@@ -931,6 +954,15 @@ def test_rank08(self):
         output = ndimage.median_filter(array, size=(2, 3))
         assert_array_almost_equal(expected, output)
 
+        # non-separable: does not allow mode sequence
+        with assert_raises(RuntimeError):
+            ndimage.percentile_filter(array, 50.0, size=(2, 3),
+                                      mode=['reflect', 'constant'])
+        with assert_raises(RuntimeError):
+            ndimage.rank_filter(array, 3, size=(2, 3), mode=['reflect']*2)
+        with assert_raises(RuntimeError):
+            ndimage.median_filter(array, size=(2, 3), mode=['reflect']*2)
+
     def test_rank09(self):
         expected = [[3, 3, 2, 4, 4],
                     [3, 5, 2, 5, 1],
@@ -1068,6 +1100,13 @@ def _filter_func(buffer, weights, total=1.0):
                 extra_keywords={'total': cf.sum()})
             assert_array_almost_equal(r1, r2)
 
+        # generic_filter doesn't allow mode sequence
+        with assert_raises(RuntimeError):
+            r2 = ndimage.generic_filter(
+                a, _filter_func, mode=['reflect', 'reflect'],
+                footprint=footprint, extra_arguments=(cf,),
+                extra_keywords={'total': cf.sum()})
+
     def test_extend01(self):
         array = numpy.array([1, 2, 3])
         weights = numpy.array([1, 0])