Lesson 8: Geometric Transformations

This lesson covers how to move, resize, and reshape images: flipping, cropping, rotating, and scaling, and then the more general hierarchy of 2D transformations — Euclidean, similarity, and affine — that those operations are all special cases of.

In [1]:
import numpy as np
import cv2
import matplotlib.pyplot as plt

A test image with no symmetry

We draw a letter "F" because it has no rotational or mirror symmetry — every flip and rotation produces a visibly different result, which makes it easy to tell the transformations apart.

In [2]:
def make_f_image(size=160):
    img = np.zeros((size, size, 3), dtype=np.uint8)
    img[:] = (30, 30, 30)
    color = (255, 200, 0)
    cv2.rectangle(img, (40, 20), (65, 140), color, -1)   # vertical stroke
    cv2.rectangle(img, (40, 20), (120, 45), color, -1)   # top horizontal stroke
    cv2.rectangle(img, (40, 65), (100, 90), color, -1)   # middle horizontal stroke
    return img

img = make_f_image()
plt.imshow(img)
plt.title('Original')
plt.axis('off')
plt.show()
No description has been provided for this image

Basic operations

Flipping

cv2.flip mirrors an image: code 1 flips horizontally (left-right), 0 flips vertically (top-bottom), -1 flips both.

In [3]:
flip_h = cv2.flip(img, 1)
flip_v = cv2.flip(img, 0)
flip_hv = cv2.flip(img, -1)

fig, axes = plt.subplots(1, 4, figsize=(12, 3))
for ax, im, title in zip(axes, [img, flip_h, flip_v, flip_hv],
                          ['Original', 'flip horizontal', 'flip vertical', 'flip both']):
    ax.imshow(im)
    ax.set_title(title, fontsize=9)
    ax.axis('off')
plt.tight_layout()
plt.show()
No description has been provided for this image

Cropping

Cropping is just array slicing — no OpenCV function needed. image[y0:y1, x0:x1] keeps rows y0..y1 and columns x0..x1.

In [4]:
cropped = img[10:100, 30:110]

plt.imshow(cropped)
plt.title(f'Cropped, shape={cropped.shape[:2]}')
plt.axis('off')
plt.show()
No description has been provided for this image

Scaling (resizing)

cv2.resize changes an image's dimensions, optionally with different factors for width and height. The ratio of width to height is the aspect ratio. Uniform scaling preserves shape (aspect ratio), non-uniform scaling stretches or squashes it.

In [5]:
uniform = cv2.resize(img, None, fx=0.5, fy=0.5)
stretched = cv2.resize(img, None, fx=1.5, fy=0.6)

fig, axes = plt.subplots(1, 3, figsize=(9, 3))
for ax, im, title in zip(axes, [img, uniform, stretched],
                          ['Original', 'Uniform scale (0.5, 0.5)', 'Non-uniform (1.5, 0.6)']):
    ax.imshow(im)
    ax.set_title(f'{title}\nshape={im.shape[:2]}', fontsize=9)
    ax.axis('off')
plt.tight_layout()
plt.show()
No description has been provided for this image

Rotating

cv2.getRotationMatrix2D builds a $2\times3$ matrix for rotating by an arbitrary angle around a chosen center, which cv2.warpAffine then applies to the image.

In [6]:
h, w = img.shape[:2]
center = (w / 2, h / 2)
M = cv2.getRotationMatrix2D(center, angle=25, scale=1.0)
print('rotation matrix:\n', M)

rotated = cv2.warpAffine(img, M, (w, h))

plt.imshow(rotated)
plt.title('Rotated 25 degrees about the center')
plt.axis('off')
plt.show()
rotation matrix:
 [[  0.90630779   0.42261826 -26.3140839 ]
 [ -0.42261826   0.90630779  41.30483798]]
No description has been provided for this image

The transformation hierarchy

Flipping, rotating, scaling, and translating are all instances of a general $2\times3$ matrix transform

$$\begin{bmatrix}x'\\y'\end{bmatrix} = \begin{bmatrix}a & b\\c & d\end{bmatrix}\begin{bmatrix}x\\y\end{bmatrix} + \begin{bmatrix}t_x\\t_y\end{bmatrix}$$

applied with cv2.warpAffine. Restricting the $2\times2$ part in different ways gives a nested family of transformations, from most to least restrictive:

Transform Free parameters Matrix form Preserves
Euclidean (rigid) rotation $\theta$, translation $(t_x,t_y)$ $\begin{bmatrix}\cos\theta & -\sin\theta\\ \sin\theta & \cos\theta\end{bmatrix}$ lengths, angles
Similarity + uniform scale $s$ $s\begin{bmatrix}\cos\theta & -\sin\theta\\ \sin\theta & \cos\theta\end{bmatrix}$ angles, ratios of lengths
Affine any invertible $2\times2$ matrix $\begin{bmatrix}a & b\\c & d\end{bmatrix}$ parallelism, ratios along a line

