Skip to content

Commit

Permalink
Fix Affine wrong rotation angle (#1091)
Browse files Browse the repository at this point in the history
* Fix Affine wrong rotation angle

* Link to issue

* Fix Perspective rot. angle for keypoints, fix Affine

* Change angle sign, do not change it manually after all changes

* Tests

* Fix tests and image center

* Fix shift_rotate tests

Co-authored-by: Eugene Khvedchenya <ekhvedchenya@gmail.com>
Co-authored-by: Vladimir Iglovikov <ternaus@users.noreply.github.com>
  • Loading branch information
3 people committed Jul 11, 2022
1 parent d7db9a0 commit 557b7b4
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 15 deletions.
28 changes: 20 additions & 8 deletions albumentations/augmentations/geometric/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,9 @@ def keypoint_rot90(keypoint, factor, rows, cols, **params):
@preserve_channel_dim
def rotate(img, angle, interpolation=cv2.INTER_LINEAR, border_mode=cv2.BORDER_REFLECT_101, value=None):
height, width = img.shape[:2]
matrix = cv2.getRotationMatrix2D((width / 2, height / 2), angle, 1.0)
# for images we use additional shifts of (0.5, 0.5) as otherwise
# we get an ugly black border for 90deg rotations
matrix = cv2.getRotationMatrix2D((width / 2 - 0.5, height / 2 - 0.5), angle, 1.0)

warp_fn = _maybe_process_in_chunks(
cv2.warpAffine, M=matrix, dsize=(width, height), flags=interpolation, borderMode=border_mode, borderValue=value
Expand Down Expand Up @@ -178,7 +180,8 @@ def keypoint_rotate(keypoint, angle, rows, cols, **params):
tuple: A keypoint `(x, y, angle, scale)`.
"""
matrix = cv2.getRotationMatrix2D(((cols - 1) * 0.5, (rows - 1) * 0.5), angle, 1.0)
center = (cols - 1) * 0.5, (rows - 1) * 0.5
matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
x, y, a, s = keypoint[:4]
x, y = cv2.transform(np.array([[[x, y]]]), matrix).squeeze()
return x, y, a + math.radians(angle), s
Expand All @@ -189,7 +192,9 @@ def shift_scale_rotate(
img, angle, scale, dx, dy, interpolation=cv2.INTER_LINEAR, border_mode=cv2.BORDER_REFLECT_101, value=None
):
height, width = img.shape[:2]
center = (width / 2, height / 2)
# for images we use additional shifts of (0.5, 0.5) as otherwise
# we get an ugly black border for 90deg rotations
center = (width / 2 - 0.5, height / 2 - 0.5)
matrix = cv2.getRotationMatrix2D(center, angle, scale)
matrix[0, 2] += dx * width
matrix[1, 2] += dy * height
Expand All @@ -209,7 +214,7 @@ def keypoint_shift_scale_rotate(keypoint, angle, scale, dx, dy, rows, cols, **pa
s,
) = keypoint[:4]
height, width = rows, cols
center = (width / 2, height / 2)
center = (cols - 1) * 0.5, (rows - 1) * 0.5
matrix = cv2.getRotationMatrix2D(center, angle, scale)
matrix[0, 2] += dx * width
matrix[1, 2] += dy * height
Expand Down Expand Up @@ -470,8 +475,15 @@ def perspective_bbox(
)


def rotation2DMatrixToEulerAngles(matrix: np.ndarray):
return np.arctan2(matrix[1, 0], matrix[0, 0])
def rotation2DMatrixToEulerAngles(matrix: np.ndarray, y_up: bool = False) -> float:
"""
Args:
matrix (np.ndarray): Rotation matrix
y_up (bool): is Y axis looks up or down
"""
if y_up:
return np.arctan2(matrix[1, 0], matrix[0, 0])
return np.arctan2(-matrix[1, 0], matrix[0, 0])


@angle_2pi_range
Expand All @@ -489,7 +501,7 @@ def perspective_keypoint(
keypoint_vector = np.array([x, y], dtype=np.float32).reshape([1, 1, 2])

x, y = cv2.perspectiveTransform(keypoint_vector, matrix)[0, 0]
angle += rotation2DMatrixToEulerAngles(matrix[:2, :2])
angle += rotation2DMatrixToEulerAngles(matrix[:2, :2], y_up=True)

scale_x = np.sign(matrix[0, 0]) * np.sqrt(matrix[0, 0] ** 2 + matrix[0, 1] ** 2)
scale_y = np.sign(matrix[1, 1]) * np.sqrt(matrix[1, 0] ** 2 + matrix[1, 1] ** 2)
Expand Down Expand Up @@ -537,7 +549,7 @@ def keypoint_affine(
return keypoint

x, y, a, s = keypoint[:4]
x, y = skimage.transform.matrix_transform(np.array([[x, y]]), matrix.params).ravel()
x, y = cv2.transform(np.array([[[x, y]]]), matrix.params[:2]).squeeze()
a += rotation2DMatrixToEulerAngles(matrix.params[:2])
s *= np.max([scale["x"], scale["y"]])
return x, y, a, s
Expand Down
7 changes: 5 additions & 2 deletions albumentations/augmentations/geometric/transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -661,11 +661,14 @@ def get_params_dependent_on_targets(self, params: dict) -> dict:
else:
translate = {"x": 0, "y": 0}

shear = {key: random.uniform(*value) for key, value in self.shear.items()}
# Look to issue https://github.com/albumentations-team/albumentations/issues/1079
shear = {key: -random.uniform(*value) for key, value in self.shear.items()}
scale = {key: random.uniform(*value) for key, value in self.scale.items()}
if self.keep_ratio:
scale["y"] = scale["x"]
rotate = random.uniform(*self.rotate)

# Look to issue https://github.com/albumentations-team/albumentations/issues/1079
rotate = -random.uniform(*self.rotate)

# for images we use additional shifts of (0.5, 0.5) as otherwise
# we get an ugly black border for 90deg rotations
Expand Down
9 changes: 5 additions & 4 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@ def test_pad_float(target):
@pytest.mark.parametrize("target", ["image", "mask"])
def test_rotate_from_shift_scale_rotate(target):
img = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]], dtype=np.uint8)
expected = np.array([[0, 0, 0, 0], [4, 8, 12, 16], [3, 7, 11, 15], [2, 6, 10, 14]], dtype=np.uint8)
expected = np.array([[4, 8, 12, 16], [3, 7, 11, 15], [2, 6, 10, 14], [1, 5, 9, 13]], dtype=np.uint8)

img, expected = convert_2d_to_target_format([img, expected], target=target)
rotated_img = FGeometric.shift_scale_rotate(
img, angle=90, scale=1, dx=0, dy=0, interpolation=cv2.INTER_NEAREST, border_mode=cv2.BORDER_CONSTANT
Expand All @@ -239,7 +240,7 @@ def test_rotate_float_from_shift_scale_rotate(target):
dtype=np.float32,
)
expected = np.array(
[[0.00, 0.00, 0.00, 0.00], [0.04, 0.08, 0.12, 0.16], [0.03, 0.07, 0.11, 0.15], [0.02, 0.06, 0.10, 0.14]],
[[0.04, 0.08, 0.12, 0.16], [0.03, 0.07, 0.11, 0.15], [0.02, 0.06, 0.10, 0.14], [0.01, 0.05, 0.09, 0.13]],
dtype=np.float32,
)
img, expected = convert_2d_to_target_format([img, expected], target=target)
Expand All @@ -252,7 +253,7 @@ def test_rotate_float_from_shift_scale_rotate(target):
@pytest.mark.parametrize("target", ["image", "mask"])
def test_scale_from_shift_scale_rotate(target):
img = np.array([[1, 2, 3, 4], [5, 6, 7, 8], [9, 10, 11, 12], [13, 14, 15, 16]], dtype=np.uint8)
expected = np.array([[6, 7, 7, 8], [10, 11, 11, 12], [10, 11, 11, 12], [14, 15, 15, 16]], dtype=np.uint8)
expected = np.array([[6, 6, 7, 7], [6, 6, 7, 7], [10, 10, 11, 11], [10, 10, 11, 11]], dtype=np.uint8)
img, expected = convert_2d_to_target_format([img, expected], target=target)
scaled_img = FGeometric.shift_scale_rotate(
img, angle=0, scale=2, dx=0, dy=0, interpolation=cv2.INTER_NEAREST, border_mode=cv2.BORDER_CONSTANT
Expand All @@ -267,7 +268,7 @@ def test_scale_float_from_shift_scale_rotate(target):
dtype=np.float32,
)
expected = np.array(
[[0.06, 0.07, 0.07, 0.08], [0.10, 0.11, 0.11, 0.12], [0.10, 0.11, 0.11, 0.12], [0.14, 0.15, 0.15, 0.16]],
[[0.06, 0.06, 0.07, 0.07], [0.06, 0.06, 0.07, 0.07], [0.10, 0.10, 0.11, 0.11], [0.10, 0.10, 0.11, 0.11]],
dtype=np.float32,
)
img, expected = convert_2d_to_target_format([img, expected], target=target)
Expand Down
2 changes: 1 addition & 1 deletion tests/test_keypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ def test_keypoint_scale(keypoint, expected, scale):

@pytest.mark.parametrize(
["keypoint", "expected", "angle", "scale", "dx", "dy"],
[[[50, 50, 0, 5], [120, 160, math.pi / 2, 10], 90, 2, 0.1, 0.1]],
[[[50, 50, 0, 5], [120, 158, math.pi / 2, 10], 90, 2, 0.1, 0.1]],
)
def test_keypoint_shift_scale_rotate(keypoint, expected, angle, scale, dx, dy):
actual = FGeometric.keypoint_shift_scale_rotate(keypoint, angle, scale, dx, dy, rows=100, cols=200)
Expand Down
55 changes: 55 additions & 0 deletions tests/test_transforms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1133,3 +1133,58 @@ def test_safe_rotate(angle: float, targets: dict, expected: dict):

for key, value in expected.items():
assert np.allclose(np.array(value), np.array(res[key])), key


@pytest.mark.parametrize(
"aug_cls",
[
(lambda rotate: A.Affine(rotate=rotate, p=1, mode=cv2.BORDER_CONSTANT, cval=0)),
(
lambda rotate: A.ShiftScaleRotate(
shift_limit=(0, 0),
scale_limit=(0, 0),
rotate_limit=rotate,
p=1,
border_mode=cv2.BORDER_CONSTANT,
value=0,
)
),
],
)
@pytest.mark.parametrize(
"img",
[
np.random.randint(0, 256, [100, 100, 3], np.uint8),
np.random.randint(0, 256, [25, 100, 3], np.uint8),
np.random.randint(0, 256, [100, 25, 3], np.uint8),
],
)
@pytest.mark.parametrize("angle", [i for i in range(-360, 360, 15)])
def test_rotate_equal(img, aug_cls, angle):
random.seed(0)

h, w = img.shape[:2]
kp = [[random.randint(0, w - 1), random.randint(0, h - 1), random.randint(0, 360)] for _ in range(50)]
kp += [
[round(w * 0.2), int(h * 0.3), 90],
[int(w * 0.2), int(h * 0.3), 90],
[int(w * 0.2), int(h * 0.3), 90],
[int(w * 0.2), int(h * 0.3), 90],
[0, 0, 0],
[w - 1, h - 1, 0],
]
keypoint_params = A.KeypointParams("xya", remove_invisible=False)

a = A.Compose([aug_cls(rotate=(angle, angle))], keypoint_params=keypoint_params)
b = A.Compose(
[A.Rotate((angle, angle), border_mode=cv2.BORDER_CONSTANT, value=0, p=1)], keypoint_params=keypoint_params
)

res_a = a(image=img, keypoints=kp)
res_b = b(image=img, keypoints=kp)
assert np.allclose(res_a["image"], res_b["image"])
res_a = np.array(res_a["keypoints"])
res_b = np.array(res_b["keypoints"])
diff = np.round(np.abs(res_a - res_b))
assert diff[:, :2].max() <= 2
assert (diff[:, -1] % 360).max() <= 1

0 comments on commit 557b7b4

Please sign in to comment.