Repository: albumentations-team/albumentations

Issue:
[Speed up] GaussianBlur
Benchmark shows that `imgaug` has faster GaussianBlur implementation => need to learn from it and fix.

Hints from issue comments:
@zetyquickly: This one probably comes down to a comparison between [`cv2.filter2D`](https://github.com/albumentations-team/albumentations/blob/1a4f551859610442c80e734df5179a3a8b6aabb8/albumentations/augmentations/functional.py#L471) and [`cv2.GaussianBlur`](https://github.com/aleju/imgaug/blob/0101108d4fed06bc5056c4a03e2bcb0216dac326/imgaug/augmenters/blur.py#L279)@ternaus: Could be. I use cv2.filder2D to apply normalized kernel to match Pillow behavbior.@ternaus: @vedantdalimkar Sped this transform up 2.5 times using separable convolutions and other speedup techniques.

Solution patch:
diff --git a/albumentations/augmentations/blur/functional.py b/albumentations/augmentations/blur/functional.py
index b6f92b7..dcdaff8 100644
--- a/albumentations/augmentations/blur/functional.py
+++ b/albumentations/augmentations/blur/functional.py
@@ -285,3 +285,48 @@ def create_gaussian_kernel(sigma: float, ksize: int = 0) -> np.ndarray:
 
     # Create 2D kernel
     return kernel_1d[:, np.newaxis] @ kernel_1d[np.newaxis, :]
+
+
+def create_gaussian_kernel_1d(sigma: float, ksize: int = 0) -> np.ndarray:
+    """Create a 1D Gaussian kernel following PIL's approach.
+
+    Args:
+        sigma: Standard deviation for Gaussian kernel.
+        ksize: Kernel size. If 0, size is computed as int(sigma * 3.5) * 2 + 1
+               to match PIL's implementation. Otherwise, must be positive and odd.
+
+    Returns:
+        np.ndarray: 1D normalized Gaussian kernel.
+    """
+    # PIL's kernel creation approach
+    size = int(sigma * 3.5) * 2 + 1 if ksize == 0 else ksize
+
+    # Ensure odd size
+    size = size + 1 if size % 2 == 0 else size
+
+    # Create x coordinates
+    x = create_gaussian_kernel_input_array(size=size)
+
+    # Compute 1D kernel using vectorized operations
+    kernel_1d = np.exp(-0.5 * (x / sigma) ** 2)
+    return kernel_1d / kernel_1d.sum()
+
+
+def create_gaussian_kernel_input_array(size: int) -> np.ndarray:
+    """Creates a 1-D array which will create an array of x-coordinates which will be input for the
+    gaussian function (values from -size/2 to size/2 with step size of 1)
+
+    Piecewise function is needed as equivalent python list comprehension is faster than np.linspace
+    for values of size < 100
+
+    Args:
+        size: kernel size
+
+    Returns:
+        np.ndarray: x-coordinate array which will be input for gaussian function that will be used for
+        separable gaussian blur
+    """
+    if size < 100:
+        return np.array(list(range(-(size // 2), (size // 2) + 1, 1)))
+
+    return np.linspace(-(size // 2), size // 2, size)
diff --git a/albumentations/augmentations/blur/transforms.py b/albumentations/augmentations/blur/transforms.py
index 674aebe..b4dea02 100644
--- a/albumentations/augmentations/blur/transforms.py
+++ b/albumentations/augmentations/blur/transforms.py
@@ -448,13 +448,13 @@ class GaussianBlur(ImageOnlyTransform):
         kernel: np.ndarray,
         **params: Any,
     ) -> np.ndarray:
-        return fmain.convolve(img, kernel=kernel)
+        return fmain.separable_convolve(img, kernel=kernel)
 
     def get_params_dependent_on_data(self, params: dict[str, Any], data: dict[str, Any]) -> dict[str, float]:
         sigma = self.py_random.uniform(*self.sigma_limit)
         ksize = self.py_random.randint(*self.blur_limit)
 
-        return {"kernel": fblur.create_gaussian_kernel(sigma, ksize)}
+        return {"kernel": fblur.create_gaussian_kernel_1d(sigma, ksize)}
 
     def get_transform_init_args_names(self) -> tuple[str, ...]:
         return "blur_limit", "sigma_limit"
diff --git a/albumentations/augmentations/functional.py b/albumentations/augmentations/functional.py
index 3bd9d90..812be3a 100644
--- a/albumentations/augmentations/functional.py
+++ b/albumentations/augmentations/functional.py
@@ -3452,3 +3452,10 @@ def apply_he_stain_augmentation(
 def convolve(img: np.ndarray, kernel: np.ndarray) -> np.ndarray:
     conv_fn = maybe_process_in_chunks(cv2.filter2D, ddepth=-1, kernel=kernel)
     return conv_fn(img)
+
+
+@clipped
+@preserve_channel_dim
+def separable_convolve(img: np.ndarray, kernel: np.ndarray) -> np.ndarray:
+    conv_fn = maybe_process_in_chunks(cv2.sepFilter2D, ddepth=-1, kernelX=kernel, kernelY=kernel)
+    return conv_fn(img)


Test patch:
diff --git a/tests/functional/test_blur.py b/tests/functional/test_blur.py
index 12339a5..aa5a58c 100644
--- a/tests/functional/test_blur.py
+++ b/tests/functional/test_blur.py
@@ -71,11 +71,29 @@ def test_kernel_shapes(sigma, ksize, expected_shape):
     kernel = fblur.create_gaussian_kernel(sigma, ksize)
     assert kernel.shape == expected_shape
 
+@pytest.mark.parametrize(
+    ["sigma", "ksize", "expected_shape"],
+    [
+        (0.5, 0, (3,)),     # Small sigma
+        (1.0, 0, (7,)),     # Medium sigma
+        (2.0, 0, (15,)),   # Large sigma
+        (3.0, 0, (21,)),   # Very large sigma
+        (1.0, 5, (5,)),     # Fixed kernel size
+        (2.0, 7, (7,)),     # Different fixed kernel size
+    ]
+)
+def test_1d_kernel_shapes(sigma, ksize, expected_shape):
+    kernel = fblur.create_gaussian_kernel_1d(sigma, ksize)
+    assert kernel.shape == expected_shape
+
 @pytest.mark.parametrize("sigma", [0.5, 1.0, 2.0, 3.0])
 def test_kernel_normalization(sigma):
     """Test that kernel sums to 1 to preserve luminance"""
     kernel = fblur.create_gaussian_kernel(sigma, 0)
+    kernel_1d = fblur.create_gaussian_kernel_1d(sigma, 0)
+
     np.testing.assert_allclose(kernel.sum(), 1.0, atol=1e-6)
+    np.testing.assert_allclose(kernel_1d.sum(), 1.0, atol=1e-6)
 
 @pytest.mark.parametrize("sigma", [0.5, 1.0, 2.0, 3.0])
 def test_kernel_symmetry(sigma):
@@ -102,6 +120,21 @@ def test_matches_pil_kernel(sigma):
 def test_kernel_peak_values(sigma, ksize, expected_max_value):
     """Test that kernel peak values decrease with increasing sigma"""
     kernel = fblur.create_gaussian_kernel(sigma, ksize)
+
+    assert np.abs(kernel.max() - expected_max_value) < 0.01
+
+@pytest.mark.parametrize(
+    ["sigma", "ksize", "expected_max_value"],
+    [
+        (0.5, 0, 0.786),  # Small sigma has highest peak
+        (2.0, 0, 0.199),  # Larger sigma has lower peak
+        (3.0, 0, 0.133),  # Even larger sigma has even lower peak
+    ]
+)
+def test_1d_kernel_peak_values(sigma, ksize, expected_max_value):
+    """Test that kernel peak values decrease with increasing sigma"""
+    kernel = fblur.create_gaussian_kernel_1d(sigma, ksize)
+
     assert np.abs(kernel.max() - expected_max_value) < 0.01
 
 def test_kernel_visual_comparison():