Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第七章:从一条光线到一束光线

一条光线是一次计算。

一束光线是一幅图像开始形成的雏形。

在第六章中,我们终于让一条光线发生了偏折。它到达了一个球面,找到了该处的局部法线,查到了界面两侧的折射率,并通过向量形式的斯涅尔定律改变了方向。那是第一个完整的折射事件。

但只凭一条光线,一个光学系统很难赢得信任。

一条光线可以告诉我们代码是否在运行。它可以帮助调试求交。它可以表明折射方向正确。它可以暴露符号错误。但它无法告诉我们图像是如何形成的。

要想看到图像的形成,我们需要很多条光线。

不是随意地向透镜扔出一些光线,而是一个经过刻意采样的光束:

穿过光瞳的光线
来自选定视场点的光线
在一个或多个波长下的光线
追踪到像面的光线

当这些光线落到像面上时,它们的横向坐标就成了数据。把这些坐标画出来,你就得到了一个点列图。

本章的核心就是这句话:

点列图不是手绘的。它是被追踪光线的落点坐标的绘图。

这话虽然听上去显而易见,但值得专门强调,因为人们遇到的点列图往往是现成的软件绘图。学习者看到的是点。软件隐藏了光线生成、光瞳采样、表面相互作用以及像面截距。

在这里,我们不会隐藏这些。

我们将自己构建光束。

一个点列图真正包含的内容

点列图是光线在像面上交点的一个散点图。

对于每一条成功穿过光学系统的光线,我们记录:

x_image
y_image

然后把它们画出来。

如果透镜在几何光学上是完美的,来自同一个物点的光线就应该落在一个像点上。光斑会收缩成一个点。真实的透镜做不到这一点。来自光瞳不同部分的光线会由于像差、离焦、视场效应和波长效应而落在稍微不同的位置上。

因此,点列图回答了一个具体问题:

对于这个视场点,
在这个或这组波长下,
通过这个孔径,
采样光线在像面上的落点在哪里?

这个问题包含好几个隐藏的输入量:

视场点
光瞳采样模式
孔径尺寸
波长
像面位置
透镜处方
材料模型
光线追迹算法

如果不清楚这些输入量,就难以解读图形。

一个紧致的光斑通常表明在该视场和波长下几何成像良好。一个宽大的光斑则表明存在模糊。一个沿单一方向拉伸的光斑,根据具体情况,可能暗示着像散、彗差、视场效应或离焦。颜色相互分离的光斑则暗示着色差。

但图形本身并不是原因,而是结果。

其计算过程是:

生成光线
→ 穿过光学系统进行光线追迹
→ 收集像面上的截距
→ 画出截距

这正是我们将构建的内容。

从孔径到光瞳采样

要生成一束光线,我们需要决定光线从何处进入系统。

对于简单的无穷远物体示例,来自同一个视场点的所有光线都以相同的方向进入。光线的区别在于它们通过入瞳的位置,或者在我们简化早期的模型中,在于它们在透镜前的发射面上的起始位置。

我们先来定义一个归一化的光瞳坐标:

(px, py)

其中圆形光瞳满足:

px² + py² <= 1

如果物理光瞳半径为 R_pupil,那么光线的起始坐标可以是:

x = R_pupil px
y = R_pupil py

这样我们就得到了一组穿过圆形孔径的光线起始位置。

对光瞳进行采样有许多方法:

方形网格裁剪至圆形
极坐标网格
圆环与辐条
随机采样
准随机采样
高斯求积

本章中,我们将使用方形网格裁剪至圆形。这不是最优雅的采样方法,但易于检查。这一点现在更重要。

以下是函数:

import numpy as np

def sample_pupil_grid(samples_per_axis, pupil_radius):
    """Return pupil sample points on a square grid clipped to a circle.

    Parameters
    ----------
    samples_per_axis:
        Number of grid points along x and y before circular clipping.

    pupil_radius:
        Physical pupil radius in the same length unit as the lens, usually mm.

    Returns
    -------
    An array of shape (N, 2), containing x, y pupil coordinates.
    """
    coords = np.linspace(-pupil_radius, pupil_radius, samples_per_axis)
    points = []

    for x in coords:
        for y in coords:
            if x*x + y*y <= pupil_radius*pupil_radius:
                points.append((x, y))

    return np.array(points, dtype=float)

测试一下:

pupil_points = sample_pupil_grid(samples_per_axis=9, pupil_radius=5.0)
print(pupil_points.shape)
print(pupil_points[:5])

这会生成一组位于半径为 5 毫米的圆形孔径内的点。

绘制这些采样点:

import matplotlib.pyplot as plt

plt.scatter(pupil_points[:, 0], pupil_points[:, 1])
plt.gca().set_aspect("equal", adjustable="box")
plt.xlabel("pupil x [mm]")
plt.ylabel("pupil y [mm]")
plt.title("Square-grid pupil sampling clipped to a circle")
plt.show()

