Repository: scikit-image/scikit-image

Issue:
Allow rectangular window sizes in threshold_niblack and threshold_sauvola
## Description
The functions `filters.threshold_{niblack,sauvola}` accept a `window_size` parameter, which defines a square/cubic window along which to threshold. However, any cuboid shape would work fine, and would be desirable e.g. in anisotropic 3D data, or even in 2D data where the lighting changes more rapidly along one dimension than the other.


Discussion:
Is a fix for this submitted in #2441?
@souravsingh no, that is only a bug fix. #2441 is critical to get merged before 0.13, whereas the added convenience of rectangular/cuboid window is a luxury that can wait.
According to this issue, do we need to work on the window_size parameter for 2D & 3D data, to decide accordingly what would be the window size for these?
I want to attempt this
Is there any particular reference work (this issue points to) on 2D or 3D window sizes for local thresholding techniques? 
Okay, so current implementation is for 2D but the image is same size. So the improvement that is required is to implement a 2D window size when the image is not of same size like rectangle? and if we consider 3D image, the same 2D implementation can be applied but on all faces of the image? Did I interpret this correctly? 
Hi all,

If you look at the code for `threshold_niblack` and `threshold_sauvola`, you'll see that the window-size parameter accepts only an integer. The idea here would be to accept a tuple with the same number of elements as the input image has dimensions. So, for example, `window_size=(3, 5)` would create a window of 3 rows by 5 columns. `window_size=(3, 15, 15)` would create a 3D window size of 3 planes by 15 rows by 15 columns. (Please refer to our documentation on [coordinate conventions](http://scikit-image.org/docs/dev/user_guide/numpy_images.html#coordinate-conventions), too.

This is the same way that Gaussian filter accepts both a single sigma as well as a tuple sigma.

I hope this helps!
Thank you, Juan.  It did help. 
I want to attempt this.
@sinha-kirti we are already working on this!
Alright! Please let me know if you require any help.
Hi @sarwatfatimam and @meriki, have you had any progress in this one? Thanks!

Resolving patch:
diff --git a/skimage/filters/thresholding.py b/skimage/filters/thresholding.py
--- a/skimage/filters/thresholding.py
+++ b/skimage/filters/thresholding.py
@@ -3,6 +3,7 @@
 import numpy as np
 from scipy import ndimage as ndi
 from collections import OrderedDict
+from collections.abc import Iterable
 from ..exposure import histogram
 from .._shared.utils import assert_nD, warn, deprecated
 from ..transform import integral_image
@@ -793,16 +794,18 @@ def threshold_triangle(image, nbins=256):
 
 def _mean_std(image, w):
     """Return local mean and standard deviation of each pixel using a
-    neighborhood defined by a rectangular window with size w times w.
+    neighborhood defined by a rectangular window size ``w``.
     The algorithm uses integral images to speedup computation. This is
-    used by threshold_niblack and threshold_sauvola.
+    used by :func:`threshold_niblack` and :func:`threshold_sauvola`.
 
     Parameters
     ----------
     image : ndarray
         Input image.
-    w : int
-        Odd window size (e.g. 3, 5, 7, ..., 21, ...).
+    w : int, tuple or list of integer
+        Odd size window specified as a single integer (e.g., 3, 5,
+        etc.) or a tuple of the same dimension than ``image``
+        containing odd integers (e.g., (3, 5)).
 
     Returns
     -------
@@ -818,27 +821,40 @@ def _mean_std(image, w):
            Retrieval XV, (San Jose, USA), Jan. 2008.
            :DOI:`10.1117/12.767755`
     """
-    if w == 1 or w % 2 == 0:
-        raise ValueError(
-            "Window size w = %s must be odd and greater than 1." % w)
+    def _sanity_check_window_size(axis_size):
+        if axis_size % 2 == 0:
+            raise ValueError(
+                "Window size w = {} must be odd."
+                .format(axis_size)
+            )
+
+    if isinstance(w, Iterable):
+        for axis in w:
+            _sanity_check_window_size(axis)
+        kern_size = tuple(w_ + 1 for w_ in w)
+    else:
+        _sanity_check_window_size(w)
+        kern_size = (w + 1, ) * image.ndim
+        w = (w, ) * image.ndim
+
+    total_window_size = np.prod(w)
 
