Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Update motion blur #2154

Merged
merged 3 commits into from
Nov 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions albumentations/augmentations/blur/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from collections.abc import Sequence
from itertools import product
from math import ceil
from random import Random
from typing import Literal
from warnings import warn

Expand Down Expand Up @@ -160,3 +161,66 @@
return final_result

return result


def create_motion_kernel(
ternaus marked this conversation as resolved.
Show resolved Hide resolved
kernel_size: int,
angle: float,
direction: float,
allow_shifted: bool,
random_state: Random,
) -> np.ndarray:
"""Create a motion blur kernel.

Args:
kernel_size: Size of the kernel (must be odd)
angle: Angle in degrees (counter-clockwise)
direction: Blur direction (-1.0 to 1.0)
allow_shifted: Allow kernel to be randomly shifted from center
random_state: Python's random.Random instance

Returns:
Motion blur kernel
"""
kernel = np.zeros((kernel_size, kernel_size), dtype=np.float32)
center = kernel_size // 2

# Convert angle to radians
angle_rad = np.deg2rad(angle)

# Calculate direction vector
dx = np.cos(angle_rad)
dy = np.sin(angle_rad)

# Create line points with direction bias
line_length = kernel_size // 2
t = np.linspace(-line_length, line_length, kernel_size * 2)

# Apply direction bias
if direction != 0:
t = t * (1 + direction)

# Generate line coordinates
x = center + dx * t
y = center + dy * t

# Apply random shift if allowed
if allow_shifted and random_state is not None:
shift_x = random_state.uniform(-1, 1) * line_length / 2
shift_y = random_state.uniform(-1, 1) * line_length / 2
x += shift_x
y += shift_y

# Round coordinates and clip to kernel bounds
x = np.clip(np.round(x), 0, kernel_size - 1).astype(int)
y = np.clip(np.round(y), 0, kernel_size - 1).astype(int)

# Keep only unique points to avoid multiple assignments
points = np.unique(np.column_stack([y, x]), axis=0)
kernel[points[:, 0], points[:, 1]] = 1

# Ensure at least one point is set
if not kernel.any():
kernel[center, center] = 1

Check warning on line 224 in albumentations/augmentations/blur/functional.py

View check run for this annotation

Codecov / codecov/patch

albumentations/augmentations/blur/functional.py#L224

Added line #L224 was not covered by tests

return kernel
186 changes: 116 additions & 70 deletions albumentations/augmentations/blur/transforms.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
from __future__ import annotations

import warnings
from typing import Any, Literal, cast
from typing import Annotated, Any, Literal, cast

import cv2
import numpy as np
from pydantic import Field, ValidationInfo, field_validator, model_validator
from pydantic import AfterValidator, Field, ValidationInfo, field_validator, model_validator
from typing_extensions import Self

from albumentations.augmentations import functional as fmain
Expand All @@ -14,6 +13,8 @@
OnePlusFloatRangeType,
OnePlusIntRangeType,
SymmetricRangeType,
check_range_bounds,
nondecreasing,
)
from albumentations.core.transforms_interface import BaseTransformInitSchema, ImageOnlyTransform
from albumentations.core.types import ScaleFloatType, ScaleIntType
Expand Down Expand Up @@ -96,77 +97,141 @@ def get_transform_init_args_names(self) -> tuple[str, ...]:


class MotionBlur(Blur):
"""Apply motion blur to the input image using a random-sized kernel.
"""Apply motion blur to the input image using a directional kernel.

This transform simulates the effect of camera or object motion during image capture,
creating a directional blur. It uses a line-shaped kernel with random orientation
to achieve this effect.
This transform simulates motion blur effects that occur during image capture,
such as camera shake or object movement. It creates a directional blur using
a line-shaped kernel with controllable angle, direction, and position.