这张图不是点列图,而是输入的采样模式。

这个区别很重要。

光瞳图显示的是光线从何处进入孔径。点列图显示的是这些光线经过追迹后落在何处。

视场采样:物点在哪

一个光瞳采样告诉我们光线穿过孔径的位置。

一个视场点告诉我们光线来自哪个物点。

对于无穷远物点,视场可以用入射角度来表示。轴上视场的光线沿光轴行进:

θx = 0
θy = 0

轴外视场的光线可能倾斜几度:

θx = 0
θy = 5 degrees

或者,根据坐标约定,视场可以用 x 或 y 来表示。我们将采用一个简单的双角度模型:

theta_x_deg
theta_y_deg

第四章中的方向辅助函数为:

import math

def normalize(v):
    v = np.asarray(v, dtype=float)
    norm = np.linalg.norm(v)

    if norm == 0:
        raise ValueError("Cannot normalize a zero vector.")

    return v / norm

def direction_from_field_angles(theta_x_deg=0.0, theta_y_deg=0.0):
    """Create a ray direction from field angles.

    The ray points toward (tan θx, tan θy, 1).
    This is a convenient object-at-infinity model.
    """
    tx = math.tan(math.radians(theta_x_deg))
    ty = math.tan(math.radians(theta_y_deg))
    return normalize([tx, ty, 1.0])

对于一个给定的视场点,光束中的所有光线都共享这个方向。它们从不同的光瞳坐标出发,但是互相平行。

这就是无穷远物点的近似:

相同视场 → 相同入射方向
不同光瞳坐标 → 不同光线起始位置

这并不是唯一可能的物点模型。对于一个有限远的物体,光线会从一个有限远的物点发出,并通过不同的光瞳位置。但对许多光学示例来说,无穷远物点是很自然的,同时也能让第一个点列图保持可读性。

制作一束光线

现在把视场方向和光瞳坐标结合起来。

我们选择一个位于第一表面前的发射面:

z_start = -20 mm

对于每一个光瞳采样 (x, y),创建一条光线:

position = [x, y, z_start]
direction = direction_from_field_angles(theta_x, theta_y)
wavelength = selected wavelength

代码如下:

def make_ray_bundle(
    pupil_points,
    z_start,
    theta_x_deg=0.0,
    theta_y_deg=0.0,
    wavelength_um=0.5875618,
    intensity=1.0,
):
    """Create a bundle of rays from pupil samples and field angles."""
    direction = direction_from_field_angles(theta_x_deg, theta_y_deg)
    rays = []

    for x, y in pupil_points:
        rays.append(
            Ray(
                position=[x, y, z_start],
                direction=direction,
                wavelength_um=wavelength_um,
                intensity=intensity,
            )
        )

    return rays

创建一束轴上光线:

pupil_points = sample_pupil_grid(samples_per_axis=11, pupil_radius=5.0)

rays = make_ray_bundle(
    pupil_points=pupil_points,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=0.0,
    wavelength_um=0.5875618,
)

print(len(rays))
print(rays[0].position, rays[0].direction)

现在我们有了很多条光线。

它们还没有形成点列图。它们只是输入的光束。

接下来要逐条进行追迹。

复用单片式透镜追迹器

从第六章开始,我们有了一个简单的双凸单片透镜:

front = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="front surface"
)

back = IntersectableSurface(
    radius=-50.0,
    z_vertex=5.0,
    semi_diameter=10.0,
    comment="back surface"
)

image = IntersectableSurface(
    radius=float("inf"),
    z_vertex=50.0,
    semi_diameter=None,
    surface_type="plane",
    comment="image plane"
)

以及一个追迹函数:

def trace_simple_singlet_ray(ray, front, back, image, glass_material):
    n_air = 1.0
    n_glass = glass_material.n(ray.wavelength_um)

    interact_refractive_surface(ray, front, n1=n_air, n2=n_glass)

    if ray.alive:
        interact_refractive_surface(ray, back, n1=n_glass, n2=n_air)

    if ray.alive:
        hit_image = intersect_surface(ray, image)

        if hit_image is None:
            ray.stop("missed image plane")
        else:
            ray.propagate(hit_image.t, refractive_index=n_air)

    return ray

现在将其应用于光束:

def trace_bundle_through_singlet(rays, front, back, image, glass_material):
    traced = []

    for ray in rays:
        traced.append(
            trace_simple_singlet_ray(
                ray,
                front=front,
                back=back,
                image=image,
                glass_material=glass_material,
            )
        )

    return traced

进行追迹:

traced_rays = trace_bundle_through_singlet(
    rays,
    front=front,
    back=back,
    image=image,
    glass_material=N_BK7,
)

现在每条光线要么到达了像面,要么停在了某处。

在绘图之前,我们应当只收集那些存活下来的光线。

收集像面上的截距

点列图需要有效光线在像面上的位置。

