{ "cells": [ { "cell_type": "markdown", "id": "3f563a4a", "metadata": {}, "source": [ "# Lesson 5: Image Moments\n", "\n", "Once we can isolate a blob (Lesson 4), moments let us summarize its shape with a handful of numbers: its area, centroid, orientation, and even a description that stays the same under translation, scale, and rotation. Moments offer a classic, lightweight alternative to learned features (covered later) for simple shape matching." ] }, { "cell_type": "code", "execution_count": null, "id": "123c5b77", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import cv2\n", "import matplotlib.pyplot as plt" ] }, { "cell_type": "markdown", "id": "59c7fbc2", "metadata": {}, "source": [ "## What is a moment?\n", "\n", "For a binary image, the raw moment $m_{pq}$ is defined as\n", "\n", "$$m_{pq} = \\sum_{x,y} x^p y^q \\, I(x, y)$$\n", "\n", "where $I(x,y)$ is 1 inside the shape and 0 elsewhere. A few special cases are already familiar quantities:\n", "\n", "- $m_{00}$ = area (pixel count)\n", "- ($\\bar{x}, \\bar{y}) = (m_{10}/m_{00}, m_{01}/m_{00})$ yields the centroid\n", "\n", "The **order** of a moment is $p+q$. The zeroth-order moment is $m_{00}$; the first-order moments are $m_{10}$ and $m_{01}$; and the second-order moments are $m_{20}$, $m_{02}$, and $m_{11}$. `cv2.moments` computes all of these in one call, along with the *central* moments $\\mu_{ij}$, which are translation-invariant; and the *Hu moments*, which are translation-, scale-, and rotation-invariant." ] }, { "cell_type": "markdown", "id": "8f80b216", "metadata": {}, "source": [ "## Raw moments, computed by hand\n", "\n", "The formula above is just a weighted sum over pixel coordinates, so for a binary image it's nothing more than: find every foreground pixel's $(x,y)$ coordinates, then sum $x^py^q$ over them. Here it is on a tiny 3x3 image with 3 foreground pixels, computed directly with NumPy and cross-checked against `cv2.moments`." ] }, { "cell_type": "code", "execution_count": null, "id": "cde812d2", "metadata": {}, "outputs": [], "source": [ "im_tiny = np.array([\n", " [0, 1, 0],\n", " [1, 1, 0],\n", " [0, 0, 0],\n", "], dtype=np.uint8)\n", "\n", "ys, xs = np.nonzero(im_tiny) # (row, col) = (y, x) of every foreground pixel\n", "\n", "m00 = len(xs) # area: just a count of foreground pixels\n", "m10 = xs.sum() # sum of x^1 y^0 over foreground pixels\n", "m01 = ys.sum() # sum of x^0 y^1 over foreground pixels\n", "\n", "m = cv2.moments(im_tiny, binaryImage=True)\n", "\n", "print(f'foreground pixels (x, y): {[(int(x), int(y)) for x, y in zip(xs, ys)]}')\n", "print(f'moments: m00 = {m00} m10 = {m10} m01 = {m01}')\n", "print(f\"cv2.moments: m00 = {m['m00']:.0f} m10 = {m['m10']:.0f} m01 = {m['m01']:.0f} <-- same as previous line\")\n", "print(f'centroid = ({m10 / m00:.2f}, {m01 / m00:.2f})')" ] }, { "cell_type": "markdown", "id": "b0228f20", "metadata": {}, "source": [ "## An elongated, rotated blob\n", "\n", "Let's draw a rotated ellipse so its orientation is easy to eyeball and check against what the moments compute." ] }, { "cell_type": "code", "execution_count": null, "id": "20294e3e", "metadata": {}, "outputs": [], "source": [ "im_binary = np.zeros((200, 200), dtype=np.uint8)\n", "pixval = 1 # or 255, but note that it will make the moments bigger \n", "center = (100, 100)\n", "axes_len = (70, 25)\n", "angle_deg = 30\n", "cv2.ellipse(im_binary, center, axes_len, angle_deg, 0, 360, pixval, cv2.FILLED)\n", "\n", "plt.imshow(255 * im_binary, cmap='gray')\n", "plt.title(f'Ellipse drawn at {angle_deg} degrees')\n", "plt.axis('off')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "8c01b6ab", "metadata": {}, "source": [ "OpenCV's `cv2.ellipse` function produces strange effects around the border. If you want to fix these, it is easy enough to write the code to draw an ellipse yourself." ] }, { "cell_type": "code", "execution_count": null, "id": "b6d04971", "metadata": {}, "outputs": [], "source": [ "def draw_ellipse(im, cen, axes, angle_deg, val):\n", " h, w = im.shape\n", " Y, X = np.mgrid[0:h, 0:w]\n", " ang = np.deg2rad(angle_deg)\n", " XX = np.cos(ang) * (X-cen[0]) + np.sin(ang) * (Y-cen[1])\n", " YY = -np.sin(ang) * (X-cen[0]) + np.cos(ang) * (Y-cen[1])\n", " return np.uint8(val * (np.sqrt( (XX**2) + ((axes[0] / axes[1])**2)*(YY**2)) < (1.006)*axes[0]))\n", "\n", "im_binary2 = np.zeros((200, 200), dtype=np.uint8)\n", "im_binary2 = draw_ellipse(im_binary2, center, axes_len, angle_deg, pixval)\n", "im_binary = im_binary2 # Let's replace the ellipse with our fixed ellipse\n", "\n", "plt.imshow(255 * im_binary2, cmap='gray')\n", "ccen = (165,90) # location of red circle when ellipse angle is 0 degrees\n", "ccen_rotx = center[0] + np.cos(np.deg2rad(angle_deg)) * (ccen[0]-center[0]) - np.sin(np.deg2rad(angle_deg)) * (ccen[1]-center[1])\n", "ccen_roty = center[1] + np.sin(np.deg2rad(angle_deg)) * (ccen[0]-center[0]) + np.cos(np.deg2rad(angle_deg)) * (ccen[1]-center[1])\n", "circle = plt.Circle((ccen_rotx, ccen_roty), 10, color='red', fill=False, linewidth=1, alpha=1.0)\n", "plt.gca().add_patch(circle)\n", "plt.title(f'Fixed ellipse - Look closely inside red circle')\n", "plt.axis('off')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "194e7955", "metadata": {}, "source": [ "## Computing raw moments\n", "\n", "Computing the raw moments is easy: just sum over the image pixels (as explained above)." ] }, { "cell_type": "code", "execution_count": null, "id": "6a1ea2d7", "metadata": {}, "outputs": [], "source": [ "def compute_moments(im):\n", " summ = 0\n", " sumx = 0\n", " sumy = 0 \n", " for y in range(im.shape[0]):\n", " for x in range(im.shape[1]):\n", " v = im[y,x]\n", " if v > 0:\n", " summ += 1\n", " sumx += x\n", " sumy += y\n", " out = {}\n", " out['m00'] = summ\n", " out['m10'] = sumx\n", " out['m01'] = sumy\n", " return out\n", "\n", "mm = compute_moments(im_binary)\n", "m = cv2.moments(im_binary, binaryImage=True)\n", "print(f\"moments: m00 = {mm['m00']:.0f} m10 = {mm['m10']:.0f} m01 = {mm['m01']:.0f}\")\n", "print(f\"cv2.moments: m00 = {m['m00']:.0f} m10 = {m['m10']:.0f} m01 = {m['m01']:.0f} <-- same as previous line\")" ] }, { "cell_type": "code", "execution_count": null, "id": "9feb5e7d", "metadata": {}, "outputs": [], "source": [] }, { "cell_type": "markdown", "id": "1bfa4393", "metadata": {}, "source": [ "## Area and centroid from moments" ] }, { "cell_type": "code", "execution_count": null, "id": "37481032", "metadata": {}, "outputs": [], "source": [ "m = cv2.moments(im_binary, binaryImage=True)\n", "\n", "area = m['m00']\n", "cx = m['m10'] / m['m00']\n", "cy = m['m01'] / m['m00']\n", "\n", "print(f'area (m00) = {area:.0f} pixels')\n", "print(f'centroid = ({cx:.1f}, {cy:.1f})')\n", "\n", "plt.imshow(im_binary, cmap='gray')\n", "plt.scatter(cx, cy, c='red', marker='x', s=80)\n", "plt.title('Centroid from moments')\n", "plt.axis('off')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "9b2f8f35", "metadata": {}, "source": [ "## Central moments\n", "\n", "Raw moments $m_{pq}$ depend on where the shape happens to sit in the image — move the shape and every $m_{ij}$ (except $m_{00}$) changes. **Central moments** fix this by measuring around the shape's own centroid $(\\bar x, \\bar y)$ instead of the image origin:\n", "\n", "$$\\mu_{pq} = \\sum_{x,y} (x-\\bar x)^p\\,(y-\\bar y)^q\\, I(x,y)$$\n", "\n", "Recomputing this sum from scratch would mean re-visiting every pixel again. Instead, there's a shift-of-origin identity — the image-moment analogue of $\\mathrm{Var}(X) = E[X^2] - E[X]^2$ — that computes the central moments directly from the raw moments:\n", "\n", "$$\\mu_{20} = M_{20} - \\bar x\\,m_{10}, \\qquad \\mu_{02} = m_{02} - \\bar y\\,m_{01}, \\qquad \\mu_{11} = m_{11} - \\bar x\\,m_{01}$$\n", "\n", "`cv2.moments` returns both raw (`m['m20']`, ...) and central (`m['mu20']`, ...) moments in the same dictionary, so in practice you never need to compute these by hand —." ] }, { "cell_type": "code", "execution_count": null, "id": "dfd085f8", "metadata": {}, "outputs": [], "source": [ "mu20_manual = m['m20'] - cx * m['m10']\n", "mu02_manual = m['m02'] - cy * m['m01']\n", "mu11_manual = m['m11'] - cx * m['m01']\n", "\n", "print(f'moments: mu20 = {mu20_manual:.1f} mu02 = {mu02_manual:.1f} mu11 = {mu11_manual:.1f}')\n", "print(f\"cv2.moments: mu20 = {m['mu20']:.1f} mu02 = {m['mu02']:.1f} mu11 = {m['mu11']:.1f}\")" ] }, { "cell_type": "markdown", "id": "69793fce", "metadata": {}, "source": [ "## Orientation from central moments\n", "\n", "The central moments $\\mu_{20}$, $\\mu_{02}$, $\\mu_{11}$ describe the spread of the shape around its centroid — essentially its covariance matrix (which we revisit in Lesson 6). The angle of the major axis (the direction of greatest spread) is\n", "\n", "$$\\theta = \\frac{1}{2}\\,\\mathrm{atan2}\\!\\left(2\\mu_{11},\\; \\mu_{20} - \\mu_{02}\\right)$$" ] }, { "cell_type": "code", "execution_count": null, "id": "965511a9", "metadata": {}, "outputs": [], "source": [ "theta = 0.5 * np.arctan2(2 * m['mu11'], m['mu20'] - m['mu02'])\n", "theta_deg = np.degrees(theta)\n", "\n", "print(f'orientation from moments = {theta_deg:.1f} degrees')\n", "print(f'angle used to draw the ellipse = {angle_deg} degrees')\n", "\n", "# The eigenvalues of the (normalized) covariance matrix of central moments\n", "# give the axis lengths of the equivalent ellipse: axis = 4*sqrt(eigenvalue).\n", "cov = np.array([[m['mu20'], m['mu11']], [m['mu11'], m['mu02']]]) / m['m00']\n", "eigvals, _ = np.linalg.eigh(cov)\n", "semi_major = 2 * np.sqrt(eigvals[-1])\n", "\n", "theta = np.deg2rad(angle_deg) ####################\n", "dx, dy = semi_major * np.cos(theta), semi_major * np.sin(theta)\n", "\n", "print(semi_major, cx, cy, dx, dy) ######################\n", "plt.imshow(im_binary, cmap='gray')\n", "plt.plot([cx - dx, cx + dx], [cy - dy, cy + dy], c='red', linewidth=2)\n", "plt.scatter(cx, cy, c='red', marker='x', s=80)\n", "plt.title('Major axis recovered from moments')\n", "plt.axis('off')\n", "plt.axis('equal')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "f1941f34", "metadata": {}, "source": [ "## Hu moments: a shape descriptor invariant to pose\n", "\n", "**Hu moments** (`cv2.HuMoments`) are 7 values calculated from the central moments that stay (nearly) the same regardless of the shape's position, size, and rotation. This makes them useful for comparing two shapes without first aligning them.\n", "\n", "To see this, we build three versions of a dog shape: at the original pose, translated, and rotated + scaled. We compare the Hu moments of these versions of the shape with each other, and with a different (cat) shape." ] }, { "cell_type": "code", "execution_count": null, "id": "d4e59c14", "metadata": {}, "outputs": [], "source": [ "def warp(im, mat):\n", " rows, cols = im.shape[:2]\n", " return cv2.warpAffine(im, np.float32(mat), (cols, rows))\n", "\n", "im_original = cv2.imread('../img/dog_clipart.png', cv2.IMREAD_GRAYSCALE)\n", "im_translated = warp(im_original, [[1, 0, 10], [0, 1, 20]])\n", "im_rotated_scaled = warp(im_original, cv2.getRotationMatrix2D([60, 60], angle=45, scale=0.5))\n", "im_different_shape = cv2.imread('../img/cat_clipart.png', cv2.IMREAD_GRAYSCALE)\n", "\n", "fig, axes = plt.subplots(1, 4, figsize=(12, 3))\n", "for ax, img, title in zip(\n", " axes,\n", " [im_original, im_translated, im_rotated_scaled, im_different_shape],\n", " ['Original', 'Translated', 'Rotated + scaled', 'Different shape'],\n", "):\n", " ax.imshow(img, cmap='gray')\n", " ax.set_title(title, fontsize=10)\n", " ax.axis('off')\n", "plt.tight_layout()\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "ac4e17f9", "metadata": {}, "source": "

Image source: Public domain pictures

" }, { "cell_type": "markdown", "id": "b89ebac4", "metadata": {}, "source": [ "Now let's compute the Hu moments." ] }, { "cell_type": "code", "execution_count": null, "id": "8459b15a", "metadata": {}, "outputs": [], "source": [ "def hu_log(im):\n", " m = cv2.moments(im, binaryImage=True)\n", " hu = cv2.HuMoments(m).flatten()\n", " # log-scale since raw Hu moments span many orders of magnitude\n", " return -np.sign(hu) * np.log10(np.abs(hu) + 1e-30)\n", "\n", "# Format with a fixed width (\":8.3f\") to align the values across rows.\n", "label_width, col_width, n_hu = 18, 8, 7\n", "print(' ' * (label_width + 2) + 'Hu moments'.center(col_width * n_hu))\n", "print(f'{\"image\":>{label_width}} ' + ''.join(f'{i:>{col_width}}' for i in range(1, n_hu + 1)))\n", "for im, name in [\n", " (im_original, 'original'),\n", " (im_translated, 'translated'),\n", " (im_rotated_scaled, 'rotated+scaled'),\n", " (im_different_shape, 'different shape'),\n", "]:\n", " row = ''.join(f'{v:{col_width}.3f}' for v in hu_log(im))\n", " print(f'{name:>{label_width}} {row}')" ] }, { "cell_type": "markdown", "id": "8b6ec7dd", "metadata": {}, "source": [ "Note that translated and rotated+scaled versions yield similar Hu moments, whereas the different shape yields much different values. This is exactly the invariance property that makes Hu moments useful for shape matching." ] }, { "cell_type": "markdown", "id": "6cdaf613", "metadata": {}, "source": [ "### Exercise\n", "\n", "1. Use `cv2.findContours` to get the outline of a blob from Lesson 4's binary image, then call `cv2.moments` on the *contour* instead of the full binary mask. Compare the centroid to the one computed here.\n", "2. Draw a shape that is mirror-flipped rather than rotated. Are its Hu moments still close to the original? (Hint: think about what determinant/parity information moments do or do not capture.)" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.x" } }, "nbformat": 4, "nbformat_minor": 5 }