Geometric transformations

Resize and interpolation, rotation without cropping, affine transforms, perspective warping for document scans, and border handling.

Resize, rotate and flip

import cv2
import numpy as np

image = cv2.imread("page.jpg")
h, w = image.shape[:2]

# shrinking: INTER_AREA is the correct choice, it averages pixels
small = cv2.resize(image, (w // 2, h // 2), interpolation=cv2.INTER_AREA)

# enlarging: INTER_CUBIC or INTER_LINEAR; INTER_AREA is wrong here
large = cv2.resize(image, (w * 2, h * 2), interpolation=cv2.INTER_CUBIC)

# aspect-ratio-preserving resize to a target height
target_h = 480
scale = target_h / h
resized = cv2.resize(image, (int(w * scale), target_h), interpolation=cv2.INTER_AREA)

# rotation about the centre without cropping the corners
angle = 30
centre = (w / 2, h / 2)
matrix = cv2.getRotationMatrix2D(centre, angle, 1.0)

cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1])
new_w = int(h * sin + w * cos)
new_h = int(h * cos + w * sin)
matrix[0, 2] += new_w / 2 - centre[0]
matrix[1, 2] += new_h / 2 - centre[1]
rotated = cv2.warpAffine(image, matrix, (new_w, new_h))
print(image.shape, rotated.shape)

flipped = cv2.flip(image, 1)        # 1 = horizontal, 0 = vertical, -1 = both
InterpolationBest forCost
INTER_NEARESTMasks and label imagesCheapest, blocky
INTER_LINEARDefault, and general useFast, slightly soft
INTER_CUBICEnlarging photographsSlower, sharper, can ring
INTER_AREAShrinkingBest quality for downscaling
INTER_LANCZOS4High-quality enlargementSlowest
  • Use INTER_AREA when shrinking and anything else when enlarging. Getting this backwards produces either aliasing or an unnecessarily blurry result.
  • cv2.resize takes (width, height) while shape reports (height, width). Swapping them is the most common bug in this lesson.
  • Never resize a mask or a label image with a smooth interpolator: it creates intermediate values that are not valid class ids. Use INTER_NEAREST.
  • Rotation about the image centre crops the corners. Compute the bounding size of the rotated rectangle first, then translate the centre.

Affine transforms from three points

# affine: three source points map to three destination points
src = np.float32([[50, 50], [200, 50], [50, 200]])
dst = np.float32([[10, 100], [200, 50], [100, 250]])

matrix = cv2.getAffineTransform(src, dst)
warped = cv2.warpAffine(image, matrix, (w, h), flags=cv2.INTER_LINEAR,
                        borderMode=cv2.BORDER_CONSTANT, borderValue=(255, 255, 255))
print(matrix)

# the general case: build the matrix yourself
#   [ a  b  tx ]
#   [ c  d  ty ]
shear = np.float32([[1, 0.3, 0],
                    [0, 1.0, 0]])
sheared = cv2.warpAffine(image, shear, (int(w * 1.3), h))

# scaling about a point other than the origin requires the translation terms
scale = 1.5
about = (w / 2, h / 2)
s = np.float32([[scale, 0, about[0] * (1 - scale)],
                [0, scale, about[1] * (1 - scale)]])
scaled = cv2.warpAffine(image, s, (int(w * scale), int(h * scale)))
  • getAffineTransform needs exactly three point pairs; it cannot solve a general homography.
  • Affine preserves parallel lines but not angles. For a document photographed at an angle you need the perspective transform instead.
  • borderMode decides what fills the new area: constant (a chosen colour), replicate (edge pixels), reflect, or wrap. Constant white is usually right for document work.
  • Do not chain resize and rotate operations when one affine transform can do both. Each resampling step loses a little sharpness.

Perspective warping

def order_corners(points):
    """Order four points as top-left, top-right, bottom-right, bottom-left."""
    points = np.array(points, dtype="float32").reshape(4, 2)
    ordered = np.zeros((4, 2), dtype="float32")
    total = points.sum(axis=1)
    ordered[0] = points[np.argmin(total)]      # top-left has the smallest sum
    ordered[2] = points[np.argmax(total)]      # bottom-right has the largest
    diff = np.diff(points, axis=1).ravel()
    ordered[1] = points[np.argmin(diff)]       # top-right has the smallest difference
    ordered[3] = points[np.argmax(diff)]
    return ordered

# find the document in the frame, then warp it flat
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blurred, 75, 200)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
largest = max(contours, key=cv2.contourArea)

perimeter = cv2.arcLength(largest, True)
approx = cv2.approxPolyDP(largest, 0.02 * perimeter, True)
print("corners found:", len(approx))

if len(approx) == 4:
    src = order_corners(approx)
    top_left, top_right, bottom_right, bottom_left = src

    width = int(max(np.linalg.norm(bottom_right - bottom_left),
                    np.linalg.norm(top_right - top_left)))
    height = int(max(np.linalg.norm(top_right - bottom_right),
                     np.linalg.norm(top_left - bottom_left)))

    dst = np.float32([[0, 0], [width - 1, 0], [width - 1, height - 1], [0, height - 1]])
    matrix = cv2.getPerspectiveTransform(src, dst)
    flat = cv2.warpPerspective(image, matrix, (width, height))
    cv2.imwrite("flat.png", flat)
⚠️
A correct corner order is the whole ball game. Feed getPerspectiveTransform a rotated corner list and you get a mirrored or twisted output that still looks plausible at a glance. Draw the ordered points on the source image and check them visually before trusting the warp.

FAQ

Why is my resized image blurry?
You are probably enlarging with INTER_AREA, which is designed for downscaling, or shrinking with INTER_NEAREST, which drops pixels. Match the interpolator to the direction.
How do I warp a document when contour detection fails?
Provide the four corners manually from a click tool, or use a detector trained for document boundaries. A manual corner step for rare failures is cheaper than tuning an edge pipeline that must work on every input.

Reading, writing and inspecting images Contours and object detection basics

Last refreshed 2026-09-18.