Two different things change as you go down the table, in opposite directions. The set of allowed transforms grows: every Euclidean transform is a special case of a similarity transform (just $s=1$), and every similarity transform is a special case of an affine transform (just a rotation-and-scale matrix instead of an arbitrary one) — so each row's transforms are a superset of the row above's. In exchange for that extra flexibility, the properties guaranteed to survive shrink: Euclidean preserves both lengths and angles; similarity gives up preserving lengths themselves (only their ratios survive); affine gives up angles too. Affine transforms are the most general of the three: they can shear a square into a parallelogram, something neither Euclidean nor similarity transforms can do.

Seeing the difference on a unit square

The clearest way to see what each transform can and cannot do is to watch what happens to a simple square.

In [7]:
square = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=np.float64)

def apply_2x2(pts, A, t=(0, 0)):
    return pts @ A.T + np.array(t)

theta = np.radians(30)
R = np.array([[np.cos(theta), -np.sin(theta)],
              [np.sin(theta),  np.cos(theta)]])

euclidean_sq  = apply_2x2(square, R, t=(0.3, 0.1))
similarity_sq = apply_2x2(square, 1.6 * R, t=(0.3, 0.1))
affine_sq     = apply_2x2(square, np.array([[1.6, 0.7], [0.2, 0.9]]), t=(0.3, 0.1))

fig, axes = plt.subplots(1, 3, figsize=(10, 3.5))
for ax, pts, title in zip(
    axes,
    [euclidean_sq, similarity_sq, affine_sq],
    ['Euclidean\n(rotate + translate)', 'Similarity\n(+ uniform scale)', 'Affine\n(shear allowed)'],
):
    ax.plot(*square.T, '--', color='gray', label='original')
    ax.plot(*pts.T, color='#e74c3c', linewidth=2, label='transformed')
    ax.set_aspect('equal')
    ax.set_title(title, fontsize=9)
    ax.legend(fontsize=7, loc='upper left')
plt.tight_layout()
plt.show()
No description has been provided for this image

Only the affine square stops looking like a (possibly resized) square — its corners are no longer 90 degrees, because affine transforms allow shear. All three keep opposite sides parallel. (The more general projective transforms, e.g., camera perspective, are covered in Lesson 23).

Fitting an affine transform from point correspondences

In practice we often don't know the transform matrix directly. Example: a flat object was photographed at an angle (or a scan came out skewed), and we want to undo the distortion. This requires us to identify a few landmark points in both the crooked photo and where they should be in a canonical, front-on view. cv2.getAffineTransform takes 3 such point correspondences (the minimum needed to determine all 6 affine parameters) and returns the matrix that maps one set onto the other — solve for the warped-to-canonical direction, and applying it rectifies the photo.

In [8]:
canvas_size = (w + 60, h + 60)

# simulate a photo taken at an angle: apply a KNOWN affine warp (rotation + shear + scale)
# -- in a real application this warp is unknown; we only ever get to see `warped` below
theta = np.radians(20)
R = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])
shear = np.array([[1, 0.25], [0, 1]])
A_true = R @ shear * 1.1
t_true = np.array([40, 20])
M_true = np.hstack([A_true, t_true.reshape(2, 1)])

warped = cv2.warpAffine(img, M_true, canvas_size)

# 3 landmarks we can identify in BOTH images (e.g. by clicking on the F's outer corners)
canonical_pts = np.float32([[40, 20], [120, 20], [40, 140]])
warped_pts = ((A_true @ canonical_pts.T).T + t_true).astype(np.float32)  # where those 3 points ended up

# solve for the warped -> canonical transform, then apply it to rectify the whole photo
M_recovered = cv2.getAffineTransform(warped_pts, canonical_pts)
rectified = cv2.warpAffine(warped, M_recovered, (w, h))

pixel_error = np.abs(rectified.astype(int) - img.astype(int)).mean()
print('mean abs pixel error, rectified vs. true original:', round(pixel_error, 2))

fig, axes = plt.subplots(1, 3, figsize=(11, 3.5))
axes[0].imshow(img)
axes[0].set_title('Original (canonical)', fontsize=9)
axes[1].imshow(warped)
for p in warped_pts:
    axes[1].scatter(*p, c='red', s=30)
axes[1].set_title('Warped photo\n(3 identified landmarks)', fontsize=9)
axes[2].imshow(rectified)
axes[2].set_title('Rectified\n(unwarped back)', fontsize=9)
for ax in axes:
    ax.axis('off')
plt.tight_layout()
plt.show()
mean abs pixel error, rectified vs. true original: 3.14
No description has been provided for this image

Exercise

  1. cv2.flip is not part of the affine family we wrote in matrix form above (rotation matrices $R$ always have determinant $+1$). What determinant does a flip's $2\times2$ matrix have? Construct the $2\times2$ matrix for flip(1) and check.
  2. Build a similarity transform matrix with $s=1$ and confirm it matches a pure Euclidean transform — i.e. similarity is a strict generalization of Euclidean, not a different family.
  3. Modify the affine matrix in the unit-square example so that opposite sides are not parallel. What has to break for that to happen? (Hint: this is exactly the boundary that separates affine from projective transforms.)