Repository: albumentations-team/albumentations

Issue:
Feature request: Add augmentation to mimic ringing and overshoot artefacts. 
## Feature description

Ringing artifacts often appear as spurious edges near sharp transitions in an image. They visually look like bands or “ghosts” near edges. Overshoot artifacts are usually combined with ringing artifacts, which manifest themselves as an increased jump at the edge transition. The main cause of these artifacts is that the signal is bandlimited without high frequencies. These artifacts are very common and usually produced by a sharping algorithm, JPEG compression, etc. See examples from [RealESRGAN paper](https://arxiv.org/pdf/2107.10833.pdf). 

<img width="700" alt="image" src="https://user-images.githubusercontent.com/15848838/142580206-3ce1c099-4f4d-406a-b3c3-19f1dc976956.png">

Idea is to use special _sinc_ filter to mimic such artefacts on clean images. _sinc_ filter kernel is defined as:
<img width="507" alt="image" src="https://user-images.githubusercontent.com/15848838/142581364-acf5911f-ff8e-4051-906b-5dbf76bf73af.png">


Then artefacts created on images will look like that:

<img width="699" alt="image" src="https://user-images.githubusercontent.com/15848838/142581332-ba5d4058-3922-420a-a1d8-54e09b5c4363.png">


## Implementation
We need to create a special function to create such kernels (probably in `functional.transforms` and then use it together with `convolve` to apply kernel to the image. 

Here is a code snippet that does this:

https://github.com/xinntao/BasicSR/blob/4d34d071218d0e767096eddefa919200d5239936/basicsr/data/degradations.py#L392-L410

## Additional context
There is an existing `Sharpen` transform in Albumentation, which implements an idea proposed here in simplified form.
 


Discussion:
Dipet: Could you provide some example images of how this transform works?
zakajd: ```python
transform = A.RingingOvershoot(
    limit=(7, 21), 
    cutoff=(np.pi / 4, np.pi / 2),
    p=1.0
)
```
Examples: 
![image](https://user-images.githubusercontent.com/15848838/142673277-dc118911-2686-42c9-903e-8f1d4b3ae3da.png) | ![image](https://user-images.githubusercontent.com/15848838/142673299-aa7c8d92-a996-434a-8269-0e7f61962346.png)
Ringing can be clearly seen on this one. 
![image](https://user-images.githubusercontent.com/15848838/142673442-0481d8ec-4f72-46e2-b911-d21f4c1aab9f.png) | ![image](https://user-images.githubusercontent.com/15848838/142673474-e89461dc-b96b-4622-beb4-14043cac768b.png)



Patch:
diff --git a/README.md b/README.md
index 07e9c39..5e8764f 100644
--- a/README.md
+++ b/README.md
@@ -158,6 +158,7 @@ Pixel-level transforms will change just an input image and will leave any additi
 - [RandomSnow](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.RandomSnow)
 - [RandomSunFlare](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.RandomSunFlare)
 - [RandomToneCurve](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.RandomToneCurve)
+- [RingingOvershoot](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.RingingOvershoot)
 - [Sharpen](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.Sharpen)
 - [Solarize](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.Solarize)
 - [Superpixels](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.Superpixels)
@@ -167,6 +168,7 @@ Pixel-level transforms will change just an input image and will leave any additi
 - [ToSepia](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.ToSepia)
 - [UnsharpMask](https://albumentations.ai/docs/api_reference/augmentations/transforms/#albumentations.augmentations.transforms.UnsharpMask)
 
+
 ### Spatial-level transforms
 Spatial-level transforms will simultaneously change both an input image as well as additional targets such as masks, bounding boxes, and keypoints. The following table shows which additional targets are supported by each transform.
 
diff --git a/albumentations/augmentations/transforms.py b/albumentations/augmentations/transforms.py
index 5595835..84fed22 100644
--- a/albumentations/augmentations/transforms.py
+++ b/albumentations/augmentations/transforms.py
@@ -10,6 +10,7 @@ from typing import Optional, Sequence, Tuple, Union
 
 import cv2
 import numpy as np
+from scipy import special
 from skimage.measure import label
 
 from ..core.transforms_interface import (
@@ -77,6 +78,7 @@ __all__ = [
     "Emboss",
     "Superpixels",
     "TemplateTransform",
+    "RingingOvershoot",
     "UnsharpMask",
 ]
 
@@ -3243,6 +3245,70 @@ class TemplateTransform(ImageOnlyTransform):
         return {"__class_fullname__": self.get_class_fullname(), "__name__": self.name}
 
 
+class RingingOvershoot(ImageOnlyTransform):
+    """Create ringing or overshoot artefacts by conlvolving image with 2D sinc filter.
+
+    Args:
+        blur_limit (int, (int, int)): maximum kernel size for sinc filter.
+            Should be in range [3, inf). Default: (7, 15).
+        cutoff (float, (float, float)): range to choose the cutoff frequency in radians.
+            Should be in range (0, np.pi)
+            Default: (np.pi / 4, np.pi / 2).
+        p (float): probability of applying the transform. Default: 0.5.
+
+    Reference:
+        dsp.stackexchange.com/questions/58301/2-d-circularly-symmetric-low-pass-filter
+        https://arxiv.org/abs/2107.10833
+
+    Targets:
+        image
+    """
+
+    def __init__(
+        self,
+        blur_limit: Union[int, Sequence[int]] = (7, 15),
+        cutoff: Union[float, Sequence[float]] = (np.pi / 4, np.pi / 2),
+        always_apply=False,
+        p=0.5,
+    ):
+        super(RingingOvershoot, self).__init__(always_apply, p)
+        self.blur_limit = to_tuple(blur_limit, 3)
+        self.cutoff = self.__check_values(to_tuple(cutoff, np.pi / 2), name="cutoff", bounds=(0, np.pi))
+
+    @staticmethod
+    def __check_values(value, name, bounds=(0, float("inf"))):
+        if not bounds[0] <= value[0] <= value[1] <= bounds[1]:
+            raise ValueError(f"{name} values should be between {bounds}")
+        return value
+
+    def get_params(self):
+        ksize = random.randrange(self.blur_limit[0], self.blur_limit[1] + 1, 2)
+        if ksize % 2 == 0:
+            raise ValueError(f"Kernel size must be odd. Got: {ksize}")
+
+        cutoff = random.uniform(*self.cutoff)
+
+        # From dsp.stackexchange.com/questions/58301/2-d-circularly-symmetric-low-pass-filter
+        kernel = np.fromfunction(
+            lambda x, y: cutoff
+            * special.j1(cutoff * np.sqrt((x - (ksize - 1) / 2) ** 2 + (y - (ksize - 1) / 2) ** 2))
+            / (2 * np.pi * np.sqrt((x - (ksize - 1) / 2) ** 2 + (y - (ksize - 1) / 2) ** 2)),
+            [ksize, ksize],
+        )
+        kernel[(ksize - 1) // 2, (ksize - 1) // 2] = cutoff ** 2 / (4 * np.pi)
+
+        # Normalize kernel
+        kernel = kernel.astype(np.float32) / np.sum(kernel)
+
+        return {"kernel": kernel}
+
+    def apply(self, img, kernel=None, **params):
+        return F.convolve(img, kernel)
+
+    def get_transform_init_args_names(self):
+        return ("blur_limit", "cutoff")
+
+
 class UnsharpMask(ImageOnlyTransform):
     """
     Sharpen the input image using Unsharp Masking processing and overlays the result with the original image.


Test patch:
diff --git a/tests/test_augmentations.py b/tests/test_augmentations.py
index b0c6954..5f44627 100644
--- a/tests/test_augmentations.py
+++ b/tests/test_augmentations.py
@@ -63,6 +63,7 @@ def test_image_only_augmentations(augmentation_cls, params, image, mask):
             A.TemplateTransform: {
                 "templates": np.random.uniform(low=0.0, high=1.0, size=(100, 100, 3)).astype(np.float32),
             },
+            A.RingingOvershoot: {"blur_limit": (3, 5)},
         },
         except_augmentations={
             A.CLAHE,
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 86c3385..da0cedc 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -313,6 +313,7 @@ AUGMENTATION_CLS_PARAMS = [
     [A.ToSepia, {}],
     [A.Transpose, {}],
     [A.VerticalFlip, {}],
+    [A.RingingOvershoot, dict(blur_limit=(7, 15), cutoff=(np.pi / 5, np.pi / 2))],
     [A.UnsharpMask, {"blur_limit": 3, "sigma_limit": 0.5, "alpha": 0.2, "threshold": 15}],
 ]
 
diff --git a/tests/test_transforms.py b/tests/test_transforms.py
index 3ce7aa3..cdbf201 100644
--- a/tests/test_transforms.py
+++ b/tests/test_transforms.py
@@ -698,7 +698,6 @@ def test_gaus_blur_limits(blur_limit, sigma, result_blur, result_sigma):
         [[0, 0], [1, 1], 0, 1],
         [[1, 1], [0, 0], 1, 0],
         [[1, 1], [1, 1], 1, 1],
-        [[0, 3], [0.1, 0.1], 3, 0.1],
     ],
 )
 def test_unsharp_mask_limits(blur_limit, sigma, result_blur, result_sigma):