def collect_image_points(traced_rays):
    """Return image-plane x, y points from rays that reached the image plane."""
    points = []
    valid_rays = []

    for ray in traced_rays:
        if ray.alive:
            points.append((ray.position[0], ray.position[1]))
            valid_rays.append(ray)

    return np.array(points, dtype=float), valid_rays

使用它:

spot_points, valid_rays = collect_image_points(traced_rays)

print("traced rays:", len(traced_rays))
print("valid rays:", len(valid_rays))
print("spot points:", spot_points.shape)

这就是点列图背后的原始数据。

先别急着绘图。先看看数字。

print(spot_points[:10])

你应该能看到像面上的 x 和 y 位置。

对于一个轴上旋转对称系统,如果像面接近焦点,光斑应该集中在光轴附近。如果像面不在最佳焦点上,光斑可能会形成一个更大的弥散圆。

这不是错误。像面位置正是输入量之一。

绘制第一张点列图

现在可以绘图了。

def plot_spot(points, title="Spot diagram"):
    if len(points) == 0:
        raise ValueError("No valid spot points to plot.")

    plt.scatter(points[:, 0], points[:, 1], s=12)
    plt.axhline(0.0, linewidth=0.5)
    plt.axvline(0.0, linewidth=0.5)
    plt.gca().set_aspect("equal", adjustable="box")
    plt.xlabel("image x [mm]")
    plt.ylabel("image y [mm]")
    plt.title(title)
    plt.show()

plot_spot(spot_points, title="On-axis spot diagram")

就是它了。

这张图初看或许并不起眼,只是一些点。很好。我们不该匆匆掠过。这些点不是装饰。它们是经过了以下过程的光线最终的坐标:

从光瞳采样生成
传播到前表面
折射进入玻璃
传播到后表面
折射进入空气
传播到像面
被记录为 x, y 坐标

点列图是光线追迹流水线可见的终点。

这是我们用自己计算构建的第一张真正的分析图。

中心化的点列图

许多光学软件中的点列图是相对于一个参考点来显示的,比如主光线截距或光斑的质心。这使得模糊的形状更易于观察。

如果绝对的像面位置很大,光斑可能远离原点。中心化有助于我们看到散布。

两种常见的选择是:

减去主光线截距
减去所有有效光线截距的质心

目前,基于质心的中心化很简单:

def center_points_by_centroid(points):
    centroid = points.mean(axis=0)
    return points - centroid, centroid

使用它:

centered_points, centroid = center_points_by_centroid(spot_points)

print("centroid:", centroid)

plot_spot(centered_points, title="On-axis spot diagram, centered by centroid")

中心化之后的图显示的是散布模式。质心则告诉你整个光束落在了哪里。

不要混淆这两者。

绝对的光斑位置对于像面和畸变是有用的。

中心化之后的光斑形状对于模糊和像差检查是有用的。

两者都是数据,回答着不同的问题。

光斑尺寸度量

点列图是视觉化的,但我们常常需要汇总的数字。

一个简单的指标是 RMS 光斑半径。

给定像点 (x_i, y_i) 和参考中心 (x_c, y_c),RMS 半径是:

r_rms = sqrt(mean((x_i - x_c)² + (y_i - y_c)²))

用代码表示:

def rms_spot_radius(points, center=None):
    """Compute RMS spot radius around a center."""
    if len(points) == 0:
        return np.nan

    if center is None:
        center = points.mean(axis=0)

    shifted = points - center
    r2 = shifted[:, 0]**2 + shifted[:, 1]**2
    return math.sqrt(float(np.mean(r2)))

计算它:

rms = rms_spot_radius(spot_points)
print("RMS spot radius [mm]:", rms)

另一个简单指标是最大半径:

def max_spot_radius(points, center=None):
    if len(points) == 0:
        return np.nan

    if center is None:
        center = points.mean(axis=0)

    shifted = points - center
    r = np.sqrt(shifted[:, 0]**2 + shifted[:, 1]**2)
    return float(np.max(r))

print("Max spot radius [mm]:", max_spot_radius(spot_points))

这些数字并不能替代理解。它们只是汇总。它们告诉你,在所选条件下,被追踪光斑的大小。

永远记住隐藏的条件:

是哪个视场
是哪个波长
是哪个孔径
是哪个像面
是哪种光瞳采样

没有这些,一个 RMS 光斑半径就只是一个飘在空中的数字。

主光线与边缘光线

现在我们需要了解两个重要的光线名称。

主光线是从某个视场点发出并通过孔径光阑中心的光线。

边缘光线通常是从轴上物点发出并通过孔径光阑边缘的光线。

这些定义在文字上很简单,但在实际系统中会变得微妙,因为入瞳可能并不物理地位于光阑面上,尤其是在多镜片系统中。对于本章简化后的无穷远物体模型,我们可以采用一个实用的近似:

主光线:
    对于选定的视场,光瞳坐标接近 (0, 0)

