{ "cells": [ { "cell_type": "markdown", "id": "add51d1a", "metadata": {}, "source": [ "# Lesson 22: Model Fitting, Robust Estimation, and RANSAC\n", "\n", "Many computer vision problems boil down to fitting a model — a line, a circle, a plane, a homography (Lesson 23), a fundamental matrix (Lesson 26) — to a set of corresponding points. In practice, that data almost always contains outliers: correspondences that are simply wrong. This lesson confronts that directly: least-squares fitting is catastrophically fragile to even a few bad points, and **RANSAC** is the standard fix." ] }, { "cell_type": "code", "execution_count": null, "id": "d33e4310", "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", "import cv2\n", "import matplotlib.pyplot as plt" ] }, { "cell_type": "markdown", "id": "54b4f929", "metadata": {}, "source": [ "## Ordinary vs. total least squares\n", "\n", "Fitting $y=mx+b$ by minimizing $\\sum(y_i-(mx_i+b))^2$ — **ordinary least squares (OLS)** — measures error *vertically*. That's the right choice when $x$ is known exactly and only $y$ is noisy, but not when both coordinates carry comparable noise, as they typically do for 2D image points: the fit gets biased toward whichever direction has less spread. (And it can't even represent a vertical line.) **Total least squares (TLS)** instead minimizes the *perpendicular* distance from each point to the line, treating both coordinates symmetrically.\n", "\n", "For a 2D line, TLS needs no new machinery: the best-fit line passes through the centroid, and its direction is the eigenvector of the (centered) points' covariance matrix with the *largest* eigenvalue — exactly Lesson 6's covariance-and-eigenvectors idea, just picking out the axis of maximum spread instead of describing a blob's shape. (With more unknowns — such as with a homography or fundamental matrix — the approach generalizes to the **singular value decomposition (SVD)**, introduced in Lesson 26.)" ] }, { "cell_type": "code", "execution_count": null, "id": "e424ca75", "metadata": {}, "outputs": [], "source": [ "rng_tls = np.random.default_rng(2)\n", "true_m, true_b = 3.0, 1.0 # a steep line, where OLS struggles most\n", "t = rng_tls.uniform(0, 5, 40)\n", "x_noisy = t + rng_tls.normal(0, 0.4, 40)\n", "y_noisy = true_m * t + true_b + rng_tls.normal(0, 0.4, 40)\n", "\n", "A_line = np.vstack([x_noisy, np.ones_like(x_noisy)]).T\n", "slope_ols, intercept_ols = np.linalg.lstsq(A_line, y_noisy, rcond=None)[0]\n", "\n", "points = np.stack([x_noisy, y_noisy], axis=1)\n", "centroid = points.mean(axis=0)\n", "cov = np.cov((points - centroid).T)\n", "eigvals, eigvecs = np.linalg.eigh(cov) # ascending order\n", "direction = eigvecs[:, -1] # largest eigenvalue = direction of max spread\n", "slope_tls = direction[1] / direction[0]\n", "intercept_tls = centroid[1] - slope_tls * centroid[0]\n", "\n", "print(f'true line: y = {true_m:.2f}x + {true_b:.2f}')\n", "print(f'OLS fit: y = {slope_ols:.2f}x + {intercept_ols:.2f} <- biased, since x is noisy too')\n", "print(f'TLS fit: y = {slope_tls:.2f}x + {intercept_tls:.2f} <- closer to the true slope')\n", "\n", "xs_tls = np.array([x_noisy.min(), x_noisy.max()])\n", "plt.scatter(x_noisy, y_noisy, s=15, alpha=0.6, label='noisy points (both x and y)')\n", "plt.plot(xs_tls, true_m * xs_tls + true_b, '--', color='gray', label='true line')\n", "plt.plot(xs_tls, slope_ols * xs_tls + intercept_ols, color='tab:orange', label='OLS (vertical error)')\n", "plt.plot(xs_tls, slope_tls * xs_tls + intercept_tls, color='tab:green', label='TLS (perpendicular error)')\n", "plt.legend(fontsize=8)\n", "plt.title('OLS vs. TLS when both x and y are noisy')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "94803ef4", "metadata": {}, "source": [ "TLS noticeably recovers the true slope better here, since the noise is genuinely symmetric in $x$ and $y$." ] }, { "cell_type": "markdown", "id": "3099b493", "metadata": {}, "source": [ "## Why least squares breaks under outliers\n", "\n", "Least-squares fitting minimizes the *sum of squared* residuals. But squaring has a drawback: every outlier (point far from the model) contributes an enormous amount to the total error, causing the whole fit to move away from all the other, correct points." ] }, { "cell_type": "code", "execution_count": null, "id": "b2e60e5f", "metadata": {}, "outputs": [], "source": [ "rng = np.random.default_rng(0)\n", "true_slope, true_intercept = 2.0, 5.0\n", "\n", "x_inliers = rng.uniform(0, 10, 40)\n", "y_inliers = true_slope * x_inliers + true_intercept + rng.normal(0, 0.5, 40)\n", "\n", "x_outliers = rng.uniform(0, 10, 15)\n", "y_outliers = rng.uniform(-20, 40, 15) # unrelated to the line at all\n", "\n", "x = np.concatenate([x_inliers, x_outliers])\n", "y = np.concatenate([y_inliers, y_outliers])\n", "\n", "A = np.vstack([x, np.ones_like(x)]).T\n", "slope_ols, intercept_ols = np.linalg.lstsq(A, y, rcond=None)[0]\n", "\n", "print(f'true line: y = {true_slope:.2f}x + {true_intercept:.2f}')\n", "print(f'ordinary least squares: y = {slope_ols:.2f}x + {intercept_ols:.2f} <- dragged off by outliers')\n", "\n", "xs = np.array([0, 10])\n", "plt.scatter(x_inliers, y_inliers, c='tab:blue', label='inliers')\n", "plt.scatter(x_outliers, y_outliers, c='tab:red', marker='x', label='outliers')\n", "plt.plot(xs, true_slope * xs + true_intercept, '--', color='gray', label='true line')\n", "plt.plot(xs, slope_ols * xs + intercept_ols, color='tab:orange', label='OLS fit')\n", "plt.legend(fontsize=8)\n", "plt.title('Ordinary least squares, corrupted by 15 outliers among 40 inliers')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "c22a3203", "metadata": {}, "source": [ "## RANSAC: fit from the inside out\n", "\n", "**RANSAC** (RANdom SAmple Consensus, Fischler & Bolles, 1981) flips the strategy: instead of using *all* the data and hoping outliers don't matter, it repeatedly picks the *smallest possible* random subset needed to define a candidate model, counts how many of the remaining points agree with it (the **consensus set**), and keeps whichever candidate has the most agreement. A final least-squares refit on the winning inlier set gives the polished result (since least squares works well without outliers)." ] }, { "cell_type": "code", "execution_count": null, "id": "0d337cee", "metadata": {}, "outputs": [], "source": [ "def ransac_line(x, y, threshold=2.0, n_iterations=200, rng=None):\n", " rng = rng or np.random.default_rng()\n", " best_inliers, best_count = None, -1\n", "\n", " for _ in range(n_iterations):\n", " i, j = rng.choice(len(x), 2, replace=False) # minimal sample: 2 points define a line\n", " if x[i] == x[j]:\n", " continue\n", " m = (y[j] - y[i]) / (x[j] - x[i])\n", " b = y[i] - m * x[i]\n", " distance = np.abs(m * x - y + b) / np.sqrt(m**2 + 1)\n", " inliers = distance < threshold\n", " if inliers.sum() > best_count:\n", " best_count, best_inliers = inliers.sum(), inliers\n", "\n", " A = np.vstack([x[best_inliers], np.ones(best_inliers.sum())]).T\n", " slope, intercept = np.linalg.lstsq(A, y[best_inliers], rcond=None)[0] # final refit, inliers only\n", " return slope, intercept, best_inliers\n", "\n", "slope_ransac, intercept_ransac, inlier_mask = ransac_line(x, y, rng=np.random.default_rng(1))\n", "\n", "print(f'true line: y = {true_slope:.2f}x + {true_intercept:.2f}')\n", "print(f'RANSAC fit: y = {slope_ransac:.2f}x + {intercept_ransac:.2f}')\n", "print(f'inliers found: {inlier_mask.sum()} / {len(x)} (planted {40} true inliers)')\n", "\n", "plt.scatter(x[inlier_mask], y[inlier_mask], c='tab:blue', label='found inliers')\n", "plt.scatter(x[~inlier_mask], y[~inlier_mask], c='tab:red', marker='x', label='rejected as outliers')\n", "plt.plot(xs, slope_ransac * xs + intercept_ransac, color='tab:green', label='RANSAC fit')\n", "plt.legend(fontsize=8)\n", "plt.title('RANSAC recovers the true line despite 27% outlier contamination')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "684587d2", "metadata": {}, "source": [ "## How many iterations does RANSAC need?\n", "\n", "If a fraction $w$ of the data are inliers, and the model needs a minimal sample of $n$ points, the probability that any one random sample is entirely inliers is $w^n$. To be at least `p` confident of drawing an all-inlier sample at least once across $N$ independent tries:\n", "\n", "$$N = \\frac{\\log(1-p)}{\\log(1-w^n)}$$\n", "\n", "More outliers, or a larger minimal sample size, cause this required number of samples to increase." ] }, { "cell_type": "code", "execution_count": null, "id": "18cbaca6", "metadata": {}, "outputs": [], "source": [ "def required_iterations(inlier_fraction, sample_size, confidence=0.99):\n", " return np.log(1 - confidence) / np.log(1 - inlier_fraction**sample_size)\n", "\n", "print(f'{\"inlier %\":>10} {\"line (n=2)\":>12} {\"homography (n=4)\":>18} {\"fundamental matrix (n=8)\":>18}')\n", "for w in [0.9, 0.7, 0.5, 0.3]:\n", " n_line = int(np.ceil(required_iterations(w, 2)))\n", " n_homog = int(np.ceil(required_iterations(w, 4)))\n", " n_fund = int(np.ceil(required_iterations(w, 8)))\n", " print(f'{100*w:>9.0f}% {n_line:>12} {n_homog:>18} {n_fund:>18}')" ] }, { "cell_type": "markdown", "id": "fe15c5cc", "metadata": {}, "source": [ "Fitting a line only needs 2 points, so even fairly heavy contamination (50% outliers) needs just a few dozen iterations. Fitting a homography needs a minimal sample of 4 points, so the same inlier fraction needs an order of magnitude more iterations — the price of a more complex model. Similarly for fitting a fundamental matrix, which needs a minimal sample of 8 points." ] }, { "cell_type": "markdown", "id": "52afd09b", "metadata": {}, "source": [ "## A gentler alternative: robust loss functions\n", "\n", "RANSAC makes a hard inlier/outlier decision. An alternative family, **M-estimators**, instead reweights every point's contribution smoothly — e.g. the **Huber loss** behaves like ordinary squared error for small residuals but switches to linear (much less aggressive) growth beyond a threshold, so a single very-wrong point can no longer dominate the total cost the way it does in `Loss = residual^2`. RANSAC and M-estimators are complementary in practice: RANSAC is excellent at rejecting *gross* outliers (completely wrong matches), while an M-estimator refinement afterward can down-weight smaller, more subtle deviations among the remaining inliers." ] }, { "cell_type": "code", "id": "c5fe888a", "source": "def huber_reweighted_fit(x, y, delta=2.0, n_iterations=10):\n weights = np.ones_like(x)\n for _ in range(n_iterations):\n sw = np.sqrt(weights)\n A = np.vstack([x, np.ones_like(x)]).T * sw[:, None] # weighted least squares\n slope, intercept = np.linalg.lstsq(A, y * sw, rcond=None)[0]\n residuals = np.abs(y - (slope * x + intercept))\n weights = np.where(residuals > delta, delta / np.maximum(residuals, 1e-6), 1.0)\n return slope, intercept\n\nslope_huber, intercept_huber = huber_reweighted_fit(x, y)\n\nprint(f'true line: y = {true_slope:.2f}x + {true_intercept:.2f}')\nprint(f'OLS fit: y = {slope_ols:.2f}x + {intercept_ols:.2f}')\nprint(f'Huber fit: y = {slope_huber:.2f}x + {intercept_huber:.2f}')\nprint(f'RANSAC fit: y = {slope_ransac:.2f}x + {intercept_ransac:.2f}')\n\nplt.scatter(x_inliers, y_inliers, c='tab:blue', label='inliers')\nplt.scatter(x_outliers, y_outliers, c='tab:red', marker='x', label='outliers')\nplt.plot(xs, true_slope * xs + true_intercept, '--', color='gray', label='true line')\nplt.plot(xs, slope_ols * xs + intercept_ols, color='tab:orange', label='OLS')\nplt.plot(xs, slope_huber * xs + intercept_huber, color='tab:purple', label='Huber-reweighted')\nplt.plot(xs, slope_ransac * xs + intercept_ransac, color='tab:green', label='RANSAC')\nplt.legend(fontsize=8)\nplt.title('Same contaminated data, three different fits')\nplt.show()", "metadata": {}, "execution_count": null, "outputs": [] }, { "cell_type": "markdown", "id": "e89edd12", "source": "The Huber fit lands between the two: much closer to the true line than OLS, though not quite as exact as RANSAC on data this heavily contaminated, since a handful of gross outliers still pull a little on every iteration rather than being cut out entirely.", "metadata": {} }, { "cell_type": "markdown", "id": "33384aac", "metadata": {}, "source": "### Exercise\n\n1. Increase `ransac_line`'s outlier count until roughly 70% of the points are outliers. Does 200 iterations remain enough to reliably recover the true line? Use the `required_iterations` formula to check whether 200 is even theoretically sufficient at that contamination level.\n2. Lower `ransac_line`'s distance `threshold` from `2.0` to `0.5`. Does the number of found inliers change, and why might too-strict a threshold actually start rejecting *genuine* inliers (hint: think about what noise, not outliers, does to correct points)?\n3. Try `huber_reweighted_fit` with a much smaller `delta` (e.g. `0.5`) and a much larger one (e.g. `5.0`). How does `delta` trade off between OLS-like behavior (barely any downweighting) and RANSAC-like hard rejection (almost binary in/out)?" } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "name": "python", "version": "3.x" } }, "nbformat": 4, "nbformat_minor": 5 }