{ "cells": [ { "cell_type": "markdown", "id": "2dd3279e", "metadata": {}, "source": [ "# Lesson 8: Geometric Transformations\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "98cca72e", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import cv2\n", "import matplotlib.pyplot as plt" ] }, { "cell_type": "markdown", "id": "1217dda8", "metadata": {}, "source": [ "## A test image with no symmetry\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "e728d856", "metadata": {}, "outputs": [], "source": [ "def make_f_image(size=160):\n", " img = np.zeros((size, size, 3), dtype=np.uint8)\n", " img[:] = (30, 30, 30)\n", " color = (255, 200, 0)\n", " cv2.rectangle(img, (40, 20), (65, 140), color, -1) # vertical stroke\n", " cv2.rectangle(img, (40, 20), (120, 45), color, -1) # top horizontal stroke\n", " cv2.rectangle(img, (40, 65), (100, 90), color, -1) # middle horizontal stroke\n", " return img\n", "\n", "img = make_f_image()\n", "plt.imshow(img)\n", "plt.title('Original')\n", "plt.axis('off')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "cffc1298", "metadata": {}, "source": [ "## Basic operations\n", "\n", "### Flipping\n", "\n", "`cv2.flip` mirrors an image: code `1` flips horizontally (left-right), `0` flips vertically (top-bottom), `-1` flips both." ] }, { "cell_type": "code", "execution_count": null, "id": "c87733ad", "metadata": {}, "outputs": [], "source": [ "flip_h = cv2.flip(img, 1)\n", "flip_v = cv2.flip(img, 0)\n", "flip_hv = cv2.flip(img, -1)\n", "\n", "fig, axes = plt.subplots(1, 4, figsize=(12, 3))\n", "for ax, im, title in zip(axes, [img, flip_h, flip_v, flip_hv],\n", " ['Original', 'flip horizontal', 'flip vertical', 'flip both']):\n", " ax.imshow(im)\n", " ax.set_title(title, fontsize=9)\n", " ax.axis('off')\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "397ea8d9", "metadata": {}, "source": [ "### Cropping\n", "\n", "Cropping is just array slicing — no OpenCV function needed. `image[y0:y1, x0:x1]` keeps rows `y0..y1` and columns `x0..x1`." ] }, { "cell_type": "code", "execution_count": null, "id": "6cc45f10", "metadata": {}, "outputs": [], "source": [ "cropped = img[10:100, 30:110]\n", "\n", "plt.imshow(cropped)\n", "plt.title(f'Cropped, shape={cropped.shape[:2]}')\n", "plt.axis('off')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "6a583624", "metadata": {}, "source": [ "### Scaling (resizing)\n", "\n", "`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." ] }, { "cell_type": "code", "execution_count": null, "id": "cb9ac95b", "metadata": {}, "outputs": [], "source": [ "uniform = cv2.resize(img, None, fx=0.5, fy=0.5)\n", "stretched = cv2.resize(img, None, fx=1.5, fy=0.6)\n", "\n", "fig, axes = plt.subplots(1, 3, figsize=(9, 3))\n", "for ax, im, title in zip(axes, [img, uniform, stretched],\n", " ['Original', 'Uniform scale (0.5, 0.5)', 'Non-uniform (1.5, 0.6)']):\n", " ax.imshow(im)\n", " ax.set_title(f'{title}\\nshape={im.shape[:2]}', fontsize=9)\n", " ax.axis('off')\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "67ecde5a", "metadata": {}, "source": [ "### Rotating\n", "\n", "`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." ] }, { "cell_type": "code", "execution_count": null, "id": "bc739d01", "metadata": {}, "outputs": [], "source": [ "h, w = img.shape[:2]\n", "center = (w / 2, h / 2)\n", "M = cv2.getRotationMatrix2D(center, angle=25, scale=1.0)\n", "print('rotation matrix:\\n', M)\n", "\n", "rotated = cv2.warpAffine(img, M, (w, h))\n", "\n", "plt.imshow(rotated)\n", "plt.title('Rotated 25 degrees about the center')\n", "plt.axis('off')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "326513cf", "metadata": {}, "source": [ "## The transformation hierarchy\n", "\n", "Flipping, rotating, scaling, and translating are all instances of a general $2\\times3$ matrix transform\n", "\n", "$$\\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}$$\n", "\n", "applied with `cv2.warpAffine`. Restricting the $2\\times2$ part in different ways gives a nested family of transformations, from most to least restrictive:\n", "\n", "| Transform | Free parameters | Matrix form | Preserves |\n", "|---|---|---|---|\n", "| **Euclidean (rigid)** | rotation $\\theta$, translation $(t_x,t_y)$ | $\\begin{bmatrix}\\cos\\theta & -\\sin\\theta\\\\ \\sin\\theta & \\cos\\theta\\end{bmatrix}$ | lengths, angles |\n", "| **Similarity** | + uniform scale $s$ | $s\\begin{bmatrix}\\cos\\theta & -\\sin\\theta\\\\ \\sin\\theta & \\cos\\theta\\end{bmatrix}$ | angles, ratios of lengths |\n", "| **Affine** | any invertible $2\\times2$ matrix | $\\begin{bmatrix}a & b\\\\c & d\\end{bmatrix}$ | parallelism, ratios along a line |\n", "\n", "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." ] }, { "cell_type": "markdown", "id": "02d1f8cc", "metadata": {}, "source": [ "### Seeing the difference on a unit square\n", "\n", "The clearest way to see what each transform can and cannot do is to watch what happens to a simple square." ] }, { "cell_type": "code", "execution_count": null, "id": "567170cf", "metadata": {}, "outputs": [], "source": [ "square = np.array([[0, 0], [1, 0], [1, 1], [0, 1], [0, 0]], dtype=np.float64)\n", "\n", "def apply_2x2(pts, A, t=(0, 0)):\n", " return pts @ A.T + np.array(t)\n", "\n", "theta = np.radians(30)\n", "R = np.array([[np.cos(theta), -np.sin(theta)],\n", " [np.sin(theta), np.cos(theta)]])\n", "\n", "euclidean_sq = apply_2x2(square, R, t=(0.3, 0.1))\n", "similarity_sq = apply_2x2(square, 1.6 * R, t=(0.3, 0.1))\n", "affine_sq = apply_2x2(square, np.array([[1.6, 0.7], [0.2, 0.9]]), t=(0.3, 0.1))\n", "\n", "fig, axes = plt.subplots(1, 3, figsize=(10, 3.5))\n", "for ax, pts, title in zip(\n", " axes,\n", " [euclidean_sq, similarity_sq, affine_sq],\n", " ['Euclidean\\n(rotate + translate)', 'Similarity\\n(+ uniform scale)', 'Affine\\n(shear allowed)'],\n", "):\n", " ax.plot(*square.T, '--', color='gray', label='original')\n", " ax.plot(*pts.T, color='#e74c3c', linewidth=2, label='transformed')\n", " ax.set_aspect('equal')\n", " ax.set_title(title, fontsize=9)\n", " ax.legend(fontsize=7, loc='upper left')\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "748e57d8", "metadata": {}, "source": [ "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)." ] }, { "cell_type": "markdown", "id": "00868ca8", "metadata": {}, "source": [ "### Fitting an affine transform from point correspondences\n", "\n", "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." ] }, { "cell_type": "code", "execution_count": null, "id": "676392c6", "metadata": {}, "outputs": [], "source": [ "canvas_size = (w + 60, h + 60)\n", "\n", "# simulate a photo taken at an angle: apply a KNOWN affine warp (rotation + shear + scale)\n", "# -- in a real application this warp is unknown; we only ever get to see `warped` below\n", "theta = np.radians(20)\n", "R = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])\n", "shear = np.array([[1, 0.25], [0, 1]])\n", "A_true = R @ shear * 1.1\n", "t_true = np.array([40, 20])\n", "M_true = np.hstack([A_true, t_true.reshape(2, 1)])\n", "\n", "warped = cv2.warpAffine(img, M_true, canvas_size)\n", "\n", "# 3 landmarks we can identify in BOTH images (e.g. by clicking on the F's outer corners)\n", "canonical_pts = np.float32([[40, 20], [120, 20], [40, 140]])\n", "warped_pts = ((A_true @ canonical_pts.T).T + t_true).astype(np.float32) # where those 3 points ended up\n", "\n", "# solve for the warped -> canonical transform, then apply it to rectify the whole photo\n", "M_recovered = cv2.getAffineTransform(warped_pts, canonical_pts)\n", "rectified = cv2.warpAffine(warped, M_recovered, (w, h))\n", "\n", "pixel_error = np.abs(rectified.astype(int) - img.astype(int)).mean()\n", "print('mean abs pixel error, rectified vs. true original:', round(pixel_error, 2))\n", "\n", "fig, axes = plt.subplots(1, 3, figsize=(11, 3.5))\n", "axes[0].imshow(img)\n", "axes[0].set_title('Original (canonical)', fontsize=9)\n", "axes[1].imshow(warped)\n", "for p in warped_pts:\n", " axes[1].scatter(*p, c='red', s=30)\n", "axes[1].set_title('Warped photo\\n(3 identified landmarks)', fontsize=9)\n", "axes[2].imshow(rectified)\n", "axes[2].set_title('Rectified\\n(unwarped back)', fontsize=9)\n", "for ax in axes:\n", " ax.axis('off')\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "66de1eef", "metadata": {}, "source": [ "### Exercise\n", "\n", "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.\n", "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.\n", "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.)" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.x" } }, "nbformat": 4, "nbformat_minor": 5 }