Repository: albumentations-team/albumentations

Issue:
Use cv2.INTER_AREA for downscaling, if interpolation is not cv2.INTER_NEAREST
`cv2.INTER_AREA` works better for downscaling => for cases when we downscale we should use `cv2.INTER_AREA`

for transforms like: Affine, Resize, SmallerstMaxSize, etc



Discussion:

Patch:
diff --git a/albumentations/augmentations/geometric/resize.py b/albumentations/augmentations/geometric/resize.py
index f7e2ba4f6..04f016dd4 100644
--- a/albumentations/augmentations/geometric/resize.py
+++ b/albumentations/augmentations/geometric/resize.py
@@ -38,6 +38,12 @@ class RandomScale(DualTransform):
         mask_interpolation (OpenCV flag): flag that is used to specify the interpolation algorithm for mask.
             Should be one of: cv2.INTER_NEAREST, cv2.INTER_LINEAR, cv2.INTER_CUBIC, cv2.INTER_AREA, cv2.INTER_LANCZOS4.
             Default: cv2.INTER_NEAREST.
+        area_for_downscale (Literal[None, "image", "image_mask"]): Controls automatic use of INTER_AREA interpolation
+            for downscaling. Options:
+            - None: No automatic interpolation selection, always use the specified interpolation method
+            - "image": Use INTER_AREA when downscaling images, retain specified interpolation for upscaling and masks
+            - "image_mask": Use INTER_AREA when downscaling both images and masks
+            Default: None.
         p (float): probability of applying the transform. Default: 0.5.
 
     Targets:
@@ -51,6 +57,8 @@ class RandomScale(DualTransform):
         - Scale factor is sampled independently per image side (width and height).
         - Bounding box coordinates are scaled accordingly.
         - Keypoint coordinates are scaled accordingly.
+        - When area_for_downscale is set, INTER_AREA interpolation will be used automatically for
+          downscaling (scale < 1.0), which provides better quality for size reduction.
 
     Mathematical formulation:
         Let (W, H) be the original image dimensions and (W', H') be the output dimensions.
@@ -84,6 +92,7 @@ class RandomScale(DualTransform):
         ...         scale_limit=(-0.3, 0.5),     # Scale between 0.7x and 1.5x
         ...         interpolation=cv2.INTER_LINEAR,
         ...         mask_interpolation=cv2.INTER_NEAREST,
+        ...         area_for_downscale="image",  # Use INTER_AREA for image downscaling
         ...         p=1.0                         # Always apply
         ...     )
         ... ], bbox_params=A.BboxParams(format='pascal_voc', label_fields=['bbox_labels']),
@@ -116,6 +125,7 @@ class RandomScale(DualTransform):
 
     class InitSchema(BaseTransformInitSchema):
         scale_limit: tuple[float, float] | float
+        area_for_downscale: Literal[None, "image", "image_mask"]
         interpolation: Literal[
             cv2.INTER_NEAREST,
             cv2.INTER_NEAREST_EXACT,
@@ -161,12 +171,14 @@ def __init__(
             cv2.INTER_LANCZOS4,
             cv2.INTER_LINEAR_EXACT,
         ] = cv2.INTER_NEAREST,
+        area_for_downscale: Literal[None, "image", "image_mask"] = None,
         p: float = 0.5,
     ):
         super().__init__(p=p)
         self.scale_limit = cast("tuple[float, float]", scale_limit)
         self.interpolation = interpolation
         self.mask_interpolation = mask_interpolation
+        self.area_for_downscale = area_for_downscale
 
     def get_params(self) -> dict[str, float]:
         """Get parameters for the transform.
@@ -194,7 +206,11 @@ def apply(
             np.ndarray: Scaled image.
 
         """
