Repository: scipy/scipy

Issue:
Filter radius as optional argument for gaussian_filter1d/gaussian_filter
Is it possible for the `gaussian_filter1d` and `gaussian_filter` functions to include an optional argument for kernel size? I see in [https://github.com/scipy/scipy/blob/v1.4.1/scipy/ndimage/filters.py#L214](https://github.com/scipy/scipy/blob/v1.4.1/scipy/ndimage/filters.py#L214) that the kernel size is determined from the values of `sigma` and `truncate`, but it would be nice for the user to be able to provide the function with the desired `radius.` Perhaps the unspecified `truncate` would be calculated based on the given `radius`, and a warning/error would be issued if the user attempts to provide all three arguments (`sigma`, `radius`, and `truncate`).


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
@@ -29,6 +29,7 @@
 # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 from collections.abc import Iterable
+import numbers
 import warnings
 import numpy
 import operator
@@ -209,7 +210,7 @@ def _gaussian_kernel1d(sigma, order, radius):
 
 @_ni_docstrings.docfiller
 def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
-                      mode="reflect", cval=0.0, truncate=4.0):
+                      mode="reflect", cval=0.0, truncate=4.0, *, radius=None):
     """1-D Gaussian filter.
 
     Parameters
@@ -228,11 +229,20 @@ def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
     truncate : float, optional
         Truncate the filter at this many standard deviations.
         Default is 4.0.
+    radius : None or int, optional
+        Radius of the Gaussian kernel. If specified, the size of
+        the kernel will be ``2*radius + 1``, and `truncate` is ignored.
+        Default is None.
 
     Returns
     -------
     gaussian_filter1d : ndarray
 
+    Notes
+    -----
+    The Gaussian kernel will have size ``2*radius + 1`` where
+    ``radius = round(truncate * sigma)``.
+
     Examples
     --------
     >>> from scipy.ndimage import gaussian_filter1d
@@ -257,6 +267,10 @@ def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
     sd = float(sigma)
     # make the radius of the filter equal to truncate standard deviations
     lw = int(truncate * sd + 0.5)
+    if radius is not None:
+        lw = radius
+    if not isinstance(lw, numbers.Integral) or lw < 0:
+        raise ValueError('Radius must be a nonnegative integer.')
     # Since we are calling correlate, not convolve, revert the kernel
     weights = _gaussian_kernel1d(sigma, order, lw)[::-1]
     return correlate1d(input, weights, axis, output, mode, cval, 0)
@@ -264,7 +278,7 @@ def gaussian_filter1d(input, sigma, axis=-1, order=0, output=None,
 
 @_ni_docstrings.docfiller
 def gaussian_filter(input, sigma, order=0, output=None,
-                    mode="reflect", cval=0.0, truncate=4.0):
+                    mode="reflect", cval=0.0, truncate=4.0, *, radius=None):
     """Multidimensional Gaussian filter.
 
     Parameters
@@ -283,9 +297,15 @@ def gaussian_filter(input, sigma, order=0, output=None,
     %(output)s
     %(mode_multiple)s
     %(cval)s
-    truncate : float
+    truncate : float, optional
         Truncate the filter at this many standard deviations.
         Default is 4.0.
+    radius : None or int or sequence of ints, optional
+        Radius of the Gaussian kernel. The radius are given for each axis
+        as a sequence, or as a single number, in which case it is equal
+        for all axes. If specified, the size of the kernel along each axis
+        will be ``2*radius + 1``, and `truncate` is ignored.
+        Default is None.
 
     Returns
     -------
@@ -301,6 +321,9 @@ def gaussian_filter(input, sigma, order=0, output=None,
     because intermediate results may be stored with insufficient
     precision.
 
+    The Gaussian kernel will have size ``2*radius + 1`` along each axis
+    where ``radius = round(truncate * sigma)``.
+
     Examples
     --------
     >>> from scipy.ndimage import gaussian_filter
@@ -336,13 +359,14 @@ def gaussian_filter(input, sigma, order=0, output=None,
     orders = _ni_support._normalize_sequence(order, input.ndim)
     sigmas = _ni_support._normalize_sequence(sigma, input.ndim)
     modes = _ni_support._normalize_sequence(mode, input.ndim)
+    radiuses = _ni_support._normalize_sequence(radius, input.ndim)
     axes = list(range(input.ndim))
-    axes = [(axes[ii], sigmas[ii], orders[ii], modes[ii])
+    axes = [(axes[ii], sigmas[ii], orders[ii], modes[ii], radiuses[ii])
             for ii in range(len(axes)) if sigmas[ii] > 1e-15]
     if len(axes) > 0:
-        for axis, sigma, order, mode in axes:
+        for axis, sigma, order, mode, radius in axes:
             gaussian_filter1d(input, sigma, axis, order, output,
-                              mode, cval, truncate)
+                              mode, cval, truncate, radius=radius)
             input = output
     else:
         output[...] = input[...]


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
@@ -1821,6 +1821,40 @@ def test_gaussian_truncate():
     assert_equal(n, 15)
 
 
+def test_gaussian_radius():
+    # Test that Gaussian filters with radius argument produce the same
+    # results as the filters with corresponding truncate argument.
+    # radius = int(truncate * sigma + 0.5)
+    # Test gaussian_filter1d
+    x = numpy.zeros(7)
+    x[3] = 1
+    f1 = ndimage.gaussian_filter1d(x, sigma=2, truncate=1.5)
+    f2 = ndimage.gaussian_filter1d(x, sigma=2, radius=3)
+    assert_equal(f1, f2)
+
+    # Test gaussian_filter when sigma is a number.
+    a = numpy.zeros((9, 9))
+    a[4, 4] = 1
+    f1 = ndimage.gaussian_filter(a, sigma=0.5, truncate=3.5)
+    f2 = ndimage.gaussian_filter(a, sigma=0.5, radius=2)
+    assert_equal(f1, f2)
+
+    # Test gaussian_filter when sigma is a sequence.
+    a = numpy.zeros((50, 50))
+    a[25, 25] = 1
+    f1 = ndimage.gaussian_filter(a, sigma=[0.5, 2.5], truncate=3.5)
+    f2 = ndimage.gaussian_filter(a, sigma=[0.5, 2.5], radius=[2, 9])
+    assert_equal(f1, f2)
+
+
+def test_gaussian_radius_invalid():
+    # radius must be a nonnegative integer
+    with assert_raises(ValueError):
+        ndimage.gaussian_filter1d(numpy.zeros(8), sigma=1, radius=-1)
+    with assert_raises(ValueError):
+        ndimage.gaussian_filter1d(numpy.zeros(8), sigma=1, radius=1.1)
+
+
 class TestThreading:
     def check_func_thread(self, n, fun, args, out):
         from threading import Thread