Args:
blur_limit (int | tuple[int, int]): Maximum kernel size for blurring the input image.
blur_limit (int | tuple[int, int]): Maximum kernel size for blurring.
Should be in range [3, inf).
- If a single int is provided, the kernel size will be randomly chosen
between 3 and that value.
- If a tuple of two ints is provided, it defines the inclusive range
of possible kernel sizes.
- If int: kernel size will be randomly chosen from [3, blur_limit]
- If tuple: kernel size will be randomly chosen from [min, max]
Larger values create stronger blur effects.
Default: (3, 7)

allow_shifted (bool): If set to True, allows the motion blur kernel to be
randomly shifted from the center. If False, the kernel will always be
centered. Default: True
angle_range (tuple[float, float]): Range of possible angles in degrees.
Controls the rotation of the motion blur line:
- 0°: Horizontal motion blur →
- 45°: Diagonal motion blur ↗
- 90°: Vertical motion blur ↑
- 135°: Diagonal motion blur ↖
Default: (0, 360)

direction_range (tuple[float, float]): Range for motion bias.
Controls how the blur extends from the center:
- -1.0: Blur extends only backward (←)
- 0.0: Blur extends equally in both directions (←→)
- 1.0: Blur extends only forward (→)
For example, with angle=0:
- direction=-1.0: ←•
- direction=0.0: ←•→
- direction=1.0: •→
Default: (-0.5, 0.5)

allow_shifted (bool): Allow random kernel position shifts.
- If True: Kernel can be randomly offset from center
- If False: Kernel will always be centered
Default: True

p (float): Probability of applying the transform. Default: 0.5

Targets:
image
Examples of angle vs direction:
1. Horizontal motion (angle=0°):
- direction=0.0: ←•→ (symmetric blur)
- direction=1.0: •→ (forward blur)
- direction=-1.0: ←• (backward blur)

Image types:
uint8, float32
2. Vertical motion (angle=90°):
- direction=0.0: ↑•↓ (symmetric blur)
- direction=1.0: •↑ (upward blur)
- direction=-1.0: ↓• (downward blur)

Number of channels:
Any
3. Diagonal motion (angle=45°):
- direction=0.0: ↙•↗ (symmetric blur)
- direction=1.0: •↗ (forward diagonal blur)
- direction=-1.0: ↙• (backward diagonal blur)

Note:
- The blur kernel is always a straight line, simulating linear motion.
- The angle of the motion blur is randomly chosen for each application.
- Larger kernel sizes result in more pronounced motion blur effects.
- When `allow_shifted` is True, the blur effect can appear more natural and varied,
as it simulates motion that isn't perfectly centered in the frame.
- This transform is particularly useful for:
* Simulating camera shake or motion blur in action scenes
* Data augmentation for object detection or tracking tasks
* Creating more challenging inputs for image stabilization algorithms
- angle controls the orientation of the motion line
- direction controls the distribution of the blur along that line
- Together they can simulate various motion effects:
* Camera shake: Small angle range + direction near 0
* Object motion: Specific angle + direction=1.0
* Complex motion: Random angle + random direction

Example:
>>> import numpy as np
>>> import albumentations as A
>>> image = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
>>> transform = A.MotionBlur(blur_limit=7, allow_shifted=True, p=0.5)
>>> result = transform(image=image)
>>> motion_blurred_image = result["image"]
>>> # Horizontal camera shake (symmetric)
>>> transform = A.MotionBlur(
... angle_range=(-5, 5), # Near-horizontal motion
... direction_range=(0, 0), # Symmetric blur
... p=1.0
... )
>>>
>>> # Object moving right
>>> transform = A.MotionBlur(
... angle_range=(0, 0), # Horizontal motion
... direction_range=(0.8, 1.0), # Strong forward bias
... p=1.0
... )

References:
- Motion blur: https://en.wikipedia.org/wiki/Motion_blur
- OpenCV filter2D (used internally):
- Motion blur fundamentals:
https://en.wikipedia.org/wiki/Motion_blur

- Directional blur kernels:
https://www.sciencedirect.com/topics/computer-science/directional-blur

