codexaperture-focal-ratio-field-view-sensor-geometry.py
aperture-focal-ratio-field-view-sensor-geometry.py
"""Original central-ray geometry and exact rectilinear angular-field model.
Tested with Python 3.14.4, NumPy 2.3.5, Matplotlib 3.10.7.
Run from _media; the output basename follows this generator's basename.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Arc
def main():
plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
"axes.spines.right": False})
fig = plt.figure(figsize=(11, 6.2), facecolor="white")
grid = fig.add_gridspec(2, 2, width_ratios=[1.15, 1], wspace=0.32, hspace=0.43)
width = 36.0
colors = ["#2364aa", "#c05a13"]
for row, (f, color) in enumerate(zip([50.0, 100.0], colors)):
ax = fig.add_subplot(grid[row, 0])
alpha = np.arctan(width / (2*f))
full_angle = np.degrees(2*alpha)
ax.axhline(0, color="0.65", ls="--", lw=1)
ax.plot([0, 0], [-12.5, 12.5], color="0.45", lw=4)
ax.plot([f, f], [-width/2, width/2], color="black", lw=4)
for sign in [-1, 1]:
x = np.array([-14.0, 0.0, f])
y = sign * x * width / (2*f)
ax.plot(x, y, color=color, lw=2)
assert np.isclose(y[-1], sign*width/2)
ax.add_patch(Arc((0, 0), 30, 30, theta1=-np.degrees(alpha),
theta2=np.degrees(alpha), color=color, lw=1.5))
ax.text(23, 3, r"$\Theta$", color=color)
ax.text(0, 21, "lens centre", ha="center", fontsize=10)
ax.text(f+3, 9, "w = 36 mm", fontsize=10)
ax.annotate("", (0, -23), (f, -23),
arrowprops={"arrowstyle": "<->", "color": "0.4"})
ax.text(f/2, -21, f"f = {f:g} mm", ha="center", fontsize=10)
ax.set(xlim=(-17, 121), ylim=(-28, 29), yticks=[-18, 0, 18],
ylabel="height (mm)", title=f"f = {f:g} mm → Θ = {full_angle:.2f}°")
ax.set_aspect("equal", adjustable="box")
if row == 1:
ax.set_xlabel("axial position (mm); same scale in both panels")
curve = fig.add_subplot(grid[:, 1])
focal_lengths = np.linspace(25, 150, 501)
angles = np.degrees(2*np.arctan(width/(2*focal_lengths)))
curve.plot(focal_lengths, angles, color="#7042a1", lw=2.5)
for f, color in zip([50, 100], colors):
angle = np.degrees(2*np.arctan(width/(2*f)))
curve.scatter([f], [angle], color=color, s=45, zorder=4)
curve.annotate(f"{angle:.2f}°", (f, angle), (f+9, angle+4), color=color)
assert np.isclose(2*f*np.tan(np.radians(angle/2)), width)
curve.set(xlim=(25, 150), ylim=(0, 78), xlabel="focal length f (mm)",
ylabel="full horizontal field Θ (degrees)",
title="36 mm sensor: calculated angular field")
curve.text(0.5, 0.91, r"$\Theta = 2\arctan\!\left(w/(2f)\right)$",
transform=curve.transAxes, ha="center", fontsize=15)
curve.grid(alpha=0.2)
fig.suptitle("Field of view: sensor width and focal length", fontsize=16)
fig.text(0.5, 0.065,
"Central rays in an ideal rectilinear thin-lens model, focused at infinity; sensor in focal plane.",
ha="center", fontsize=10)
fig.text(0.5, 0.028,
"The 25 mm lens opening is unchanged. Calculated geometry, not a measured lens response.",
ha="center", fontsize=10)
fig.subplots_adjust(left=0.075, right=0.97, bottom=0.17, top=0.85)
fig.savefig(Path(__file__).with_suffix(".png").name, dpi=110,
facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()