Repository: scikit-image/scikit-image

Issue:
Harden functions in skimage.filters.rank to not accept a one-dimensional image
### Description:

I was in a situation where I wanted to do local thresholding on a 2D image, but I had mistakenly given a 1D array as the input argument to `skimage.filters.ran.otsu()`. The error made it seem like the function only accepted 3D arrays. I believe this could be fixed by running expanding the if tree a bit, like below. This pattern is repeated in a number of functions in this module.

This seems like a small fix, but I have never contributed to a library as big as this one and am afraid setting up a local dev environment would take too much of my time at the moment.

Current behaviour:
```
> import numpy as np
> from skimage.filters.rank import otsu
> from skimage.morphology import disk
> otsu(np.eye(10).ravel(), disk(3))

ValueError: The parameter `image` must be a 3-dimensional array
```

Desired behaviour:
```
> import numpy as np
> from skimage.filters.rank import otsu
> from skimage.morphology import disk
> otsu(np.eye(10).ravel(), disk(3))

ValueError: The parameter `image` must be a 2- or 3-dimensional array
```

Proposed solution
```
# scikit-image/skimage/filters/rank/generic.py

def otsu(image, ...):
    np_image = np.asanyarray(image)
    if np_image.ndim == 2:
        return _apply_scalar_per_pixel(...)
    elif np_image.ndim == 3:  # Added check
        return _apply_scalar_per_pixel_3D(...)
    else:
        raise ValueError('The parameter `image` must be a 2- or 3-dimensional array') # Added error
```



Discussion:
Great catch @wduverger! I'd call this a bug in our API documentation and I agree with you on how we should address this. I'm gonna label this for now in case someone is looking for an easy "Good first issue". If you decide to takle this we are totally happy with helping you with a quick setup of the development environment. Thanks for the contribution so far! :)
Hi @lagru, I'm Daria :)
Been wanting to start contributing, and this seems like a great way to start.
Can you please assign me to the issue and I'll create a PR?

I already managed to set up a dev environment, so I'm ready to go.
Thanks!
Hey @DariaHasin, we very rarely assign issues to someone explicitly. Please go ahead and make a PR and feel welcome to ping me if you need help or input. :)

> I already managed to set up a dev environment, so I'm ready to go.

Great. If you have feedback or thoughts on the process, they can be very valuable.