- OpenCV filter2D (used for convolution):
https://docs.opencv.org/master/d4/d86/group__imgproc__filter.html#ga27c049795ce870216ddfb366086b5a04

- Research on motion blur simulation:
"Understanding and Evaluating Blind Deconvolution Algorithms" (CVPR 2009)
https://doi.org/10.1109/CVPR.2009.5206815

- Motion blur in photography:
"The Manual of Photography", Chapter 7: Motion in Photography
ISBN: 978-0240520377

- Kornia's implementation (similar approach):
https://kornia.readthedocs.io/en/latest/augmentation.html#kornia.augmentation.RandomMotionBlur

See Also:
- GaussianBlur: For uniform blur effects
- MedianBlur: For noise reduction while preserving edges
- RandomRain: Another motion-based effect
- Perspective: For geometric motion-like distortions

"""

class InitSchema(BlurInitSchema):
allow_shifted: bool
angle_range: Annotated[tuple[float, float], AfterValidator(nondecreasing)]
direction_range: Annotated[
tuple[float, float],
AfterValidator(nondecreasing),
AfterValidator(check_range_bounds(min_val=-1.0, max_val=1.0)),
]

def __init__(
self,
blur_limit: ScaleIntType = 7,
allow_shifted: bool = True,
angle_range: tuple[float, float] = (0, 360),
direction_range: tuple[float, float] = (-1.0, 1.0),
always_apply: bool | None = None,
p: float = 0.5,
):
super().__init__(blur_limit=blur_limit, p=p, always_apply=always_apply)
super().__init__(blur_limit=blur_limit, p=p)
self.allow_shifted = allow_shifted
self.blur_limit = cast(tuple[int, int], blur_limit)
self.angle_range = angle_range
self.direction_range = direction_range

def get_transform_init_args_names(self) -> tuple[str, ...]:
return (*super().get_transform_init_args_names(), "allow_shifted")
return (*super().get_transform_init_args_names(), "allow_shifted", "angle_range", "direction_range")

def apply(self, img: np.ndarray, kernel: np.ndarray, **params: Any) -> np.ndarray:
return fmain.convolve(img, kernel=kernel)
Expand All @@ -175,38 +240,19 @@ def get_params(self) -> dict[str, Any]:
ksize = self.py_random.choice(list(range(self.blur_limit[0], self.blur_limit[1] + 1, 2)))
if ksize <= TWO:
raise ValueError(f"ksize must be > 2. Got: {ksize}")
kernel = np.zeros((ksize, ksize), dtype=np.uint8)
x1, x2 = self.py_random.randint(0, ksize - 1), self.py_random.randint(0, ksize - 1)
if x1 == x2:
y1, y2 = self.py_random.sample(range(ksize), 2)
else:
y1, y2 = self.py_random.randint(0, ksize - 1), self.py_random.randint(0, ksize - 1)

def make_odd_val(v1: int, v2: int) -> tuple[int, int]:
len_v = abs(v1 - v2) + 1
if len_v % 2 != 1:
if v2 > v1:
v2 -= 1
else:
v1 -= 1
return v1, v2

if not self.allow_shifted:
x1, x2 = make_odd_val(x1, x2)
y1, y2 = make_odd_val(y1, y2)

xc = (x1 + x2) / 2
yc = (y1 + y2) / 2

center = ksize / 2 - 0.5
dx = xc - center
dy = yc - center
x1, x2 = (int(i - dx) for i in [x1, x2])
y1, y2 = (int(i - dy) for i in [y1, y2])

cv2.line(kernel, (x1, y1), (x2, y2), 1, thickness=1)

# Normalize kernel
angle = self.py_random.uniform(*self.angle_range)
direction = self.py_random.uniform(*self.direction_range)

# Create motion blur kernel
kernel = fblur.create_motion_kernel(
ksize,
angle,
direction,
allow_shifted=self.allow_shifted,
random_state=self.py_random,
)

return {"kernel": kernel.astype(np.float32) / np.sum(kernel)}


Expand Down