-        return fgeometric.scale(img, scale, self.interpolation)
+        interpolation = self.interpolation
+        if self.area_for_downscale in ["image", "image_mask"] and scale < 1.0:
+            interpolation = cv2.INTER_AREA
+
+        return fgeometric.scale(img, scale, interpolation)
 
     def apply_to_mask(
         self,
@@ -213,7 +229,11 @@ def apply_to_mask(
             np.ndarray: Scaled mask.
 
         """
-        return fgeometric.scale(mask, scale, self.mask_interpolation)
+        interpolation = self.mask_interpolation
+        if self.area_for_downscale == "image_mask" and scale < 1.0:
+            interpolation = cv2.INTER_AREA
+
+        return fgeometric.scale(mask, scale, interpolation)
 
     def apply_to_bboxes(self, bboxes: np.ndarray, **params: Any) -> np.ndarray:
         """Apply the transform to bounding boxes.
@@ -266,6 +286,12 @@ class MaxSizeTransform(DualTransform):
         mask_interpolation (OpenCV flag): Flag for the mask interpolation algorithm.
             Should be one of: cv2.INTER_NEAREST, cv2.INTER_LINEAR, cv2.INTER_CUBIC, cv2.INTER_AREA, cv2.INTER_LANCZOS4.
             Default: cv2.INTER_NEAREST.
+        area_for_downscale (Literal[None, "image", "image_mask"]): Controls automatic use of INTER_AREA interpolation
+            for downscaling. Options:
+            - None: No automatic interpolation selection, always use the specified interpolation method
+            - "image": Use INTER_AREA when downscaling images, retain specified interpolation for upscaling and masks
+            - "image_mask": Use INTER_AREA when downscaling both images and masks
+            Default: None.
         p (float): Probability of applying the transform. Default: 1.
 
     Targets:
@@ -278,6 +304,8 @@ class MaxSizeTransform(DualTransform):
         - This is a base class that should be extended by concrete resize transforms.
         - The scaling calculation is implemented in derived classes.
         - Aspect ratio is preserved by applying the same scale factor to both dimensions.
+        - When area_for_downscale is set, INTER_AREA interpolation will be used automatically for
+          downscaling (scale < 1.0), which provides better quality for size reduction.
 
     Examples:
         >>> import numpy as np
@@ -316,6 +344,7 @@ class MaxSizeTransform(DualTransform):
         ...         max_size_hw=(None, None),  # Not used in our custom implementation
         ...         interpolation=cv2.INTER_LINEAR,
         ...         mask_interpolation=cv2.INTER_NEAREST,
+        ...         area_for_downscale="image",  # Use INTER_AREA when downscaling images
         ...         p=1.0
         ...     )
         ... ], bbox_params=A.BboxParams(format='pascal_voc', label_fields=['bbox_labels']),
@@ -346,6 +375,7 @@ class MaxSizeTransform(DualTransform):
     class InitSchema(BaseTransformInitSchema):
         max_size: int | list[int] | None
         max_size_hw: tuple[int | None, int | None] | None
+        area_for_downscale: Literal[None, "image", "image_mask"] = None
         interpolation: Literal[
             cv2.INTER_NEAREST,
             cv2.INTER_NEAREST_EXACT,
@@ -395,6 +425,7 @@ def __init__(
             cv2.INTER_LANCZOS4,
             cv2.INTER_LINEAR_EXACT,
         ] = cv2.INTER_NEAREST,
+        area_for_downscale: Literal[None, "image", "image_mask"] = None,
         p: float = 1,
     ):
         super().__init__(p=p)