边缘光线:
    对于轴上视场,光瞳坐标接近光瞳边缘

这足以学习它们在计算上的作用。

主光线有助于定义轴外视场的像高。它经常被用作中心化点列图的参考。

边缘光线有助于定义孔径行为和初级量。它告诉我们一条边缘光线会如何表现。

让我们在简单模型中创建它们。

def make_chief_ray(z_start, theta_x_deg=0.0, theta_y_deg=0.0, wavelength_um=0.5875618):
    """Simplified chief ray: starts at pupil center."""
    return Ray(
        position=[0.0, 0.0, z_start],
        direction=direction_from_field_angles(theta_x_deg, theta_y_deg),
        wavelength_um=wavelength_um,
    )

def make_marginal_ray(z_start, pupil_radius, wavelength_um=0.5875618):
    """Simplified marginal ray for an on-axis field: starts at pupil edge."""
    return Ray(
        position=[pupil_radius, 0.0, z_start],
        direction=direction_from_field_angles(0.0, 0.0),
        wavelength_um=wavelength_um,
    )

追迹它们:

chief = make_chief_ray(z_start=-20.0, theta_x_deg=0.0, theta_y_deg=3.0)
marginal = make_marginal_ray(z_start=-20.0, pupil_radius=5.0)

trace_simple_singlet_ray(chief, front, back, image, N_BK7)
trace_simple_singlet_ray(marginal, front, back, image, N_BK7)

print("chief image point:", chief.position)
print("marginal image point:", marginal.position)

在真实的设计程序中,主光线和边缘光线的瞄准可能会更复杂。程序可能会在考虑了光阑之前的光学系统后,求解出穿过光瞳中心和孔径边缘的光线。

但其理念不变:

主光线 → 视场参考
边缘光线 → 孔径边缘参考

它们是更大的光线束中特殊的采样。

轴外视场点列图

现在让我们为轴外视场生成一个光斑。

例如:

theta_y = 3 degrees

这意味着所有光线都在 y-z 平面内以倾斜方式进入。在我们的简单模型中,光束从整个光瞳出发,并都以相同的倾斜方向行进。

pupil_points = sample_pupil_grid(samples_per_axis=13, pupil_radius=5.0)

off_axis_rays = make_ray_bundle(
    pupil_points=pupil_points,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=3.0,
    wavelength_um=0.5875618,
)

traced_off_axis = trace_bundle_through_singlet(
    off_axis_rays,
    front=front,
    back=back,
    image=image,
    glass_material=N_BK7,
)

off_axis_points, valid_off_axis = collect_image_points(traced_off_axis)

plot_spot(off_axis_points, title="Off-axis spot diagram, absolute coordinates")

由于视场在轴外,光斑可能会偏离原点。这个偏移是预料之中的,它就是像高。

要检查模糊形状,对其进行中心化:

off_axis_centered, off_axis_centroid = center_points_by_centroid(off_axis_points)

print("off-axis centroid:", off_axis_centroid)
print("off-axis RMS radius:", rms_spot_radius(off_axis_points))

plot_spot(off_axis_centered, title="Off-axis spot diagram, centered")

形状可能不再呈圆形。它可能会拉伸、偏斜,或形成某种暗示着轴外像差的图案。

目前不要过度诊断。我们还没有正式引入彗差、像散、场曲或畸变。第九章将会做这些。现在,知道轴外光线束的行为不一定和轴上光束一样就够了。

点列图给了我们证据。

视场序列:从中心到边缘

光学软件常常显示多个视场点的点列图。这样你就能看到像质从视场中心到边缘是如何变化的。

我们可以做一个小型的版本。

def spot_for_field(theta_y_deg, samples_per_axis=13, pupil_radius=5.0, wavelength_um=0.5875618):
    pupil_points = sample_pupil_grid(samples_per_axis, pupil_radius)

    rays = make_ray_bundle(
        pupil_points=pupil_points,
        z_start=-20.0,
        theta_x_deg=0.0,
        theta_y_deg=theta_y_deg,
        wavelength_um=wavelength_um,
    )

    traced = trace_bundle_through_singlet(rays, front, back, image, N_BK7)
    points, valid = collect_image_points(traced)

    return points, valid

for field_angle in [0.0, 1.5, 3.0, 5.0]:
    points, valid = spot_for_field(field_angle)
    centered, centroid = center_points_by_centroid(points)

    print(
        f"field {field_angle:.1f} deg: "
        f"valid rays={len(valid)}, "
        f"centroid={centroid}, "
        f"RMS={rms_spot_radius(points):.6f} mm"
    )

这已经是一张光学分析表了。

它告诉我们,在我们简化的设置下,被追迹的光斑如何随视场角变化。

我们可以单独绘制每个视场。在书籍文本中,分别绘图通常比把多个视场挤在一张图上更清晰。下面是一次一个视场:

field_angle = 5.0
points, valid = spot_for_field(field_angle)
centered, centroid = center_points_by_centroid(points)

