Repository: scipy/scipy

Issue:
BUG: incorrect origin tuple handling in ndimage `minimum_filter` and `maximum_filter` with footprint covering a subset of axes
### Describe your issue.

In SciPy 1.11, axes support was added to a number of ndimage filters. While porting this to CuPy I found that for `minimum_filter` and `maximum_filter` when all three of the following criteria are true, there is a bug in `origin` argument handling:

1. `len(axes) < ndim`
2. a non-separable `footprint` array is provided
3. origin is provided as a tuple

I have a fix for the issue and will submit a small MR for it.

### Reproducing Code Example

```python
import numpy as np
from scipy.ndimage import maximum_filter

img = np.arange((5 * 6 * 7), dtype=np.float64).reshape(5, 6, 7)
footprint = np.ones((3, 5), dtype=bool)
footprint[1, 0] = 0
out = maximum_filter(img, footprint=footprint, axes=(-2, -1), origin=(0, 1))
```


### Error message

```shell
RuntimeError                              Traceback (most recent call last)
Cell In[3], line 7
      5 footprint = np.ones((3, 5), dtype=bool)
      6 footprint[1, 0] = 0
----> 7 out = maximum_filter(img, footprint=footprint, axes=(-2, -1), origin=(0, 1))

File ~/mambaforge/envs/cucim310/lib/python3.10/site-packages/scipy/ndimage/_filters.py:1331, in maximum_filter(input, size, footprint, output, mode, cval, origin, axes)
   1287 @_ni_docstrings.docfiller
   1288 def maximum_filter(input, size=None, footprint=None, output=None,
   1289                    mode="reflect", cval=0.0, origin=0, *, axes=None):
   1290     """Calculate a multidimensional maximum filter.
   1291 
   1292     Parameters
   (...)
   1329     >>> plt.show()
   1330     """
-> 1331     return _min_or_max_filter(input, size, footprint, None, output, mode,
   1332                               cval, origin, 0, axes)

File ~/mambaforge/envs/cucim310/lib/python3.10/site-packages/scipy/ndimage/_filters.py:1200, in _min_or_max_filter(input, size, footprint, structure, output, mode, cval, origin, minimum, axes)
   1198         output[...] = input[...]
   1199 else:
-> 1200     origins = _ni_support._normalize_sequence(origin, input.ndim)
   1201     if num_axes < input.ndim:
   1202         if footprint.ndim != num_axes:

File ~/mambaforge/envs/cucim310/lib/python3.10/site-packages/scipy/ndimage/_ni_support.py:68, in _normalize_sequence(input, rank)
     66     if len(normalized) != rank:
     67         err = "sequence argument must have length equal to input rank"
---> 68         raise RuntimeError(err)
     69 else:
     70     normalized = [input] * rank

RuntimeError: sequence argument must have length equal to input rank
```
```


### SciPy/NumPy/Python version and system information

```shell
1.11.1 1.26.4 sys.version_info(major=3, minor=10, micro=12, releaselevel='final', serial=0)
```



Discussion:

Resolving patch:
diff --git a/scipy/ndimage/_filters.py b/scipy/ndimage/_filters.py
--- a/scipy/ndimage/_filters.py
+++ b/scipy/ndimage/_filters.py
@@ -1240,7 +1240,7 @@ def _min_or_max_filter(input, size, footprint, structure, output, mode,
             footprint = np.asarray(footprint, dtype=bool)
     input = np.asarray(input)
     if np.iscomplexobj(input):
-        raise TypeError('Complex type not supported')
+        raise TypeError("Complex type not supported")
     output = _ni_support._get_output(output, input)
     temp_needed = np.may_share_memory(input, output)
     if temp_needed:
@@ -1266,7 +1266,7 @@ def _min_or_max_filter(input, size, footprint, structure, output, mode,
         else:
             output[...] = input[...]
     else:
-        origins = _ni_support._normalize_sequence(origin, input.ndim)
+        origins = _ni_support._normalize_sequence(origin, num_axes)
         if num_axes < input.ndim:
             if footprint.ndim != num_axes:
                 raise RuntimeError("footprint array has incorrect shape")
@@ -1274,17 +1274,23 @@ def _min_or_max_filter(input, size, footprint, structure, output, mode,
                 footprint,
                 tuple(ax for ax in range(input.ndim) if ax not in axes)
             )
+            # set origin = 0 for any axes not being filtered
+            origins_temp = [0,] * input.ndim
+            for o, ax in zip(origins, axes):
+                origins_temp[ax] = o
+            origins = origins_temp
+
         fshape = [ii for ii in footprint.shape if ii > 0]
         if len(fshape) != input.ndim:
             raise RuntimeError('footprint array has incorrect shape.')
         for origin, lenf in zip(origins, fshape):
             if (lenf // 2 + origin < 0) or (lenf // 2 + origin >= lenf):
-                raise ValueError('invalid origin')
+                raise ValueError("invalid origin")
         if not footprint.flags.contiguous:
             footprint = footprint.copy()
         if structure is not None:
             if len(structure.shape) != input.ndim:
-                raise RuntimeError('structure array has incorrect shape')
+                raise RuntimeError("structure array has incorrect shape")
             if num_axes != structure.ndim:
                 structure = np.expand_dims(
                     structure,


Test patch:
diff --git a/scipy/ndimage/tests/test_filters.py b/scipy/ndimage/tests/test_filters.py
--- a/scipy/ndimage/tests/test_filters.py
+++ b/scipy/ndimage/tests/test_filters.py
@@ -792,6 +792,32 @@ def test_filter_axes_kwargs(self, filter_func, size0, size, kwargs, axes):
         expected = filter_func(array, *args, size_3d, **kwargs)
         assert_allclose(output, expected)
 
+    @pytest.mark.parametrize("filter_func, kwargs",
+                             [(ndimage.minimum_filter, {}),
+                              (ndimage.maximum_filter, {}),
+                              (ndimage.median_filter, {}),
+                              (ndimage.rank_filter, {"rank": 1}),
+                              (ndimage.percentile_filter, {"percentile": 30})])
+    def test_filter_weights_subset_axes_origins(self, filter_func, kwargs):
+        axes = (-2, -1)
+        origins = (0, 1)
+        array = np.arange(6 * 8 * 12, dtype=np.float64).reshape(6, 8, 12)
+        axes = np.array(axes)
+
+        # weights with ndim matching len(axes)
+        footprint = np.ones((3, 5), dtype=bool)
+        footprint[0, 1] = 0  # make non-separable
+
+        output = filter_func(
+            array, footprint=footprint, axes=axes, origin=origins, **kwargs)
+
+        output0 = filter_func(
+            array, footprint=footprint, axes=axes, origin=0, **kwargs)
+
+        # output has origin shift on last axis relative to output0, so
+        # expect shifted arrays to be equal.
+        np.testing.assert_array_equal(output[:, :, 1:], output0[:, :, :-1])
+
     @pytest.mark.parametrize(
         'filter_func, args',
         [(ndimage.gaussian_filter, (1.0,)),      # args = (sigma,)