Repository: kornia/kornia

Issue:
Improve speed for box blur (mean filter)
## 🚀 Feature
<!-- A clear and concise description of the feature proposal -->

Originally from this: https://github.com/kornia/kornia/issues/720#issuecomment-881859300

In theory box blur (mean filter) can be implemented in O(n) time.

Two solutions

- Use 2 separable 1D kernels (like `gaussian_blur2d()`) -> O(nk) time, should be simple. Only launch 2 pytorch calls.
- Use intergral image (like [here](https://github.com/wuhuikai/DeepGuidedFilter/blob/master/GuidedFilteringLayer/GuidedFilter_PyTorch/guided_filter_pytorch/box_filter.py)) -> O(n) time. It seems like there will be quite a lot of overhead for small kernels

## Motivation

<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]. If this is related to another GitHub issue, please link here too -->

## Pitch

<!-- A clear and concise description of what you want to happen. -->

## Alternatives

<!-- A clear and concise description of any alternative solutions or features you've considered, if any. -->

## Additional context

<!-- Add any other context or screenshots about the feature request here. -->

______________________________________________________________________

#### Consider also to contribute to Kornia universe projects :)

<sub>

- [**Tutorials**](https://github.com/kornia/tutorials): our repository containing the tutorials.

</sub>



Discussion:

Resolving patch:
diff --git a/kornia/filters/__init__.py b/kornia/filters/__init__.py
--- a/kornia/filters/__init__.py
+++ b/kornia/filters/__init__.py
@@ -18,6 +18,7 @@
 from .kernels import (
     gaussian,
     get_binary_kernel2d,
+    get_box_kernel1d,
     get_box_kernel2d,
     get_diff_kernel2d,
     get_gaussian_discrete_kernel1d,
@@ -47,6 +48,7 @@
 __all__ = [
     "gaussian",
     "get_binary_kernel2d",
+    "get_box_kernel1d",
     "get_box_kernel2d",
     "get_gaussian_kernel1d",
     "get_gaussian_discrete_kernel1d",
diff --git a/kornia/filters/blur.py b/kornia/filters/blur.py
--- a/kornia/filters/blur.py
+++ b/kornia/filters/blur.py
@@ -1,13 +1,14 @@
 from __future__ import annotations
 
 from kornia.core import Module, Tensor
+from kornia.core.check import KORNIA_CHECK_IS_TENSOR
 
-from .filter import filter2d
-from .kernels import get_box_kernel2d, normalize_kernel2d
+from .filter import filter2d, filter2d_separable
+from .kernels import _unpack_2d_ks, get_box_kernel1d, get_box_kernel2d
 
 
 def box_blur(
-    input: Tensor, kernel_size: tuple[int, int] | int, border_type: str = 'reflect', normalized: bool = True
+    input: Tensor, kernel_size: tuple[int, int] | int, border_type: str = 'reflect', separable: bool = False
 ) -> Tensor:
     r"""Blur an image using the box filter.
 
@@ -29,7 +30,7 @@ def box_blur(
         kernel_size: the blurring kernel size.
         border_type: the padding mode to be applied before convolving.
           The expected modes are: ``'constant'``, ``'reflect'``, ``'replicate'`` or ``'circular'``.
-        normalized: if True, L1 norm of the kernel is set to 1.
+        separable: run as composition of two 1d-convolutions.
 
     Returns:
         the blurred tensor with shape :math:`(B,C,H,W)`.
@@ -44,10 +45,18 @@ def box_blur(
         >>> output.shape
         torch.Size([2, 4, 5, 7])
     """
-    kernel = get_box_kernel2d(kernel_size, device=input.device, dtype=input.dtype)
-    if normalized:
-        kernel = normalize_kernel2d(kernel)
-    return filter2d(input, kernel, border_type)
+    KORNIA_CHECK_IS_TENSOR(input)
+
+    if separable:
+        ky, kx = _unpack_2d_ks(kernel_size)
+        kernel_y = get_box_kernel1d(ky, device=input.device, dtype=input.dtype)
+        kernel_x = get_box_kernel1d(kx, device=input.device, dtype=input.dtype)
+        out = filter2d_separable(input, kernel_x, kernel_y, border_type)
+    else:
+        kernel = get_box_kernel2d(kernel_size, device=input.device, dtype=input.dtype)
+        out = filter2d(input, kernel, border_type)
+
+    return out
 
 
 class BoxBlur(Module):
@@ -69,7 +78,7 @@ class BoxBlur(Module):
         border_type: the padding mode to be applied before convolving.
           The expected modes are: ``'constant'``, ``'reflect'``,
           ``'replicate'`` or ``'circular'``. Default: ``'reflect'``.
-        normalized: if True, L1 norm of the kernel is set to 1.
+        separable: run as composition of two 1d-convolutions.
 
     Returns:
         the blurred input tensor.
@@ -87,20 +96,33 @@ class BoxBlur(Module):
     """
 
     def __init__(
-        self, kernel_size: tuple[int, int] | int, border_type: str = 'reflect', normalized: bool = True
+        self, kernel_size: tuple[int, int] | int, border_type: str = 'reflect', separable: bool = False
     ) -> None:
         super().__init__()
         self.kernel_size = kernel_size
         self.border_type = border_type
-        self.normalized = normalized
+        self.separable = separable
+
+        if separable:
+            ky, kx = _unpack_2d_ks(self.kernel_size)
+            self.register_buffer("kernel_y", get_box_kernel1d(ky))
+            self.register_buffer("kernel_x", get_box_kernel1d(kx))
+            self.kernel_y: Tensor
+            self.kernel_x: Tensor
+        else:
+            self.register_buffer("kernel", get_box_kernel2d(kernel_size))
+            self.kernel: Tensor
 
     def __repr__(self) -> str:
         return (
             f"{self.__class__.__name__}"
             f"(kernel_size={self.kernel_size}, "
-            f"normalized={self.normalized}, "
-            f"border_type={self.border_type})"
+            f"border_type={self.border_type}, "
+            f"separable={self.separable})"
         )
 
     def forward(self, input: Tensor) -> Tensor:
-        return box_blur(input, self.kernel_size, self.border_type, self.normalized)
+        KORNIA_CHECK_IS_TENSOR(input)
+        if self.separable:
+            return filter2d_separable(input, self.kernel_x, self.kernel_y, self.border_type)
+        return filter2d(input, self.kernel, self.border_type)
diff --git a/kornia/filters/kernels.py b/kornia/filters/kernels.py
--- a/kornia/filters/kernels.py
+++ b/kornia/filters/kernels.py
@@ -260,10 +260,34 @@ def laplacian_1d(window_size: int, *, device: Device | None = None, dtype: Dtype
     return filter_1d
 
 
+def get_box_kernel1d(kernel_size: int, *, device: Device | None = None, dtype: Dtype | None = None) -> Tensor:
+    r"""Utility function that returns a 1-D box filter.
+
+    Args:
+        kernel_size: the size of the kernel.
+        device: the desired device of returned tensor.
+        dtype: the desired data type of returned tensor.
+    Returns:
+        A tensor with shape :math:`(1, \text{kernel\_size})`, filled with the value
+        :math:`\frac{1}{\text{kernel\_size}}`.
+    """
+    scale = tensor(1.0 / kernel_size, device=device, dtype=dtype)
+    return scale.expand(1, kernel_size)
+
+
 def get_box_kernel2d(
     kernel_size: tuple[int, int] | int, *, device: Device | None = None, dtype: Dtype | None = None
 ) -> Tensor:
-    r"""Utility function that returns a box filter."""
+    r"""Utility function that returns a 2-D box filter.
+
+    Args:
+        kernel_size: the size of the kernel.
+        device: the desired device of returned tensor.
+        dtype: the desired data type of returned tensor.
+    Returns:
+        A tensor with shape :math:`(1, \text{kernel\_size}[0], \text{kernel\_size}[1])`,
+        filled with the value :math:`\frac{1}{\text{kernel\_size}[0] \times \text{kernel\_size}[1]}`.
+    """
     ky, kx = _unpack_2d_ks(kernel_size)
     scale = tensor(1.0 / (kx * ky), device=device, dtype=dtype)
     return scale.expand(1, ky, kx)


Test patch:
diff --git a/test/filters/test_blur.py b/test/filters/test_blur.py
--- a/test/filters/test_blur.py
+++ b/test/filters/test_blur.py
@@ -7,14 +7,21 @@
 
 class TestBoxBlur(BaseTester):
     @pytest.mark.parametrize('kernel_size', [5, (3, 5)])
-    @pytest.mark.parametrize('normalized', [True, False])
-    def test_smoke(self, kernel_size, normalized, device, dtype):
+    def test_smoke(self, kernel_size, device, dtype):
         inpt = torch.rand(1, 1, 10, 10, device=device, dtype=dtype)
 
-        bb = BoxBlur(kernel_size, 'reflect', normalized)
+        bb = BoxBlur(kernel_size, 'reflect')
         actual = bb(inpt)
         assert actual.shape == (1, 1, 10, 10)
 
+    @pytest.mark.parametrize('kernel_size', [5, (3, 5)])
+    @pytest.mark.parametrize('batch_size', [1, 2])
+    def test_separable(self, batch_size, kernel_size, device, dtype):
+        inpt = torch.randn(batch_size, 3, 10, 10, device=device, dtype=dtype)
+        out1 = box_blur(inpt, kernel_size, separable=False)
+        out2 = box_blur(inpt, kernel_size, separable=True)
+        self.assert_close(out1, out2)
+
     def test_exception(self):
         inpt = torch.rand(1, 1, 3, 3)
 
@@ -31,10 +38,8 @@ def test_cardinality(self, batch_size, kernel_size, device, dtype):
         expected = (batch_size, 3, 4, 4)
         assert actual.shape == expected
 
-    # TODO(dmytro): normalized does not make any effect
     @pytest.mark.parametrize('batch_size', [1, 2])
-    @pytest.mark.parametrize('normalized', [True, False])
-    def test_kernel_3x3(self, batch_size, normalized, device, dtype):
+    def test_kernel_3x3(self, batch_size, device, dtype):
         inp = torch.tensor(
             [
                 [
@@ -52,7 +57,7 @@ def test_kernel_3x3(self, batch_size, normalized, device, dtype):
         ).repeat(batch_size, 1, 1, 1)
 
         kernel_size = (3, 3)
-        actual = box_blur(inp, kernel_size, normalized=normalized)
+        actual = box_blur(inp, kernel_size)
         expected = torch.tensor(35.0 * batch_size, device=device, dtype=dtype)
 
         self.assert_close(actual.sum(), expected)
@@ -94,11 +99,12 @@ def test_kernel_3x1(self, device, dtype):
         self.assert_close(actual[0, 0, 0, 0], torch.tensor((4 + 0 + 4) / 3, device=device, dtype=dtype))
         self.assert_close(actual[0, 0, 1, 0], torch.tensor((0 + 4 + 8) / 3, device=device, dtype=dtype))
 
+    @pytest.mark.parametrize('separable', [False, True])
     @pytest.mark.parametrize('batch_size', [1, 2])
-    def test_noncontiguous(self, batch_size, device, dtype):
+    def test_noncontiguous(self, batch_size, separable, device, dtype):
         inp = torch.rand(3, 5, 5, device=device, dtype=dtype).expand(batch_size, -1, -1, -1)
 
-        actual = box_blur(inp, 3)
+        actual = box_blur(inp, 3, separable=separable)
 
         assert actual.is_contiguous()
 
@@ -122,11 +128,12 @@ def test_module(self, kernel_size, batch_size, device, dtype):
 
         self.assert_close(actual, expected)
 
+    @pytest.mark.parametrize('separable', [False, True])
     @pytest.mark.parametrize('kernel_size', [5, (5, 7)])
     @pytest.mark.parametrize('batch_size', [1, 2])
-    def test_dynamo(self, batch_size, kernel_size, device, dtype, torch_optimizer):
+    def test_dynamo(self, batch_size, kernel_size, separable, device, dtype, torch_optimizer):
         inpt = torch.ones(batch_size, 3, 10, 10, device=device, dtype=dtype)
-        op = BoxBlur(kernel_size)
+        op = BoxBlur(kernel_size, separable=separable)
         op_optimized = torch_optimizer(op)
 
         self.assert_close(op(inpt), op_optimized(inpt))