plot_spot(centered, title=f"Centered spot diagram, field = {field_angle} deg")

这种模式正是商业软件自动完成的工作。但现在我们知道了数据流水线。

多色光线束

到目前为止,我们一次只追迹一个波长。

一幅真实的可见光点列图可能包含多个波长。每个波长通过材料模型使用自己的折射率。如果图形按波长对光线着色,色差分离就会变得可见。

让我们创建一个多波长的光束。

def make_polychromatic_bundle(
    pupil_points,
    z_start,
    theta_x_deg=0.0,
    theta_y_deg=0.0,
    wavelengths_um=(0.4861327, 0.5875618, 0.6562725),
):
    rays = []

    for wl in wavelengths_um:
        rays.extend(
            make_ray_bundle(
                pupil_points=pupil_points,
                z_start=z_start,
                theta_x_deg=theta_x_deg,
                theta_y_deg=theta_y_deg,
                wavelength_um=wl,
            )
        )

    return rays

追迹:

wavelengths = [0.4861327, 0.5875618, 0.6562725]
pupil_points = sample_pupil_grid(samples_per_axis=11, pupil_radius=5.0)

poly_rays = make_polychromatic_bundle(
    pupil_points=pupil_points,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=0.0,
    wavelengths_um=wavelengths,
)

traced_poly = trace_bundle_through_singlet(poly_rays, front, back, image, N_BK7)

按波长收集点:

def collect_points_by_wavelength(traced_rays):
    groups = {}

    for ray in traced_rays:
        if not ray.alive:
            continue

        wl = ray.wavelength_um
        groups.setdefault(wl, []).append((ray.position[0], ray.position[1]))

    return {wl: np.array(points, dtype=float) for wl, points in groups.items()}

绘图:

groups = collect_points_by_wavelength(traced_poly)

for wl, points in groups.items():
    plt.scatter(points[:, 0], points[:, 1], s=12, label=f"{wl:.4f} µm")

plt.gca().set_aspect("equal", adjustable="box")
plt.xlabel("image x [mm]")
plt.ylabel("image y [mm]")
plt.title("Polychromatic spot diagram")
plt.legend()
plt.show()

这张图可能显示出微小的波长相关偏移或弥散差异。具体的外观取决于透镜、孔径、视场和像面。

重要的不是这个简单的单片透镜是否呈现出漂亮的图案——它不会。重要的是,色差行为已经通过计算进入了:

ray.wavelength_um
→ material.n(wavelength)
→ 不同的折射角度
→ 不同的像面截距

一张多色点列图并非魔法。它就是把许多单色光线追迹的结果画在一起,或按照某种既定规则组合起来。

像面移动时光斑为何会变化

点列图总是与像面的位置绑定的。

移动像面,同一组出射光线与它相交的位置就会不同。在焦点附近,光斑可能很小。在焦前或焦后,光斑可能会扩大。

这并非显示设置,而是几何关系。

让我们计算不同像面位置下的 RMS 光斑半径。

def rms_for_image_plane(z_image, field_angle=0.0, wavelength_um=0.5875618):
    image_surface = IntersectableSurface(
        radius=float("inf"),
        z_vertex=z_image,
        semi_diameter=None,
        surface_type="plane",
        comment="image plane",
    )

    pupil_points = sample_pupil_grid(samples_per_axis=13, pupil_radius=5.0)

    rays = make_ray_bundle(
        pupil_points=pupil_points,
        z_start=-20.0,
        theta_x_deg=0.0,
        theta_y_deg=field_angle,
        wavelength_um=wavelength_um,
    )

    traced = trace_bundle_through_singlet(
        rays,
        front=front,
        back=back,
        image=image_surface,
        glass_material=N_BK7,
    )

    points, valid = collect_image_points(traced)

    if len(points) == 0:
        return np.nan

    return rms_spot_radius(points)

扫描焦面:

z_values = np.linspace(35.0, 65.0, 61)
rms_values = [rms_for_image_plane(z) for z in z_values]

plt.plot(z_values, rms_values)
plt.xlabel("image plane z [mm]")
plt.ylabel("RMS spot radius [mm]")
plt.title("RMS spot radius versus image plane position")
plt.show()

这张图不是点列图,而是基于光斑尺寸的焦面扫描。

其最小值根据我们选择的 RMS 标准,提示了一个最佳焦面区域。

这是另一个来自软件的教训:

点列图依赖于焦面位置。一个看上去很糟糕的光斑,可能意味着透镜本身很差,也可能意味着像面没有放在分析所期望的位置。

一个真正的光学程序可能会提供多种焦面定义方式。第八章我们将学习近轴焦面和初级量,然后在第九章更仔细地学习像差和最佳焦面。

现在,只需记住:像面是分析的一部分,而不是事后的补充。

光瞳采样密度会改变图形

一张用 9 条光线做的点列图和一张用 169 条光线做的点列图,显示的可能都是同一个系统,但看起来会不同。

