Skip to content
AI360XpertBeta

Image Transformations: Affine, Rotation, Homography

Every photo warp is a small matrix times a point. Translation slides it, rotation turns it, and only a homography can tilt a square into a trapezoid.

Translation moves 10, 20 to 15, 17, rotation moves 3, 1 to minus 1, 3, and only homography bends parallels.
Translation moves 10, 20 to 15, 17, rotation moves 3, 1 to minus 1, 3, and only homography bends parallels.

Why Does This Exist?

Photos never line up by themselves. A phone tilts, a drone drifts, and a scanned page skews, so pixels from two views sit in different coordinate frames. Without a warp model there is no panorama, no stabilization, and no measurement from images.

All warps share one language. Points become homogeneous triples [x,y,1][x, y, 1], warps become 3×33 \times 3 matrices, and the output is the matrix times the point, divided by the last coordinate. That language feeds homogeneous coordinates and camera models, and resampling the warped grid needs interpolation.

The one idea you need first

Append a 11 to every point. Translation is not linear in [x,y][x, y], but it is linear in [x,y,1][x, y, 1] because the extra column carries the shift. The last row decides the family: [0,0,1][0, 0, 1] keeps parallels parallel, anything else lets them meet.

Think of It Like This

Sliding tracing paper versus tilting a projector

Copy a square onto tracing paper and slide it: sides stay parallel and lengths stay equal. Spin the paper and the square rotates. Those are affine moves. Now project the same slide onto a tilted wall: the near edge grows and parallels meet. That tilt is a homography.

It stops at depth. Paper and projector assume one flat sheet. Real scenes have multiple depths, where one global matrix cannot align foreground and background at once.

How It Actually Works

Write a point as [x,y,1][x, y, 1] and a warp as x′=Hxx' = H x, then divide by the third entry. Translation uses ones on the diagonal and shifts in the last column. Rotation by angle θ\theta uses c=cos⁡θc = \cos\theta and s=sin⁡θs = \sin\theta in the top left 2×22 \times 2 block. Scale multiplies axes by sxs_x and sys_y. Affine adds shear with six free numbers and keeps its last row [0,0,1][0, 0, 1]. Homography frees the last row, so division by ww bends lines.

In plain words: txt_x shifts right, tyt_y shifts down, cc and ss turn the axes, and h31h_{31} and h32h_{32} control perspective tilt. Every symbol is a knob with units in pixels except the rotation entries, which are unitless cosines and sines.

Worked example

Translate [10,20][10, 20] by tx=5t_x = 5 and ty=−3t_y = -3: x′=10+5=15x' = 10 + 5 = 15, y′=20+(−3)=17y' = 20 + (-3) = 17. The point lands at [15,17][15, 17].

Rotate [3,1][3, 1] by 9090 degrees counterclockwise. Here c=0c = 0 and s=1s = 1, so x′=cx−sy=0×3−1×1=−1x' = c x - s y = 0 \times 3 - 1 \times 1 = -1 and y′=sx+cy=1×3+0×1=3y' = s x + c y = 1 \times 3 + 0 \times 1 = 3. The point lands at [−1,3][-1, 3], exactly a quarter turn.

Apply perspective HH with rows [1,0,0][1, 0, 0], [0,1,0][0, 1, 0], [0.1,0,1][0.1, 0, 1] to [2,1][2, 1]. In homogeneous form [2,1,1][2, 1, 1] maps to [2,1,0.1×2+1]=[2,1,1.2][2, 1, 0.1 \times 2 + 1] = [2, 1, 1.2]. Divide: x′=2/1.2≈1.67x' = 2 / 1.2 \approx 1.67, y′=1/1.2≈0.83y' = 1 / 1.2 \approx 0.83. An affine matrix would leave the denominator at 11 and keep parallels parallel. The 1.21.2 is the whole difference, and the diagram draws this 1.671.67 beside the trapezoid.

Code

The point is the divide. Forgetting the homogeneous normalization applies the tilt to the numerator and skips it in the denominator.

import numpy as np

def warp_point(H: np.ndarray, pt: np.ndarray) -> np.ndarray:    p = np.array([pt[0], pt[1], 1.0])    q = H @ p    return q[:2] / q[2]  # divide by w

H_affine = np.array([[0.0, -1.0, 5.0], [1.0, 0.0, -3.0], [0.0, 0.0, 1.0]])print(warp_point(H_affine, np.array([3.0, 1.0])))  # -> [ 4. -0.]H_persp = np.array([[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.1, 0.0, 1.0]])print(np.round(warp_point(H_persp, np.array([2.0, 1.0])), 2))  # -> [1.67 0.83]

The first line chains the worked rotation with the worked translation: [3,1][3, 1] turns to [−1,3][-1, 3] then shifts to [4,0][4, 0]. The second line reproduces 1.671.67 and 0.830.83 from the hand computation.

Watch Out For

Multiplying by H and forgetting to divide by w

Affine habits carry over: apply a 3×33 \times 3 homography and use the first two outputs directly. The symptom is a warp that looks almost right near the origin but stretches wildly at the image edge, because w=1.2w = 1.2 versus w=2.5w = 2.5 changes the answer by a factor of two. Always normalize by the third coordinate before comparing against pixel positions.

Sampling warped coordinates with nearest neighbour by default

A warp lands at fractional pixels such as 1.671.67. Rounding to 22 is nearest sampling, and it jitters straight lines into staircases, especially after downscaling. The symptom is shimmering edges in video stabilization. Use bilinear interpolation for photos, and budget the extra four taps per pixel, reserving nearest for label masks where blending classes would invent new ones.

The Quick Version

  • Points are [x,y,1][x, y, 1], warps are 3×33 \times 3, output is HxHx divided by ww.
  • Translation adds: 10,2010, 20 with 5,−35, -3 gives 15,1715, 17.
  • Rotation by 90 degrees sends 3,13, 1 to minus 1,31, 3.
  • Affine keeps last row [0,0,1][0, 0, 1] and preserves parallels.
  • Homography frees the last row: 2,12, 1 with tilt 0.10.1 gives 1.67,0.831.67, 0.83 and bends parallels.
  • Divide by ww every time, and interpolate rather than round fractional positions.