Repository: python-pillow/Pillow

Issue:
Gaussian/Box blur with different X and Y radius
### What did you do?

Call `ImageFilter.BoxBlur` or `ImageFilter.GaussianBlur` with the `radius` parameter set to a 2-tuple containing an X and Y radius.

### What did you expect to happen?

The blur operation should use different radii for the X and Y dimensions. If one of the dimensions is zero, then the blur should only be done in the other axis.

### What actually happened?

This is not supported, per documentation (the radius must be a single number).

### What are your OS, Python and Pillow versions?

* OS: Linux
* Python: 3.11
* Pillow: 9.5.0

### Example code

```python
# im is an image

# This works
im.filter(ImageFilter.GaussianBlur(32))

# This doesn't work
im.filter(ImageFilter.GaussianBlur((32, 16)))
```

### Other notes

It looks like there's already internal code for doing single-dimensional box blur; making the 2-dimensional blur functions (which are exposed to Python) use different radii per dimension should be pretty easy. See this code:

https://github.com/python-pillow/Pillow/blob/a5b025629023477ec62410ce77fd717c372d9fa2/src/libImaging/BoxBlur.c#L266-L281

I would probably be happy to send in a PR, if this is a feature that you would welcome -- let me know.



Discussion:
Personally, I absolutely welcome for this feature. 

Resolving patch:
diff --git a/src/PIL/ImageFilter.py b/src/PIL/ImageFilter.py
--- a/src/PIL/ImageFilter.py
+++ b/src/PIL/ImageFilter.py
@@ -157,7 +157,8 @@ class GaussianBlur(MultibandFilter):
     approximates a Gaussian kernel. For details on accuracy see
     <https://www.mia.uni-saarland.de/Publications/gwosdek-ssvm11.pdf>
 
-    :param radius: Standard deviation of the Gaussian kernel.
+    :param radius: Standard deviation of the Gaussian kernel. Either a sequence of two
+                   numbers for x and y, or a single number for both.
     """
 
     name = "GaussianBlur"
@@ -166,7 +167,12 @@ def __init__(self, radius=2):
         self.radius = radius
 
     def filter(self, image):
-        return image.gaussian_blur(self.radius)
+        xy = self.radius
+        if not isinstance(xy, (tuple, list)):
+            xy = (xy, xy)
+        if xy == (0, 0):
+            return image.copy()
+        return image.gaussian_blur(xy)
 
 
 class BoxBlur(MultibandFilter):
@@ -176,21 +182,31 @@ class BoxBlur(MultibandFilter):
     which runs in linear time relative to the size of the image
     for any radius value.
 
-    :param radius: Size of the box in one direction. Radius 0 does not blur,
-                   returns an identical image. Radius 1 takes 1 pixel
-                   in each direction, i.e. 9 pixels in total.
+    :param radius: Size of the box in a direction. Either a sequence of two numbers for
+                   x and y, or a single number for both.
+
+                   Radius 0 does not blur, returns an identical image.
+                   Radius 1 takes 1 pixel in each direction, i.e. 9 pixels in total.
     """
 
     name = "BoxBlur"
 
     def __init__(self, radius):
-        if radius < 0:
+        xy = radius
+        if not isinstance(xy, (tuple, list)):
+            xy = (xy, xy)
+        if xy[0] < 0 or xy[1] < 0:
             msg = "radius must be >= 0"
             raise ValueError(msg)
         self.radius = radius
 
     def filter(self, image):
-        return image.box_blur(self.radius)
+        xy = self.radius
+        if not isinstance(xy, (tuple, list)):
+            xy = (xy, xy)
+        if xy == (0, 0):
+            return image.copy()
+        return image.box_blur(xy)
 
 
 class UnsharpMask(MultibandFilter):


Test patch:
diff --git a/Tests/test_box_blur.py b/Tests/test_box_blur.py
--- a/Tests/test_box_blur.py
+++ b/Tests/test_box_blur.py
@@ -22,7 +22,7 @@ def test_imageops_box_blur():
 
 
 def box_blur(image, radius=1, n=1):
-    return image._new(image.im.box_blur(radius, n))
+    return image._new(image.im.box_blur((radius, radius), n))
 
 
 def assert_image(im, data, delta=0):
diff --git a/Tests/test_image_filter.py b/Tests/test_image_filter.py
--- a/Tests/test_image_filter.py
+++ b/Tests/test_image_filter.py
@@ -23,9 +23,12 @@
         ImageFilter.MinFilter,
         ImageFilter.ModeFilter,
         ImageFilter.GaussianBlur,
+        ImageFilter.GaussianBlur(0),
         ImageFilter.GaussianBlur(5),
+        ImageFilter.GaussianBlur((2, 5)),
         ImageFilter.BoxBlur(0),
         ImageFilter.BoxBlur(5),
+        ImageFilter.BoxBlur((2, 5)),
         ImageFilter.UnsharpMask,
         ImageFilter.UnsharpMask(10),
     ),
@@ -185,12 +188,21 @@ def test_consistency_5x5(mode):
             assert_image_equal(source.filter(kernel), reference)
 
 
-def test_invalid_box_blur_filter():
+@pytest.mark.parametrize(
+    "radius",
+    (
+        -2,
+        (-2, -2),
+        (-2, 2),
+        (2, -2),
+    ),
+)
+def test_invalid_box_blur_filter(radius):
     with pytest.raises(ValueError):
-        ImageFilter.BoxBlur(-2)
+        ImageFilter.BoxBlur(radius)
 
     im = hopper()
     box_blur_filter = ImageFilter.BoxBlur(2)
-    box_blur_filter.radius = -2
+    box_blur_filter.radius = radius
     with pytest.raises(ValueError):
         im.filter(box_blur_filter)