codex/circular-pupil-point-spread-scale.py

circular-pupil-point-spread-scale.py
"""Circular pupil and inverse PSF scale; writes its PNG to the current directory.

Tested with Python 3.14, NumPy 2.3.5 and Matplotlib 3.10.7.
Scalar, monochromatic, in-focus model, without aberration or obscuration.
Run from _media with MPLBACKEND=Agg and a writable MPLCONFIGDIR.
"""

import json
from pathlib import Path

import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
from matplotlib.patches import Circle
import numpy as np


def bessel(order, z):
    z = np.asarray(z, dtype=float)
    angles = 2 * np.pi * (np.arange(512) + 0.5) / 512
    return np.mean(np.cos(z[..., None] * np.sin(angles) - order * angles), axis=-1)


def amplitude(q):
    q = np.asarray(q, dtype=float)
    safe = np.where(q == 0, 1.0, q)
    return np.where(q == 0, 1.0, 2 * bessel(1, q) / safe)


def main():
    wavelength = 0.550  # Vacuum wavelength, micrometres.
    apertures = [0.25, 0.50]
    zero = 3.8317059702075125
    radii = [zero * wavelength / (2 * np.pi * na) for na in apertures]
    assert abs(radii[0] / radii[1] - 2) < 1e-14
    assert abs(float(amplitude(zero))) < 1e-13
    q = np.linspace(0, 18, 9001)
    profile = amplitude(q) ** 2
    x = np.linspace(-2.1, 2.1, 1401)
    xy = np.linspace(-2.1, 2.1, 401)
    xx, yy = np.meshgrid(xy, xy)
    cmap = LinearSegmentedColormap.from_list("white_blue", ["white", "#bdd8ec", "#18577b"])
    plt.rcParams.update({"font.size": 10, "axes.spines.top": False,
                         "axes.spines.right": False, "figure.facecolor": "white"})
    fig, axes = plt.subplots(2, 3, figsize=(11.4, 7.4), layout="constrained",
                             gridspec_kw={"width_ratios": [1, 1, 1.3]})
    for row, (na, radius) in enumerate(zip(apertures, radii)):
        ax = axes[row, 0]
        ax.add_patch(Circle((0, 0), na, facecolor="#bdd8ec", edgecolor="#18577b", lw=2))
        ax.set(xlim=(-0.65, 0.65), ylim=(-0.65, 0.65), aspect="equal",
               title=f"Pupil: NA = {na:.2f}",
               xlabel=r"$\lambda_0 k_x/(2\pi)$", ylabel=r"$\lambda_0 k_y/(2\pi)$")
        ax.annotate("", (na, 0), (0, 0), arrowprops={"arrowstyle": "->", "color": "#18577b"})
        ax.text(0, -0.09, f"radius {na:.2f}", ha="center", fontsize=9)
        factor = 2 * np.pi * na / wavelength
        image = np.interp(factor * np.hypot(xx, yy), q, profile)
        ax = axes[row, 1]
        ax.imshow(image, extent=(-2.1, 2.1, -2.1, 2.1), origin="lower",
                  vmin=0, vmax=1, cmap=cmap)
        ax.add_patch(Circle((0, 0), radius, fill=False, ls=":", ec="#ce641c", lw=1.5))
        ax.set(title="Point response, same spatial scale", xlabel=r"$x$ (µm)", ylabel=r"$y$ (µm)")
        ax = axes[row, 2]
        line = np.interp(factor * np.abs(x), q, profile)
        ax.plot(x, line, color="#18577b", lw=2.3)
        for sign in [-1, 1]:
            ax.axvline(sign * radius, color="#ce641c", ls=":", lw=1.5)
        ax.set(xlim=(-2.1, 2.1), ylim=(0, 1.08), title=f"First zero: r = {radius:.3f} µm",
               xlabel=r"$x$ (µm)", ylabel="Intensity / own peak")
        ax.grid(alpha=0.2)
    fig.suptitle("A wider circular pupil makes a narrower point response", fontsize=15)
    fig.supxlabel("Ideal scalar model, not measured data. Vacuum wavelength 550 nm; in focus.\n"
                  "Each response has unit peak. Orange dotted circles / lines mark the first zero.", fontsize=10)
    path = Path(__file__).stem + ".png"
    fig.savefig(path, dpi=100, facecolor="white", transparent=False)
    plt.close(fig)
    print(json.dumps({"path": path, "dimensions": [1140, 740],
                      "vacuum_wavelength_um": wavelength, "NA": apertures,
                      "first_zero_radii_um": radii, "inverse_scale_ratio": radii[0] / radii[1],
                      "data": "ideal model"}))


if __name__ == "__main__":
    main()