底层的光学系统没有变,变的是采样。

我们来比较一下:

for n in [5, 9, 17]:
    pupil_points = sample_pupil_grid(samples_per_axis=n, pupil_radius=5.0)

    rays = make_ray_bundle(
        pupil_points=pupil_points,
        z_start=-20.0,
        theta_y_deg=3.0,
        wavelength_um=0.5875618,
    )

    traced = trace_bundle_through_singlet(rays, front, back, image, N_BK7)
    points, valid = collect_image_points(traced)
    centered, centroid = center_points_by_centroid(points)

    print(
        f"samples_per_axis={n}, "
        f"valid rays={len(valid)}, "
        f"RMS={rms_spot_radius(points):.6f} mm"
    )

    plot_spot(centered, title=f"Centered spot, field 3 deg, grid {n}x{n}")

随着采样密度的增加,大致的形状应当变得更清晰。但更多的采样也会消耗更多的计算。

这是计算光学中一个重要主题的小小预览:

采样选择会影响数值结果和视觉解读

我们将在第十四章讨论采样、归一化和 FFT 网格时,强烈地再次触及这一点。对于点列图,风险已经可见。稀疏的光斑可能隐藏结构。密集的光斑则能揭示结构。一个选择不当的采样模式可能产生误导。

图形只与它背后的采样一样诚实。

更好的光瞳采样器:圆环与角度

裁剪至圆形的方形网格虽然简单,但光学中的光线图常常使用基于圆环的采样。这能更直接地控制径向位置。

这是一个简单的极坐标采样器:

def sample_pupil_polar(num_rings, points_per_ring, pupil_radius, include_center=True):
    """Return pupil points sampled on concentric rings."""
    points = []

    if include_center:
        points.append((0.0, 0.0))

    for ring in range(1, num_rings + 1):
        r = pupil_radius * ring / num_rings
        count = points_per_ring * ring

        for j in range(count):
            phi = 2.0 * math.pi * j / count
            x = r * math.cos(phi)
            y = r * math.sin(phi)
            points.append((x, y))

    return np.array(points, dtype=float)

绘制它:

polar_points = sample_pupil_polar(
    num_rings=4,
    points_per_ring=6,
    pupil_radius=5.0
)

plt.scatter(polar_points[:, 0], polar_points[:, 1])
plt.gca().set_aspect("equal", adjustable="box")
plt.xlabel("pupil x [mm]")
plt.ylabel("pupil y [mm]")
plt.title("Polar pupil sampling")
plt.show()

现在用它追迹:

rays = make_ray_bundle(
    pupil_points=polar_points,
    z_start=-20.0,
    theta_y_deg=3.0,
    wavelength_um=0.5875618,
)

traced = trace_bundle_through_singlet(rays, front, back, image, N_BK7)
points, valid = collect_image_points(traced)
centered, centroid = center_points_by_centroid(points)

plot_spot(centered, title="Centered spot from polar pupil sampling")

这个光斑看起来可能与方形网格版本不同,因为光瞳采样不一样。透镜没有变,变的是采样。

这是一个重要的教训。点列图是对光线行为的采样观察,而非连续的真相本身。

渐晕与被遮挡的光线

并非每条生成的光线都一定能抵达像面。

一条光线可能因为以下原因而终止:

它没有命中表面
它射在了通光口径之外
它发生了全内反射
它没能到达像面

对于点列图,我们通常只绘制那些成功到达像面的光线。但我们也应当对丢失的光线进行计数。

def ray_status_counts(rays):
    counts = {}

    for ray in rays:
        key = "alive" if ray.alive else ray.stop_reason
        counts[key] = counts.get(key, 0) + 1

    return counts

使用它:

counts = ray_status_counts(traced)
print(counts)

如果大量光线被遮挡,单看点列图可能无法获知全貌。一个由很少存活光线构成的小光斑不一定自动就是好消息。它可能意味着大部分孔径被遮挡了。

这正是光学软件常常单独报告渐晕或光线失败率的原因。

点列图回答的是:

存活的光线落在了哪里?

它并没有完全回答:

有多少光线没能到达那里?

你需要两者兼顾。

谨慎地解读一张点列图

一张点列图可以告诉你很多信息,但也可能诱惑你过度解读。

下面是一种谨慎的解读方式。

首先,检查条件:

视场点
波长或波长组
孔径
像面位置
采样模式
参考中心
单位

其次,查看比例尺。一张在图形上看起来很大的光斑,换算成毫米可能很小。一张看起来小的光斑,与像素尺寸或衍射尺度相比可能仍然很大。

第三,问问自己这张图是绝对坐标还是中心化的。绝对坐标显示像面位置。中心化坐标显示弥散情况。

第四,检查是否所有光线都存活了。一个来自严重遮挡光束的干净光斑可能具有误导性。