Resolving patch:
diff --git a/skimage/filters/rank/generic.py b/skimage/filters/rank/generic.py
old mode 100755
new mode 100644
--- a/skimage/filters/rank/generic.py
+++ b/skimage/filters/rank/generic.py
@@ -385,11 +385,14 @@ def autolevel(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._autolevel, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._autolevel_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def equalize(image, footprint, out=None, mask=None,
@@ -435,11 +438,14 @@ def equalize(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._equalize, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._equalize_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def gradient(image, footprint, out=None, mask=None,
@@ -485,11 +491,14 @@ def gradient(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._gradient, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._gradient_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def maximum(image, footprint, out=None, mask=None,
@@ -544,11 +553,14 @@ def maximum(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._maximum, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._maximum_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def mean(image, footprint, out=None, mask=None,
@@ -594,11 +606,14 @@ def mean(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._mean, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._mean_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def geometric_mean(image, footprint, out=None, mask=None,
@@ -649,11 +664,14 @@ def geometric_mean(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._geometric_mean, image,
                                        footprint, out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._geometric_mean_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def subtract_mean(image, footprint, out=None, mask=None,
@@ -707,11 +725,14 @@ def subtract_mean(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._subtract_mean, image,
                                        footprint, out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._subtract_mean_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def median(image, footprint=None, out=None, mask=None,
@@ -765,11 +786,14 @@ def median(image, footprint=None, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._median, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._median_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def minimum(image, footprint, out=None, mask=None,
@@ -824,11 +848,14 @@ def minimum(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._minimum, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._minimum_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def modal(image, footprint, out=None, mask=None,
@@ -876,11 +903,14 @@ def modal(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._modal, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._modal_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def enhance_contrast(image, footprint, out=None, mask=None,
@@ -930,11 +960,14 @@ def enhance_contrast(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._enhance_contrast, image,
                                        footprint, out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._enhance_contrast_3D,
                                           image, footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def pop(image, footprint, out=None, mask=None,
@@ -987,11 +1020,14 @@ def pop(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._pop, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._pop_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def sum(image, footprint, out=None, mask=None,
@@ -1044,11 +1080,14 @@ def sum(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._sum, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._sum_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def threshold(image, footprint, out=None, mask=None,
@@ -1101,11 +1140,14 @@ def threshold(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._threshold, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._threshold_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def noise_filter(image, footprint, out=None, mask=None,
@@ -1167,7 +1209,7 @@ def noise_filter(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._noise_filter, image,
                                        footprint_cpy, out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         # ensure that the central pixel in the footprint is empty
         centre_r = int(footprint.shape[0] / 2) + shift_y
         centre_c = int(footprint.shape[1] / 2) + shift_x
@@ -1181,6 +1223,10 @@ def noise_filter(image, footprint, out=None, mask=None,
                                           mask=mask, shift_x=shift_x,
                                           shift_y=shift_y, shift_z=shift_z)
 
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
+
 
 def entropy(image, footprint, out=None, mask=None,
             shift_x=False, shift_y=False, shift_z=False):
@@ -1234,12 +1280,15 @@ def entropy(image, footprint, out=None, mask=None,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y,
                                        out_dtype=np.float64)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._entropy_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z,
                                           out_dtype=np.float64)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def otsu(image, footprint, out=None, mask=None,
@@ -1291,11 +1340,14 @@ def otsu(image, footprint, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._otsu, image, footprint,
                                        out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._otsu_3D, image,
                                           footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )
 
 
 def windowed_histogram(image, footprint, out=None, mask=None,
@@ -1396,8 +1448,11 @@ def majority(image, footprint, *, out=None, mask=None,
         return _apply_scalar_per_pixel(generic_cy._majority, image,
                                        footprint, out=out, mask=mask,
                                        shift_x=shift_x, shift_y=shift_y)
-    else:
+    elif np_image.ndim == 3:
         return _apply_scalar_per_pixel_3D(generic_cy._majority_3D,
                                           image, footprint, out=out, mask=mask,
                                           shift_x=shift_x, shift_y=shift_y,
                                           shift_z=shift_z)
+    raise ValueError(
+        f'`image` must have 2 or 3 dimensions, got {np_image.ndim}.'
+    )


Test patch:
diff --git a/skimage/filters/rank/tests/test_rank.py b/skimage/filters/rank/tests/test_rank.py
--- a/skimage/filters/rank/tests/test_rank.py
+++ b/skimage/filters/rank/tests/test_rank.py
@@ -59,6 +59,38 @@ def test_subtract_mean_underflow_correction(dtype):
 ref_data_3d = dict(np.load(fetch('data/rank_filters_tests_3d.npz')))
 
 
+@pytest.mark.parametrize(
+    'func',
+    [
+        rank.autolevel,
+        rank.equalize,
+        rank.gradient,
+        rank.maximum,
+        rank.mean,
+        rank.geometric_mean,
+        rank.subtract_mean,
+        rank.median,
+        rank.minimum,
+        rank.modal,
+        rank.enhance_contrast,
+        rank.pop,
+        rank.sum,
+        rank.threshold,
+        rank.noise_filter,
+        rank.entropy,
+        rank.otsu,
+        rank.majority
+    ],
+)
+def test_1d_input_raises_error(func):
+    image = np.arange(10)
+    footprint = disk(3)
+    with pytest.raises(
+        ValueError, match='`image` must have 2 or 3 dimensions, got 1'
+    ):
+        func(image, footprint)
+
+
 class TestRank:
     def setup_method(self):
         np.random.seed(0)