{
 "cells": [
  {
   "cell_type": "markdown",
   "id": "303af2bc",
   "metadata": {},
   "source": [
    "# Lecture 3 — Sampling, Quantization & Interpolation\n",
    "**Week 3 | Spring 2026**  \n",
    "**Instructor:** Dr. Hugo Guillen Ramirez  \n",
    "**Course:** Introduction to Image Analysis\n",
    "\n",
    "---\n",
    "\n",
    "### How to use this notebook\n",
    "This notebook is a **follow-along companion** to the lecture slides.  \n",
    "Each section mirrors the slide deck. Run cells sequentially; every demo is self-contained.\n",
    "\n",
    "**Exercises** are marked with ✏️ and should take **10–15 min** each."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d765ce89",
   "metadata": {},
   "source": [
    "---\n",
    "## Setup & Imports\n",
    "Run this cell first — everything else depends on it."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c665dd1f",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "import matplotlib.gridspec as gridspec\n",
    "from scipy.ndimage import gaussian_filter, laplace\n",
    "from scipy.interpolate import RectBivariateSpline\n",
    "from scipy.ndimage import zoom as scipy_zoom\n",
    "from scipy.signal import fftconvolve\n",
    "import time, warnings\n",
    "warnings.filterwarnings('ignore')\n",
    "\n",
    "try:\n",
    "    from skimage import data as skdata\n",
    "    from skimage.transform import resize as sk_resize\n",
    "    HAS_SKIMAGE = True\n",
    "except ImportError:\n",
    "    HAS_SKIMAGE = False\n",
    "    print('scikit-image not found — using synthetic test images.')\n",
    "\n",
    "plt.rcParams.update({'figure.dpi': 100, 'axes.titlesize': 13,\n",
    "                     'axes.labelsize': 11, 'font.family': 'DejaVu Sans'})\n",
    "\n",
    "# ── Load a standard greyscale test image ──────────────────────────────────────\n",
    "def load_test_image(size=(256, 256)):\n",
    "    \"\"\"Return a greyscale float32 image in [0, 1].\"\"\"\n",
    "    if HAS_SKIMAGE:\n",
    "        img = skdata.camera()\n",
    "        img = sk_resize(img, size, anti_aliasing=True)\n",
    "    else:\n",
    "        y, x = np.ogrid[:size[0], :size[1]]\n",
    "        cy, cx = size[0] / 2, size[1] / 2\n",
    "        r = np.sqrt((x - cx)**2 + (y - cy)**2)\n",
    "        img = 0.5 + 0.5 * np.sin(r * 0.5)\n",
    "    return img.astype(np.float32)\n",
    "\n",
    "IMG = load_test_image()\n",
    "print(f'Test image ready: shape={IMG.shape}, dtype={IMG.dtype}, '\n",
    "      f'range=[{IMG.min():.2f}, {IMG.max():.2f}]')\n",
    "plt.imshow(IMG, cmap='gray'); plt.title('Test image'); plt.axis('off'); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4829db84",
   "metadata": {},
   "source": [
    "---\n",
    "# Section 1 — Sampling\n",
    "\n",
    "## 1.1 Fourier Transform Refresher\n",
    "\n",
    "The **1-D Fourier Transform** decomposes a signal into its frequency components:\n",
    "\n",
    "$$F(\\omega) = \\int_{-\\infty}^{\\infty} f(x)\\, e^{-2\\pi i \\omega x}\\, dx$$\n",
    "\n",
    "**Key property for sampling:** multiplication in the spatial domain $\\Leftrightarrow$ convolution in the frequency domain.\n",
    "\n",
    "### Rect → Sinc Fourier pair\n",
    "\n",
    "A rectangular pulse of width $W$ transforms to a sinc:\n",
    "$$\\mathrm{rect}_W(x) \\;\\xrightarrow{\\mathcal{F}}\\; W\\,\\mathrm{sinc}(W\\omega)$$\n",
    "\n",
    "Wider aperture $\\Leftrightarrow$ narrower spectrum."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "45cb6bb1",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Rect → Sinc Fourier pair ─────────────────────────────────────────────────\n",
    "omega = np.linspace(-6, 6, 2000)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 4))\n",
    "for W in [0.5, 1.0, 2.0]:\n",
    "    F = W * np.sinc(W * omega)          # np.sinc(x) = sin(πx)/(πx)\n",
    "    ax.plot(omega, F, label=f'W = {W}')\n",
    "\n",
    "ax.axhline(0, color='k', lw=0.5)\n",
    "ax.set_xlabel('Frequency ω'); ax.set_ylabel('F(ω)')\n",
    "ax.set_title('Fourier Transform of rect_W(x) → W · sinc(Wω)\\n'\n",
    "             'Wider aperture = narrower spectrum')\n",
    "ax.legend(); plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "8e1da192",
   "metadata": {},
   "source": [
    "## 1.2 Sampling in the Frequency Domain\n",
    "\n",
    "Sampling a continuous signal $f(x)$ with spacing $\\Delta x$ replicates its spectrum at intervals of $f_s = 1/\\Delta x$.\n",
    "\n",
    "If the copies **overlap**, we get **aliasing** — false low-frequency content injected into the signal."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "b6422278",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Spectral replication: aliasing visualisation ─────────────────────────────\n",
    "omega = np.linspace(-4, 4, 4000)\n",
    "omega_max = 0.8\n",
    "\n",
    "def original_spectrum(w, wmax):\n",
    "    return np.maximum(0, 1 - np.abs(w) / wmax)\n",
    "\n",
    "F_orig = original_spectrum(omega, omega_max)\n",
    "\n",
    "fig, axes = plt.subplots(1, 2, figsize=(13, 4))\n",
    "for ax, fs, title in zip(axes, [3.0, 1.2],\n",
    "        ['Adequate  (fs=3.0 > 2×0.8)  — NO aliasing',\n",
    "         'Inadequate (fs=1.2 < 2×0.8) — ALIASING']):\n",
    "    ax.fill_between(omega, original_spectrum(omega, omega_max),\n",
    "                    alpha=0.4, color='steelblue', label='Original F(ω)')\n",
    "    for k in [-1, 1]:\n",
    "        shifted = original_spectrum(omega - k * fs, omega_max)\n",
    "        ax.fill_between(omega, shifted, alpha=0.35, color='tomato',\n",
    "                        label='Copy' if k == 1 else None)\n",
    "    ax.axvline(fs / 2,  ls='--', color='k', lw=0.8, label='Nyquist freq')\n",
    "    ax.axvline(-fs / 2, ls='--', color='k', lw=0.8)\n",
    "    ax.set_title(title); ax.set_xlabel('ω'); ax.legend(fontsize=8)\n",
    "\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "5344f508",
   "metadata": {},
   "source": [
    "## 1.3 Nyquist–Shannon Sampling Theorem\n",
    "\n",
    "> A band-limited signal with highest frequency $f_{\\max}$ can be **perfectly reconstructed** from its samples if and only if\n",
    "> $$f_s \\geq 2\\,f_{\\max} \\quad\\Longleftrightarrow\\quad \\Delta x \\leq \\frac{1}{2\\,f_{\\max}}$$\n",
    "\n",
    "Below: an 8 Hz signal sampled at three different rates."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "971d9409",
   "metadata": {},
   "outputs": [],
   "source": [
    "import numpy as np\n",
    "import matplotlib.pyplot as plt\n",
    "\n",
    "# ── 1-D Nyquist demo ─────────────────────────────────────────────────────────\n",
    "t  = np.linspace(0, 1, 2000)\n",
    "fs = 8                                # signal frequency\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(15, 4), sharey=True)\n",
    "configs = [\n",
    "    (40,  'steelblue', 'Adequate (fs=40 > 16)'),\n",
    "    (16,  'seagreen',  'Borderline (fs=16 = 2×8)'),\n",
    "    (9,   'tomato',    'Under-sampled (fs=9 < 16) → ALIAS'),\n",
    "]\n",
    "\n",
    "for ax, (fs_s, col, title) in zip(axes, configs):\n",
    "    t_s = np.arange(0, 1, 1 / fs_s)\n",
    "    s_s = np.sin(2 * np.pi * fs * t_s)\n",
    "    ax.plot(t, np.sin(2 * np.pi * fs * t), 'steelblue', lw=1, alpha=0.5)\n",
    "    ax.stem(t_s, s_s, linefmt=col, markerfmt='o', basefmt=' ')\n",
    "    \n",
    "    if fs_s == 9:\n",
    "        # Generalized alias formula that preserves the sign/phase\n",
    "        f_alias = fs - fs_s * round(fs / fs_s)\n",
    "        \n",
    "        # Now we just plug f_alias straight into the sine function\n",
    "        ax.plot(t, np.sin(2 * np.pi * f_alias * t), 'tomato', ls='--',\n",
    "                lw=1.5, label=f'Alias at {f_alias} Hz')\n",
    "        ax.legend(fontsize=8)\n",
    "        \n",
    "    ax.set_title(title); ax.set_xlabel('t')\n",
    "\n",
    "plt.suptitle('Nyquist–Shannon: 8 Hz signal at different sampling rates',\n",
    "             fontsize=13, y=1.02)\n",
    "plt.tight_layout()\n",
    "plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "b5f7b450",
   "metadata": {},
   "source": [
    "## 1.4 2-D Aliasing Examples\n",
    "\n",
    "In 2-D, aliasing can change both the **frequency** and the **orientation** of patterns."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e5f24828",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── 2-D aliasing: rolling pattern below Nyquist ─────────────────────────────\n",
    "size = 128; delta_x = 15\n",
    "x = np.arange(size); y = np.arange(size)\n",
    "X, Y = np.meshgrid(x, y)\n",
    "\n",
    "# Diagonal frequencies chosen so x-freq exceeds Nyquist at Δx=15\n",
    "x_freq, y_freq = 0.05, 0.02\n",
    "img_original = np.sin(2 * np.pi * (x_freq * X + y_freq * Y))\n",
    "\n",
    "# Sinc kernel for reconstruction\n",
    "kx = np.arange(-size//2, size//2); ky = np.arange(-size//2, size//2)\n",
    "KX, KY = np.meshgrid(kx, ky)\n",
    "sinc_kernel = np.sinc(KX / delta_x) * np.sinc(KY / delta_x)\n",
    "\n",
    "# Sample and reconstruct\n",
    "sampling_mask = np.zeros((size, size))\n",
    "sampling_mask[::delta_x, ::delta_x] = 1\n",
    "img_sampled = img_original * sampling_mask\n",
    "img_recon   = fftconvolve(img_sampled, sinc_kernel, mode='same')\n",
    "\n",
    "fig, axes = plt.subplots(1, 4, figsize=(16, 4))\n",
    "titles = ['(a) Original pattern', '(b) Sinc kernel (Δx=15)',\n",
    "          '(c) Sampled (Δx=15)', '(d) Reconstruction (aliased!)']\n",
    "images = [img_original, sinc_kernel, img_sampled, img_recon]\n",
    "for ax, im, t in zip(axes, images, titles):\n",
    "    ax.imshow(im, cmap='gray'); ax.set_title(t, fontsize=10); ax.axis('off')\n",
    "plt.suptitle('Under-sampling changes both frequency AND orientation', fontsize=12)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "046583fa",
   "metadata": {},
   "source": [
    "## 1.5 Zone Plate: Variable Frequency Content"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "97f16d3e",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Zone plate: aliasing where local frequency exceeds Nyquist ───────────────\n",
    "size = 256; delta_x = 5\n",
    "x = np.arange(-size//2, size//2); y = np.arange(-size//2, size//2)\n",
    "X, Y = np.meshgrid(x, y)\n",
    "\n",
    "zone_plate = np.sin(0.005 * (X**2 + Y**2))\n",
    "sinc_k = np.sinc(X / delta_x) * np.sinc(Y / delta_x)\n",
    "mask = np.zeros((size, size)); mask[::delta_x, ::delta_x] = 1\n",
    "sampled = zone_plate * mask\n",
    "recon   = fftconvolve(sampled, sinc_k, mode='same')\n",
    "\n",
    "fig, axes = plt.subplots(1, 4, figsize=(16, 4))\n",
    "for ax, im, t in zip(axes,\n",
    "        [zone_plate, sinc_k, sampled, recon],\n",
    "        ['(a) Zone plate', '(b) Sinc (Δx=5)', '(c) Sampled', '(d) Reconstructed']):\n",
    "    ax.imshow(im, cmap='gray'); ax.set_title(t, fontsize=10); ax.axis('off')\n",
    "plt.suptitle('Zone plate: aliasing only where the local frequency exceeds Nyquist',\n",
    "             fontsize=12)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "74c54175",
   "metadata": {},
   "source": [
    "## 1.6 Anti-Aliasing: Gaussian Pre-Filter\n",
    "\n",
    "**The golden rule:** if you can't increase $f_s$, you must decrease $\\omega_{\\max}$ *before* sampling.\n",
    "\n",
    "**Standard pipeline:** Gaussian blur ($\\sigma \\approx \\text{factor}/2$) → subsample."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "ad02e490",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Anti-aliasing on the zone plate ──────────────────────────────────────────\n",
    "sigma_blur = 4\n",
    "zone_blurred = gaussian_filter(zone_plate, sigma=sigma_blur)\n",
    "sampled_aa = zone_blurred * mask\n",
    "recon_aa   = fftconvolve(sampled_aa, sinc_k, mode='same')\n",
    "\n",
    "fig, axes = plt.subplots(2, 3, figsize=(14, 8))\n",
    "titles_top = ['Original zone plate', 'Sampled (no filter)', 'Reconstructed (aliased)']\n",
    "titles_bot = ['Gaussian-blurred (σ=4)', 'Sampled (after blur)', 'Reconstructed (clean)']\n",
    "for ax, im, t in zip(axes[0], [zone_plate, sampled, recon], titles_top):\n",
    "    ax.imshow(im, cmap='gray'); ax.set_title(t); ax.axis('off')\n",
    "for ax, im, t in zip(axes[1], [zone_blurred, sampled_aa, recon_aa], titles_bot):\n",
    "    ax.imshow(im, cmap='gray'); ax.set_title(t); ax.axis('off')\n",
    "\n",
    "plt.suptitle('Anti-aliasing: blur removes high frequencies BEFORE sampling',\n",
    "             fontsize=13, y=1.01)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "91d74eed",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Anti-aliasing on a real image ────────────────────────────────────────────\n",
    "def downsample(img, n):\n",
    "    \"\"\"Naive pixel-drop downsampling.\"\"\"\n",
    "    return img[::n, ::n]\n",
    "\n",
    "def downsample_aa(img, n, sigma):\n",
    "    \"\"\"Standard pipeline: Gaussian blur then subsample.\"\"\"\n",
    "    return gaussian_filter(img, sigma=sigma)[::n, ::n]\n",
    "\n",
    "factor = 4\n",
    "sigma_rule = factor / 2   # rule of thumb\n",
    "\n",
    "fig, axes = plt.subplots(1, 4, figsize=(16, 4))\n",
    "axes[0].imshow(IMG, cmap='gray', vmin=0, vmax=1)\n",
    "axes[0].set_title(f'Original {IMG.shape}')\n",
    "\n",
    "naive = downsample(IMG, factor)\n",
    "axes[1].imshow(naive, cmap='gray', vmin=0, vmax=1)\n",
    "axes[1].set_title(f'Naive ↓{factor}× (aliased)')\n",
    "\n",
    "for i, s in enumerate([sigma_rule, sigma_rule * 2]):\n",
    "    aa = downsample_aa(IMG, factor, s)\n",
    "    axes[i+2].imshow(aa, cmap='gray', vmin=0, vmax=1)\n",
    "    axes[i+2].set_title(f'AA ↓{factor}× σ={s:.1f}')\n",
    "\n",
    "for ax in axes: ax.axis('off')\n",
    "plt.suptitle('Rule of thumb: σ ≈ factor / 2', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "d0445f45",
   "metadata": {},
   "source": [
    "---\n",
    "### ✏️ Exercise 1 — Checkerboard Aliasing (~10 min)\n",
    "\n",
    "**Tasks**\n",
    "1. Create a 256×256 checkerboard (`cell_size=8`).\n",
    "2. Downsample by 4× **without** and **with** Gaussian pre-filtering (try σ = 1, 2, 4).\n",
    "3. Display results side by side (spatial domain, top row) and their **2-D Fourier magnitude** (bottom row).\n",
    "4. **Question:** At which σ does the Moiré pattern disappear? Why?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "4e6a0e4e",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Exercise 1: Checkerboard aliasing ────────────────────────────────────────\n",
    "def make_checkerboard(size=256, cell=8):\n",
    "    y, x = np.indices((size, size))\n",
    "    return ((x // cell + y // cell) % 2).astype(np.float32)\n",
    "\n",
    "checker = make_checkerboard(256, 8)\n",
    "factor  = 4\n",
    "sigmas  = [1, 2, 4]\n",
    "naive   = checker[::factor, ::factor]\n",
    "aas     = [gaussian_filter(checker, s)[::factor, ::factor] for s in sigmas]\n",
    "\n",
    "fig, axes = plt.subplots(2, 5, figsize=(16, 7))\n",
    "\n",
    "# Top row: spatial domain\n",
    "all_imgs  = [checker, naive] + aas\n",
    "all_titles = ['Original', f'Naive ↓{factor}× (Moiré!)'] + \\\n",
    "             [f'σ={s} then ↓{factor}×' for s in sigmas]\n",
    "\n",
    "for col, (im, title) in enumerate(zip(all_imgs, all_titles)):\n",
    "    axes[0, col].imshow(im, cmap='gray', vmin=0, vmax=1)\n",
    "    axes[0, col].set_title(title, fontsize=9); axes[0, col].axis('off')\n",
    "\n",
    "# Bottom row: Fourier magnitude (log scale)\n",
    "for col, im in enumerate(all_imgs):\n",
    "    mag = np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(im))))\n",
    "    axes[1, col].imshow(mag, cmap='inferno')\n",
    "    axes[1, col].set_title('|FFT|', fontsize=9); axes[1, col].axis('off')\n",
    "\n",
    "axes[0, 0].set_ylabel('Spatial'); axes[1, 0].set_ylabel('Fourier')\n",
    "plt.suptitle('Exercise 1 — Checkerboard: Moiré artefacts vs. anti-aliasing',\n",
    "             fontsize=13, y=1.01)\n",
    "plt.tight_layout(); plt.show()\n",
    "\n",
    "# TODO: Answer the question — at which sigma does aliasing vanish?\n",
    "#       Hint: relate the checkerboard's spatial frequency to the Nyquist limit\n",
    "#       after downsampling by factor 4."
   ]
  },
  {
   "cell_type": "markdown",
   "id": "7f3ce997",
   "metadata": {},
   "source": [
    "---\n",
    "# Section 2 — Quantization\n",
    "\n",
    "After sampling, each pixel is mapped to a **finite set of levels**: $Q(f) \\in \\{q_1, \\dots, q_L\\}$.\n",
    "\n",
    "With $B$ bits we get $L = 2^B$ levels and a quantization step $\\Delta = (f_{\\max} - f_{\\min}) / L$.\n",
    "\n",
    "**False contours** (banding) appear when $\\Delta$ is large relative to the local gradient."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "c69a4536",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Uniform quantization utility ─────────────────────────────────────────────\n",
    "def quantize(img, bits):\n",
    "    \"\"\"Quantize a float [0,1] image to `bits` bit depth.\"\"\"\n",
    "    L = 2 ** bits\n",
    "    return np.clip(np.floor(img * L) / L, 0, 1).astype(np.float32)\n",
    "\n",
    "def quant_rms(img, bits):\n",
    "    return float(np.sqrt(np.mean((img - quantize(img, bits))**2)))\n",
    "\n",
    "# ── Banding demo on a smooth gradient ────────────────────────────────────────\n",
    "gradient = np.tile(np.linspace(0, 1, 256, dtype=np.float32), (64, 1))\n",
    "bit_depths = [8, 4, 3, 2]\n",
    "\n",
    "fig, axes = plt.subplots(len(bit_depths) + 1, 1, figsize=(12, 6), sharex=True)\n",
    "axes[0].imshow(gradient, cmap='gray', vmin=0, vmax=1, aspect='auto')\n",
    "axes[0].set_ylabel('float32\\n(ref)', fontsize=9); axes[0].set_yticks([])\n",
    "\n",
    "for ax, bits in zip(axes[1:], bit_depths):\n",
    "    ax.imshow(quantize(gradient, bits), cmap='gray', vmin=0, vmax=1, aspect='auto')\n",
    "    ax.set_ylabel(f'{bits}-bit\\n({2**bits} lvl)', fontsize=9); ax.set_yticks([])\n",
    "\n",
    "plt.suptitle('Uniform quantization — banding on a smooth gradient', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4015787a",
   "metadata": {},
   "source": [
    "## 2.1 Image Histograms\n",
    "\n",
    "The histogram $h(r_k) = n_k$ counts the number of pixels at each intensity level.\n",
    "The **normalised histogram** $p(r_k) = n_k / (MN)$ is a probability distribution.\n",
    "\n",
    "Histograms reveal contrast, dynamic range, saturation, and compression artefacts."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "e26cba1a",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Quantization on the test image + histograms ──────────────────────────────\n",
    "bit_depths = [8, 4, 2]\n",
    "images_q = {b: quantize(IMG, b) for b in bit_depths}\n",
    "\n",
    "fig = plt.figure(figsize=(14, 8))\n",
    "gs = gridspec.GridSpec(2, 4, figure=fig, hspace=0.4, wspace=0.3)\n",
    "\n",
    "ax = fig.add_subplot(gs[0, 0])\n",
    "ax.imshow(IMG, cmap='gray', vmin=0, vmax=1); ax.set_title('Original'); ax.axis('off')\n",
    "\n",
    "for col, bits in enumerate(bit_depths, 1):\n",
    "    ax = fig.add_subplot(gs[0, col])\n",
    "    ax.imshow(images_q[bits], cmap='gray', vmin=0, vmax=1)\n",
    "    ax.set_title(f'{bits}-bit ({2**bits} lvl)\\nRMS={quant_rms(IMG, bits):.4f}')\n",
    "    ax.axis('off')\n",
    "\n",
    "# Histograms\n",
    "ax = fig.add_subplot(gs[1, 0])\n",
    "ax.hist(IMG.ravel(), bins=256, color='steelblue', alpha=0.7)\n",
    "ax.set_title('Original histogram'); ax.set_xlim(0, 1)\n",
    "\n",
    "for col, bits in enumerate(bit_depths, 1):\n",
    "    ax = fig.add_subplot(gs[1, col])\n",
    "    ax.hist(images_q[bits].ravel(), bins=256, color='steelblue', alpha=0.7)\n",
    "    ax.set_title(f'{bits}-bit histogram'); ax.set_xlim(0, 1)\n",
    "\n",
    "plt.suptitle('Uniform quantization — image quality and histograms', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "691cdb2d",
   "metadata": {},
   "source": [
    "## 2.2 Lloyd–Max Quantization\n",
    "\n",
    "Uniform bins are optimal **only for uniform distributions**.\n",
    "Lloyd–Max adapts bin sizes to the image histogram by iterating two rules:\n",
    "\n",
    "1. **Centroid step:** $q_k = \\frac{\\int_{z_k}^{z_{k+1}} z\\, p(z)\\, dz}{\\int_{z_k}^{z_{k+1}} p(z)\\, dz}$\n",
    "\n",
    "2. **Midpoint step:** $z_k = \\frac{1}{2}(q_{k-1} + q_k)$"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "72eb4d19",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Lloyd–Max quantizer (histogram-driven) ───────────────────────────────────\n",
    "def lloyd_max_histogram(img, n_levels=8, n_iter=50, n_bins=1024, eps=1e-7):\n",
    "    \"\"\"Lloyd-Max quantizer using the image histogram.\"\"\"\n",
    "    vals = img.ravel().astype(np.float64)\n",
    "    hist_counts, bin_edges = np.histogram(vals, bins=n_bins, range=(0, 1))\n",
    "    bin_centres = 0.5 * (bin_edges[:-1] + bin_edges[1:])\n",
    "    p = hist_counts.astype(np.float64)\n",
    "\n",
    "    # Initialise: uniform boundaries\n",
    "    bounds = np.linspace(0, 1, n_levels + 1)\n",
    "\n",
    "    for it in range(n_iter):\n",
    "        # Centroid step\n",
    "        levels = np.empty(n_levels)\n",
    "        for k in range(n_levels):\n",
    "            mask = (bin_centres >= bounds[k]) & (bin_centres < bounds[k + 1])\n",
    "            mass = p[mask].sum()\n",
    "            levels[k] = (p[mask] * bin_centres[mask]).sum() / mass if mass > eps else \\\n",
    "                        0.5 * (bounds[k] + bounds[k + 1])\n",
    "        # Midpoint step\n",
    "        new_bounds = np.concatenate([[0], 0.5 * (levels[:-1] + levels[1:]), [1]])\n",
    "        if np.max(np.abs(new_bounds - bounds)) < eps:\n",
    "            break\n",
    "        bounds = new_bounds\n",
    "\n",
    "    # Apply to image\n",
    "    idx = np.digitize(vals, bounds[1:-1])\n",
    "    q_img = levels[np.clip(idx, 0, n_levels - 1)].reshape(img.shape)\n",
    "    return q_img.astype(np.float32), bounds, levels\n",
    "\n",
    "# Run Lloyd–Max at the same number of levels as 3-bit uniform\n",
    "n_levels = 8\n",
    "lm_img, bounds, levels = lloyd_max_histogram(IMG, n_levels=n_levels)\n",
    "uni_img = quantize(IMG, 3)\n",
    "\n",
    "rms_uni = float(np.sqrt(np.mean((IMG - uni_img)**2)))\n",
    "rms_lm  = float(np.sqrt(np.mean((IMG - lm_img)**2)))\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(14, 4))\n",
    "axes[0].imshow(IMG,     cmap='gray', vmin=0, vmax=1); axes[0].set_title('Original')\n",
    "axes[1].imshow(uni_img, cmap='gray', vmin=0, vmax=1)\n",
    "axes[1].set_title(f'Uniform 3-bit\\nRMS = {rms_uni:.4f}')\n",
    "axes[2].imshow(lm_img,  cmap='gray', vmin=0, vmax=1)\n",
    "axes[2].set_title(f'Lloyd–Max (8 lvl)\\nRMS = {rms_lm:.4f}')\n",
    "for ax in axes: ax.axis('off')\n",
    "plt.suptitle(f'Lloyd–Max reduces RMS by {(1 - rms_lm/rms_uni)*100:.0f}% '\n",
    "             f'at the same number of levels', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d2397671",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Decision boundaries on the image histogram ──────────────────────────────\n",
    "fig, ax = plt.subplots(figsize=(11, 4))\n",
    "ax.hist(IMG.ravel(), bins=256, density=True, color='steelblue',\n",
    "        alpha=0.6, label='Image histogram p(z)')\n",
    "\n",
    "# Uniform boundaries\n",
    "uni_bounds = np.linspace(0, 1, n_levels + 1)\n",
    "for i, b in enumerate(uni_bounds[1:-1]):\n",
    "    ax.axvline(b, color='tomato', lw=1, ls='--', alpha=0.7,\n",
    "               label='Uniform boundaries' if i == 0 else None)\n",
    "\n",
    "# Lloyd–Max boundaries and levels\n",
    "for i, b in enumerate(bounds[1:-1]):\n",
    "    ax.axvline(b, color='darkgreen', lw=1.5, ls='-', alpha=0.8,\n",
    "               label='Lloyd–Max boundaries' if i == 0 else None)\n",
    "for i, q in enumerate(levels):\n",
    "    ax.axvline(q, color='gold', lw=1, ls=':',\n",
    "               label='Lloyd–Max levels' if i == 0 else None)\n",
    "\n",
    "ax.set_xlabel('Pixel value z'); ax.set_ylabel('p(z)')\n",
    "ax.set_title('Uniform vs Lloyd–Max: boundary placement on the histogram')\n",
    "ax.legend(fontsize=9); plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "fbd171ca",
   "metadata": {},
   "source": [
    "---\n",
    "### ✏️ Exercise 2 — Lloyd–Max on a Bimodal Image (~15 min)\n",
    "\n",
    "A bimodal histogram (e.g. a silhouette or a brain MRI slice) has two intensity peaks.\n",
    "Uniform quantization wastes levels in the gap between peaks.\n",
    "\n",
    "**Tasks**\n",
    "1. Create a synthetic bimodal image (code skeleton below).\n",
    "2. Quantize it with **uniform 3-bit** and **Lloyd–Max (8 levels)**.\n",
    "3. Plot both results and their histograms.\n",
    "4. **Question:** Where do the Lloyd–Max boundaries cluster? Why does this improve RMS?"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "deb36a34",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Exercise 2: Bimodal image ────────────────────────────────────────────────\n",
    "# Step 1: Create a bimodal test image\n",
    "np.random.seed(42)\n",
    "bimodal = np.zeros((256, 256), dtype=np.float32)\n",
    "bimodal[:128, :] = np.clip(0.25 + 0.06 * np.random.randn(128, 256), 0, 1)\n",
    "bimodal[128:, :] = np.clip(0.75 + 0.06 * np.random.randn(128, 256), 0, 1)\n",
    "\n",
    "# Step 2: Quantize with both methods\n",
    "uni_bimodal = quantize(bimodal, 3)\n",
    "lm_bimodal, lm_bounds_b, lm_levels_b = lloyd_max_histogram(bimodal, n_levels=8)\n",
    "\n",
    "rms_uni_b = float(np.sqrt(np.mean((bimodal - uni_bimodal)**2)))\n",
    "rms_lm_b  = float(np.sqrt(np.mean((bimodal - lm_bimodal)**2)))\n",
    "\n",
    "# Step 3: Plot\n",
    "fig, axes = plt.subplots(2, 3, figsize=(14, 8))\n",
    "axes[0, 0].imshow(bimodal,     cmap='gray', vmin=0, vmax=1); axes[0, 0].set_title('Original (bimodal)')\n",
    "axes[0, 1].imshow(uni_bimodal, cmap='gray', vmin=0, vmax=1); axes[0, 1].set_title(f'Uniform 3-bit\\nRMS={rms_uni_b:.4f}')\n",
    "axes[0, 2].imshow(lm_bimodal,  cmap='gray', vmin=0, vmax=1); axes[0, 2].set_title(f'Lloyd–Max 8 lvl\\nRMS={rms_lm_b:.4f}')\n",
    "\n",
    "for ax in axes[0]: ax.axis('off')\n",
    "\n",
    "# Histograms with boundaries\n",
    "for col, (im, title) in enumerate([(bimodal, 'Original'), (uni_bimodal, 'Uniform'), (lm_bimodal, 'Lloyd–Max')]):\n",
    "    axes[1, col].hist(im.ravel(), bins=256, color='steelblue', alpha=0.6)\n",
    "    axes[1, col].set_title(title + ' histogram'); axes[1, col].set_xlim(0, 1)\n",
    "\n",
    "# Show LM boundaries on the original histogram\n",
    "for b in lm_bounds_b[1:-1]:\n",
    "    axes[1, 0].axvline(b, color='darkgreen', lw=1.5, ls='-', alpha=0.8)\n",
    "\n",
    "plt.suptitle('Exercise 2 — Lloyd–Max on a bimodal distribution', fontsize=13)\n",
    "plt.tight_layout(); plt.show()\n",
    "\n",
    "# TODO: Answer — where do the Lloyd–Max boundaries cluster, and why?"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "14d0854c",
   "metadata": {},
   "source": [
    "---\n",
    "# Section 3 — Interpolation\n",
    "\n",
    "Interpolation estimates pixel values at **non-integer coordinates** — needed for resizing, rotation, warping, and registration.\n",
    "\n",
    "## 3.1 1-D Linear Interpolation\n",
    "\n",
    "Given two samples $Q_0 = f(x_0)$ and $Q_1 = f(x_1)$, the linear estimate at $x$ is:\n",
    "\n",
    "$$f(x) = (1-t)\\,Q_0 + t\\,Q_1 \\quad\\text{where}\\quad t = \\frac{x - x_0}{x_1 - x_0}$$"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "47445f73",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── 1-D linear interpolation: step-by-step ───────────────────────────────────\n",
    "x_nodes = np.array([0.0, 1.0, 3.0, 4.5, 6.0])\n",
    "y_nodes = np.array([0.3, 0.8, 0.2, 0.9, 0.4])\n",
    "\n",
    "def linear_interp_1d(x_nodes, y_nodes, x_query):\n",
    "    \"\"\"Piecewise linear interpolation from the formula on the slides.\"\"\"\n",
    "    out = np.empty_like(x_query)\n",
    "    for i, xq in enumerate(x_query):\n",
    "        idx = np.clip(np.searchsorted(x_nodes, xq) - 1, 0, len(x_nodes) - 2)\n",
    "        x0, x1 = x_nodes[idx], x_nodes[idx + 1]\n",
    "        Q0, Q1 = y_nodes[idx], y_nodes[idx + 1]\n",
    "        t = (xq - x0) / (x1 - x0)\n",
    "        out[i] = (1 - t) * Q0 + t * Q1\n",
    "    return out\n",
    "\n",
    "xq = np.linspace(0, 6, 500)\n",
    "yq = linear_interp_1d(x_nodes, y_nodes, xq)\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(9, 4))\n",
    "ax.plot(xq, yq, 'steelblue', lw=2, label='Linear interpolation')\n",
    "ax.plot(x_nodes, y_nodes, 'ko', ms=8, zorder=5, label='Known samples')\n",
    "for xi, yi in zip(x_nodes, y_nodes):\n",
    "    ax.annotate(f'({xi},{yi})', (xi, yi), textcoords='offset points',\n",
    "                xytext=(5, 8), fontsize=8)\n",
    "ax.set_xlabel('x'); ax.set_ylabel('f(x)')\n",
    "ax.set_title('1-D Piecewise Linear Interpolation'); ax.legend(); ax.grid(alpha=0.3)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "4af1af03",
   "metadata": {},
   "source": [
    "## 3.2 Nearest-Neighbor Interpolation (2-D)\n",
    "\n",
    "$$I'(x,y) = I\\!\\left(\\lfloor x + 0.5 \\rfloor,\\; \\lfloor y + 0.5 \\rfloor\\right)$$\n",
    "\n",
    "Fast ($O(1)$), preserves exact values, but **blocky**. Correct for label maps."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "153f7b86",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Nearest-Neighbor from scratch ────────────────────────────────────────────\n",
    "def nearest_upsample(img, scale):\n",
    "    H, W = img.shape\n",
    "    H_out, W_out = int(H * scale), int(W * scale)\n",
    "    r_src = (np.arange(H_out) + 0.5) / scale - 0.5\n",
    "    c_src = (np.arange(W_out) + 0.5) / scale - 0.5\n",
    "    r_nn = np.clip(np.floor(r_src + 0.5).astype(int), 0, H - 1)\n",
    "    c_nn = np.clip(np.floor(c_src + 0.5).astype(int), 0, W - 1)\n",
    "    return img[r_nn[:, None], c_nn[None, :]]\n",
    "\n",
    "small = IMG[::8, ::8]   # 32×32\n",
    "nn_up = nearest_upsample(small, 8)\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(13, 4))\n",
    "axes[0].imshow(IMG, cmap='gray', vmin=0, vmax=1); axes[0].set_title('Original 256×256')\n",
    "axes[1].imshow(small, cmap='gray', vmin=0, vmax=1); axes[1].set_title('Downsampled 32×32')\n",
    "axes[2].imshow(nn_up, cmap='gray', vmin=0, vmax=1); axes[2].set_title('NN upsampled 256×256')\n",
    "for ax in axes: ax.axis('off')\n",
    "plt.suptitle('Nearest-Neighbor: blocky staircase artefacts', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "c9bec77e",
   "metadata": {},
   "source": [
    "## 3.3 Bilinear Interpolation (2-D)\n",
    "\n",
    "Three linear interpolations: 2 horizontal + 1 vertical.\n",
    "\n",
    "$$f(x,y) = (1-\\alpha)(1-\\beta)\\,Q_{11} + \\alpha(1-\\beta)\\,Q_{21} + (1-\\alpha)\\beta\\,Q_{12} + \\alpha\\beta\\,Q_{22}$$"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "d7cc81f4",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Bilinear: step-by-step visualisation on a 2×2 cell ──────────────────────\n",
    "import matplotlib.patches as mpatches\n",
    "\n",
    "Q00, Q10, Q01, Q11 = 0.1, 0.8, 0.4, 0.6\n",
    "x0, x1, y0, y1 = 0.0, 1.0, 0.0, 1.0\n",
    "xq, yq = 0.4, 0.7\n",
    "\n",
    "alpha = (xq - x0) / (x1 - x0)\n",
    "beta  = (yq - y0) / (y1 - y0)\n",
    "R0 = (1 - alpha) * Q00 + alpha * Q10       # along y0\n",
    "R1 = (1 - alpha) * Q01 + alpha * Q11       # along y1\n",
    "Iq = (1 - beta) * R0 + beta * R1           # vertical\n",
    "\n",
    "fig, ax = plt.subplots(figsize=(5, 5))\n",
    "ax.set_xlim(-0.3, 1.5); ax.set_ylim(-0.3, 1.5); ax.set_aspect('equal')\n",
    "# Grid\n",
    "ax.plot([0, 1, 1, 0, 0], [0, 0, 1, 1, 0], 'k-', lw=1.5)\n",
    "# Corner values\n",
    "for (cx, cy, val) in [(0,0,Q00),(1,0,Q10),(0,1,Q01),(1,1,Q11)]:\n",
    "    ax.plot(cx, cy, 'ko', ms=10)\n",
    "    ax.annotate(f'Q={val}', (cx, cy), textcoords='offset points',\n",
    "                xytext=(8, 8), fontsize=11, fontweight='bold')\n",
    "# Intermediate points\n",
    "ax.plot(xq, 0, 's', color='teal', ms=10)\n",
    "ax.annotate(f'R0={R0:.2f}', (xq, 0), textcoords='offset points',\n",
    "            xytext=(8, -15), fontsize=10, color='teal')\n",
    "ax.plot(xq, 1, 's', color='purple', ms=10)\n",
    "ax.annotate(f'R1={R1:.2f}', (xq, 1), textcoords='offset points',\n",
    "            xytext=(8, 8), fontsize=10, color='purple')\n",
    "# Query point\n",
    "ax.plot(xq, yq, '*', color='red', ms=15, zorder=5)\n",
    "ax.annotate(f'f({xq},{yq})={Iq:.3f}', (xq, yq), textcoords='offset points',\n",
    "            xytext=(12, 0), fontsize=11, color='red', fontweight='bold')\n",
    "# Lines\n",
    "ax.plot([xq, xq], [0, 1], '--', color='steelblue', lw=1.5)\n",
    "ax.set_title(f'Bilinear interpolation step-by-step\\n'\n",
    "             f'α={alpha:.1f}, β={beta:.1f}', fontsize=12)\n",
    "ax.set_xlabel('x'); ax.set_ylabel('y')\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9b901860",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Bilinear from scratch — full image upsample ─────────────────────────────\n",
    "def bilinear_upsample(img, scale):\n",
    "    \"\"\"Bilinear upsampling (pure NumPy, matches the slide derivation).\"\"\"\n",
    "    H, W = img.shape\n",
    "    H_out, W_out = int(H * scale), int(W * scale)\n",
    "    r_src = np.clip((np.arange(H_out) + 0.5) / scale - 0.5, 0, H - 1.0001)\n",
    "    c_src = np.clip((np.arange(W_out) + 0.5) / scale - 0.5, 0, W - 1.0001)\n",
    "    r0 = np.floor(r_src).astype(int); dr = (r_src - r0)[:, None]\n",
    "    c0 = np.floor(c_src).astype(int); dc = (c_src - c0)[None, :]\n",
    "    r1 = np.minimum(r0 + 1, H - 1); c1 = np.minimum(c0 + 1, W - 1)\n",
    "    return ((1-dr)*(1-dc) * img[r0[:,None], c0[None,:]] +\n",
    "            (1-dr)*dc     * img[r0[:,None], c1[None,:]] +\n",
    "            dr*(1-dc)     * img[r1[:,None], c0[None,:]] +\n",
    "            dr*dc         * img[r1[:,None], c1[None,:]]).astype(np.float32)\n",
    "\n",
    "bl_up = bilinear_upsample(small, 8)\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(13, 4))\n",
    "axes[0].imshow(small, cmap='gray', vmin=0, vmax=1); axes[0].set_title('32×32 input')\n",
    "axes[1].imshow(nn_up, cmap='gray', vmin=0, vmax=1); axes[1].set_title('NN 256×256')\n",
    "axes[2].imshow(bl_up, cmap='gray', vmin=0, vmax=1); axes[2].set_title('Bilinear 256×256')\n",
    "for ax in axes: ax.axis('off')\n",
    "plt.suptitle('NN vs Bilinear: bilinear removes the blocky artefacts', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "a2f1197a",
   "metadata": {},
   "source": [
    "## 3.4 Bicubic & Spline Interpolation\n",
    "\n",
    "**Bicubic** uses a 4×4 neighbourhood and a cubic weight function → sharper, but may ring.\n",
    "\n",
    "**Spline** ($C^2$ smooth) is the gold standard for medical image registration."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "9387d75d",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Full comparison: NN / Bilinear / Bicubic / Spline ────────────────────────\n",
    "def resize_method(img, out_shape, method):\n",
    "    zy = out_shape[0] / img.shape[0]; zx = out_shape[1] / img.shape[1]\n",
    "    orders = {'nearest': 0, 'bilinear_scratch': 1, 'bicubic': 3, 'spline': 5}\n",
    "    if method == 'bilinear_scratch':\n",
    "        return bilinear_upsample(img, zy)\n",
    "    return scipy_zoom(img, (zy, zx), order=orders[method]).astype(np.float32)\n",
    "\n",
    "methods = ['nearest', 'bilinear_scratch', 'bicubic', 'spline']\n",
    "labels  = ['Nearest Neighbor', 'Bilinear (scratch)', 'Bicubic', 'Spline (B5)']\n",
    "results = {}; times_ms = {}\n",
    "\n",
    "for m in methods:\n",
    "    t0 = time.perf_counter()\n",
    "    results[m] = resize_method(small, IMG.shape, m)\n",
    "    times_ms[m] = (time.perf_counter() - t0) * 1000\n",
    "\n",
    "fig, axes = plt.subplots(1, 5, figsize=(18, 4))\n",
    "axes[0].imshow(IMG, cmap='gray', vmin=0, vmax=1); axes[0].set_title('Ground truth')\n",
    "for ax, m, lbl in zip(axes[1:], methods, labels):\n",
    "    rmse = float(np.sqrt(np.mean((IMG - results[m])**2)))\n",
    "    ax.imshow(results[m], cmap='gray', vmin=0, vmax=1)\n",
    "    ax.set_title(f'{lbl}\\nRMSE={rmse:.4f}  {times_ms[m]:.0f}ms', fontsize=9)\n",
    "for ax in axes: ax.axis('off')\n",
    "plt.suptitle('Interpolation comparison: 32×32 → 256×256', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "046237d1",
   "metadata": {},
   "source": [
    "---\n",
    "# Section 4 — Image Pyramids & Multi-scale Analysis\n",
    "\n",
    "## 4.1 Gaussian Pyramid\n",
    "\n",
    "$$G_0 = \\text{original}, \\qquad G_{k+1} = \\mathrm{DOWN}(G_k * h_\\sigma)$$\n",
    "\n",
    "Each level: blur → subsample ↓2. Level $k$ has $1/4$ the pixels of level $k-1$.\n",
    "\n",
    "## 4.2 Laplacian Pyramid\n",
    "\n",
    "$$L_k = G_k - \\mathrm{UP}(G_{k+1}), \\qquad L_N = G_N$$\n",
    "\n",
    "Stores **band-pass residuals** (details lost during blurring). Allows **perfect reconstruction**."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "f1910f02",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Gaussian & Laplacian pyramids ────────────────────────────────────────────\n",
    "def build_gaussian_pyramid(img, n=5, sigma=1.2):\n",
    "    pyr = [img.copy()]\n",
    "    for _ in range(n - 1):\n",
    "        pyr.append(gaussian_filter(pyr[-1], sigma)[::2, ::2])\n",
    "    return pyr\n",
    "\n",
    "def upsample2x(img, target_shape):\n",
    "    return scipy_zoom(img, (target_shape[0]/img.shape[0],\n",
    "                            target_shape[1]/img.shape[1]),\n",
    "                      order=1).astype(np.float32)\n",
    "\n",
    "def build_laplacian_pyramid(gpyr):\n",
    "    lpyr = [gpyr[k] - upsample2x(gpyr[k+1], gpyr[k].shape)\n",
    "            for k in range(len(gpyr) - 1)]\n",
    "    lpyr.append(gpyr[-1])\n",
    "    return lpyr\n",
    "\n",
    "def reconstruct(lpyr):\n",
    "    img = lpyr[-1].copy()\n",
    "    for k in range(len(lpyr) - 2, -1, -1):\n",
    "        img = lpyr[k] + upsample2x(img, lpyr[k].shape)\n",
    "    return img\n",
    "\n",
    "# Build pyramids\n",
    "gpyr = build_gaussian_pyramid(IMG, n=5)\n",
    "lpyr = build_laplacian_pyramid(gpyr)\n",
    "\n",
    "# Display Gaussian pyramid\n",
    "fig, axes = plt.subplots(1, 5, figsize=(16, 3.5))\n",
    "for i, g in enumerate(gpyr):\n",
    "    axes[i].imshow(g, cmap='gray', vmin=0, vmax=1)\n",
    "    axes[i].set_title(f'G{i}  {g.shape[0]}×{g.shape[1]}', fontsize=10)\n",
    "    axes[i].axis('off')\n",
    "plt.suptitle('Gaussian Pyramid — progressive blur + downsample', fontsize=13)\n",
    "plt.tight_layout(); plt.show()\n",
    "\n",
    "# Display Laplacian pyramid\n",
    "fig, axes = plt.subplots(1, 5, figsize=(16, 3.5))\n",
    "for i, l in enumerate(lpyr):\n",
    "    if i < len(lpyr) - 1:\n",
    "        vm = np.percentile(np.abs(l), 99)\n",
    "        axes[i].imshow(l, cmap='bwr', vmin=-vm, vmax=vm)\n",
    "        axes[i].set_title(f'L{i} (band-pass)  {l.shape[0]}×{l.shape[1]}', fontsize=10)\n",
    "    else:\n",
    "        axes[i].imshow(l, cmap='gray', vmin=0, vmax=1)\n",
    "        axes[i].set_title(f'L{i} (low-pass base)  {l.shape[0]}×{l.shape[1]}', fontsize=10)\n",
    "    axes[i].axis('off')\n",
    "plt.suptitle('Laplacian Pyramid — band-pass residuals (red = +, blue = −)', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "3d9d16fb",
   "metadata": {},
   "outputs": [],
   "source": [
    "# ── Perfect reconstruction verification ──────────────────────────────────────\n",
    "recon = np.clip(reconstruct(lpyr), 0, 1)\n",
    "rmse = float(np.sqrt(np.mean((IMG - recon)**2)))\n",
    "\n",
    "fig, axes = plt.subplots(1, 3, figsize=(13, 4))\n",
    "axes[0].imshow(IMG,   cmap='gray', vmin=0, vmax=1); axes[0].set_title('Original')\n",
    "axes[1].imshow(recon, cmap='gray', vmin=0, vmax=1)\n",
    "axes[1].set_title(f'Reconstructed from Laplacian\\nRMSE = {rmse:.2e}')\n",
    "axes[2].imshow(np.abs(IMG - recon) * 500, cmap='hot', vmin=0, vmax=1)\n",
    "axes[2].set_title('|Difference| × 500')\n",
    "for ax in axes: ax.axis('off')\n",
    "plt.suptitle('Perfect Reconstruction — errors at machine precision', fontsize=13)\n",
    "plt.tight_layout(); plt.show()"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "786bde1f",
   "metadata": {},
   "source": [
    "---\n",
    "## Lecture Summary\n",
    "\n",
    "| Topic | Key Take-away |\n",
    "|---|---|\n",
    "| Image formation | $I = E \\cdot R$; sensor integrates via PSF convolution |\n",
    "| Fourier / Sampling | Sampling replicates the spectrum; aliasing = spectral overlap |\n",
    "| Nyquist condition | $f_s \\geq 2 f_{\\max}$ ↔ $\\Delta x \\leq 1/(2\\omega_{\\max})$ |\n",
    "| Anti-aliasing | Gaussian low-pass before downsampling ($\\sigma \\approx \\text{factor}/2$) |\n",
    "| Uniform quantization | $\\Delta / \\sqrt{12}$ RMS error; banding below ~4 bits |\n",
    "| Lloyd–Max | Adapts bins to histogram → lower MSE for the same $L$ |\n",
    "| Interpolation | NN (fast, blocky) → Bilinear (smooth) → Bicubic (sharp) → Spline ($C^2$) |\n",
    "| Image pyramids | Gaussian (blur+↓2) and Laplacian (band-pass residual, perfect recon) |"
   ]
  },
  {
   "cell_type": "markdown",
   "id": "2dd96b59",
   "metadata": {},
   "source": [
    "---\n",
    "## References\n",
    "\n",
    "1. **Gonzalez, R.C. & Woods, R.E.** (2018). *Digital Image Processing* (4th ed.). Pearson.\n",
    "2. **Burger, W. & Burge, M.** (2016). *Digital Image Processing: An Algorithmic Introduction Using Java* (2nd ed.). Springer.\n",
    "3. **Burt, P.J. & Adelson, E.H.** (1983). *The Laplacian pyramid as a compact image code.* IEEE Trans. Commun., 3(4).\n",
    "4. **Lloyd, S.P.** (1982). *Least squares quantization in PCM.* IEEE Trans. Inf. Theory, 28(2), 129–137."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "id": "1fd7ae73-d783-4c34-8683-479055512183",
   "metadata": {},
   "outputs": [],
   "source": []
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.11.14"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