第五,跨视场和波长进行比较。一个透镜可能在轴上表现良好而在轴外较差;可能在绿光下良好而在蓝光或红光下更差。

第六,记住其局限性:

几何点列图不包含衍射。

最后这一点很重要。

一个完美的几何光斑仍然可以产生有限的衍射图样。反过来,一个小的几何光斑并不自动意味着在每个空间频率上都有出色的 MTF。点列图很有用,但它们并不是成像故事的全部。

本书之所以正朝着 PSF 和 MTF 前进,正是因为几何光线的落点并不足够。

不过现在,点列图是恰当的下一步,它们显示了真实光线做了什么。

点列图与光线扇形图

区分两种常见基于光线的图形是很有用的。

点列图绘制的是像面上的落点:

x_image, y_image

光线扇形图通常绘制的是横向光线误差相对于光瞳坐标的关系:

光瞳坐标 → 光线误差

两者都是用追迹后的光线构建的。它们只是以不同方式组织结果。

点列图问的是:

光线落在了哪里?

光线扇形图问的是:

落点误差如何随光瞳变化?

第九章将使用光线扇形图来更清晰地讨论像差。目前来说,点列图是更简单的视觉结果。

但请记住它们之间的关系:

同样的追迹光线
不同的绘图量

这是光学分析中反复出现的模式。

Optiland 的点列图比较

现在让我们将教学流水线与一个真实的库调用进行比较。

在 Optiland 中,高层的工作流程要简短得多。在构建或加载一个光学系统后,可以通过一个分析对象来生成点列图。

一个紧凑的示例如下:

from optiland.samples.objectives import CookeTriplet
from optiland.analysis import SpotDiagram

lens = CookeTriplet()

spot = SpotDiagram(lens)
spot.view()

对于一个自定义的单片透镜,其工作流程遵循同样的精神:

from optiland import optic
from optiland.analysis import SpotDiagram

lens = optic.Optic()

lens.surfaces.add(index=0, radius=float("inf"), thickness=float("inf"))
lens.surfaces.add(index=1, radius=50.0, thickness=5.0, material="N-BK7", is_stop=True)
lens.surfaces.add(index=2, radius=-50.0, thickness=45.0)
lens.surfaces.add(index=3)

lens.set_aperture(aperture_type="EPD", value=10.0)
lens.fields.set_type("angle")
lens.fields.add(y=0.0)
lens.fields.add(y=3.0)
lens.wavelengths.add(value=0.5876, is_primary=True)

spot = SpotDiagram(lens)
spot.view()

具体的选项和方法签名可能因版本而异,因此请将此视为概念上的比较,而非对每个已安装环境的承诺。

关键的点在于,库调用隐藏了什么。

当你调用:

spot = SpotDiagram(lens)
spot.view()

库必须执行以下这些操作的某种版本:

读取视场
读取波长
读取孔径定义
生成光线采样
穿过所有表面追迹光线
收集像面上的截距
绘制点

这正是我们手动构建的流水线。

不同之处在于,Optiland 提供了完整的框架、更好的表面处理、恰当的系统簿记以及分析工具。我们的代码是一个教学骨架。它的存在是为了让计算可见。

我们不是在替代这个库。我们是在识别当图形出现时,库所执行的那些计算。

为什么我们的图形可能与 Optiland 不完全一致

如果你将我们的点列图与 Optiland 的比较,发现它们不同,不要慌张。

可能的原因有很多:

不同的光瞳采样方式
不同的光线瞄准方式
不同的孔径定义
不同的像面处理
不同的材料数据
不同的波长约定
不同的视场坐标约定
不同的焦点求解
不同的归一化或中心化方式

这并非比较的失败,而是课程的一部分。

光学分析充满各种约定。一张点列图不仅仅是“那张点列图”,而是在特定条件下的一张点列图。

要进行公平比较,你必须匹配:

透镜处方
表面符号
玻璃模型
波长
孔径类型与数值
视场类型与数值
像面位置
光线采样模式
图形中心化约定

这非常多。这也正是理解其计算为何重要的原因。

如果你只拥有最终的图形,你可能不知道是哪个约定导致了差异。

如果你理解背后的流水线,你就知道该去哪里寻找。

一个简洁的光束追迹总结

让我们将本章核心的代码路径汇总在一起。

# 1. Choose pupil sampling.
pupil_points = sample_pupil_grid(samples_per_axis=13, pupil_radius=5.0)

# 2. Choose field and wavelength.
theta_y_deg = 3.0
wavelength_um = 0.5875618

# 3. Generate rays.
rays = make_ray_bundle(
    pupil_points=pupil_points,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=theta_y_deg,
    wavelength_um=wavelength_um,
)

# 4. Trace rays.
traced_rays = trace_bundle_through_singlet(
    rays,
    front=front,
    back=back,
    image=image,
    glass_material=N_BK7,
)

# 5. Collect image-plane intercepts.
spot_points, valid_rays = collect_image_points(traced_rays)

# 6. Center and summarize.
centered_points, centroid = center_points_by_centroid(spot_points)
rms_radius = rms_spot_radius(spot_points)

print("valid rays:", len(valid_rays))
print("centroid:", centroid)
print("RMS spot radius:", rms_radius)

# 7. Plot.
plot_spot(centered_points, title="Centered spot diagram")

这就是一整个小型的点列图流水线。

它之所以简短,是因为前几章已经完成了它们的工作。

这就是精心构建整个链条的乐趣。一旦各个部件就位,分析函数就不再神秘。

本章对完整计算链路的补充

在本书开头,整个链路是:

透镜处方
→ 表面与材料
→ 真实光线追迹
→ 近轴参考
→ 光瞳采样
→ 光程差
→ 光瞳函数
→ 点扩散函数
→ 光学传递函数
→ 调制传递函数

本章填补了“真实光线追迹”的关键部分,并将光瞳采样作为一项刻意的计算操作引入。

我们现在可以这样说:

透镜处方定义表面
材料模型定义 n(λ)
光线对象携带位置、方向和波长
求交找到撞击点
向量斯涅尔计算新方向
光瞳采样生成大量光线
视场采样设定了入射方向
像面截距产生点列图

这是一条从数据到图形的真实计算路径。

还没有 MTF,还没有 PSF,还没有波前。但这个基础已不再抽象。

你可以生成光线,追迹它们,然后绘制它们的落点。

这是光学软件开始让人感觉不那么像神谕,而更像一系列可理解的算法的时刻之一。

生成光线束时的常见错误

让我们把这些陷阱收集起来。

错误 1:混淆光瞳图与点列图

光瞳图显示光线从何处出发或穿过孔径。

点列图显示追迹后的光线落在像面上的何处。

它们不是同一张图。

错误 2:忘记视场方向

对于无穷远物体,不同的视场点意味着不同的入射方向。如果每个光束都使用相同的方向,那么你只在测试一个视场。

错误 3:用不同采样方式比较点列图

一个 5×5 的光瞳网格和一个 21×21 的光瞳网格可能显示出不同的视觉结构。在比较图形之前,先匹配采样方式。

错误 4:忽略被遮挡的光线

存活光线的点列图应结合光线失败计数来阅读。一个经过严重裁剪后很小的光斑可能具有误导性。

错误 5:忘记像面位置

移动像面,光斑就会变化。一个大光斑可能表示离焦,而不仅仅是像差。

错误 6:混淆绝对坐标与中心化坐标

绝对光斑图显示像面位置。中心化光斑图显示弥散情况。要清楚自己正在看的是哪一种。

错误 7:将点列图视为衍射结果

点列图是几何光线的结果。它们不显示衍射模糊。它们很重要,但不是 PSF 或 MTF。

错误 8:在缺乏视场背景的情况下称主光线为“中心光线”

在简单的轴上设置中,主光线可能看起来像是中心光线。但对于轴外视场和真实的光瞳定义,主光线的含义是与孔径光阑和视场点绑定的。

错误 9:对教学用简单透镜过度解读

我们的单片透镜是一个学习系统。它没有被优化。如果光斑看起来很难看,那可能就是正确的结果。

这里的目标是理解计算,而不是假装一个简单的双凸透镜就是现代成像物镜。

现在你应该能做到的事

你现在应该能够解释点列图,而不再说:

软件把它画出来了。

一个更好的回答是:

程序对一个选定的视场和波长,跨光瞳对光线进行采样,利用光线-表面求交和斯涅尔折射,将这些光线追迹通过透镜,记录存活光线在像面上的截距,最后绘制出这些截距。

这句话包含了整章的内容。

你还应该能够识别其主要输入量:

光瞳采样
视场角
波长
孔径
像面
透镜处方
材料模型

并且你应该能够写出一个最简的 Python 版本来生成这张图。

这是一个有意义的里程碑。

前方的路

我们现在已经完成了真实光线追迹的第一个有效版本。

但仅有真实光线追迹还不足以理解一个光学系统。

在下一章,我们将有意倒退回近似。

这听起来可能很奇怪。在辛苦追迹真实光线之后,为什么要回到近轴光学呢?

因为近轴光学给了我们系统的骨架。

真实光线显示实际发生了什么。近轴光线显示初级结构:焦距、像面位置、F 数、放大率以及近轴区域的主要行为。

没有这个骨架,点列图就可能变成孤立的图片。你可能会看到光线弥散开来,但不知道它们是围绕什么弥散的。你可能会看到焦点改变,但不知道初级焦点参考在哪里。你可能会追迹很多光线,却不知道系统的基本光焦度。

所以第八章将引入近轴光学,它不是一条老式的弯路,而是理解的坐标系。

真实光线追迹告诉我们光线实际去了哪里。

近轴光学告诉我们系统最初想要达成什么。

生成验证图

第七章光线束检查生成的 Cooke Triplet 光斑采样图