-    left_pad = w // 2 + 1
-    right_pad = w // 2
-    padded = np.pad(image.astype('float'), (left_pad, right_pad),
+    pad_width = tuple((k_ // 2 + 1, k_ // 2) for k_ in w)
+    padded = np.pad(image.astype('float'), pad_width,
                     mode='reflect')
     padded_sq = padded * padded
 
     integral = integral_image(padded)
     integral_sq = integral_image(padded_sq)
 
-    kern = np.zeros((w + 1,) * image.ndim)
+    kern = np.zeros(kern_size)
     for indices in itertools.product(*([[0, -1]] * image.ndim)):
         kern[indices] = (-1) ** (image.ndim % 2 != np.sum(indices) % 2)
 
     sum_full = ndi.correlate(integral, kern, mode='constant')
-    m = crop(sum_full, (left_pad, right_pad)) / (w ** image.ndim)
+    m = crop(sum_full, pad_width) / total_window_size
     sum_sq_full = ndi.correlate(integral_sq, kern, mode='constant')
-    g2 = crop(sum_sq_full, (left_pad, right_pad)) / (w ** image.ndim)
+    g2 = crop(sum_sq_full, pad_width) / total_window_size
     # Note: we use np.clip because g2 is not guaranteed to be greater than
     # m*m when floating point error is considered
     s = np.sqrt(np.clip(g2 - m * m, 0, None))
@@ -860,10 +876,12 @@ def threshold_niblack(image, window_size=15, k=0.2):
 
     Parameters
     ----------
-    image: (N, M) ndarray
-        Grayscale input image.
-    window_size : int, optional
-        Odd size of pixel neighborhood window (e.g. 3, 5, 7...).
+    image: ndarray
+        Input image.
+    window_size : int, tuple or list of integer, optional
+        Odd size window specified as a single integer (e.g., 3, 5,
+        etc.) or a tuple of the same dimension than ``image``
+        containing odd integers (e.g., (3, 5)).
     k : float, optional
         Value of parameter k in threshold formula.
 
@@ -909,10 +927,12 @@ def threshold_sauvola(image, window_size=15, k=0.2, r=None):
 
     Parameters
     ----------
-    image: (N, M) ndarray
-        Grayscale input image.
-    window_size : int, optional
-        Odd size of pixel neighborhood window (e.g. 3, 5, 7...).
+    image: ndarray
+        Input image.
+    window_size : int, tuple or list of integer, optional
+        Odd size window specified as a single integer (e.g., 3, 5,
+        etc.) or a tuple of the same dimension than ``image``
+        containing odd integers (e.g., (3, 5)).
     k : float, optional
         Value of the positive parameter k.
     r : float, optional


Test patch:
diff --git a/skimage/filters/tests/test_thresholding.py b/skimage/filters/tests/test_thresholding.py
--- a/skimage/filters/tests/test_thresholding.py
+++ b/skimage/filters/tests/test_thresholding.py
@@ -1,7 +1,6 @@
 import pytest
 import numpy as np
 from scipy import ndimage as ndi
-
 import skimage
 from skimage import data
 from skimage._shared._warnings import expected_warnings
@@ -20,6 +19,7 @@
                                           _cross_entropy)
 from skimage._shared import testing
 from skimage._shared.testing import assert_equal, assert_almost_equal
+from skimage._shared.testing import assert_array_equal
 
 
 class TestSimpleImage():
@@ -187,6 +187,30 @@ def test_threshold_sauvola(self):
         out = self.image > thres
         assert_equal(ref, out)
 
+    def test_threshold_niblack_iterable_window_size(self):
+        ref = np.array(
+            [[False, False, False, True, True],
+             [False, False, True, True, True],
+             [False, True, True, True, False],
+             [False, True, True, True, False],
+             [True, True, False, False, False]]
+        )
+        thres = threshold_niblack(self.image, window_size=[3, 5], k=0.5)
+        out = self.image > thres
+        assert_array_equal(ref, out)
+
+    def test_threshold_sauvola_iterable_window_size(self):
+        ref = np.array(
+            [[False, False, False, True, True],
+             [False, False, True, True, True],
+             [False, False, True, True, False],
+             [False, True, True, True, False],
+             [True, True, False, False, False]]
+        )
+        thres = threshold_sauvola(self.image, window_size=(3, 5), k=0.2, r=128)
+        out = self.image > thres
+        assert_array_equal(ref, out)
+
 
 def test_otsu_camera_image():
     camera = skimage.img_as_ubyte(data.camera())
@@ -431,11 +455,18 @@ def test_triangle_flip():
     assert(len(unequal_pos[0]) / t_img.size < 1e-2)
 
 
-def test_mean_std_2d():
+@pytest.mark.parametrize(
+    "window_size, mean_kernel",
+    [(11, np.full((11,) * 2,  1 / 11 ** 2)),
+     ((11, 11), np.full((11, 11), 1 / 11 ** 2)),
+     ((9, 13), np.full((9, 13), 1 / np.prod((9, 13)))),
+     ((13, 9), np.full((13, 9), 1 / np.prod((13, 9)))),
+     ((1, 9), np.full((1, 9), 1 / np.prod((1, 9))))
+     ]
+)
+def test_mean_std_2d(window_size, mean_kernel):
     image = np.random.rand(256, 256)
-    window_size = 11
     m, s = _mean_std(image, w=window_size)
-    mean_kernel = np.full((window_size,) * 2,  1 / window_size**2)
     expected_m = ndi.convolve(image, mean_kernel, mode='mirror')
     np.testing.assert_allclose(m, expected_m)
     expected_s = ndi.generic_filter(image, np.std, size=window_size,
@@ -443,10 +474,15 @@ def test_mean_std_2d():
     np.testing.assert_allclose(s, expected_s)
 
 
-def test_mean_std_3d():
+@pytest.mark.parametrize(
+    "window_size, mean_kernel", [
+        (5, np.full((5,) * 3, 1 / 5) ** 3),
+        ((5, 5, 5), np.full((5, 5, 5), 1 / 5 ** 3)),
+        ((1, 5, 5), np.full((1, 5, 5), 1 / 5 ** 2)),
+        ((3, 5, 7), np.full((3, 5, 7), 1 / np.prod((3, 5, 7))))]
+)
+def test_mean_std_3d(window_size, mean_kernel):
     image = np.random.rand(40, 40, 40)
-    window_size = 5
-    mean_kernel = np.full((window_size,) * 3, 1 / window_size**3)
     m, s = _mean_std(image, w=window_size)
     expected_m = ndi.convolve(image, mean_kernel, mode='mirror')
     np.testing.assert_allclose(m, expected_m)