@@ -402,6 +433,7 @@ def __init__(
         self.max_size_hw = max_size_hw
         self.interpolation = interpolation
         self.mask_interpolation = mask_interpolation
+        self.area_for_downscale = area_for_downscale
 
     def apply(
         self,
@@ -411,7 +443,12 @@ def apply(
     ) -> np.ndarray:
         height, width = img.shape[:2]
         new_height, new_width = max(1, round(height * scale)), max(1, round(width * scale))
-        return fgeometric.resize(img, (new_height, new_width), interpolation=self.interpolation)
+
+        interpolation = self.interpolation
+        if self.area_for_downscale in ["image", "image_mask"] and scale < 1.0:
+            interpolation = cv2.INTER_AREA
+
+        return fgeometric.resize(img, (new_height, new_width), interpolation=interpolation)
 
     def apply_to_mask(
         self,
@@ -421,7 +458,12 @@ def apply_to_mask(
     ) -> np.ndarray:
         height, width = mask.shape[:2]
         new_height, new_width = max(1, round(height * scale)), max(1, round(width * scale))
-        return fgeometric.resize(mask, (new_height, new_width), interpolation=self.mask_interpolation)
+
+        interpolation = self.mask_interpolation
+        if self.area_for_downscale == "image_mask" and scale < 1.0:
+            interpolation = cv2.INTER_AREA
+
+        return fgeometric.resize(mask, (new_height, new_width), interpolation=interpolation)
 
     def apply_to_bboxes(self, bboxes: np.ndarray, **params: Any) -> np.ndarray:
         # Bounding box coordinates are scale invariant
@@ -472,6 +514,12 @@ class LongestMaxSize(MaxSizeTransform):
         mask_interpolation (OpenCV flag): flag that is used to specify the interpolation algorithm for mask.
             Should be one of: cv2.INTER_NEAREST, cv2.INTER_LINEAR, cv2.INTER_CUBIC, cv2.INTER_AREA, cv2.INTER_LANCZOS4.
             Default: cv2.INTER_NEAREST.
+        area_for_downscale (Literal[None, "image", "image_mask"]): Controls automatic use of INTER_AREA interpolation
+            for downscaling. Options:
+            - None: No automatic interpolation selection, always use the specified interpolation method
+            - "image": Use INTER_AREA when downscaling images, retain specified interpolation for upscaling and masks
+            - "image_mask": Use INTER_AREA when downscaling both images and masks
+            Default: None.
         p (float): probability of applying the transform. Default: 1.
 
     Targets:
@@ -485,6 +533,7 @@ class LongestMaxSize(MaxSizeTransform):
         - This transform will not crop the image. The resulting image may be smaller than specified in both dimensions.
         - For non-square images, both sides will be scaled proportionally to maintain the aspect ratio.
         - Bounding boxes and keypoints are scaled accordingly.
+        - When area_for_downscale is set, INTER_AREA will be used for downscaling, providing better quality.
 
     Mathematical Details:
         Let (W, H) be the original width and height of the image.
@@ -517,11 +566,11 @@ class LongestMaxSize(MaxSizeTransform):
         >>> import albumentations as A
         >>> import cv2
         >>> # Using max_size
-        >>> transform1 = A.LongestMaxSize(max_size=1024)
+        >>> transform1 = A.LongestMaxSize(max_size=1024, area_for_downscale="image")
         >>> # Input image (1500, 800) -> Output (1024, 546)
         >>>
         >>> # Using max_size_hw with both dimensions
-        >>> transform2 = A.LongestMaxSize(max_size_hw=(800, 1024))
+        >>> transform2 = A.LongestMaxSize(max_size_hw=(800, 1024), area_for_downscale="image_mask")
         >>> # Input (1500, 800) -> Output (800, 427)
         >>> # Input (800, 1500) -> Output (546, 1024)
         >>>
@@ -531,7 +580,7 @@ class LongestMaxSize(MaxSizeTransform):
         >>>
         >>> # Common use case with padding
         >>> transform4 = A.Compose([
-        ...     A.LongestMaxSize(max_size=1024),
+        ...     A.LongestMaxSize(max_size=1024, area_for_downscale="image"),
         ...     A.PadIfNeeded(min_height=1024, min_width=1024),
         ... ])
 
@@ -592,6 +641,12 @@ class SmallestMaxSize(MaxSizeTransform):
         mask_interpolation (OpenCV flag): flag that is used to specify the interpolation algorithm for mask.
             Should be one of: cv2.INTER_NEAREST, cv2.INTER_LINEAR, cv2.INTER_CUBIC, cv2.INTER_AREA, cv2.INTER_LANCZOS4.
             Default: cv2.INTER_NEAREST.
+        area_for_downscale (Literal[None, "image", "image_mask"]): Controls automatic use of INTER_AREA interpolation
+            for downscaling. Options:
+            - None: No automatic interpolation selection, always use the specified interpolation method
+            - "image": Use INTER_AREA when downscaling images, retain specified interpolation for upscaling and masks
+            - "image_mask": Use INTER_AREA when downscaling both images and masks
+            Default: None.
         p (float): Probability of applying the transform. Default: 1.
 
     Targets:
@@ -605,6 +660,7 @@ class SmallestMaxSize(MaxSizeTransform):
         - This transform will not crop the image. The resulting image may be larger than specified in both dimensions.
         - For non-square images, both sides will be scaled proportionally to maintain the aspect ratio.
         - Bounding boxes and keypoints are scaled accordingly.
+        - When area_for_downscale is set, INTER_AREA will be used for downscaling, providing better quality.
 
     Mathematical Details:
         Let (W, H) be the original width and height of the image.
@@ -637,11 +693,11 @@ class SmallestMaxSize(MaxSizeTransform):
         >>> import numpy as np
         >>> import albumentations as A
         >>> # Using max_size
-        >>> transform1 = A.SmallestMaxSize(max_size=120)
+        >>> transform1 = A.SmallestMaxSize(max_size=120, area_for_downscale="image")
         >>> # Input image (100, 150) -> Output (120, 180)
         >>>
         >>> # Using max_size_hw with both dimensions
-        >>> transform2 = A.SmallestMaxSize(max_size_hw=(100, 200))
+        >>> transform2 = A.SmallestMaxSize(max_size_hw=(100, 200), area_for_downscale="image_mask")
         >>> # Input (80, 160) -> Output (100, 200)
         >>> # Input (160, 80) -> Output (400, 200)
         >>>
@@ -699,6 +755,12 @@ class Resize(DualTransform):
         mask_interpolation (OpenCV flag): flag that is used to specify the interpolation algorithm for mask.
             Should be one of: cv2.INTER_NEAREST, cv2.INTER_LINEAR, cv2.INTER_CUBIC, cv2.INTER_AREA, cv2.INTER_LANCZOS4.
             Default: cv2.INTER_NEAREST.
+        area_for_downscale (Literal[None, "image", "image_mask"]): Controls automatic use of INTER_AREA interpolation
+            for downscaling. Options:
+            - None: No automatic interpolation selection, always use the specified interpolation method
+            - "image": Use INTER_AREA when downscaling images, retain specified interpolation for upscaling and masks
+            - "image_mask": Use INTER_AREA when downscaling both images and masks
+            Default: None.
         p (float): probability of applying the transform. Default: 1.
 
     Targets:
@@ -735,6 +797,7 @@ class Resize(DualTransform):
         ...         width=224,
         ...         interpolation=cv2.INTER_LINEAR,
         ...         mask_interpolation=cv2.INTER_NEAREST,
+        ...         area_for_downscale="image",  # Use INTER_AREA when downscaling images
         ...         p=1.0
         ...     )
         ... ], bbox_params=A.BboxParams(format='pascal_voc', label_fields=['bbox_labels']),
@@ -770,6 +833,7 @@ class Resize(DualTransform):
     class InitSchema(BaseTransformInitSchema):
         height: int = Field(ge=1)
         width: int = Field(ge=1)
+        area_for_downscale: Literal[None, "image", "image_mask"] = None
         interpolation: Literal[
             cv2.INTER_NEAREST,
             cv2.INTER_NEAREST_EXACT,
@@ -811,6 +875,7 @@ def __init__(
             cv2.INTER_LANCZOS4,
             cv2.INTER_LINEAR_EXACT,
         ] = cv2.INTER_NEAREST,
+        area_for_downscale: Literal[None, "image", "image_mask"] = None,
         p: float = 1,
     ):
         super().__init__(p=p)
@@ -818,6 +883,7 @@ def __init__(
         self.width = width
         self.interpolation = interpolation
         self.mask_interpolation = mask_interpolation
+        self.area_for_downscale = area_for_downscale
 
     def apply(self, img: np.ndarray, **params: Any) -> np.ndarray:
         """Apply resizing to the image.
@@ -830,7 +896,14 @@ def apply(self, img: np.ndarray, **params: Any) -> np.ndarray:
             np.ndarray: Resized image.
 
         """
-        return fgeometric.resize(img, (self.height, self.width), interpolation=self.interpolation)
+        height, width = img.shape[:2]
+        is_downscale = (self.height < height) or (self.width < width)
+
+        interpolation = self.interpolation
+        if self.area_for_downscale in ["image", "image_mask"] and is_downscale:
+            interpolation = cv2.INTER_AREA
+
+        return fgeometric.resize(img, (self.height, self.width), interpolation=interpolation)
 
     def apply_to_mask(self, mask: np.ndarray, **params: Any) -> np.ndarray:
         """Apply resizing to the mask.
@@ -843,7 +916,14 @@ def apply_to_mask(self, mask: np.ndarray, **params: Any) -> np.ndarray:
             np.ndarray: Resized mask.
 
         """
-        return fgeometric.resize(mask, (self.height, self.width), interpolation=self.mask_interpolation)
+        height, width = mask.shape[:2]
+        is_downscale = (self.height < height) or (self.width < width)
+
+        interpolation = self.mask_interpolation
+        if self.area_for_downscale == "image_mask" and is_downscale:
+            interpolation = cv2.INTER_AREA
+
+        return fgeometric.resize(mask, (self.height, self.width), interpolation=interpolation)
 
     def apply_to_bboxes(self, bboxes: np.ndarray, **params: Any) -> np.ndarray:
         """Apply the transform to bounding boxes.


Test patch:
diff --git a/tests/test_resize_area_downscale.py b/tests/test_resize_area_downscale.py
new file mode 100644
index 000000000..09289685e
--- /dev/null
+++ b/tests/test_resize_area_downscale.py
@@ -0,0 +1,175 @@
+import cv2
+import numpy as np
+import pytest
+
+import albumentations as A
+
+
+def get_downscale_image():
+    """Create 100x100 test image for downscaling tests."""
+    image = np.zeros((100, 100, 3), dtype=np.uint8)
+    # Add recognizable pattern
+    image[20:80, 20:80, 0] = 255  # Red square
+    image[40:60, 40:60, 1] = 255  # Green square in the middle
+    return image
+
+
+def get_upscale_image():
+    """Create 50x50 test image for upscaling tests."""
+    image = np.zeros((50, 50, 3), dtype=np.uint8)
+    # Add recognizable pattern
+    image[10:40, 10:40, 0] = 255  # Red square
+    image[20:30, 20:30, 1] = 255  # Green square in the middle
+    return image
+
+
+def get_mask(size):
+    """Create a mask of the specified size."""
+    mask = np.zeros((size, size), dtype=np.uint8)
+    # Add a simple pattern
+    center = size // 2
+    mask[center - size//4:center + size//4, center - size//4:center + size//4] = 1
+    return mask
+
+
+@pytest.mark.parametrize(
+    ["transform_cls", "downscale_params", "upscale_params"],
+    [
+        (
+            A.RandomScale,
+            {"scale_limit": (-0.5, -0.5)},  # Fixed 0.5 downscale (100px→50px)
+            {"scale_limit": (1.0, 1.0)}      # Fixed 2.0 upscale (50px→100px)
+        ),
+        (
+            A.Resize,
+            {"height": 50, "width": 50},     # Downscale 100px→50px
+            {"height": 100, "width": 100}    # Upscale 50px→100px
+        ),
+        (
+            A.LongestMaxSize,
+            {"max_size": 50},                # Downscale 100px→50px
+            {"max_size": 100}                # Upscale 50px→100px
+        ),
+        (
+            A.SmallestMaxSize,
+            {"max_size": 50},                # Downscale 100px→50px
+            {"max_size": 100}                # Upscale 50px→100px
+        ),
+    ],
+)
+@pytest.mark.parametrize(
+    "interpolation",
+    [
+        cv2.INTER_NEAREST,
+        cv2.INTER_LINEAR,
+        cv2.INTER_CUBIC,
+        cv2.INTER_LANCZOS4,
+    ],
+)
+class TestAreaForDownscaleOutput:
+    def test_downscale_area_option_matches_area_interp(self, transform_cls, downscale_params, upscale_params, interpolation):
+        """Test that specified interpolation with area_for_downscale='image' matches AREA without area_for_downscale for downscaling."""
+        image = get_downscale_image()
+
+        # Transform 1: Specified interpolation with area_for_downscale="image"
+        transform1 = transform_cls(
+            interpolation=interpolation,
+            area_for_downscale="image",
+            p=1.0,
+            **downscale_params
+        )
+        result1 = transform1(image=image)
+
+        # Transform 2: AREA interpolation without area_for_downscale
+        transform2 = transform_cls(
+            interpolation=cv2.INTER_AREA,
+            area_for_downscale=None,
+            p=1.0,
+            **downscale_params
+        )
+        result2 = transform2(image=image)
+
+        # The image outputs should be identical
+        np.testing.assert_array_equal(
+            result1["image"],
+            result2["image"],
+            err_msg=f"Downscale outputs differ for {transform_cls.__name__} with {interpolation}+area_for_image vs AREA"
+        )
+
+    def test_downscale_area_option_for_mask(self, transform_cls, downscale_params, upscale_params, interpolation):
+        """Test that area_for_downscale='image_mask' affects mask interpolation."""
+        image = get_downscale_image()
+        mask = get_mask(100)
+
+        # Transform 1: With area_for_downscale="image" (should not affect mask)
+        transform1 = transform_cls(
+            interpolation=interpolation,
+            mask_interpolation=cv2.INTER_NEAREST,
+            area_for_downscale="image",
+            p=1.0,
+            **downscale_params
+        )
+        result1 = transform1(image=image, mask=mask)
+
+        # Transform 2: With area_for_downscale="image_mask" (should affect mask)
+        transform2 = transform_cls(
+            interpolation=interpolation,
+            mask_interpolation=cv2.INTER_NEAREST,
+            area_for_downscale="image_mask",
+            p=1.0,
+            **downscale_params
+        )
+        result2 = transform2(image=image, mask=mask)
+
+        # Both should produce identical images (both using AREA for downscaling)
+        np.testing.assert_array_equal(
+            result1["image"],
+            result2["image"],
+            err_msg=f"Image outputs differ between area_for_downscale='image' and 'image_mask'"
+        )
+
+        # Transform 3: With AREA mask interpolation (should match area_for_downscale="image_mask")
+        transform3 = transform_cls(
+            interpolation=interpolation,
+            mask_interpolation=cv2.INTER_AREA,
+            area_for_downscale=None,
+            p=1.0,
+            **downscale_params
+        )
+        result3 = transform3(image=image, mask=mask)
+
+        # Mask from transform2 (NEAREST+area_for_image_mask) should match transform3 (AREA)
+        np.testing.assert_array_equal(
+            result2["mask"],
+            result3["mask"],
+            err_msg=f"Mask with NEAREST+area_for_image_mask should match mask with AREA interpolation"
+        )
+
+    def test_upscale_ignores_area_for_downscale(self, transform_cls, downscale_params, upscale_params, interpolation):
+        """Test that area_for_downscale has no effect when upscaling."""
+        image = get_upscale_image()
+
+        # Transform 1: With area_for_downscale="image"
+        transform1 = transform_cls(
+            interpolation=interpolation,
+            area_for_downscale="image",
+            p=1.0,
+            **upscale_params
+        )
+        result1 = transform1(image=image)
+
+        # Transform 2: Without area_for_downscale
+        transform2 = transform_cls(
+            interpolation=interpolation,
+            area_for_downscale=None,
+            p=1.0,
+            **upscale_params
+        )
+        result2 = transform2(image=image)
+
+        # Outputs should be identical since area_for_downscale shouldn't affect upscaling
+        np.testing.assert_array_equal(
+            result1["image"],
+            result2["image"],
+            err_msg=f"Upscale outputs differ with/without area_for_downscale"
+        )