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

计算光学

Inspiration Overflow

本书说明镜片处方如何经过光线追迹、波前误差、点扩散函数等计算,得到最终的 MTF 曲线。各章同时使用公式和可执行的 Python 示例,明确展示数值约定和计算前提。

Read in English

阅读方式

如果还不熟悉光线追迹或傅里叶光学,建议按目录顺序阅读。每章会使用前文给出的定义和约定。已经掌握几何光学的读者,可以从第 10 章开始阅读波前到 MTF 的计算。

经过测试的 Python 包、依赖锁定文件和复现命令见配套代码

致谢

本书的部分示例和验证测试使用开源光学设计平台 Optiland。感谢 Kramer Harrison 与 Optiland 贡献者以 MIT License 发布该软件。许可原文见第三方许可声明

许可证

正文与图片采用 CC BY 4.0 许可。配套源代码采用 MIT License

第1章:为什么MTF并非来自软件按钮

镜头设计程序会让光学显得简单得令人怀疑。

你构建一个镜头。输入几个曲率半径、厚度、玻璃、波长和视场点。点击一个按钮。一幅图出现了。

也许是一个点列图。也许是光线像差曲线。也许是波前图。也许就是那幅许多光学设计师既期待又畏惧的图:MTF曲线。

这条曲线看起来很有权威。它有坐标轴,有弧矢和子午方向的线条,一边是空间频率,另一边是调制传递值。如果曲线保持得很高,设计看来很有希望。如果曲线过早下降,则某个环节出了问题。这幅图似乎用清晰的工程语言在说话:

这个镜头能很好地保持对比度。
这个镜头在精细细节上损失了对比度。
这个视场点比中心差。
这个波长的表现不同。
这个设计还不够完善。

这些信息都很有用。但问题在于:

看到一条MTF曲线并不等同于理解它是怎么来的。

这并非对光学设计软件的抱怨。优秀的软件会隐藏繁琐的工作,在生产工作中这是一种馈赠。你不想每次调整曲率半径时都要手动计算每一个光线与表面的交点。你不想每次检查衍射性能时都自己写一遍快速傅里叶变换。你希望工具能快速、可靠地完成这些计算。

但学习与生产不同。

对学习而言,这种便利可能变成一堵墙。软件给出了结果,但计算的中间过程却消失了。你可能学会辨认一条好的MTF曲线,却不知道这条曲线是如何构建的。你可能知道PSF代表点扩散函数,OPD代表光程差,MTF代表调制传递函数,但可能仍然不知道一个镜头参数是如何变成这些量的。

这个缺口正是本书要讨论的主题。

我们要打开这个黑盒子。

不是一次性全部打开。那样不公平。这个盒子里装着几何、向量数学、光线追迹、一阶光学、波前、采样、傅里叶变换、归一化、单位和优化。如果我们在第一章就把所有这些抛给读者,那等于什么也没教。我们只是用一个黑盒子换了另一个更吓人的黑盒子。

因此,我们从路线图开始。

你点击MTF按钮后出现的曲线并不是由按钮创造的。它是一条计算链的终点:

镜头参数
→ 表面和材料
→ 实际光线追迹
→ 近轴参考
→ 光瞳采样
→ 光程差
→ 光瞳函数
→ 点扩散函数
→ 光学传递函数
→ 调制传递函数

你现在还不需要理解每一步。眼下只需注意这条链的结构。MTF位于接近末端的位置。它并不是悬在光学系统之上的孤立量度。它依赖于镜头几何、玻璃、波长、孔径、视场点、像面、波前误差以及数值采样方法。

这就是为什么单条MTF曲线既有强大之处,也有危险之处。

强大,因为它将众多效应浓缩成一张易读的图。

危险,因为这种浓缩可能让你忘记底层的机制。

曲线是结果,不是起点

假设我们有一个镜头。

在软件层面,这个镜头可能显示为一个表格:

表面   曲率半径   厚度   材料   半口径
0     无穷远    无穷远  air   —
1      50.0     5.0   N-BK7  10.0
2     -50.0     45.0  air    10.0
3     无穷远    0.0   image  —

这不只是记账。它是计算的起点。

每一行告诉程序光线可能遇到什么样的表面,这个表面在什么位置,光线到达后应该如何偏折,以及下一个表面在何处等待。一个实际的光学设计可能包含更多的表面、非球面形状、孔径、求解、镀膜、非球面项、多个配置和多个波长。但基本思路是一样的:

镜头不只是一张图。它是为计算而准备的一组结构化的输入。

这是我们需要建立的第一个习惯。

当你看到一张镜头布局图时,要问:它是用什么数据产生的?

当你看到一条光线追迹时,要问:光线是怎样被表示的?

当你看到一张点列图时,要问:那些点是从哪里来的?

当你看到一条MTF曲线时,要问:是怎样一组计算创建了它?

这个习惯比本章开头的任何单个公式都更重要。一旦你知道公式属于哪里,学起来会更容易。

计算链的一个小小预览

在接触实际光学系统之前,我们先看一下这条链中刻意简化的一小段。

在后续章节中,我们将根据追迹的光线计算波前误差。我们不会取巧。我们将构建光线模型,与表面求交,计算折射方向,将实际光线与参考光线进行比较,并在整个光瞳上构建OPD。

现在,我们从这条链的半途开始。假设我们已经有了光瞳函数。这意味着在出瞳面上我们知道两件事:

  1. 哪些区域允许光线通过;
  2. 光学系统对这部分光线施加了怎样的相位延迟。

在标量傅里叶光学中,一个简化的光瞳函数可以写为:

P(x, y) = A(x, y) exp(i 2π W(x, y))

这里 A(x, y) 是孔径振幅掩模。在光瞳内部它可以为1,外部为0。W(x, y) 是以波长为单位的波前误差。指数函数将波前误差转为光学相位。

如果觉得这太快了,不用担心。我们会在波前相关章节中仔细回到这一点。现在,我们只想看到一点:PSF和MTF并非神秘之物。它们是可以被计算的。

下面是一个最小化的Python预览:

import numpy as np
import matplotlib.pyplot as plt

# A small square computational grid.
N = 256
u = np.linspace(-1.0, 1.0, N)
X, Y = np.meshgrid(u, u)
rho = np.sqrt(X**2 + Y**2)

# Circular pupil: light passes inside the unit circle.
aperture = rho <= 1.0

# A toy wavefront error in waves.
# This is not a full lens model yet. It is only a simple phase pattern.
defocus_waves = 0.5
W = defocus_waves * (2.0 * rho**2 - 1.0)

# Pupil function: amplitude mask times phase.
pupil = aperture * np.exp(1j * 2.0 * np.pi * W)

# PSF: squared magnitude of the Fourier transform of the pupil function.
amplitude = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(pupil)))
psf = np.abs(amplitude) ** 2
psf = psf / psf.sum()

# OTF: Fourier transform of the PSF.
otf = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(psf)))

# MTF: normalized magnitude of the OTF.
mtf = np.abs(otf)
mtf = mtf / mtf.max()

# A simple horizontal slice through the MTF.
center = N // 2
plt.plot(mtf[center, :])
plt.xlabel("sample index")
plt.ylabel("normalized modulation")
plt.title("A toy MTF slice from a toy pupil function")
plt.show()

这段代码还不是一个镜头设计程序。它没有玻璃库,没有曲面,没有斯涅尔折射,没有像面求解,没有视场角,没有物理频率轴,也没有精细的采样校准。很好。它本来就不该做得超过它的本分。

但它说明了一件重要的事情。

图上最终的曲线不是魔术变出来的。它来自数据。数据来自光瞳。光瞳包含了孔径和相位。相位代表了波前误差。PSF来自光瞳场的傅里叶变换。OTF来自PSF的傅里叶变换。MTF来自OTF的模值。

即使在这个玩具例子中,曲线也有它的来龙去脉。

这就是我们贯穿全书的态度。

当一个结果出现时,我们会追问它是由什么产生的。

软件路径与学习路径

真正的光学设计软件比上面的简短脚本做得要多得多。

它必须知道如何表示一个光学系统。它必须在一系列表面上追迹光线。它必须计算光线与表面的交点。它必须利用局部表面法线应用斯涅尔定律。它必须处理光阑、光瞳、波长、视场点和像面。它必须提供一阶量,如有效焦距和F数。它必须计算像差、波前、PSF、MTF以及优化的评价函数。

开源工具在此很有用,因为它们让我们可以比较两种理解层次:

最小教学代码
实际光学设计库

第一层是我们自己的小型代码。它不完整,但透明。它能让我们看到算法的骨架。

第二层是一个真正的库。它可以处理更多情况,具有更好的内部组织,并将计算与实际的光学设计工作流连接起来。

在本书中,Optiland将作为我们主要的开源参考实现。其当前文档将其描述为一个基于Python的光学设计、分析与优化开源框架,支持近轴、实际和偏振感知光线追迹,波前分析,PSF/MTF分析,散射,优化,可视化以及可微分机器学习工作流。该文档也指出项目仍在积极开发中,而这恰好是我们将原理与某一特定API版本分开的原因。(optiland.readthedocs.io

最后这句话很重要。

Optiland不是本书的主角。

计算链才是主角。

Optiland在这里很有价值,因为它给了我们可以检查、可以执行的机制。我们不是要记住某一个库的API;我们是在学习任何一个严肃的光学设计工具都必定以某种形式执行的计算。

例如,当前的Optiland示例展示了加载示例镜头、绘制布局、追迹光线、创建点列图以及计算PSF和MTF的简洁调用。一个小预览可能像这样:(optiland.readthedocs.io

from optiland.samples.objectives import CookeTriplet
from optiland.analysis import SpotDiagram
from optiland.psf import FFTPSF
from optiland.mtf import FFTMTF

lens = CookeTriplet()

# Draw the optical layout.
lens.draw()

# A geometric ray-based analysis.
spot = SpotDiagram(lens)
spot.view()

# A diffraction-based PSF calculation.
psf = FFTPSF(lens, field=(0, 0), wavelength="primary")
psf.view(projection="2d", num_points=256)

# An MTF calculation.
mtf = FFTMTF(lens)
mtf.view()

这种代码让现代光学计算变得平易近人。但我们应该谨慎。如果我们就此停下,一个简短的库调用就会变成一个新的黑盒子。

因此,我们通常会以两种方式阅读这类代码。

第一种阅读是实践性的:

好,这就是我向库请求PSF或MTF的方式。

第二种阅读是计算性的:

这个库必须已经知道了什么才能回答?

为了让 FFTPSF(lens, field=(0, 0), wavelength="primary") 工作,程序必须能够访问一个定义好的光学系统、一个视场点、一个波长、一个孔径、一种构建波前信息的方法以及一种采样策略。为了让 FFTMTF(lens) 工作,它最终必须生成或使用与PSF或OTF等价的信息,然后将其归一化并显示对比度随空间频率的变化。

调用之所以简短,是因为库已经将工作组织好了。

但工作依然在那里。

MTF实际上表达什么

让我们暂停一下,让物理意义变得更具体一些。

一个完美的黑白条纹图案具有很高的对比度。暗条纹是暗的,亮条纹是亮的。如果一个光学系统对这个图案成像很差,亮条纹会渗入暗条纹。精细的细节会变成一团灰色的糊状。调制降低了。

MTF测量的是在不同空间频率下有多少对比度得以保留。

低空间频率意味着宽大的细节:大字母、粗条纹、图像中缓慢的变化。

高空间频率意味着精细的细节:细线条、小纹理、锐利的边缘。

一个光学系统可能在很好地保留低频对比度的同时损失掉高频对比度。这就是为什么一张图像可能看起来很清晰,但仍缺乏精细细节。这也是为什么用简单的“清晰/不清晰”来评判光学设计是过于粗糙的。

MTF给我们一个更结构化的提问:

在这一视场点,
对这个波长或波长加权,
沿这个方向,
在这个空间频率下,
还剩下多少调制?

这个问题已经充满了条件。不存在自由漂移的MTF。MTF总是针对某一系统、某一视场、某一波长条件,采用某一采样和计算方法。

这也是初学者感到困惑的原因之一。曲线看起来像一个单一对象,但它有着许多隐藏的输入。

这里我们可以放慢脚步。

你现在还不需要记住每一个条件。只需记住,当你看到一条MTF曲线时,你看到的并不是某种绝对意义上的“镜头质量”。你看到的是在给定假设下对镜头的一个计算视图。

这使得曲线更有用,而不是更无用。这意味着我们可以提出更好的问题。

如果MTF在轴外下降,那罪魁是像散?彗差?场曲?采样问题?离焦?像面位置不好?波长加权?真正的设计限制?

仅凭曲线本身不能回答所有这些问题。但一旦我们理解了产生这条曲线的链,我们就可以逆向追踪问题。

第一个黑盒子:镜头参数

每一次计算都始于输入。在光学设计中,最重要的输入就是镜头参数。

镜头参数是以数据形式写下的光学系统。它告诉我们光依次遇到的表面序列。一个简单的表面项可能包含:

曲率半径
到下一个表面的厚度
该表面之后的材料
半口径
圆锥常数
表面类型

在镜头图中,你看到的是弯曲的玻璃。

在参数中,计算机看到的是指令。

一个半径为 R 的球面不仅仅是一个漂亮的边界。它是一个几何对象。当一条光线靠近它时,程序必须求解光线与球面的交点问题。求得交点后,程序必须计算表面法线。然后,它必须利用表面两侧的折射率来计算出射方向。

这意味着最终的MTF曲线取决于许多听起来很基础的早期决定:

我们是如何储存表面的?
我们是如何计算交点的?
在这个波长我们用了哪个折射率?
光阑在哪里?
采样了哪些光线?
像面在哪里?

这就是为什么第2章不会从像质开始。它会从镜头参数开始。

我们将把镜头从图纸中取出,并将其写成一种数据结构。这听起来可能不如MTF那么有吸引力,但这是正确的起点。如果光学系统没有被清晰地表示,那么后续的每一步计算都会变得模糊。

第二个黑盒子:光线追迹

当镜头被表示出来之后,程序就开始追迹光线。

一条光线可以写为:

r(t) = r0 + t d

这里 r0 是起始点,d 是方向矢量,t 是沿光线的距离参数。这个简短的方程是光学计算中沉默的勤劳者之一。

为了将一条光线追迹通过一个镜头,程序反复询问:

这条光线在下一个表面的什么位置碰触?
该点的法线是什么?
该表面前后的折射率是什么?
根据斯涅尔定律,折射后的方向是什么?
光线是否通过了孔径?
它落在了像面的什么位置?

从概念上讲这并不神秘。它就是一系列几何与向量运算。

但在教科书中,这部分很容易被跳过,因为相关代数并不总是优雅的。在软件中也很容易被隐藏,因为用户通常想要的是输出,而不是求交函数。因此,许多学习者会从“斯涅尔定律存在”直接跳到“软件进行光线追迹”,而中间的实际计算却缺失了。

我们不会跳过它。

第4、5、6章是本书的根系。我们将定义一条光线,计算它碰到平面或球面的位置,并计算折射方向。与PSF和MTF相比,这些章节可能显得不够精彩,但这正是黑盒子首次被打开的地方。

如果光线追迹不清楚,那之后的一切都只是仪式。

如果光线追迹清楚了,点列图、光线像差曲线和波前就会变得不那么神秘。

第三个黑盒子:从光线到波前

点列图可以从光线交点构建出来。在光瞳上追迹许多光线,让它们落在像面上,画出这些点。这已经能告诉我们很多关于几何模糊的信息。

但PSF和衍射MTF需要另一种表示形式。

它们需要波前信息。

这是许多学习者能感觉到但说不出的桥梁。几何光学谈论光线。傅里叶光学谈论复振幅、光瞳函数、衍射图案和变换。一个实用的光学设计程序必须把这些世界连接起来。

这种连接通常通过光程和光程差来实现。

大致来说,光程衡量光在具有折射率的介质中传播的相位行程。OPD将实际光程与参考光程进行比较。一旦在整个光瞳上知道了OPD,就可以将其转化为相位。一旦知道了整个光瞳上的相位,我们就能形成光瞳函数。一旦有了光瞳函数,我们就可以计算PSF。

那一刻,前面那段玩具代码就开始合理了。

玩具代码从光瞳函数开始。在全书中,我们将从这个光学系统出发,赢得那个光瞳函数。

这条链将变为:

在镜头中追迹光线
→ 计算光瞳上的光程差
→ 将OPD转化为相位
→ 构建光瞳函数
→ 傅里叶变换得到PSF

初看起来,这好像很繁重。确实很多。但难度并不在于哪一个单一步骤是魔法。难度在于这些步骤属于不同的思维工具箱。

几何给我们光线。

物理光学给我们相位。

数值计算给我们数组和FFT。

光学工程给我们解读。

本书就是将那些工具箱缝合在一起。

第四个黑盒子:从PSF到MTF

一旦我们有了PSF,MTF就近了,但在人的感受中它仍然不是自动的。

PSF告诉我们一个点物体在像中如何扩展。一个理想的点会保持为一个点。一个实际的光学系统则会因衍射、像差、离焦、采样等因素使其扩展。

光学传递函数,即OTF,描述了系统如何传递空间频率内容。在简化的计算术语中:

OTF = PSF的傅里叶变换
MTF = OTF的模值

通常我们会将MTF归一化,使零频值为1。这样我们就得到一条调制从1开始并通常随空间频率增加而下降的曲线。

这种紧凑的关系是MTF在被呈现时会让人觉得简单的原因之一。但这种简单性具有欺骗性。PSF已经包含了之前所有因素导致的后果。MTF继承了这一切。

一条糟糕的MTF曲线绝不仅仅是“MTF不好”。

它可能在告诉你关于像差的信息。或是焦点。或是视场位置。或是孔径。或是波长。或是采样。或是计算设置中的错误。

这就是为什么本书不把MTF作为一开始的一个独立章节。我们从问题开始,但我们不会假装答案是即时的。

我们将在第13章恰当地到达MTF。到那时,这条曲线应该不再感觉像软件的神谕了。它应该感觉像一次你可以检查的计算的最终图表。

本书将有何不同

许多光学书籍在解释原理方面极其出色。许多软件手册在解释命令方面极其出色。两者都有价值。但它们之间有一个缺口。

这个缺口看起来像这样:

我知道公式。
我知道软件按钮。
但我不知道连接这两者的计算。

本书就生活在这个缺口里。

对于每一个主要的思想,我们将使用三个层次。

第一,光学原理:

我们正在解决怎样的物理或几何问题?

第二,计算形式:

哪些变量、方程、数组和算法代表了那个问题?

第三,可执行代码:

我们能否自己写一个小版本,然后用真实工具进行比较?

小版本很重要。它能防止我们把Optiland或任何库作为一个替代的黑盒子。

与真实工具的比较也很重要。它能防止我们编写从未接触实际光学设计的玩具代码。

你可以把这本书想成两条轨道:

轨道A:最小化的Python实现
轨道B:Optiland参考工作流

轨道A教授骨架。

轨道B展示骨架如何存在于一个更完整的机体中。

我们会小心地在两者之间移动。

关于代码示例的提醒

光学代码对约定非常敏感。

单位是重要的。符号约定是重要的。表面索引是重要的。波长单位是重要的。坐标系统是重要的。FFT归一化是重要的。视场是以角度还是物高指定是重要的。频率是每毫米的线对数还是归一化采样索引是重要的。

这意味着在光学中阅读代码示例应当比阅读普通的编程片段更加仔细。

当我们写几行NumPy时,通常我们是在默默地选择一种约定。当我们调用一个库时,这个库早已为我们选好了许多约定。学习计算光学的一个很大部分就是学会提问:

单位是什么?
什么被归一化了?
原点在哪里?
我们在哪一个平面上?
哪个是主波长?
这是哪个视场点?
图中的坐标轴实际意味着什么?

这听起来可能过于琐碎。其实不然。这些问题正决定了你是在理解一幅图,还是仅仅在欣赏它。

稍后,第14章将专门讲解这一类麻烦:采样、归一化、FFT移位、频率轴及单位转换。那一章可能会节省你许多小时的困惑。它不华丽,但它温情。

学完本章后你应该能做到什么

学完本章后,你还无法从一个真实的镜头参数出发完整实现MTF计算。这要求太高了。

但你应该能够说出比下面这句更精确的话:

软件计算MTF。

你应该能说出:

MTF曲线是一条链的终点。系统从一个镜头参数开始。光线通过表面和材料被追迹。程序在某一视场和波长下构建几何与波前信息。光瞳函数导向PSF。PSF导向OTF。OTF的模值给出MTF。

这已经是一大步了。

你已经从结果观察者变为过程思考者。

这种转变会改变你阅读每一张光学图的方式。

一张点列图不再只是一张散点图。它是许多追迹光线落在像面上的记录。

一条光线像差曲线不再只是一条诊断曲线。它是横贯光瞳的横向光线误差的一种结构视面。

一张OPD图不再只是彩色装饰。它是连接光线几何与波相位的桥梁。

一个PSF不再只是一幅模糊图像。它是由光瞳场产生的强度分布。

一条MTF曲线不再只是一个按钮输出。它是图像对比度传递的频域总结。

这就是本书的整个方向。

我们并不是试图让光学设计看起来容易。那是不诚实的。光学设计的要求很高。

但要求高并不等于神秘。

很多神秘感来自缺失的步骤。一旦这些步骤变得可见,这门学科就会变得更可教。当然还是很难,是的。但不再被密封着。

前方的路

在下一章,我们将回到这条链的开端。

在我们追迹光线之前,在我们计算波前之前,在我们再次谈及PSF或MTF之前,我们需要回答一个朴素的问题:

一个镜头参数到底是什么?

不是作为PDF表格。

不是作为一张图。

而是作为数据。

我们将把光学系统拆解为曲率半径、厚度、材料、孔径、波长和视场。然后我们会写一个简单镜头的Python小表示。之后,当一条光线穿过系统时,它将不再是在模糊的示意图中穿行。它将是在我们可以检查的数据中穿行。

那也是计算光学开始的地方。

不是从MTF按钮开始。

而是从使得按钮成为可能的输入开始。

生成的验证图

由第1章计算生成的一个玩具MTF切片

来源与验证说明

本章中面向软件的陈述是版本限定的。配套项目固定于 optiland==0.6.0 ;PyPI单独记录了该版本,与可能独立更新的公共Read the Docs页面分开。本章的核心主张不依赖于某一次Optiland的API调用。它依赖于稳定的计算链:镜头参数、追迹、波前或光瞳表示、PSF、OTF和MTF。

第二章:镜片处方到底是什么

一张镜片图很容易让人喜欢。

你能看到弯曲的玻璃片,看到空气间隙,看到一个光阑。也许还能看到光线在系统中偏折,汇聚在像面附近。一幅图能给你的眼睛提供可抓住的东西。

但图并不是光学软件用来计算的东西。

软件需要一份处方。

起初,镜片处方看起来平淡得令人失望。它通常是一个表格:表面编号、曲率半径、厚度、材料、孔径,或许还有圆锥常数,或许还有非球面系数,或许还有注释。与光线追迹布局或MTF曲线图相比,它几乎显得官僚。

这种平淡正是关键所在。

镜片处方就是用数据写成的光学系统。

一旦系统被写成数据,程序就可以对它做点什么。它可以计算表面位置,可以询问光线与下一个表面在何处相交,可以在某个波长下查找折射率,可以判断光线是否被孔径阻挡,可以追迹许多光线到像面。继而,它可以构建点列图、波前图、PSF图像和MTF曲线。

所以,在我们追迹光线之前,在我们计算OPD之前,在我们接触傅里叶变换之前,我们需要学会一项虽不显眼却至关重要的技能:

我们需要将镜片处方视为一个可计算对象来读取。

本章讨论的就是这个对象。

不是漂亮的布局图,不是最终的图像质量,仅仅是让后续所有计算成为可能的数据结构。

这听起来可能只是小小一步,其实不然。如果处方不清楚,后续一切都会变得不清楚。一个错误的表面半径、一个被误解的厚度,或一个错位的材料分配,都可能悄悄毒害整个计算链。

在这里我们可以很明确。我们不是在死记硬背专业的镜头设计表格;我们是在把一块玻璃变成Python程序可用的数据。

处方是一个序列

大多数入门光学图让镜头看起来像一个空间中的物体。而处方则让它看起来像一个序列。

光线穿过一个表面,然后是下一个,再下一个。在序列式光学系统中,表面沿着光路排列。程序最初看到的并不是人眼的“一个镜头”,而是更接近于:

surface 0 → surface 1 → surface 2 → surface 3 → ...

每个表面告诉程序:

我在哪里?
我是什么形状?
我后面是什么介质?
下一个表面有多远?
此处的可用孔径有多大?

对于一个简单的单透镜,一个最简的表格可能长这样:

surface   radius      thickness     material     semi-diameter     note
0         infinity    infinity      air          —                 object
1         +50.0       5.0           N-BK7        10.0              front lens surface
2         -50.0       45.0          air          10.0              back lens surface
3         infinity    0.0           air          —                 image plane

这已经足够解释几条重要的惯例。

第一行通常是物面。如果物体在无穷远,半径和厚度可以表示为无穷大。这并不意味着软件要去画一个无限大的面,而是表示入射光线要按物在无穷远的约定来处理。

表面1是第一片物理玻璃表面。在本例中它的半径是正的。它的厚度是“5.0”,意味着从表面1到表面2沿光轴的距离是5.0个单位。如果我们使用毫米,这就是5.0毫米。

表面1列出的材料是N-BK7。在常见的序列处方约定中,这意味着表面1之后的介质是N-BK7。换句话说,一旦光线穿过表面1,它就在N-BK7中传播,直到到达表面2。

表面2的材料是air,意味着穿过表面2后,光线离开玻璃,在空气中向像面传播。

表面3是像面。它在序列中依然被当作一个表面处理,即使它可能不是一片物理玻璃。

这是第一个有用的思维转变:

像面不仅仅是“图结束的地方”。在计算中,它是又一个目标表面,可以在那里评估光线位置。

点列图最终就是这样成为可能。程序通过前面的表面追迹光线,然后问它们落在像面何处。

主要列项

让我们逐一拆解常见的列项。

一个基础的镜片处方常包含如下字段:

radius
thickness
material
semi-diameter
conic constant
surface type

专业软件可能包含更多:镀膜、孔径类型、求解类型、拾取、坐标断点、非球面系数、衍射项、热模型、公差数据,等等。这些我们目前还不需要。

现在,上面的六个字段就足够搭建第一个数据结构。

半径:以人类可读形式表示的表面曲率

半径告诉我们一个表面有多弯。

对于球面,半径就是切出该表面的球体半径。大半径意味着平缓的表面;无穷大半径意味着平面;小半径意味着更陡的弯曲。

在许多光学处方中,半径带有符号。符号的约定取决于坐标系,不同工具可能会仔细定义各自的符号。在本书中,我们将使用常见的序列光学直觉:光线大致沿正z方向传播,半径的符号告诉我们曲率中心位于表面顶点哪一侧。

现在,不要让符号约定成为床底下的怪物。只要在必要的时候,我们会在代码中把它明确化。

半径常被转换为曲率:

c = 1 / R

如果R为无穷大,曲率为零。这正是我们对平面所期望的。

用曲率c代替半径R,许多方程会更简洁,所以这种小转换很常见。

在代码中,我们应温柔地处理无穷大。平面不应该引起除零意外或特例灾难。我们可以写一个辅助函数:

import math

def curvature_from_radius(radius):
    """Return curvature c = 1/R, with planes represented by c = 0."""
    if math.isinf(radius):
        return 0.0
    return 1.0 / radius

print(curvature_from_radius(50.0))        # 0.02
print(curvature_from_radius(float("inf"))) # 0.0

这算不上什么华丽的代码。但它正是那种能让光学程序保持清醒的小约定。

厚度:到下一个表面的距离

厚度是最容易被误解的列项之一。

在序列处方中,厚度通常意味着:

从本面到下一个面的轴向距离。

它并不代表“这个面的厚度”。一个数学表面没有厚度,它是一个边界。厚度列告诉我们,到达下一个边界前要移动多远。

对一个物理透镜元件来说,第一面之后的厚度通常是玻璃的中心厚度。对一个空气间隙,第二面之后的厚度可能是穿过空气到达下一个光学元件或像面的距离。

在我们的简单单透镜中:

surface 1 thickness = 5.0

意味着从前玻璃面到后玻璃面的轴向距离是5.0毫米。

surface 2 thickness = 45.0

意味着从后玻璃面到像面的轴向距离是45.0毫米。

这一约定很重要,因为它定义了各表面的全局位置。

如果表面1位于z = 0,那么表面2就在z = 5,表面3在z = 50

这已经是一个小型的坐标化光学系统。

我们来计算这些位置。

from dataclasses import dataclass
from typing import Optional
import math

@dataclass
class Surface:
    radius: float
    thickness: float
    material_after: str = "air"
    semi_diameter: Optional[float] = None
    conic: float = 0.0
    comment: str = ""

def surface_vertices(surfaces):
    """Return the z-coordinate of each surface vertex."""
    z = 0.0
    vertices = []

    for s in surfaces:
        vertices.append(z)

        # The last image surface may have thickness 0.
        # An infinite object thickness should not move our finite drawing origin.
        if not math.isinf(s.thickness):
            z += s.thickness

    return vertices

surfaces = [
    Surface(radius=float("inf"), thickness=float("inf"), comment="object at infinity"),
    Surface(radius=+50.0, thickness=5.0, material_after="N-BK7",
            semi_diameter=10.0, comment="front lens surface"),
    Surface(radius=-50.0, thickness=45.0, material_after="air",
            semi_diameter=10.0, comment="back lens surface"),
    Surface(radius=float("inf"), thickness=0.0, material_after="air",
            comment="image plane"),
]

# For a simple finite layout, we usually start drawing at the first physical surface.
finite_surfaces = surfaces[1:]
for i, (s, z) in enumerate(zip(finite_surfaces, surface_vertices(finite_surfaces)), start=1):
    print(i, z, s.comment)

这打印了系统有限部分各表面的位置。在计算绘图坐标时,我们跳过了无限远物面那一行,因为它是一个边界条件,而不是一个有限物理表面。

这时,处方已经变成了一个程序可以审视的东西。

材料:该表面之后的介质

材料列告诉我们光线穿过一个表面后进入了什么介质。

这一点值得重复,因为它是常见的错误来源:

在序列处方中,材料通常属于表面之后的空间,而非之前。

以单透镜的表面1为例,表面之后的材料是玻璃。表面2之后是空气。

这意味着每个表面交互需要两个折射率:

n_before → n_after

在表面1,对于进入透镜的光线:

air → N-BK7

在表面2:

N-BK7 → air

本章我们不会计算折射,那在后面。但处方已经包含了所需的信息。

目前,我们可以加入一个非常小的材料模型。它还不能处理真实的色散。第三章会更仔细地处理波长和折射率。此处,我们只想让数据结构知道有不同介质存在。

SIMPLE_INDEX = {
    "air": 1.0,
    "N-BK7": 1.5168,  # approximate value near the yellow helium d-line
}

def refractive_index(material_name, wavelength_um=0.5876):
    """A placeholder material model.

    Chapter 3 will replace this with wavelength-dependent glass behavior.
    """
    try:
        return SIMPLE_INDEX[material_name]
    except KeyError:
        raise ValueError(f"Unknown material: {material_name}")

def media_before_after(surfaces, surface_index, starting_material="air"):
    """Return material before and after a surface in a sequential system."""
    if surface_index == 0:
        before = starting_material
    else:
        before = surfaces[surface_index - 1].material_after

    after = surfaces[surface_index].material_after
    return before, after

for i, s in enumerate(finite_surfaces):
    before, after = media_before_after(finite_surfaces, i)
    print(f"Surface {i+1}: {before} -> {after}")

一个真正的光学库会使用玻璃目录,并根据波长计算折射率。我们的占位模型故意做得很简单。它是一个路标,而不是一个完成态的材料引擎。

要点在于结构:

之后的折射依赖于已编码在处方中的材料转换。

半口径:表面的可用尺寸

半口径是表面孔径的可用半径。如果半口径是10毫米,则通光圆形区域的直径就是20毫米。

人们很容易只把半口径当作绘图参数。不要这样做,它也是光线阻挡参数。

一条光线可能在几何上与一个球面相交,但如果交点落在可用半口径之外,则该光线对于这个表面应被视为被阻挡或无效。

在简单的圆形孔径检查中:

sqrt(x² + y²) <= semi_diameter

或者,更高效地:

x² + y² <= semi_diameter²

代码:

def inside_clear_aperture(x, y, semi_diameter):
    if semi_diameter is None:
        return True
    return x*x + y*y <= semi_diameter*semi_diameter

print(inside_clear_aperture(3.0, 4.0, 5.0))  # True
print(inside_clear_aperture(6.0, 0.0, 5.0))  # False

之后当我们追迹一束光线时,这微小的检查会很重要。有些光线能穿过通光孔径,有些会被截断。最终的点列图、照度分布、PSF和MTF都可能受到孔径定义的影响。

再说一次,后续的图形就始于这里,在这平淡的表格中。

光阑面:控制光束的孔径

并非每一个半口径都是孔径光阑。

每个表面可以有自己的通光孔径,但孔径光阑是那个限制了通过系统的光线锥角的元件。在许多光学系统中,光阑可能是一个物理隔圈。在简单例子中,某片透镜表面本身可能被标记为光阑。

处方经常标记某一个面为光阑:

surface 1: is_stop = True

这不只是画图时的备注。光阑有助于确定入瞳、出瞳、光线采样、F数和哪些光线被认为是成像光束的一部分。

现在,我们可以在Surface类中加入一个is_stop字段。

@dataclass
class Surface:
    radius: float
    thickness: float
    material_after: str = "air"
    semi_diameter: Optional[float] = None
    conic: float = 0.0
    is_stop: bool = False
    comment: str = ""

这还不会计算光瞳,它仅仅记录信息。后续章节会用到它。

这是计算光学中反复出现的模式:

先记录光学数据
再编写消费这些数据的算法

如果数据结构含糊不清,算法就会变得一团糟。

圆锥常数:超越球面的第一步

球面并不是唯一有用的光学表面。

许多镜头使用圆锥面或非球面来控制像差。圆锥常数描述了表面如何偏离球面,变成抛物面、椭球面或双曲面等形状。

旋转对称的圆锥表面矢高可以写为:

z(r) = (c r²) / (1 + sqrt(1 - (1 + k)c²r²))

其中:

r = sqrt(x² + y²)
c = 1 / R
k = conic constant

对于球面,k = 0

此时,你不需要背下矢高方程,只需看它做了什么。它将一个表面处方转换为一个高度函数。给定离光轴的径向距离r,它告诉我们表面相对于其顶点的z矢高。

这很重要,因为光线与表面相交最终会问:

光线与这个实际表面形状在何处相交?

本章我们只计算矢高,还不求解完整的光线与非球面相交。

import numpy as np

def conic_sag(r, radius, conic=0.0):
    """Sag z(r) for a rotationally symmetric conic surface."""
    if math.isinf(radius):
        return np.zeros_like(r, dtype=float)

    c = 1.0 / radius
    inside = 1.0 - (1.0 + conic) * (c * r)**2

    # Numerical guard for values very close to zero.
    inside = np.maximum(inside, 0.0)

    return (c * r**2) / (1.0 + np.sqrt(inside))

r = np.linspace(0.0, 10.0, 6)
print(conic_sag(r, radius=50.0, conic=0.0))

输出值在近轴处很小,向边缘逐渐增大,这就是表面变弯曲的过程。

如果你想可视化它:

import matplotlib.pyplot as plt

r = np.linspace(0.0, 10.0, 200)

plt.plot(r, conic_sag(r, radius=50.0, conic=0.0), label="sphere, R=50")
plt.plot(r, conic_sag(r, radius=50.0, conic=-1.0), label="paraboloid-like, k=-1")
plt.xlabel("radial coordinate r [mm]")
plt.ylabel("sag z [mm]")
plt.title("Conic sag profiles")
plt.legend()
plt.show()

这是一个安静却重要的时刻。表格中的一行已变为数学曲面。曲面已变为一个函数。函数可以被求值。之后,一条光线可以与之相交。

黑箱就是这样开始被打开的。

表面类型:平面、球面、圆锥面、非球面

表面类型告诉程序该使用什么几何模型。

一个最简程序可能只支持:

plane
sphere

实用的设计工具可能支持:

standard spherical surface
conic surface
even asphere
odd asphere
toroidal surface
freeform surface
coordinate break
diffractive surface
mirror
image surface

每种类型意味着不同的几何和不同的相交逻辑。

这就是为什么表面数据结构不应该只存储数字,还应该存储这些数字意味着什么。

对于我们的教学代码,可以从以下开始:

@dataclass
class Surface:
    radius: float
    thickness: float
    material_after: str = "air"
    semi_diameter: Optional[float] = None
    conic: float = 0.0
    is_stop: bool = False
    surface_type: str = "spherical"
    comment: str = ""

如果surface_type"spherical"且半径为无穷大,我们可以将其视为平面。如果是"image",我们知道它就是最终的评估平面。更丰富的程序会用不同的类表示不同的表面类型。对教学来说,一个字段就足够了。

第二章的目的不是完美的光学软件架构,而是让计算意义可见。

一个完整的最小处方对象

现在我们可以把各部分拼起来。

一个镜头系统不只是一列表面,它还有系统级的设置:

aperture definition
field points
wavelengths
units

表面描述的是序列几何,系统设置则描述我们如何照明和评估系统。

让我们编写一个小的LensSystem

from dataclasses import dataclass, field
from typing import List, Optional
import math

@dataclass
class Surface:
    radius: float
    thickness: float
    material_after: str = "air"
    semi_diameter: Optional[float] = None
    conic: float = 0.0
    is_stop: bool = False
    surface_type: str = "spherical"
    comment: str = ""

    @property
    def curvature(self):
        if math.isinf(self.radius):
            return 0.0
        return 1.0 / self.radius

@dataclass
class LensSystem:
    surfaces: List[Surface] = field(default_factory=list)
    aperture_type: str = "EPD"
    aperture_value: float = 10.0
    field_type: str = "angle"
    fields: List[float] = field(default_factory=lambda: [0.0])
    wavelengths_um: List[float] = field(default_factory=lambda: [0.5876])
    units: str = "mm"

    def add_surface(self, surface: Surface):
        self.surfaces.append(surface)

    def finite_vertices(self):
        """Return z positions for finite surfaces after skipping object at infinity."""
        finite = self.surfaces

        if finite and math.isinf(finite[0].thickness):
            finite = finite[1:]

        z = 0.0
        vertices = []
        for s in finite:
            vertices.append(z)
            if not math.isinf(s.thickness):
                z += s.thickness
        return vertices

    def print_prescription(self):
        print(" idx | radius | curvature | thickness | material | semi-dia | stop | comment")
        print("-----+--------+-----------+-----------+----------+----------+------+---------")
        for i, s in enumerate(self.surfaces):
            sd = "" if s.semi_diameter is None else f"{s.semi_diameter:.3g}"
            print(
                f"{i:>4} | "
                f"{s.radius:>7g} | "
                f"{s.curvature:>9.4g} | "
                f"{s.thickness:>9g} | "
                f"{s.material_after:>8} | "
                f"{sd:>8} | "
                f"{str(s.is_stop):>4} | "
                f"{s.comment}"
            )

现在定义我们的单透镜:

lens = LensSystem(aperture_type="EPD", aperture_value=10.0)

lens.add_surface(Surface(
    radius=float("inf"),
    thickness=float("inf"),
    material_after="air",
    surface_type="object",
    comment="object at infinity"
))

lens.add_surface(Surface(
    radius=+50.0,
    thickness=5.0,
    material_after="N-BK7",
    semi_diameter=10.0,
    is_stop=True,
    comment="front lens surface"
))

lens.add_surface(Surface(
    radius=-50.0,
    thickness=45.0,
    material_after="air",
    semi_diameter=10.0,
    comment="back lens surface"
))

lens.add_surface(Surface(
    radius=float("inf"),
    thickness=0.0,
    material_after="air",
    surface_type="image",
    comment="image plane"
))

lens.print_prescription()

print("\nFinite surface vertex positions:")
for z in lens.finite_vertices():
    print(z)

这还不算光线追迹,但它是更基础的东西:

这是一个光学系统的可计算描述。

这个描述现在包含了后续算法所需的最小信息。

第三章将改进材料模型,第四章将定义光线,第五章将使用表面几何计算交点,第六章将使用材料转换计算折射。

这条链已经可见。

为什么物面和像面也是表面

初学者常常想删掉物面和像面行,因为它们不是物理透镜元件。

请抵制这种冲动。

在序列光学中,物面和像面很有用,因为它们使系统变得统一。它们让程序能把光路视为有起点和终点的序列。

物面定义了光线如何起始。如果物体在无穷远,轴上视场点的光线平行于光轴进入。如果物体在有限距离,光线可能从一个有限的物高开始。

像面定义了在哪里评估光线。点列图就是根据像面上的光线坐标建立的。焦面移动可以通过移动像面来表示。优化可能会改变最后的厚度以改善像质。

所以,即使这些行看起来有些刻意,它们发挥着计算上的作用。

它们回答了这两个问题:

光线如何进入系统?
我们在哪里测量它们的结果?

没有这两个答案,镜头就只是漂浮在空间中的玻璃。

处方不是图片

让我们把这个区别说得很清楚。

处方表可以产生一幅图,但它本身并不等同于图。

图是从数据衍生出的可视化结果。处方是数据。

这很重要,因为一幅图可能隐藏细节。它可能不显示精确的半径,可能为了可读性而夸大形状,可能省略孔径限制或圆锥项,可能不显示波长、视场或材料色散。一份处方,如果完整,会保存计算所需的细节。

在代码中,绘图调用应该在建好数据之后发生:

prescription data → layout drawing

而不是反过来。

这也许听起来很明显,但以后可以省去许多困惑。当软件画出一个镜头时,图并不是真相的来源。真相的来源是图背后的光学模型。

Optiland版的同一种思路

我们的小Python结构很有用,因为它把骨架暴露了出来。真正的光学设计库需要一个更完备的模型。

Optiland的文档将Optic类作为定义光学系统的主要接口,并将表面描述为光学元件的构件。它的入门示例通过添加表面、设置孔径、添加视场、添加波长、画出系统并追迹光线来构建一个单透镜。(Optiland)当前速查表展示的是相同的逐面模式:创建一个optic.Optic(),添加物面、透镜和像面,设置入瞳直径、视场类型和波长。(Optiland

我们单透镜的精简Optiland版本如下:

from optiland import optic

lens = optic.Optic()

# Object surface: object at infinity.
lens.surfaces.add(
    index=0,
    radius=float("inf"),
    thickness=float("inf")
)

# Front surface of the singlet.
# The material is the medium after this surface.
lens.surfaces.add(
    index=1,
    radius=50.0,
    thickness=5.0,
    material="N-BK7",
    is_stop=True
)

# Back surface of the singlet.
# After this surface, rays travel in air.
lens.surfaces.add(
    index=2,
    radius=-50.0,
    thickness=45.0
)

# Image surface.
lens.surfaces.add(index=3)

# System-level settings.
lens.set_aperture(aperture_type="EPD", value=10.0)
lens.fields.set_type("angle")
lens.fields.add(y=0.0)
lens.wavelengths.add(value=0.5876, is_primary=True)

lens.draw(num_rays=5)

不要匆忙略过这段代码。

它在做和我们教学结构相同的概念工作。它创建了一个序列式光学系统,添加了表面,给表面后面分配了材料,指定了光阑,设置了孔径、视场和波长,然后画出了系统。

Optiland代码更短,因为库已经知道如何组织光学对象。我们的教学代码更显式,因为我们想看到骨架。

两者都有用。

如果只使用教学代码,我们可能会造出一个永远不接触真实光学软件实践的玩具世界。

如果只使用Optiland调用,我们可能会将一个黑箱替换为另一个黑箱。

所以我们会两条轨道都保留。

Optiland调用有意隐藏的内容

当我们写下:

lens.surfaces.add(index=1, radius=50.0, thickness=5.0, material="N-BK7")

时,很多工作被委派了出去。

库必须存储这个表面,必须决定如何表示几何,必须将本面与下一个面连接,必须知道以后如何查找材料,必须参与绘图、光线追迹、近轴分析和优化。

这是好的软件设计。库不应该让用户手动连接每一个内部细节。

但作为学习者,我们还是应该问:

这一行代码暗示了什么?

它暗示了一个表面顶点,暗示了曲率,暗示了一个介质转换,暗示了到下一个表面的距离,暗示了未来的光线与表面相交,暗示了未来的折射。

一个简短的API调用并不是一次小的光学事件。它是许多后续计算的紧凑表达。

这正是我们要建立的习惯。

一个简单的处方读取器

专业镜头文件有自己的格式。我们不会在这里构建一个完整的文件解析器,但看看处方表与代码多接近是有益的。

假设我们把表面数据写成一个字典列表:

raw_prescription = [
    {
        "radius": float("inf"),
        "thickness": float("inf"),
        "material_after": "air",
        "surface_type": "object",
        "comment": "object at infinity",
    },
    {
        "radius": 50.0,
        "thickness": 5.0,
        "material_after": "N-BK7",
        "semi_diameter": 10.0,
        "is_stop": True,
        "comment": "front lens surface",
    },
    {
        "radius": -50.0,
        "thickness": 45.0,
        "material_after": "air",
        "semi_diameter": 10.0,
        "comment": "back lens surface",
    },
    {
        "radius": float("inf"),
        "thickness": 0.0,
        "material_after": "air",
        "surface_type": "image",
        "comment": "image plane",
    },
]

然后构建一个LensSystem

def build_lens_from_dicts(rows):
    lens = LensSystem()
    for row in rows:
        lens.add_surface(Surface(
            radius=row["radius"],
            thickness=row["thickness"],
            material_after=row.get("material_after", "air"),
            semi_diameter=row.get("semi_diameter"),
            conic=row.get("conic", 0.0),
            is_stop=row.get("is_stop", False),
            surface_type=row.get("surface_type", "spherical"),
            comment=row.get("comment", ""),
        ))
    return lens

lens = build_lens_from_dicts(raw_prescription)
lens.print_prescription()

这很简单,但其含义很大。

镜片处方可以被存储、加载、转换、检查、绘图、追迹、优化和比较。

这就是为什么光学设计软件始于表格。表格不是老旧的界面选择,它们是暴露序列模型的自然方式。

追迹光线前的合理性检查

在程序追迹哪怕一条光线之前,它都应该检查处方有无明显问题。

例如:

是否至少有一个表面?
是否有一个像面?
是否有光阑或孔径定义?
有限厚度是否为非负?
材料是否已知?
半口径(给出时)是否为正?

我们来写几个简单的检查。

def validate_lens(lens):
    errors = []

    if not lens.surfaces:
        errors.append("Lens has no surfaces.")

    if lens.surfaces and lens.surfaces[-1].surface_type != "image":
        errors.append("Last surface is not marked as an image surface.")

    if not any(s.is_stop for s in lens.surfaces):
        errors.append("No stop surface is marked.")

    for i, s in enumerate(lens.surfaces):
        if not math.isinf(s.thickness) and s.thickness < 0:
            errors.append(f"Surface {i} has negative thickness.")

        if s.semi_diameter is not None and s.semi_diameter <= 0:
            errors.append(f"Surface {i} has non-positive semi-diameter.")

        if s.material_after not in SIMPLE_INDEX:
            errors.append(f"Surface {i} uses unknown material: {s.material_after}")

    return errors

errors = validate_lens(lens)

if errors:
    print("Prescription problems:")
    for e in errors:
        print("-", e)
else:
    print("Prescription looks usable for the next step.")

这段代码并不证明光学设计是好的,它只检查数据内部是否可用。

这个区别很重要。

一份合法的处方可能描述了一个很糟糕的镜头。它可能产生巨大的像差,可能并不在你期望的地方聚焦,可能截断太多光线,可能MTF很差。

但在我们能评判性能之前,系统至少必须定义得足够清楚,以便能进行计算。

从处方绘图

从处方可以生成一幅粗略的2D图。我们不会在这里制作专业级布局图,但即使是一张简单的草图也有助于确认符号和位置没有荒谬之处。

目前,我们只画两个镜片表面的矢高轮廓。

import numpy as np
import matplotlib.pyplot as plt

def plot_simple_lens(lens):
    finite_surfaces = lens.surfaces[1:] if math.isinf(lens.surfaces[0].thickness) else lens.surfaces
    z_vertices = lens.finite_vertices()

    for s, z0 in zip(finite_surfaces, z_vertices):
        if s.surface_type == "image":
            y = np.linspace(-12, 12, 2)
            z = np.full_like(y, z0)
            plt.plot(z, y, linestyle="--", label="image plane")
            continue

        if s.semi_diameter is None:
            continue

        y = np.linspace(-s.semi_diameter, s.semi_diameter, 200)
        r = np.abs(y)
        sag = conic_sag(r, s.radius, s.conic)

        # This simple plot assumes sag is measured along +z from the vertex.
        # Later chapters will handle sign conventions more carefully.
        z = z0 + sag
        plt.plot(z, y, label=s.comment or "surface")

    plt.gca().set_aspect("equal", adjustable="box")
    plt.xlabel(f"z [{lens.units}]")
    plt.ylabel(f"y [{lens.units}]")
    plt.title("Very simple lens layout from prescription data")
    plt.legend()
    plt.show()

plot_simple_lens(lens)

这个绘图函数故意做了限制。它没有对所有可能表面正确处理符号约定,没有画出玻璃填充区域,也没有追迹光线。它只是对处方含有几何内容做出的第一个确认。

这对本章来说已经足够。

当你运行一个成熟的光学软件包并调用draw()时,你看到的是同一思想的更强大版本:

surface data → surface geometry → plotted layout

图是跟随处方的。

单位:许多错误的沉默源头

光学处方通常对距离使用毫米,对波长使用微米,但你永远不该默默假设。

一个系统可能存储:

surface radii in mm
thicknesses in mm
semi-diameters in mm
wavelengths in µm
spatial frequency in cycles/mm

这种混合很常见,但需要留意。

如果你使用0.5876作为波长,你的意思是毫米、微米还是纳米?在许多光学设计语境中,0.5876表示微米。但在原生的Python中,一个数字没有单位,它只是一个浮点数。

因此,数据结构应该显式存储单位,或者遵循清晰的规定。

我们的LensSystem存储了:

units = "mm"
wavelengths_um = [0.5876]

这是对未来读者(包括未来的你)的一点点善意。

第十四章会更着力返回单位问题,因为PSF和MTF计算尤其容易被单位错误破坏。现在,我们只需要这个习惯:

绝不让一个裸数字假装是不言自明的。

读取处方时的常见错误

我们早点收集几个错误,免得日后代价高昂。

错误1:认为材料属于表面本身

一个表面是一个边界。材料列通常描述的是该边界之后的介质。

这影响折射。在每个表面,你需要表面之前和之后的折射率。

错误2:认为厚度是每一行的物理厚度

厚度是到下一个面的距离。有时那是玻璃中心厚度,有时是空气间隙,有时是到像面的距离。

错误3:将无穷大视为普通的大数

无穷大半径意味着平面;无穷大物距意味着物在无穷远的行为。这些都是约定,应当刻意处理。

除非你确切知道为什么要这样做,否则不要用随意的巨大数字替代无穷大。

错误4:忽视符号约定

半径符号、传播方向、表面法线方向和矢高符号是连在一起的。一个符号错误可能产生一个看起来合理但折射完全不对的系统。

在光线追迹章节中,我们会把符号约定保持明确。

错误5:混淆半口径和入瞳直径

一个表面的半口径是该表面的可用半径。入瞳直径是一个系统级的孔径量。在某些系统中它们有关联,但不是同一个概念。

错误6:忘记视场和波长

对许多分析来说,没有视场和波长的处方是不完整的。你可能描述了玻璃和表面,但还没有描述系统是如何被使用的。

光线追迹需要光线。光线取决于视场和孔径采样。折射取决于波长。后续的像质计算更取决于两者。

为什么本章对MTF很重要

可能你会觉得我们已经离MTF走得相当远了。其实没有。

本书末尾的MTF曲线依赖于所有这些早期选择。

半径影响折射。厚度影响传播距离。材料影响折射率。半口径和光阑影响哪些光线能通过。视场定义了观察方向。波长改变了相位和折射。像面定义了结果在哪里被测量。

这意味着,早在计算傅里叶变换之前,MTF曲线就已经在此处被塑形了。

这是计算光学的核心教训之一:

像质指标是下游结果。上游数据才是要紧的。

当一个软件包产生MTF图时,它评价的不是一个抽象的“镜头”,而是一个具体的处方在特定系统设置下的表现。

所以,如果你看到一张MTF图,却不了解处方、波长、视场、孔径、焦面位置和计算方法,那么你看到的其实是一个大部分背景被剥离了的结果。

有时,这对于快速的视觉比较来说无妨。但对于理解来说,这不行。

我们构建了什么

我们现在有了一个小而富有意义的光学模型。

它可以存储:

surface radius
thickness to next surface
material after surface
semi-diameter
conic constant
stop flag
surface type
aperture setting
field setting
wavelength setting
units

它可以计算:

curvature from radius
finite surface vertex positions
simple conic sag values
basic aperture checks
basic prescription validation

它还可以与真实库(如Optiland)逐面构建光学系统的方式进行对比。

这已足够迈向下一问题。

眼下,我们的材料模型简单得几乎令人尴尬。它说N-BK7只有一个折射率,就这样了。真实的玻璃并不这样表现。镜头弯曲蓝光和红光的方式不同。同一份处方在不同波长下评价,聚焦位置可能不同,产生的像差也不同。

所以第三章将迈出下一步:

当折射率依赖于波长时,材料列到底意味着什么?

一旦回答了这个问题,处方就不再仅仅是几何了。它会变成一个具有颜色相关行为的光学系统。

而那时,我们终于该准备好定义一条光线了。

生成的验证图表

从第二章处方模型生成的圆锥矢高轮廓图

来源与验证说明

本章中的处方词汇遵循标准的镜头设计实践:半径、厚度、材料、孔径、视场、波长、圆锥数据和像面设置。此简单教学处方并非制造处方,其目的是在开始完整光线追迹之前,使数据模型可审视。完整的出版书目见appendices/SOURCES_AND_FURTHER_READING.md

第3章:材料、波长与折射率

镜头处方里有一个小列,标题是material

在第2章中,那一列看起来很简单。某个表面写着N-BK7,另一个表面写着air。我们把材料当作该表面之后的介质,这样就已经为光线追迹做好了准备。

但材料列不仅仅是一个标签。

它是一个承诺。

当光线到达一个表面时,程序最终必须回答:

表面之前的折射率是多少?
表面之后的折射率是多少?
在当前波长下?

最后一句话就是陷阱。

不是:

折射率是多少?

而是:

在当前波长下,折射率是多少?

这一个条件改变了一切。

同一种玻璃不会把所有的颜色都弯曲同样的量。蓝光和红光看到的折射率略有不同。这种差异称为色散。正是色散使得棱镜能把白光分解为彩色。它也是镜头存在色差的原因之一。一片能把绿光聚焦得很整齐的透镜,可能会把蓝光和红光聚焦到略微不同的位置。

这不是一个旁生细节,它很早就会进入计算。

从第1章开始的完整链条是:

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

本章处于链条的第二环:

表面和材料

在我们能追迹通过玻璃的光线之前,我们需要知道玻璃在数值上意味着什么。在我们能计算折射之前,我们需要n_beforen_after。在我们能比较波长之前,我们需要一个能将波长转换为折射率的函数。

所以本章只有一个主要任务:

将材料列变成一个依赖波长的折射率函数。

我们将从物理概念入手,然后写一个小的 Python 材料模型,接着再和真实光学库处理材料的方式进行对比。

同一种玻璃在不同波长下的数值并不相同

想象一片用 N-BK7 制成的简单单透镜。

如果你问“N-BK7 的折射率是多少?”,你可能会看到一个常见的答案:

n_d ≈ 1.5168

这个数很有用,但还不够完整。

下标是有意义的。n_d中的d指的是大约在 587.6 nm 附近的一条参考谱线。光学玻璃目录中,折射率常常会在几个命名波长处给出。数值1.5168并不意味着“N-BK7 总是 1.5168”。它大致上表示:

N-BK7 在 d 线波长处的折射率大约是 1.5168。

在更短的波长下,比如大约 486.1 nm 的蓝光处,折射率会略微增大。在更长的波长下,比如大约 656.3 nm 的红光处,折射率会略微减小。

这听起来变化很小。从数值上看的确很小。但镜头恰恰是由穿过各个表面后累积起来的微小角度变化构成的。折射率的一个微小变化就能移动焦点,改变像差,并影响像质。

因此,本章的第一条规则很简单:

在光学计算中,一种材料不仅仅是一个名字,它还是一个关于波长的函数。

在代码中,我们想要这种形式:

n = refractive_index(material_name, wavelength_um)

或者,更好的形式是:

n = material.n(wavelength_um)

这正是我们所需的小小的计算对象。

为什么波长单位值得被认真对待

在写公式之前,我们需要定好单位。

光学设计通常使用:

镜头距离:毫米
波长:微米
空间频率:每毫米线对数

这种混合方式是正常的,但如果忘记了,它也会很危险。

587.6 nm 的波长是:

587.6 nm = 0.5876 µm = 0.0005876 mm

同一个物理波长可以写成三种形式。一个 Python 浮点数并不知道你想的是哪一种。

对本书中的材料计算,我们将采用微米作为波长的单位:

wavelength_um = 0.5876

这种约定并非凭空而定。许多光学玻璃色散公式就是用微米来表示波长的。当前 Optiland 的材料文档也明确指出,在调用材料折射率时,波长输入要以微米为单位。(Optiland)

所以,当我们写:

wavelength_um = 0.5876

我们指的是 d 线附近,而不是半毫米,也不是 0.5876 纳米。

这听起来很较真。但并不是。光学中的单位错误常常会产生一段时期内看起来还像那么回事的数字。然后计算结果在三个章节之后失效,而 bug 就藏在一个看起来无害的0.55里面。

还是对自己好一点,把单位写在名字里吧。

故意从错误的模型开始

最简单的材料模型是常数折射率:

class ConstantMaterial:
    def __init__(self, n):
        self.n_value = float(n)

    def n(self, wavelength_um):
        return self.n_value

像这样使用它:

air = ConstantMaterial(1.0)
glass = ConstantMaterial(1.5168)

for wl in [0.4861, 0.5876, 0.6563]:
    print(wl, glass.n(wl))

输出是:

0.4861 1.5168
0.5876 1.5168
0.6563 1.5168

对于真实的玻璃,这个模型在物理上并不正确。它说这种材料对蓝光、黄绿光和红光的折射率都一样。

那为什么要写它呢?

因为它是正确的基线。

许多光学程序都支持某种形式的、具有固定折射率的理想材料。这对测试几何关系、调试光线追迹和构建简单例子都很有用。例如,Optiland 的材料教程就包含了一个IdealMaterial,具有固定的折射率和消光系数。(Optiland)

常数折射率材料之所以不错,并不是因为它简单。只有在忘记我们做了哪些简化时,它才是错的。

这种区别很重要。

在计算中,简化是被允许的。静默的简化才是危险的。

折射率曲线

现在我们想要一种折射率随波长而变的材料。

对于可见光区域的普通光学玻璃,折射率通常随波长增加而减小。蓝光看到的折射率比红光更高,这叫做正常色散。

一个粗糙的玩具模型可以是这样的:

class ToyDispersiveMaterial:
    def __init__(self, n_ref=1.5168, wavelength_ref_um=0.5876, slope=-0.02):
        self.n_ref = float(n_ref)
        self.wavelength_ref_um = float(wavelength_ref_um)
        self.slope = float(slope)

    def n(self, wavelength_um):
        return self.n_ref + self.slope * (wavelength_um - self.wavelength_ref_um)

然后:

toy_glass = ToyDispersiveMaterial()

for wl in [0.4861, 0.5876, 0.6563]:
    print(wl, toy_glass.n(wl))

这样得到的折射率在较短波长下更高,在较长波长下更低。这从趋势上看是有用的,但并不是一个真正的玻璃模型。

我们可以把它画出来:

import numpy as np
import matplotlib.pyplot as plt

toy_glass = ToyDispersiveMaterial()
wavelengths = np.linspace(0.45, 0.70, 200)
indices = [toy_glass.n(wl) for wl in wavelengths]

plt.plot(wavelengths, indices)
plt.xlabel("wavelength [µm]")
plt.ylabel("refractive index")
plt.title("Toy dispersion model")
plt.show()

这张图值得让我们暂停一下。

处方中的材料列已经不再是一个标签,它变成了一条曲线。

之后,当我们追迹光线时,一条蓝光和一条红光穿过同一个镜头表面时,会用到不同的n_after值。这意味着它们会被折射不同的量,从而可能聚焦到不同的位置。

这条曲线就是色度行为进入计算的地方。

Sellmeier 方程

真实的光学玻璃目录通常使用色散公式。其中最常用的之一就是 Sellmeier 方程。

对于一个三项的 Sellmeier 模型:

n²(λ) = 1
      + B₁ λ² / (λ² - C₁)
      + B₂ λ² / (λ² - C₂)
      + B₃ λ² / (λ² - C₃)

其中:

n(λ) = 波长 λ 处的折射率
λ    = 波长,对光学玻璃系数通常以微米为单位
Bᵢ   = Sellmeier B 系数
Cᵢ   = Sellmeier C 系数,通常单位为 µm²

这个公式看起来比它的思想更吓人。它仍然只是一个函数:

波长 → 折射率

系数是通过对实测材料行为进行拟合得到的。不同的玻璃有不同的系数。一旦系数确定下来,程序就可以在许多波长下计算折射率。

我们来把它实现出来。

import math

class SellmeierMaterial:
    def __init__(self, B, C, name="unnamed"):
        self.B = tuple(float(x) for x in B)
        self.C = tuple(float(x) for x in C)
        self.name = name

    def n(self, wavelength_um):
        lam2 = wavelength_um * wavelength_um
        n2 = 1.0

        for B_i, C_i in zip(self.B, self.C):
            n2 += B_i * lam2 / (lam2 - C_i)

        return math.sqrt(n2)

对于 N-BK7,一组常见的 SCHOTT Sellmeier 系数是:

B1 = 1.039612120
B2 = 0.231792344
B3 = 1.010469450

C1 = 0.006000699
C2 = 0.0200179144
C3 = 103.56065300

SCHOTT 的 N-BK7 数据表列出了这些色散常数,并给出了像n_d = 1.51680以及阿贝数ν_d = 64.17这样的数值。(Schott Media)

现在写出这种玻璃:

N_BK7 = SellmeierMaterial(
    B=(1.039612120, 0.231792344, 1.010469450),
    C=(0.006000699, 0.0200179144, 103.56065300),
    name="N-BK7",
)

for wl in [0.4861327, 0.5875618, 0.6562725]:
    print(f"{wl:.7f} µm  n = {N_BK7.n(wl):.7f}")

打印出的具体数值会因四舍五入而略有不同,但你应该能观察到预期的模式:

蓝光/F 线:折射率较高
d 线:     折射率居中
红光/C 线:折射率较低

这种模式就是色散。

绘制真实的色散曲线

现在绘制折射率曲线。

import numpy as np
import matplotlib.pyplot as plt

wavelengths = np.linspace(0.40, 0.75, 400)
indices = [N_BK7.n(wl) for wl in wavelengths]

plt.plot(wavelengths, indices)
plt.xlabel("wavelength [µm]")
plt.ylabel("refractive index")
plt.title("N-BK7 refractive index from Sellmeier equation")
plt.show()

曲线从蓝光向红光方向向下倾斜。

这条曲线的坡度很平缓,但光学系统在意平缓的坡度。一个镜头表面并不会去问一个数值是否显得显著,它只是在斯涅耳定律中使用这个数值。微小的折射率差异会变成微小的角度差异。经过传播之后,这个角度差异就会变成纵向或横向的色差误差。经过多个表面,它就成了设计问题的一部分。

这就是为什么存在消色差双胶合镜。单种玻璃存在色散。将具有不同色散性质的玻璃配对在一起,可以让设计师减小色差。我们现在还不会设计消色差镜,但它们存在的原因已经在这条曲线中清晰可见了。

阿贝数:一个紧凑的色散概括

玻璃目录需要一种紧凑的方式来描述色散。一种常见的概括方式就是阿贝数。

对于 d 线惯例:

V_d = (n_d - 1) / (n_F - n_C)

其中:

n_d = 氦 d 线处折射率,约 587.6 nm
n_F = 氢 F 线处折射率,约 486.1 nm
n_C = 氢 C 线处折射率,约 656.3 nm

Optiland 的材料 API 也使用 d、F、C 谱线来定义同样的V_d。(Optiland)

我们来计算它。

F_LINE_UM = 0.4861327
D_LINE_UM = 0.5875618
C_LINE_UM = 0.6562725

def abbe_number_vd(material):
    n_F = material.n(F_LINE_UM)
    n_d = material.n(D_LINE_UM)
    n_C = material.n(C_LINE_UM)
    return (n_d - 1.0) / (n_F - n_C)

print(abbe_number_vd(N_BK7))

对于 N-BK7,结果应当接近 64.17,具体数值取决于系数的精度和舍入方式。

这个数字意味着什么?

更高的阿贝数意味着更低的色散。更低的阿贝数意味着更强的色散。

冕牌玻璃常常具有相对较高的阿贝数。火石玻璃则往往具有较低的阿贝数和较高的色散。这种区分对于传统的色差校正是很关键的。

但我们应当小心。

阿贝数只是一个概括,而不是完整的折射率模型。它用三个波长来描述一条曲线。这对于分类、比较和近似建模都很有用,但它并不等同于拥有完整的色散曲线。

这是光学计算中反复出现的一个主题:

一个紧凑的参数只有在你还记得它压缩了什么的时候才是有用的。

阿贝数不能替代n(λ)。它只是提供了关于n(λ)的一个重要线索。

波长组与权重

实际的光学设计通常不会只在一个波长下被评估。

一个简单的可见光系统可能会使用三个波长:

蓝:  0.4861 µm
绿:  0.5876 µm
红:  0.6563 µm

一个更贴近实际的成像设计可能会使用多个波长,并为它们赋予权重。权重告诉软件,在某个特定的分析或优化过程中,每个波长有多重要。

一个粗略的例子:

wavelength [µm]   weight
0.4861            0.5
0.5876            1.0
0.6563            0.5

这并不意味着绿色光线“更真实”。它只是说,在设计评估中,该波长被赋予了更高的重要性。

权重可以代表一种简化的光谱分布、传感器响应、明视觉人眼响应、设计优先级,或者仅仅是一个实际的工程选择。其含义取决于具体系统。

对于本书的教学代码,我们可以这样表示波长:

from dataclasses import dataclass

@dataclass
class Wavelength:
    value_um: float
    weight: float = 1.0
    is_primary: bool = False

wavelengths = [
    Wavelength(0.4861327, weight=0.5),
    Wavelength(0.5875618, weight=1.0, is_primary=True),
    Wavelength(0.6562725, weight=0.5),
]

然后我们可以评估材料折射率:

for wl in wavelengths:
    n = N_BK7.n(wl.value_um)
    marker = " primary" if wl.is_primary else ""
    print(f"{wl.value_um:.7f} µm  weight={wl.weight:.2f}  n={n:.7f}{marker}")

这是一个很小的数据结构,但它却是多波长光学计算的起点。

之后,当我们计算点列图时,我们可能会在多个波长下追迹光线,并用不同颜色绘制它们。当我们计算 MTF 时,我们可能会计算加权的多色结果。当优化一个镜头时,评价函数可能会包含多个波长下的误差。

后文的图形依然始于此。

更新第2章的镜头系统

在第2章中,我们的LensSystem将波长存储为一个简单的浮点数列表:

wavelengths_um = [0.5876]

现在我们可以更明确一些:

from dataclasses import dataclass, field
from typing import List, Optional
import math

@dataclass
class Wavelength:
    value_um: float
    weight: float = 1.0
    is_primary: bool = False

@dataclass
class Surface:
    radius: float
    thickness: float
    material_after: str = "air"
    semi_diameter: Optional[float] = None
    conic: float = 0.0
    is_stop: bool = False
    surface_type: str = "spherical"
    comment: str = ""

    @property
    def curvature(self):
        if math.isinf(self.radius):
            return 0.0
        return 1.0 / self.radius

@dataclass
class LensSystem:
    surfaces: List[Surface] = field(default_factory=list)
    wavelengths: List[Wavelength] = field(default_factory=list)
    units: str = "mm"

    def add_surface(self, surface):
        self.surfaces.append(surface)

    def add_wavelength(self, value_um, weight=1.0, is_primary=False):
        self.wavelengths.append(Wavelength(value_um, weight, is_primary))

现在我们需要一个材料库。

class MaterialLibrary:
    def __init__(self):
        self._materials = {}

    def add(self, name, material):
        self._materials[name] = material

    def n(self, name, wavelength_um):
        if name == "air":
            return 1.0

        if name not in self._materials:
            raise ValueError(f"Unknown material: {name}")

        return self._materials[name].n(wavelength_um)

注册 N-BK7:

materials = MaterialLibrary()
materials.add("N-BK7", N_BK7)

再次构建那片单透镜:

lens = LensSystem()

lens.add_surface(Surface(
    radius=float("inf"),
    thickness=float("inf"),
    material_after="air",
    surface_type="object",
    comment="object at infinity",
))

lens.add_surface(Surface(
    radius=+50.0,
    thickness=5.0,
    material_after="N-BK7",
    semi_diameter=10.0,
    is_stop=True,
    comment="front lens surface",
))

lens.add_surface(Surface(
    radius=-50.0,
    thickness=45.0,
    material_after="air",
    semi_diameter=10.0,
    comment="back lens surface",
))

lens.add_surface(Surface(
    radius=float("inf"),
    thickness=0.0,
    material_after="air",
    surface_type="image",
    comment="image plane",
))

lens.add_wavelength(0.4861327, weight=0.5)
lens.add_wavelength(0.5875618, weight=1.0, is_primary=True)
lens.add_wavelength(0.6562725, weight=0.5)

现在对每个物理表面和每个波长,询问材料过渡。

def material_before_after(lens, surface_index, starting_material="air"):
    if surface_index == 0:
        before = starting_material
    else:
        before = lens.surfaces[surface_index - 1].material_after

    after = lens.surfaces[surface_index].material_after
    return before, after

for wl in lens.wavelengths:
    print(f"\nWavelength {wl.value_um:.7f} µm")
    for i, surface in enumerate(lens.surfaces):
        if surface.surface_type in ("object", "image"):
            continue

        before, after = material_before_after(lens, i)
        n_before = materials.n(before, wl.value_um)
        n_after = materials.n(after, wl.value_um)

        print(
            f"surface {i}: {before:>6} n={n_before:.7f} "
            f"→ {after:>6} n={n_after:.7f}"
        )

这仍然不是光线追迹,但它现在已经为光线追迹做好了准备。

对每个表面,每个波长,程序都可以获得n_beforen_after

这正是第6章的折射算法所需要的。

斯涅耳定律正在等待这些数字

我们现在并不推导矢量折射,这值得用单独一章来讲。但我们可以预览一下为什么折射率函数如此重要。

标量形式的斯涅耳定律是:

n₁ sin θ₁ = n₂ sin θ₂

其中:

n₁ = 表面之前的折射率
n₂ = 表面之后的折射率
θ₁ = 相对于表面法线的入射角
θ₂ = 相对于表面法线的折射角

如果n₂随波长变化,那么θ₂也会随波长变化。

让我们算一个很小的例子。假设一束光在空气中以 10 度的入射角进入 N-BK7。

import math

def refracted_angle_deg(theta1_deg, n1, n2):
    theta1 = math.radians(theta1_deg)
    s2 = n1 * math.sin(theta1) / n2

    if abs(s2) > 1.0:
        return None  # total internal reflection

    theta2 = math.asin(s2)
    return math.degrees(theta2)

theta1_deg = 10.0

for wl in [F_LINE_UM, D_LINE_UM, C_LINE_UM]:
    n2 = N_BK7.n(wl)
    theta2 = refracted_angle_deg(theta1_deg, n1=1.0, n2=n2)
    print(f"{wl:.7f} µm  n={n2:.7f}  theta2={theta2:.6f} degrees")

折射角的差异会很小。这没关系,小差异是真实的。

镜头包含弯曲的表面。光线以不同的高度入射,局部的表面法线也会变化,光线在折射后会继续传播。一个表面上的微小角度差异,在另一个表面或像面上就会变成位置上的差异。

这就是色差在计算上的起源。

它并不是后来才以一个神秘的缺陷形式出现的。它始于这里:

n = n(λ)

然后斯涅耳定律使用了这个n值。

一个薄透镜近似中的纵向色差

完整的色度焦距计算需要光线追迹或近轴系统计算。两者我们以后都会学到。但现在,一个薄透镜的预览可以显示出这个方向。

对于一片在空气中的薄透镜,一个粗略的、类似透镜制造者公式的关系是:

1/f ≈ (n - 1) (1/R₁ - 1/R₂)

这个简化形式忽略了厚度,并假设透镜在空气中。对严肃的设计而言它还不够,但用它来看色散的影响已经足够了。

如果n随波长变化,那么f也会随波长变化。

让我们对一个对称的双凸透镜计算这个值,其中:

R₁ = +50 mm
R₂ = -50 mm
def thin_lens_focal_length(n, R1, R2):
    power = (n - 1.0) * (1.0 / R1 - 1.0 / R2)
    return 1.0 / power

R1 = +50.0
R2 = -50.0

for wl in [F_LINE_UM, D_LINE_UM, C_LINE_UM]:
    n = N_BK7.n(wl)
    f = thin_lens_focal_length(n, R1, R2)
    print(f"{wl:.7f} µm  n={n:.7f}  f≈{f:.3f} mm")

你应该能看到,在所有三个波长下,焦距并不完全相同。

这还不是我们最终的模型。它是对一个事实的预览,这个事实光线追迹器稍后将以更直接的方式展示出来:

单透镜并不能把所有波长都聚焦到同一位置。

现在,其原因已经清晰可见:材料曲线进入了折射计算。

材料也可以吸收光

到目前为止,我们只讨论了折射率n

许多材料数据库还会描述消光系数k,它与吸收相关。在复折射率中:

N = n + i k

实部n控制相速度和折射,而k描述损耗。

对于接下来即将学习的几何光线追迹章节,我们将主要使用n。但知道一个更丰富的材料模型也可以包含吸收信息是有益的。Optiland 的材料类既包含折射率方法,也包含消光系数方法,其材料教程演示了如何同时计算随波长变化的nk。(Optiland)

我们现在还不会把吸收构建到光线追迹器里,那样会分散对主链条的注意力。但当稍后我们讨论透射、膜层或非可见光材料时,k就会成为故事的一部分。

现在:

光线方向计算 → 需要 n(λ)
吸收/透射计算 → 可能需要 k(λ)

一步一步来。

Optiland 材料模型

我们的教学代码中有两种材料类型:

ConstantMaterial
SellmeierMaterial

真实的库需要得更多。

当前 Optiland 材料文档描述了好几种材料类型,包括IdealMaterialAbbeMaterialAbbeMaterialEMaterial;它的Material类连接到来自 refractiveindex.info 的数据,而IdealMaterial提供固定折射率的行为,AbbeMaterial则提供基于折射率和阿贝数的模型。(Optiland)

一个最简的、Optiland 风格的材料检查可以像这样:

import numpy as np
import matplotlib.pyplot as plt

from optiland.materials import IdealMaterial, AbbeMaterial, Material

# 固定折射率材料。
ideal = IdealMaterial(n=1.5, k=0)

# 用 n_d 和阿贝数构建的近似玻璃模型。
abbe_glass = AbbeMaterial(n=1.5168, abbe=64.17, model="buchdahl")

# 基于数据库的材料查找。
# 根据已安装的版本和数据库内容,
# 指定 reference 或 catalog 可以帮助消除材料的歧义。
bk7 = Material("N-BK7", reference="Schott")

wavelengths = np.linspace(0.45, 0.70, 200)

plt.plot(wavelengths, ideal.n(wavelengths), label="Ideal n=1.5")
plt.plot(wavelengths, abbe_glass.n(wavelengths), label="Abbe model")
plt.plot(wavelengths, bk7.n(wavelengths), label="N-BK7 database")

plt.xlabel("wavelength [µm]")
plt.ylabel("refractive index")
plt.title("不同材料模型对比")
plt.legend()
plt.show()

这段代码并不是要取代我们的 Sellmeier 实现,它展示的是库层面的同一种思路。

模式是稳定一致的:

材料对象 → n(wavelength)

这就是我们想要理解的接口。

真实的库可能会用到数据库、拟合公式、缓存、搜索规则和后端数组。我们的教学类用到的是三个系数和一个平方根。这种差异在工程上很重要,但概念上的契约是相同的。

材料属于表面过渡

让我们回到处方上来。

在一个顺序镜头表中,在某个表面列出的材料,通常描述的是该表面之后的介质。

对于单透镜:

surface 1: material_after = N-BK7
surface 2: material_after = air

在表面1:

n_before = n_air(λ)
n_after  = n_N-BK7(λ)

在表面2:

n_before = n_N-BK7(λ)
n_after  = n_air(λ)

这是一个最容易写出错误光线追迹器的地方。

如果你不小心在两侧使用了同一种材料,光线就不会偏折。如果你把前后搞反了,光线可能会朝错误的方向偏折。如果你使用玻璃的常数折射率,系统就不会展现出色度行为。如果你把以纳米为单位的波长,传给了一个期望以微米为单位的函数,结果可能就是无意义的。

因此,在写折射函数之前,让材料的过渡显式化是值得的。

def index_transition(lens, materials, surface_index, wavelength_um):
    before_name, after_name = material_before_after(lens, surface_index)
    n_before = materials.n(before_name, wavelength_um)
    n_after = materials.n(after_name, wavelength_um)
    return n_before, n_after

然后:

for i, surface in enumerate(lens.surfaces):
    if surface.surface_type in ("object", "image"):
        continue

    n1, n2 = index_transition(lens, materials, i, D_LINE_UM)
    print(f"surface {i}: n1={n1:.7f}, n2={n2:.7f}")

这个函数虽然很小,但它保护了后续章节的逻辑。

一个光线追迹器不应该自己去猜测该用哪个折射率。处方和材料库应当来回答这个问题。

主波长并不是唯一的波长

光学设计软件通常会标记一个波长为主波长。

主波长可能被用于布局、对焦、近轴参考基准或默认分析。但这并不意味着其他波长只是装饰。它们对于评价色差校正可能是至关重要的。

一个常见的初学者错误是看到了主波长,就忘记了其余波长。

更好的说法是:

主波长是参考波长。
波长组描述的是需要评估的光谱条件。

代码中:

def primary_wavelength(lens):
    primary = [wl for wl in lens.wavelengths if wl.is_primary]

    if len(primary) != 1:
        raise ValueError("Lens should have exactly one primary wavelength.")

    return primary[0]

print(primary_wavelength(lens))

这个函数看起来几乎过于严格了,但在这里,严格是有帮助的。模糊的光学设置会导致模糊的结果。

稍后当我们计算点列图时,我们可以选择:

只追迹主波长
追迹所有波长
追迹所有波长,并代入权重

这些是不同的分析方法。程序应当知道它正在做的是哪一种。

波长权重并不神奇

假设一个 MTF 图被标注为“多色”。

这听起来很厉害,但它仍然必须具有某个具体的含义。

在某个环节,程序必须将来自多个波长的信息组合起来。它可能会用到权重,可能会用到一个光谱分布,可能会用到传感器响应,也可能会用到一组用户自定义的简化设置。

一个简单的加权平均计算如下:

def weighted_average(values, weights):
    values = np.asarray(values, dtype=float)
    weights = np.asarray(weights, dtype=float)
    return np.sum(values * weights) / np.sum(weights)

对于折射率本身来说,对于一个镜头计算,直接取平均通常并不是正确的物理操作,因为每个波长的光线都应当用其自身的折射率来追迹。但这个函数展示了加权组合的一般思路。

一个更好的思维模型是:

对于每一个波长:
    利用 n(λ) 计算光学结果

然后:
    根据分析方法,组合各波长下的特定结果

这对于后面的内容很重要。

一个多色点列图并不是通过追迹一个平均波长来生成的。它是通过追迹多个波长,并显示或组合它们的光线交点来生成的。一个多色 MTF 也不是假装玻璃有一个平均折射率,它要求在最终组合之前,先进行波长相关的计算。

这是一个软件命令可能大量隐藏细节的地方。

按钮上可能会写着:

多色 MTF

而计算过程则是:

波长组
→ 每个波长下的材料折射率
→ 每个波长下的光线或波前计算
→ 每个波长下的像质结果
→ 加权组合

我们现在还未准备好计算整个链条,但我们现在知道了它起始于何处。

关于材料与波长的常见错误

趁这些错误还容易看出来,我们现在就把它们收集起来。

错误1:将折射率当作一个普适常数

N-BK7 ≈ 1.5168并不是一个完整的材料模型,它只是在 d 线附近的一个参考值。

当你有意想要一个简化的模型时,可以使用常数折射率。不要无意地使用它。

错误2:用错误的单位传递波长

一个期望以微米为单位的函数,应当接收到:

0.5876

而不是:

587.6

除非该函数明确期望以纳米为单位。

这是悄无声息地毁掉一个计算的最容易的方式之一。

错误3:把阿贝数和完整的色散混为一谈

阿贝数是使用选定的波长对色散做的概括。它很有用,但它并不是整个折射率曲线。

错误4:忘记材料是“表面之后”

在顺序处方中,列在第i个表面的材料,通常描述的是表面i之后的介质。

一个表面的折射需要两侧:

前一种材料 → 当前的 material_after

错误5:过早对折射率取平均

对于多波长分析,除非是一种有意的近似,否则不要用一个平均折射率去替代所有波长。应当根据所要执行的分析,对每个波长分别进行追迹或计算。

错误6:忽略有效波长范围

色散公式在指定的波长范围内有效。在该范围之外进行外推,可能会产生错误的结果。真实的材料数据库通常会附带范围信息,这是有原因的。

错误7:忘记了空气同样是一种材料

对于许多可见光的教学例子,我们将空气视为n = 1.0。在高精度工作中,空气本身也有依赖于波长、温度、气压和湿度的折射率。我们现在将使用1.0,因为这能让早期的光线追迹器保持聚焦。但这仍然是一个模型上的选择。

本章为计算链条增加了什么

在第2章中,我们可以说:

表面1材料为 N-BK7

现在我们可以说:

表面1将介质从空气变为 N-BK7

在 λ = 0.4861 µm 下:
    n_before = 1.0
    n_after  = n_N-BK7(0.4861)

在 λ = 0.5876 µm 下:
    n_before = 1.0
    n_after  = n_N-BK7(0.5876)

在 λ = 0.6563 µm 下:
    n_before = 1.0
    n_after  = n_N-BK7(0.6563)

这是一项真实的进步。

处方不再仅仅是几何关系。它现在包含了依赖于波长的光学行为。

我们的计算链条已变为:

表面表
→ 材料名称
→ 材料模型
→ 折射率 n(λ)
→ 每个表面的折射率过渡
→ 为斯涅耳折射做好准备

这正是我们定义光线之前所缺失的那一块拼图。

一个小小的最终测试

让我们写一段紧凑的测试,来确认本章的工作。

def print_surface_indices(lens, materials):
    for wl in lens.wavelengths:
        print(f"\nλ = {wl.value_um:.7f} µm, weight = {wl.weight}")
        for i, s in enumerate(lens.surfaces):
            if s.surface_type in ("object", "image"):
                continue

            before, after = material_before_after(lens, i)
            n1 = materials.n(before, wl.value_um)
            n2 = materials.n(after, wl.value_um)

            print(
                f"surface {i}: {before} → {after}, "
                f"n1={n1:.7f}, n2={n2:.7f}"
            )

print_surface_indices(lens, materials)

如果这条指令对不同波长打印出了不同的 N-BK7 折射率,那么材料系统就在正常工作。

它还没开始追迹光线。它只是在准备光线追迹所需要的数值。

这就够了。

前方的路

我们现在拥有了:

作为数据的镜头处方
表面几何关系
材料名称
波长设置
一个折射率函数 n(λ)
每个表面的折射率过渡

下一个对象就是光线本身。

一条光线并不是图画里一条模糊的线。在代码中,它必须有位置、方向、波长,或许还有强度、状态标志,以及最终还要有光程。它必须能够传播,必须能够与表面相遇,必须携带那个能告诉材料库该用哪个折射率的波长。

因此第4章将会问一个朴素的问题:

在代码中,光线是什么?

一旦我们回答了这个问题,各个部分就会开始运动起来。

镜头处方提供了世界。

材料模型赋予了每种介质其依赖于波长的行为。

而光线,将是那位旅行者。

生成的验证图

根据第3章材料模型生成的BK7色散曲线

来源与验证说明

关于波长相关材料的讨论,应当对照玻璃目录和光学常数资料来阅读,而不应仅仅对照这个简化的教学代码。在配套的验证中,BK7 色散是为教学目的生成的;实际生产工作应使用目录来源,并记录准确的玻璃供应商、参考数据、波长单位和温度假设。

第4章:如何表示一条光线

光学图纸上的一条光线看起来毫不费力。

它只是一条线。

它离开物体,穿过透镜,在表面弯曲,落到像面附近。如果几条光线一起画出,它们组成整齐的扇形。如果它们通过一个好的透镜进行追迹,光线会聚拢。如果透镜质量差,光线会发散。这个图像足够直观,以至于我们可能忘记一个简单的事实:

画出的光线还不是可计算的光线。

程序无法追迹一幅草图。它需要数字。

它需要知道光线从哪里开始、向哪个方向传播、携带什么波长、是否仍然有效,以及最终积累了多少光程。如果我们想从基本原理出发构建点列图、波前图、PSF 和 MTF,就必须首先将这个对象明确地表示出来。

本章正是关于这个对象。

不是表面。不是折射。还不到像质。

就只是光线。

这看起来可能是个小话题,但它是接下来一切内容的根基。光线追迹器并不是什么神奇的光学引擎。它是一个循环,重复地对一条光线执行几个操作:

从一条光线开始
→ 找到它撞击下一个表面的位置
→ 计算表面法线
→ 折射或反射光线
→ 传播到下一个表面
→ 重复

如果光线本身模糊不清,整个循环就会变得模糊。

因此,让我们把光线具体化。

光线是一条带有光学附加信息的参数化直线

光线的基本几何形式是:

r(t) = r0 + t d

这里:

r0 = 光线的起始点
d  = 方向向量
t  = 沿光线类似于距离的参数

如果 d 被归一化,使其长度为 1,那么 t 的单位与坐标相同。如果位置以毫米为单位,那么 t 的单位也是毫米。

这是一个非常有用的约定。

例如,如果:

r0 = [0, 0, 0]
d  = [0, 0, 1]

那么:

r(10) = [0, 0, 10]

光线沿正 z 方向前进了 10 mm。

如果光线倾斜:

d = [0.1, 0, 0.995]

那么向前移动会同时改变 xz

这就是几何。但一条光学光线需要的不仅仅是几何。它还应该至少携带:

波长
强度
光程
有效/无效状态

之后,更高级的光线还可能携带偏振态、相位、表面历史,或用于优化的微分信息。我们现在还不需要所有这些。

目前,我们的光线将携带五项信息:

位置
方向
波长
强度
光程

以及一个状态标记:

存活或停止

这足以开始。

方向始终归一化

方向向量应当是归一化的。

这意味着:

||d|| = 1

或者:

sqrt(dx² + dy² + dz²) = 1

为什么坚持这一点?

因为这会使传播过程变得清晰。如果 d 具有单位长度,那么 t 就是物理距离。让光线移动 t = 5.0 意味着在空间中移动 5.0 mm。

如果 d 未归一化,那 t 就难以解释。方向为 [0, 0, 10] 的光线和方向为 [0, 0, 1] 的光线指向同一方向,但相同的 t 值会让它们移动完全不同的距离。

这不是我们想要的。

因此,光线构造函数应在创建时立刻将方向归一化。

import numpy as np

def normalize(v):
    """返回向量 v 的单位长度副本。"""
    v = np.asarray(v, dtype=float)
    norm = np.linalg.norm(v)

    if norm == 0:
        raise ValueError("不能归一化零向量。")

    return v / norm

测试一下:

d = normalize([0, 0, 10])
print(d)
print(np.linalg.norm(d))

结果是:

[0. 0. 1.]
1.0

这个小小的辅助函数会随处可见。它是那些默默维持光线追迹稳定性的工具之一。

第一个 Ray 类

我们来写一个最小化的 Ray 类。

from dataclasses import dataclass, field
import numpy as np

@dataclass
class Ray:
    position: np.ndarray
    direction: np.ndarray
    wavelength_um: float = 0.5876
    intensity: float = 1.0
    opl: float = 0.0
    alive: bool = True
    stop_reason: str = ""
    history: list = field(default_factory=list)

    def __post_init__(self):
        self.position = np.asarray(self.position, dtype=float)
        self.direction = normalize(self.direction)

        if self.position.shape != (3,):
            raise ValueError("光线位置必须是 3D 向量。")

        if self.direction.shape != (3,):
            raise ValueError("光线方向必须是 3D 向量。")

        if self.wavelength_um <= 0:
            raise ValueError("波长必须为正。")

        if self.intensity < 0:
            raise ValueError("强度不能为负。")

    def point_at(self, t):
        """返回点 r(t) = r0 + t d。"""
        return self.position + t * self.direction

    def copy(self):
        """返回光线的一个副本。"""
        return Ray(
            position=self.position.copy(),
            direction=self.direction.copy(),
            wavelength_um=self.wavelength_um,
            intensity=self.intensity,
            opl=self.opl,
            alive=self.alive,
            stop_reason=self.stop_reason,
            history=list(self.history),
        )

这还算不上一个光线追迹器,但它是一个定义清晰的对象,可供光线追迹器使用。

一条光线有位置和方向。它知道自己的波长、强度。它有一个累积的光程 opl。它可以是存活或停止的。可以存储历史,以备我们想检查它曾经经过哪里。

现在创建一条光线:

ray = Ray(
    position=[0.0, 0.0, 0.0],
    direction=[0.1, 0.0, 1.0],
    wavelength_um=0.5876,
)

print("位置:", ray.position)
print("方向:", ray.direction)
print("方向长度:", np.linalg.norm(ray.direction))
print("t=10 处的点:", ray.point_at(10.0))

方向自动进行了归一化。t = 10 处的点沿着归一化方向走了 10 mm。

这是第一条真正的可计算光线。

方向向量不同于角度

在简单的二维图中,我们常用角度来描述光线。

例如:

一条从光轴倾斜 5 度的光线

这对人类讨论来说没问题。但在三维计算中,方向向量往往更方便。

方向向量可以表示 x 和 y 两个方向的倾斜。它也很自然地与点积、表面法线、向量折射和求交点配合。

不过,从角度创建方向仍然是很有用的。

设光轴为正 z 轴。一条在 x-z 平面内倾斜 θx 角的光线,其方向是:

d = [sin θx, 0, cos θx]

对于小视场角,这就足够了。

import math

def direction_from_x_angle(theta_x_deg):
    """创建在 x-z 平面内倾斜的单位方向向量。"""
    theta = math.radians(theta_x_deg)
    return np.array([math.sin(theta), 0.0, math.cos(theta)])

for angle in [0.0, 5.0, 10.0]:
    print(angle, direction_from_x_angle(angle))

对于同时有 x 和 y 方向倾斜的情况,我们可以写一个简单的辅助函数。一种简洁的构造方法是让光线指向平面 z = 1

def direction_from_field_angles(theta_x_deg=0.0, theta_y_deg=0.0):
    """从近似的视场角创建方向向量。

    光线指向 (tan θx, tan θy, 1)。
    这对基于视场角的示例很方便。
    """
    tx = math.tan(math.radians(theta_x_deg))
    ty = math.tan(math.radians(theta_y_deg))
    return normalize([tx, ty, 1.0])

测试:

print(direction_from_field_angles(0.0, 0.0))
print(direction_from_field_angles(5.0, 0.0))
print(direction_from_field_angles(0.0, 5.0))
print(direction_from_field_angles(5.0, 5.0))

这个辅助函数并非一个完整的视场模型。它只是一种实用的方式,用来生成大致沿光轴方向、并具有指定角度倾斜的光线。

之后,当我们从视场点和光瞳坐标生成光线束时,这类函数会成为更大型采样程序的一部分。

按距离传播光线

对光线最简单的操作是在均匀介质中传播。

如果光线移动物理距离 s,它的新位置是:

r_new = r_old + s d

如果介质的折射率为 n,光程增加量为:

ΔOPL = n s

这一点值得关注。

几何距离和光程不是一回事。一条光线在空气中通过 10 mm,若 n ≈ 1,光程大约 10 mm。同样的几何距离在折射率 n = 1.5 的玻璃中,其光程大约 15 mm。

这在之后计算 OPD 时会很重要。波前误差并非仅由几何距离构建。它依赖于光程。

让我们实现传播。

@dataclass
class Ray:
    position: np.ndarray
    direction: np.ndarray
    wavelength_um: float = 0.5876
    intensity: float = 1.0
    opl: float = 0.0
    alive: bool = True
    stop_reason: str = ""
    history: list = field(default_factory=list)

    def __post_init__(self):
        self.position = np.asarray(self.position, dtype=float)
        self.direction = normalize(self.direction)

        if self.position.shape != (3,):
            raise ValueError("光线位置必须是 3D 向量。")
        if self.direction.shape != (3,):
            raise ValueError("光线方向必须是 3D 向量。")
        if self.wavelength_um <= 0:
            raise ValueError("波长必须为正。")
        if self.intensity < 0:
            raise ValueError("强度不能为负。")

    def point_at(self, t):
        return self.position + t * self.direction

    def propagate(self, distance, refractive_index=1.0):
        """按几何距离向前移动光线。

        distance 与位置单位一致,通常为 mm。
        refractive_index 无量纲。
        """
        if not self.alive:
            return self

        if distance < 0:
            raise ValueError("传播距离应为非负值。")

        old_position = self.position.copy()
        self.position = self.point_at(distance)
        self.opl += refractive_index * distance

        self.history.append({
            "event": "propagate",
            "from": old_position,
            "to": self.position.copy(),
            "distance": distance,
            "n": refractive_index,
            "opl": self.opl,
        })

        return self

    def stop(self, reason):
        self.alive = False
        self.stop_reason = reason
        self.history.append({
            "event": "stop",
            "reason": reason,
            "position": self.position.copy(),
        })
        return self

    def copy(self):
        return Ray(
            position=self.position.copy(),
            direction=self.direction.copy(),
            wavelength_um=self.wavelength_um,
            intensity=self.intensity,
            opl=self.opl,
            alive=self.alive,
            stop_reason=self.stop_reason,
            history=list(self.history),
        )

现在试试:

ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5876)
ray.propagate(10.0, refractive_index=1.0)

print(ray.position)
print(ray.opl)

输出是:

[ 0.  0. 10.]
10.0

通过玻璃:

ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5876)
ray.propagate(10.0, refractive_index=1.5)

print(ray.position)
print(ray.opl)

位置依然是 [0, 0, 10],但光程是 15.0

这个小小的区别是从几何光线追迹通向波前计算的一座桥梁。

传播到 z 平面

在序列光学系统中,许多有用的表面是沿 z 轴排列的。甚至在我们处理曲面之前,就有一个有用的问题:

这条光线在哪里与平面 z = z_target 相遇?

对光线:

r(t) = r0 + t d

其 z 坐标为:

z(t) = z0 + t dz

令其等于 z_target

z_target = z0 + t dz

求解 t

t = (z_target - z0) / dz

这个公式很简单,但它已经教会我们一个重要的习惯:求交点本质上就是解参数。我们找到使光线落在目标表面上的参数 t

代码如下:

def distance_to_z_plane(ray, z_target, eps=1e-12):
    """返回光线到达 z = z_target 时的距离参数 t。"""
    dz = ray.direction[2]

    if abs(dz) < eps:
        return None

    t = (z_target - ray.position[2]) / dz

    if t < 0:
        return None

    return t

现在使用它:

ray = Ray([0, 0, 0], [0.1, 0.0, 1.0])
t = distance_to_z_plane(ray, 50.0)

print("到 z=50 的距离:", t)
print("点:", ray.point_at(t))

光线到达 z 平面时,其 x 坐标不为零。这个 x 坐标就是由光线倾斜引起的横向位移。

我们可以添加一个方法:

def propagate_to_z(ray, z_target, refractive_index=1.0):
    t = distance_to_z_plane(ray, z_target)

    if t is None:
        ray.stop(f"无法到达 z 平面 {z_target}")
        return ray

    return ray.propagate(t, refractive_index=refractive_index)

测试:

ray = Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=5.0))
propagate_to_z(ray, 100.0, refractive_index=1.0)

print(ray.position)
print(ray.opl)

这是本书中第一个可用的类似求交点的操作。它仅是一个垂直于光轴的平面,但它引入了核心模式:

求解 t
求值 r(t)
更新光线

在第 5 章中,我们将对真正的表面使用同样的模式。

光线可能错过、向后走或变为无效

t 的公式本身还不够。一条光线可能无法沿前向到达平面。

如果 dz = 0,光线平行于 z 平面,永远不会到达不同的 z 值。

如果 t < 0,该平面在光线后方,而非前方。

这就是 distance_to_z_plane 在这些情形下返回 None 的原因。

这不仅仅是防御性编程。光线追迹充满有效性检查。

光线可能由于以下原因无效:

它错过一个表面
它到达的位置超出通光孔径
预计折射时发生全反射
它向后传播
它遇到不支持的面形
它损失了过多强度

与其假装每条光线都能存活,不如让有效性成为光线对象的一部分。

这就是我们的 Ray 类包含:

alive
stop_reason

的原因。当我们追迹一个光线束时,这将变得很有用。一些光线能穿过整个系统,另一些可能被截断。点列图通常只应包含能到达像面的光线。

从不停止光线的光线追迹器不是乐观,而是粗心。

波长属于光线

在第 3 章中,我们构建了折射率依赖于波长的材料模型:

n = n(λ)

光线追迹时,λ 应该放在哪里?

它应该放在光线对象上。

一束蓝光和红光可能从同一点出发,沿相同初始方向前进,但当它们遇到玻璃时,材料库会给出不同的折射率。这意味着折射可能会将它们偏转到稍微不同的方向上。

因此,光线必须携带其波长:

blue_ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.4861327)
green_ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5875618)
red_ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.6562725)

之后,在表面处,追迹代码可以询问:

n_before = materials.n(before_material, ray.wavelength_um)
n_after = materials.n(after_material, ray.wavelength_um)

这就是材料章节与光线章节的连接方式。

透镜处方会说明哪些表面之后是什么材料。材料库说明每种材料在各波长下的表现。光线携带的波长用于选择正确的折射率。

各个部分开始组合到一起。

强度也属于光线

对早期的几何光线追迹而言,强度似乎没有必要。许多简单的光线追迹只关心光线去了哪里。

但从一开始将强度包含进来是有用的,因为实际的光学计算经常需要知道光线是否被均等加权。

一条光线的强度可能较低,因为:

它来自加权波长
它来自加权光瞳采样点
它经过一个部分透射的光学元件
它在表面发生反射或吸收

我们目前还不去模拟镀膜或吸收。但一个 intensity 字段几乎不占什么开销,却让光线对象更加诚实。

目前有一条简单规则:

intensity = 1.0 表示全权重
intensity = 0.0 表示无贡献

之后生成光线束时,我们可能会根据光瞳采样或波长权重给光线分配权重。这在汇总结果时会有影响。

再次强调,并非所有字段都会被立即使用。一个好的数据结构是为未来步骤做准备的,同时又不会变得臃肿。

光程不可能永远被忽略

初学者写的光线追迹器如果只是想得到点列图,可以忽略光程。

但本书不会止于点列图。我们要通往 OPD、波前、PSF 和 MTF。而为了走这条路,光程至关重要。

当一条光线以几何距离 s 通过折射率为 n 的介质时,它累积:

OPL += n s

对于一系列介质:

OPL = n1 s1 + n2 s2 + n3 s3 + ...

这看起来无伤大雅。但之后 OPD 会比较不同光线的光程。这种比较是几何光线追迹通往波动光学的桥梁之一。

因此,我们的光线从一开始就存储 opl

即使我们直到第 10 章才完全使用它,这个习惯依然重要:

一条光线不只是一条几何线。它也是穿过光学介质的一条路径。

几何告诉我们它去哪里。

OPL 告诉我们它走了多远的光学距离。

光线历史用于学习,不总是用于生产

我们的 Ray 类存储一个 history 列表。每次传播事件可以附加一条小记录。

这对学习和调试很有帮助:

ray = Ray([0, 0, 0], [0.05, 0, 1])
ray.propagate(10.0, refractive_index=1.0)
ray.propagate(5.0, refractive_index=1.5)

for event in ray.history:
    print(event)

生产级的光线追迹器可能不会为每条光线存储完整历史,因为这很昂贵。如果你要追迹数百万条光线,内存就会变得重要。

但对一个教学实现来说,历史很可爱。它让我们能检查路径,而不是只相信最终答案。这符合本书的精神:我们不想隐藏计算过程,而是想看到它。

之后,如果性能变得重要,我们可以关闭历史。

目前,清晰性胜出。

一个小型的光线表格

让我们生成几条光线,并将它们传播到同一个 z 平面。

rays = [
    Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=0.0)),
    Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=2.0)),
    Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=-2.0)),
]

z_image = 100.0

for ray in rays:
    propagate_to_z(ray, z_image)
    print(
        f"近似角度方向={ray.direction}, "
        f"击中位置={ray.position}, "
        f"OPL={ray.opl:.3f}"
    )

轴上光线落在 x = 0 处。倾斜光线落在相反两侧。

这还不是透镜,只是自由空间传播。但即使在这里,我们也能看到点列图的雏形:光线的着点集合。

让我们把它们画出来。

import matplotlib.pyplot as plt

xs = [ray.position[0] for ray in rays]
ys = [ray.position[1] for ray in rays]

plt.scatter(xs, ys)
plt.axhline(0, linewidth=0.5)
plt.axvline(0, linewidth=0.5)
plt.xlabel("z 平面处的 x [mm]")
plt.ylabel("z 平面处的 y [mm]")
plt.title("光线在 z 平面上的着点")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()

这是一个非常无聊的点列图。很好。无聊的图有时是最好的起点。

还没有发生折射。还没有击中任何表面。也没有像差。我们仅仅是在确认光线能够移动、携带其属性,并落在某个可测量的位置。

从一条光线到许多光线

多数光学分析不会只追迹一条光线。

它追迹光线束。

一个光线束可以代表:

光瞳中不同的高度
不同的视场点
不同的波长
不同的物点
不同的采样权重

现在,让我们在 x-z 平面内创建一个简单的光线扇形,全部从原点出发,但具有不同的角度。

def make_angle_fan(theta_min_deg, theta_max_deg, count, wavelength_um=0.5876):
    angles = np.linspace(theta_min_deg, theta_max_deg, count)
    return [
        Ray(
            position=[0.0, 0.0, 0.0],
            direction=direction_from_field_angles(theta_x_deg=a),
            wavelength_um=wavelength_um,
        )
        for a in angles
    ]

fan = make_angle_fan(-5.0, 5.0, 11)

for ray in fan:
    propagate_to_z(ray, 100.0)

xs = [ray.position[0] for ray in fan]
ys = [ray.position[1] for ray in fan]

plt.scatter(xs, ys)
plt.xlabel("z = 100 mm 处的 x")
plt.ylabel("z = 100 mm 处的 y")
plt.title("自由空间扇形光线着点")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()

光线线性散开,因为没有透镜。

之后,当我们加入表面和折射时,同样的光线束会发生弯曲。经过一个透镜追迹后,像面上的着点将成为一个真正的点列图。

这个点列图不是"画"出来的。它是由光线对象在系统中传播后得到的结果。

这正是我们想实现的思维转变。

实际光线和近轴光线

现在我们需要做一个重要的区分。

实际光线就是我们一直在构建的东西:一条具有完整位置向量和方向向量的几何光线。它可以击中精确的表面,可以使用精确的法线进行折射。除非我们特意创建小角度的光线,否则它并不假定小角度。

近轴光线则不同。它存在于光轴附近的小角度近似之中。近轴光学通常追踪像光线高度和斜率这样的量,而不是完整的 3D 几何:

y = 光线高度
u = 光线角度或斜率

近轴光线并非无用的"虚假"模型。它是一个捕捉光学系统一阶骨架的简化模型。

实际光线追迹问的是:

实际光线通过实际表面几何的路径是怎样的?

近轴光线追迹问的是:

近轴的、小角度的光线在一阶近似下表现如何?

两者都很重要。

近轴光线有助于计算:

有效焦距
后焦距
主平面
F 数
放大率
一阶布局

实际光线有助于计算:

像差
点列图
光线扇形图
实际焦面行为
视场相关误差

在本书中,我们会同时用到两者。但我们绝不能混淆它们。

一个小型近轴光线类可能如下:

@dataclass
class ParaxialRay:
    y: float      # 光线高度
    u: float      # 小角度或斜率
    wavelength_um: float = 0.5876

    def propagate(self, distance):
        """小角度传播:y_new = y + distance * u。"""
        self.y = self.y + distance * self.u
        return self

这比实际的 Ray 类简单得多。它没有 3D 位置,没有精确的方向向量,没有表面求交逻辑。当我们在第 8 章学习一阶光学时,这种简单性很有用。

但第 5–7 章的光线追迹器将使用实际光线。

这就是清晰的边界:

第 4–7 章:实际光线的表示与追迹
第 8 章:近轴光线传递与一阶光学
第 9 章起:将实际光线与近轴参考进行比较

如果你心中保持这个分界清晰,许多后续主题会变得更容易。

光线不是光束

另一个有用的区分:

光线不是光束。

光线是一个几何样本。光束是光的物理分布。

当我们通过光瞳追迹许多光线时,我们并不是在声称每条光线都是微小的物理光线束。我们是在对光学系统的行为进行采样。一个光线束是一种计算上的表示。

这一点很重要,因为不同的分析会用到不同的采样思路。

对点列图,我们可能追迹有限数量的光线,并画出它们的交点。

对波前分析,我们可能追迹与光瞳坐标相关联的光线,并计算光程差。

对衍射 PSF,我们最终需要的是一个光瞳函数,而不仅仅是光线着点列表。

光线是一个工具。它很强大,但它不是光的全部物理。

这就是本书不会止步于几何光线追迹的原因。它将用光线来构建通向波前和衍射计算的桥梁。

但首先,光线本身必须是正确的。

坐标约定

现在说明我们在教学代码中要使用的坐标约定。

z 轴:名义光轴
x, y:横向坐标
光大致沿 +z 方向传播
位置以毫米为单位
波长以微米为单位
方向向量无量纲且归一化

这是一个约定,而非自然法则。其他软件可能使用不同的坐标选择或符号约定。专业的光学设计程序对这些定义非常谨慎。

就我们的目的而言,这个约定让早期示例清晰易读。

一条从原点发出、沿光轴传播的光线是:

Ray(position=[0, 0, 0], direction=[0, 0, 1])

一条从轴上 5 mm 高度发出、向前传播的光线是:

Ray(position=[0, 5, 0], direction=[0, 0, 1])

一条在 x 方向倾斜的光线是:

Ray(position=[0, 0, 0], direction=direction_from_field_angles(theta_x_deg=3.0))

这些选择简单到可以手工检查。

这正是在增加曲面之前我们所期望的。

保持单位清晰

光线对象同时携带位置和波长,但它们使用不同的单位:

位置:mm
波长:µm

这看起来可能有点奇怪,但它遵循常见的光学设计实践。

为什么不把所有东西都存成毫米?

我们当然可以。但材料色散公式和玻璃库常用微米为单位表示波长。如果我们让波长以微米为单位,第 3 章的材料代码就会保持自然。

危险在于混用它们。

在计算光线传播和表面求交点时,使用毫米。

向材料查询折射率时,传入微米。

之后,将 OPD 转为相位时,要小心:OPD 可能以毫米为单位,而波长可能以微米为单位。这种转换很重要。我们将在处理波前相位时明确处理它。

目前,只需保持标签清晰。

一个好的光线对象不会解决所有单位问题,但它可以让你看清单位的假设。

一个更细致的传播示例

让我们将波长、材料折射率和 OPL 结合起来。

假设一条绿光光线经历:

20 mm 空气中
5 mm N-BK7 中
20 mm 空气中

在 d 线,N-BK7 的折射率约为 1.5168。几何距离是:

20 + 5 + 20 = 45 mm

光程约为:

1.0 × 20 + 1.5168 × 5 + 1.0 × 20 = 47.584 mm

我们来计算它:

ray = Ray([0, 0, 0], [0, 0, 1], wavelength_um=0.5876)

ray.propagate(20.0, refractive_index=1.0)
ray.propagate(5.0, refractive_index=1.5168)
ray.propagate(20.0, refractive_index=1.0)

print("位置:", ray.position)
print("OPL:", ray.opl)

这个例子故意只考虑一维。它忽略了折射,并假设光线只是简单通过各段。但它教我们如何记账。

之后,当光线在表面发生偏折时,各段的几何长度将从求交计算中得到。折射率将从材料模型中获取。光程将逐段累积。

最终的 OPD 计算会比较这些累积的路径。

所以,即便是现在,光线对象也在承载着未来的波前信息。

绘制光线路径

光线的历史让我们可以画出简单的路径。

def extract_path_from_history(ray):
    points = []

    for event in ray.history:
        if event["event"] == "propagate":
            if not points:
                points.append(event["from"])
            points.append(event["to"])

    return np.array(points)

ray = Ray([0, 0, 0], direction_from_field_angles(theta_x_deg=5.0))
ray.propagate(20.0)
ray.propagate(30.0)

path = extract_path_from_history(ray)

plt.plot(path[:, 2], path[:, 0], marker="o")
plt.xlabel("z [mm]")
plt.ylabel("x [mm]")
plt.title("光线在 x-z 平面内的路径")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()

我们将 z 画在水平轴,x 画在垂直轴,因为这类似于通常的光学布局视图。

这依然是一条直线。这很好。在光线能正确弯曲之前,它首先应该能正确走直线。

在光线追迹中,大量 bug 往往更容易在你首先测试无聊的直线行为时被发现。

一个真实光学库做了什么

我们的 Ray 类是用于学习的。一个真实的商业光学库则必须处理更多。

例如,一个实用的光线追迹系统可能需要:

大量光线的数组
多个视场
多个波长
逐面数据
孔径剪裁
光线瞄准
主光线和边缘光线搜索
偏振态
渐晕因子
坐标断开
表面局部坐标
对 NumPy、PyTorch 或 JAX 的后端支持

作为本书的参考库, Optiland 在一个完整的光学系统模型中提供了更高级的光线追迹工作流。我们不会逐行模仿它的内部光线对象,那样会把本书变成一份 API 评注,这不是本书的目的。

取而代之,我们采用一种两层的阅读方式。

在教学代码层面,一条光线是透明的:

ray = Ray(position=[0, 0, 0], direction=[0, 0, 1], wavelength_um=0.5876)

在库层面,光线通常通过一个光学系统生成和追迹:

# 示意性的 Optiland 风格工作流。
# 具体方法名可能因版本而异。

from optiland.samples.objectives import CookeTriplet

lens = CookeTriplet()

# 该库可以绘制系统并追迹光线用于可视化。
lens.draw(num_rays=5)

这类调用有意隐藏了许多细节。它选择光线样本,将其追迹通过各表面,并绘制结果。这很有用。

但现在我们知道底层必须存在什么:

光线位置
光线方向
光线波长
表面交点
材料折射率
折射更新
有效性检查

确切的组织方式可能与我们教学用的类有所不同。但计算上的责任不会消失。

一个库可以让光线追迹变得方便,但它无法让光线变得多余。

一个最简的光线追迹骨架

现在我们可以写出一个光线追迹器的骨架,尽管尚未实现表面。

def trace_ray_through_system(ray, surfaces, materials):
    """仅是一个骨架。

    第 5 章将填充光线-表面求交。
    第 6 章将填充折射。
    """
    for surface in surfaces:
        if not ray.alive:
            break

        # 1. 找到光线与此表面的交点。
        # intersection = intersect(ray, surface)

        # 2. 如果未命中,停止光线。
        # if intersection is None:
        #     ray.stop("missed surface")
        #     break

        # 3. 将光线传播到交点。
        # distance = intersection.distance
        # ray.propagate(distance, refractive_index=current_n)

        # 4. 检查通光孔径。
        # if outside aperture:
        #     ray.stop("blocked by aperture")
        #     break

        # 5. 计算法线。
        # normal = surface_normal(surface, ray.position)

        # 6. 使用 ray.wavelength_um 计算 n_before 和 n_after。
        # n1, n2 = index_transition(...)

        # 7. 折射光线方向。
        # ray.direction = refract(ray.direction, normal, n1, n2)

        pass

    return ray

这个函数现在还运行不了。它是一个蓝图。

重点在于,光线对象已经包含了未来函数所需的信息。

intersect 将使用光线的位置和方向。

index_transition 将使用光线的波长。

propagate 将更新光线的位置和光程。

refract 将更新光线的方向。

光线追迹器不是一个巨大的咒语。它是一系列有明确输入和输出的小型操作的序列。

测试 Ray 类

在光线之上构建更多机制之前,我们先写几个小测试。

def test_direction_is_normalized():
    ray = Ray([0, 0, 0], [0, 0, 10])
    assert np.isclose(np.linalg.norm(ray.direction), 1.0)

def test_point_at():
    ray = Ray([1, 2, 3], [0, 0, 1])
    p = ray.point_at(5)
    assert np.allclose(p, [1, 2, 8])

def test_propagate_updates_position_and_opl():
    ray = Ray([0, 0, 0], [0, 0, 1])
    ray.propagate(10, refractive_index=1.5)
    assert np.allclose(ray.position, [0, 0, 10])
    assert np.isclose(ray.opl, 15.0)

def test_propagate_to_z():
    ray = Ray([0, 0, 0], [0, 0, 1])
    propagate_to_z(ray, 25.0)
    assert np.allclose(ray.position, [0, 0, 25.0])

test_direction_is_normalized()
test_point_at()
test_propagate_updates_position_and_opl()
test_propagate_to_z()

print("所有光线测试通过。")

这可能感觉对一个光学书籍来说有点过于像软件工程了。其实不然。

光学计算就是软件。如果基础的光线对象是错的,那么后续的光学图可能看起来仍然漂亮,但它们是错的。

小测试不是装饰,而是我们保持计算可信的方式。

表示光线时的常见错误

现在就来收集这些陷阱。

错误 1:未归一化方向

如果方向向量不是单位长度,传播距离的含义就会模糊。在构造时就归一化它。

错误 2:将方向与终点混淆

方向向量不是光线试图要到达的点,而是一个指向。如果你想定义一条从点 A 指向点 B 的光线,请计算:

d = normalize(B - A)

而不是 d = B

错误 3:忘记波长

没有波长的光线无法在色散材料中选择正确的折射率。

你可以使用一个默认波长,但它应当是明确的。

错误 4:忽略无效光线

有些光线应该被停止。如果你继续追迹被阻挡或丢失的光线,后续图表可能会包含无意义的信息。

错误 5:将光程当作几何距离

几何距离是 s。光程是 n s。它们仅在 n = 1 时才相等。

错误 6:默默混用单位

位置可能是毫米而波长是微米。这只有当代码明确说明时才是可以接受的。

错误 7:混淆实际光线和近轴光线

近轴光线是一阶近似。实际光线是通过实际表面追迹的几何对象。两者都有用,但不可互换。

本章给了我们什么

我们现在有了一条可计算的光线。

它可以:

存储位置和方向
归一化其方向
携带波长和强度
累积光程
按距离向前移动
移动到 z 平面
无效时停止
记录历史以供检查

这看起来可能还不够戏剧性。没有透镜对焦。没有光线发生偏折。也没有点列图出现。

但基础现在是坚实的。

第 2 章的处方给了我们光学系统的数据。

第 3 章的材料模型给了我们作为波长函数的折射率。

本章的光线对象给了我们一个可以在该系统中穿行的东西。

下一个缺失的拼图是表面相遇。

一条光线可以沿直线移动。一个透镜表面位于空间某处。光线追迹器必须回答:

这条光线在哪个点会击中该表面?

对平面来说,这很容易。

对球面来说,它会变成一个二次方程。

对非球面及其他更复杂的表面,可能需要数值方法。

真正的工作就将从这里开始。

第 5 章将用我们在这里构建的光线,让它与几何形状发生碰撞。我们将手工计算光线-平面和光线-球面的交点。这是本书最重要的章节之一,因为它展示了许多讲解会跳过的那一部分。

光线图显示一条线接触到玻璃。

光线追迹器必须计算出这一次接触。

生成的验证图

从第 4 章光线模型生成的光线表示

第5章:光线如何击中曲面

透镜图让这一步看起来毫不费力。

一条光线向前传播。它到达一个玻璃表面。发生偏折。然后继续前进。

在图中,接触点显而易见,因为你的眼睛看到线与曲线相切。但程序看不到那幅图。程序只有数字:

光线位置
光线方向
表面位置
表面形状

从这些数字出发,它必须回答一个非常具体的问题:

这条光线在哪个点击中这个表面?

这就是光线-表面求交。

它是几何光线追迹中最重要的计算之一,也是解释中最容易被跳过的一步。许多书籍和手册展示光线在表面偏折,然后迅速转入斯涅尔定律。但斯涅尔定律只有在我们知道光线与表面相交的位置以及该点处的表面法线后才能发挥作用。

因此本章会放慢节奏。

我们暂时不会涉及折射。那是第6章的内容。这里只处理几何:

光线 + 平面  → 交点
光线 + 球面 → 交点
交点 → 孔径检查
交点 → 表面法线

到本章结束时,光线能够与平面或球面表面碰撞。它还未发生偏折。但它会确切地知道偏折应该在哪里发生。

这是很大的一步。

一句话描述问题

根据第4章,我们的光线为:

r(t) = r0 + t d

其中:

r0 = 光线起点位置
d  = 归一化光线方向
t  = 前进距离参数

表面是满足某些方程的点集。

对于垂直于光轴的平面:

z = z_v

对于球面:

(x - cx)² + (y - cy)² + (z - cz)² = R²

求交意味着:

找到使得光线点 r(t) 位于表面上的 t 值。

这就是全部思想。

对于平面,方程是线性的。

对于球面,方程变为二次方程。

对于更复杂的非球面或自由曲面,方程可能需要数值求解。但平面和球面足以构建基础。

表面有一个顶点

在编写求交代码之前,我们还需要一个光学词汇。

在序列透镜处方中,每个表面沿光轴有一个顶点位置。如果第一个物理表面在 z = 0,其厚度为 5 mm,那么下一个表面顶点在 z = 5 mm

顶点是表面与光轴相交的点。

对于旋转对称球面,我们可以认为表面被放置在顶点位置 z_v,并带有一个带符号的半径 R

使用前面章节的约定:

光线通常沿 +z 方向传播
表面顶点位于 z = z_v
带符号的半径 R 给出曲率中心在 z = z_v + R

所以如果:

z_v = 0
R = +50 mm

球心位于:

z_c = 50 mm

如果:

z_v = 5
R = -50 mm

球心位于:

z_c = -45 mm

这个带符号半径的约定让一个公式在序列光学系统中既能描述凸面也能描述凹面。

我们将在教学代码中使用这个模型。

求交用的小型表面类

前面章节的 Surface 类存储了半径、厚度、材料、半直径和表面类型。对于求交,我们还需要实际的顶点位置 z

在一个完整的透镜系统中,我们可以根据厚度列计算顶点位置。对于本章,使用一个单独的、直接存储顶点的小型类会更方便。

from dataclasses import dataclass
from typing import Optional
import math
import numpy as np

@dataclass
class IntersectableSurface:
    radius: float
    z_vertex: float
    semi_diameter: Optional[float] = None
    surface_type: str = "spherical"
    comment: str = ""

这并不意味着永久性地替代处方数据结构。它是一个教学对象,让我们可以专注于求交,而无需带着整个透镜系统。

例如:

front_surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前球面"
)

image_plane = IntersectableSurface(
    radius=float("inf"),
    z_vertex=50.0,
    semi_diameter=None,
    surface_type="plane",
    comment="像平面"
)

前表面是球面。像平面是位于 z = 50 的平面。

现在我们可以让光线击中它们了。

光线-平面求交

从最简单的情况开始。

垂直于光轴的平面方程为:

z = z_v

光线为:

r(t) = r0 + t d

只写出 z 坐标:

z(t) = z0 + t dz

令其等于平面:

z_v = z0 + t dz

解出 t

t = (z_v - z0) / dz

这与第4章中传播到某个 z 平面的计算相同。现在我们将它正式视为一个表面求交。

def intersect_plane_z(ray, z_plane, eps=1e-12):
    """返回光线与平面 z = z_plane 相交的前向距离 t。

    如果光线与平面平行或交点不在前方,则返回 None。
    """
    dz = ray.direction[2]

    if abs(dz) < eps:
        return None

    t = (z_plane - ray.position[2]) / dz

    if t <= eps:
        return None

    return t

eps 值避免将微小的数值噪声视为真实的前向交点。在光线追迹中,这很重要。当光线刚刚落到一个表面上后,我们不希望下一次的求交测试在 t = 0 处重新发现同一个表面。

测试一下:

ray = Ray(position=[0, 0, 0], direction=[0.1, 0, 1.0])

t = intersect_plane_z(ray, z_plane=50.0)
p = ray.point_at(t)

print("t =", t)
print("point =", p)

输出应该显示一个 z = 50 的点。由于光线是倾斜的,x 值将为非零。

这已经是一次求交了。

不需要任何绘图。

将求交结果转化为一个事件

我们可以编写一个辅助函数,将光线移动到平面上。

def propagate_to_plane(ray, z_plane, refractive_index=1.0):
    t = intersect_plane_z(ray, z_plane)

    if t is None:
        ray.stop(f"未击中平面 z={z_plane}")
        return ray

    ray.propagate(t, refractive_index=refractive_index)
    return ray

这里使用了第4章中的 Ray 类。它同时更新了位置和光程长。

ray = Ray(position=[0, 0, 0], direction=[0.1, 0, 1.0])
propagate_to_plane(ray, z_plane=50.0)

print(ray.position)
print(ray.opl)
print(ray.alive)

光线现在位于平面上。如果这是一个像平面,我们可以记录其 x 和 y 坐标作为点列图的一个点。

对于平面表面,求交很简单。真正的乐趣从球面开始。

光线-球面求交

一个球形光学表面是一个球体的一部分。

设其球心为:

C = [0, 0, z_c]

其半径大小为:

|R|

一个点 p = [x, y, z] 位于该球面上,如果满足:

||p - C||² = R²

光线点为:

p(t) = r0 + t d

将光线代入球面方程:

||r0 + t d - C||² = R²

这成为一个关于 t 的二次方程。

设:

oc = r0 - C

则有:

||oc + t d||² = R²

展开:

(d · d)t² + 2(d · oc)t + (oc · oc - R²) = 0

所以:

a = d · d
b = 2(d · oc)
c = oc · oc - R²

求解:

t = (-b ± sqrt(b² - 4ac)) / (2a)

由于我们的光线方向已归一化,理论上 a 应为 1。但我们仍然计算它,使函数更健壮。

判别式告诉我们光线是否击中球面:

判别式 = b² - 4ac

如果判别式为负,没有实数交点。

如果判别式为零,光线与球面相切。

如果判别式为正,光线在数学意义上有两个交点。

对于光学光线追迹,我们通常需要最近的正向交点。

代码如下:

def sphere_center_from_surface(surface):
    """返回球面的曲率中心。"""
    if math.isinf(surface.radius):
        return None

    return np.array([0.0, 0.0, surface.z_vertex + surface.radius], dtype=float)

def intersect_sphere(ray, surface, eps=1e-9):
    """返回光线与球面相交的前向距离 t。

    表面由顶点位置 z_vertex 和带符号半径表示。
    球心位于 z_vertex + radius。
    """
    if math.isinf(surface.radius):
        return intersect_plane_z(ray, surface.z_vertex, eps=eps)

    center = sphere_center_from_surface(surface)
    radius_abs = abs(surface.radius)

    oc = ray.position - center

    a = float(np.dot(ray.direction, ray.direction))
    b = 2.0 * float(np.dot(ray.direction, oc))
    c = float(np.dot(oc, oc) - radius_abs * radius_abs)

    disc = b * b - 4.0 * a * c

    if disc < 0:
        return None

    sqrt_disc = math.sqrt(max(disc, 0.0))

    t1 = (-b - sqrt_disc) / (2.0 * a)
    t2 = (-b + sqrt_disc) / (2.0 * a)

    candidates = [t for t in (t1, t2) if t > eps]

    if not candidates:
        return None

    return min(candidates)

现在测试一条轴上光线击中球形透镜的前表面。

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面"
)

ray = Ray(position=[0.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

t = intersect_sphere(ray, surface)
p = ray.point_at(t)

print("t =", t)
print("intersection =", p)

对于轴上光线,交点应在顶点处:

[0, 0, 0]

或非常接近。

现在尝试一条轴外平行光线:

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

t = intersect_sphere(ray, surface)
p = ray.point_at(t)

print("t =", t)
print("intersection =", p)

x 坐标应保持为 5,而 z 应略为正值。为什么是正的?因为球心在 z = 50 处,球面向着入射光线凸起。在高度 x = 5 处,表面点沿 z 轴略位于顶点右侧。

这就是从求交几何中自然出现的表面垂度。

将求交结果与垂度比较

从第2章可知,球面垂度可由下式计算:

sag(r) = R - sign(R) sqrt(R² - r²)

根据符号约定的不同,存在几种等价形式。对于我们使用的顶点-球心约定,球面满足:

x² + y² + (z - (z_v + R))² = R²

解出经过顶点的分支得到:

z = z_v + R - sign(R) sqrt(R² - r²)

其中:

r² = x² + y²

我们来实现它:

def spherical_sag(x, y, surface):
    """返回球面在横向点 (x, y) 处的 z 坐标。"""
    if math.isinf(surface.radius):
        return surface.z_vertex

    R = surface.radius
    r2 = x*x + y*y
    R2 = R*R

    if r2 > R2:
        return None

    return surface.z_vertex + R - math.copysign(math.sqrt(R2 - r2), R)

现在比较轴外交点:

x, y, z = p
print("交点 z:", z)
print("垂度 z:", spherical_sag(x, y, surface))

它们应该一致。

这是一个很好的完整性检查。光线-球面求交和表面垂度从两个不同的方向描述了相同的几何:

求交:这条光线在哪里与表面相遇?
垂度:表面在给定的 x, y 处位于何处?

两者都很有用。

为什么有两个球面交点

一条直线可能穿过球体两次。

对于一个完整的球体,这是显而易见的。光线在近端进入球体,在远端穿出。

但光学表面并不是整个球体。它是顶点附近的一个局部区域。在序列光线追迹中,我们通常需要与该表面的第一个正向交点。

这就是 intersect_sphere 选择最小正 t 的原因。

这对于许多教学例子而言是可行的,但有一个微妙之处。

在实际的序列光学系统中,预期的表面是处方中的下一个表面。光线可能在数学意义上与球体的远端相交,但那不是我们指的物理表面区域。孔径检查和序列几何有助于排除无关交点。

对于光轴附近的普通球面透镜表面,选择最近的正向交点是正确的第一规则。

当系统变得更复杂时,稳健的光线追迹需要额外的保护措施:

表面孔径限制
预期的传播方向
局部坐标系
避免自交
处理坐标间断
表面分支选择

我们目前还不需要这些全部。但知道专业光线追迹器在这里保持谨慎是有原因的,这是有益的。

孔径检查

仅找到数学交点是不够的。

光线可能在无限球面上击中清晰光学区域之外。

透镜表面有一个半直径。如果交点距离光轴太远,光线被遮挡。

对于圆形清晰孔径:

x² + y² <= semi_diameter²

代码中:

def inside_surface_aperture(point, surface):
    """如果点位于圆形清晰孔径内,返回 True。"""
    if surface.semi_diameter is None:
        return True

    x, y, _ = point
    return x*x + y*y <= surface.semi_diameter * surface.semi_diameter

测试:

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

points = [
    np.array([0.0, 0.0, 0.0]),
    np.array([5.0, 0.0, 0.0]),
    np.array([10.0, 0.0, 0.0]),
    np.array([11.0, 0.0, 0.0]),
]

for p in points:
    print(p, inside_surface_aperture(p, surface))

x = 11 的点在外部。

这个检查可能看起来平淡无奇。但它不是。孔径裁剪会影响光束、渐晕、点列图、光瞳采样、照明以及后续的像质计算。

一条击中数学球面但位于清晰孔径之外的光线,不应该被视为通过了透镜。

完整的交点记录

返回比 t 更多的信息是有用的。

当光线击中一个表面时,我们可能想要:

距离 t
交点
是否在孔径内
表面法线
表面引用

我们来定义一个小的交点记录。

@dataclass
class SurfaceHit:
    t: float
    point: np.ndarray
    normal: np.ndarray
    inside_aperture: bool
    surface: IntersectableSurface

现在我们需要法线。

平面的表面法线

对于平面 z = z_v,法线可以是:

[0, 0, 1]

或:

[0, 0, -1]

两者在几何上都是有效的。符号对于折射很重要,因为斯涅尔定律使用法线方向。

对于本章,我们将平面的默认几何法线定义为:

[0, 0, 1]

然后在第6章中,我们将在应用矢量折射公式之前,相对于入射光线确定法线方向。

def plane_normal():
    return np.array([0.0, 0.0, 1.0])

球面的表面法线

对于球面,某点处的法线沿球心到该点的半径方向。

法线 = normalize(point - center)

代码:

def surface_normal(point, surface):
    """返回表面上一点处的几何单位法线。"""
    if math.isinf(surface.radius) or surface.surface_type == "plane":
        return plane_normal()

    center = sphere_center_from_surface(surface)
    return normalize(point - center)

测试正半径前表面顶点处的法线:

surface = IntersectableSurface(radius=50.0, z_vertex=0.0)
point = np.array([0.0, 0.0, 0.0])

print(surface_normal(point, surface))

法线为:

[0, 0, -1]

为什么 z 为负?因为球心位于 z = +50,而顶点位于 z = 0。从球心到顶点的向量指向负 z。

对于同一个双凸透镜的后表面:

back_surface = IntersectableSurface(radius=-50.0, z_vertex=5.0)
point = np.array([0.0, 0.0, 5.0])

print(surface_normal(point, back_surface))

法线为:

[0, 0, 1]

这很好。两个球面在其顶点处的几何法线方向相反。

现在不用担心法线是否指向折射的“正确方向”。在第6章中,我们会仔细处理方向问题。

目前,法线是表面几何的一个属性。

完整的求交函数

现在将各部分组合起来。

def intersect_surface(ray, surface, eps=1e-9):
    """如果光线与表面相交,返回 SurfaceHit,否则返回 None。"""
    if surface.surface_type == "plane" or math.isinf(surface.radius):
        t = intersect_plane_z(ray, surface.z_vertex, eps=eps)
    else:
        t = intersect_sphere(ray, surface, eps=eps)

    if t is None:
        return None

    point = ray.point_at(t)
    normal = surface_normal(point, surface)
    inside = inside_surface_aperture(point, surface)

    return SurfaceHit(
        t=t,
        point=point,
        normal=normal,
        inside_aperture=inside,
        surface=surface,
    )

测试:

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面"
)

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

hit = intersect_surface(ray, surface)

print("t:", hit.t)
print("point:", hit.point)
print("normal:", hit.normal)
print("inside aperture:", hit.inside_aperture)

这是我们的代码首次完整回答了几何问题。

光线击中了表面。我们知道在哪里。我们知道法线。我们知道该点是否在清晰孔径内。

下一章将使用法线和折射率来计算出射光线方向。

将光线移动到交点

一条交点记录告诉了我们光线会击中的位置。但光线对象本身尚未移动。

我们可以编写:

def propagate_to_surface(ray, surface, refractive_index=1.0):
    """如果可能,将光线移动到表面交点。

    不折射。仅传播到交点并检查孔径。
    """
    hit = intersect_surface(ray, surface)

    if hit is None:
        ray.stop(f"未击中表面: {surface.comment}")
        return None

    if not hit.inside_aperture:
        ray.propagate(hit.t, refractive_index=refractive_index)
        ray.stop(f"被孔径阻挡: {surface.comment}")
        return hit

    ray.propagate(hit.t, refractive_index=refractive_index)

    ray.history.append({
        "event": "surface_hit",
        "surface": surface.comment,
        "point": hit.point.copy(),
        "normal": hit.normal.copy(),
        "inside_aperture": hit.inside_aperture,
    })

    return hit

试试看:

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

hit = propagate_to_surface(ray, surface)

print("ray position:", ray.position)
print("ray alive:", ray.alive)
print("stop reason:", ray.stop_reason)

现在光线应停留在球面上。

同样,它尚未偏折。这种限制是故意为之。我们希望每一步都是可检查的。

第5章:光线击中何处?
第6章:击中后它去向何方?

不要太早把它们混在一起。

可视化光线-球面交点

虽然我们知道图片是由计算推导出来的,但一张图仍然有帮助。

让我们追踪若干条平行光线到球面,并绘制它们的交点。

import matplotlib.pyplot as plt

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前球面"
)

rays = [
    Ray(position=[x, 0.0, -20.0], direction=[0.0, 0.0, 1.0])
    for x in np.linspace(-9.0, 9.0, 7)
]

hit_points = []

for ray in rays:
    hit = intersect_surface(ray, surface)
    if hit is not None and hit.inside_aperture:
        hit_points.append(hit.point)

hit_points = np.array(hit_points)

# 在 x-z 平面绘制表面轮廓。
xs = np.linspace(-10.0, 10.0, 300)
zs = [spherical_sag(x, 0.0, surface) for x in xs]

plt.plot(zs, xs, label="球面")

# 绘制入射光线段和交点。
for ray, hp in zip(rays, hit_points):
    plt.plot([ray.position[2], hp[2]], [ray.position[0], hp[0]], linewidth=0.8)

plt.scatter(hit_points[:, 2], hit_points[:, 0], label="交点")
plt.xlabel("z [mm]")
plt.ylabel("x [mm]")
plt.title("平行光线击中球面")
plt.gca().set_aspect("equal", adjustable="box")
plt.legend()
plt.show()

该图应显示直的平行光线到达曲面上的不同点。轴外光线在曲面上击中不同的 z 位置,因为表面是弯曲的。

这不仅仅是一幅画。每个接触点都是计算得到的。

这一区别正是本章的重点。

如果光线偏离了怎么办?

光线可能会偏离球面。

例如,如果光线在表面外部很远的地方传播:

ray = Ray(position=[100.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])
hit = intersect_surface(ray, surface)

print(hit)

这可能返回 None,或者根据几何,它可能在数学上击中大的母球。但如果表面的半直径很小,它应该通不过孔径检查。

这就是为什么两项检查都很重要:

光线是否与数学表面相交?
交点是否在清晰孔径内?

对于光学表面,第二个问题通常和第一个同样重要。

一条光线可能击中了球体,但射失了透镜。

相切光线与数值精度

如果一条光线刚好擦过球面,二次方程的判别式接近于零。在数值上,这可能很微妙。

在精确数学中:

判别式 = 0

意味着相切。

在浮点计算中,本应为零的值可能由于舍入而出现:

-1e-14

这就是为什么一些光线追迹器使用容差。一个健壮的函数可能将非常小的负判别式视为零。

我们使用了:

sqrt_disc = math.sqrt(max(disc, 0.0))

但在此之前先检查:

if disc < 0:
    return None

对于教学代码,这是可以接受的。对于更健壮的代码,我们可能会写成:

if disc < -eps:
    return None
disc = max(disc, 0.0)

这将允许数值舍入引起的微小负值。

这里有一个更宽容的版本:

def intersect_sphere_tolerant(ray, surface, eps=1e-9):
    if math.isinf(surface.radius):
        return intersect_plane_z(ray, surface.z_vertex, eps=eps)

    center = sphere_center_from_surface(surface)
    radius_abs = abs(surface.radius)
    oc = ray.position - center

    a = float(np.dot(ray.direction, ray.direction))
    b = 2.0 * float(np.dot(ray.direction, oc))
    c = float(np.dot(oc, oc) - radius_abs * radius_abs)

    disc = b*b - 4.0*a*c

    if disc < -eps:
        return None

    disc = max(disc, 0.0)
    sqrt_disc = math.sqrt(disc)

    t1 = (-b - sqrt_disc) / (2.0*a)
    t2 = (-b + sqrt_disc) / (2.0*a)

    candidates = [t for t in (t1, t2) if t > eps]

    if not candidates:
        return None

    return min(candidates)

数值容差不是高级奢侈品。它是在有限精度机器上让几何正常工作的组成部分。

求交与局部坐标

到目前为止,所有表面都位于 z 轴上。这对最初几章来说是足够的。

一个真正的光学设计程序需要局部坐标。一个表面可能是倾斜的、偏心的,或者放置在坐标间断之后。光线可能需要被变换到表面的局部坐标系中,在那里求交,然后再变换回来。

通用模式是:

全局光线
→ 变换到局部表面坐标
→ 计算交点
→ 计算局部法线
→ 将点和法线变换回全局坐标

这听起来很复杂,也确实如此。但请注意,基本的求交问题保持不变。局部坐标系是为了让表面方程更简单。

我们不会在本书的核心教学代码中实现坐标间断。那会分散对主要计算链的注意力。但当你看到专业库处理倾斜反射镜、棱镜、离轴系统或自由曲面时,请记住它正在做这种坐标记录工作。

骨架仍然是:

光线 + 表面方程 → t → 点 → 法线

坐标变换只是准备输入,并将结果返回给全局系统。

从处方构建表面顶点

在第2章中,透镜处方给了我们厚度。对于求交,我们需要表面顶点位置。

让我们编写一个辅助函数,将一系列处方表面转换为带有 z 位置的可求交表面。

假设我们有一个像之前一样的 Surface 类:

@dataclass
class Surface:
    radius: float
    thickness: float
    material_after: str = "air"
    semi_diameter: Optional[float] = None
    conic: float = 0.0
    is_stop: bool = False
    surface_type: str = "spherical"
    comment: str = ""

现在进行转换:

def build_intersectable_surfaces(prescription_surfaces):
    """将处方表面转换为带有顶点 z 位置的表面。

    如果存在,跳过无限远物面行。
    """
    surfaces = prescription_surfaces

    if surfaces and math.isinf(surfaces[0].thickness):
        surfaces = surfaces[1:]

    z = 0.0
    result = []

    for s in surfaces:
        surface_type = s.surface_type

        if math.isinf(s.radius):
            surface_type = "plane"

        result.append(IntersectableSurface(
            radius=s.radius,
            z_vertex=z,
            semi_diameter=s.semi_diameter,
            surface_type=surface_type,
            comment=s.comment,
        ))

        if not math.isinf(s.thickness):
            z += s.thickness

    return result

现在透镜处方可以为求交代码提供输入。

这是第2章的数据结构首次与第4章的真实光线移动结合起来。

链条变得可执行了:

处方厚度
→ 表面顶点位置
→ 光线-表面求交
→ 交点和法线

无折射追踪通过两个表面

让我们追踪一条光线到一个单透镜的两个表面,但不使其偏折。

这在光学上是错误的,但作为几何是有用的。

prescription = [
    Surface(
        radius=float("inf"),
        thickness=float("inf"),
        surface_type="object",
        comment="无限远物面"
    ),
    Surface(
        radius=50.0,
        thickness=5.0,
        material_after="N-BK7",
        semi_diameter=10.0,
        comment="前表面"
    ),
    Surface(
        radius=-50.0,
        thickness=45.0,
        material_after="air",
        semi_diameter=10.0,
        comment="后表面"
    ),
    Surface(
        radius=float("inf"),
        thickness=0.0,
        surface_type="image",
        comment="像平面"
    ),
]

surfaces = build_intersectable_surfaces(prescription)

ray = Ray(position=[3.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])

for s in surfaces:
    hit = propagate_to_surface(ray, s, refractive_index=1.0)

    if hit is None or not ray.alive:
        break

    print(s.comment, ray.position, "normal", hit.normal)

光线穿过前表面,然后后表面,最后到达像平面。它没有偏折,因此这不是真正的光学追踪。但它确认了几何正常工作。

这种中间测试非常有用。

在添加折射之前,确保求交工作。

在添加波前之前,确保折射工作。

在添加点扩散函数之前,确保光程差工作。

一个黑箱往往就是太多未经测试的步骤堆叠在一起。

像平面只是另一个平面求交

在解释上,像平面通常是特殊的,但在几何上它很简单。

它通常被表示为一个垂直于光轴的平面:

z = z_image

当光线到达它时,我们记录它们的横向坐标:

x_image, y_image

这些坐标成为点列图点。

如果像平面仅是一个评估表面,它不需要折射。它是我们测量光线的地方,而不一定是物理边界。

这就是为什么我们的 build_intersectable_surfaces 函数可以将无限远半径的像面当作平面处理。

稍后,在完整的追迹中,像平面将是最后一个表面。到达它的光线将用于几何像分析。

表面法线与折射是分开的工作

我们很容易直接从求交跳到偏折。让我们多坚持一小会儿。

在表面交点上,我们现在知道了:

点
法线
前材料
后材料
光线方向
波长

折射计算将使用:

入射方向
表面法线
n_before
n_after

但法线本身是一个几何对象。它应该在斯涅尔定律之前且独立地计算出来。

这种分离使代码保持干净:

求交代码:
    返回点和法线

材料代码:
    返回 n_before 和 n_after

折射代码:
    返回新方向

如果所有这些都混在一个函数里,调试就会变得痛苦。

当光线偏折不正确时,你将不知道是点错了,法线错了,折射率错了,还是矢量公式错了。

小函数不仅仅是风格偏好。它们使光学错误变得可见。

下一步的预览

在本章结束时,一条击中球面的光线有一个入射方向和一个法线。

例如:

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])
surface = IntersectableSurface(radius=50.0, z_vertex=0.0, semi_diameter=10.0)

hit = intersect_surface(ray, surface)

print("入射方向:", ray.direction)
print("表面法线:", hit.normal)

入射方向可能是:

[0, 0, 1]

法线将是倾斜的,因为光线击中了远离光轴的曲面:

法线 ≈ [小的 x 分量, 0, 主要是负的 z]

那个倾斜的法线正是轴外光线偏折不同于轴上光线的原因。斯涅尔定律并不看整个透镜图。它看到的是交点处的局部表面法线。

这就是折射的局部性质。

曲面透镜并非魔法。它是许多逐点变化的局部表面法线。

这是一个美妙的想法,同时也具有计算上的实用性。

光线-表面求交中的常见错误

让我们在它们造成困扰之前,把这些陷阱点出来。

错误1:忘记选择正向交点

二次方程给出数学根。有些可能位于光线之后。使用正的 t 值,并选择最近的有效值。

错误2:忽略孔径限制

一条光线可能击中了母球,但在实际透镜表面之外。一定要检查清晰孔径。

错误3:混淆半径符号与半径大小

带符号的半径定位曲率中心。球面方程使用半径的大小。

球心 z = z_vertex + R
球面半径 = |R|

错误4:使用错误的表面分支

一个完整的球有两个面。一个序列光学表面是其顶点附近的一个局部区域。在通常情况下,最近的正向交点有效,但更复杂的系统需要谨慎。

错误5:不使用容差

浮点计算并不精确。非常小的负判别式或微小的 t 值可能导致虚假的错射或自交。

错误6:混淆垂度和求交逻辑

垂度给出已知横向坐标的表面高度。求交解决光线在何处遇到表面。它们是相关的,但回答不同的问题。

错误7:在验证交点之前计算折射

不要折射一条射失表面或被孔径阻挡的光线。

错误8:丢失法线方向

表面法线必须归一化并小心存储。折射章节将需要它。

本章给我们的东西

我们现在拥有了光线追迹的几何核心。

我们可以:

表示一个平面表面
表示一个带符号半径的球面
计算光线-平面求交
计算光线-球面求交
选择最近的正向交点
检查交点是否在清晰孔径内
在交点处计算表面法线
将光线移动到表面
记录交点

这不是一个小成就。

第4章中的光线只能在空的空间中传播。

在第5章之后,光线可以找到玻璃。

它仍不能偏折。但这对于一次基本的真实光线追迹而言,是唯一缺失的动作。

下一章将添加它。

我们将获取入射方向、表面法线以及第3章中的折射率。然后使用矢量形式的斯涅尔定律计算出射方向。

在那时,光线将最终实现图中一直展示的行为:

它将击中表面
并改变方向

但这一次,我们将确切地知道那发生在何处,使用了哪个法线,哪些折射率,以及新的方向是如何计算的。

这就是一条软件指令变成一次可检查的计算的过程。

生成的验证图

从第5章求交模型生成的光线与曲面交点图

第6章:如何计算折射方向

一束光线现在到达了表面。

这句话听起来简单,只是因为我们早已完成了那些隐秘的工作。

在第4章中,我们为光线赋予了位置、方向、波长、强度以及光程。在第5章中,我们让这束光线与平面或球面发生碰撞。我们计算了碰撞点,检查了通光孔径,又计算了局部表面法线。

现在,这束光线就位于表面处。

它有一个入射方向:

d_in

表面有一个局部法线:

N

材料模型给出了两侧的折射率:

n1 = 表面前的折射率
n2 = 表面后的折射率

下一个问题是每个光学学子都期待的:

光线经过折射后,会朝哪个方向前进?

本章就将回答这个问题。

我们会从标量形式的斯涅耳定律开始,但不会止步于此。光学软件通常不会用量角器画角度来追迹光线,它用的是方向向量和表面法线。因此,有用的形式是矢量折射。

这正是许多错误发生的地方。

公式本身并不算长。困难之处在于:

法线方向
单位向量
哪个折射率是 n1,哪个是 n2
全内反射
数值容差

我们会慢慢来。这一章是本书中至关重要的章节之一。

在这一章之后,光线最终会做出镜头图纸上一直展示的那些动作:

撞击一个表面
根据局部法线及折射率发生偏折
继续沿着新的方向前进

只是现在,偏折将通过计算得出。

从标量斯涅耳定律开始

我们熟悉的斯涅耳定律形式是:

n1 sin θ1 = n2 sin θ2

其中:

n1 = 入射介质的折射率
n2 = 透射介质的折射率
θ1 = 从表面法线量起的入射角
θ2 = 从表面法线量起的折射角

这条定律指出,当光线穿过折射率不同的介质时,其方向会发生改变。

如果光从空气进入玻璃:

n1 ≈ 1.0
n2 ≈ 1.5

那么光线会向法线方向偏折。

如果光从玻璃进入空气:

n1 ≈ 1.5
n2 ≈ 1.0

那么光线会偏离法线。

当光线以 θ1=0 垂直入射时,其几何方向保持不变。虽然光在介质中的速度和波长可能会改变,但它的几何方向保持不变。

我们先写一个标量角度函数,仅仅是为了观察定律的数值表现。

import math

def snell_angle(theta1_deg, n1, n2):
    """返回用标量斯涅耳定律算出的折射角(度)。

    theta1_deg 是从表面法线量起的角度。
    如果发生全内反射,则返回 None。
    """
    theta1 = math.radians(theta1_deg)
    sin_theta2 = (n1 / n2) * math.sin(theta1)

    if abs(sin_theta2) > 1.0:
        return None

    theta2 = math.asin(sin_theta2)
    return math.degrees(theta2)

再试一下空气到玻璃的情况:

for theta in [0, 10, 20, 30, 40]:
    theta2 = snell_angle(theta, n1=1.0, n2=1.5)
    print(theta, "→", theta2)

折射角更小,光线向法线方向偏折。

再试试玻璃到空气:

for theta in [0, 10, 20, 30, 40, 50]:
    theta2 = snell_angle(theta, n1=1.5, n2=1.0)
    print(theta, "→", theta2)

在某个入射角,函数会返回 None,这就是全内反射。

标量形式有助于建立直觉,但对于真正的光线追迹来说,它还不够。

光线追迹程序通常不直接存储“与法线的夹角”,它存储的是:

入射方向向量
表面法线向量

所以我们需要矢量版本。

为什么标量角度不够用

想象一束光线偏离光轴射到一个球面上。

局部法线不再是 [0, 0, 1],它从球心指向碰撞点。它的 x 分量和 y 分量可能不再为零。

在标量斯涅耳定律中,你可以这样写:

入射角是 θ1
折射角是 θ2

但在三维代码中,这并不能告诉你出射向量是什么。除非同时知道入射面,否则有无数个方向能以同样的角度与法线相交。

入射面由以下两者定义:

入射方向
表面法线

折射光线必须位于该平面内。用矢量公式可以自然而然地处理这一点。

所以计算任务就是:

给定:
    d_in, normal, n1, n2

计算:
    d_out

其中两个方向向量都是单位长度。

这正是我们想要的函数:

d_out = refract_direction(d_in, normal, n1, n2)

但在编写这个函数之前,我们需要处理一个看似琐碎却极其重要的细节。

法线可能有两个方向

表面法线可以指向两个完全相反的方向。

对于一个平面而言,下面这两个法线都是有效的:

[0, 0, 1]
[0, 0, -1]

对于一个球面,其几何法线通常指向球心外侧。那是一个完全合理的几何法线。但对于折射来说,公式要求法线相对于入射光线的方向保持一致。

这正是许多光线追迹程序最先出错的地方。

让我们定义一条约定:

在应用矢量折射公式之前,需要先调整法线的方向,使其与入射光线的方向相反。

换句话说,调整之后应满足:

dot(d_in, normal) <= 0

如果入射光线大致沿着 +z 方向,调整后的法线应大致指向入射侧的后方。

以下是辅助函数:

import numpy as np

def normalize(v):
    """返回向量 v 的单位长度副本。"""
    v = np.asarray(v, dtype=float)
    norm = np.linalg.norm(v)

    if norm == 0:
        raise ValueError("无法对零向量进行归一化。")

    return v / norm

def orient_normal_against_ray(direction, normal):
    """调整法线方向,使其与入射方向相反。"""
    d = normalize(direction)
    n = normalize(normal)

    if np.dot(d, n) > 0:
        n = -n

    return n

测试一下:

d_in = np.array([0.0, 0.0, 1.0])

print(orient_normal_against_ray(d_in, [0, 0, 1]))
print(orient_normal_against_ray(d_in, [0, 0, -1]))

两种情形的输出结果都应为:

[ 0.  0. -1.]

这正是我们想要的。折射函数不应关心表面给出的是 [0,0,1] 还是 [0,0,-1],它应该在使用法线之前先调整其方向。

这个小小的函数消除了大量由符号错误引起的 bug。

矢量折射公式

设:

i = 入射单位方向
n = 调整后与入射方向相反的单位法线
η = n1 / n2

入射角的余弦是:

cosθ1 = - i · n

透射方向可以写作:

t = η i + (η cosθ1 - cosθ2) n

其中:

cosθ2 = sqrt(1 - η²(1 - cos²θ1))

平方根内的项至关重要:

k = 1 - η²(1 - cos²θ1)

如果 k < 0,则不存在实数的折射光线,即发生全内反射。

以上就是完整的计算逻辑。

归一化入射方向
归一化并调整法线方向
计算 η = n1 / n2
计算 cosθ1
计算 k
如果 k < 0:全内反射
否则计算出射方向
归一化出射方向

现在用 Python 来写。

def refract_direction(direction, normal, n1, n2, eps=1e-12):
    """用矢量斯涅耳定律计算折射光线方向。

    参数
    ----------
    direction:
        入射光线方向。不需要预先归一化。

    normal:
        碰撞点的表面法线。它可以指向任意方向;
        本函数会将其调整为与入射光线相反的方向。

    n1:
        入射介质的折射率。

    n2:
        透射介质的折射率。

    返回值
    -------
    单位出射方向向量;如果发生全内反射,则返回 None。
    """
    i = normalize(direction)
    n = orient_normal_against_ray(i, normal)

    eta = n1 / n2
    cos_theta1 = -float(np.dot(i, n))

    # 数值安全:确保余弦值在有效范围内。
    cos_theta1 = max(-1.0, min(1.0, cos_theta1))

    sin2_theta2 = eta * eta * (1.0 - cos_theta1 * cos_theta1)

    if sin2_theta2 > 1.0 + eps:
        return None

    # 钳制微小数值溢出。
    sin2_theta2 = min(1.0, max(0.0, sin2_theta2))
    cos_theta2 = math.sqrt(1.0 - sin2_theta2)

    t = eta * i + (eta * cos_theta1 - cos_theta2) * n
    return normalize(t)

这个函数就是本章的核心。

它不是一个黑盒,它是斯涅耳定律的直接计算形式。

入射光线方向和表面法线决定了入射面。折射率比值控制着偏折程度。平方根测试则检测全内反射。

现在我们必须仔细测试它。

一个测量角度的辅助函数

为了与标量斯涅耳定律进行比较,我们需要一种方法来测量方向与法线之间的夹角。

法线有两个方向,即 normal-normal,因此我们使用点积的绝对值。

def angle_to_normal_line(direction, normal):
    """返回方向与法线之间较小的夹角。"""
    d = normalize(direction)
    n = normalize(normal)
    c = abs(float(np.dot(d, n)))
    c = max(-1.0, min(1.0, c))
    return math.degrees(math.acos(c))

现在我们可以将矢量折射与标量斯涅耳定律进行比较测试。

测试1:垂直入射

沿法线传播的光线不应改变方向。

d_in = np.array([0.0, 0.0, 1.0])
normal = np.array([0.0, 0.0, -1.0])

d_out = refract_direction(d_in, normal, n1=1.0, n2=1.5)

print("入射方向:", d_in)
print("出射方向:", d_out)
print("角度:", angle_to_normal_line(d_out, normal))

出射方向仍应为:

[0, 0, 1]

与法线的夹角应为 0

这是一个基本的完整性检查。如果这里失败了,不要继续,先修正法线方向或公式。

测试2:空气到玻璃

现在使用与法线成30度角的光线。设z轴为法线方向,并让光线在x-z平面内传播。

def direction_from_angle_to_z(theta_deg):
    """在 x-z 平面内、大致沿 +z 方向传播的方向。"""
    theta = math.radians(theta_deg)
    return normalize([math.sin(theta), 0.0, math.cos(theta)])

d_in = direction_from_angle_to_z(30.0)
normal = np.array([0.0, 0.0, -1.0])

d_out = refract_direction(d_in, normal, n1=1.0, n2=1.5)

print("入射角:", angle_to_normal_line(d_in, normal))
print("出射角:", angle_to_normal_line(d_out, normal))
print("标量斯涅耳:", snell_angle(30.0, n1=1.0, n2=1.5))
print("出射方向:", d_out)

矢量的出射角度应与标量斯涅耳定律相符。

从空气到玻璃会向法线偏折,因此出射角应小于30度。

这个测试证实了在简单平面情况下矢量公式与标量定律是一致的。

测试3:玻璃到空气

现在交换折射率。

d_in = direction_from_angle_to_z(30.0)
normal = np.array([0.0, 0.0, -1.0])

d_out = refract_direction(d_in, normal, n1=1.5, n2=1.0)

print("入射角:", angle_to_normal_line(d_in, normal))
print("出射角:", angle_to_normal_line(d_out, normal))
print("标量斯涅耳:", snell_angle(30.0, n1=1.5, n2=1.0))
print("出射方向:", d_out)

从玻璃到空气会偏离法线,因此出射角应大于30度。

同样,矢量结果应与标量结果相符。

测试4:全内反射

当光试图以过于陡峭的角度从较高折射率介质进入较低折射率介质时,会发生全内反射。

临界角满足:

sin θc = n2 / n1

其中 n1 > n2。

对于玻璃到空气:

θc = arcsin(1.0 / 1.5) ≈ 41.8 度

因此,50度的入射角不应产生透射光线。

d_in = direction_from_angle_to_z(50.0)
normal = np.array([0.0, 0.0, -1.0])

d_out = refract_direction(d_in, normal, n1=1.5, n2=1.0)

print(d_out)

结果应为:

None

这并不代表光线在物理上消失了,而是意味着不存在折射的透射光线。此时界面会对光线进行内部反射。在折射透镜追迹路径中,根据系统模型的不同,我们可以终止该光线,也可以处理反射。

对于本书中基础的折射光线追迹器,我们将会终止该光线并记录原因。

全内反射并非错误

很容易将 None 当作代码的失败,但事实并非如此。

全内反射是一种真实的物理状态。代码通过检测到不存在折射方向来完成其职责。

光线追迹器接下来做什么,取决于光学模型。

对于纯折射系统,可以将该光线标记为无效:

终止光线:全内反射

对于支持反射的系统,则可以改为计算反射方向:

d_reflect = d_in - 2(d_in · n)n

在此我们不会构建反射式光线追迹器,但值得了解的是,全内反射是一个分支事件,而非数值意外。

在我们基础透镜光线追迹器中,首先停止追迹光线是最合适的行为。

用于对比的反射函数

尽管本章的重点是折射,但反射的公式足够简短,值得展示一下。它也有助于理解法线方向的问题。

def reflect_direction(direction, normal):
    """将方向关于表面法线进行反射。"""
    i = normalize(direction)
    n = normalize(normal)

    return normalize(i - 2.0 * np.dot(i, n) * n)

如果发生全内反射,更完整的追迹器可以使用此函数。目前,当折射无法进行时,我们仅会终止光线的折射追迹。

本书的主线是透镜成像,而非反射镜系统。

将波长重新纳入计算

到目前为止,我们使用的都是固定值,例如:

n1 = 1.0
n2 = 1.5

但在第3章中,我们已经知道真实材料的折射率是随波长变化的:

n = n(λ)

光线携带了波长信息:

ray.wavelength_um

因此,在每个表面上,追迹代码都应该向材料库查询:

n1 = materials.n(material_before, ray.wavelength_um)
n2 = materials.n(material_after, ray.wavelength_um)

这就是色散折射进入光线追迹器的方式。

一束蓝光和一束红光可以有相同的入射方向,并撞击到相同的表面点。但由于玻璃在不同波长下的折射率不同,它们的出射方向会略有差异。

让我们用一个平面界面来演示一下。

假设我们有第3章中的 N-BK7 材料模型:

# N_BK7.n(wavelength_um) 返回折射率。
# 在本示例中,假设 N_BK7 已经定义好了。

现在对比蓝光、绿光和红光以30度角进入玻璃的情况。

F_LINE_UM = 0.4861327
D_LINE_UM = 0.5875618
C_LINE_UM = 0.6562725

normal = np.array([0.0, 0.0, -1.0])
d_in = direction_from_angle_to_z(30.0)

for wl in [F_LINE_UM, D_LINE_UM, C_LINE_UM]:
    n_glass = N_BK7.n(wl)
    d_out = refract_direction(d_in, normal, n1=1.0, n2=n_glass)
    angle = angle_to_normal_line(d_out, normal)

    print(f"{wl:.7f} µm  n={n_glass:.7f}  出射角={angle:.6f} deg")

虽然差异很小,但却真实存在。

在正常色散材料中,较短波长通常对应更高的折射率,因此蓝光会比红光向法线偏折得更厉害一些。

这就是通过一行代码体现出的色散行为:

n_glass = N_BK7.n(wl)

这就是第3章不可或缺的原因。如果没有与波长相关的材料折射率,光线追迹器就无法展现色差。

曲面上的折射

现在,让我们使用第5章所给出的内容:一个球面上的碰撞点和局部法线。

想象一束平行光线射入一个 N-BK7 球面透镜的前表面。

我们已经有了:

光线
表面
intersect_surface(ray, surface)

碰撞记录中包含:

hit.point
hit.normal

现在我们计算出射方向。

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面球面"
)

ray = Ray(
    position=[5.0, 0.0, -20.0],
    direction=[0.0, 0.0, 1.0],
    wavelength_um=0.5875618
)

hit = intersect_surface(ray, surface)

n_air = 1.0
n_glass = N_BK7.n(ray.wavelength_um)

d_out = refract_direction(
    direction=ray.direction,
    normal=hit.normal,
    n1=n_air,
    n2=n_glass
)

print("碰撞点:", hit.point)
print("表面法线:", hit.normal)
print("入射方向:", ray.direction)
print("出射方向:", d_out)

对于离轴光线,表面法线具有 x 分量。出射方向也将获得 x 分量。这就是表面在聚焦光线。

这正是透镜发挥作用的局部原因。

镜头并不知道像面在哪里。在每个表面上,光线只看到:

局部法线
入射方向
折射率 n1
折射率 n2

整体的聚焦行为是由许多局部折射和传播汇聚而成的。

这是一个强有力的理念,也正是光线追迹软件所计算的内容。

在表面上更新光线

折射函数返回一个方向,但我们的光线对象必须相应地更新。

一次表面交互包括以下步骤:

1. 找到表面碰撞点
2. 将光线通过当前介质传播到碰撞点
3. 检查孔径
4. 计算折射率
5. 计算折射方向
6. 更新光线方向
7. 继续进入下一个介质

第3、4、5章几乎提供了所有所需的部件,现在我们将它们组合起来。

首先,定义一个小的结果对象:

from dataclasses import dataclass

@dataclass
class RefractionResult:
    success: bool
    direction: np.ndarray | None
    reason: str = ""

如果你的 Python 版本不支持 np.ndarray | None,可以从 typing 导入 Optional[np.ndarray]

现在包装折射调用:

def refract_at_surface(ray, hit, n1, n2):
    """返回光线在表面碰撞处的折射结果。"""
    d_out = refract_direction(
        direction=ray.direction,
        normal=hit.normal,
        n1=n1,
        n2=n2
    )

    if d_out is None:
        return RefractionResult(
            success=False,
            direction=None,
            reason="total internal reflection"
        )

    return RefractionResult(
        success=True,
        direction=d_out,
        reason=""
    )

然后,一次表面交互就可以更新光线:

def interact_refractive_surface(ray, surface, n1, n2):
    """将光线传播到一个表面并进行折射。

    此函数假设 n1 是表面前的介质折射率,
    n2 是表面后的介质折射率。
    """
    if not ray.alive:
        return None

    hit = intersect_surface(ray, surface)

    if hit is None:
        ray.stop(f"missed surface: {surface.comment}")
        return None

    # 在入射介质中传播至碰撞点。
    ray.propagate(hit.t, refractive_index=n1)

    if not hit.inside_aperture:
        ray.stop(f"blocked by aperture: {surface.comment}")
        return hit

    result = refract_at_surface(ray, hit, n1=n1, n2=n2)

    if not result.success:
        ray.stop(result.reason)
        return hit

    old_direction = ray.direction.copy()
    ray.direction = result.direction

    ray.history.append({
        "event": "refract",
        "surface": surface.comment,
        "point": ray.position.copy(),
        "normal": hit.normal.copy(),
        "n1": n1,
        "n2": n2,
        "direction_before": old_direction,
        "direction_after": ray.direction.copy(),
    })

    return hit

这个函数是一个里程碑。

现在光线可以:

找到一个表面
移动到该表面
检查孔径
偏折进入下一介质
以新方向继续前进

这就是真正的光线追迹。

虽然仍然简单,但确实是真实的。

穿过单个球面界面的追迹

让我们追迹几束平行光线,观察它们如何穿过一个球面空气-玻璃界面的。

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="air-to-glass spherical surface"
)

rays = [
    Ray(position=[x, 0.0, -20.0], direction=[0.0, 0.0, 1.0], wavelength_um=0.5875618)
    for x in np.linspace(-8.0, 8.0, 5)
]

n_air = 1.0
n_glass = N_BK7.n(0.5875618)

for ray in rays:
    interact_refractive_surface(ray, surface, n1=n_air, n2=n_glass)

    print(
        "位置:", ray.position,
        "方向:", ray.direction,
        "存活:", ray.alive
    )

轴上光线应保持在 z 方向上。离轴光线应向内侧偏折。相对两侧的光线应呈对称偏折。

这种对称性是一项有用的健全性检查。如果两侧的离轴光线都向横向的同一方向偏折,那一定是哪里出了问题。

最可能的原因包括:

法线符号错误
球面中心符号错误
n1/n2 顺序错误
交点分支选取错误

由于我们保持了各函数间的独立性,调试路线已经十分清晰。

可视化偏折

让我们绘制围绕球面的入射与出射线段。

import matplotlib.pyplot as plt

def line_points(start, direction, length):
    start = np.asarray(start, dtype=float)
    direction = normalize(direction)
    end = start + length * direction
    return start, end

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面球面"
)

rays = [
    Ray(position=[x, 0.0, -20.0], direction=[0.0, 0.0, 1.0], wavelength_um=0.5875618)
    for x in np.linspace(-8.0, 8.0, 7)
]

plt.figure()

# 在 x-z 平面中绘制表面轮廓。
xs = np.linspace(-10.0, 10.0, 300)
zs = [spherical_sag(x, 0.0, surface) for x in xs]
plt.plot(zs, xs, label="spherical surface")

for ray in rays:
    start = ray.position.copy()
    incoming_dir = ray.direction.copy()

    hit = interact_refractive_surface(ray, surface, n1=1.0, n2=N_BK7.n(ray.wavelength_um))

    if hit is None:
        continue

    hit_point = ray.position.copy()
    outgoing_dir = ray.direction.copy()

    # 入射线段。
    plt.plot([start[2], hit_point[2]], [start[0], hit_point[0]], linewidth=0.8)

    # 出射线段的预览。
    end = hit_point + 25.0 * outgoing_dir
    plt.plot([hit_point[2], end[2]], [hit_point[0], end[0]], linewidth=0.8)

plt.xlabel("z [mm]")
plt.ylabel("x [mm]")
plt.title("球面空气-玻璃界面的折射")
plt.gca().set_aspect("equal", adjustable="box")
plt.legend()
plt.show()

这幅图是真正计算得来的。

它并非一张装饰性的草图,而是对计算出的交点和计算出的折射方向的可视化呈现。

这正是我们在全书中希望推动的那种转变。

n1 与 n2 的顺序至关重要

在透镜的前表面:

空气 → 玻璃

因此:

n1 = n_air
n2 = n_glass

在透镜的后表面:

玻璃 → 空气

因此:

n1 = n_glass
n2 = n_air

如果你把它们弄反了,光线就会向错误的方向偏折。

这就是第3章中材料过渡逻辑的重要性所在。追迹代码不应随意猜测。

像下面这样的函数会很有帮助:

def material_before_after(lens, surface_index, starting_material="air"):
    """返回顺序表面之前和之后的材料名称。"""
    if surface_index == 0:
        before = starting_material
    else:
        before = lens.surfaces[surface_index - 1].material_after

    after = lens.surfaces[surface_index].material_after
    return before, after

def index_transition(lens, materials, surface_index, wavelength_um):
    before_name, after_name = material_before_after(lens, surface_index)
    n_before = materials.n(before_name, wavelength_um)
    n_after = materials.n(after_name, wavelength_um)
    return n_before, n_after

然后在某个表面上:

n1, n2 = index_transition(lens, materials, surface_index, ray.wavelength_um)

这一行代码将正确的折射率顺序编码了进去。

没有这套规范,多表面追迹就会变得很脆弱。

将交点求取与材料过渡结合

在顺序系统中,一个完整的表面步骤需要同时用到几何数据和材料数据。

表面几何告诉我们:

光线在何处碰撞
法线是什么
光线是否在孔径内

而透镜规格和材料库告诉我们:

n_before
n_after

折射公式则告诉我们:

新的方向

让我们勾勒一下组合循环。

def trace_refractive_surfaces(ray, intersectable_surfaces, lens, materials, start_index=1):
    """将一束光线追迹通过多个折射表面。

    这是一个简化的骨架。它假设 intersectable_surfaces 与
    经过对象行处理后的 lens.surfaces 对应相同的顺序表面。
    """
    for local_i, surface in enumerate(intersectable_surfaces):
        if not ray.alive:
            break

        # 跳过对象面(如果存在)。
        if surface.comment.lower().startswith("object"):
            continue

        # 像面是一个评估面,而非折射边界。
        if surface.comment.lower().startswith("image"):
            hit = intersect_surface(ray, surface)
            if hit is None:
                ray.stop("missed image plane")
                break
            ray.propagate(hit.t, refractive_index=1.0)
            break

        prescription_index = start_index + local_i
        n1, n2 = index_transition(
            lens,
            materials,
            prescription_index,
            ray.wavelength_um
        )

        interact_refractive_surface(ray, surface, n1=n1, n2=n2)

    return ray

这仍然是简化版。它假设了一个直接的顺序系统。它不处理坐标转折、反射镜、反射镀膜、浸没介质、倾斜表面或更复杂的孔径定义。

这样就可以了。

在同一个地方集中表达了计算链条:

光线波长
→ 材料折射率
→ 表面碰撞点
→ 法线
→ 矢量斯涅耳折射
→ 更新后的光线方向

这就是真实光线追迹的核心。

双面单透镜追迹

现在我们来做一个基本的双凸单透镜。

我们将追迹光线通过:

前表面:空气 → N-BK7
后表面:N-BK7 → 空气
像面:   仅用于评估

使用前面章节中的表面:

front = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面"
)

back = IntersectableSurface(
    radius=-50.0,
    z_vertex=5.0,
    semi_diameter=10.0,
    comment="后表面"
)

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

追迹一束光线:

ray = Ray(
    position=[5.0, 0.0, -20.0],
    direction=[0.0, 0.0, 1.0],
    wavelength_um=0.5875618
)

n_air = 1.0
n_glass = N_BK7.n(ray.wavelength_um)

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

# 在空气中传播到像面。
hit_image = intersect_surface(ray, image)
ray.propagate(hit_image.t, refractive_index=n_air)

print("像点:", ray.position)
print("最终方向:", ray.direction)
print("OPL:", ray.opl)
print("存活:", ray.alive)
print("终止原因:", ray.stop_reason)

这是首次完成一个简单透镜的完整折射追迹。

它还不是一个成熟的光学设计程序。它没有从适当的物面视场发射光线,也没有计算最佳焦点,也不处理所有边缘情况。但它执行了核心操作:

空气传播
前表面交点求取
空气到玻璃的折射
玻璃传播
后表面交点求取
玻璃到空气的折射
空气传播至像面

这正是许多光学设计工具底层的骨架。

从一束光线到一个小光扇

让我们追迹一个平行光扇穿过单透镜,并标出它们的落点。

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

现在追迹若干条光线。

rays = [
    Ray(position=[x, 0.0, -20.0], direction=[0.0, 0.0, 1.0], wavelength_um=0.5875618)
    for x in np.linspace(-8.0, 8.0, 9)
]

traced = [
    trace_simple_singlet_ray(ray, front, back, image, N_BK7)
    for ray in rays
]

for ray in traced:
    print(ray.position, ray.alive, ray.stop_reason)

如果像面没有恰好位于最佳焦点处,光线可能不会汇聚到一个点上。这很正常。事实上,这正是图像分析的起点。

一个真正的镜头设计程序会帮助寻找焦点,计算近轴量,并在选定的视场和波长下生成点列图。我们还没有到达那一步。但现在光线确实已经穿过了折射表面。

追迹机制已经运转起来了。

绘制追迹出的光线

由于我们的光线存储了历史记录,我们可以画出它们的路径。

def path_points_from_ray(ray):
    points = []

    for event in ray.history:
        if event["event"] == "propagate":
            if not points:
                points.append(event["from"])
            points.append(event["to"])

    if len(points) == 0:
        return np.empty((0, 3))

    return np.array(points)

plt.figure()

# 绘制大致的表面轮廓。
for s in [front, back]:
    xs = np.linspace(-s.semi_diameter, s.semi_diameter, 300)
    zs = [spherical_sag(x, 0.0, s) for x in xs]
    plt.plot(zs, xs, color="black", linewidth=1.0)

# 绘制像面。
plt.axvline(image.z_vertex, linestyle="--", linewidth=1.0)

# 绘制光线路径。
for ray in traced:
    path = path_points_from_ray(ray)
    if len(path) > 0:
        plt.plot(path[:, 2], path[:, 0], linewidth=0.8)

plt.xlabel("z [mm]")
plt.ylabel("x [mm]")
plt.title("穿过折射单透镜的小光扇")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()

这幅图依然简单,但它已经跨越了一条重要的界限。

光线在表面处偏折,是因为代码计算了以下内容:

表面碰撞点
局部法线
n1 和 n2
矢量斯涅耳方向

这已经不仅仅是几何学了,它已经是折射光线追迹了。

为何不同高度的光线偏折不同

一束平行的轴上光线会以不同的高度打到透镜上。

在球面的中心处,法线与光轴对齐。离轴越远,法线就越倾斜。

斯涅耳定律使用的是入射光线与局部法线之间的夹角。因此,不同的光线高度会对应不同的入射角。

这意味着球面并不会将所有平行光线偏折完全相同的量。这就是球差存在的原因之一。我们稍后会研究它,但目前这一原因已可见端倪。

在每一个光线高度:

相同的入射方向
不同的表面法线
不同的折射方向

这就是边缘光线和近轴光线可能不会汇聚在同一焦点上的计算层面原因。

透镜并不是以某个整体动作去“聚焦光线”的,它是在整个表面上局部地改变光线的方向。而像面正是这些局部作用的集体结果。

这是一种更好的思维模型。

为何不同波长的光线偏折不同

现在追迹同一光线在三个不同波长下的情形。

for wl in [F_LINE_UM, D_LINE_UM, C_LINE_UM]:
    ray = Ray(
        position=[5.0, 0.0, -20.0],
        direction=[0.0, 0.0, 1.0],
        wavelength_um=wl
    )

    trace_simple_singlet_ray(ray, front, back, image, N_BK7)

    print(
        f"λ={wl:.7f} µm  "
        f"像面 x={ray.position[0]:.6f}  "
        f"z={ray.position[2]:.3f}  "
        f"存活={ray.alive}"
    )

像面 x 位置可能略有不同,最终方向也可能不同。

这一微小的差异就是实际追迹中色差产生的根源。

再次强调,这并不神秘。它源于:

N_BK7.n(wavelength)

这改变了 n1/n2,进而改变了折射方向。

整个链条都是明确的。

Optiland 在高层次上做了什么

此时,将我们的教学代码与真实库的工作流程进行比较会很有帮助。

在我们的代码中,我们手动编写了:

求表面交点
计算法线
查找折射率
应用矢量斯涅耳定律
更新光线

在成熟的光学设计库中,这些步骤都被封装在追迹方法之中。用户可能只需几行代码来定义镜头并绘制追迹出的光线。

一个类似 Optiland 风格的单透镜工作流程可能如下所示:

from optiland import optic

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.wavelengths.add(value=0.5876, is_primary=True)

lens.draw(num_rays=7)

根据所安装的 Optiland 版本,具体的光线访问方法可能会有所不同。这里进行比较的目的并不是记忆某个特定的 API 调用,而是为了认识到,当库绘制折射光线时,它在底层必须完成哪些工作。

对于每束光线以及每个折射表面,都会发生某种形式的以下过程:

找到碰撞点
找到法线
计算折射率
应用斯涅耳定律
继续追迹

库可能对其进行了向量化处理,可能使用了后端的数组系统,可能支持更多种类的表面类型,也可能包含了鲁棒的光线瞄准和瞳孔管理功能,但它无法跳过物理定律。

软件的命令可以隐藏这些步骤。

但它无法废除这些步骤。

如何将我们的结果与库的结果对比

一次良好的对比要保持适度。

不要一开始就去对比整个 MTF 曲线,那中间隔着太多环节。先对比一个表面或一个简单的单透镜。

一个实用的对比计划是:

1. 在教学代码和 Optiland 中构建同一个简单透镜。
2. 使用相同的波长。
3. 如果 API 暴露了光线层级的追迹,使用相同的入射光线高度和方向。
4. 比较经过第一表面之后的光线位置和方向。
5. 然后比较经过第二表面之后的情况。
6. 只有在这之后,才去比较像面的截距。

如果库不能方便地暴露同层级的光线数据,可以先比较布局图或最终光线截距。但概念上,最好的对比方式是逐表面进行。

当结果出现差异时,不要立刻认为库有误或自己的代码有误。首先检查约定:

表面半径符号
表面顶点位置
材料分配
波长单位
法线方向
孔径定义
物面光线设置
像面位置

大多数早期的差异都来自约定不匹配,而非物理错误。

这就是我们保持代码显式的原因。它让我们有东西可以检查。

一个小的诊断打印输出

当折射后的光线看起来不对劲时,可以在表面处打印出相关数值。

def debug_refraction(ray, surface, n1, n2):
    hit = intersect_surface(ray, surface)

    if hit is None:
        print("无碰撞点。")
        return

    d_out = refract_direction(ray.direction, hit.normal, n1, n2)

    print("表面:", surface.comment)
    print("碰撞点:", hit.point)
    print("入射方向:", normalize(ray.direction))
    print("几何法线:", normalize(hit.normal))
    print("调整后法线:", orient_normal_against_ray(ray.direction, hit.normal))
    print("n1, n2:", n1, n2)
    print("入射角:", angle_to_normal_line(ray.direction, hit.normal))

    if d_out is None:
        print("全内反射")
    else:
        print("出射方向:", d_out)
        print("出射角:", angle_to_normal_line(d_out, hit.normal))

使用它:

ray = Ray(position=[5.0, 0.0, -20.0], direction=[0.0, 0.0, 1.0])
debug_refraction(ray, front, n1=1.0, n2=N_BK7.n(0.5875618))

这种诊断输出虽然不那么光鲜,但正是理解光线追迹器的途径。

如果光线向错误的方向偏折,诊断输出通常能揭示原因。

可能是法线方向反了。

可能是 n1 和 n2 被交换了。

可能是碰撞点与你想象的不一样。

可能是光线打到了球面的错误分支上。

数据会讲述出真相。

矢量折射中的常见错误

让我们清楚地列举一下这些常见错误。

错误1:未对向量进行归一化

公式要求使用单位方向向量和单位法线。

如果 directionnormal 没有归一化,余弦计算就会出错。务必归一化它们。

错误2:在未调整方向的情况下直接使用法线

几何法线可能指向任意一侧。在应用公式之前,需调整其方向,使其与入射方向相反。

应满足如下条件:

dot(direction, adjusted_normal) <= 0

错误3:交换 n1 和 n2

在空气到玻璃的界面,应使用:

n1 = air
n2 = glass

在玻璃到空气的界面,应使用:

n1 = glass
n2 = air

若交换二者,光线会向错误方向偏折。

错误4:将全内反射视为崩溃

当平方根项变为负数时,物理上意味着不存在折射光线。需明确处理这种情况。

错误5:从表面而不是法线量测角度

斯涅耳定律使用的是与法线之间的夹角,而不是与表面切平面的夹角。

这是最容易出现的概念性错误之一。

错误6:忘记波长

对于真实玻璃,n2 取决于波长。如果所有波长都使用相同的折射率,模型中的色差效应就会消失。

错误7:在检查孔径之前进行折射

如果光线落在通光孔径之外,它应被阻挡。不要将其视为通过玻璃而进行折射。

错误8:在错误的时刻增加光程

光线在介质中传播时会累积光程。在理想零厚度的界面处进行折射时,改变的是方向,而非几何路程长度。

在我们的简化模型中:

传播到表面 → 更新光程
在表面折射   → 更新方向

错误9:忘记数值容差

在临界角附近,极小的浮点误差就可能决定计算出的 sin²θ2 是略低于还是略高于 1。需使用一个较小的容差。

矢量公式的物理意义

人们很容易把矢量公式当作一段可以复制粘贴的代码,让我们把它与物理联系起来。

相对于表面法线,入射方向可以分解为两个部分:

切向分量
法向分量

在理想折射界面上,切向分量受到斯涅耳定律的约束,而法向分量会发生变化,以使出射方向为单位长度并位于透射一侧。

公式:

t = η i + (η cosθ1 - cosθ2) n

恰恰完成了这一点。它根据折射率比值缩放切向行为,然后选择合适的法向分量。

这就是出射光线保持在入射面内的原因。

这也正是当切向要求将迫使出射方向具有不可能的长度时,全内反射就会出现的原因。

因此,这段代码并非魔法,它是以矢量形式写出的斯涅耳定律的几何体现。

一个完整的单面教学示例

以下是整章核心计算的一个紧凑版本。

# 一束光线在空气中射向一个球面 N-BK7 表面。
ray = Ray(
    position=[5.0, 0.0, -20.0],
    direction=[0.0, 0.0, 1.0],
    wavelength_um=0.5875618
)

surface = IntersectableSurface(
    radius=50.0,
    z_vertex=0.0,
    semi_diameter=10.0,
    comment="前表面球面"
)

# 1. 找到碰撞点。
hit = intersect_surface(ray, surface)

if hit is None:
    ray.stop("missed surface")
elif not hit.inside_aperture:
    ray.stop("blocked by aperture")
else:
    # 2. 在空气中移动到碰撞点。
    ray.propagate(hit.t, refractive_index=1.0)

    # 3. 计算折射率。
    n1 = 1.0
    n2 = N_BK7.n(ray.wavelength_um)

    # 4. 折射。
    d_out = refract_direction(ray.direction, hit.normal, n1, n2)

    if d_out is None:
        ray.stop("total internal reflection")
    else:
        ray.direction = d_out

print("位置:", ray.position)
print("方向:", ray.direction)
print("OPL:", ray.opl)
print("存活:", ray.alive)
print("原因:", ray.stop_reason)

这就是折射事件的全部,完全暴露在外。

没有按钮,没有隐藏的光线追迹,没有绘图中难以解释的偏折。

只有计算本身。

本章如何改变了整本书

本章之前,我们拥有的是零散的部件:

透镜规格
材料模型
光线对象
表面交点求取
表面法线

本章之后,这些部件协同作用起来。

一束光线现在能够使用物理折射穿过表面了。

这意味着我们可以开始产出几何光学的首批真正成果:

穿过透镜的光线路径
像面截距
点列图
边缘光线与主光线的行为
实际光线偏差
色散焦点漂移

这些还不是最终的像质评价指标,但它们是基础。

下游的一切都依赖这一步骤。

点列图是由许多折射光线构成的。

光扇图也是通过比较不同瞳孔坐标下的折射光线而构建的。

波前计算需要光线路径和光程长度。

PSF 和 MTF 最终依赖于波前信息。

因此,这一束光线在这一表面上的偏折并不是一个小事件。它是链条中的一环,这个链条一直通回到第 1 章提出那个问题:

MTF 曲线到底是怎么来的?

答案的一部分是:

它来自于许多由矢量斯涅耳定律计算出的局部折射。

下一步是什么

一束折射光线固然有用,但光学分析通常不会就此止步。

一个透镜不会仅凭一条光线就形成图像。我们需要一束光线:

遍布瞳孔的光线
来自不同视场点的光线
不同波长的光线

当这些光线到达像面时,它们的截距就成了数据。标出这些截距,你就得到了点列图。比较不同瞳孔坐标,你就能开始看出像差的结构。追迹不同波长,颜色误差就会显现出来。

这就是第 7 章的内容。

我们将有意识地生成光线束,而不是让软件包替我们隐式地选择它们。我们将对瞳孔进行采样,赋予视场方向,将光线追迹穿过单透镜,并标出它们的落点。

到那时,一幅熟悉的光学分析图形就不再只是一张图片了。

它将变成一系列计算出的光线事件的集合。

生成的验证图

根据第6章折射模型生成的斯涅耳角度映射

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

一条光线是一次计算。

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

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

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

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

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

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

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

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

本章的核心就是这句话:

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

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

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

我们将自己构建光束。

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

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

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

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 光斑采样图

第8章:为什么近轴光学仍然重要

我们刚刚构建了真实光线追迹。

这感觉是进步,确实是。现在,一条光线可以从一个光瞳坐标出发,传播到一个球面,找到局部法线,通过玻璃折射,离开透镜,并落在像平面上。由这些光线组成的光束可以产生一个点列图。

所以现在退一步可能感觉很奇怪。

为什么在有了真实光线之后还要讨论近轴光学?

为什么在已经有了更精确的光线追迹器时,还要回到薄透镜、小角度、焦距、F数、数值孔径和ABCD矩阵?

因为近轴光学并不是真实光线追迹的原始替代品。

它是骨架。

真实光线追迹告诉我们光线实际做了什么。近轴光学告诉我们系统在一阶形式下试图做什么。没有这个骨架,点列图可能会变成孤立的图片。你可能会看到光线分散,但不知道它们在围绕什么分散。你可能会看到焦点变化,但不知道真实光线偏离的是哪一个一阶焦点。

一个点列图说:

这是采样真实光线落点的位置。

近轴光学说:

这是系统所围绕的理想一阶像结构。

两者都需要。

本章介绍近轴参考系统:光线高度、光线斜率、传输矩阵、薄透镜、有效焦距、后焦距、F数、数值孔径和放大倍率。该模型故意保持小巧。它给我们下一章所需的参考框架,而不是取代光学设计教科书。

第9章将提出一个问题:

为什么真实光线会偏离近轴光线?

那个问题是像差的开始。

所以,我们首先需要理解近轴光线。

近轴光线是一种简化的描述

我们代码中的一条真实光线携带一个3D位置和一个3D方向:

位置  = [x, y, z]
方向 = [dx, dy, dz]

近轴光线更简单。

在一个横向平面内,它可以表示为:

y = 光线高度
u = 光线角度或斜率

这里y是光轴上方的高度,u是光线与光轴的小角度。

对于小角度:

sin u ≈ u
tan u ≈ u

u以弧度为单位时。

这个微小角度近似是近轴光学的核心。

这个近似并不意味着近轴光学是粗心的。它意味着近轴光学有意研究靠近光轴且角度很小的光线。这足以揭示系统的一阶行为。

一条真实光线回答:

这条实际光线在实际表面上会走向哪里?

一条近轴光线回答:

在一阶近似下,一条近轴光线如何表现?

这些是不同的问题。

两者都有用。

近轴光线矢量

我们将一条近轴光线写成一个两元素矢量:

[y, u]

其中:

y = 光轴上方的高度,单位毫米
u = 斜率或小角度,单位弧度

在代码中:

from dataclasses import dataclass
import numpy as np
import math

@dataclass
class ParaxialRay:
    y: float
    u: float  # 弧度,或小角度近似下的斜率

    def as_vector(self):
        return np.array([self.y, self.u], dtype=float)

    @classmethod
    def from_vector(cls, v):
        return cls(y=float(v[0]), u=float(v[1]))

一条平行于光轴、高度为5毫米的光线是:

ray = ParaxialRay(y=5.0, u=0.0)

一条以微小向下角度穿过光轴的光线可能是:

ray = ParaxialRay(y=5.0, u=-0.05)

这比真实光线包含的信息少得多。这正是关键。近轴光线是一阶行为的简化载体。

近轴形式的传播

如果一条近轴光线在均匀介质中传播一段距离d,它的高度会根据其斜率变化:

y_new = y + d u

其斜率保持不变:

u_new = u

这可以写成一个矩阵:

[ y_new ]   [ 1  d ] [ y ]
[ u_new ] = [ 0  1 ] [ u ]

这是第一个ABCD矩阵。

传播矩阵为:

T(d) = [[1, d],
        [0, 1]]

在代码中:

def translation_matrix(distance):
    """近轴光线在均匀介质中通过距离d的传播。"""
    return np.array([
        [1.0, distance],
        [0.0, 1.0],
    ], dtype=float)

def apply_matrix(M, ray):
    """将一个2x2近轴矩阵应用于一个ParaxialRay。"""
    return ParaxialRay.from_vector(M @ ray.as_vector())

测试:

ray = ParaxialRay(y=5.0, u=-0.1)

M = translation_matrix(20.0)
out = apply_matrix(M, ray)

print(out)

结果应该是:

y = 3.0
u = -0.1

因为:

5 + 20 × (-0.1) = 3

这不是粗糙的草图。它是用代码写的一阶光线传输。

薄透镜矩阵

薄透镜会改变光线的斜率,但不会改变光线在透镜平面处的高度。

对于焦距为f的薄透镜(在空气中):

y_new = y
u_new = u - y / f

矩阵为:

L(f) = [[1,    0],
        [-1/f, 1]]

在代码中:

def thin_lens_matrix(focal_length):
    """空气中薄透镜矩阵。"""
    f = float(focal_length)

    if f == 0:
        raise ValueError("焦距不能为零。")

    return np.array([
        [1.0, 0.0],
        [-1.0 / f, 1.0],
    ], dtype=float)

测试一条平行光线以5毫米高度入射到50毫米薄透镜:

ray = ParaxialRay(y=5.0, u=0.0)

L = thin_lens_matrix(50.0)
out = apply_matrix(L, ray)

print(out)

出射斜率为:

u_new = -5 / 50 = -0.1

这意味着光线现在向下朝光轴传播。如果它再传播50毫米,就会到达光轴:

T = translation_matrix(50.0)

after_focus = apply_matrix(T, out)
print(after_focus)

高度应接近零。

这是近轴版本的聚焦。

它没有使用真实的表面相交,没有使用精确的球面法线。它使用的是透镜的一阶模型。

这就是它如此紧凑的原因。

矩阵乘积即光学系统

一个光学系统可以表示为一组近轴矩阵的乘积。

如果一条光线依次通过:

传播 d1
薄透镜 f
传播 d2

则有:

ray_out = T(d2) L(f) T(d1) ray_in

最右边的矩阵最先作用。

因此系统矩阵是:

M = T(d2) @ L(f) @ T(d1)

在代码中:

def system_matrix(*matrices):
    """按照光线作用顺序组合矩阵。

    system_matrix(A, B, C) 返回 C @ B @ A,如果光线先看到A,然后B,然后C。
    """
    M = np.eye(2)

    for element in matrices:
        M = element @ M

    return M

这个版本让我们可以按物理顺序编写元素:

M = system_matrix(
    translation_matrix(10.0),
    thin_lens_matrix(50.0),
    translation_matrix(40.0),
)

光线先看到10 mm的传播,然后是透镜,然后是40 mm的传播。

这个顺序约定值得明确说明。矩阵光学很紧凑,但顺序很重要。交换两个元素会改变系统。

ABCD矩阵

一个通用的一阶系统矩阵写为:

M = [[A, B],
     [C, D]]

它将输入光线高度和斜率映射为输出光线高度和斜率:

[ y_out ]   [ A  B ] [ y_in ]
[ u_out ] = [ C  D ] [ u_in ]

因此:

y_out = A y_in + B u_in
u_out = C y_in + D u_in

每个系数都有含义。

A 表示输入高度对输出高度的贡献。

B 表示输入角度对输出高度的贡献。

C 表示输入高度对输出角度的贡献。

D 表示输入角度对输出角度的贡献。

对光学光焦度最重要的系数通常是C

对于薄透镜:

M = [[1,    0],
     [-1/f, 1]]

因此:

C = -1/f

这就是为什么,对于空气中的系统,有效焦距通常由下式得到:

EFL = -1 / C

C不为零时。

我们来编写这些。

def matrix_elements(M):
    A, B = M[0, 0], M[0, 1]
    C, D = M[1, 0], M[1, 1]
    return A, B, C, D

def effective_focal_length(M):
    """空气中系统的有效焦距,使用 [y, u] 约定的返回值。"""
    A, B, C, D = matrix_elements(M)

    if abs(C) < 1e-12:
        return math.inf

    return -1.0 / C

测试:

L = thin_lens_matrix(50.0)
print(effective_focal_length(L))

应该打印50.0

这令人安心。在这个简化模型中,一个50毫米的薄透镜具有50毫米的有效焦距。

后焦距

有效焦距和后焦距相关但并非总是相同。

有效焦距与系统的光焦度和主平面有关。后焦距是从最后一个表面或最后一个参考平面到后焦点的距离。

对于一个系统矩阵 M = [[A, B], [C, D]],考虑一条入射准直光线:

u_in = 0

在系统之后:

y_out = A y_in
u_out = C y_in

现在让光线在系统后传播一段距离s。它的高度变为:

y(s) = y_out + s u_out

后焦点是这条准直入射光线与光轴相交的位置:

0 = A y_in + s C y_in

对于非零的y_in

s = -A / C

因此,在这个简单的同一介质约定下:

BFL = -A / C

编写它:

def back_focal_length(M):
    """从输出参考平面测量的后焦距,在空气中。"""
    A, B, C, D = matrix_elements(M)

    if abs(C) < 1e-12:
        return math.inf

    return -A / C

对于一个参考平面在透镜本身的薄透镜:

L = thin_lens_matrix(50.0)

print("EFL:", effective_focal_length(L))
print("BFL:", back_focal_length(L))

两者都是50 mm

现在将透镜之后的一段传播加入系统矩阵:

M = system_matrix(
    thin_lens_matrix(50.0),
    translation_matrix(10.0),
)

print(M)
print("EFL:", effective_focal_length(M))
print("BFL from output plane:", back_focal_length(M))

有效焦距仍然是50毫米,因为光焦度没有改变。但是从新的输出平面测量的后焦距发生了变化。如果输出平面在薄透镜后10毫米处,那么到焦点的剩余距离大约是40毫米。

这是一个关键的区别。

一个系统可以具有相同的有效焦距,但根据最后一个参考平面或最后一个表面位置的不同,后焦距会不同。

这就是为什么光学处方关心表面位置,而不仅仅是光焦度。

由矩阵推导薄透镜公式

熟悉的薄透镜公式是:

1/s + 1/s' = 1/f

其中:

s  = 透镜前的物距
s' = 透镜后的像距
f  = 焦距

符号约定各不相同,所以这个公式常常引起混淆。我们不在抽象层面争论符号,而是从矩阵出发,推导一个简单的情况。

假设一个物点位于薄透镜前距离s处。一条光线从物点以高度0出发,以高度y到达透镜。它在透镜处的斜率大约是:

u = y / s

在薄透镜之后:

u_after = u - y/f
        = y/s - y/f

为了使光线在距离s' 后到达光轴上的像点:

0 = y + s' u_after

代入:

0 = y + s'(y/s - y/f)

除以 y

0 = 1 + s'(1/s - 1/f)

整理得:

1/s' = 1/f - 1/s

或:

1/s + 1/s' = 1/f

在这种正距离设置下。

在代码中:

def thin_lens_image_distance(object_distance, focal_length):
    """对于薄透镜,物距为s,返回像距s'。

    使用 1/s + 1/s' = 1/f。
    采用简单的实物体、实像正距离约定。
    """
    s = float(object_distance)
    f = float(focal_length)

    denom = 1.0 / f - 1.0 / s

    if abs(denom) < 1e-12:
        return math.inf

    return 1.0 / denom

测试:

for s in [100.0, 200.0, 1000.0, math.inf]:
    if math.isinf(s):
        sp = 50.0
    else:
        sp = thin_lens_image_distance(s, 50.0)

    print("物距:", s, "像距:", sp)

随着物体向远处移动,像距趋近于焦距。

这就是为什么物体在无穷远的例子如此方便。在近轴薄透镜模型中,平行入射光线会聚到焦平面。

放大倍率

对于一个物距为s、像距为s'的薄透镜,横向放大倍率为:

m = -s' / s

负号表示在通常的实物体、实像约定下,像是倒立的。

在代码中:

def thin_lens_magnification(object_distance, image_distance):
    return -image_distance / object_distance

例子:

s = 100.0
f = 50.0
sp = thin_lens_image_distance(s, f)
m = thin_lens_magnification(s, sp)

print("s' =", sp)
print("m =", m)

对于s = 100 mmf = 50 mm,像距为100 mm,放大倍率为-1

同样,这是一个一阶结果。真实透镜可能存在畸变、场曲、像差以及有限厚度效应。但近轴放大倍率提供了参考结构。

没有它,“畸变”就没有明确的含义。畸变不仅仅是“图像看起来奇怪”。它是实际像位置与预期的近轴物像映射之间的偏离。

我们将在第9章回到这一点。

F数

F数是最常见的光学量之一。

对于一个对无穷远调焦的透镜,一个有用的一阶定义是:

F/# = f / D

其中:

f = 有效焦距
D = 入瞳直径

一个50毫米透镜,入瞳直径为10毫米,有:

F/# = 50 / 10 = 5

或称为f/5。

在代码中:

def f_number(effective_focal_length, entrance_pupil_diameter):
    D = float(entrance_pupil_diameter)

    if D <= 0:
        raise ValueError("入瞳直径必须为正。")

    return effective_focal_length / D

使用:

f = 50.0
D = 10.0

print(f_number(f, D))

这很简单,但它直接与光线束相联系。

入瞳直径控制着光锥。较大的孔径意味着一个更快的系统,更小的F数,并且通常对像差校正的要求更高。较小的孔径意味着一个更慢的系统,更大的F数,并且通常几何像差减小,但衍射模糊增加。

这是几何思维和衍射思维交汇的地方之一。

大孔径可能改善集光能力和衍射受限分辨率,但它也要求透镜处理更极端的光线。如果设计未校正,真实光线像差可能占主导。

因此,F数不仅仅是相机镜头上的一个标签。它是关于孔径相对于焦距的一阶概括。

数值孔径

数值孔径(NA)描述了光锥的角度。

在像空间,对于折射率为n的介质:

NA = n sin θ

其中θ是边缘光线相对于光轴的角度。

对于空气中的小角度:

NA ≈ sin θ ≈ θ

对于空气中的系统,还有一个常用的近似关系:

NA ≈ 1 / (2 F/#)

当系统对无穷远调焦且角度不太大时成立。

让我们实现两者。

def numerical_aperture(n_medium, marginal_angle_rad):
    return n_medium * math.sin(marginal_angle_rad)

def na_from_f_number(fno):
    return 1.0 / (2.0 * fno)

对于一个50毫米焦距和10毫米入瞳直径的系统:

fno = f_number(50.0, 10.0)

print("F/#:", fno)
print("近轴NA:", na_from_f_number(fno))

近似NA是0.1

这对应于近轴近似下大约0.1弧度的边缘光线角度。

同样,这是一阶的。在高NA情况下,精确的定义和正弦行为更为重要。但对于许多入门光学系统,这个近似给出了一个有用的尺度。

F数与边缘光线关联

让我们将F数与一条近轴光线联系起来。

对于无穷远物体,一条边缘光线以与光轴平行、高度为:

y = D / 2

的状态入射。

经过焦距为f的薄透镜后,其斜率变为:

u = -y / f = -(D/2) / f

因此,边缘光线角度的大小约为:

|u| = D / (2f) = 1 / (2 F/#)

这与空气中近似的NA相同。

这是一个美妙的时刻,因为它连接了几个量:

光瞳直径
焦距
F数
边缘光线斜率
数值孔径

这些不是需要死记硬背的独立事实。它们是从不同角度观察一阶光锥。

在代码中:

def marginal_slope_from_f_and_d(f, D):
    return -(D / 2.0) / f

f = 50.0
D = 10.0

u_marginal = marginal_slope_from_f_and_d(f, D)

print("边缘斜率:", u_marginal)
print("近轴NA:", abs(u_marginal))
print("从F/#得到的NA:", na_from_f_number(f_number(f, D)))

在近轴近似下,这些数字一致。

这正是近轴光学有用的原因。它将几何关系压缩成你可以推理的关系。

真实光线边缘行为与近轴边缘行为的对比

现在将本章与前一章联系起来。

在真实光线追迹器中,位于光瞳边缘的边缘光线在球表面上碰到的法线不同于近轴光线。它按照精确的几何折射。

在近轴光学中,边缘光线由一个高度和一个小斜率表示。透镜以线性方式改变其斜率:

u_new = u - y/f

如果光线靠近轴且角度很小,两种描述应该接近。

如果光线远离轴,或者透镜有明显的像差,真实光线就会偏离近轴预测。

那个偏离并不是一件麻烦事。它是像差分析的开始。

一次真实光线追迹给出:

实际像面交点

近轴模型给出:

一阶预期交点

两者之差给出:

横向光线误差

第9章将直接建立在这思想上。

一个近轴焦点扫描

我们用矩阵做一个小的近轴焦点计算。

一条准直光线以高度y进入薄透镜,应该在焦距处与光轴相交。我们可以通过传播到不同的z位置并测量高度来验证这一点。

def trace_paraxial_thin_lens_to_plane(y_in, u_in, f, z_after_lens):
    ray = ParaxialRay(y=y_in, u=u_in)

    ray_after_lens = apply_matrix(thin_lens_matrix(f), ray)
    ray_at_plane = apply_matrix(translation_matrix(z_after_lens), ray_after_lens)

    return ray_at_plane

扫描像面位置:

f = 50.0
y_in = 5.0
u_in = 0.0

z_values = np.linspace(30.0, 70.0, 9)

for z in z_values:
    out = trace_paraxial_thin_lens_to_plane(y_in, u_in, f, z)
    print(f"z={z:.1f} mm, y={out.y:.4f} mm")

z = 50 mm处,高度应接近零。

这是焦点扫描的近轴版本。它使用了一条边缘光线而不是一整个光束。对于薄透镜,结果在近轴模型内是精确的。

在一个真实的球面单透镜中,边缘光线可能不会在与近轴光线完全相同的点与轴相交。这个差异就是球差。

同样,近轴光学提供了参考。

用一个简单的厚透镜近似矩阵

一个真实的单透镜有两个表面和厚度。我们也可以从折射和传播构建一个一阶模型,但最简洁的矩阵形式取决于所选的光线变量。

有两种常见的约定:

[y, u]
[y, n u]

第二种通常被称为约化角度约定。它使得在具有不同折射率的介质间折射矩阵特别简洁。

为了保持本章的可读性,我们不会构建一个带有介质变化的完整厚透镜折射矩阵系统。那需要仔细的符号约定,并且会偏离主要目的。

相反,我们会做两件事:

  1. 使用薄透镜矩阵以获得清晰的一阶直觉;
  2. 将矩阵结果作为真实光线追迹的一个参考概念。

这是一个有意的边界。

本章并不取代光学设计程序的近轴引擎。它展示了那个引擎试图计算的内容。

一个生产级光学库可以从完整的序列系统计算一阶特性,包括表面光焦度、厚度、折射率、光阑、光瞳和主平面。我们的教学代码展示的是骨架。

那个骨架足以理解输出。

主平面,不必假装它们是简单的图

对于薄透镜,主平面位于透镜平面处。这就是为什么焦距和后焦距相同。

对于厚透镜或多元件系统,有效光焦度的行为仿佛与某些主平面相关,这些主平面可能位于物理玻璃的内部或外部。

这就是为什么:

EFL 和 BFL 可能不同

有效焦距是从一个主平面测量的。后焦距是从最后一个表面或输出参考平面测量的。

这个区别在实际设计中很重要。

一个透镜可能具有很长的有效焦距,但后焦距很短。或者它可能被设计为长后焦距,以便为反射镜、传感器组件、滤光片、快门或机械安装座留出空间。

如果你只知道焦距,你就不知道像平面相对于最后一个玻璃表面的位置。

近轴系统矩阵有助于计算这些一阶距离。

在我们的同介质ABCD模型中:

EFL = -1 / C
BFL = -A / C

这两个值之间的差异表明,后主平面不在输出参考平面上。

这不仅仅是术语。它影响布局。

一个EFL和BFL不同的例子

让我们做一个简单的系统:一个薄透镜,然后一个10毫米的传播到输出参考平面。

M = system_matrix(
    thin_lens_matrix(50.0),
    translation_matrix(10.0),
)

A, B, C, D = matrix_elements(M)

print("M =")
print(M)
print("A, B, C, D =", A, B, C, D)
print("EFL:", effective_focal_length(M))
print("BFL:", back_focal_length(M))

有效焦距仍然是50毫米。从输出平面测量的后焦距是40毫米。

透镜的光焦度没有改变。只是参考平面向下游移动了10毫米。

这个例子很简单,但它传达了一个真实的教训:

一阶量总是与参考平面相关。

当光学软件报告EFL、BFL、FFL、主平面位置或光瞳位置时,它正在进行近轴簿记。这些值不是装饰性的标签。它们告诉你系统是如何组织的。

视场角和像高

对于无穷远物体,近轴像高约为:

y_image ≈ f tan θ

对于小角度:

tan θ ≈ θ

因此:

y_image ≈ f θ

其中θ以弧度为单位。

在代码中:

def paraxial_image_height_from_field(focal_length, field_angle_deg):
    theta = math.radians(field_angle_deg)
    return focal_length * math.tan(theta)

例子:

for angle in [0.0, 1.0, 3.0, 5.0]:
    print(angle, paraxial_image_height_from_field(50.0, angle))

这是一阶像高。

现在将此联系到第7章。对于离轴点列图,质心或主光线交点可能出现在某个像高处。近轴像高为我们预期那个视场点落点的位置提供了一个参考。

如果真实主光线落点与近轴预测不同,那个差异就与畸变有关。

我们现在还不准备完全分析畸变。但这种关系已经可见:

视场角
→ 近轴像高
→ 真实主光线像高
→ 畸变比较

这是近轴光学重要的另一个原因。它赋予“偏离”以意义。

一阶布局不是一个像质结果

这一点非常重要,需要明确说明。

一个近轴设计可能看起来不错,但仍可能产生糟糕的图像。

一阶光学可以告诉你:

大致焦距
像距
放大倍率
F数
NA
光瞳关系
视场高度

它本身不能告诉你:

球差
彗差
像散
场曲
具体的畸变
色差模糊
PSF
MTF

这些需要真实光线追迹以及波前或衍射分析。

因此,近轴光学并不是像质的最终评判者。

但它仍然是骨架。如果一阶系统有误,后续分析可能是在解决错误的问题。

一个焦距错误的透镜,不会因为在另一个尺度上有一个漂亮的MTF图而得救。一个后焦距不足的设计可能在光学上很有趣,但在机械上无法使用。一个F数错误的系统可能无法满足集光或衍射要求。

一阶设计搭建了舞台。

真实光线和波前光学分析告诉我们在那个舞台上系统表现如何。

一个小型近轴设计函数

让我们编写一个紧凑的函数,从焦距和光瞳直径总结一个薄透镜设计。

@dataclass
class ThinLensFirstOrder:
    focal_length: float
    entrance_pupil_diameter: float

    @property
    def efl(self):
        return self.focal_length

    @property
    def fno(self):
        return f_number(self.efl, self.entrance_pupil_diameter)

    @property
    def approx_na(self):
        return na_from_f_number(self.fno)

    def image_height(self, field_angle_deg):
        return paraxial_image_height_from_field(self.efl, field_angle_deg)

    def marginal_ray_slope(self):
        return marginal_slope_from_f_and_d(self.efl, self.entrance_pupil_diameter)

使用它:

design = ThinLensFirstOrder(
    focal_length=50.0,
    entrance_pupil_diameter=10.0,
)

print("EFL:", design.efl)
print("F/#:", design.fno)
print("近轴NA:", design.approx_na)
print("边缘斜率:", design.marginal_ray_slope())

for field in [0.0, 3.0, 5.0]:
    print("视场角", field, "像高", design.image_height(field))

这不是一个透镜优化工具。它是一个一阶计算器。

但即使是这个小类也能给出有用的预期:

系统有多快?
像高有多大?
我们应该期望多大的边缘锥角?
平行光应在哪里聚焦?

这些预期帮助我们解读真实光线的输出。

比较近轴焦点和真实焦点

让我们直接连接到第6章和第7章的单透镜。

我们的真实单透镜具有:

前半径 = +50 mm
后半径 = -50 mm
中心厚度 = 5 mm
材料 = N-BK7

对于空气中的双凸透镜,一个粗略的薄透镜估计是:

1/f ≈ (n - 1)(1/R1 - 1/R2)

对于R1 = +50R2 = -50,和n ≈ 1.5168

def thin_lens_focal_length_from_radii(n, R1, R2):
    power = (n - 1.0) * (1.0 / R1 - 1.0 / R2)

    if abs(power) < 1e-12:
        return math.inf

    return 1.0 / power

n_bk7_d = N_BK7.n(0.5875618)
f_est = thin_lens_focal_length_from_radii(n_bk7_d, R1=50.0, R2=-50.0)

print("薄透镜估计焦距:", f_est)

这个估计值将在48毫米左右。它忽略了厚度、精确的主平面和真实的球面光线行为,但它给出了一个尺度。

在第7章中,如果你扫描像面位置并在该尺度附近找到了一个RMS斑点最小值,那应该感觉合理。如果最佳焦点在500毫米或5毫米处,你会怀疑存在符号、单位或追迹错误。

这是近轴光学的另一个实际用途:

它为你提供了合理性检查。

近轴估计不必完美才有用。它告诉你真实光线结果是否在正确的范围内。

用矩阵代码进行薄透镜焦点检查

现在将估计焦距作为薄透镜使用,并比较不同视场下的近轴像高。

f_est = thin_lens_focal_length_from_radii(n_bk7_d, R1=50.0, R2=-50.0)
D = 10.0

design = ThinLensFirstOrder(
    focal_length=f_est,
    entrance_pupil_diameter=D,
)

print("估计EFL:", design.efl)
print("估计F/#:", design.fno)
print("估计NA:", design.approx_na)

for field in [0.0, 1.0, 3.0, 5.0]:
    print(
        f"field={field:.1f} deg, "
        f"近轴像高={design.image_height(field):.4f} mm"
    )

现在,你对于点列图中使用的相同类型的视场角有了一阶预期。

这有助于你避免一个常见的初学者困惑。

当一个离轴斑点出现在远离原点的位置时,这并不自动意味着是像差。那个位移的很大一部分仅仅是像高。像差是与理想像点的偏离,而不是像高本身。

这就是为什么中心点列图对于模糊形状有用,而绝对点列图对于像位置有用。

近轴光学告诉你“理想像位置”的含义。

Optiland近轴分析作为更高级的版本

一个真正的光学设计库不需要我们手写的薄透镜捷径。它可以基于实际的序列模型计算近轴特性。

对于一个Optiland系统,高层思路是:

from optiland import optic

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.wavelengths.add(value=0.5876, is_primary=True)

# 示意性示例:
# 使用库的分析工具检查近轴或一阶特性。
# 确切的属性名称可能因Optiland版本而异。

根据安装的版本,近轴特性可能通过分析类、系统属性或专用方法暴露出来。确切的API接口不如概念比较重要。

库级别的近轴引擎应该能够回答以下问题:

有效焦距是多少?
后焦距是多少?
F数是多少?
主平面在哪里?
近轴像高是多少?
孔径关系是什么?

我们的教学代码回答了关于薄透镜和简单ABCD系统的那些问题的缩小版。

这正是本书的双轨方法:

最小代码 → 理解骨架
Optiland工作流 → 查看实用工具

不要将它们混淆。我们的代码并不试图成为一个完整的近轴分析包。它试图使输出变得可读。

为什么近轴结果可能与简单薄透镜估计不匹配

如果你将库对一个真实单透镜的近轴结果与我们的薄透镜估计进行比较,数字可能会不同。

这是预期的。

原因包括:

有限的透镜厚度
主平面偏移
玻璃目录使用的精确折射率
波长选择
表面符号约定
参考平面定义
孔径定义
系统前后介质

薄透镜方程是一个近似。对一个厚序列系统的完整近轴分析仍然是一阶的,但它包含了更多实际处方信息。

因此,层级结构是:

薄透镜估计:
    快速尺度和合理性检查

ABCD矩阵模型:
    所选元件下的一阶系统行为

库近轴分析:
    从实际处方得到的一阶特性

真实光线追迹:
    采样光线的精确几何行为

波前 / PSF / MTF:
    含衍射的成像分析

每一层都有其职责。

当我们要求一层去完成另一层的工作时,问题就会出现。

近轴光学作为调试工具

近轴光学不仅仅是理论。它也是一个调试工具。

如果你的真实光线追迹器给出了奇怪的结果,近轴估计可以帮助隔离问题。

例如:

情况1:焦点错得很离谱

如果一个简单的双凸透镜的焦距应该在50毫米左右,但你的真实光线聚焦在5000毫米左右,请检查:

半径单位
折射率
表面符号
法线方向
斯涅尔 n1/n2 顺序
像面单位

情况2:离轴像高差了一个很大因子

检查:

视场角单位
度与弧度
方向矢量构建
像面位置
坐标约定

情况3:F数与光线锥角不匹配

检查:

孔径直径与半径
入瞳与物理光阑
边缘光线选择
焦距

情况4:真实光线与近轴光线在近轴区不一致

对于真实表面,有些差异是正常的,但近轴光线通常应与近轴预测接近。如果不接近,检查:

表面相交
表面法线
符号约定
矩阵约定
波长和材料折射率

近轴光学为你提供了第二条计算路径。这很有价值。

当两个独立的简单检查不一致时,你就知道该在哪里寻找问题。

一个小对比:近轴与真实边缘光线

让我们勾勒一个对比。

  1. 使用薄透镜估计预测焦点。
  2. 追踪一条小高度的真实光线通过单透镜。
  3. 观察它与光轴相交的位置。
  4. 比较交点距离。

一条小高度的光线应该比边缘光线更接近近轴行为。

下面是一个辅助函数,用于估计追迹一条真实光线离开透镜后在何处与光轴相交。

假设我们追踪光线通过两个单透镜表面,但尚未到达像面。

def trace_simple_singlet_to_exit(ray, front, back, 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)

    return ray

def z_axis_crossing_after_exit(ray):
    """估计一条真实光线在x-z平面中与y=0相交的位置。

    这假设光线位于x-z平面内,并使用x作为横向坐标。
    """
    x0 = ray.position[0]
    z0 = ray.position[2]
    dx = ray.direction[0]
    dz = ray.direction[2]

    if abs(dx) < 1e-12:
        return math.inf

    t = -x0 / dx

    if t < 0:
        return math.inf

    return z0 + t * dz

现在比较小高度和大高度的光线:

for height in [0.5, 2.0, 5.0, 8.0]:
    ray = Ray(
        position=[height, 0.0, -20.0],
        direction=[0.0, 0.0, 1.0],
        wavelength_um=0.5875618,
    )

    trace_simple_singlet_to_exit(ray, front, back, N_BK7)
    z_cross = z_axis_crossing_after_exit(ray)

    print(f"输入高度={height:.1f} mm,与轴相交 z≈{z_cross:.3f} mm")

如果透镜有球差,不同光线高度与光轴的交点会在不同的z位置。

小高度的光线应该最接近近轴焦点。边缘光线可能偏离得更明显。

这是通往第9章的桥梁。

近轴光线提供了参考。真实光线揭示了偏离。

接下来“像差”的含义

现在,我们已准备好从实用计算的角度定义像差。

在一阶近似下,系统有一个理想的行为:

一个视场点映射到一个近轴像点
一个轴上准直光束聚焦在一个近轴焦平面
近轴光线遵循ABCD预测

真实光线可能并不完全遵循这个理想。

它们的偏离表现为:

不同光线高度有不同的焦点
离轴不对称性
不同的子午和弧矢行为
与视场相关的模糊
色差偏移
与理想像高的偏离(畸变)

这些偏离就是像差。

这并不意味着透镜在道义上是“坏的”。每个真实光学系统都有局限性。像差分析告诉我们我们正在看到的是哪种局限性。

一个点列图展示了结果。

一个近轴参考有助于解释这个结果。

这就是为什么本章刚好位于点列图之后、像差之前。

近轴光学中的常见错误

让我们汇总陷阱。

错误1:将近轴光学视为过时

近轴光学并不过时。它是系统的一阶骨架。真实光线追迹增加了细节;它并没有抹去骨架。

错误2:忘记弧度

小角度方程使用弧度。如果你在y = f θ中直接使用度,结果将相差大约57.3倍。

错误3:混合符号约定

薄透镜公式和矩阵光学依赖于符号约定。在相信一个公式之前,先定义你的约定。

当有疑问时,测试简单情况:

通过正透镜的平行光线应在下游聚焦
轴上光线应保持轴上
传播不应改变斜率

错误4:混淆EFL和BFL

有效焦距与光焦度和主平面相关。后焦距是从最后一个表面或输出参考平面测量的。

对于在参考平面处的薄透镜,它们是相等的。但一般情况不是。

错误5:混淆孔径直径和半径

F数使用入瞳直径,不是半径。

如果你误用了半径,你的F数会差两倍。

错误6:将近轴焦点视为最佳真实焦点

近轴焦点是一阶参考。一个真实透镜可能由于像差,在另一个平面上有最佳RMS点焦点。

错误7:期望薄透镜估计与厚透镜软件完全匹配

薄透镜估计是合理性检查。对实际处方的完整近轴分析包含更多信息。

错误8:将近轴指标用作最终像质指标

焦距、F数、NA和放大倍率本身不会告诉你PSF或MTF。它们描述了一阶布局。

错误9:忘记视场参考

离轴像高是预期的。问题不在于斑点是否远离原点。问题在于真实像位置与近轴预测相比如何。

本章给了我们什么

我们现在有了一阶参考框架。

我们可以:

将近轴光线表示为 [高度, 斜率]
用传播矩阵传播它
用薄透镜矩阵弯曲它
将元件组合成一个ABCD矩阵
从C计算有效焦距
从A和C计算后焦距
用薄透镜方程估计像距
计算放大倍率
计算F数
估计数值孔径
从视场角估计近轴像高
使用近轴结果作为真实光线追迹的合理性检查

这不是偏离计算光学。

它是一阶的计算光学。

公式比真实光线追迹代码短,但它们回答的是不同的问题。它们告诉我们在像差、衍射和采样细节使图像复杂化之前,光学系统是如何组织的。

那种组织是至关重要的。

前方的路

下一章会提出一个自然的问题:

如果近轴光学给出了一阶理想,为什么真实光线会错过它?

现在我们有了回答这个问题的工具。

一条真实光线可能开始于与一条近轴光线相同的光瞳坐标。它可能通过同样的名义上的透镜。但它遇到的是实际的弯曲表面、精确的局部法线以及真实的斯涅尔折射。在更高的光线高度和离轴视场下,这些精确的相互作用不再遵循简单的线性近轴模型。

其差异表现为像差。

我们不会把它埋在一大堆三阶公式之下。相反,我们将使用我们已经构建的计算:

追迹真实光线
计算近轴参考
比较像面误差
绘制横向光线像差曲线
解读图样

这将使像差不再像是教科书中的命名怪物,更像是光线数据中可测量的偏离。

近轴骨架现在已就位。

接下来,我们观察真实光线从中偏离。

生成的验证图

从第8章矩阵模型生成的近轴焦点

来源与验证说明

本章中的ABCD矩阵公式使用本章所述的符号和排序约定。其他光学书籍可能使用不同的坐标符号、光线矢量定义或乘法顺序。当对比另一来源或软件包的结果时,在比较数字之前,先比较约定。

第九章:为什么真实光线会偏离近轴光线

点列图是诚实的,但它并不总能解释原因。

它告诉你光线落点在哪里。它显示光束是紧密的还是发散的。它可能呈现一个圆形模糊、彗星状的拖尾、一条线、色分离或轴外畸变。但点列图本身并不总能告诉你光线为何落成那样。

要理解原因,我们需要一个参考基准。

第八章给了我们这个基准:近轴光学。

近轴模型告诉我们光学系统在一阶近似下的表现。在轴附近,小角度时,光线遵循简单的线性规则。一个场点映射到一个近轴像点。一个准直的轴上光束聚焦在近轴焦平面上。基于薄透镜的估算可以给出焦距的大致比例。F 数和数值孔径告诉我们光线锥的宽窄。

真实光线并不保证遵循那个理想情况。

它们撞击实际表面,计算实际的局部法线,根据矢量斯涅耳定律折射,穿过实际的厚度和材料。在较大的光瞳高度和轴外视场下,实际的光线路径开始偏离近轴预测。

这种偏离并非模糊的缺陷。

它是可以测量的。

本章就是关于测量和解读这种偏离。

我们将用到三个概念:

近轴参考基准
真实光线落点
横向光线误差

然后我们将这些误差转化为光线扇面图,并用它们来介绍主要的像差:

球差
彗差
像散
场曲
畸变
色差

我们不会进行完整的三阶像差推导。那是另一本书的内容。这里的目标是计算上的理解:

追踪真实光线,将它们与参考基准比较,并解读误差的模式。

这是一条更温和的了解像差的途径。我们不是从名称开始,而是从数据开始。

像差是对参考基准的偏离

“像差”这个词听起来像是一堆需要死记硬背的缺陷名称。

球差、彗差、像散、场曲、畸变、色差。

这些名称很重要。光学设计师使用它们,因为它们描述了可识别的模式。但一旦理解了其底层概念,这些名称就变得更容易了。

像差是对理想成像的偏离。

本章中,我们将使用一个基于光线的实用定义:

真实光线落点 - 参考落点 = 横向光线误差

如果一条真实光线恰巧落在参考基准预计的位置,那么横向光线误差为零。

如果它落在别处,误差就是像平面坐标的差值。

对于一条落在:

(x_real, y_real)

的光线,以及一个参考点:

(x_ref, y_ref)

横向误差为:

εx = x_real - x_ref
εy = y_real - y_ref

这就是光线像差图背后的基本计算。

微妙之处不在于减法,而在于如何选择参考基准。

常见的参考基准包括:

近轴像点
主光线落点
光斑质心
最佳焦点位置
波前相关分析中的最佳拟合球面中心

不同的参考基准回答不同的问题。如果使用主光线作为参考,你移除了像移,通常也从绘制的误差中移除了畸变。如果使用近轴像点,你就能看到畸变和视场相关的像位移。如果使用最佳焦点作为参考,你可能只关注重新聚焦后的剩余模糊。

这就是为什么软件中的光线扇面图不仅仅是“光线扇面”。它们是具有参考约定的光线扇面图。

我们将从一个简单的参考基准开始:主光线落点或质心。然后,当畸变重要时,我们会讨论近轴参考基准。

从点列图到横向误差

在第七章中,我们的点列图数据点就是像平面落点坐标:

points = [(x1, y1), (x2, y2), ...]

要将其转换为横向误差,需要选择一个参考基准。

对于居中后的点列图,我们使用质心:

center = mean(points)

然后:

errors = points - center

这已经是横向误差的计算。

在代码中:

import numpy as np
import math
import matplotlib.pyplot as plt

def transverse_errors(points, reference=None):
    """Return transverse ray errors relative to a reference point.

    points:
        Array of shape (N, 2), image-plane x, y intercepts.

    reference:
        Optional array-like [x_ref, y_ref]. If None, use centroid.
    """
    points = np.asarray(points, dtype=float)

    if len(points) == 0:
        return points, np.array([np.nan, np.nan])

    if reference is None:
        reference = points.mean(axis=0)
    else:
        reference = np.asarray(reference, dtype=float)

    return points - reference, reference

在收集了点列图数据后使用它:

errors, reference = transverse_errors(spot_points)

print("reference:", reference)
print("first errors:", errors[:5])

如果我们对 errors[:, 0]errors[:, 1] 做散点图,就会得到居中后的点列图。

但光线扇面图有所不同。

光线扇面图不仅显示误差的二维分布,它还将误差作为光瞳坐标的函数来绘制。

这正是其诊断价值所在。

为什么光线扇面图能揭示结构

点列图回答:

光线落在了哪里?

光线扇面图问的是:

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

这是一个更具结构化的问题。

对于子午扇面,我们可以让一个光瞳坐标沿一条线变化:

Px from -1 to +1, Py = 0

然后绘制像误差随 Px 的变化。

对于弧矢扇面,我们改变另一个光瞳坐标:

Py from -1 to +1, Px = 0

然后绘制像误差随 Py 的变化。

因此,光线扇面图是通过对光瞳进行线性采样构建的,不一定需要完整的二维网格。

这就是为什么光线扇面图比密集的点列图更能清晰地显示像差模式。点列图可能看起来像一团云,而光线扇面图展示了函数关系:

光瞳坐标 → 横向误差

如果误差主要是一条直线,通常表明有离焦。如果它以三次曲线形状弯曲,可能存在球差。如果轴外扇面不对称,可能涉及彗差。如果子午和弧矢扇面表现不同,像散或场曲可能参与其中。

我们不会仅凭一张图就下结论,但我们可以开始解读这些形状。

在光瞳内进行线性采样

让我们创建光瞳内的线性采样点。

对于 x 扇面:

x varies from -R to +R
y = 0

对于 y 扇面:

x = 0
y varies from -R to +R

在归一化的光瞳坐标中,我们使用:

P = coordinate / pupil_radius

这样 P 的范围是 -1+1

def sample_pupil_line(axis, num_points, pupil_radius):
    """Sample a line through the circular pupil.

    axis:
        "x" for x-line samples with y = 0.
        "y" for y-line samples with x = 0.
    """
    P = np.linspace(-1.0, 1.0, num_points)
    points = []

    for p in P:
        if axis == "x":
            points.append((p * pupil_radius, 0.0))
        elif axis == "y":
            points.append((0.0, p * pupil_radius))
        else:
            raise ValueError("axis must be 'x' or 'y'")

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

如果我们希望包含中心光线,就强制点数设为奇数:

def odd_count(n):
    return n if n % 2 == 1 else n + 1

现在构建一个线性光束:

def make_line_bundle(
    axis,
    num_points,
    pupil_radius,
    z_start,
    theta_x_deg=0.0,
    theta_y_deg=0.0,
    wavelength_um=0.5875618,
):
    num_points = odd_count(num_points)
    P, pupil_points = sample_pupil_line(axis, num_points, pupil_radius)

    rays = 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=wavelength_um,
    )

    return P, rays

这样我们同时得到了归一化光瞳坐标和光线。

利用我们的单片透镜追踪器构建光线扇面图

现在我们可以通过前面章节中的简单单片透镜来追踪线性光束。

假设我们仍保留了:

front
back
image
N_BK7
trace_bundle_through_singlet(...)
collect_image_points(...)

现在编写一个光线扇面函数。

def ray_fan_for_singlet(
    axis,
    num_points,
    pupil_radius,
    z_start,
    theta_x_deg,
    theta_y_deg,
    wavelength_um,
    front,
    back,
    image,
    glass_material,
    reference="centroid",
):
    """Compute a simple transverse ray fan for the singlet.

    Returns:
        P: normalized pupil coordinates
        errors: transverse errors, shape (N, 2), for valid rays
        points: image-plane intercepts
        valid_mask: Boolean mask for rays that reached the image plane
        ref: reference point used for transverse error
    """
    P, rays = make_line_bundle(
        axis=axis,
        num_points=num_points,
        pupil_radius=pupil_radius,
        z_start=z_start,
        theta_x_deg=theta_x_deg,
        theta_y_deg=theta_y_deg,
        wavelength_um=wavelength_um,
    )

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

    points = []
    valid_P = []

    for p, ray in zip(P, traced):
        if ray.alive:
            points.append((ray.position[0], ray.position[1]))
            valid_P.append(p)

    points = np.array(points, dtype=float)
    valid_P = np.array(valid_P, dtype=float)

    if len(points) == 0:
        return valid_P, points, points, np.array([], dtype=bool), np.array([np.nan, np.nan])

    if reference == "centroid":
        errors, ref = transverse_errors(points)
    elif reference == "chief":
        # With odd line sampling, the center point is the closest chief-like sample
        center_index = np.argmin(np.abs(valid_P))
        ref = points[center_index]
        errors, ref = transverse_errors(points, reference=ref)
    else:
        raise ValueError("reference must be 'centroid' or 'chief'")

    return valid_P, errors, points, ref

现在绘制扇面图。

对于轴上视场,使用 x 扇面:

P, errors, points, ref = ray_fan_for_singlet(
    axis="x",
    num_points=51,
    pupil_radius=5.0,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=0.0,
    wavelength_um=0.5875618,
    front=front,
    back=back,
    image=image,
    glass_material=N_BK7,
    reference="chief",
)

plt.plot(P, errors[:, 0], marker=".")
plt.axhline(0.0, linewidth=0.8)
plt.axvline(0.0, linewidth=0.8)
plt.xlabel("normalized pupil coordinate P_x")
plt.ylabel("transverse error εx [mm]")
plt.title("On-axis x ray fan")
plt.show()

这个图就是一个光线扇面图。

它不是什么新的物理原理。它只是对同样的追踪光线数据进行了不同的组织方式。

光线追踪 → 像面落点 → 减去参考点 → 绘制误差与光瞳坐标的关系

这就是整个构建过程。

离焦:最简单的光线扇面形状

在命名复杂的像差之前,先从离焦开始。

假设一个光束理想上会在某个平面聚焦,但我们在焦前或焦后的位置进行测量。那么光线落点会大致随光瞳坐标线性偏移。边缘光线落在一边,中心光线靠近参考点,另一侧边缘光线落在另一边。

光线扇面图近似为一条直线。

通过移动像平面很容易模拟这一点。

def make_image_plane(z_image):
    return IntersectableSurface(
        radius=float("inf"),
        z_vertex=z_image,
        semi_diameter=None,
        surface_type="plane",
        comment="image plane",
    )

现在比较不同像面位置下的扇面图:

for z_image in [40.0, 50.0, 60.0]:
    image_test = make_image_plane(z_image)

    P, errors, points, ref = ray_fan_for_singlet(
        axis="x",
        num_points=51,
        pupil_radius=5.0,
        z_start=-20.0,
        theta_x_deg=0.0,
        theta_y_deg=0.0,
        wavelength_um=0.5875618,
        front=front,
        back=back,
        image=image_test,
        glass_material=N_BK7,
        reference="chief",
    )

    plt.plot(P, errors[:, 0], marker=".", label=f"z={z_image:.0f} mm")

plt.axhline(0.0, linewidth=0.8)
plt.axvline(0.0, linewidth=0.8)
plt.xlabel("normalized pupil coordinate P_x")
plt.ylabel("transverse error εx [mm]")
plt.title("Defocus changes the slope of the ray fan")
plt.legend()
plt.show()

主要变化通常是扇形图中斜率的倾斜。

这很有用,因为离焦会掩盖其他模式。如果像平面不在一个有意义的焦平面上,光线扇面图可能主要由直线项主导。一旦重新聚焦,剩下的曲率就更容易检查了。

这就是为什么光学分析软件通常允许你改变焦点或减去某些参考项。这个图不是一个纯粹的原始真相,而是一个诊断视图。

球差:边缘光线与近轴光线的聚焦位置不同

用我们目前的代码最容易观察到的一种像差就是球差。

对于一个轴上物点,完美的近轴模型会使所有入射的平行光线聚焦在同一点。而一个真实的球面通常不会这样。靠近边缘的光线可能与中心光线在轴上交叉的位置不同。

在光线扇面图中,球差通常表现为一种弯曲的模式,常见的形状是横向光线误差与光瞳坐标呈近似的三次方关系。

让我们计算单片透镜后不同入射高度的光线大致在何处与轴相交。

def trace_simple_singlet_to_exit(ray, front, back, 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)

    return ray

def z_axis_crossing_xz(ray):
    """Estimate where a ray in the x-z plane crosses x=0 after exit."""
    x0 = ray.position[0]
    z0 = ray.position[2]
    dx = ray.direction[0]
    dz = ray.direction[2]

    if abs(dx) < 1e-12:
        return math.inf

    t = -x0 / dx

    if t < 0:
        return math.inf

    return z0 + t * dz

现在扫描光线高度:

heights = np.linspace(0.5, 8.0, 16)
crossings = []

for h in heights:
    ray = Ray(
        position=[h, 0.0, -20.0],
        direction=[0.0, 0.0, 1.0],
        wavelength_um=0.5875618,
    )

    trace_simple_singlet_to_exit(ray, front, back, N_BK7)
    crossings.append(z_axis_crossing_xz(ray))

plt.plot(heights, crossings, marker=".")
plt.xlabel("input ray height [mm]")
plt.ylabel("axis crossing z [mm]")
plt.title("Longitudinal focus shift with ray height")
plt.show()

如果光线交叉位置随光线高度变化,则说明在这种设置下透镜存在球差。

这个结论不是从记忆中贴上的标签,而是从数据中得出的:

近轴光线聚焦在此处
边缘光线聚焦在别处

这就是计算意义上的球差。

固定像平面上的横向球差

轴向交叉点是一个纵向视角。而在固定像平面上的光线扇面图则提供了横向视角。

选择一个像平面。追踪通过光瞳的光线。绘制横向误差。

image_focus = make_image_plane(50.0)

P, errors, points, ref = ray_fan_for_singlet(
    axis="x",
    num_points=101,
    pupil_radius=5.0,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=0.0,
    wavelength_um=0.5875618,
    front=front,
    back=back,
    image=image_focus,
    glass_material=N_BK7,
    reference="chief",
)

plt.plot(P, errors[:, 0])
plt.axhline(0.0, linewidth=0.8)
plt.axvline(0.0, linewidth=0.8)
plt.xlabel("normalized pupil coordinate P_x")
plt.ylabel("transverse error εx [mm]")
plt.title("On-axis transverse ray fan")
plt.show()

对于一个旋转对称的轴上系统,x 扇面和 y 扇面应该表现相似。如果绘图中的曲线弯曲并以预期的方式对称,你看到的就是光线高度如何改变焦点和像面落点。

不必担心给每个系数都起个名字。计算层面的理解已经足够:

误差不是恒定的
误差不仅仅是一个线性的离焦项
误差随光瞳坐标变化

这种变化就是光线追踪暴露出的像差。

彗差:轴外不对称性

现在转到轴外。

彗差出现在轴外视场点,当来自不同光瞳区域的光线产生不对称的模糊时。在点列图中,彗差可能看起来像彗星:一个密集的头部和扩散的尾巴。在光线扇面图中,彗差会引入在轴上对称情况下不存在的非对称项。

让我们追踪一个轴外视场:

image_focus = make_image_plane(50.0)

P, errors, points, ref = ray_fan_for_singlet(
    axis="y",
    num_points=101,
    pupil_radius=5.0,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=5.0,
    wavelength_um=0.5875618,
    front=front,
    back=back,
    image=image_focus,
    glass_material=N_BK7,
    reference="chief",
)

plt.plot(P, errors[:, 1])
plt.axhline(0.0, linewidth=0.8)
plt.axvline(0.0, linewidth=0.8)
plt.xlabel("normalized pupil coordinate P_y")
plt.ylabel("transverse error εy [mm]")
plt.title("Off-axis ray fan")
plt.show()

现在也绘制轴外点列图:

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

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

traced = trace_bundle_through_singlet(rays, front, back, image_focus, N_BK7)
spot_points, valid = collect_image_points(traced)
spot_errors, ref = transverse_errors(spot_points)

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

如果居中的光斑不对称,那是一个线索。如果随着光瞳坐标变化,光线扇面图显示出不对称的偏离,那是另一个线索。

这就是以实用方式认识彗差的方法:

不是先接触它的名称,
而是先通过追踪光线数据中观察到的轴外不对称误差模式。

简单的单片透镜并未校正轴外像差,因此它是一个很好的教学对象。其糟糕表现足以展示问题所在。

子午与弧矢特性

轴外系统有一个特别的复杂之处。

存在两个重要的平面:

子午面
弧矢面

如果视场在 y 方向,子午面包含光轴和视场方向。弧矢面与之垂直。

从实用的光线扇面图角度来看,我们经常检查两个扇面:

子午扇面
弧矢扇面

确切的命名取决于坐标约定。重要的计算思想是:

沿两条正交的光瞳线采样光线,并比较它们的横向误差。

如果子午扇面和弧矢扇面表现不同,系统正在告诉你,轴外成像在两个平面上并不相同。

这种差异与像散和场曲相关。

让我们为同一个轴外视场计算两个线性的风扇图:

field_angle = 5.0
image_focus = make_image_plane(50.0)

P_x, errors_x, points_x, ref_x = ray_fan_for_singlet(
    axis="x",
    num_points=101,
    pupil_radius=5.0,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=field_angle,
    wavelength_um=0.5875618,
    front=front,
    back=back,
    image=image_focus,
    glass_material=N_BK7,
    reference="chief",
)

P_y, errors_y, points_y, ref_y = ray_fan_for_singlet(
    axis="y",
    num_points=101,
    pupil_radius=5.0,
    z_start=-20.0,
    theta_x_deg=0.0,
    theta_y_deg=field_angle,
    wavelength_um=0.5875618,
    front=front,
    back=back,
    image=image_focus,
    glass_material=N_BK7,
    reference="chief",
)

plt.plot(P_x, errors_x[:, 0], label="x fan: εx")
plt.plot(P_y, errors_y[:, 1], label="y fan: εy")
plt.axhline(0.0, linewidth=0.8)
plt.axvline(0.0, linewidth=0.8)
plt.xlabel("normalized pupil coordinate")
plt.ylabel("transverse error [mm]")
plt.title("Two orthogonal ray fans for an off-axis field")
plt.legend()
plt.show()

这种比较比任何单一曲线都更有信息量。

如果一个平面的聚焦方式与另一个不同,移动像平面可能改善一个扇面却使另一个恶化。这就是存在像散现象的实用标志。

像散:两个线焦点而不是一个点焦点

像散并非泛指“一切都很模糊”。

对于一个轴外视场,光束两个正交截面的光线可能在轴向不同位置达到最佳焦点。系统不是拥有一个清晰的点焦点,而是有两个倾向于线状的焦点:子午焦点和弧矢焦点。

从计算上观察这一点的简单方法是扫描像平面位置并测量两个方向上的 RMS 宽度。

让我们使用轴外光束,随着像平面移动测量光斑的散布。

def spot_statistics_for_plane(z_image, field_angle, wavelength_um=0.5875618):
    image_test = make_image_plane(z_image)
    pupil_points = sample_pupil_grid(samples_per_axis=17, 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, back, image_test, N_BK7)
    points, valid = collect_image_points(traced)

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

    centered, ref = transverse_errors(points)
    rms_x = math.sqrt(float(np.mean(centered[:, 0]**2)))
    rms_y = math.sqrt(float(np.mean(centered[:, 1]**2)))
    rms_r = rms_spot_radius(points)

    return rms_x, rms_y, rms_r

扫描焦点:

z_values = np.linspace(35.0, 70.0, 71)
field_angle = 5.0

rms_x_values = []
rms_y_values = []
rms_r_values = []

for z in z_values:
    rms_x, rms_y, rms_r = spot_statistics_for_plane(z, field_angle)
    rms_x_values.append(rms_x)
    rms_y_values.append(rms_y)
    rms_r_values.append(rms_r)

plt.plot(z_values, rms_x_values, label="RMS x")
plt.plot(z_values, rms_y_values, label="RMS y")
plt.plot(z_values, rms_r_values, label="RMS radius")
plt.xlabel("image plane z [mm]")
plt.ylabel("RMS spot size [mm]")
plt.title("Focus scan for an off-axis field")
plt.legend()
plt.show()

如果 RMS x 和 RMS y 的最小值出现在不同的 z 位置,说明系统在横向两个方向上的最佳聚焦行为不同。

这就是计算层面的像散特征。

再次强调,我们不是让你记忆图表,而是展示光线追踪如何揭示这种效应。

场曲:最佳焦点随视场变化

一个平坦的传感器是平的,但实际光学系统可能偏好一个弯曲的像面。

场曲意味着最佳焦点位置随视场变化。中心视场可能在一个像平面上聚焦,而边缘视场希望另一个像平面。

这很容易与普通的离焦混淆,除非你比较多个视场点。

让我们为几个视场计算最佳 RMS 焦点。

def best_focus_by_rms(field_angle, z_min=35.0, z_max=70.0, count=71):
    z_values = np.linspace(z_min, z_max, count)
    rms_values = []

    for z in z_values:
        _, _, rms_r = spot_statistics_for_plane(z, field_angle)
        rms_values.append(rms_r)

    rms_values = np.array(rms_values)
    idx = np.nanargmin(rms_values)

    return z_values[idx], rms_values[idx], z_values, rms_values

运行它:

for field_angle in [0.0, 2.0, 4.0, 6.0]:
    z_best, rms_best, _, _ = best_focus_by_rms(field_angle)
    print(
        f"field={field_angle:.1f} deg, "
        f"best z≈{z_best:.2f} mm, "
        f"best RMS≈{rms_best:.6f} mm"
    )

如果最佳焦点随视场变化,那在计算上就是场曲。

如果最佳焦点面是弯曲的,平坦的像平面无法完美匹配所有视场。设计者可能接受它、校正它,或将其与其他像差平衡。相机镜头、显微物镜、目镜或投影镜头对此的重视程度各不相同。

关键点很简单:

场曲不仅仅是边缘模糊。
它是一种随视场变化的最佳焦点位置。

通过焦点扫描就能看到这一点。

畸变:即使光斑清晰,像的位置也是错的

畸变与模糊不同。

一个透镜可以形成一个相对清晰的像点,但位置错误。这就是畸变。

这就是为什么居中的点列图会隐藏畸变。如果你减去质心或主光线落点,你就移除了像的位移,只关注散布。这对于分析模糊很有用,但对于评估映射精度则不然。

要看到畸变,需比较真实像高和近轴像高。

根据第八章:

y_paraxial ≈ f tan θ

对于我们的简单单片透镜,我们可以使用薄透镜焦距估算作为粗略参考。真实的软件库会从实际光学系统提供更好的近轴参考,但薄透镜估算足以展示方法。

def real_chief_ray_image_height(field_angle_deg, z_image, wavelength_um=0.5875618):
    image_test = make_image_plane(z_image)

    chief = make_chief_ray(
        z_start=-20.0,
        theta_x_deg=0.0,
        theta_y_deg=field_angle_deg,
        wavelength_um=wavelength_um,
    )

    trace_simple_singlet_ray(chief, front, back, image_test, N_BK7)

    if not chief.alive:
        return np.nan

    return chief.position[1]

现在比较:

f_est = thin_lens_focal_length_from_radii(
    N_BK7.n(0.5875618),
    R1=50.0,
    R2=-50.0,
)

z_image = 50.0

for field_angle in [1.0, 2.0, 3.0, 5.0, 7.0]:
    y_real = real_chief_ray_image_height(field_angle, z_image)
    y_para = paraxial_image_height_from_field(f_est, field_angle)
    error = y_real - y_para

    print(
        f"field={field_angle:.1f} deg, "
        f"real y={y_real:.4f} mm, "
        f"paraxial y={y_para:.4f} mm, "
        f"distortion-like error={error:.4f} mm"
    )

这并不是一个精细的畸变分析。焦距参考是粗略的,我们简化后的主光线模型也比较简单。但结构是正确的:

视场角
→ 近轴像高
→ 真实主光线像高
→ 差异

这个差异就是映射误差。

光斑可能很小,但映射仍然可能畸变。这就是为什么畸变属于像差家族,但其表现却与模糊不同。

色差:不同波长,不同的光线

色差源自材料模型。

在第三章中,我们了解到玻璃的折射率取决于波长:

n = n(λ)

在第六章中,我们在斯涅耳定律中使用了那个折射率。

因此,不同波长的光线通过同一个透镜时,路径并不完全相同。

有两个常见的色差效应需要注意:

轴向色差:
    不同波长聚焦在轴向不同的位置

倍率色差:
    轴外成像时,不同波长对应不同的像高

让我们估算一个简单的轴向色差焦点位置。

for wl in [0.4861327, 0.5875618, 0.6562725]:
    z_best, rms_best, _, _ = best_focus_by_rms(
        field_angle=0.0,
        z_min=35.0,
        z_max=70.0,
        count=71,
    )
    print(wl, z_best, rms_best)

这个版本不小心在 spot_statistics_for_plane 中使用了默认波长,所以让我们明确指定波长。

def best_focus_by_rms_for_wavelength(
    field_angle,
    wavelength_um,
    z_min=35.0,
    z_max=70.0,
    count=71,
):
    z_values = np.linspace(z_min, z_max, count)
    rms_values = []

    for z in z_values:
        rms_x, rms_y, rms_r = spot_statistics_for_plane(
            z_image=z,
            field_angle=field_angle,
            wavelength_um=wavelength_um,
        )
        rms_values.append(rms_r)

    rms_values = np.array(rms_values)
    idx = np.nanargmin(rms_values)

    return z_values[idx], rms_values[idx]

现在计算:

for wl in [0.4861327, 0.5875618, 0.6562725]:
    z_best, rms_best = best_focus_by_rms_for_wavelength(
        field_angle=0.0,
        wavelength_um=wl,
    )

    print(
        f"λ={wl:.7f} µm, "
        f"best focus z≈{z_best:.2f} mm, "
        f"best RMS≈{rms_best:.6f} mm"
    )

如果不同波长的最佳焦点不同,你看到的就是轴向色差。

对于倍率色差,比较轴外主光线像高随波长的变化:

field_angle = 5.0
z_image = 50.0

for wl in [0.4861327, 0.5875618, 0.6562725]:
    y_real = real_chief_ray_image_height(
        field_angle_deg=field_angle,
        z_image=z_image,
        wavelength_um=wl,
    )

    print(f"λ={wl:.7f} µm, chief ray image y={y_real:.6f} mm")

如果像高随波长变化,在这个简化分析中就是倍率色差。

单片透镜看起来不会好,也确实如此。它之所以有用,是因为色差来源于同样的光线追踪机制。

在概念层面上,不需要一个专门的“色差模块”。

链条是:

波长
→ 折射率
→ 折射方向
→ 像面落点
→ 随波长变化的焦点或像高

从计算出发的分类,而非死记硬背

现在我们可以用计算术语总结经典像差。

球差

轴上光线在不同光瞳半径处聚焦不同。

计算迹象:

轴向交点 z 随光瞳高度变化
轴上光线扇面图显示误差与光瞳坐标呈弯曲关系

彗差

轴外光线形成不对称的模糊。

计算迹象:

轴外光斑变得不对称
轴外光线扇面图显示出不对称的误差项

像散

子午和弧矢截面聚焦不同。

计算迹象:

两个正交方向的扇面行为不同
RMS x 和 RMS y 可能在不同的像平面处达到最小值

场曲

最佳焦点随视场变化。

计算迹象:

最佳 RMS 焦点 z 随视场角改变而移动
平坦像平面无法同样好地匹配所有视场

畸变

像点与近轴映射位置相比有位移,即使光斑是清晰的。

计算迹象:

真实主光线或质心像高与近轴像高不同
居中的点列图可能掩盖这一点

色差

不同波长成像不同。

计算迹象:

最佳焦点随波长移动
轴外像高随波长变化
复色光光斑按颜色分离

这种学习像差的方法比背诵表格要慢,但也更扎实。

现在,这些名称都与可测量的行为相关联了。

为什么真实光线扇面图不是波前图

光线扇面图功能强大,但它仍然基于光线。

它绘制的是横向像误差与光瞳坐标的关系。它不直接显示光程差。它本身不给出 PSF 或 MTF。它仍然是几何光学的一部分。

这一局限性是重要的。

两个具有相似点列图的系统,可能波前相位行为不同。当几何光斑很小时,衍射会占据主导地位。接近衍射极限的镜头需要进行波前分析,而不仅仅是光线落点分析。

所以,本章是一座桥梁,但不是最后一座桥梁。

我们已经从:

光线落在哪里?

进入到:

真实光线如何偏离参考基准?

下一章将进入到:

光线路径如何转化为光瞳上的光程差?

那将是连接几何光线追踪与波动光学的桥梁。

Optiland 的 RayFan 作为一个更高层级的版本

一个真正的光学库已经具备光线扇面图工具。

在当前的 Optiland 文档中,RayFan 分析接受一个光学系统、视场、波长和采样点数;它追踪光瞳内的线性分布采样,并存储 x 和 y 方向的光线扇面数据。源码还显示,标准视图会将光线扇面误差相对于归一化光瞳坐标绘图,并分 x、y 扇面面板展示。(Optiland)

一个简洁的 Optiland 风格工作流程如下:

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

lens = CookeTriplet()

fan = RayFan(
    lens,
    fields="all",
    wavelengths="all",
    num_points=101,
)

fan.view()

对于自定义的单片透镜,设置方式遵循之前使用的光学系统构建风格:

from optiland import optic
from optiland.analysis import RayFan

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=5.0)
lens.wavelengths.add(value=0.5876, is_primary=True)

fan = RayFan(lens, fields="all", wavelengths="all", num_points=101)
fan.view()

将此视为库层面的对比代码。确切的导入路径和选项可能随项目发展而变化,但其计算含义是稳定的:

选择视场和波长
通过光瞳追踪线性采样
测量像面横向误差
绘制误差与归一化光瞳坐标的关系图

当前的 Optiland 源码还包含一个 BestFitRayFan 类,它将光线扇面数据参考到一个最佳拟合球面中心,而不是通常的主光线中心参考。这很好地提醒了我们,光线扇面图依赖于参考约定,而不仅仅是追踪到的光线。(Optiland)

这正是我们先自己构建一个小版本的原因。

当一个库给你一张漂亮的扇面图时,你现在可以问:

采样了哪条光瞳线?
使用了什么视场和波长?
减去了哪个参考点?
畸变是否被移除?
无效光线是否被抑制?

这些问题并非吹毛求疵,它们决定了图表的含义。

一个小型诊断工作流程

解读光线扇面结果时,应遵循一个有纪律的顺序。

首先,检查设置:

视场
波长
孔径
像平面
参考约定
采样密度

其次,检查是否有光线丢失:

被孔径遮挡
错过了某个表面
发生全内反射
无效的像面落点

第三,将离焦与残留形状分开。如果扇面图主要是一条直线,移动像平面或减去离焦项后,再诊断更细微的像差。

第四,比较 x 和 y 扇面。不要假设一个扇面能讲述完整的故事。

第五,比较视场。轴上和轴外行为通常完全不同。

第六,比较波长。如果曲线因波长而分离,那是材料模型在说话。

最后,将扇面图与点列图联系起来。扇面图解释结构,光斑展示二维落点模式。两者不能互相替代。

这个工作流程比仅凭视觉记忆去识别每种像差更为可靠。

解读像差时的常见错误

让我们明确列出这些陷阱。

错误 1:将所有模糊都称为“球差”

球差特指光线行为如何随光瞳半径变化,且是针对轴上视场而言的。轴外模糊可能涉及彗差、像散、场曲和其他效应。

错误 2:忘记像平面

如果像平面选择不当,光线扇面图可能被离焦主导。在诊断细微像差之前,应先检查焦点。

错误 3:居中操作消除了畸变

居中的光斑或参考主光线的扇面图可能移除了像位移。这对于模糊分析有用,但可能会隐藏畸变。

错误 4:比较不同参考基准的扇面图

参考主光线的扇面图与参考近轴像点或最佳拟合焦点的扇面图是不同的。

错误 5:忽略波长

单色光扇面图无法显示色差分离。复色光扇面图可能包含多个重叠的故事。

错误 6:过于自信地解读稀疏的扇面图

采样密度很重要。点太少会隐藏曲率,或使曲线看起来比实际更平滑。

错误 7:将光线扇面图当作衍射分析

光线扇面图属于几何光学。它们不能直接告诉你衍射 PSF 或 MTF。

错误 8:忘记单位

扇面图中的误差 0.01 mm 可能大也可能小,取决于像素大小、艾里斑尺寸、焦距和预期用途。

错误 9:期望一个简单单片透镜像一个校正良好的物镜那样工作

一个双凸的单片透镜是一个教学系统。它会清晰地显示像差,因为它未经校正。这在这里是很有用的。

错误 10:仅凭一幅图就下诊断

应结合使用点列图、光线扇面图、焦点扫描、视场比较和波长比较。一幅图很少揭示全部真相。

本章带给我们的收获

我们现在有了一种讨论像差的计算方法。

我们可以:

追踪真实光线通过镜头
选择一个参考落点
计算横向光线误差
跨光瞳采样线性扇面
绘制光线扇面曲线
扫描焦点位置
比较视场
比较波长
将光线扇面形状与经典像差联系起来

重要的转变是:

像差不再仅仅是名字,它们是计算出的光线误差中的模式。

这是本书后续部分恰当的理解水平。

当我们以后计算 OPD、PSF、OTF 和 MTF 时,我们不会忘记光线的故事。波动光学并没有抹去几何光学,而是扩展了它。光瞳上的波前建立在光程差之上,而这些光程来自系统中追踪的光线路径。

光线扇面图告诉我们光线如何偏离。

波前图将告诉我们相位如何偏离。

PSF 将展示一个点如何扩散。

MTF 将显示对比度如何保持。

这条链正在变长,但每个环节现在都清晰可见。

前方的道路

光线扇面图是一个强大的诊断工具,但它仍然停留在像平面坐标中。它告诉我们横向误差。

要进入衍射分析,我们需要一个不同的量。

我们需要光程差。

光线不仅仅落在某个地方,它在穿过空气和玻璃时也会累积光程。穿过光瞳的不同光线可能携带着相对于某个参考波前不同的光程长度。这种差异就成为了相位误差。

这就是从光线追踪到波动光学的桥梁。

因此,下一章将问:

我们如何将追踪到的光线路径转化为光瞳上的 OPD?

一旦我们有了 OPD,后续章节就可以构建光瞳函数、PSF、OTF,最后是 MTF。

点列图来自光线落点。

波前图来自光线携带的光程。

我们现在知道了第一个,接下来去学习第二个。

生成的验证图

第九章像差检查生成的光线扇面图样本

第 10 章:OPD 与波前误差

一个点列图告诉我们光线落在哪里。

这很有用。非常有用。在第 7 章中,点列图是我们第一个真正的分析图。在第 9 章中,光线扇形图帮助我们看清实际光线如何偏离近轴参考。我们可以观察横向光线误差,开始识别球差、彗差、像散、场曲、畸变和色差效应。

但这种观察方式存在局限。

一条光线可以落在接近正确位置的地方,而仍然带着错误的相位到达。

这句话是进入本书下一部分的门。

几何光线追踪跟踪光线去向。衍射成像关心光场如何叠加。要用傅里叶方法计算 PSF,我们不仅需要一组光线交点坐标,还需要一个复瞳函数。要建立这个瞳函数,我们需要瞳面上的相位。要得到相位,我们需要光程差。

这就是桥梁:

实际光线追迹
→ 光程长度
→ 光程差
→ 波前误差
→ 瞳面相位
→ PSF
→ OTF
→ MTF

本章是关于这座桥梁的中间部分。

我们将探讨:

什么是光程长度?
什么是 OPD?
什么是参考波前?
为什么 OPD 通常显示在瞳面上?
我们如何在 Python 中创建一个简单的 OPD 图?
这如何连接到 Optiland 的波前分析?

本章不会从完整的光线追迹中计算具有生产质量的 OPD。这需要谨慎的参考几何、出瞳坐标、光线瞄准、焦点约定以及逐面的光程记录。真正的程序库会处理这些细节。例如,当前 Optiland 的波前工具包括 OPD 和波前分析模块,其 Wavefront 类文档记录为根据所选策略计算出瞳光线交点、OPD、光线强度和参考球半径。(Optiland)

我们在这里的工作是理解这些工具在计算什么。

因此,我们将做两件事:

1. 小心地构建概念。
2. 自己生成简化的 OPD 图。

一旦你理解了 OPD 图,第 11 章就可以将其转换为复瞳函数。

那就是几何光学变成傅里叶光学的时刻。

为什么光线落点不够

想象两条光线到达像平面上同一点。

仅从点列图来看,它们看起来完美。它们落在一起。

但假设一条光线走过的光程比另一条稍长。那么当波干涉时,它们的相位可能不匹配。如果相位差很小,它们大部分是相长干涉。如果相位差很大,它们可能部分抵消或在衍射图样中产生结构。

点列图无法显示这一点。

点列图记录横向位置:

x_image, y_image

波前分析记录瞳面上的光程差:

OPD(Px, Py)

这些是不同种类的数据。

光线交点坐标回答:

光线去了哪里?

OPD 回答:

每个瞳面点相对于参考携带了多少光学相位延迟?

两者都很重要。

对于有严重像差的镜头,点列图可以提供很多信息。对于一个接近衍射极限的镜头,点列图可能变得不那么揭示性。几何光斑可能很小,但波前相位仍然决定详细的衍射图样。

这就是为什么我们需要 OPD。

它是光线追迹和 PSF 之间缺失的量。

光程长度

一束光在空气和玻璃中传播时,不仅覆盖了几何距离,还累积了光程长度。

对于折射率为 n 的介质中长度为 ds 的一小段,光程贡献是:

dOPL = n ds

沿整个路径:

OPL = ∫ n ds

在有离散分段的光线追迹器中,这变为:

OPL = n1 s1 + n2 s2 + n3 s3 + ...

其中:

s1, s2, s3 = 几何段长度
n1, n2, n3 = 这些段上的折射率

这正是为什么我们的 Ray 类从第 4 章开始就一直携带 opl

当我们写:

ray.propagate(distance, refractive_index=n)

我们更新了:

ray.opl += n * distance

当时,这看起来可能像是多余的簿记。现在它变得至关重要。

光线的最终位置告诉我们它落在哪里。

光线累积的 OPL 告诉我们它走了多长的光学距离。

这两条信息导致不同的分析。

一个小小的 OPL 示例

让我们重温一个最简单的路径。

一条光线经过:

空气中 20 mm
玻璃中 5 mm
空气中 20 mm

如果玻璃折射率是 1.5,那么:

几何距离 = 20 + 5 + 20 = 45 mm
OPL = 1.0×20 + 1.5×5 + 1.0×20 = 47.5 mm

用代码:

segments = [
    {"distance_mm": 20.0, "n": 1.0},
    {"distance_mm": 5.0,  "n": 1.5},
    {"distance_mm": 20.0, "n": 1.0},
]

opl = sum(seg["distance_mm"] * seg["n"] for seg in segments)
geometric = sum(seg["distance_mm"] for seg in segments)

print("几何距离 [mm]:", geometric)
print("光程长度 [mm]:", opl)

光线物理上走了 45 mm,但在光学上走了 47.5 mm。

这种差异不是表面功夫。它决定相位。

如果另一条光线走了略微不同的光程,即使它们的几何终点很接近,两条光线也可能以不同的相位到达。

从 OPL 到 OPD

单纯的光程长度还不是波前误差。

OPL 是沿一条射线累积的绝对路径。OPD 是相对于参考的差值。

一个简单的定义是:

OPD = OPL_ray - OPL_reference

但参考的选择很关键。

对于成像,理想的参考通常不是平面。一个朝向像点汇聚的完美会聚波具有以该像点为中心的球波前。所以参考波前通常是一个球面。

这意味着 OPD 被更准确地理解为:

实际波前与选定的参考波前之间的光程差

或者,用射线术语来说:

每条光线的光程与理想参考路径之间相差多少

这就是波前分析变得有些微妙的地方。我们必须决定“理想”意味着什么。

可能的参考选择包括:

主光线参考球
质心参考球
最佳拟合参考球
近轴参考球
像平面参考

不同的选择会去除或保留不同的低阶项,如平移、倾斜和离焦。这不是作弊,而是选择 OPD 图应该回答什么问题。

当前 Optiland 的文档直接反映了这一思想:其 Wavefront 分析接受策略选择,如 chief_raycentroid_spherebest_fit_sphere,并包含 OPD 数据的 remove_tilt 选项。(Optiland)

这告诉我们一个重要的事情:

OPD 不只是一个原始数值。它是相对于参考几何的数值。

在解释 OPD 图之前,必须先知道参考是什么。

以长度表示的 OPD 与以波长表示的 OPD

OPD 可以用长度单位表达:

OPD = 0.00025 mm

它也可以用波长来表示:

OPD_waves = OPD_length / wavelength

这对于相位通常更有用。

如果波长为 0.5 µm,那么 OPD 为 0.25 µm 就是半个波长:

0.25 µm / 0.5 µm = 0.5 waves

相位与以波长表示的 OPD 的关系为:

phase = 2π × OPD_waves

或使用长度单位:

phase = 2π × OPD_length / wavelength

这种单位转换是错误容易隐藏的地方。

如果 OPL 以毫米存储,波长以微米存储,我们必须转换:

wavelength_mm = wavelength_um × 1e-3

然后:

OPD_waves = OPD_mm / wavelength_mm

让我们编写辅助函数:

import numpy as np
import math
import matplotlib.pyplot as plt

def wavelength_um_to_mm(wavelength_um):
    return wavelength_um * 1e-3

def opd_mm_to_waves(opd_mm, wavelength_um):
    wavelength_mm = wavelength_um_to_mm(wavelength_um)
    return opd_mm / wavelength_mm

def opd_waves_to_phase(opd_waves):
    return 2.0 * np.pi * opd_waves

测试:

opd_um = 0.25
wavelength_um = 0.5

opd_mm = opd_um * 1e-3
waves = opd_mm_to_waves(opd_mm, wavelength_um)

print("OPD [waves]:", waves)
print("phase [radians]:", opd_waves_to_phase(waves))

结果应该是:

0.5 waves
π radians

这一步将在第 11 章中直接被使用。

瞳面坐标

OPD 通常表现为瞳面上的函数。

我们不按像平面坐标绘制 OPD,而是在归一化瞳面坐标上绘制:

Px, Py

其中瞳面圆为:

Px² + Py² <= 1

瞳面坐标标识了采样光线通过瞳面的位置。对于瞳面上的每个点,波前相对于参考有某个光程差。

所以 OPD 图是:

OPD(Px, Py)

这就是为什么波前图看起来像彩色圆盘。圆盘是瞳面。颜色是 OPD。

让我们创建一个瞳面网格。

def make_normalized_pupil_grid(N):
    """创建一个带有圆形瞳面掩膜的方形网格。

    Returns
    -------
    X, Y:
        从 -1 到 +1 的网格坐标。

    R:
        半径 sqrt(X^2 + Y^2)。

    mask:
        布尔圆形孔径掩膜。
    """
    u = np.linspace(-1.0, 1.0, N)
    X, Y = np.meshgrid(u, u)
    R = np.sqrt(X**2 + Y**2)
    mask = R <= 1.0
    return X, Y, R, mask

绘制掩膜:

X, Y, R, mask = make_normalized_pupil_grid(256)

plt.imshow(mask, extent=[-1, 1, -1, 1], origin="lower")
plt.xlabel("Pupil X")
plt.ylabel("Pupil Y")
plt.title("Circular pupil mask")
plt.colorbar(label="inside pupil")
plt.show()

这还不是 OPD。这是 OPD 将要存在的域。

一个简化的波前函数

现在我们将直接生成一个 OPD 图。

这还不是从完整光线追迹计算出来的,而是一个受控的教学模型。我们在瞳面上选择一个波前误差函数,然后将其可视化。

这很有用,因为本章的目标是表示方法:

OPD over pupil → phase → pupil function

OPD 的来源可以是实际光线追迹、拟合的 Zernike 模型、测量的波前传感器,或简化的多项式。表示方法是相同的。

让我们以波长为单位构建一个玩具波前。

使用归一化瞳面坐标:

X, Y ∈ [-1, 1]
R² = X² + Y²

添加简单项:

类离焦项:      2R² - 1
类像散项:      X² - Y²
类彗差项:      (3R² - 2)Y
类球差项:      6R⁴ - 6R² + 1

这些不是严格归一化的 Zernike 多项式,而是教学用的形状。这对于理解 OPD 图如何工作来说足够了。

def toy_opd_waves(X, Y, mask, defocus=0.0, astig=0.0, coma=0.0, spherical=0.0):
    """在归一化圆形瞳面上创建一个以波长为单位的玩具 OPD 图。

    各项为简单教学多项式,非归一化 Zernike 多项式。
    """
    R2 = X**2 + Y**2
    R4 = R2**2

    W = np.zeros_like(X, dtype=float)

    W += defocus * (2.0 * R2 - 1.0)
    W += astig * (X**2 - Y**2)
    W += coma * (3.0 * R2 - 2.0) * Y
    W += spherical * (6.0 * R4 - 6.0 * R2 + 1.0)

    W = np.where(mask, W, np.nan)
    return W

现在创建一个图:

X, Y, R, mask = make_normalized_pupil_grid(256)

W = toy_opd_waves(
    X, Y, mask,
    defocus=0.25,
    astig=0.10,
    coma=0.08,
    spherical=0.05,
)

这里 W 是以波长为单位的 OPD。

这意味着:

W = 1.0 表示一个波长的 OPD
W = 0.5 表示半个波长
W = 0.0 表示等于参考

绘制它:

def plot_opd_map(W, title="OPD map [waves]"):
    plt.imshow(W, extent=[-1, 1, -1, 1], origin="lower")
    plt.xlabel("Pupil X")
    plt.ylabel("Pupil Y")
    plt.title(title)
    plt.colorbar(label="OPD [waves]")
    plt.gca().set_aspect("equal", adjustable="box")
    plt.show()

plot_opd_map(W, title="Toy OPD map")

现在我们有了一个 OPD 图。

它不是一个点列图,不是一个图像,也不是一个 PSF。

它是瞳面上的波前误差。

解读 OPD 图

一个 OPD 图是光程中的高度图。

高区和低区意味着瞳面的不同部分相对于参考具有不同的相位延迟。

一个完美校正的波前在去除不相关的平移和倾斜后将是平坦的:

OPD(Px, Py) ≈ 0

离焦的波前呈碗状或圆顶状。

像散沿着正交瞳面方向改变符号。

彗差具有与视场方向相关的不对称形状。

球差是旋转对称的,但以更高阶的方式随瞳面半径变化。

同样,不要过度记忆这些图片。计算意义更为重要:

每个瞳面点都有一个相位延迟。
OPD 图存储了那个延迟。
瞳函数将把那个延迟转换成复相位因子。

OPD 图不是最终的像质指标,而是一个中间表示。

但它是让衍射得以进入的表示。

平移、倾斜和离焦

一些 OPD 项会以可能对不同分析而言重要或不重要的方式平移波前。

平移

平移是加到各处的常数 OPD:

W_new(Px, Py) = W(Px, Py) + constant

这为整个瞳面添加相同的相位。对于强度 PSF,全局相位因子通常不改变结果。所以平移常常被移除。

倾斜

倾斜是贯穿瞳面的线性相位斜坡:

W(Px, Py) = a Px + b Py

倾斜会移动 PSF 的位置。如果你想独立于指向或主光线偏移来分析像质,可以移除倾斜。如果你关心图像位移,可以保留它。

离焦

离焦是二次波前项。移除离焦相当于改变参考焦点。有时你想移除它以评估最佳焦点。有时你想保留它以了解当前像平面离焦点有多远。

这就是波前软件通常提供移除或保留低阶项选项的原因。

去除了平移的图并不“更真实”。它回答了不同的问题。

让我们编写一个简单的平移移除:

def remove_piston(W, mask):
    """减去瞳面上的平均 OPD。"""
    W2 = W.copy()
    mean = np.nanmean(W2[mask])
    W2[mask] -= mean
    return W2

使用它:

W_no_piston = remove_piston(W, mask)

print("mean before:", np.nanmean(W[mask]))
print("mean after:", np.nanmean(W_no_piston[mask]))

对于倾斜移除,进行平面拟合:

def remove_piston_and_tilt(W, X, Y, mask):
    """从瞳面上的 W 中移除最佳拟合平面 a + bX + cY。"""
    valid = mask & np.isfinite(W)

    A = np.column_stack([
        np.ones(np.count_nonzero(valid)),
        X[valid],
        Y[valid],
    ])

    b = W[valid]
    coeffs, *_ = np.linalg.lstsq(A, b, rcond=None)

    plane = coeffs[0] + coeffs[1] * X + coeffs[2] * Y
    W2 = W.copy()
    W2[valid] = W[valid] - plane[valid]

    return W2, coeffs

使用它:

W_flat, coeffs = remove_piston_and_tilt(W, X, Y, mask)

print("removed plane coefficients:", coeffs)
plot_opd_map(W_flat, title="Toy OPD map after piston and tilt removal")

这段代码有意简化了。真正的光学波前工具可能使用参考球、光线瞄准、Zernike 拟合或最佳拟合策略。但思路是相同的:

移除代表参考选择的项
检查残余波前误差

RMS 和峰谷波前误差

两种常见的 OPD 摘要是 RMS 波前误差和峰谷波前误差。

RMS 波前误差为:

RMS = sqrt(mean((W - mean(W))²))

在瞳面上。

峰谷值为:

PV = max(W) - min(W)

在瞳面上。

代码实现:

def wavefront_rms(W, mask):
    valid = mask & np.isfinite(W)
    values = W[valid]
    values = values - np.mean(values)
    return math.sqrt(float(np.mean(values**2)))

def wavefront_pv(W, mask):
    valid = mask & np.isfinite(W)
    values = W[valid]
    return float(np.max(values) - np.min(values))

计算:

print("RMS [waves]:", wavefront_rms(W, mask))
print("PV [waves]:", wavefront_pv(W, mask))

这些数字很有用,但它们不是全部。

具有相同 RMS 的波前可能具有不同的空间结构。一个可能主要是离焦,另一个可能有高频波纹。它们的 PSF 可能不同,它们的 MTF 可能不同。

所以 RMS 是一个摘要,而不是像质神谕。

这一课现在应该感觉很熟悉了。光斑 RMS 并没有取代点列图。波前 RMS 没有取代 OPD 图或 PSF。

OPD 以毫米、微米和波长表示

让我们将玩具波前从波长转换为物理长度。

假设波长为:

λ = 0.5876 µm

那么:

OPD_um = W_waves × λ_um

并且:

OPD_mm = OPD_um × 1e-3

代码:

def opd_waves_to_um(W_waves, wavelength_um):
    return W_waves * wavelength_um

def opd_waves_to_mm(W_waves, wavelength_um):
    return W_waves * wavelength_um * 1e-3

示例:

wavelength_um = 0.5876

W_rms_waves = wavefront_rms(W, mask)
W_rms_um = opd_waves_to_um(W_rms_waves, wavelength_um)

print("RMS [waves]:", W_rms_waves)
print("RMS [µm]:", W_rms_um)

这种转换很重要,因为不同工具以不同方式报告 OPD。一些报告波长,一些报告长度。一些在纳米尺度标记图。一些按分析波长归一化。

例如,Optiland 的 WavefrontData 文档描述了 opd 字段作为归一化到波长的光程差数据。(Optiland)

因此,如果你在工具之间比较输出,务必检查单位。

在 0.5 µm 下 0.1 waves 的波前误差与在 10 µm 下 0.1 waves 的波前误差物理长度不同。

以波长表示时,它们看起来相等。但以微米表示时,它们不相等。

一个线轮廓:OPD 扇形图

OPD 图是一个二维瞳面图。

OPD 扇形图是光线扇形图的波前版本:它显示沿瞳面上一条线的 OPD。

例如:

OPD(Px, 0)

或:

OPD(0, Py)

让我们提取一条经过中心的水平线。

def center_line_profile(W, axis="x"):
    N = W.shape[0]
    c = N // 2

    if axis == "x":
        coord = np.linspace(-1.0, 1.0, N)
        values = W[c, :]
    elif axis == "y":
        coord = np.linspace(-1.0, 1.0, N)
        values = W[:, c]
    else:
        raise ValueError("axis must be 'x' or 'y'")

    return coord, values

绘制:

px, wx = center_line_profile(W_no_piston, axis="x")

plt.plot(px, wx)
plt.axhline(0.0, linewidth=0.8)
plt.axvline(0.0, linewidth=0.8)
plt.xlabel("Pupil X")
plt.ylabel("OPD [waves]")
plt.title("OPD fan through pupil center")
plt.show()

这看起来在精神上类似于光线扇形图,但纵轴不同。

光线扇形图:

transverse image error [mm]

OPD 扇形图:

optical path difference [waves or length]

两者都是瞳面坐标诊断工具。它们回答不同的问题。

光线像差与波像差相关但不相同

很容易说:

光线扇形图是波前的斜率

这个想法中有真理,但需要谨慎。在经典光学理论中,在某些近似下,横向光线像差与波像差的导数相关。在大数值孔径或有复杂波前结构的情况下,这种关系可能需要更谨慎的处理。

对于本书,实际信息是:

光线扇形图显示光线在像平面上偏离的位置。
OPD 图显示瞳面上的光程误差。
它们相互关联,但在所有情况下都不能简单替代。

这对我们的计算链来说足够了。

我们不需要在这里推导完整的数学关系。我们需要理解为什么下一个表示是必要的。

光线落点引导我们得到点列图和光线扇形图。

光程差引导我们得到瞳面相位和衍射。

这就是过渡。

从 OPD 到相位

现在来读本章最重要的一行。

如果 W 是以波长表示的 OPD,那么瞳面相位为:

φ(Px, Py) = 2π W(Px, Py)

代码:

phase = 2.0 * np.pi * W_no_piston

如果 OPD 以毫米为单位,那么:

phase = 2.0 * np.pi * opd_mm / wavelength_mm

这个相位就是复瞳函数所使用的:

P(Px, Py) = A(Px, Py) exp(i φ(Px, Py))

其中:

A(Px, Py) = 孔径振幅
φ(Px, Py) = 来自 OPD 的相位

不要匆忙略过这一点。这是第 10 章与第 11 章之间的交接。

OPD 图是实数值。

瞳函数是复数值。

转换是通过相位进行的。

这就是波前误差如何变成傅里叶光学可以使用的东西。

瞳面相位预览

让我们绘制相位包裹在 π 之间的图。

def wrap_phase(phase):
    """将相位包裹到 [-pi, pi]。"""
    return (phase + np.pi) % (2.0 * np.pi) - np.pi

phase = opd_waves_to_phase(W_no_piston)
phase_wrapped = np.where(mask, wrap_phase(phase), np.nan)

plt.imshow(phase_wrapped, extent=[-1, 1, -1, 1], origin="lower")
plt.xlabel("Pupil X")
plt.ylabel("Pupil Y")
plt.title("Wrapped pupil phase")
plt.colorbar(label="phase [radians]")
plt.gca().set_aspect("equal", adjustable="box")
plt.show()

这个图可能看起来比 OPD 图更突兀,因为相位包裹在 ±π 处跳跃。这并不意味着波前在那里物理跳跃,而是相位显示以 为模进行包裹。

这是另一个常见的混淆来源。

对于平滑的波前误差,OPD 通常更容易阅读。

包裹的相位对于观察复场的周期性很有用。

两者都是相同底层延迟的视图。

一个特意简化的 OPD 到 PSF 预览

我们还没有进入完整的 PSF 章节,但一个小小的预览有助于展示为什么 OPD 很重要。

给定:

孔径振幅 A
相位 φ

瞳面场为:

pupil = A exp(iφ)

一个简化的夫琅禾费 PSF 可以从瞳场傅里叶变换的模平方计算出来。

现在不必担心缩放。第 12 章将妥善处理这一点。这里的目的仅仅是展示改变 OPD 会改变衍射图样。

def quick_psf_from_opd(W_waves, mask):
    """根据以波长表示的 OPD 生成一个最小化、未缩放的 PSF 预览。

    这只是为了直观理解。第 12 章将处理采样和缩放。
    """
    phase = 2.0 * np.pi * np.nan_to_num(W_waves, nan=0.0)
    pupil = mask.astype(float) * np.exp(1j * phase)

    field = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(pupil)))
    psf = np.abs(field)**2
    psf = psf / psf.sum()

    return psf

比较一个平坦波前和一个有像差的波前:

W_flat = np.where(mask, 0.0, np.nan)
psf_flat = quick_psf_from_opd(W_flat, mask)
psf_aberrated = quick_psf_from_opd(W_no_piston, mask)

绘制一个:

def plot_psf_preview(psf, title):
    plt.imshow(np.log10(psf + 1e-12), origin="lower")
    plt.title(title)
    plt.colorbar(label="log10 intensity")
    plt.show()

plot_psf_preview(psf_flat, "Preview PSF: flat wavefront")
plot_psf_preview(psf_aberrated, "Preview PSF: aberrated wavefront")

这只是一个预览,但它使桥梁可见。

点列图来自光线交点坐标。

PSF 来自复瞳场。

复瞳场来自孔径振幅和 OPD 导出的相位。

因此,PSF/MTF 不能仅通过光线落点来完全解释。

它们需要波前相位。

实际光线追迹如何提供 OPD

我们的玩具 OPD 图是从多项式项创建的。真正的波前分析必须从光学系统计算 OPD。

概念上的步骤如下:

1. 选择视场和波长。
2. 在瞳面上采样光线。
3. 通过光学系统追迹光线。
4. 为每条光线累积光程长度。
5. 确定一个参考波前或参考球。
6. 将每条光线的光程与参考比较。
7. 在瞳面坐标上存储 OPD。
8. 可选择移除平移、倾斜或焦点项。

第 5 步是微妙的一步。参考波前是使 OPD 有意义的东西。没有它,你只有 OPL 值,没有波前误差。

这也是为什么生产工具会暴露策略选项。策略定义了参考几何是如何选择的。

在 Optiland 文档记载的 Wavefront 类中,参数包括视场、波长、光线数量、分布模式如 hexapolar、策略选择、无焦模式以及倾斜移除。文档说明该类计算出瞳光线交点、OPD、光线强度和参考球半径。(Optiland)

这就是上述概念列表的程序库级别版本。

一个简化的从 OPL 到 OPD 的草图

让我们编写一个草图来澄清 OPL 和 OPD 之间的区别。

假设我们有几条光线,每条有:

瞳面坐标
OPL
参考 OPL

那么:

def compute_opd_from_opl(opl_values, reference_opl_values):
    """将 OPD 计算为 OPL 差值。"""
    return np.asarray(opl_values) - np.asarray(reference_opl_values)

如果参考只是主光线 OPL,那么:

def compute_opd_relative_to_chief(opl_values, chief_index):
    opl_values = np.asarray(opl_values, dtype=float)
    return opl_values - opl_values[chief_index]

这很简单,但不是一个完整的成像 OPD 计算。仅基于主光线 OPL 的差值可能不包括到像点的正确球面参考。它可能根据几何结构混入倾斜或离焦。

这就是为什么这段代码只是一个草图。

真正要点是:

OPL 沿光线累积。
OPD 是 OPL 与选定参考的比较。
波前误差是在瞳面上解释的 OPD。

将这三者区分开。

Optiland OPD 图比较

Optiland 提供了更高级别的 OPD 工具,因此实际工作流程可以比我们的教学模型短得多。

当前文档库展示了一个 OPD 图示例,使用:

from optiland.samples.infrared import InfraredTripletF4
from optiland.wavefront import OPD

lens = InfraredTripletF4()
lens.draw()

opd = OPD(lens, field=(0, 1), wavelength=4.2)
opd.view()

该示例将输出标记为在瞳面坐标上的 OPD 图,并报告以波长表示的 RMS。(Optiland)

对于一个可见光单透镜,概念性工作流程看起来类似:

from optiland import optic
from optiland.wavefront import OPD

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.wavelengths.add(value=0.5876, is_primary=True)

opd = OPD(lens, field=(0, 0), wavelength=0.5876)
opd.view()

请将此视为程序库级别的比较代码。具体行为可能取决于安装的 Optiland 版本和光学模型。重要的计算意义是稳定的:

程序库对瞳面进行采样
追迹光线
相对于参考几何计算 OPD
在瞳面坐标上显示 OPD

本章中的教学代码直接将 OPD 生成为多项式图。Optiland 从实际光学系统计算 OPD。

两者产生相同类型的对象:

OPD(Px, Py)

那个对象正是第 11 章所需要的。

波前分析和 Zernike 系数

OPD 图经常被分解为 Zernike 多项式。

Zernike 是一组定义在单位圆上的函数。它们很有用,因为瞳面通常是圆形的,而常见的像差类形状可以用低阶 Zernike 项表示。

例如,类 Zernike 项可以表示:

平移
倾斜
离焦
像散
彗差
球差

这就是为什么光学软件经常在 OPD 图旁边报告 Zernike 系数。

我们不会在这里推导 Zernike 多项式。本章的目的更基本:理解 OPD 图本身。

但了解 Zernike 的位置是有帮助的:

OPD 图:
    瞳面上原始或计算得出的波前误差

Zernike 拟合:
    该 OPD 图的紧凑系数表示

Zernike 表不是理解 OPD 的替代品。它是描述它的一种紧凑方式。

Optiland 的波前 API 概览包括与 OPD、波前、参考几何以及 PSF/MTF 模块并列的 Zernike 相关 OPD 模块。(Optiland)

这种放置是合理的:Zernike 是波前分析的一部分,而不是一个独立的宇宙。

OPD 能告诉你什么,不能告诉你什么

OPD 很强大,但我们不应该夸大它。

OPD 可以告诉你:

瞳面上的相位误差
RMS 波前误差
峰谷波前误差
主要的低阶波前形状
瞳面相位是否接近平坦
波前误差如何随视场或波长变化

OPD 本身不直接告诉你:

完整的 PSF 强度分布
MTF 曲线
传感器采样效应
杂散光
镀膜鬼影
制造公差
偏振效应
散射

OPD 是衍射计算的输入,而不是最终答案。

这与我们之前看到的模式相同。

处方不是光线追迹。

光线追迹不是点列图。

点列图不是波前。

波前不是 PSF。

PSF 不是 MTF。

每个表示都保留了一些信息,转换了一些信息,并丢弃或摘要了一些信息。

技能在于知道哪个表示回答哪个问题。

一个有用的心理模型

这是记住这种过渡的最简单方法:

光线位置误差:
    “光线落在了哪里?”

波前误差:
    “波在这个瞳面点是迟到还是早到?”

光线扇形图将第一种误差相对于瞳面坐标绘制。

OPD 扇形图将第二种误差相对于瞳面坐标绘制。

点列图显示光线落点。

OPD 图显示瞳面上的相位延迟。

PSF 将显示当所有这些瞳面贡献干涉时会发生什么。

最后一个词很重要:干涉。

光线图不干涉。波会干涉。

OPD 是光线追迹世界将相位信息交给波动光学世界的方式。

OPD 常见错误

在我们继续之前,让我们指出陷阱。

错误 1:混淆 OPL 和 OPD

OPL 是沿光线累积的光程长度。

OPD 是该路径与参考之间的差值。

一个 OPL 值列表不会自动成为波前图。

错误 2:忘记参考几何

OPD 总是取决于参考。主光线球、质心球、最佳拟合球、近轴焦点以及像平面参考可能给出不同的图。

询问使用了什么参考。

错误 3:混淆长度单位和波长

如果 OPD 以毫米为单位,波长以微米为单位,在相除之前进行转换。

wavelength_mm = wavelength_um × 1e-3

错误 4:将平移视为图像劣化

常数相位偏移通常不会改变强度 PSF。平移在干涉测量或分段系统中可能很重要,但对于普通的单瞳 PSF 强度,它经常被移除。

错误 5:不清楚移除倾斜的含义就移除

移除倾斜会使波前相位重新居中,并常常移动 PSF 参考。这可能是可取的,但它改变了所问的问题。

错误 6:总是把离焦视为不好或总是可移除的

离焦可能代表错误的像平面、有意的焦点移动,或者你想优化去掉的项。不要盲目移除它。

错误 7:将包裹的相位解读为物理不连续

相位在 ±π 处包裹。包裹相位中的跳变可能只是显示包裹,而非物理不连续。

错误 8:认为 RMS 波前误差讲述全部故事

RMS 很有用,但具有相同 RMS 的不同 OPD 形状可能产生不同的 PSF 和 MTF。

错误 9:忘记波长依赖性

相同的物理 OPD 长度在不同波长下对应不同的波长数。

错误 10:期望 OPD 取代光线图

OPD 是波前表示。光线扇形图和点列图仍然是有用的几何诊断工具。

本章给了我们什么

我们现在已经跨越了从几何光线追迹到波动光学的桥梁。

我们可以解释:

OPL 作为 n ds 累积
OPD 作为相对于参考的光程差
为什么 OPD 绘制在瞳面坐标上
为什么 OPD 可以用长度或波长表示
OPD 如何变成相位
为什么平移、倾斜和离焦依赖于分析惯例
如何生成玩具 OPD 图
如何计算 RMS 和 PV 波前误差
OPD 图如何不同于点列图和光线扇形图
Optiland 的波前工具如何融入同一链条

核心变换是:

以波长表示的 OPD(Px, Py)
→ phase φ(Px, Py) = 2π OPD(Px, Py)

这就是与下一章的确切交接。

前方的路

现在我们有了一个实值波前误差图。

但衍射计算需要瞳面上的复场。

该场有两部分:

振幅:光线在哪里通过以及有多强
相位:每个瞳面点携带多少光学延迟

孔径掩膜提供振幅。

OPD 图提供相位。

它们一起构成瞳函数:

P(Px, Py) = A(Px, Py) exp(i 2π W(Px, Py))

其中 W 是以波长表示的 OPD。

第 11 章将仔细构建那个瞳函数。一旦我们有了它,PSF 就不再是一个神秘的像质图,而是一个复瞳场的傅里叶变换,随之而来的所有关于采样、缩放和归一化的常见警告也在附近等待。

我们快要理解了。

光线追迹器告诉我们光去了哪里。

OPD 图告诉我们波前如何被延迟。

瞳函数将把那个延迟转化为每个瞳面点上的复数。

那就是衍射计算开始的地方。

生成的验证图

来自第 10 章波前模型的 OPD 图

来源和验证说明

OPD 的讨论有意考虑了约定。OPD 只有在定义了参考波前或参考几何之后才有意义。本章中的玩具 OPD 图验证了表示方法和相位转换;它不能替代生产级的光线瞄准、出瞳分析、参考球拟合或 Zernike 报告。

第11章 瞳孔函数:用复数表示波前

在上一章末尾,光程差(OPD)为我们提供了一种新的信息。

点列图告诉我们会聚点的位置。光线扇面图告诉我们实际光线与参考光线的偏离。OPD则告诉我们更细微的信息:对于瞳孔平面上的每个点,该处的波前与理想参考波前相比,超前或滞后了多少。

这已经是一大步了。但OPD本身仍不足以生成图像。

为了计算衍射点扩散函数(PSF),计算机需要知道整个瞳孔的光在像面上如何干涉。这意味着每个瞳孔点需要两种信息:

  1. 透过的光量。
  2. 该光线具有的相位。

这两方面正是瞳孔函数所存储的内容。

瞳孔函数是波前误差转化为复数数组的关键步骤。它是几何光学路径误差与傅里叶光学之间的桥梁。

本章在概念上篇幅不长,但在整个处理链条中地位重要:

OPD图
→ 瞳孔振幅
→ 瞳孔相位
→ 复瞳孔函数
→ PSF

下一章将对这个复瞳孔函数进行傅里叶变换,并将其转化为PSF。这里,我们将仔细准备输入数据。

不要跳过这一步。许多错误的PSF和MTF计算都源于瞳孔函数构建时使用了错误的单位、错误的掩膜或错误的符号约定。


1. 我们正在打开的黑箱

衍射计算在书中常以简洁的等式呈现:

PSF与瞳孔函数的傅里叶变换相关。

在通常的夫琅禾费近似下,这个说法是正确的,但它掩盖了一个实际问题:

代码中瞳孔函数究竟是什么?

它不只是孔径形状。

它不只是OPD图。

它不只是相位图。

它是瞳孔平面上一个复值函数:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x, y) = A(x, y)\exp\left(i\frac{2\pi W(x, y)}{\lambda}\right)

其中:

  • P(x,y)P(x, y) 是复瞳孔函数。
  • A(x,y)A(x, y) 是瞳孔振幅。
  • W(x,y)W(x, y) 是OPD或波前误差。
  • λ\lambda 是波长。
  • ii 是虚数单位。
  • x,yx, y 是瞳孔坐标。

这个公式可以概括本章的全部内容。

但仅有这一行是不够的。我们需要知道每个部分的意义、如何采样,以及如何在不自欺欺人的情况下编写代码。


2. 孔径掩膜:光存在的地方

从最简单的部分开始:孔径。

对于一个理想的圆形瞳孔,光通过圆内各点,并在圆外被阻挡。在归一化瞳孔坐标中,我们可以写:

ρ=x2+y2 \rho = \sqrt{x^2 + y^2}

瞳孔内部:

ρ1 \rho \leq 1

瞳孔外部:

ρ>1 \rho > 1

因此,最简单的孔径掩膜是:

M(x,y)={1,ρ10,ρ>1 M(x, y) = \begin{cases} 1, & \rho \leq 1 \ 0, & \rho > 1 \end{cases}

这个掩膜还不是完整的瞳孔函数。它只说明了哪些地方允许光线通过。

在代码中,这变成一个布尔数组:

mask = rho <= 1.0

这行代码很重要。没有它,你的FFT将包含来自瞳孔外部的光线。结果可能看起来像个图案,但它不再是你打算建模的光学系统的PSF。

在计算光学中,这是一个有益的习惯:

错误的数组仍可能产生一幅美丽的图像。

计算机并不知道你的瞳孔是否物理上合理。它只执行你给出的数字。


3. 瞳孔振幅:并不总是只有1

对于一个清晰、理想、均匀照明的圆形孔径,振幅通常写为:

A(x,y)=M(x,y) A(x, y) = M(x, y)

这意味着:

  • 瞳孔内部的振幅为1;
  • 瞳孔外部的振幅为0。

这是最简单的情况,也是正确的起点。

但在实际光学系统中,振幅可能更复杂。它可能包括:

  • 渐晕;
  • 中心遮挡;
  • 望远镜中的蜘蛛支架;
  • 透过率变化;
  • 变迹;
  • 偏振相关效应;
  • 镀膜或材料的透过率差异。

例如,带有中心遮挡的望远镜,其掩膜如下:

M(x,y)={1,rinner<ρ10,其它情况 M(x, y) = \begin{cases} 1, & r_\text{inner} < \rho \leq 1 \ 0, & \text{其它情况} \end{cases}

带有变迹的系统,其孔径内部可能具有平滑的振幅,例如:

A(ρ)=exp(αρ2) A(\rho) = \exp(-\alpha \rho^2)

在本章中,我们将主要使用简单的圆形瞳孔。但重要的是要清楚地区分:

掩膜:允许光通过的区域
振幅:通过的场振幅大小
强度:振幅的平方

振幅不是强度。如果强度透过率为TT,那么场振幅通常与以下值成比例:

A=T A = \sqrt{T}

这个平方根是常见的错误来源。如果你将强度直接放入场振幅数组中,PSF的能量将是错误的。

目前,我们将保持:

amplitude = mask.astype(float)

这足以构建第一个瞳孔函数。


4. OPD变成相位

OPD图告诉我们每个瞳孔点具有多少光程差。

如果某一点的OPD为W(x,y)W(x,y),那么对应的相位为:

ϕ(x,y)=2πW(x,y)λ \phi(x, y) = \frac{2\pi W(x, y)}{\lambda}

这是关键的转换。

单位规则很简单,但很严格:

W和λ必须使用相同的长度单位。

如果WW以米为单位,λ\lambda也必须以米为单位。

如果WW以微米为单位,λ\lambda也必须以微米为单位。

如果WW以纳米为单位,而λ\lambda以米为单位,代码仍然会运行,但相位将因10910^9的因子而出错。不会有警告,只会有一个错误的结果。

让我们把物理意义具体化。

假设波长为:

λ=550 nm \lambda = 550\text{ nm}

如果OPD为一个整波长:

W=λ W = \lambda

则:

ϕ=2πλλ=2π \phi = \frac{2\pi\lambda}{\lambda} = 2\pi

该点完成了一个完整的相位周期。

如果OPD为半个波长:

W=λ2 W = \frac{\lambda}{2}

则:

ϕ=π \phi = \pi

该点的相位差了半个周期。

这就是为什么很小的OPD会产生如此大的影响。几百纳米在机械尺度上很小,但在光学上,它可能是可见光波长中的很大一部分。

瞳孔函数将此相位以复数形式存储:

exp(iϕ)=cosϕ+isinϕ \exp(i\phi) = \cos\phi + i\sin\phi

因此,完整的瞳孔函数变为:

P(x,y)=A(x,y)exp(iϕ(x,y)) P(x, y) = A(x, y)\exp(i\phi(x, y))

或者,代入相位:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x, y) = A(x, y)\exp\left(i\frac{2\pi W(x, y)}{\lambda}\right)

这是最简洁的记忆形式。


5. 为什么这里复数不是装饰品

人们很容易将复数视为数学上的装饰。但在这里,它们不是装饰。它们是我们存储相位的方式。

一个实值的OPD图说明:

这个瞳孔点超前或滞后了这么多光程

一个复瞳孔函数说明:

这个瞳孔点以这个振幅和这个相位作出贡献

当我们稍后计算PSF时,来自不同瞳孔点的光将发生干涉。干涉取决于相位。

瞳孔的两个部分可能具有相同的振幅但相反的相位。它们可能会部分抵消。两个部分也可能相位对齐。它们可能会相互加强。

这就是为什么点列图不能完全替代衍射计算的原因。点列图将光线视为会聚点。瞳孔函数则准备光的波动特性,以便进行相干求和。

这里有一个重要的转变:

几何光线追迹问的是:
这条光线射向哪里?

波动光学问的是:
所有瞳孔贡献如何作为复场叠加在一起?

瞳孔函数正是我们准备第二个问题答案的地方。


6. 最小的瞳孔网格

现在我们用Python编写第一个版本。

我们将使用归一化瞳孔坐标。这意味着瞳孔半径为1,坐标范围大致为:

x 从 -1 到 1
y 从 -1 到 1

这还不是以毫米为单位的物理长度。它是一个归一化坐标系。这对本章来说是可以的,因为我们关注的是转换:

OPD → 相位 → 复瞳孔

这是生成网格的代码:

import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    """
    创建带有归一化瞳孔坐标的正方形采样网格。

    返回
    -------
    x, y : 二维数组
        归一化瞳孔坐标。
    rho : 二维数组
        径向瞳孔坐标。
    theta : 二维数组
        角度瞳孔坐标。
    mask : 二维布尔数组
        在单位圆形瞳孔内为True。
    """
    coord = np.linspace(-1.0, 1.0, n)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask

输出数组的形状都是:

(n, n)

正方形网格便于进行FFT,尽管物理瞳孔是圆形的。圆形孔径用掩膜表示。

这是数值光学中最早的小妥协之一:

计算用的数组是正方形的。
光学孔径可能不是。
掩膜告诉数组什么是物理上真实的。

这种区别对下一章非常重要。


7. 一个简单的OPD图

在实际的光学设计程序中,OPD是根据光学系统计算得到的。光线追迹穿过表面,累积光程,然后将波前与参考球面或参考波前进行比较。

为了学习,我们可以从一个合成的OPD图开始。这样我们可以在每个例子中测试瞳孔函数机制,而不需要完整的镜头模型。

我们将创建一个具有两个成分的简单波前:

  1. 类似离焦的变化。
  2. 类似像散的变化。

这并非完整的Zernike实现。它只是一种简洁的方式,生成一个可识别的OPD模式。

def synthetic_opd(rho, theta, mask, wavelength):
    """
    创建一个以米为单位的合成OPD图。

    这是一个教学用波前,而非完整的光学设计结果。
    系数是波长的几分之一。
    """
    opd = np.zeros_like(rho)

    # 以米为单位的系数
    defocus = 0.50 * wavelength
    astigmatism = 0.25 * wavelength

    # 简单的波前项
    opd += defocus * (2.0 * rho**2 - 1.0)
    opd += astigmatism * rho**2 * np.cos(2.0 * theta)

    # 在瞳孔外部,OPD在物理上没有意义。
    # 在数值上我们保留为零,但掩膜会阻挡它。
    opd[~mask] = 0.0

    return opd

重要的不是具体用了什么多项式。重要的是opd数组与瞳孔网格具有相同的形状。

每个采样的瞳孔点现在都有一个波前误差。

网格点 → OPD值 → 相位值 → 复瞳孔值

这就是我们想要明确表达的转换。


8. 构建复瞳孔函数

现在我们可以编写完成本章主要任务的函数。

def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    """
    将OPD图转换为复瞳孔函数。

    参数
    ----------
    opd : 二维数组
        光程差,以米为单位。
    mask : 二维布尔数组
        在孔径内为True。
    wavelength : 浮点数
        以米为单位的波长。
    amplitude : 二维数组或None
        场振幅透过率。如果为None,则使用清晰孔径。

    返回
    -------
    pupil : 二维复数组
        复瞳孔函数。
    phase : 二维数组
        以弧度为单位的相位。
    """
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength

    pupil = amplitude * np.exp(1j * phase)

    # 确保孔径外部的场为零。
    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase

这个函数很小,但它执行了一个非常重要的转换。

让我们运行它:

wavelength = 550e-9  # 550 nm,以米为单位

x, y, rho, theta, mask = make_pupil_grid(n=256)
opd = synthetic_opd(rho, theta, mask, wavelength)

pupil, phase = pupil_function_from_opd(
    opd=opd,
    mask=mask,
    wavelength=wavelength
)

此时:

  • opd 是以米为单位的实值数组。
  • phase 是以弧度为单位的实值数组。
  • pupil 是复值数组。

你可以检查:

print(opd.dtype)
print(phase.dtype)
print(pupil.dtype)

第三个应当是复数类型。

将这一点作为健全性检查。如果你的瞳孔函数不是复数,那么你还没有以能够发生干涉的形式存储相位。

一个非常常见的Python 3笔误看起来像这样:

print phase.dtype

这行在Python 3中无效。应该是:

print(phase.dtype)

这是一个小的提醒:在计算光学中,许多错误并不是重大的理论错误。有些仅仅是语法、形状、单位或约定上的错误。要尽早发现它们。


9. 可视化OPD、相位和振幅

在进行PSF计算之前,一定要先查看一下瞳孔数据。

一个好规则是:

永远不要对你没有检查过的瞳孔函数进行傅里叶变换。

我们可以绘制三样东西:

  1. 以波长为单位的OPD。
  2. 包裹相位。
  3. 振幅掩膜。

以波长为单位的OPD是:

W(x,y)λ \frac{W(x,y)}{\lambda}

这通常比米更容易理解。

def show_pupil_maps(opd, phase, pupil, mask, wavelength):
    opd_waves = opd / wavelength

    wrapped_phase = np.angle(pupil)

    opd_plot = np.where(mask, opd_waves, np.nan)
    phase_plot = np.where(mask, wrapped_phase, np.nan)
    amp_plot = np.abs(pupil)

    plt.figure(figsize=(5, 4))
    plt.imshow(opd_plot, origin="lower", extent=[-1, 1, -1, 1])
    plt.colorbar(label="OPD [waves]")
    plt.title("OPD map")
    plt.xlabel("Normalized pupil x")
    plt.ylabel("Normalized pupil y")
    plt.tight_layout()
    plt.show()

    plt.figure(figsize=(5, 4))
    plt.imshow(phase_plot, origin="lower", extent=[-1, 1, -1, 1])
    plt.colorbar(label="Wrapped phase [rad]")
    plt.title("Pupil phase")
    plt.xlabel("Normalized pupil x")
    plt.ylabel("Normalized pupil y")
    plt.tight_layout()
    plt.show()

    plt.figure(figsize=(5, 4))
    plt.imshow(amp_plot, origin="lower", extent=[-1, 1, -1, 1])
    plt.colorbar(label="Field amplitude")
    plt.title("Pupil amplitude")
    plt.xlabel("Normalized pupil x")
    plt.ylabel("Normalized pupil y")
    plt.tight_layout()
    plt.show()


show_pupil_maps(opd, phase, pupil, mask, wavelength)

有几点需要注意。

OPD图可能看起来平滑而连续。

相位图可能会出现突然的跳跃。这并不一定意味着波前不连续。这可能只是相位在π-\piπ\pi之间发生了包裹。

这是一个常见的混淆来源。

复指数并不关心相位写为:

0 0

还是:

2π 2\pi

因为它们在单位圆上是同一点。

所以当你用np.angle绘制相位时,通常看到的是包裹相位。包裹相位很有用,但它可能让平滑的波前看起来像是含有尖锐的跳跃。

OPD图通常更适合理解物理波前。包裹相位图更适合检查即将进入FFT的复场。


10. 相位包裹并不自动意味着错误

让我们暂停一下,因为这种错误非常普遍。

假设OPD在整个瞳孔上从0逐渐增加到一个波长。相位从:

0 0

增加到:

2π 2\pi

np.angle通常将复相位显示在如下范围:

πϕπ -\pi \leq \phi \leq \pi

因此,当相位经过π\pi时,绘图值会跳转到π-\pi

这个跳跃看起来很剧烈,但它可能只是一个显示约定。

复数本身在单位圆上是连续的:

exp(iπ)=1 \exp(i\pi) = -1

以及:

exp(iπ)=1 \exp(-i\pi) = -1

所以显示的跳跃边缘可能几乎代表相同的复数值。

这就是为什么你应该同时检查:

以波长为单位的OPD
包裹相位

OPD告诉你物理波前误差。

包裹相位告诉你复瞳孔中存储的相位。

它们回答的是相关但不同的问题。


11. 无像差的清晰圆形孔径

在引入有像差的波前之前,先构建理想情况会有所帮助。

如果瞳孔内各处OPD为零:

W(x,y)=0 W(x,y)=0

那么:

P(x,y)=A(x,y)exp(0)=A(x,y) P(x,y)=A(x,y)\exp(0)=A(x,y)

对于一个清晰的圆形孔径:

P(x,y)=M(x,y) P(x,y)=M(x,y)

因此瞳孔函数只是一个复数值为1+0i1+0i的平坦圆盘。

zero_opd = np.zeros_like(rho)

perfect_pupil, perfect_phase = pupil_function_from_opd(
    opd=zero_opd,
    mask=mask,
    wavelength=wavelength
)

print(np.max(np.abs(perfect_pupil)))
print(np.min(np.abs(perfect_pupil[mask])))

在瞳孔内部,振幅应为1。

在瞳孔外部,场应为0。

这个完美的瞳孔将在下一章中产生熟悉的衍射受限艾里斑图样。

在这一步,我们还没有计算艾里斑。我们只是在准备产生它的场。

整个过程是这样的:

平坦的圆形瞳孔
→ 傅里叶变换
→ 类似艾里斑的PSF

像差会改变瞳孔相位。它们不一定会改变瞳孔振幅。

因此,一个有像差的瞳孔可能在孔径内部振幅仍为1,但其相位不再是平坦的。

核心思想是:

一个有像差的清晰系统可以具有均匀的振幅但非均匀的相位。

12. 添加中心遮挡

现在让我们只修改振幅。

这个例子很有用,因为它分离了两种效应:

  • 孔径形状影响振幅;
  • OPD影响相位。

中心遮挡可以通过阻挡瞳孔中间部分来表示。

def annular_amplitude(rho, inner_radius=0.3):
    """
    环形瞳孔的场振幅。
    """
    return ((rho >= inner_radius) & (rho <= 1.0)).astype(float)


amplitude_annular = annular_amplitude(rho, inner_radius=0.3)

annular_pupil, annular_phase = pupil_function_from_opd(
    opd=zero_opd,
    mask=mask,
    wavelength=wavelength,
    amplitude=amplitude_annular
)

这里OPD仍为零。相位仍为平坦。但瞳孔振幅改变了。

这意味着即使没有像差,PSF也会发生变化。

这是一个重要的教训:

并非所有的PSF变化都来自波前误差。
有些PSF变化来自孔径形状和振幅透过率。

如果你为一个望远镜建模,却忘记了中心遮挡,即使你的OPD是完美的,衍射图样也会是错误的。

如果你错误地模拟了渐晕,PSF和MTF可能会因为瞳孔振幅的改变而改变。

再次强调,漂亮的输出并不能证明输入是正确的。


13. Optiland在这一步的贡献

我们的最小代码使用了一个合成的OPD图。这对学习很有用,但它并不是真实镜头分析通常开始的方式。

在一个真实的光学系统中,OPD应该来自光学处方:

表面
→ 材料
→ 波长
→ 视场
→ 瞳孔采样
→ 光线追迹
→ 参考波前
→ OPD图

这就是像Optiland这样的工具变得有用的地方。教学的模式应该是:

使用Optiland或其他光学设计库从真实系统计算OPD。
然后使用相同的瞳孔函数公式来理解接下来会发生什么。

具体的API可能随版本而变化,所以在书籍项目中,最安全的方法是在配套代码中锁定库的版本。从概念上讲,工作流程如下所示:

# 版本检查工作流程。
# 在使用具体的类和方法的名称之前,
# 检查已安装的Optiland版本和文档。

# 1. 构建或加载一个光学系统。
# optic = ...

# 2. 选择视场和波长。
# field = ...
# wavelength = 550e-9

# 3. 运行波前 / OPD分析。
# wavefront = ...
# opd = wavefront.opd_map(...)

# 4. 获取或定义瞳孔掩膜。
# mask = wavefront.pupil_mask(...)

# 5. 将OPD转换为复瞳孔函数。
# pupil, phase = pupil_function_from_opd(opd, mask, wavelength)

这并非要取代Optiland内部的PSF工具。相反,它解释了背后的桥梁。

如果Optiland给你一个OPD图,你现在知道接下来的数学步骤是什么:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x, y) = A(x, y)\exp\left(i\frac{2\pi W(x, y)}{\lambda}\right)

而如果Optiland直接给你一个PSF,现在你也知道了该PSF计算所依赖的隐藏输入之一。

这就是本书双轨方法的意义所在。

库执行完整的工程工作流程。最小代码让你看到计算的骨架。


14. 符号约定:不统一意见的静默源头

有一个细节我们应该小心处理:指数中的符号。

你可能会看到瞳孔函数被写成:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x,y)=A(x,y)\exp\left(i\frac{2\pi W(x,y)}{\lambda}\right)

或者:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x,y)=A(x,y)\exp\left(-i\frac{2\pi W(x,y)}{\lambda}\right)

这两种约定都出现在光学和信号处理领域,通常取决于傅里叶变换的约定和OPD的定义。

这很烦人,但可以处理。

规则不是“某一符号总是正确的”。规则是:

在OPD、瞳孔函数、傅里叶变换和坐标定义中使用一致的符号约定。

仅对于强度PSF,改变一个实值相位函数的符号通常会产生一个相关或镜像的结果。但当你关心相位敏感的量、非对称像差、相干成像,或与某个软件包进行比较时,约定就会很重要。

对于本书的最小代码,我们将使用:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x, y)=A(x,y)\exp\left(i\frac{2\pi W(x,y)}{\lambda}\right)

如果你的软件包使用相反的约定,不要慌张。检查其文档,然后在你的比较代码中调整符号。

一个实用的比较检查清单是:

  1. 使用相同的波长。
  2. 使用相同的瞳孔坐标方向。
  3. 使用相同的OPD参考。
  4. 使用相同的相位符号约定。
  5. 在转到PSF时使用相同的FFT约定。

当两个程序不一致时,首先怀疑的不应是“物理错了”。首先怀疑的应是单位、符号、采样和归一化。


15. 瞳孔坐标:物理的还是归一化的?

在本章中我们使用了归一化坐标:

1x1 -1 \leq x \leq 1

1y1 -1 \leq y \leq 1

圆形瞳孔定义为:

x2+y21 x^2+y^2 \leq 1

这对教学很方便。

在一个真实的光学系统中,瞳孔坐标也可以用物理单位(如毫米)表示。瞳孔半径可能是:

Rpupil=5 mm R_\text{pupil}=5\text{ mm}

那么物理瞳孔坐标(X,Y)(X,Y)可以归一化为:

x=XRpupil x = \frac{X}{R_\text{pupil}}

y=YRpupil y = \frac{Y}{R_\text{pupil}}

OPD本身并不会自动归一化。OPD仍然是一个长度。

所以你可能会遇到:

x, y: 归一化瞳孔坐标
W: 米
λ: 米
phase: 弧度

这种组合完全没问题。

坐标系描述的是你在瞳孔中的位置。OPD和波长描述的是该处存在多少相位延迟。

将这些角色分开:

瞳孔坐标:位置
OPD:光程差
波长:相位转换的尺度
相位:以弧度为单位的角度

当这种区分清晰了,代码的调试会变得容易得多。


16. 瞳孔函数不是PSF

这一点值得直截了当地说明。

瞳孔函数不是点光源的图像。

瞳孔函数存在于瞳孔平面。它描述的是孔径上的复场。

PSF存在于像平面。它描述的是衍射和像差之后的强度分布。

下一章将通过傅里叶变换将它们连接起来,但它们不是同一个对象。

一个粗略的图示是:

瞳孔平面:
孔径上的复场

像平面:
像点周围的强度分布

这很重要,因为一个相位图可能看起来很严重,而得到的PSF可能仍然很紧凑;或者一个看似很小的瞳孔遮挡就可能产生可见的衍射环。

你不能总是从一张瞳孔图直接读出最终的图像质量。瞳孔函数是输入。PSF是经过相干传播后的输出。

这就是为什么我们正在小心地构建整个链条,而不是直接跳到最终的图。


17. 一个完整的最小脚本

下面是本章整个最小实现的紧凑版本。

它创建:

  • 一个瞳孔网格;
  • 一个圆形掩膜;
  • 一个合成的OPD图;
  • 一个复瞳孔函数;
  • OPD、相位和振幅的图。
import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    coord = np.linspace(-1.0, 1.0, n)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask


def synthetic_opd(rho, theta, mask, wavelength):
    opd = np.zeros_like(rho)

    defocus = 0.50 * wavelength
    astigmatism = 0.25 * wavelength

    opd += defocus * (2.0 * rho**2 - 1.0)
    opd += astigmatism * rho**2 * np.cos(2.0 * theta)

    opd[~mask] = 0.0

    return opd


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength
    pupil = amplitude * np.exp(1j * phase)

    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase


def plot_map(data, title, colorbar_label):
    plt.figure(figsize=(5, 4))
    plt.imshow(data, origin="lower", extent=[-1, 1, -1, 1])
    plt.colorbar(label=colorbar_label)
    plt.title(title)
    plt.xlabel("Normalized pupil x")
    plt.ylabel("Normalized pupil y")
    plt.tight_layout()
    plt.show()


wavelength = 550e-9

x, y, rho, theta, mask = make_pupil_grid(n=256)
opd = synthetic_opd(rho, theta, mask, wavelength)

pupil, phase = pupil_function_from_opd(
    opd=opd,
    mask=mask,
    wavelength=wavelength
)

opd_waves = np.where(mask, opd / wavelength, np.nan)
wrapped_phase = np.where(mask, np.angle(pupil), np.nan)
amplitude = np.abs(pupil)

plot_map(opd_waves, "OPD map", "OPD [waves]")
plot_map(wrapped_phase, "Wrapped pupil phase", "Phase [rad]")
plot_map(amplitude, "Pupil amplitude", "Field amplitude")

这个脚本此时还没有计算PSF。这是故意的。

在这一点上,正确的问题不是:

图像在哪里?

正确的问题是:

我们是否正确地构建了将形成图像的复场?

一旦答案是肯定的,下一章的傅里叶变换就说得通了。


18. 常见错误

让我们在继续之前总结最常见的错误。

错误1:单位混用

这是最危险的一个。

错误:

opd = 200      # 纳米,但未注明
wavelength = 550e-9  # 米
phase = 2 * np.pi * opd / wavelength

正确:

opd = 200e-9
wavelength = 550e-9
phase = 2 * np.pi * opd / wavelength

始终确保OPD和波长使用相同的单位。

错误2:忘记孔径掩膜

如果你计算:

pupil = np.exp(1j * phase)

而没有应用掩膜,你就允许光通过整个正方形数组。

正确:

pupil = mask.astype(float) * np.exp(1j * phase)
pupil[~mask] = 0.0

FFT并不知道你的孔径应该是圆形的,除非你告诉它。

错误3:混淆振幅和强度

如果一个滤光片透过25%的强度,场振幅因子不是0.25。而是:

0.25=0.5 \sqrt{0.25}=0.5

错误:

amplitude = intensity_transmission

通常正确:

amplitude = np.sqrt(intensity_transmission)

这很重要,因为PSF强度是根据复场幅值的平方计算的。

错误4:认为包裹相位中的跳跃总是物理上的跳跃

np.angle(pupil)中的突然跳跃可能只是相位包裹。

在断定波前不连续之前,先检查以波长为单位的OPD。

错误5:未检查约定就与软件比较

如果你的PSF与某个软件包不同,请检查:

  • 波长;
  • 瞳孔采样;
  • OPD参考;
  • 相位符号;
  • FFT移位;
  • 归一化;
  • 坐标方向。

在做改变物理模型的决定之前,先做这些检查。

错误6:将瞳孔函数当作最终结果

瞳孔函数是一个中间表示。它极其重要,但它不是PSF,也不是MTF。

它的作用是将振幅和相位信息带入衍射计算中。


19. 本章为完整链条添加了什么

我们现在可以更新本书的主要计算链条了:

镜头处方
→ 表面和材料
→ 光线追迹
→ 光程
→ OPD图
→ 瞳孔振幅
→ 瞳孔相位
→ 复瞳孔函数

本章执行了一次精确的转换:

以长度单位表示的OPD
→ 以弧度表示的相位
→ 孔径上的复场

这个转换小到几行代码就能容纳。但从概念上讲,它是本书中最重要的步骤之一。

没有它,OPD仅仅是一个波前误差图。

有了它,波前变成了能够干涉、衍射并形成图像的东西。

这就是为什么瞳孔函数是进入PSF计算的大门。

在下一章中,我们将把这个复瞳孔函数送入傅里叶变换。结果将不再存在于瞳孔平面。它将成为一个点图像的强度分布:PSF。

而从那个PSF开始,我们最终将接近开启这段旅程的MTF曲线。


章节总结

瞳孔函数存储了孔径上的复场。

它结合了:

振幅
相位
孔径掩膜

核心公式是:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x, y) = A(x, y)\exp\left(i\frac{2\pi W(x, y)}{\lambda}\right)

其中OPD W(x,y)W(x,y)必须与波长λ\lambda使用相同的长度单位。

孔径掩膜告诉代码光在哪里。振幅告诉我们有多少场穿透。相位告诉我们OPD如何改变复场。

一个完美的清晰圆形瞳孔具有平坦的相位和均匀的振幅。一个有像差的瞳孔可能具有均匀的振幅但非均匀的相位。一个被遮挡或变迹的瞳孔即使OPD为零,也可能具有非均匀的振幅。

本章中的主要代码动作是:

phase = 2.0 * np.pi * opd / wavelength
pupil = amplitude * np.exp(1j * phase)
pupil[~mask] = 0.0

这就是从OPD到傅里叶光学的桥梁。

接下来,我们将使用这个瞳孔函数来计算PSF。

生成验证图

由第11章瞳孔函数模型生成的瞳孔振幅和相位

第 12 章:如何从光瞳函数计算 PSF

点源应该是最简单的物体。

它没有大小。它没有纹理。它没有边缘,没有图案,没有细节。在几何光学中,一个完美透镜会把来自该点的所有光线汇聚到像平面中的一个点。

但真实的光学系统不会形成数学上的点。

即使是完美的圆形孔径也会把一个点扩散成一个小衍射斑。像差会使它进一步扩散、变形、将能量移出中心,或产生不对称的裙边。

那个扩散后的点的像就是点扩散函数,或称PSF

前一章构建了衍射计算所需的物体:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x, y) = A(x, y)\exp\left(i\frac{2\pi W(x, y)}{\lambda}\right)

这是复光瞳函数。它存储孔径振幅和波前相位。

现在我们让那个光瞳函数形成一个像。

简化的计算链是:

复光瞳函数
→ 傅里叶变换
→ 复像平面场
→ 取幅度平方
→ PSF

用代码表示,骨架很短:

field = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(pupil)))
psf = np.abs(field) ** 2
psf = psf / np.sum(psf)

这三行就是本章的核心。

但它们不是魔法。我们需要拆解它们的含义,它们使用的假设,以及可能出错的地方。


1. PSF 代表什么

PSF 是点状物体在像平面产生的强度分布。

如果光学系统是完美的且没有衍射,PSF 将是一个无限小的点。但物理光有波长,孔径有有限尺寸。有限孔径不能将光聚焦成一个无限小的点。

对于一个圆形、无像差的光瞳,理想的衍射斑就是我们熟悉的艾里斑

  • 一个明亮的中央核;
  • 环绕的圆环;
  • 强度从中心向外递减。

这就是衍射受限的 PSF。

当存在像差时,光瞳相位不再平坦。该畸变复光瞳的傅里叶变换在像平面重新分配能量。PSF 的形状改变。

所以 PSF 不仅仅是一张漂亮的图片。它回答了一个具体的成像问题:

如果物体包含一个理想的点,系统在像中画出怎样的强度图样?

一旦知道了这一点,我们就非常接近理解模糊、分辨率和 MTF。


2. 从光瞳平面到像平面

光瞳函数存在于光瞳平面。它告诉我们孔径上各点的复场。

PSF 存在于像平面。它告诉我们聚焦点像周围的强度。

在通常的标量夫琅禾费衍射近似下,焦区复场正比于光瞳函数的傅里叶变换:

U(ξ,η)F{P(x,y)} U(\xi, \eta) \propto \mathcal{F}{P(x, y)}

PSF 是那个复场的幅度平方:

PSF(ξ,η)=U(ξ,η)2 \mathrm{PSF}(\xi, \eta) = |U(\xi, \eta)|^2

将两者结合:

PSF(ξ,η)F{P(x,y)}2 \mathrm{PSF}(\xi, \eta) \propto \left|\mathcal{F}{P(x, y)}\right|^2

其中:

  • P(x,y)P(x,y) 是复光瞳函数;
  • U(ξ,η)U(\xi,\eta) 是复像平面场;
  • ξ,η\xi,\eta 是像平面或角度坐标;
  • F\mathcal{F} 表示傅里叶变换。

比例符号很重要。真实的物理 PSF 需要正确的坐标缩放和能量归一化。为了学习计算链,我们可以先计算一个归一化的 PSF:

ξ,ηPSF(ξ,η)=1 \sum_{\xi,\eta}\mathrm{PSF}(\xi,\eta)=1

这意味着把 PSF 视为一个能量分布。总能量为 1,我们研究这些能量去了哪里。

这种归一化简捷而有用:

psf = psf / np.sum(psf)

它不能解决所有的物理缩放问题,但能让不同光瞳函数之间的对比更加清晰。


3. 为什么傅里叶变换会出现

傅里叶变换之所以出现,是因为像平面场是通过光瞳上所有点的相干叠加构建的。

光瞳上的每一点贡献一个小波。在远场或焦平面近似下,这些贡献之间的相位关系随像平面位置变化。傅里叶变换就是为每个输出位置将所有这些复贡献相加的数学操作。

这是光线图所隐藏的部分。

光线图说:

光瞳的这部分将光射向这个方向。

衍射计算说:

所有光瞳点都贡献复振幅,这些振幅发生干涉。

PSF 是由干涉产生的。

这就是为什么光瞳函数必须是复的。如果我们仅保留 OPD 作为一个实值映射,我们会知道波前误差,但还没有相干叠加所需的对象。

现在的计算链看起来是这样的:

OPD 映射
→ 相位映射
→ 复光瞳函数
→ 相干傅里叶叠加
→ 像平面强度

这就是波前误差变成可见模糊的时刻。


4. 一个完美圆形光瞳

从最简单的情况开始:一个清晰、无像差的圆形孔径。

在光瞳内部:

A(x,y)=1 A(x,y)=1

W(x,y)=0 W(x,y)=0

所以:

P(x,y)=1 P(x,y)=1

在光瞳外部:

P(x,y)=0 P(x,y)=0

这个平坦的圆形光瞳产生衍射受限的艾里斑。

让我们复用前一章的光瞳网格概念。

import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    coord = np.linspace(-1.0, 1.0, n)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength
    pupil = amplitude * np.exp(1j * phase)

    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase

现在创建一个完美光瞳:

wavelength = 550e-9  # meters

x, y, rho, theta, mask = make_pupil_grid(n=256)

zero_opd = np.zeros_like(rho)

perfect_pupil, perfect_phase = pupil_function_from_opd(
    opd=zero_opd,
    mask=mask,
    wavelength=wavelength
)

光瞳是复的,但在这种完美情况下,其相位在孔径内为零。所以复值简单地为:

1+0i 1 + 0i

在圆形内部。

现在我们准备好计算 PSF。


5. 用 FFT 计算 PSF

离散傅里叶变换通过 numpy.fft.fft2 计算。

因为我们通常希望中央峰在显示图像的中心,我们使用 fftshift

一个谨慎的版本在变换前使用 ifftshift,变换后使用 fftshift

def compute_psf(pupil, normalize=True):
    """
    Compute a normalized PSF from a complex pupil function.

    Parameters
    ----------
    pupil : 2D complex array
        Complex pupil function.
    normalize : bool
        If True, normalize total PSF energy to 1.

    Returns
    -------
    psf : 2D array
        Intensity PSF.
    field : 2D complex array
        Complex field in the image plane.
    """
    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(pupil)
        )
    )

    psf = np.abs(field) ** 2

    if normalize:
        total = np.sum(psf)
        if total > 0:
            psf = psf / total

    return psf, field

运行它:

perfect_psf, perfect_field = compute_psf(perfect_pupil)

这个 perfect_psf 数组就是我们采样的圆形光瞳的衍射受限 PSF。

它还没有以微米或角秒为单位标记。这是一个采样的傅里叶平面结果。我们稍后在本章回到坐标缩放。

首先,看一下图样。


6. 显示 PSF

PSF 通常有一个非常亮的中央峰和弱得多的周围结构。如果你用线性度显示它,中央峰会强烈地主导,以致于圆环很难看到。

所以我们经常同时显示:

  1. 线性强度;
  2. 对数强度。
def show_psf(psf, title="PSF"):
    plt.figure(figsize=(5, 4))
    plt.imshow(psf, origin="lower")
    plt.colorbar(label="Normalized intensity")
    plt.title(title + " - linear scale")
    plt.xlabel("Image sample x")
    plt.ylabel("Image sample y")
    plt.tight_layout()
    plt.show()

    plt.figure(figsize=(5, 4))
    plt.imshow(np.log10(psf + 1e-12), origin="lower")
    plt.colorbar(label="log10 intensity")
    plt.title(title + " - log scale")
    plt.xlabel("Image sample x")
    plt.ylabel("Image sample y")
    plt.tight_layout()
    plt.show()


show_psf(perfect_psf, title="Diffraction-limited circular pupil")

对数图并不是另一个 PSF。它是同一个 PSF 用了一种显示变换来展示。

这个区别很重要。对数尺度的 PSF 会让弱的光环看起来视觉上很重要。线性尺度的 PSF 会隐藏仍然影响 MTF 的结构。好的分析往往需要这两种视角。

一个合理的习惯是:

用线性尺度来判断能量集中度。
用对数尺度来检视微弱结构。

中央峰通常对清晰度最重要,但周围微弱的能量告诉你对比度去了哪里。


7. 零填充:让显示的 PSF 更平滑

FFT 输出的采样取决于输入数组的大小。如果光瞳数组很小,PSF 可能看起来像块状或欠采样。

一种常见技术是在 FFT 之前对光瞳进行零填充

零填充并不添加新的光学信息。它更精细地插值傅里叶域的显示。

这是一个带填充的 PSF 函数:

def pad_array_centered(array, pad_factor=2):
    """
    Zero-pad a 2D array around its center.

    Parameters
    ----------
    array : 2D array
        Input array.
    pad_factor : int
        Output size will be pad_factor times the input size.

    Returns
    -------
    padded : 2D array
        Centered zero-padded array.
    """
    if pad_factor == 1:
        return array

    n, m = array.shape
    new_n = n * pad_factor
    new_m = m * pad_factor

    padded = np.zeros((new_n, new_m), dtype=array.dtype)

    start_n = (new_n - n) // 2
    start_m = (new_m - m) // 2

    padded[start_n:start_n + n, start_m:start_m + m] = array

    return padded


def compute_psf(pupil, pad_factor=2, normalize=True):
    """
    Compute a PSF from a complex pupil function, with optional zero padding.
    """
    padded_pupil = pad_array_centered(pupil, pad_factor=pad_factor)

    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(padded_pupil)
        )
    )

    psf = np.abs(field) ** 2

    if normalize:
        total = np.sum(psf)
        if total > 0:
            psf = psf / total

    return psf, field

现在计算一个更平滑的显示:

perfect_psf, perfect_field = compute_psf(
    perfect_pupil,
    pad_factor=4
)

show_psf(perfect_psf, title="Diffraction-limited PSF with zero padding")

再次强调,零填充不是神奇的“分辨率”。它给了我们在相同物理特征之间更多的输出采样。

一个有用的说法是:

零填充让绘制的 PSF 更平滑。
它并不让光学系统更清晰。

这句话能避免很多错误解读。


8. 解读完美 PSF

完美圆形光瞳的 PSF 有三个主要特征。

第一,中央峰紧凑且对称。这就是艾里核。

第二,圆环围绕中央峰。这些圆环不是像差。它们是由有限圆形孔径的衍射产生的。

第三,由于光瞳是径向对称且相位平坦,整个图样是径向对称的。

这是一个重要的基准。

如果光学系统有一个圆形清晰孔径且无像差,PSF 并不是一个点。它已经被衍射扩散了。

所以当有人说“衍射受限”时,他们并不是指“无限清晰”。他们指的是系统主要受限于衍射而非像差。

在实践中:

完美波前 + 有限孔径
→ 衍射受限 PSF

畸变波前 + 有限孔径
→ 像差 PSF

PSF 只有相对于这个基准才能被正确判断。正确的比较对象不是数学上的点,而是在相同孔径和波长下的衍射受限 PSF。


9. 添加像差

现在让我们复用一个合成的 OPD 映射。我们将制作一个类似离焦和像散的波前,就像前一章那样。

def synthetic_opd(rho, theta, mask, wavelength):
    """
    Create a synthetic OPD map in meters.

    This is a teaching wavefront, not a full optical design result.
    """
    opd = np.zeros_like(rho)

    defocus = 0.50 * wavelength
    astigmatism = 0.25 * wavelength

    opd += defocus * (2.0 * rho**2 - 1.0)
    opd += astigmatism * rho**2 * np.cos(2.0 * theta)

    opd[~mask] = 0.0

    return opd

构建像差光瞳:

aberrated_opd = synthetic_opd(
    rho=rho,
    theta=theta,
    mask=mask,
    wavelength=wavelength
)

aberrated_pupil, aberrated_phase = pupil_function_from_opd(
    opd=aberrated_opd,
    mask=mask,
    wavelength=wavelength
)

aberrated_psf, aberrated_field = compute_psf(
    aberrated_pupil,
    pad_factor=4
)

现在显示它:

show_psf(aberrated_psf, title="Aberrated PSF")

与完美 PSF 相比,像差 PSF 应该显示出能量从中心重新分配。根据像差的不同,中央峰可能变宽、变得不对称,或发展出侧翼结构。

这是本章核心的视觉课程:

像差是光瞳中的相位误差。
PSF 显示了该相位误差把像的能量送到了哪里。

波前图可能看起来很抽象。PSF 则展示了其后果。


10. 对比完美和像差 PSF

并列显示很有用。

def show_two_psfs(psf_a, psf_b, title_a, title_b, log_scale=False):
    if log_scale:
        image_a = np.log10(psf_a + 1e-12)
        image_b = np.log10(psf_b + 1e-12)
        label = "log10 intensity"
    else:
        image_a = psf_a
        image_b = psf_b
        label = "Normalized intensity"

    vmin = min(np.nanmin(image_a), np.nanmin(image_b))
    vmax = max(np.nanmax(image_a), np.nanmax(image_b))

    plt.figure(figsize=(10, 4))

    plt.subplot(1, 2, 1)
    plt.imshow(image_a, origin="lower", vmin=vmin, vmax=vmax)
    plt.colorbar(label=label)
    plt.title(title_a)
    plt.xlabel("Image sample x")
    plt.ylabel("Image sample y")

    plt.subplot(1, 2, 2)
    plt.imshow(image_b, origin="lower", vmin=vmin, vmax=vmax)
    plt.colorbar(label=label)
    plt.title(title_b)
    plt.xlabel("Image sample x")
    plt.ylabel("Image sample y")

    plt.tight_layout()
    plt.show()


show_two_psfs(
    perfect_psf,
    aberrated_psf,
    "Perfect PSF",
    "Aberrated PSF",
    log_scale=False
)

show_two_psfs(
    perfect_psf,
    aberrated_psf,
    "Perfect PSF",
    "Aberrated PSF",
    log_scale=True
)

线性对比告诉我们有多少能量仍集中在中心附近。

对数对比显示微弱的结构。

两者都很重要。如果中央能量下降,精细细节的对比度通常会受损。如果微弱能量扩散到远处,图像会获得光晕或低水平模糊。

这就是为什么 PSF 比单个数字更有信息。它显示了模糊的空间形状。

我们将在下一章计算的 MTF,将这种空间信息压缩成频率响应。那很强大,但也隐藏了一些可见的结构。PSF 让我们直接看到模糊。


11. 包围能量:一个简单的 PSF 摘要

在转到 MTF 之前,引入一个简单的基于 PSF 的摘要:包围能量。

包围能量问的是:

PSF 总能量中有多少位于给定半径内?

这与 MTF 不同,但常常有用。更锐利的 PSF 将能量集中到更小的半径内。

这是一个以像素为单位简单实现:

def encircled_energy(psf):
    """
    Compute encircled energy as a function of radius in pixel units.

    Parameters
    ----------
    psf : 2D array
        Normalized PSF.

    Returns
    -------
    radii : 1D array
        Radius values in pixels.
    energy : 1D array
        Encircled energy for each radius.
    """
    n, m = psf.shape
    cy = (n - 1) / 2.0
    cx = (m - 1) / 2.0

    yy, xx = np.indices(psf.shape)
    r = np.sqrt((xx - cx)**2 + (yy - cy)**2)

    r_flat = r.ravel()
    psf_flat = psf.ravel()

    order = np.argsort(r_flat)
    r_sorted = r_flat[order]
    psf_sorted = psf_flat[order]

    cumulative = np.cumsum(psf_sorted)

    return r_sorted, cumulative

绘制完美对像差:

r_perfect, e_perfect = encircled_energy(perfect_psf)
r_aberr, e_aberr = encircled_energy(aberrated_psf)

plt.figure(figsize=(6, 4))
plt.plot(r_perfect, e_perfect, label="Perfect")
plt.plot(r_aberr, e_aberr, label="Aberrated")
plt.xlabel("Radius [pixels]")
plt.ylabel("Encircled energy")
plt.title("Encircled energy comparison")
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.show()

如果像差 PSF 把能量向外扩散,它的包围能量曲线上升得更慢。

这给我们一个简单的说法:

像差系统需要一个更大的半径来包含相同比例的点源能量。

再次强调,这不是 MTF 的替代品。这是观察成像形成的另一种方式。

PSF 是空间的。包围能量总结空间集中度。MTF 将描述空间频率响应。

它们是关联的,但并非完全相同。


12. 斯特利尔比:中央峰作为警示灯

另一个常见的基于 PSF 的量是斯特利尔比

在简单的计算术语中,斯特利尔比比较了像差 PSF 的峰值强度与衍射受限 PSF 的峰值强度,在匹配的归一化和采样下:

S=max(PSFaberrated)max(PSFdiffraction-limited) S = \frac{\max(\mathrm{PSF}\text{aberrated})} {\max(\mathrm{PSF}\text{diffraction-limited})}

代码:

def strehl_ratio(aberrated_psf, perfect_psf):
    return np.max(aberrated_psf) / np.max(perfect_psf)


strehl = strehl_ratio(aberrated_psf, perfect_psf)
print(strehl)

较低的斯特利尔比意味着中央峰相对于理想衍射受限情况损失了能量。

这是一个有用的警示灯,但它不是一个完整的像质描述。

两个 PSF 可以有相似的峰值但形状不同。一个可能有对称展宽,另一个可能有彗星状拖尾。它们的视觉效果可能不同。

所以规则是:

斯特利尔比总结中央峰。
PSF 图像显示整个模糊结构。

PSF 仍然值得观察。


13. 为什么点列图和 PSF 不同

在这一点上,值得将 PSF 与前面章节的点列图进行比较。

点列图来自追迹光线。它显示光线在哪与像平面相交。

PSF 来自衍射计算。它使用复光瞳函数,包括相位,并计算干涉。

对于严重像差的系统,它们可能讲述相似的故事,但它们不是相同的计算。

点列图回答:

采样光线落在了哪里?

PSF 回答:

一个点源在波干涉后产生怎样的强度分布?

对于较大的像差,几何点列图可以给出一个有意义的模糊第一印象。但在接近衍射极限时,点列图可能产生误导。它可能显示一个紧密的光线束,却未能显示衍射环。它也不能直接告诉我们能量在干涉作用下是如何分布的。

这就是光学软件提供多种图的原因:

  • 点列图;
  • 光线扇形图;
  • OPD;
  • PSF;
  • MTF。

它们不是冗余的。它们是同一光学系统的不同投影。

完整的计算路径很重要:

光线追迹
→ OPD
→ 光瞳函数
→ PSF

点列图从光线交点分支出来。PSF 从波前相位分支出来。

这一区别是本书第四部分的核心。


14. 艾里斑与衍射极限

对于一个完美圆形孔径,解析的 PSF 就是艾里斑。其强度轮廓通常用贝塞尔函数表示。我们这里不需要完整的解析推导,但我们应该知道实际意义。

第一暗环的角半径近似为:

θ1.22λD \theta \approx 1.22\frac{\lambda}{D}

其中:

  • θ\theta 是角半径,单位为弧度;
  • λ\lambda 是波长;
  • DD 是孔径直径。

在焦平面,对应的半径近似为:

r1.22λN r \approx 1.22\lambda N

其中 NN 是 f 数。

这告诉我们一些物理上重要的事情:

更长的波长产生更大的衍射斑。
更小的孔径直径产生更大的衍射斑。
更大的 f 数产生更大的衍射斑。

这就是为什么缩小光圈可以改善像差但会使衍射模糊更糟。PSF 是由像差和孔径尺寸共同塑造的。

我们的 FFT 计算已经根据光瞳函数再现了艾里斑的结构。为了给输出采样分配物理单位,我们需要孔径大小、焦距、波长以及采样关系。这种完整的缩放在工程应用中很重要,但核心的生成机制已经可见:

清晰圆形光瞳
→ 傅里叶变换
→ 类艾里 PSF

这是我们先需要的计算事实。


15. 坐标缩放:FFT 输出的含义

FFT 返回频率域的采样。如果光瞳坐标被归一化,输出坐标也是归一化的倒易坐标。这对于比较形状没问题,但物理解释需要缩放。

对于一个在光瞳坐标 X,YX,Y 上采样的物理孔径场,焦平面坐标与衍射角有关。大略地:

θxλfx \theta_x \sim \lambda f_x

θyλfy \theta_y \sim \lambda f_y

其中 fx,fyf_x,f_y 是与光瞳平面采样相关的空间频率坐标。

在焦距为 FF 的透镜焦平面上:

XimageFθx X_\text{image} \sim F\theta_x

YimageFθy Y_\text{image} \sim F\theta_y

确切的实现取决于你如何采样物理光瞳以及如何定义傅里叶变换归一化。

对于本章,最安全的实用划分是:

首先计算出正确的归一化 PSF 形状。
然后使用已知的孔径采样、波长和焦距附加物理坐标。

试图同时做两件事常常让初学者困惑。

这里是一个使用物理光瞳直径的简单坐标辅助函数:

def psf_angular_coordinates(n_output, pupil_sample_spacing, wavelength):
    """
    Estimate angular coordinates for FFT-based Fraunhofer PSF.

    Parameters
    ----------
    n_output : int
        Number of samples in the padded pupil / PSF array.
    pupil_sample_spacing : float
        Physical pupil-plane sample spacing, in meters.
    wavelength : float
        Wavelength, in meters.

    Returns
    -------
    theta : 1D array
        Angular coordinate samples, in radians.
    """
    freq = np.fft.fftshift(
        np.fft.fftfreq(n_output, d=pupil_sample_spacing)
    )

    theta = wavelength * freq

    return theta

如果你的原始光瞳直径是 DD,而未填充的光瞳网格在整个正方形坐标范围内有 nn 个采样,大致的采样间距是:

pupil_sample_spacing = D / n

然后:

D = 5e-3  # 5 mm aperture diameter
n_input = perfect_pupil.shape[0]
pad_factor = 4
n_output = n_input * pad_factor

dx_pupil = D / n_input
theta = psf_angular_coordinates(
    n_output=n_output,
    pupil_sample_spacing=dx_pupil,
    wavelength=wavelength
)

这给出了以弧度为单位的角采样。如果焦距已知,转换为焦平面长度:

focal_length = 50e-3  # 50 mm
image_coord = focal_length * theta

这种坐标缩放很有用,但也需要谨慎。随着你的模型越接近实际,你必须更仔细地定义:

  • 光瞳直径;
  • 入瞳 vs 出瞳;
  • 焦距;
  • 像空间折射率;
  • 采样约定;
  • FFT 归一化;
  • 场点;
  • 波长。

目前,记住主要的警告:

PSF 数组很容易计算。
物理坐标标签需要仔细的簿记。

第 14 章会更积极地回到采样和归一化。


16. 一个完整的最小 PSF 脚本

这是一个构建完美和像差 PSF 的紧凑脚本。

import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    coord = np.linspace(-1.0, 1.0, n)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask


def synthetic_opd(rho, theta, mask, wavelength):
    opd = np.zeros_like(rho)

    defocus = 0.50 * wavelength
    astigmatism = 0.25 * wavelength

    opd += defocus * (2.0 * rho**2 - 1.0)
    opd += astigmatism * rho**2 * np.cos(2.0 * theta)

    opd[~mask] = 0.0

    return opd


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength
    pupil = amplitude * np.exp(1j * phase)

    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase


def pad_array_centered(array, pad_factor=2):
    if pad_factor == 1:
        return array

    n, m = array.shape
    new_n = n * pad_factor
    new_m = m * pad_factor

    padded = np.zeros((new_n, new_m), dtype=array.dtype)

    start_n = (new_n - n) // 2
    start_m = (new_m - m) // 2

    padded[start_n:start_n + n, start_m:start_m + m] = array

    return padded


def compute_psf(pupil, pad_factor=4, normalize=True):
    padded_pupil = pad_array_centered(pupil, pad_factor=pad_factor)

    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(padded_pupil)
        )
    )

    psf = np.abs(field) ** 2

    if normalize:
        total = np.sum(psf)
        if total > 0:
            psf = psf / total

    return psf, field


def show_two_psfs(psf_a, psf_b, title_a, title_b, log_scale=False):
    if log_scale:
        image_a = np.log10(psf_a + 1e-12)
        image_b = np.log10(psf_b + 1e-12)
        label = "log10 intensity"
    else:
        image_a = psf_a
        image_b = psf_b
        label = "Normalized intensity"

    vmin = min(np.nanmin(image_a), np.nanmin(image_b))
    vmax = max(np.nanmax(image_a), np.nanmax(image_b))

    plt.figure(figsize=(10, 4))

    plt.subplot(1, 2, 1)
    plt.imshow(image_a, origin="lower", vmin=vmin, vmax=vmax)
    plt.colorbar(label=label)
    plt.title(title_a)
    plt.xlabel("Image sample x")
    plt.ylabel("Image sample y")

    plt.subplot(1, 2, 2)
    plt.imshow(image_b, origin="lower", vmin=vmin, vmax=vmax)
    plt.colorbar(label=label)
    plt.title(title_b)
    plt.xlabel("Image sample x")
    plt.ylabel("Image sample y")

    plt.tight_layout()
    plt.show()


wavelength = 550e-9

x, y, rho, theta, mask = make_pupil_grid(n=256)

zero_opd = np.zeros_like(rho)
perfect_pupil, _ = pupil_function_from_opd(
    opd=zero_opd,
    mask=mask,
    wavelength=wavelength
)

aberrated_opd = synthetic_opd(
    rho=rho,
    theta=theta,
    mask=mask,
    wavelength=wavelength
)

aberrated_pupil, _ = pupil_function_from_opd(
    opd=aberrated_opd,
    mask=mask,
    wavelength=wavelength
)

perfect_psf, _ = compute_psf(perfect_pupil, pad_factor=4)
aberrated_psf, _ = compute_psf(aberrated_pupil, pad_factor=4)

show_two_psfs(
    perfect_psf,
    aberrated_psf,
    "Perfect PSF",
    "Aberrated PSF",
    log_scale=False
)

show_two_psfs(
    perfect_psf,
    aberrated_psf,
    "Perfect PSF",
    "Aberrated PSF",
    log_scale=True
)

strehl = np.max(aberrated_psf) / np.max(perfect_psf)
print("Approximate Strehl ratio:", strehl)

这个脚本仍然是一个教学模型。它不能替代完整的光学设计软件包。但它清晰地暴露了计算骨架:

制作光瞳
→ 从 OPD 添加相位
→ FFT
→ 幅度平方
→ 归一化
→ 对比

这正是我们想要打开的那个隐藏步骤。


17. 将此与 Optiland 连接

在一个真正的 Optiland 工作流中,你通常不会手动根据合成多项式创建 OPD 映射。你会定义或加载一个光学系统,选择一个场点和波长,然后请求分析工具提供波前或 PSF 结果。

概念上,流程是:

Optiland 光学系统
→ 光线追迹和波前分析
→ 光瞳函数或等效内部表示
→ PSF 分析

一个验证式的对比工作流看起来像这样:

# 版本检查工作流大纲。
# 检查你安装的 Optiland 版本以获取准确的 API 名称。

# 1. 构建或加载一个光学系统。
# optic = ...

# 2. 选择场点和波长。
# field = ...
# wavelength = ...

# 3. 使用 Optiland 分析工具计算波前 / OPD。
# wavefront = ...
# opd = wavefront.opd_map(...)

# 4. 从该 OPD 构建一个光瞳函数。
# pupil, phase = pupil_function_from_opd(opd, mask, wavelength)

# 5. 使用 NumPy 计算一个教学 PSF。
# psf_numpy, field_numpy = compute_psf(pupil, pad_factor=4)

# 6. 使用 Optiland 自身的 PSF 分析计算 PSF。
# psf_optiland = ...

# 7. 对比形状、归一化、采样和坐标缩放。

二十行 NumPy 代码不能取代一个成熟的光学设计工具。但它们让隐藏的计算变得可检视。

当 Optiland 给出一个 PSF 时,你现在应该能够问:

  • 采样的是哪个光瞳?
  • 使用了哪个波长?
  • 使用了哪个场点?
  • 包含了什么 OPD 或相位?
  • 应用了什么孔径掩模?
  • 使用了什么 FFT 采样和归一化?
  • 图是线性还是对数的?
  • 坐标是像平面、角度还是归一化的?

这些问题比记忆一个按钮顺序更有价值。

工具可以产生 PSF。理解意味着知道为了让那个 PSF 存在,必须发生什么。


18. PSF 计算中的常见错误

错误 1:直接对 OPD 进行傅里叶变换

OPD 不是光瞳函数。

错误:

psf = np.abs(np.fft.fft2(opd)) ** 2

正确:

phase = 2.0 * np.pi * opd / wavelength
pupil = amplitude * np.exp(1j * phase)
psf = np.abs(np.fft.fft2(pupil)) ** 2

FFT 作用于复场,而不是作为高度图的 OPD。

错误 2:忘记取幅度平方

傅里叶变换给出一个复场。PSF 是强度。

错误:

psf = np.fft.fft2(pupil)

正确:

field = np.fft.fft2(pupil)
psf = np.abs(field) ** 2

强度是幅度平方。

错误 3:比较未归一化的 PSF

如果两个 PSF 的总和不同,峰值对比可能会产生误导。

更好的是:

psf = psf / np.sum(psf)

然后你可以更公平地比较能量分布。

错误 4:相信零填充能提高分辨率

零填充让 PSF 显示更平滑。它并不提高物理光学分辨率。

错误 5:忽略显示尺度

线性图和对数图会让同一个 PSF 感觉非常不同。

在解读圆环、光晕或微弱拖尾之前,始终检查正在使用哪个尺度。

错误 6:忘记 PSF 取决于波长和场点

一个 PSF 不是一个透镜的通用属性。

它取决于:

  • 波长;
  • 场点;
  • 聚焦位置;
  • 孔径;
  • 光瞳采样;
  • 如果包括的话,偏振假设;
  • 像差状态。

一个单独的 PSF 图只是更大的光学行为中的一个切片。


19. 本章为完整链添加了什么

我们现在可以扩展计算链:

透镜处方
→ 表面和材料
→ 光线追迹
→ 光程长度
→ OPD 映射
→ 光瞳振幅和相位
→ 复光瞳函数
→ 傅里叶变换
→ PSF

新的步骤是:

PSF=F{P(x,y)}2 \mathrm{PSF} = \left|\mathcal{F}{P(x,y)}\right|^2

为实际比较应用了归一化。

这是一个重要的里程碑。

我们已经从几何光线走向了一个点的波动光学图像。我们也看到了为什么在一个真实的光学系统中,一个点永远不会保持一个点。

一个清晰的圆形光瞳产生衍射受限的类艾里图样。一个有像差的光瞳改变了孔径上的相位,并重新分配了 PSF 中的能量。一个有遮挡或变迹的光瞳改变了振幅,也改变了 PSF。

PSF 是孔径、衍射和像差在像空间交汇的地方。

下一章将进一步迈出一步。与其问一个点是如何模糊的,我们将问不同的空间频率是如何传输的。

这将我们从 PSF 带到 OTF,然后到 MTF。

在第 1 章看起来像软件输出的 MTF 曲线现在几乎伸手可及了。


本章摘要

PSF 是一个理想点源在像平面产生的强度分布。

在通常的标量夫琅禾费衍射近似下,复像平面场正比于复光瞳函数的傅里叶变换:

U(ξ,η)F{P(x,y)} U(\xi,\eta) \propto \mathcal{F}{P(x,y)}

PSF 是该场的幅度平方:

PSF(ξ,η)=U(ξ,η)2 \mathrm{PSF}(\xi,\eta) = |U(\xi,\eta)|^2

用最简 NumPy 代码表示:

field = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(pupil)))
psf = np.abs(field) ** 2
psf = psf / np.sum(psf)

一个完美的清晰圆形光瞳产生衍射受限的类艾里 PSF。一个有像差的光瞳将能量从理想的中央峰重新分配出去。一个改变了孔径振幅也会改变 PSF,即使波前相位是平坦的。

PSF 应被仔细检视,通常同时在线性和对数显示下。零填充可以让显示的 PSF 更平滑,但它并不提高实际的光学分辨率。

最重要的是,FFT 必须应用于复光瞳函数,而不是直接应用于 OPD 映射。

现在的计算链达到:

OPD
→ 光瞳函数
→ PSF

接下来,我们将问这个 PSF 如何对不同空间频率的图像细节作用。那个问题将引向 OTF 和 MTF。

生成的验证图

使用第 12 章傅里叶模型生成的完美与像差 PSF 对比

来源与验证说明

本章中的 PSF 计算使用适合教授光瞳到像计算的标量傅里叶光学模型。它有意省略了矢量衍射、偏振效应、部分相干、探测器采样以及详细的物理单位。这些省略是本章的边界,而不是声称这些效应不重要。

第13章:OTF与MTF:将PSF转换为频率响应

我们以一条曲线开启了这本书。

MTF曲线出现在光学设计软件中,仿佛是一个最终结论:这里锐利,那里薄弱,某个视场较好,另一个视场较差。它看起来清晰、有权威。只需读出够多的信息,就足以带来风险。

但MTF曲线并非天生就是曲线。

它是一条计算链条的终点。

现在,这条链条已不再隐藏:

镜头处方
→ 光线追迹
→ 光程长度
→ OPD
→ 光瞳函数
→ PSF
→ OTF
→ MTF

在第12章中,我们从复光瞳函数计算了PSF。PSF告诉我们一个理想点如何在像面上扩散。

现在我们问一个不同的问题:

如果物体包含不同空间频率的图案,
光学系统对每种频率的传递有多强?

这个问题引出了光学传递函数,即OTF

MTF,即调制传递函数,是OTF的幅值。

最简洁的形式是:

OTF=F{PSF} \mathrm{OTF} = \mathcal{F}{\mathrm{PSF}}

MTF=OTF \mathrm{MTF} = |\mathrm{OTF}|

这两行很简单。它们的含义却不小。

本章中,PSF将不再仅仅是一个点的像,而成为频率响应。


1. MTF因何存在

PSF回答:

系统对一个点做了什么?

MTF回答:

系统对不同细节尺寸的对比度做了什么?

物体可被看作包含许多空间频率。

大而缓慢的变化属于低空间频率。精细细节属于高空间频率。

例如:

  • 一大块灰色区域是低频;
  • 粗的黑白条纹图形是较低频率;
  • 非常细的条纹图形是较高频率;
  • 微小纹理、发丝、织物编织或精细印刷文字含有高频信息。

光学系统对这些频率的传递是不均等的。

低频对比度可能很好地保留。高频对比度可能被削弱。在某一点上,精细细节可能不再以有效对比度传递。

这正是MTF曲线所展示的。

典型的MTF曲线在零频处始于近1,并随频率升高而下降。

这并不意味着每个系统在所有方向上都平滑下降。真实系统可能存在结构、方向差异、视场依赖、波长依赖以及采样问题。但第一个思维模型是:

MTF告诉我们对比度传递如何随空间频率变化。

这个句子有用,但不完整。本章的后续部分将使其可计算。


2. 将PSF视为冲激响应

要理解为何PSF的傅里叶变换得到OTF,我们需要一个来自成像理论的概念。

对于非相干、平移不变的成像系统,图像可以建模为由PSF模糊的物体。

用简洁的符号表示:

Iimage(x,y)=Iobject(x,y)PSF(x,y) I_\text{image}(x,y) = I_\text{object}(x,y) * \mathrm{PSF}(x,y)

其中 * 表示卷积。

这意味着:

最终图像是通过将每个物点替换为PSF的副本,
并将所有这些副本叠加而构成的。

这是一个强有力的概念。

若PSF狭窄,点保持集中,图像看起来锐利。

若PSF宽阔,每个物点扩散得更广,图像失去精细细节。

卷积定理进而告诉我们:

F{Iimage}=F{Iobject}F{PSF} \mathcal{F}{I_\text{image}} = \mathcal{F}{I_\text{object}} \cdot \mathcal{F}{\mathrm{PSF}}

PSF的傅里叶变换就是系统的传递函数:

OTF(fx,fy)=F{PSF(x,y)} \mathrm{OTF}(f_x,f_y)=\mathcal{F}{\mathrm{PSF}(x,y)}

因此OTF告诉我们物体的每个空间频率成分如何被光学系统改变。

这就是PSF和OTF是同一成像行为的两个视角的原因:

PSF:空域模糊
OTF:频域传递

它们并非悬浮于空间的独立测量量。它们是一对傅里叶变换对。


3. OTF是复数的;MTF是其幅值

OTF通常是复数的:

OTF(fx,fy)=OTF(fx,fy)eiΦ(fx,fy) \mathrm{OTF}(f_x,f_y) = |\mathrm{OTF}(f_x,f_y)|e^{i\Phi(f_x,f_y)}

它具有:

  • 幅值;
  • 相位。

幅值就是MTF:

MTF(fx,fy)=OTF(fx,fy) \mathrm{MTF}(f_x,f_y)=|\mathrm{OTF}(f_x,f_y)|

相位通常被称为相位传递函数,即PTF:

PTF(fx,fy)=arg(OTF(fx,fy)) \mathrm{PTF}(f_x,f_y)=\arg(\mathrm{OTF}(f_x,f_y))

许多光学设计讨论聚焦于MTF,因为对比度传递极为有用。但MTF丢掉了OTF的相位部分。

这不是一个小细节。

MTF告诉你每个空间频率下剩下多少对比度。但它不能完全告诉你结构如何移动、是否出现相位反转,或不对称模糊如何影响图像外观。

这是本书的一个核心判断:

MTF很重要,但并非图像质量故事的全部。

这不是反对MTF的论点。而是反对将一条曲线视为包含了所有光学真值。

PSF、OTF相位、视场依赖、波长依赖、畸变、传感器采样和图像处理都可能重要。

目前,我们将直接从PSF计算OTF和MTF。然后我们将学习如何解读结果,而不盲目崇拜它。


4. 归一化:MTF为何始于1

零频成分表示平均亮度传递。对于一个总能量为1的归一化PSF:

x,yPSF(x,y)=1 \sum_{x,y}\mathrm{PSF}(x,y)=1

零频处的OTF值也应为1:

OTF(0,0)=1 \mathrm{OTF}(0,0)=1

因此零频处的MTF是:

MTF(0,0)=1 \mathrm{MTF}(0,0)=1

在数值代码中,由于FFT惯例,可能会出现小幅缩放差异。最安全的实践方法是用零频值对OTF进行归一化:

OTFnorm(fx,fy)=OTF(fx,fy)OTF(0,0) \mathrm{OTF}_\text{norm}(f_x,f_y) = \frac{\mathrm{OTF}(f_x,f_y)} {\mathrm{OTF}(0,0)}

然后:

MTF(fx,fy)=OTFnorm(fx,fy) \mathrm{MTF}(f_x,f_y) = |\mathrm{OTF}_\text{norm}(f_x,f_y)|

这使得MTF从1开始。

在代码中,将FFT输出移位使零频居中后:

center = (otf.shape[0] // 2, otf.shape[1] // 2)
otf = otf / otf[center]
mtf = np.abs(otf)

这一小步归一化能避免许多困惑。

如果MTF不从1开始,不要立即更改光学模型。首先检查归一化。


5. 从PSF计算OTF和MTF

我们直接基于上一章的代码构建。

保持相同的概念流程:

光瞳函数
→ PSF
→ OTF
→ MTF

以下是最简OTF/MTF函数:

import numpy as np


def compute_otf_mtf(psf):
    """
    从归一化或未归一化的PSF计算光学传递函数和调制传递函数。

    Parameters
    ----------
    psf : 2D array
        点扩散函数。

    Returns
    -------
    otf : 2D complex array
        归一化后的光学传递函数。
    mtf : 2D array
        调制传递函数。
    """
    otf = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(psf)
        )
    )

    center = (otf.shape[0] // 2, otf.shape[1] // 2)

    if np.abs(otf[center]) > 0:
        otf = otf / otf[center]

    mtf = np.abs(otf)

    return otf, mtf

该结构与PSF计算相似,但对象不同。

在第12章中:

复光瞳的FFT → 复像场
平方幅值 → PSF

此处:

PSF的FFT → OTF
幅值 → MTF

不要将这两步混淆。

一个常见的错误计算是取光瞳的FFT并将其幅值称为MTF。这跳过了PSF到OTF的关系,将相干场传播与非相干图像传递混淆了。

本章的正确链条是:

先得到PSF。
再得到OTF。
然后得到MTF。

6. 复用PSF代码

为完整起见,以下是我们所需的一组小型工作函数。

这不是一个完整的光学设计程序。这是暴露出来的计算骨架。

import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    coord = np.linspace(-1.0, 1.0, n)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength
    pupil = amplitude * np.exp(1j * phase)

    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase


def pad_array_centered(array, pad_factor=2):
    if pad_factor == 1:
        return array

    n, m = array.shape
    new_n = n * pad_factor
    new_m = m * pad_factor

    padded = np.zeros((new_n, new_m), dtype=array.dtype)

    start_n = (new_n - n) // 2
    start_m = (new_m - m) // 2

    padded[start_n:start_n + n, start_m:start_m + m] = array

    return padded


def compute_psf(pupil, pad_factor=4, normalize=True):
    padded_pupil = pad_array_centered(pupil, pad_factor=pad_factor)

    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(padded_pupil)
        )
    )

    psf = np.abs(field) ** 2

    if normalize:
        total = np.sum(psf)
        if total > 0:
            psf = psf / total

    return psf, field


def compute_otf_mtf(psf):
    otf = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(psf)
        )
    )

    center = (otf.shape[0] // 2, otf.shape[1] // 2)

    if np.abs(otf[center]) > 0:
        otf = otf / otf[center]

    mtf = np.abs(otf)

    return otf, mtf

现在我们可以制作一个完美的圆形光瞳并计算其PSF和MTF。

wavelength = 550e-9

x, y, rho, theta, mask = make_pupil_grid(n=256)

zero_opd = np.zeros_like(rho)

perfect_pupil, _ = pupil_function_from_opd(
    opd=zero_opd,
    mask=mask,
    wavelength=wavelength
)

perfect_psf, _ = compute_psf(
    perfect_pupil,
    pad_factor=4
)

perfect_otf, perfect_mtf = compute_otf_mtf(perfect_psf)

此时,perfect_mtf 是一个二维数组。

这一点很重要。MTF并非天生就是一条曲线。它首先是一个二维频率响应:

MTF(fx,fy) \mathrm{MTF}(f_x,f_y)

我们熟悉的曲线通常是该二维函数的一个切片。

这个切片可以是水平的、垂直的、弧矢的、子午的、径向的或平均的,依上下文而定。

曲线并非全貌。它是一个选定的视图。


7. 显示二维MTF

首先我们查看二维MTF。

def show_mtf_2d(mtf, title="MTF"):
    plt.figure(figsize=(5, 4))
    plt.imshow(mtf, origin="lower", vmin=0, vmax=1)
    plt.colorbar(label="MTF")
    plt.title(title)
    plt.xlabel("Frequency sample x")
    plt.ylabel("Frequency sample y")
    plt.tight_layout()
    plt.show()


show_mtf_2d(perfect_mtf, title="Diffraction-limited 2D MTF")

对于完美的圆形光瞳,二维MTF应是径向对称的。它在中心最高,然后向截止频率递减。

这是衍射受限PSF在频域的对应物。

PSF展示了空间中类艾里斑的模糊图案。

MTF展示了该模糊如何降低不同空间频率的对比度。

一个并不比另一个更真实。它们是同一成像行为的两种表示。

PSF窄
→ 高频传递更强

PSF宽
→ 高频传递更弱

这种关系不仅仅是视觉上的。它源自傅里叶变换。


8. 提取简单的MTF曲线

大多数光学设计软件不仅显示二维MTF图,通常还展示一维曲线。

对于轴上旋转对称系统,穿过MTF中心的水平或垂直切片可能足以进行简单演示。

我们提取中心切片。

def central_mtf_slices(mtf):
    """
    提取穿过中心的水平和垂直MTF切片。

    Returns
    -------
    freq_index : 1D array
        相对于零频的采样索引。
    horizontal : 1D array
        沿水平频率轴的MTF。
    vertical : 1D array
        沿垂直频率轴的MTF。
    """
    n, m = mtf.shape
    cy = n // 2
    cx = m // 2

    horizontal = mtf[cy, :]
    vertical = mtf[:, cx]

    freq_index = np.arange(m) - cx

    return freq_index, horizontal, vertical

现在只绘制正频部分:

def plot_mtf_slices(mtf, title="MTF slices"):
    freq_index, horizontal, vertical = central_mtf_slices(mtf)

    center = len(freq_index) // 2
    freq_positive = freq_index[center:]

    horizontal_positive = horizontal[center:]
    vertical_positive = vertical[center:]

    plt.figure(figsize=(6, 4))
    plt.plot(freq_positive, horizontal_positive, label="Horizontal slice")
    plt.plot(freq_positive, vertical_positive, "--", label="Vertical slice")
    plt.xlabel("Frequency sample index")
    plt.ylabel("MTF")
    plt.ylim(0, 1.05)
    plt.title(title)
    plt.grid(True)
    plt.legend()
    plt.tight_layout()
    plt.show()


plot_mtf_slices(perfect_mtf, title="Diffraction-limited MTF slices")

对于理想圆形光瞳,两条曲线应几乎相同。小的数值差异可能来自采样。

这是我们得到的第一条MTF曲线。

它仍以采样索引为单位,而不是每毫米线对。这对理解链条是可以接受的。物理频率单位需要坐标缩放,第14章将花更多时间讨论这一点。

目前,重点在于:

这条曲线来自二维MTF的一个切片,
二维MTF来自PSF的傅里叶变换,
PSF来自光瞳函数的傅里叶变换。

这就是打开的黑箱。


9. 加入像差并观察MTF下降

现在创建一个带像差的光瞳并比较MTF曲线。

再次使用教学用的OPD图:

def synthetic_opd(rho, theta, mask, wavelength):
    """
    创建一个教学用的OPD图(单位:米)。

    这是教学波前,并非完整光学设计结果。
    """
    opd = np.zeros_like(rho)

    defocus = 0.50 * wavelength
    astigmatism = 0.25 * wavelength

    opd += defocus * (2.0 * rho**2 - 1.0)
    opd += astigmatism * rho**2 * np.cos(2.0 * theta)

    opd[~mask] = 0.0

    return opd

计算带像差的PSF和MTF:

aberrated_opd = synthetic_opd(
    rho=rho,
    theta=theta,
    mask=mask,
    wavelength=wavelength
)

aberrated_pupil, _ = pupil_function_from_opd(
    opd=aberrated_opd,
    mask=mask,
    wavelength=wavelength
)

aberrated_psf, _ = compute_psf(
    aberrated_pupil,
    pad_factor=4
)

aberrated_otf, aberrated_mtf = compute_otf_mtf(aberrated_psf)

现在比较中心切片:

def plot_mtf_comparison(mtf_a, mtf_b, label_a, label_b, title="MTF comparison"):
    freq_index, h_a, v_a = central_mtf_slices(mtf_a)
    _, h_b, v_b = central_mtf_slices(mtf_b)

    center = len(freq_index) // 2
    freq_positive = freq_index[center:]

    plt.figure(figsize=(7, 4))

    plt.plot(freq_positive, h_a[center:], label=f"{label_a} horizontal")
    plt.plot(freq_positive, v_a[center:], "--", label=f"{label_a} vertical")
    plt.plot(freq_positive, h_b[center:], label=f"{label_b} horizontal")
    plt.plot(freq_positive, v_b[center:], "--", label=f"{label_b} vertical")

    plt.xlabel("Frequency sample index")
    plt.ylabel("MTF")
    plt.ylim(0, 1.05)
    plt.title(title)
    plt.grid(True)
    plt.legend()
    plt.tight_layout()
    plt.show()


plot_mtf_comparison(
    perfect_mtf,
    aberrated_mtf,
    label_a="Perfect",
    label_b="Aberrated",
    title="Perfect vs aberrated MTF"
)

带像差的MTF应低于完美MTF,尤其是在较高空间频率处。

这不是任意的惩罚。它源于PSF中的能量扩散。

更宽或结构更复杂的PSF降低了系统保留精细细节对比度的能力。

链条是:

光瞳相位误差
→ PSF改变
→ OTF改变
→ 降低或方向依赖的MTF

这就是一条原本看起来像软件报告般的曲线背后的计算故事。


10. 方向很重要:弧矢与子午MTF

光学设计软件通常绘制弧矢和子午MTF曲线。

这些名称起初可能显得神秘,但概念是几何的。

对于一个离轴视场点,存在一个包含以下内容的平面:

  • 光轴;
  • 主光线;
  • 视场点。

这就是子午径向方向。

与之垂直的方向是弧矢方向。

在理想轴上旋转对称的情况下,弧矢和子午行为相同。离轴时,它们可能显著不同。

这种差异并非装饰性的。像散、彗差及视场依赖的像差常常对不同方向产生不同影响。

在我们简化的居中阵列中,我们可以将一条中心切片视为子午方向,而将垂直切片视为弧矢方向,以此模拟这个想法。

这仅是一个教学简化。

def sagittal_tangential_slices_simple(mtf):
    """
    对居中MTF阵列的简化弧矢/子午切片。

    对于真实的离轴视场,弧矢和子午方向应根据视场点和光轴定义。
    这里我们用垂直和水平中心切片作为教学代理。
    """
    n, m = mtf.shape
    cy = n // 2
    cx = m // 2

    tangential = mtf[cy, :]  # 水平代理
    sagittal = mtf[:, cx]    # 垂直代理

    freq_index = np.arange(m) - cx

    return freq_index, sagittal, tangential

现在绘制它们:

def plot_sagittal_tangential_simple(mtf, title="Sagittal and tangential MTF"):
    freq_index, sagittal, tangential = sagittal_tangential_slices_simple(mtf)

    center = len(freq_index) // 2
    freq_positive = freq_index[center:]

    plt.figure(figsize=(6, 4))
    plt.plot(freq_positive, sagittal[center:], label="Sagittal proxy")
    plt.plot(freq_positive, tangential[center:], "--", label="Tangential proxy")
    plt.xlabel("Frequency sample index")
    plt.ylabel("MTF")
    plt.ylim(0, 1.05)
    plt.title(title)
    plt.grid(True)
    plt.legend()
    plt.tight_layout()
    plt.show()


plot_sagittal_tangential_simple(
    aberrated_mtf,
    title="Simplified sagittal/tangential MTF"
)

在真实镜头分析中,不应随意将水平和垂直切片重命名为弧矢和子午切片,而不了解视场几何。

真正的定义依赖于视场点。

更安全的说法是:

弧矢和子午MTF是二维频率响应的方向性切片,
其定义相对于视场几何。

软件为了方便隐藏了该几何关系。在学习计算时,我们应保持其可见。


11. 频率单位:仅有采样索引是不够的

我们的图形目前使用频率采样索引。这足以验证计算链条,但不足以进行工程解释。

真实的MTF曲线通常相对于空间频率绘制:

每毫米线对

或等效地:

每毫米周期数

线对是指一条亮线加一条暗线,因此每毫米线对和每毫米周期数在实际上常被混用。

为物理地标注频率轴,我们需要PSF在像面的采样间距。

若PSF的采样间距为:

Δx \Delta x

则频率采样为:

f=fftfreq(N,d=Δx) f = \mathrm{fftfreq}(N, d=\Delta x)

在 NumPy 中:

freq = np.fft.fftshift(np.fft.fftfreq(n, d=dx_image))

其中 dx_image 若以毫米为单位,则频率单位为周期/毫米。

这是一个辅助函数:

def mtf_frequency_axis(n, dx_image):
    """
    为MTF数组创建移位后的频率轴。

    Parameters
    ----------
    n : int
        采样点数。
    dx_image : float
        像面采样间距。

    Returns
    -------
    freq : 1D array
        空间频率采样,单位为dx_image单位的倒数。
    """
    return np.fft.fftshift(np.fft.fftfreq(n, d=dx_image))

如果 dx_image 单位是毫米,频率就是周期/毫米。

然后正频MTF图可以使用物理单位:

def plot_mtf_with_frequency_axis(mtf, dx_image, title="MTF"):
    n, m = mtf.shape
    cy = n // 2
    cx = m // 2

    freq = mtf_frequency_axis(m, dx_image)
    mtf_slice = mtf[cy, :]

    plt.figure(figsize=(6, 4))
    plt.plot(freq[cx:], mtf_slice[cx:])
    plt.xlabel("Spatial frequency [cycles per unit]")
    plt.ylabel("MTF")
    plt.ylim(0, 1.05)
    plt.title(title)
    plt.grid(True)
    plt.tight_layout()
    plt.show()

这个函数在结构上是正确的,但它需要一个真实的 dx_image

这正是许多错误潜入之处。

PSF的采样间距取决于:

  • 光瞳采样;
  • 波长;
  • 焦距或像空间缩放;
  • 零填充;
  • FFT惯例。

我们在第12章引入了简单的角坐标辅助方法。第14章将仔细返回此问题,因为频率轴错误是生成看似可信但错误的MTF图的最快途径之一。

目前,请牢记此警告:

计算MTF数组很简单。
正确标注频率轴需要细致的采样记账。

12. 衍射受限截止频率

现在介绍一个值得引入的物理频率尺度。

对于非相干衍射受限的圆形孔径(在空气中),像空间的截止空间频率通常写作:

fc=1λN f_c = \frac{1}{\lambda N}

其中:

  • fcf_c 是截止频率;
  • λ\lambda 是波长;
  • NN 是F数。

如果波长以毫米为单位,则 fcf_c 的单位是每毫米周期数。

同一概念也可用数值孔径书写:

fc=2NAλ f_c = \frac{2,\mathrm{NA}}{\lambda}

这些公式很有用,因为它们给出了衍射受限非相干MTF降至零的近似上限频率。

例如,若:

λ=0.00055 mm \lambda = 0.00055\text{ mm}

且:

N=5.6 N = 5.6

则:

fc10.00055×5.6325 cycles/mm f_c \approx \frac{1}{0.00055 \times 5.6} \approx 325\ \text{cycles/mm}

这并不意味着每个f/5.6的真实镜头在325周期/毫米附近都能提供优秀对比度。它意味着衍射受限的截止尺度大约在那个数值附近。

像差、离焦、制造误差、传感器采样和图像处理都会影响实际可用的部分。

一个谨慎的表述是:

衍射截止给出物理上限。
实际MTF曲线告诉我们在此上限以下还剩余多少对比度。

这是一个很好的例子,说明应该如何解读MTF:不是将其视为神秘分数,而是视为逐频对比度传递。


13. MTF为何可能下降而PSF看起来并不糟糕

有时PSF图看起来并不显著恶化,但MTF曲线已明显下降。

这并不矛盾。

PSF是一幅空间图像。我们的眼睛倾向于聚焦于中心核心和可见拖尾。能量的微小重新分布仍会影响高频对比度。

MTF对PSF如何与不同频率的正弦图案相互作用很敏感。

PSF的微小展宽就能降低高频MTF,即使PSF在显示屏上仍然“看起来相当锐利”。

这是MTF成为如此有用的工程指标的原因之一。

它能揭示对比度损失,而这种损失从PSF图像中难以仅靠肉眼判断。

但相反的问题也存在。一条MTF曲线可能隐藏了模糊的空间特征。两个系统在选定频率下可能具有相似的MTF,但PSF形状不同。一个可能产生对称模糊;另一个可能产生方向性拖尾或光晕。

因此我们应将PSF和MTF一起解读:

PSF显示点能量去向何处。
MTF显示对比度随空间频率如何存留。

两种视图都不会使对方过时。


14. OTF中的相位:MTF隐藏的部分

由于MTF是OTF的幅值,它丢弃了OTF相位。

我们来计算并显示OTF的相位:

def show_otf_phase(otf, title="OTF phase"):
    phase = np.angle(otf)

    plt.figure(figsize=(5, 4))
    plt.imshow(phase, origin="lower", cmap="twilight")
    plt.colorbar(label="Phase [rad]")
    plt.title(title)
    plt.xlabel("Frequency sample x")
    plt.ylabel("Frequency sample y")
    plt.tight_layout()
    plt.show()

如果你的 Matplotlib 安装不支持 "twilight" 颜色映射,请使用默认颜色映射:

def show_otf_phase_default(otf, title="OTF phase"):
    phase = np.angle(otf)

    plt.figure(figsize=(5, 4))
    plt.imshow(phase, origin="lower")
    plt.colorbar(label="Phase [rad]")
    plt.title(title)
    plt.xlabel("Frequency sample x")
    plt.ylabel("Frequency sample y")
    plt.tight_layout()
    plt.show()

对于对称的居中PSF,OTF相位可能很简单。对于偏移或不对称的PSF,相位可能携带重要信息。

以下是一个小演示。

如果我们平移一个PSF,其MTF幅值可能保持不变,而OTF相位会改变。

shifted_psf = np.roll(perfect_psf, shift=10, axis=1)

shifted_otf, shifted_mtf = compute_otf_mtf(shifted_psf)

plot_mtf_comparison(
    perfect_mtf,
    shifted_mtf,
    label_a="Original",
    label_b="Shifted",
    title="MTF before and after PSF shift"
)

MTF曲线可能看起来几乎相同。但图像位置发生了变化。

该平移携带在相位中,而非幅值。

这是一个关于MTF局限性的清晰例子:

MTF可以说对比度得到了保留。
它可能不会告诉您图像已经移动了。

在许多镜头质量情境中,MTF仍然极具价值。但如果您需要完整的图像形成、配准、相位行为或不对称效应,就不能忽视OTF相位和PSF形状。


15. MTF与对比度

MTF为何对应于对比度?

设想一个具有正弦强度变化的物体:

I(x)=I0[1+mcos(2πfx)] I(x)=I_0\left[1+m\cos(2\pi f x)\right]

其中:

  • I0I_0 是平均强度;
  • mm 是调制或对比度;
  • ff 是空间频率。

经过成像系统后,同一频率可能以减小的调制出现:

Iimage(x)=I0[1+mcos(2πfx+ϕ)] I_\text{image}(x)=I_0\left[1+m'\cos(2\pi f x+\phi)\right]

在该频率处的MTF为:

MTF(f)=mm \mathrm{MTF}(f)=\frac{m'}{m}

这就是MTF的对比度传递含义。

若MTF为1,该频率的对比度得以保留。

若MTF为0.5,调制度减半。

若MTF接近0,该频率几乎未被传递。

这就是MTF比许多原始光学量更容易与图像细节联系起来的原因。

点斑尺寸告诉我们一些信息。OPD告诉我们一些信息。PSF告诉我们一些信息。但MTF直接询问:

在此细节尺寸下还剩下多少对比度?

这就是它成为标准图像质量指标的原因。

但请记住边界:

MTF描述空间频率的对比度传递。
它并不描述图像的每个感知或几何属性。

这一区别使该指标保持有用,而不使其变得神奇。


16. 完整的PSF到MTF最小脚本

这是一个紧凑的脚本,运行了完美光瞳和带像差光瞳的完整路径。

import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    coord = np.linspace(-1.0, 1.0, n)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength
    pupil = amplitude * np.exp(1j * phase)

    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase


def synthetic_opd(rho, theta, mask, wavelength):
    opd = np.zeros_like(rho)

    defocus = 0.50 * wavelength
    astigmatism = 0.25 * wavelength

    opd += defocus * (2.0 * rho**2 - 1.0)
    opd += astigmatism * rho**2 * np.cos(2.0 * theta)

    opd[~mask] = 0.0

    return opd


def pad_array_centered(array, pad_factor=4):
    if pad_factor == 1:
        return array

    n, m = array.shape
    new_n = n * pad_factor
    new_m = m * pad_factor

    padded = np.zeros((new_n, new_m), dtype=array.dtype)

    start_n = (new_n - n) // 2
    start_m = (new_m - m) // 2

    padded[start_n:start_n + n, start_m:start_m + m] = array

    return padded


def compute_psf(pupil, pad_factor=4, normalize=True):
    padded_pupil = pad_array_centered(pupil, pad_factor=pad_factor)

    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(padded_pupil)
        )
    )

    psf = np.abs(field) ** 2

    if normalize:
        total = np.sum(psf)
        if total > 0:
            psf = psf / total

    return psf, field


def compute_otf_mtf(psf):
    otf = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(psf)
        )
    )

    center = (otf.shape[0] // 2, otf.shape[1] // 2)

    if np.abs(otf[center]) > 0:
        otf = otf / otf[center]

    mtf = np.abs(otf)

    return otf, mtf


def central_mtf_slices(mtf):
    n, m = mtf.shape
    cy = n // 2
    cx = m // 2

    horizontal = mtf[cy, :]
    vertical = mtf[:, cx]

    freq_index = np.arange(m) - cx

    return freq_index, horizontal, vertical


def plot_mtf_comparison(mtf_a, mtf_b, label_a, label_b):
    freq_index, h_a, v_a = central_mtf_slices(mtf_a)
    _, h_b, v_b = central_mtf_slices(mtf_b)

    center = len(freq_index) // 2
    freq_positive = freq_index[center:]

    plt.figure(figsize=(7, 4))

    plt.plot(freq_positive, h_a[center:], label=f"{label_a} horizontal")
    plt.plot(freq_positive, v_a[center:], "--", label=f"{label_a} vertical")
    plt.plot(freq_positive, h_b[center:], label=f"{label_b} horizontal")
    plt.plot(freq_positive, v_b[center:], "--", label=f"{label_b} vertical")

    plt.xlabel("Frequency sample index")
    plt.ylabel("MTF")
    plt.ylim(0, 1.05)
    plt.title("MTF comparison")
    plt.grid(True)
    plt.legend()
    plt.tight_layout()
    plt.show()


wavelength = 550e-9

x, y, rho, theta, mask = make_pupil_grid(n=256)

zero_opd = np.zeros_like(rho)
perfect_pupil, _ = pupil_function_from_opd(
    opd=zero_opd,
    mask=mask,
    wavelength=wavelength
)

aberrated_opd = synthetic_opd(
    rho=rho,
    theta=theta,
    mask=mask,
    wavelength=wavelength
)

aberrated_pupil, _ = pupil_function_from_opd(
    opd=aberrated_opd,
    mask=mask,
    wavelength=wavelength
)

perfect_psf, _ = compute_psf(perfect_pupil, pad_factor=4)
aberrated_psf, _ = compute_psf(aberrated_pupil, pad_factor=4)

perfect_otf, perfect_mtf = compute_otf_mtf(perfect_psf)
aberrated_otf, aberrated_mtf = compute_otf_mtf(aberrated_psf)

plot_mtf_comparison(
    perfect_mtf,
    aberrated_mtf,
    label_a="Perfect",
    label_b="Aberrated"
)

与第1章的代码预览相比,这个脚本现在应感觉不那么神秘了。

在本书开头,这些行看起来像是一个快捷方式:

otf = np.fft.fftshift(np.fft.fft2(psf))
mtf = np.abs(otf)

现在它们在链条中有了自己的位置。

它们并非任意的傅里叶变换。它们是根据非相干成像系统的点扩散函数计算其频率响应。

这就是使用代码与理解代码之间的区别。


17. Optiland对比工作流程

在真实的Optiland工作流程中,进行实际设计工作时,MTF通常应使用库自带的分析工具计算。

我们NumPy版本的教学价值不同。它让我们能检查工具在概念上必须执行的操作。

一个经验证的工作流程概览如下所示:

# 经验证的工作流程概览。
# 检查您安装的Optiland版本以获取确切的API名称。

# 1. 构建或加载光学系统。
# optic = ...

# 2. 选择视场、波长、孔径和焦点设置。
# field = ...
# wavelength = ...

# 3. 使用Optiland的PSF分析。
# psf_result = ...

# 4. 使用Optiland的MTF分析。
# mtf_result = ...

# 5. 如果采样PSF可用,从PSF重建教学用MTF。
# psf_array = ...
# otf_numpy, mtf_numpy = compute_otf_mtf(psf_array)

# 6. 比较:
# - MTF归一化
# - 频率轴
# - 弧矢/子午定义
# - 波长与视场设置
# - 采样与填充

比较不仅仅是匹配图形。它关乎提出正确的问题:

  • 这条MTF是针对哪个视场点的?
  • 使用了哪个波长或波长权重?
  • 绘制的曲线是弧矢、子午、径向还是平均的?
  • 频率轴是以周/毫米、周/度还是归一化单位?
  • PSF是基于衍射还是几何的?
  • MTF是多色还是单色的?
  • 像面是否重新对焦?
  • 使用了何种归一化?

这时开放工具的教育价值就变得清晰了。

您不仅仅是按下一个按钮。您可以检查输入,复现简化的部分,并理解为何结果会变化。

这并不会使工具变得不必要。它使工具变得不那么神秘。


18. 仔细阅读MTF曲线

一旦知道了MTF如何计算,您就可以带着更好的问题阅读曲线。

低频区域

低频对应大范围对比度。

如果低频MTF不佳,图像可能显得整体平坦或模糊。在合理的光学系统中,低频MTF通常较高,除非存在严重像差、散射、离焦或其他对比度降低效应。

中频区域

中频通常对应视觉上重要的细节。

对于相机镜头,该区域能强烈影响感知的锐度。一个能良好保留中等空间频率的镜头,即使高频响应不完美,也可能看起来清晰。

高频区域

高频对应精细细节。

高频MTF对衍射、像差、焦点、采样和制造敏感。它通常最先下降。

但不应孤立解读高频MTF。一个系统可能拥有可接受的高频响应,但在其他方面表现不佳,如场曲、畸变、眩光或方向性模糊。

截止频率

截止频率是指对比度传递基本为零的频率。

对于非相干衍射受限成像,这与波长和数值孔径有关。在实际成像系统中,有用对比度可能在理论截止频率之前就变得过低。

一个细心的读者不仅会问:

曲线在哪里结束?

还会问:

在该应用重要的频率处还剩下多少对比度?

这个与应用相关的问题通常更有用。


19. MTF为何强大但并非万能

MTF成为标准指标,是因为它将大量成像行为压缩到一条可读曲线上。

这是它的优势。

也是它的危险所在。

一条单一的MTF曲线可能无法告诉您:

  • 模糊是对称还是不对称;
  • PSF是否有长拖尾;
  • 是否存在图像位移;
  • 相位行为是否重要;
  • 畸变如何影响几何形状;
  • 颜色通道如何不同;
  • 性能如何随视场变化;
  • 传感器采样如何与光学模糊相互作用;
  • 图像处理如何改变最终对比度。

即使多条MTF曲线也不能完全替代查看PSF、点列图、波前、场曲、畸变和系统处方。

因此更好的习惯是:

将MTF用作频域摘要,
而非光学系统的完整描述。

这是本书开头那个尖锐主张的审慎版本:

能够阅读MTF曲线不等于理解它从何而来。

现在我们可以确切说出它从何而来。

它来自PSF。

PSF来自复光瞳函数。

光瞳函数来自振幅和OPD。

OPD来自通过光学系统计算的光程差。

这条链条才是本书的真正主题。


20. OTF与MTF计算中的常见错误

错误1:直接从光瞳取得MTF

错误做法:

mtf = np.abs(np.fft.fft2(pupil))

这不是非相干MTF。

光瞳的变换给出复像场。您仍需PSF:

field = np.fft.fft2(pupil)
psf = np.abs(field) ** 2
otf = np.fft.fft2(psf)
mtf = np.abs(otf)

顺序至关重要。

错误2:忘记OTF归一化

如果MTF不从1开始,在解读结果前先检查归一化。

otf = otf / otf[center]
mtf = np.abs(otf)

错误3:混淆频率索引与物理频率

数组索引并非周/毫米。

要获得物理频率,需要像面采样间距。

freq = np.fft.fftshift(np.fft.fftfreq(n, d=dx_image))

难点在于正确得知 dx_image

错误4:无视场几何知识而将水平和垂直切片称为弧矢与子午

对于真实离轴视场,弧矢和子午方向是相对于光轴和视场点定义的。

水平与垂直切片仅为教学代理,除非已知坐标系。

错误5:忽略波长和视场点

MTF随波长和视场位置变化。

单条曲线并不是在所有条件下“镜头的MTF”。它是特定配置下的MTF。

错误6:将MTF视为最终真理

MTF很强大。但它并不完整。

应结合PSF、波前、点列图、视场图及成像任务知识使用它。


21. 本章对完整链条的补充

我们现在可以写出从波前到MTF的完整路径:

OPD图
→ 光瞳相位
→ 复光瞳函数
→ PSF
→ OTF
→ MTF

新增的步骤是:

OTF(fx,fy)=F{PSF(x,y)} \mathrm{OTF}(f_x,f_y)=\mathcal{F}{\mathrm{PSF}(x,y)}

以及:

MTF(fx,fy)=OTF(fx,fy) \mathrm{MTF}(f_x,f_y)=|\mathrm{OTF}(f_x,f_y)|

在代码中:

otf = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(psf)))
otf = otf / otf[center]
mtf = np.abs(otf)

这就是按钮背后的曲线。

虽非完整故事,却是基本的计算骨架。

我们现在已经抵达了第1章所指向的终点。本书的其余部分将使这一结果更安全、更实用,并与真实光学设计联系更紧密。

第14章将放慢脚步,做些非常必要的事:审视此计算可能出错的方式。

因为到了现在,我们已知足够去犯令人印象深刻的错误。

采样、FFT移位、频率轴、归一化、填充和单位,都可能产生看起来合理但含义错误的MTF图。

因此,在转向优化之前,我们需要一章救援章节。


章末小结

PSF描述空间模糊。OTF描述频率响应。MTF是OTF的幅值。

对于非相干、平移不变的成像:

OTF=F{PSF} \mathrm{OTF}=\mathcal{F}{\mathrm{PSF}}

MTF=OTF \mathrm{MTF}=|\mathrm{OTF}|

在代码中:

otf = np.fft.fftshift(np.fft.fft2(np.fft.ifftshift(psf)))
otf = otf / otf[center]
mtf = np.abs(otf)

正确归一化后,MTF从1开始。它通常随空间频率升高而下降,显示出对比度传递如何对更精细细节减弱。

二维MTF可切片为曲线。弧矢和子午曲线是相对于视场几何定义的方向性切片;水平与垂直切片仅为简化代理,除非坐标系已知。

MTF之所以强大,是因为它按空间频率总结对比度传递。它的局限在于丢弃了OTF相位,并将空间模糊行为压缩到选定曲线中。

本章最重要的结果不仅仅是公式。而是这条链条:

PSF
→ OTF
→ MTF

现在,MTF曲线不再是一个神秘的输出。它是PSF的计算结果,而PSF本身又是光瞳函数与波前误差的计算结果。

生成的验证图

MTF切片,由第13章OTF和MTF计算生成

来源与验证说明

此处的OTF/MTF公式假定为非相干、平移不变的成像,并具有正确归一化的PSF。真实光学设计软件可能根据额外惯例计算衍射MTF、几何MTF、多色MTF、随视场变化的曲线或特定方向的弧矢/子午曲线。本章在添加这些生产细节之前,保持了核心傅里叶关系的可见性。

第14章:采样、归一化以及那些破坏PSF与MTF的小错误

至此,我们已有足够的代码来绘制令人信服的图像。

这是好消息。

但也很危险。

当采样出现错误时,PSF图看起来依然可以很平滑且科学。MTF曲线可以从1开始,优雅地下降,但其频率轴却是错的。傅里叶变换在数学上可能正确,却代表着错误的物理孔径。一个平移的数组可能产生看起来几乎正确的结果,但其中心却位于错误的位置。

本章是一章“补救”性质的章节。它不会引入新的光学概念,而是让前三个章节更为安全。

我们已经建立起这样一条链路:

光程差 (OPD)
→ 光瞳函数
→ PSF
→ OTF
→ MTF

现在,我们放慢脚步并追问:

这条链路何时才能产生物理上有意义的结果?

答案取决于那些看似细小,却会彻底破坏一切的细节:

  • FFT平移(shift);
  • 阵列中心化;
  • 孔径掩膜(mask);
  • 补零(zero padding);
  • 光瞳采样;
  • 像平面采样;
  • 频率轴单位;
  • PSF归一化;
  • OTF归一化;
  • 波长单位;
  • 焦距单位;
  • 子午与弧矢方向。

这些并非装饰性的实现细节,它们决定了图像的实际含义。

本章有一条尖锐但有用的准则:

一个正确的公式救不了一个采样错误的数组。

让我们逐一梳理常见的陷阱。


1. 问题所在:漂亮但错误的图

傅里叶变换极为“顺从”。

它不会询问你的波长是以米还是纳米为单位;它不知道你的光瞳直径是5毫米还是5米;它不知道零频是否应位于阵列中心;它也不知道你的PSF是否已经归一化。

它只处理数字。

这正是为什么基于FFT的光学计算可能静悄悄地出错。

错误的光线–表面交点通常会产生明显的错误:光线飞散、交点消失,或者程序崩溃。

错误的MTF计算却可以产生一条完美平滑的曲线。

这更为危险,因为它看起来像模像样。

实践中的教训是:

在FFT光学中,调试的第一个问题不是:
“这张图看起来像光学图吗?”

而是:
“它的单位、采样间隔、归一化方式和坐标约定是什么?”

本章将为你提供一个回答此问题的核查清单。


2. 三个数组,三种坐标系

光瞳数组、PSF数组和MTF数组并不生活在同一个坐标系中。

这是混淆的首要来源。

光瞳数组

光瞳数组描述的是孔径上的场分布。

其坐标可以是:

归一化的光瞳坐标

也可以是物理坐标,例如:

光瞳上的米或毫米

光瞳函数为:

P(x,y) P(x,y)

其中 x,yx,y 是光瞳平面坐标。

PSF数组

PSF数组描述的是像平面上的强度分布。

其坐标可以是:

角坐标

也可以是:

焦平面上的长度坐标
$$

PSF为:

$$
\mathrm{PSF}(X,Y)
$$

其中 $X,Y$ 是像平面坐标。

### MTF数组

MTF数组描述的是空间频率响应。

其坐标是频率坐标:

```text
线对/毫米

或者:

线对/弧度

有时是归一化的频率。

MTF为:

$$ \mathrm{MTF}(f_x,f_y) $$

其中 $f_x,f_y$ 是空间频率。

因此,这条链路不仅是:

数组 → 数组 → 数组

而是:

光瞳平面场
→ 像平面强度
→ 像平面空间频率响应

这种区分可以节省大量调试时间。


3. DFT的采样关系

离散傅里叶变换将采样间隔联系在一起。

若以间隔:

$$ \Delta x $$

对一个函数进行 $N$ 点采样,则对应频率样本的间隔为:

$$ \Delta f = \frac{1}{N\Delta x} $$

对于偶数长度的数组,经过 fftshift 之后,平移后的频率坐标为:

$$ f_k = \frac{k - N/2}{N\Delta x} $$

在NumPy中,这表示为:

freq = np.fft.fftshift(np.fft.fftfreq(N, d=dx))

这一行代码是构建频率轴最安全的方式。

重要之处在于:

  • N 是被变换数组的样本点数;
  • dx 是输入域中的样本间隔;
  • 输出单位是每 dx 单位的周期数(线对数)。

因此,若:

dx = 0.01  # 毫米

则:

freq 的单位是 线对/毫米

若:

dx = 10e-6  # 米

则:

freq 的单位是 线对/米

FFT本身不携带单位。你通过使用正确的 dx 来赋予其单位。

这是本章最重要的采样概念。


4. 从光瞳采样到PSF采样

当我们从光瞳函数计算PSF时,实际上是在对光瞳场进行傅里叶变换。

对于夫琅禾费衍射,光瞳的空间频率映射的是衍射角度。

一个有用的实践关系是:

$$ \theta = \lambda f_\text{pupil} $$

其中:

  • $\theta$ 是衍射角度(弧度);
  • $\lambda$ 是波长;
  • $f_\text{pupil}$ 是与光瞳平面采样相关的空间频率。

若光瞳采样间隔为 $\Delta u$,并且补零后的光瞳数组有 $N$ 个样本点,则角度采样间隔近似为:

$$ \Delta \theta = \frac{\lambda}{N\Delta u} $$

在焦距 $F$ 下,焦平面上的采样间隔为:

$$ \Delta X = F\Delta\theta $$

因此:

$$ \Delta X = \frac{F\lambda}{N\Delta u} $$

这个公式值得慢慢阅读。

它表明PSF的采样间隔取决于:

  • 焦距;
  • 波长;
  • FFT补零后的尺寸 $N$;
  • 物理光瞳的采样间隔。

补零会改变 $N$,因此会改变显示的PSF采样间隔,为PSF的显示提供更多的采样点。

但它不会改变物理孔径。

以下是一个辅助函数:

import numpy as np


def psf_sample_spacing_from_pupil(
    wavelength,
    focal_length,
    pupil_sample_spacing,
    n_fft
):
    """
    估计夫琅禾费传播下焦平面PSF的采样间隔。

    参数
    ----------
    wavelength : float
        波长,单位:米。
    focal_length : float
        焦距,单位:米。
    pupil_sample_spacing : float
        光瞳采样点之间的物理间距,单位:米。
    n_fft : int
        补零后的FFT数组尺寸。

    返回
    -------
    dx_image : float
        近似的像平面采样间隔,单位:米。
    """
    dtheta = wavelength / (n_fft * pupil_sample_spacing)
    dx_image = focal_length * dtheta

    return dx_image

如果你希望结果以毫米为单位:

dx_image_mm = dx_image * 1e3

这是从光瞳数组通往物理PSF坐标的桥梁。


5. 示例:具有物理坐标标记的PSF

让我们创建一个简单的物理设置。

假设:

wavelength = 550 nm
focal length = 50 mm
pupil diameter = 10 mm

F数为:

$$ N = \frac{F}{D} = \frac{50}{10} = 5 $$

现在构建光瞳。

import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    coord = np.linspace(-1.0, 1.0, n, endpoint=False)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength
    pupil = amplitude * np.exp(1j * phase)
    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase


def pad_array_centered(array, pad_factor=4):
    if pad_factor == 1:
        return array

    n, m = array.shape
    new_n = n * pad_factor
    new_m = m * pad_factor

    padded = np.zeros((new_n, new_m), dtype=array.dtype)

    start_n = (new_n - n) // 2
    start_m = (new_m - m) // 2

    padded[start_n:start_n + n, start_m:start_m + m] = array

    return padded


def compute_psf(pupil, pad_factor=4, normalize=True):
    padded_pupil = pad_array_centered(pupil, pad_factor=pad_factor)

    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(padded_pupil)
        )
    )

    psf = np.abs(field) ** 2

    if normalize:
        total = np.sum(psf)
        if total > 0:
            psf = psf / total

    return psf, field

现在定义物理参数:

wavelength = 550e-9      # 550 nm
focal_length = 50e-3     # 50 mm
pupil_diameter = 10e-3   # 10 mm

n_pupil = 256
pad_factor = 4
n_fft = n_pupil * pad_factor

x, y, rho, theta, mask = make_pupil_grid(n=n_pupil)

zero_opd = np.zeros_like(rho)

pupil, _ = pupil_function_from_opd(
    opd=zero_opd,
    mask=mask,
    wavelength=wavelength
)

psf, _ = compute_psf(
    pupil,
    pad_factor=pad_factor
)

归一化坐标网格从 $-1$ 到 $1$,代表整个光瞳直径。因此粗略的光瞳采样间隔为:

$$ \Delta u = \frac{D}{N_\text{pupil}} $$

代码中:

pupil_sample_spacing = pupil_diameter / n_pupil

dx_image = psf_sample_spacing_from_pupil(
    wavelength=wavelength,
    focal_length=focal_length,
    pupil_sample_spacing=pupil_sample_spacing,
    n_fft=n_fft
)

dx_image_mm = dx_image * 1e3

print("Image-plane sample spacing [mm]:", dx_image_mm)
print("Image-plane sample spacing [um]:", dx_image * 1e6)

现在我们可以为PSF坐标轴赋予物理意义。

def image_coordinate_axis(n, dx):
    """
    创建中心化的像平面坐标轴。
    """
    center = n // 2
    index = np.arange(n) - center
    return index * dx


coord_image_m = image_coordinate_axis(psf.shape[0], dx_image)
coord_image_um = coord_image_m * 1e6

extent_um = [
    coord_image_um[0],
    coord_image_um[-1],
    coord_image_um[0],
    coord_image_um[-1],
]

plt.figure(figsize=(5, 4))
plt.imshow(psf, origin="lower", extent=extent_um)
plt.colorbar(label="Normalized intensity")
plt.xlabel("Image x [um]")
plt.ylabel("Image y [um]")
plt.title("Diffraction-limited PSF with physical axis")
plt.tight_layout()
plt.show()

现在,这张PSF图不再仅仅是一个数组图像。其坐标轴具有物理含义。

这是一个巨大的进步。

但它也带来了责任——如果光瞳直径、焦距、波长或补零尺寸出错,坐标轴的标签就会随之出错。


6. 检验艾里斑半径

一个很好的合理性检查是艾里斑的第一暗环半径。

对于圆形孔径,焦平面上的第一暗环半径近似为:

$$ r_\text{Airy} = 1.22\lambda N $$

其中 $N$ 为F数。

代入:

λ = 550 nm
N = 5

得到:

$$ r_\text

1.22 \times 550\text{ nm} \times 5 $$

$$ r_\text{Airy} \approx 3.36\ \mu\text{m} $$

代码:

f_number = focal_length / pupil_diameter
airy_radius = 1.22 * wavelength * f_number

print("F-number:", f_number)
print("Airy first dark radius [um]:", airy_radius * 1e6)

这为我们提供了检验PSF坐标轴的一个粗略方法。

所绘PSF中的第一暗环应该接近该半径。

由于采样、有限阵列效应、显示插值以及我们定位最小值的方式,它或许不会完全匹配。但如果该环出现在3米或0.003纳米处,那就一定有问题。

在计算光学中,这种合理性检验不是可选的。

一个好习惯是:

在计算衍射受限PSF之后,
检查艾里斑的尺度在物理上是否合理。

仅此一项检验就能抓住许多单位上的错误。


7. 错误采样示例:光瞳直径错误下的PSF

让我们故意犯一个错误。

假设实际光瞳直径为10 mm,但我们错误地将其标记为1 mm。

计算出的PSF数组并不会改变。为什么?因为数组值来自归一化的光瞳掩膜。

但物理坐标轴却会剧烈变化。

wrong_pupil_diameter = 1e-3  # 错误:1 mm 而非 10 mm

wrong_pupil_sample_spacing = wrong_pupil_diameter / n_pupil

wrong_dx_image = psf_sample_spacing_from_pupil(
    wavelength=wavelength,
    focal_length=focal_length,
    pupil_sample_spacing=wrong_pupil_sample_spacing,
    n_fft=n_fft
)

print("Correct dx [um]:", dx_image * 1e6)
print("Wrong dx [um]:", wrong_dx_image * 1e6)

由于光瞳采样间隔变为了原来的十分之一,像平面采样间隔变为原来的十倍。

PSF图像看起来依然像艾里斑图样,但其物理尺寸已被错误标记。

这就是那种静默失效的模式。

数值阵列本身是不够的。坐标模型至关重要。

错误的图可能看起来很“光学”,甚至很漂亮,但它描述的是一个完全不同的物理系统。


8. 从PSF采样到MTF频率轴

现在我们向前一步。

MTF是由PSF计算得到的。其频率轴由PSF的采样间隔决定。

若PSF采样间隔为 $\Delta X$,则MTF的频率样本为:

$$ f_k = \frac{k - N/2}{N\Delta X} $$

NumPy中:

freq = np.fft.fftshift(np.fft.fftfreq(N, d=dx_image))

dx_image 以米为单位, freq 的单位即为 线对/米。

要转换为 线对/毫米:

freq_cyc_per_mm = freq / 1000.0

因为1米包含1000毫米,线对/米除以1000得到线对/毫米。

以下是一个包含坐标轴生成的整洁MTF计算:

def compute_otf_mtf(psf):
    otf = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(psf)
        )
    )

    center = (otf.shape[0] // 2, otf.shape[1] // 2)

    if np.abs(otf[center]) > 0:
        otf = otf / otf[center]

    mtf = np.abs(otf)

    return otf, mtf


def mtf_frequency_axis(n, dx_image):
    """
    生成MTF的频率轴。

    参数
    ----------
    n : int
        PSF / MTF 的采样点数。
    dx_image : float
        PSF 采样间隔,单位:米。

    返回
    -------
    freq_cyc_per_mm : 1维数组
        空间频率,单位:线对/毫米。
    """
    freq_cyc_per_m = np.fft.fftshift(
        np.fft.fftfreq(n, d=dx_image)
    )

    freq_cyc_per_mm = freq_cyc_per_m / 1000.0

    return freq_cyc_per_mm

现在进行计算并绘图:

otf, mtf = compute_otf_mtf(psf)

freq = mtf_frequency_axis(
    n=mtf.shape[0],
    dx_image=dx_image
)

center = mtf.shape[0] // 2
mtf_slice = mtf[center, :]

plt.figure(figsize=(6, 4))
plt.plot(freq[center:], mtf_slice[center:])
plt.xlabel("Spatial frequency [cycles/mm]")
plt.ylabel("MTF")
plt.ylim(0, 1.05)
plt.title("Diffraction-limited MTF")
plt.grid(True)
plt.tight_layout()
plt.show()

现在x轴具有了物理意义。


9. 检验衍射截止频率

对于非相干、衍射受限、圆形孔径的成像系统,截止频率近似为:

$$ f_c = \frac{1}{\lambda N} $$

其中:

  • $f_c$ 的单位是 线对 / 长度;
  • $\lambda$ 使用相同的长度单位;
  • $N$ 为F数。

若 $\lambda$ 以毫米为单位,则 $f_c$ 的单位是 线对/毫米。

wavelength_mm = wavelength * 1e3
cutoff_cyc_per_mm = 1.0 / (wavelength_mm * f_number)

print("Diffraction cutoff [cycles/mm]:", cutoff_cyc_per_mm)

对于550 nm和F/5,截止频率近似为:

$$ f_c \approx 364\ \text{cycles/mm} $$

MTF应在该频率附近趋近于零。

再次强调,在小型教学脚本中结果可能并不完美,但应在合理区间内。

如果你的MTF图在3.64线对/毫米或3640000线对/毫米处趋零,请怀疑有单位错误。

截止频率提供了一个强力的合理性检查:

艾里半径检验PSF轴。
衍射截止频率检验MTF轴。

这两项检查应当成为自动化的步骤。


10. 错误的MTF轴示例

我们故意用错误的PSF采样间隔来生成错误的MTF频率轴。

MTF数值相同,但频率标签却变了。

wrong_freq = mtf_frequency_axis(
    n=mtf.shape[0],
    dx_image=wrong_dx_image
)

plt.figure(figsize=(7, 4))
plt.plot(freq[center:], mtf_slice[center:], label="Correct axis")
plt.plot(wrong_freq[center:], mtf_slice[center:], "--", label="Wrong axis")
plt.xlabel("Spatial frequency [cycles/mm]")
plt.ylabel("MTF")
plt.ylim(0, 1.05)
plt.title("Same MTF values, different frequency labels")
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()

这正是那种可以逃过肉眼检查的错误。

曲线形状完全相同,只有x轴发生了变化。

但对于工程解读来说,这绝非小问题。它会改变以下问题的答案:

在50线对/毫米处的MTF是多少?
截止频率是多少?
它与传感器像素间距相比如何?

一个频率轴被错误标记的系统可能将一个性能孱弱的系统误判为强大,或者相反。

仅靠曲线形状是不够的。


11. FFT平移(shift):中心在哪?

FFT平移错误是另一个经典陷阱。

NumPy的FFT函数使用这样一种数组约定:在进行平移之前,零频率位于数组的起始位置。

为了显示和光学解读,我们通常希望将零频率或PSF峰值置于中心。

因此我们使用:

np.fft.fftshift(...)

有时还需:

np.fft.ifftshift(...)

由光瞳计算PSF的常见模式是:

field = np.fft.fftshift(
    np.fft.fft2(
        np.fft.ifftshift(pupil)
    )
)

由PSF计算OTF的常见模式是:

otf = np.fft.fftshift(
    np.fft.fft2(
        np.fft.ifftshift(psf)
    )
)

在变换过程中应保持中心化数组的中心化。

存在几种有效的约定,但混合使用不同约定是危险的。

一个实用的测试方法是:

peak_index = np.unravel_index(np.argmax(psf), psf.shape)
print("PSF peak index:", peak_index)
print("Expected center:", (psf.shape[0] // 2, psf.shape[1] // 2))

对于一个轴上理想光瞳,PSF的峰值应位于或非常接近中心。

如果它出现在角落,那意味着你用于显示的平移约定是错误的。

对于MTF:

otf_center = (otf.shape[0] // 2, otf.shape[1] // 2)
print("OTF center value:", otf[otf_center])
print("MTF center value:", mtf[otf_center])

归一化后,MTF的中心值应接近1。

这些检查很简单,请使用它们。


12. 奇数与偶数数组尺寸

偶数尺寸和奇数尺寸的数组在中心行为上略有不同。

对于一个 $N=1024$ 的偶数数组,fftshift 后的中心索引为:

N // 2

对于一个 $N=1025$ 的奇数数组,它也是:

N // 2

但中心周围的对称性略有不同。

这并不意味着奇数尺寸被禁止,而是意味着你应该保持一致性,并检查峰值和零频率出现的位置。

对于教学代码,2的幂次或偶数尺寸很方便:

256, 512, 1024

现代FFT库并不强制要求这些尺寸,但它们能使示例显得干净。

调试时,避免一次改变太多东西。不要同时更改:

  • 数组尺寸;
  • 补零因子;
  • 孔径直径;
  • 波长;
  • FFT平移约定。

一次只改一件事,观察发生了什么。

这样做速度虽慢,但行之有效。


13. 补零:是插值,而非增加物理信息

补零很有用,但常被误解。

当你在计算PSF之前对光瞳进行补零时,你增加了傅里叶域输出的样本点数。

显示的PSF变得更平滑。

但物理孔径并未改变,光学分辨率也未提高。

正确的表述是:

补零是对采样后的PSF进行插值。
它不会创造新的光学信息。

让我们比较不同的补零因子。

pad_factors = [1, 2, 4, 8]

plt.figure(figsize=(7, 4))

for pf in pad_factors:
    psf_pf, _ = compute_psf(pupil, pad_factor=pf)

    n_fft_pf = n_pupil * pf
    dx_pf = psf_sample_spacing_from_pupil(
        wavelength=wavelength,
        focal_length=focal_length,
        pupil_sample_spacing=pupil_sample_spacing,
        n_fft=n_fft_pf
    )

    axis_pf = image_coordinate_axis(psf_pf.shape[0], dx_pf) * 1e6
    center_pf = psf_pf.shape[0] // 2
    slice_pf = psf_pf[center_pf, :]

    plt.plot(axis_pf, slice_pf / np.max(slice_pf), label=f"pad {pf}")

plt.xlim(-15, 15)
plt.xlabel("Image coordinate [um]")
plt.ylabel("Normalized peak intensity")
plt.title("Zero padding changes display sampling")
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()

这些曲线应描述同一个衍射图样,但更高的补零因子给出了更平滑的采样轮廓。

如果补零显著改变了物理图像,那意味着还有其他地方出了问题。

一个有用的检查是:

当补零改变时,艾里斑半径不应改变。
横跨艾里斑的采样点数量应该改变。

这句话是一个很好的调试工具。


14. PSF归一化:按峰值还是按能量?

PSF的显示通常有两种归一化方式。

能量归一化

PSF总和等于1:

$$ \sum \mathrm{PSF} = 1 $$

代码:

psf_energy = psf / np.sum(psf)

这在比较能量分布时很有用。

峰值归一化

PSF峰值等于1:

$$ \max(\mathrm{PSF}) = 1 $$

代码:

psf_peak = psf / np.max(psf)

这在绘制剖面图或视觉上比较形状时很有用。

它们回答的问题不同。

能量归一化保留了总能量的可比性;峰值归一化使中心峰值在视觉上具有可比性。

对于斯特列尔比,你需要匹配的能量归一化和采样:

strehl = np.max(aberrated_psf_energy) / np.max(perfect_psf_energy)

如果一个PSF是峰值归一化,另一个是能量归一化,这个比值就毫无意义。

这是那种可以静悄悄地潜伏在笔记本中的错误。

一个实用的习惯是:

清晰地命名归一化后的数组:
psf_energy_normalized
psf_peak_normalized

不要把一切都命名为 psf


15. OTF归一化:除以零频分量

OTF是PSF的傅里叶变换。

OTF的零频值即为PSF的总能量。若PSF之和为1,平移后OTF的中心应为1。

但数值上的缩放和归一化选择仍可能有所变化。

因此我们通常这样做归一化:

center = (otf.shape[0] // 2, otf.shape[1] // 2)
otf = otf / otf[center]
mtf = np.abs(otf)

这使得:

零频率处的MTF = 1

一个基本检查:

print(mtf[center])

它应该非常接近:

1.0

如果不是,请检查:

  • PSF中心化;
  • OTF平移;
  • OTF中心索引;
  • PSF归一化;
  • 偶发的复数或负PSF值。

零频频点是你的锚点,切勿忽视。


16. 错误归一化示例

让我们创建两个PSF:理想的与有像差的。

def synthetic_opd(rho, theta, mask, wavelength):
    opd = np.zeros_like(rho)

    defocus = 0.50 * wavelength
    astigmatism = 0.25 * wavelength

    opd += defocus * (2.0 * rho**2 - 1.0)
    opd += astigmatism * rho**2 * np.cos(2.0 * theta)

    opd[~mask] = 0.0

    return opd


aberrated_opd = synthetic_opd(
    rho=rho,
    theta=theta,
    mask=mask,
    wavelength=wavelength
)

aberrated_pupil, _ = pupil_function_from_opd(
    opd=aberrated_opd,
    mask=mask,
    wavelength=wavelength
)

perfect_psf, _ = compute_psf(
    pupil,
    pad_factor=4,
    normalize=True
)

aberrated_psf, _ = compute_psf(
    aberrated_pupil,
    pad_factor=4,
    normalize=True
)

现在正确地计算斯特列尔比:

strehl_correct = np.max(aberrated_psf) / np.max(perfect_psf)
print("Correct Strehl:", strehl_correct)

现在,我们通过先将两者峰值归一化来进行一次错误比较:

perfect_peak_norm = perfect_psf / np.max(perfect_psf)
aberrated_peak_norm = aberrated_psf / np.max(aberrated_psf)

strehl_wrong = np.max(aberrated_peak_norm) / np.max(perfect_peak_norm)
print("Wrong Strehl after peak normalization:", strehl_wrong)

错误的结果将会是1。

这显然毫无用处——在计算峰值比之前,我们亲手抹去了峰值的差异。

这个例子虽然简单,但其教训具有普适性:

在决定了你要比较什么量之后再进行归一化。

而非在此之前。


17. 孔径掩膜:方形数组陷阱

一个方形的NumPy数组并非圆形孔径。

这种错误很容易犯:

pupil_wrong = np.ones((256, 256), dtype=complex)

这并不代表一个清晰的圆形透镜孔径,它代表的是一个方形孔径。

方形孔径的衍射图样和MTF均与圆形不同。

正确的圆形光瞳需要一个掩膜:

pupil_correct = mask.astype(complex)

让我们比较一下:

square_pupil = np.ones_like(pupil, dtype=complex)

square_psf, _ = compute_psf(square_pupil, pad_factor=4)
circular_psf, _ = compute_psf(pupil, pad_factor=4)

square_otf, square_mtf = compute_otf_mtf(square_psf)
circular_otf, circular_mtf = compute_otf_mtf(circular_psf)

center = circular_mtf.shape[0] // 2
freq = mtf_frequency_axis(circular_mtf.shape[0], dx_image)

plt.figure(figsize=(7, 4))
plt.plot(freq[center:], circular_mtf[center, center:], label="Circular pupil")
plt.plot(freq[center:], square_mtf[center, center:], "--", label="Square pupil")
plt.xlabel("Spatial frequency [cycles/mm]")
plt.ylabel("MTF")
plt.ylim(0, 1.05)
plt.title("Aperture shape changes MTF")
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()

这种差异并非数值上的偶然,而是物理上的必然。

孔径形状会影响衍射。

因此,在计算PSF之前,第一个检查就是:

这个光瞳数组代表的是我所认为的那个孔径吗?

在对其进行傅里叶变换之前,先绘制光瞳的振幅图。

每次都请这样做。


18. 端点采样:linspace 可能悄然改变你的网格

在之前的章节中,我们经常使用:

coord = np.linspace(-1.0, 1.0, n)

默认情况下,这会包含两个端点。

对于许多教学绘图来说,这是可以接受的。但对于FFT采样,同时包含 $-1$ 和 $1$ 会略显尴尬,因为这些端点代表了周期性采样区间的重复边界。

一个对FFT更友好的版本是:

coord = np.linspace(-1.0, 1.0, n, endpoint=False)

这会在不重复端点的情况下对区间进行采样。

使用默认设置会毁掉你的结果吗?对于初学者示例,通常不会。但它可能引起微小的非对称性和采样差异。

对于细致的FFT工作,推荐使用显式采样:

coord = (np.arange(n) - n / 2) / (n / 2)

或:

coord = np.linspace(-1.0, 1.0, n, endpoint=False)

更深层的规则是:

了解你的网格是否包含端点。
不要让默认的采样选择悄无声息地定义你的光学模型。

这也是为什么生产级光学软件拥有精细的内部采样约定的原因之一。


19. 单位:最枯燥也最强大的调试工具

单位并非文书装饰。

它们是计算的一部分。

以下是本书此部分最常见的单位配对:

OPD: 米 (meters)
wavelength: 米
phase: 弧度

pupil diameter: 米
focal length: 米
PSF coordinate: 米

PSF sample spacing: 毫米
MTF frequency: 线对/毫米

危险的情况是混合了隐藏单位:

wavelength = 550       # 本意是纳米
focal_length = 50      # 本意是毫米
pupil_diameter = 10    # 本意是毫米

这段代码可能可以运行,但它完全没有可靠的物理意义。

更安全的编写方式是:

wavelength = 550e-9
focal_length = 50e-3
pupil_diameter = 10e-3

然后仅在显示时进行转换:

print(dx_image * 1e6, "um")
print(freq / 1000.0, "cycles/mm")

一条简单的纪律很有帮助:

在计算内部使用国际单位制 (SI)。
仅在输入和输出的边界处转换单位。

这个习惯能预防大量错误。


20. 符号约定与镜像结果

在第11章中我们曾提及,光瞳相位的符号约定可以不同:

$$ P(x,y)=A(x,y)\exp\left(i\frac{2\pi W(x,y)}{\lambda}\right) $$

或者:

$$ P(x,y)=A(x,y)\exp\left(-i\frac{2\pi W(x,y)}{\lambda}\right) $$

改变符号可能会根据像差和傅里叶约定的不同,镜像或共轭部分场行为。

对于对称像差,强度PSF看起来可能相似。对于非对称像差(如彗差类相位),PSF的方向可能会翻转。

这意味着两种实现可能在视觉上不一致,但它们各自内部可能是一致的。

一个实用的对比检查清单:

相同的 OPD 定义
相同的相位符号
相同的光瞳坐标取向
相同的 FFT 约定
相同的显示取向

不要只对比最终的图像,要对中间数组进行比对:

  • OPD图;
  • 相位图;
  • 光瞳振幅;
  • 光瞳相位;
  • PSF中心;
  • MTF中心值。

当最终的PSF出现镜像时,错误更可能存在于符号约定或坐标取向上,而非衍射理论中。


21. 子午与弧矢方向:不要猜测

对于具有旋转对称性的轴上系统,水平和垂直方向的MTF剖面可能几乎相同。

对于轴外视场,子午和弧矢方向至关重要。

但它们并非在所有图像中都是简单的“x”和“y”。

它们的定义与以下因素相关:

  • 光轴;
  • 视场点;
  • 主光线或子午面。

因此,如下做法是草率的:

horizontal = tangential
vertical = sagittal

在特定的坐标设置下,这或许成立,但并非普遍成立。

更安全的表述是:

水平和垂直剖面是数组方向。
子午和弧矢剖面是光学视场方向。

要与Optiland或任何光学设计软件进行对比,应检查该软件如何为所选视场定义这些方向。

一个好的调试问题是:

我数组中哪个方向对应于视场的子午面?

没有这个答案,诸如“子午”和“弧矢”的标签就只是装饰,不可信赖。


22. 一个小型诊断工具箱

现在让我们收集几个能让调试更轻松的函数。

它们并不花哨,但很实用。

def check_array_center(name, array):
    """
    打印实值数组的峰值位置。
    对 PSF 和 MTF 检查非常有用。
    """
    peak = np.unravel_index(np.argmax(np.abs(array)), array.shape)
    center = (array.shape[0] // 2, array.shape[1] // 2)

    print(f"{name}")
    print("  shape: ", array.shape)
    print("  peak:  ", peak)
    print("  center:", center)
    print()


def check_psf_normalization(psf):
    """
    打印基本的 PSF 归一化信息。
    """
    print("PSF checks")
    print("  sum: ", np.sum(psf))
    print("  max: ", np.max(psf))
    print("  min: ", np.min(psf))
    print()


def check_mtf_normalization(mtf):
    """
    打印 MTF 的中心值。
    """
    center = (mtf.shape[0] // 2, mtf.shape[1] // 2)

    print("MTF checks")
    print("  center value:", mtf[center])
    print("  max value:   ", np.max(mtf))
    print("  min value:   ", np.min(mtf))
    print()

使用它们:

check_array_center("PSF", psf)
check_psf_normalization(psf)

otf, mtf = compute_otf_mtf(psf)
check_array_center("MTF", mtf)
check_mtf_normalization(mtf)

对于一个中心化的衍射受限PSF:

  • PSF峰值应靠近中心;
  • 若为能量归一化,PSF之和应为1;
  • MTF中心值应为1;
  • MTF最大值通常应接近1。

这些检查并不能证明一切正确,但它们能迅速捕捉到常见的失败。


23. 一个完整的修正后PSF/MTF工作流

这是一个将簿记工作保持显式的紧凑工作流。

import numpy as np
import matplotlib.pyplot as plt


def make_pupil_grid(n=256):
    coord = np.linspace(-1.0, 1.0, n, endpoint=False)
    x, y = np.meshgrid(coord, coord)

    rho = np.sqrt(x**2 + y**2)
    theta = np.arctan2(y, x)
    mask = rho <= 1.0

    return x, y, rho, theta, mask


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength
    pupil = amplitude * np.exp(1j * phase)
    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase


def pad_array_centered(array, pad_factor=4):
    if pad_factor == 1:
        return array

    n, m = array.shape
    new_n = n * pad_factor
    new_m = m * pad_factor

    padded = np.zeros((new_n, new_m), dtype=array.dtype)

    start_n = (new_n - n) // 2
    start_m = (new_m - m) // 2

    padded[start_n:start_n + n, start_m:start_m + m] = array

    return padded


def compute_psf(pupil, pad_factor=4):
    padded_pupil = pad_array_centered(pupil, pad_factor=pad_factor)

    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(padded_pupil)
        )
    )

    psf = np.abs(field) ** 2
    psf = psf / np.sum(psf)

    return psf, field


def compute_otf_mtf(psf):
    otf = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(psf)
        )
    )

    center = (otf.shape[0] // 2, otf.shape[1] // 2)
    otf = otf / otf[center]

    mtf = np.abs(otf)

    return otf, mtf


def psf_sample_spacing_from_pupil(
    wavelength,
    focal_length,
    pupil_sample_spacing,
    n_fft
):
    dtheta = wavelength / (n_fft * pupil_sample_spacing)
    return focal_length * dtheta


def image_coordinate_axis(n, dx):
    center = n // 2
    return (np.arange(n) - center) * dx


def mtf_frequency_axis(n, dx_image):
    freq_cyc_per_m = np.fft.fftshift(
        np.fft.fftfreq(n, d=dx_image)
    )
    return freq_cyc_per_m / 1000.0


# 物理参数,计算内部使用国际单位制 (SI)
wavelength = 550e-9
focal_length = 50e-3
pupil_diameter = 10e-3

# 采样参数
n_pupil = 256
pad_factor = 4
n_fft = n_pupil * pad_factor

# 构建光瞳
x, y, rho, theta, mask = make_pupil_grid(n=n_pupil)
opd = np.zeros_like(rho)

pupil, phase = pupil_function_from_opd(
    opd=opd,
    mask=mask,
    wavelength=wavelength
)

# 计算 PSF
psf, field = compute_psf(
    pupil=pupil,
    pad_factor=pad_factor
)

# 坐标簿记
pupil_sample_spacing = pupil_diameter / n_pupil

dx_image = psf_sample_spacing_from_pupil(
    wavelength=wavelength,
    focal_length=focal_length,
    pupil_sample_spacing=pupil_sample_spacing,
    n_fft=n_fft
)

image_axis_um = image_coordinate_axis(psf.shape[0], dx_image) * 1e6

# 计算 MTF
otf, mtf = compute_otf_mtf(psf)
freq_cyc_per_mm = mtf_frequency_axis(mtf.shape[0], dx_image)

# 合理性检查
f_number = focal_length / pupil_diameter
airy_radius_um = 1.22 * wavelength * f_number * 1e6
cutoff_cyc_per_mm = 1.0 / ((wavelength * 1e3) * f_number)

print("F-number:", f_number)
print("Airy first dark radius [um]:", airy_radius_um)
print("PSF sample spacing [um]:", dx_image * 1e6)
print("Diffraction cutoff [cycles/mm]:", cutoff_cyc_per_mm)

# 绘制 PSF
extent_um = [
    image_axis_um[0],
    image_axis_um[-1],
    image_axis_um[0],
    image_axis_um[-1],
]

plt.figure(figsize=(5, 4))
plt.imshow(psf, origin="lower", extent=extent_um)
plt.colorbar(label="Normalized intensity")
plt.xlabel("Image x [um]")
plt.ylabel("Image y [um]")
plt.title("PSF with physical coordinates")
plt.tight_layout()
plt.show()

# 绘制 MTF 剖面
center = mtf.shape[0] // 2

plt.figure(figsize=(6, 4))
plt.plot(freq_cyc_per_mm[center:], mtf[center, center:])
plt.xlabel("Spatial frequency [cycles/mm]")
plt.ylabel("MTF")
plt.ylim(0, 1.05)
plt.title("MTF with physical frequency axis")
plt.grid(True)
plt.tight_layout()
plt.show()

这并非可能的最短代码。

这是刻意为之。

此版本更青睐于可追溯性而非紧凑性。

每一个物理参数都清晰可见。每一个采样步骤都明确写出。每一个输出坐标轴都源自已知量。

这种代码更容易让人信赖。


24. Optiland 对比:在指责库之前要检查什么

将本教学代码与Optiland进行对比时,不要期待能偶然匹配。

一个真正的光学设计库可能在以下方面包含更精细的处理:

  • 出瞳几何;
  • 参考球面;
  • 视场相关的光瞳映射;
  • 波长权重;
  • 像空间折射率;
  • 焦点位置;
  • 渐晕;
  • 偏振;
  • 采样策略;
  • 内部归一化;
  • 子午/弧矢定义。

因此,不匹配并不自动意味着是错误。

在指责库或你的代码之前,请检查:

1. 同一光学系统?
2. 同一视场点?
3. 同一波长?
4. 同一孔径光阑和光瞳直径?
5. 同一焦平面?
6. 同一 OPD 参考?
7. 同一光瞳采样?
8. 同一 FFT 补零?
9. 同一 PSF 归一化?
10. 同一 MTF 频率单位?
11. 同一子午/弧矢定义?
12. 同一显示比例?

一个用于验证的对比工作流大致如下:

# 版本检查的工作流程概要。
# 检查你安装的 Optiland 版本以获取准确的 API 名称。

# 1. 构建或加载光学系统。
# optic = ...

# 2. 设置波长、视场点、孔径和焦点。
# wavelength = ...
# field = ...

# 3. 使用 Optiland 的分析工具生成 PSF 和 MTF。
# psf_optiland = ...
# mtf_optiland = ...

# 4. 如果可能,提取或重建可比较的数组。
# psf_array = ...
# dx_image = ...

# 5. 从采样后的 PSF 重新计算 OTF/MTF。
# otf_numpy, mtf_numpy = compute_otf_mtf(psf_array)

# 6. 比较:
# - PSF 峰值位置
# - PSF 总和
# - 零频率处的 MTF
# - 频率轴
# - 截止频率
# - 子午/弧矢方向定义

这里教育性的目的,并非要证明你简短的脚本比Optiland更正确——那是不可能的。

其目的是理解,在两种计算能够相互比较之前,哪些方面必须对齐。

这是一种非常具有实践意义的理解。


25. 可疑PSF的调试序列

当一个PSF看起来不对劲时,不要随意改动代码。

请遵循一个序列。

步骤1:检查光瞳振幅

绘制:

plt.imshow(np.abs(pupil), origin="lower")
plt.colorbar(label="Amplitude")
plt.title("Pupil amplitude")
plt.show()

追问:

孔径形状正确吗?
中心遮拦是按预期包含还是排除了?
孔径之外是否有非零值?

步骤2:检查光瞳相位

绘制:

plt.imshow(np.angle(pupil), origin="lower")
plt.colorbar(label="Phase [rad]")
plt.title("Wrapped pupil phase")
plt.show()

追问:

相位图案是否与预期的 OPD 匹配?
相位包裹是否在预期之内?
单位是否一致?

步骤3:检查PSF中心化

check_array_center("PSF", psf)

追问:

对于轴上点,峰值是否在中心附近?

步骤4:检查PSF能量

check_psf_normalization(psf)

追问:

归一化后,PSF 总和是否等于 1?

步骤5:检查物理尺度

计算:

airy_radius = 1.22 * wavelength * f_number

追问:

艾里斑的尺度是否合理?

这个序列能捕捉到大多数初学者的错误。


26. 可疑MTF的调试序列

当MTF曲线看起来不对劲时,请使用另一个序列。

步骤1:首先检查PSF

不要还没检查PSF就去调试MTF。

一个错误的PSF不可能产生正确的MTF。

步骤2:检查OTF中心

center = (otf.shape[0] // 2, otf.shape[1] // 2)
print(otf[center])

归一化后,中心应该接近:

1 + 0j

步骤3:检查MTF中心

print(mtf[center])

它应该接近:

1

步骤4:检查频率轴

计算衍射截止频率:

cutoff_cyc_per_mm = 1.0 / ((wavelength * 1e3) * f_number)

追问:

MTF 是否在预期截止频率附近趋于零?

步骤5:检查方向标签

追问:

这些是数组切片,还是真正的子午/弧矢切片?

步骤6:检查显示范围

确保图像没有隐藏负值、被裁剪或超出范围的值。

对于 MTF:

plt.ylim(0, 1.05)

通常对初步检查很有用。


27. 最常见的失败模式

以下是一张关于症状及其可能原因的紧凑表格。

症状可能的原因
PSF峰值出现在角落缺少或不一致的 fftshift / ifftshift
PSF看起来像方形孔径的衍射图样忘记了圆形孔径掩膜
PSF的物理尺寸完全错误光瞳直径、焦距、波长或补零在坐标计算中出错
MTF不从1开始OTF未对零频分量归一化,中心索引错误,或PSF本身有问题
MTF截止频率与预期值差距很大PSF采样间隔错误或单位转换错误
每种情况下的斯特列尔比都是1PSF在计算斯特列尔比之前已被峰值归一化
子午和弧矢标签与软件不一致方向定义与视场几何不匹配
对数PSF看起来效果显著,但线性PSF看起来正常显示尺度差异,不一定是物理灾难
改变补零似乎改变了光学分辨率误解了补零或坐标轴缩放
有像差的PSF与参考相比呈镜像符号约定或坐标取向不匹配

这张表并不能代替理解,它是一个分流工具。

当出现问题时,它告诉了你首先应该去检查哪些地方。


28. 为什么本章要放在优化前面

人们或许会忍不住从MTF直接跳到优化。

毕竟,一旦我们能计算MTF,为什么不立刻开始改进它呢?

因为优化会放大错误。

如果PSF的尺度错误,优化器可能去改进一个错误的指标。

如果MTF频率轴错误,优化器可能去青睐错误的空间频率。

如果归一化不一致,优化器可能通过改变能量尺度而非图像质量来表面上提高性能。

如果子午和弧矢方向被标错,优化器可能修正的是错误的方向行为。

优化并不会让一个糟糕的指标变得诚实。

它只会忠实地遵循你给它的那个指标。

这就是本章坐落于此的原因。

在要求软件去改善一个镜头之前,我们需要清楚我们的数字意味着什么。

现在,这条链路应该带着安全措施来解读:

光瞳函数
→ 经过检查的振幅与相位
→ 具有已知采样和归一化的 PSF
→ 在零频处归一化的 OTF
→ 具有正确频率轴和方向标签的 MTF

只有如此,才适宜在评价函数中使用这些量。


29. 可实践的检查清单

每当你计算PSF或MTF时,都使用这份检查清单。

光瞳检查

[ ] 孔径掩膜正确吗?
[ ] 振幅场是振幅而非强度吗?
[ ] OPD 是否与波长使用相同的长度单位?
[ ] 相位符号约定是否已知?
[ ] 光瞳坐标取向是否已知?
[ ] 光瞳是否采样得足够密集?

PSF检查

[ ] FFT平移约定是否一致?
[ ] 对于轴上点,PSF峰值是否居中?
[ ] 需要时是否进行了PSF能量归一化?
[ ] 补零是否被理解为插值而非更好的光学?
[ ] PSF坐标轴是否由光瞳采样、波长和焦距推导得出?
[ ] 对于一个完美的圆形光瞳,艾里斑半径看起来合理吗?

MTF检查

[ ] OTF 是否从 PSF 计算,而非直接从 OPD 或光瞳计算?
[ ] OTF 是否由其零频值归一化?
[ ] MTF 是否从 1 开始?
[ ] 频率轴是否使用了预期的单位?
[ ] 衍射截止频率看起来合理吗?
[ ] 子午和弧矢方向被正确定义了吗?

对比检查

[ ] 相同波长?
[ ] 相同视场点?
[ ] 相同焦平面?
[ ] 相同孔径和光瞳定义?
[ ] 相同采样密度?
[ ] 相同归一化?
[ ] 相同显示比例?
[ ] 相同坐标取向?

这份检查清单看起来或许平淡无奇。但在实际工作中,平淡的清单能节省数小时的时间。


30. 本章为完整链路增添了什么

前面的章节给了我们这些公式:

$$ P(x,y)=A(x,y)\exp\left(i\frac{2\pi W(x,y)}{\lambda}\right) $$

$$ \mathrm{PSF}=|\mathcal{F}{P(x,y)}|^2 $$

$$ \mathrm{OTF}=\mathcal{F}{\mathrm{PSF}} $$

$$ \mathrm{MTF}=|\mathrm{OTF}| $$

本章增添了使这些公式在代码中有意义的条件。

它告诉我们,每一次变换都需要采样,每一张图都需要单位,每一次比较都需要归一化。

因此,真正的计算链路不仅仅是:

OPD
→ pupil
→ PSF
→ MTF

它更接近于:

具有正确单位的 OPD
→ 具有正确掩膜和相位约定的 pupil
→ 具有正确 FFT 平移、采样和归一化的 PSF
→ 在零频处归一化的 OTF
→ 具有正确频率轴和方向标签的 MTF

这个更长的版本不那么优雅。

但它也更接近真相。

下一章将进入优化。我们终于要问,光学软件在“改进”一个设计时究竟在做什么。

但现在,我们可以安全地提出这个问题。

优化并非魔法,它是由评价函数引导的重复计算。

经过本章,我们明白了一件重要的事情:

在优化一个光学指标之前,先确保这个指标的含义你已了然于胸。

本章小结

基于FFT的PSF和MTF计算功能强大,但对采样、单位、中心化和归一化非常敏感。

DFT的采样关系为:

$$ \Delta f = \frac{1}{N\Delta x} $$

由物理光瞳计算PSF时,近似的焦平面采样间隔为:

$$ \Delta X = \frac{F\lambda}{N\Delta u} $$

其中 $F$ 是焦距,$\lambda$ 是波长,$N$ 是补零后的FFT尺寸,$\Delta u$ 是光瞳平面的采样间隔。

对于MTF,频率轴由PSF采样间隔得到:

freq = np.fft.fftshift(np.fft.fftfreq(n, d=dx_image))

PSF应通过艾里斑半径进行检验:

$$ r_\text{Airy}=1.22\lambda N $$

MTF应通过衍射截止频率进行检验:

$$ f_c=\frac{1}{\lambda N} $$

其中 $N$ 是F数。

补零能改善显示的采样,但并不会提高物理光学分辨率。PSF能量归一化、PSF峰值归一化以及OTF零频归一化服务于不同的目的,不应随意混用。

最重要的实践习惯是检查中间结果:

光瞳振幅
光瞳相位
PSF中心与能量
OTF中心值
MTF频率轴

只有当采样和单位都值得信赖时,图像才值得信赖。

生成的验证图像

根据第14章采样检查生成的内边距和采样对比图

第15章:评价函数:软件实际在优化什么

优化是光学设计软件最能让人感到神奇的地方。

你修改几个设置,选一些变量,点击优化按钮。系统更新。点列图改善,MTF上升。镜头看起来比之前更“设计”了。

这是一种强大的体验。

但这也很容易被误解。

优化并不意味着软件像人一样知道什么是好的镜头。除非你将你的关心用数值表达出来,否则软件不知道你关心什么。它不知道镜头应该可制造、可负担、紧凑、明亮、稳定、优雅或适合特定传感器,除非这些要求出现在模型、约束或评价目标中。

在数值层面,优化通常做的事简单得多:

change variables
→ evaluate a merit function
→ try to make that merit function smaller

就是这样。

难点不在于这句话,难点在于决定评价函数应该衡量什么。

一个糟糕的评价函数可以用完全错误的方式让光学系统变得“更好”。

一个好的评价函数不会取代光学判断,而是将光学判断足够谨慎地编码成数字,让优化器能够处理它。

本章揭开这个计算过程。


1. 优化不是“自动改进”

“优化”这个词让人安心。它暗示软件正在将设计推向某种客观的优秀。

但优化器并不优化“优秀”。

它优化一个定义好的量。

如果这个量是RMS光斑半径,优化器可能会减小RMS光斑半径。

如果是波前RMS,它就会减小波前RMS。

如果是在选定频率和视场点上的MTF误差,它就会改善这些选定的MTF值。

如果这个量忽略了畸变,优化器可能会产生畸变很差的设计。

如果忽略了透镜厚度,优化器可能产生不切实际的形状。

如果忽略了制造公差,优化器可能找到一个只在纸面上可行的脆弱设计。

这是本章最重要的一句话:

优化器跟随评价函数,而不是你的意图。

你的意图必须被翻译成变量、操作数、目标、权重、约束和边界。

这个翻译就是光学设计工作。

软件按钮执行的,是已经编码在评价函数里的判断。


2. 什么是评价函数

评价函数是为设计打分的单一数字。

通常值越低越好。

一个常见的最小二乘形式如下:

M(x)=iwifi(x)2 M(\mathbf{x}) = \sum_i w_i f_i(\mathbf{x})^2

其中:

  • x\mathbf{x} 是设计变量向量;
  • fi(x)f_i(\mathbf{x}) 是第 ii 个评价项误差;
  • wiw_i 是该评价项的权重;
  • M(x)M(\mathbf{x}) 是总评价函数值。

函数 fif_i 通常是一个操作数误差:

fi(x)=qi(x)ti f_i(\mathbf{x}) = q_i(\mathbf{x}) - t_i

其中:

  • qi(x)q_i(\mathbf{x}) 是计算得到的光学量;
  • tit_i 是目标值。

例如:

计算焦距 - 目标焦距
计算RMS光斑半径 - 目标RMS光斑半径
计算畸变 - 目标畸变
计算50线对/毫米处的MTF - 目标MTF

优化器不需要知道这些量的情感含义。它只需要评估 MM,然后寻找能减小 MM 的变量 x\mathbf{x}

所以评价函数是一个翻译层:

光学设计目标
→ 数值误差
→ 加权和
→ 一个标量分数

优化器看到的就是这个标量分数。


3. 变量、操作数、目标、权重、边界

在写代码之前,我们需要一些术语。

变量

变量是优化器允许改变的设计参数。

例子:

  • 面半径;
  • 厚度;
  • 空气间隔;
  • 玻璃选择;
  • 非球面系数;
  • 光阑位置;
  • 像面位置;
  • 透镜元件间隔。

如果一个参数不是变量,优化器就不能改变它。

这听起来显而易见,但很重要。如果焦点位置固定,优化器就无法重新对焦。如果玻璃固定,它就无法通过选择其他材料来减少色差。如果曲率固定,它就不能以不同方式弯曲透镜。

优化器的能力受限于你给出的变量。

操作数

操作数是评价函数中使用的测量量。

例子:

  • 有效焦距;
  • RMS光斑半径;
  • 光线交点误差;
  • OPD RMS;
  • 畸变;
  • 主光线角度;
  • 在选定频率处的MTF;
  • 总长;
  • 最小边缘厚度。

一个操作数本身不一定是设计目标。当它与目标值和权重配对时,就成为目标的一部分。

目标

目标是操作数的期望值。

例子:

EFL应为50毫米
畸变应为0
RMS光斑应尽可能小
50线对/毫米处的MTF应尽可能高
透镜厚度应保持在1毫米以上

有些目标是精确的,有些是软性的偏好。

权重

权重告诉优化器每个操作数有多重要。

如果一个操作数权重很大,优化器会尽力满足它,甚至不惜牺牲其他操作数。

如果一个操作数权重极小,优化器可能很大程度上忽略它。

权重不仅仅是技术设置,它们是用数字书写的设计优先级。

边界

边界定义允许的范围。

例子:

半径必须保持在-200毫米到200毫米之间
厚度必须保持在1毫米到20毫米之间
像面位移必须保持在-2毫米到2毫米之间

边界防止优化器逃向荒谬或不可能的设计。

没有边界,优化器可能找到一个数学上方便、但没有人能制造、装配甚至解释的解。


4. 一个微小的评价函数

我们从可能最简单的评价函数开始。

假设我们想让数字 xx 变成3。

误差是:

f(x)=x3 f(x)=x-3

评价函数是:

M(x)=(x3)2 M(x)=(x-3)^2

代码:

def merit_scalar(x):
    return (x - 3.0) ** 2

最小值出现在:

x=3 x=3

因为此时:

M(3)=0 M(3)=0

这还不是光学设计,但展示了形式。

现在假设我们想要两件事:

  • xx 应为3;
  • yy 应为10。

那么:

M(x,y)=w1(x3)2+w2(y10)2 M(x,y)=w_1(x-3)^2+w_2(y-10)^2

代码:

def merit_two_variables(v):
    x, y = v

    w1 = 1.0
    w2 = 1.0

    error_x = x - 3.0
    error_y = y - 10.0

    return w1 * error_x**2 + w2 * error_y**2

如果增大 w2,优化器会更在意匹配 y=10y=10

这已经是许多光学评价函数的基本结构。唯一的区别在于,真实操作数通常不是简单的变量,它们是通过光线追迹、波前分析、PSF、MTF、几何关系及约束计算得到的。


5. 一个简单的优化器循环

在使用SciPy之前,让我们手工编写最基本的优化器思路。

我们会尝试不同的值并保留最好的。

import numpy as np


def merit_scalar(x):
    return (x - 3.0) ** 2


candidate_values = np.linspace(-10.0, 10.0, 401)

best_x = None
best_m = np.inf

for x in candidate_values:
    m = merit_scalar(x)

    if m < best_m:
        best_m = m
        best_x = x

print("Best x:", best_x)
print("Best merit:", best_m)

这种暴力搜索对于高维问题很慢,但它很诚实。它从概念上展示了优化在做什么:

尝试变量值
计算评价函数
保留较低的评价函数

真正的优化器更高效,它智能地选择下一个尝试值。但它仍然是在尝试减小一个标量评价函数。

记住这一点很重要,它可以防止我们将优化视为某种神秘的智能。


6. 使用SciPy进行最小化

对于实际的Python示例,我们可以使用 scipy.optimize.minimize

import numpy as np
from scipy.optimize import minimize


def merit_two_variables(v):
    x, y = v

    w1 = 1.0
    w2 = 1.0

    error_x = x - 3.0
    error_y = y - 10.0

    return w1 * error_x**2 + w2 * error_y**2


initial_guess = np.array([0.0, 0.0])

result = minimize(
    merit_two_variables,
    initial_guess,
    method="Nelder-Mead"
)

print(result.x)
print(result.fun)

结果应该接近:

x = 3
y = 10

这仍然是一个玩具示例。但现在我们有了与真实设计脚本相同的外层结构:

定义变量
定义评价函数
选择初始猜测
调用优化器
检查结果

光学设计只是在评价函数内部增加了更昂贵的计算。


7. 优化焦点位置

现在让我们让示例光学化。

我们将优化像面位置。

这是光学中最容易理解的优化问题之一:

给定经过镜头后的光线束,
像面应该放在哪里以最小化光斑尺寸?

我们将使用一个简化的光线模型。

想象光线在 z=0z=0 处离开镜头。每条光线有:

  • 高度 yy
  • 斜率 uu

在距离 zz 处的像面上,光线高度是:

yimage=y+zu y_\text{image}=y+zu

如果所有光线交于同一点,光斑就小。

如果它们落在不同高度,光斑就大。

让我们生成一个具有类似球差行为的光线束。边缘光线将相对近轴光线略有不同的聚焦。

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize


def generate_teaching_rays(n_rays=41, pupil_radius=1.0, f0=50.0, spherical=4.0):
    """
    Generate a 1D teaching ray bundle after a lens.

    Parameters
    ----------
    n_rays : int
        Number of rays across the pupil.
    pupil_radius : float
        Maximum ray height at the lens.
    f0 : float
        Base paraxial focal distance.
    spherical : float
        Amount of pupil-dependent focal shift.

    Returns
    -------
    y : 1D array
        Ray heights at z=0.
    u : 1D array
        Ray slopes after the lens.
    """
    y = np.linspace(-pupil_radius, pupil_radius, n_rays)

    # A simple pupil-dependent focus distance.
    # Marginal rays focus at a slightly different distance.
    focus_distance = f0 + spherical * y**2

    # Slope chosen so each ray would cross y=0 at its own focus distance.
    u = -y / focus_distance

    return y, u

这不是一个完整的镜头模型,而是一个受控的教学模型。

它给出的光线并不都严格共享同一个焦点。

现在定义在候选像面上的光线交点:

def ray_heights_at_plane(y, u, z_image):
    return y + z_image * u

在这个一维示例中定义RMS光斑半径:

RMS=1Njyj2 \mathrm{RMS}=\sqrt{\frac{1}{N}\sum_j y_j^2}

def rms_spot_1d(y_image):
    return np.sqrt(np.mean(y_image**2))

评价函数可以是RMS光斑的平方:

def focus_merit(v, y, u):
    """
    Merit function for focus optimization.

    v[0] is the image plane position.
    """
    z_image = v[0]

    y_image = ray_heights_at_plane(y, u, z_image)

    rms = rms_spot_1d(y_image)

    return rms**2

现在运行优化:

y, u = generate_teaching_rays(
    n_rays=41,
    pupil_radius=1.0,
    f0=50.0,
    spherical=4.0
)

initial_guess = np.array([50.0])

result = minimize(
    focus_merit,
    initial_guess,
    args=(y, u),
    method="Nelder-Mead"
)

best_focus = result.x[0]

print("Best focus position:", best_focus)
print("Best merit:", result.fun)

最佳焦点将靠近基础焦距,但并不完全等于每条光线的焦点。因为不同光瞳区域偏好不同的焦平面,优化器选择一个折衷方案。

这已经是一个真实的光学设计概念:

最佳焦点往往是一个折衷,而不是每条光线都变为完美的地方。

这句话很重要。优化常常是平衡相互竞争误差的过程。


8. 绘制评价函数与焦点位置的关系

查看评价函数景观图很有帮助。

z_values = np.linspace(45.0, 58.0, 300)
merit_values = []

for z in z_values:
    merit_values.append(focus_merit([z], y, u))

merit_values = np.array(merit_values)

plt.figure(figsize=(6, 4))
plt.plot(z_values, merit_values)
plt.axvline(best_focus, linestyle="--", label="Optimized focus")
plt.xlabel("Image plane position z")
plt.ylabel("Merit: RMS spot squared")
plt.title("Focus merit function")
plt.grid(True)
plt.legend()
plt.tight_layout()
plt.show()

这张图很有用,因为它消除了神秘感。

优化器不是在“思考焦点”。它是沿着一条曲线移动,寻找更低的值。

对于这个单变量情况,评价函数景观很简单。真实的光学设计问题通常不简单:

  • 多个变量;
  • 多个视场;
  • 多个波长;
  • 多个表面;
  • 非线性光线追迹;
  • 约束;
  • 局部最小值;
  • 性能与可制造性之间的权衡。

但基本思想相同。

优化器搜索一个评价函数景观。
景观由你的光学模型和评价函数创建。

9. 优化前后光斑图

让我们比较一个猜测的焦点和优化后的焦点。

def plot_ray_intercepts(y, u, z_before, z_after):
    y_before = ray_heights_at_plane(y, u, z_before)
    y_after = ray_heights_at_plane(y, u, z_after)

    ray_index = np.arange(len(y))

    plt.figure(figsize=(7, 4))
    plt.plot(ray_index, y_before, "o", label=f"Before: z={z_before:.2f}")
    plt.plot(ray_index, y_after, "o", label=f"After: z={z_after:.2f}")
    plt.axhline(0.0, linewidth=1)
    plt.xlabel("Ray index")
    plt.ylabel("Ray height at image plane")
    plt.title("Ray intercepts before and after focus optimization")
    plt.grid(True)
    plt.legend()
    plt.tight_layout()
    plt.show()


plot_ray_intercepts(
    y=y,
    u=u,
    z_before=50.0,
    z_after=best_focus
)

优化后的焦点应减小光线交点的散布。

但请注意它不能做什么。如果光线束包含类似球差的行为,它不能使所有光线都完美。移动像面只改变一个变量,它不能消除底层的像差。

这是另一个重要教训:

优化器不能使用对误差无能为力的变量来修复该误差。

如果只能移动像面,优化器只能重新调焦。它不能重新设计镜头。

要减少球差,可能需要曲率变化、非球面项、更多元件、孔径变化、玻璃变化或其他变量。

变量选择决定了优化器能做什么。


10. 添加权重:近轴光线与边缘光线

在光学设计中,并非每条光线或每个操作数都需要相同的权重。

假设我们更关心中心光瞳光线而非边缘光线。或者我们更关心边缘光线,因为它们揭示与孔径相关的像差。

权重改变折衷。

让我们添加光线权重。

def weighted_rms_spot_1d(y_image, weights):
    weights = np.asarray(weights)
    weights = weights / np.sum(weights)

    return np.sqrt(np.sum(weights * y_image**2))


def focus_merit_weighted(v, y, u, weights):
    z_image = v[0]
    y_image = ray_heights_at_plane(y, u, z_image)

    rms = weighted_rms_spot_1d(y_image, weights)

    return rms**2

现在创建强调边缘光线的权重:

pupil_coordinate = y / np.max(np.abs(y))
marginal_weights = 1.0 + 4.0 * pupil_coordinate**2

result_marginal = minimize(
    focus_merit_weighted,
    initial_guess,
    args=(y, u, marginal_weights),
    method="Nelder-Mead"
)

best_focus_marginal = result_marginal.x[0]

print("Best focus, equal weights:", best_focus)
print("Best focus, marginal weighted:", best_focus_marginal)

最佳焦点可能发生偏移。

这不是错误,而是设计优先级变得可见。

权重不是中立的。

它们告诉优化器该关心什么。

这就是为什么在不理解的情况下复制别人的评价函数可能很有风险。你可能会同时复制别人的优先级。


11. 优化薄透镜曲率

现在让我们优化更接近透镜设计的东西。

我们将使用空气中的薄透镜公式:

Φ=(n1)(c1c2) \Phi = (n - 1)(c_1 - c_2)

其中:

  • Φ\Phi 是光焦度;
  • nn 是折射率;
  • c1=1/R1c_1 = 1/R_1 是第一面曲率;
  • c2=1/R2c_2 = 1/R_2 是第二面曲率。

有效焦距近似为:

f=1Φ f = \frac{1}{\Phi}

这是一个近轴薄透镜模型,并非真正的厚透镜优化。它有用之处在于变量-操作数-目标的结构可见。

假设我们想要焦距为50毫米,玻璃指数 n=1.5n=1.5

我们可以优化 c1c_1c2c_2

但有无穷多种曲率对可以产生相同的焦度。例如,很多 (c1c2)(c_1-c_2) 组合能给出相同焦距。

所以我们添加一个弯曲偏好:保持曲率适中。

这就创建了一个有两个目标的评价函数:

  1. 匹配目标焦距;
  2. 避免不必要的强曲率。
import numpy as np
from scipy.optimize import minimize


def thin_lens_efl(c1, c2, n=1.5):
    """
    Thin lens effective focal length in air.

    Curvatures are in 1/mm.
    Focal length is in mm.
    """
    power = (n - 1.0) * (c1 - c2)

    if np.abs(power) < 1e-12:
        return np.inf

    return 1.0 / power


def curvature_merit(v, target_efl=50.0, n=1.5):
    """
    Merit function for a toy thin-lens curvature optimization.

    Variables
    ---------
    v[0] : c1, first surface curvature [1/mm]
    v[1] : c2, second surface curvature [1/mm]
    """
    c1, c2 = v

    efl = thin_lens_efl(c1, c2, n=n)

    # Operand 1: focal length error
    efl_error = (efl - target_efl) / target_efl

    # Operand 2: curvature penalty
    # This discourages extremely curved surfaces.
    curvature_penalty = c1**2 + c2**2

    w_efl = 100.0
    w_bend = 1.0

    merit = w_efl * efl_error**2 + w_bend * curvature_penalty

    return merit

现在用边界优化:

initial_curvatures = np.array([0.02, -0.02])

bounds = [
    (-0.1, 0.1),  # c1
    (-0.1, 0.1),  # c2
]

result = minimize(
    curvature_merit,
    initial_curvatures,
    method="L-BFGS-B",
    bounds=bounds
)

c1_opt, c2_opt = result.x
efl_opt = thin_lens_efl(c1_opt, c2_opt, n=1.5)

print("Optimized c1:", c1_opt)
print("Optimized c2:", c2_opt)
print("Optimized EFL:", efl_opt)
print("Final merit:", result.fun)

这个例子刻意简化了,它不能取代真实的透镜优化。

但它展示了一个核心设计事实:

如果评价函数只要求焦距,
许多设计可能都满足。
额外的操作数会塑造优化器更偏好哪种解。

在真实的光学设计中,评价函数可能包括光斑尺寸、波前误差、畸变、厚度、远心度、主光线角度、MTF、玻璃约束和可制造性限制。

这些操作数不是装饰,它们定义了设计。


12. 硬约束与惩罚

有两种常见的方式处理设计要求。

硬约束

硬约束表示:

该值必须保持在允许的范围内。

例如:

bounds = [
    (-0.1, 0.1),
    (-0.1, 0.1),
]

优化器不允许跳出这些边界。

这对以下变量很有用:

  • 曲率限制;
  • 厚度限制;
  • 空气间隔限制;
  • 调焦行程限制;
  • 孔径约束。

软惩罚

软惩罚在某个量变得不理想时增加代价。

例如:

curvature_penalty = c1**2 + c2**2

这并不禁止曲率,只是不鼓励大曲率。

另一个软惩罚可能是:

thickness_penalty = max(0.0, minimum_allowed - thickness)**2

这会惩罚低于最小值的厚度。

硬约束和软惩罚服务于不同目的。

硬约束定义搜索区域。软惩罚在该区域内表达偏好。

一个成熟的评价函数通常同时使用两者。


13. 评价函数是一种设计论述

人们很容易把评价函数看作一个技术文件。

它不止于此。

评价函数是一个关于什么重要的论述。

如果你给轴上性能的权重重、轴外性能的权重轻,你是在说视场中心更重要。

如果你只优化一个波长,你是在说色差校正不属于这次优化。

如果你目标MTF在10线对/毫米和30线对/毫米,但不管80线对/毫米,你是在说某些细节尺度比其他更重要。

如果你处罚总长,你是在说紧凑性重要。

如果你处罚强曲率,你是在说可制造性或灵敏度重要。

优化器不会质疑这些选择。

它全盘接受。

所以当优化后的设计让你意外时,先问自己:

我实际上让优化器去改善什么?

而不是:

为什么优化器不理解我?

它足够精准地理解了评价函数,并照着执行。

可能是评价函数没有理解你。


14. 局部最小值与起点

许多光学评价函数是非线性的。

这意味着景观中可能存在多个山谷。

优化器可能找到一个附近的局部最小值,而不是全局最佳设计。

初始设计很关键。

这就是为什么光学设计通常从一个合理的起始形式开始:

  • 已知的镜头类型;
  • 近轴布局;
  • 缩比后的现有设计;
  • 大致已经工作的简单设计;
  • 设计者选择的结构。

优化功能强大,但不能替代选择一个合理的起始结构。

一个糟糕的起点可能导致:

  • 差的局部最小值;
  • 不可能实现的几何形状;
  • 光线追迹失败;
  • 极端的曲率;
  • 不可制造的面形;
  • 不稳定的设计;
  • 浪费计算。

一句有用的实用语句:

优化在你给它的设计空间内改进设计。
它不会自动发明正确的设计空间。

这就是为什么第16章将使用库克三片式镜头,而不是要求优化器从零创造镜头。

库克三片式已经是一个有意义的架构,优化可以在此基础上精炼。


15. 评价函数可能自我冲突

真实的评价函数包含许多操作数。

这些操作数可能相互冲突。

例如:

  • 改善边缘视场MTF可能损害中心性能;
  • 减小畸变可能增加场曲;
  • 减小球差可能增加彗差;
  • 让镜头更快可能恶化像差;
  • 减小总长可能增加面曲率;
  • 改善一个波长可能损害另一个波长。

优化器不会通过智慧解决冲突。它通过权重和可用变量解决冲突。

如果两个目标互相斗争,优化器会根据评价函数找到一个折衷。

那个折衷可能是也可能不是你想要的。

这就是为什么有经验的光学设计师不会简单地按一次优化就接受结果。他们会检查设计,调整评价函数,更改变量,添加约束,有时改变架构,然后再次优化。

这个循环是人与数值交互的:

定义目标
→ 优化
→ 检查结果
→ 修订目标或变量
→ 再次优化

那个循环不是自动化失败。它是数值优化成为设计工作的方法。


16. 评价函数的尺度很重要

假设一个操作数以毫米为单位,另一个以波长为单位。

一个误差可能自然在:

0.001

附近,而另一个可能在:

10

附近。

如果你平方两者再未缩放相加,数值较大的量可能占主导地位,即使它在光学上并非更重要。

这就是为什么操作数常常被归一化。

例如:

fEFL=EFLEFLtargetEFLtarget f_\text{EFL} = \frac{\mathrm{EFL} - \mathrm{EFL}\text{target}}{\mathrm{EFL}\text{target}}

这将以相对误差表示焦距误差。

对于光斑尺寸,你可能会除以一个允许值:

fspot=RMS spotallowed RMS f_\text{spot} = \frac{\mathrm{RMS\ spot}}{\mathrm{allowed\ RMS}}

对于畸变:

fdistortion=distortionallowed distortion f_\text{distortion} = \frac{\mathrm{distortion}}{\mathrm{allowed\ distortion}}

这样评价函数更容易解释:

M=iwifi2 M = \sum_i w_i f_i^2

其中每个 fif_i 是无量纲的或至少经过缩放。

良好的缩放有助于优化器和设计师。

糟糕的缩放会让权重产生误导。

当操作数本身的自然量级差异极大时,权重为10的意义很小。


17. 直接优化MTF

既然我们花了数章计算PSF和MTF,自然会问:

能否将MTF直接放入评价函数?

概念上可以。

例如,假设我们希望某频率处的MTF至少为0.5。

一个简单的惩罚可以是:

f=max(0,0.5MTF(f0)) f = \max(0, 0.5 - \mathrm{MTF}(f_0))

然后:

MMTF=wf2 M_\text{MTF} = w f^2

代码:

def mtf_target_penalty(mtf_value, target=0.5):
    error = max(0.0, target - mtf_value)
    return error**2

只有当MTF低于目标时,这才惩罚设计。

但直接MTF优化可能代价昂贵,因为每次评价函数评估可能需要:

光线追迹
→ OPD或光瞳
→ PSF
→ OTF
→ MTF

它还可能对采样选择敏感或有噪声。

这不意味着它是错的,而是说我们应该谨慎。

许多光学工作流使用混合操作数:

  • 基于光线的操作数用于速度和稳健性;
  • 波前操作数用于衍射相关行为;
  • MTF操作数用于最终的成像质量目标;
  • 几何操作数用于可制造性和封装。

一个实用的优化策略往往从较简单的操作数开始,稍后再转向更针对成像质量的操作数。

具体策略取决于设计任务。

对本书而言,重要的是更简单的内容:

只有在正确计算了MTF之后,才能优化它。

这就是为什么第14章在本章之前。


18. 一个同时包含光斑和焦距的玩具评价函数

让我们在代码中组合两个光学目标。

假设我们有:

  1. 一个焦点位置变量;
  2. 目标焦点在50毫米左右;
  3. RMS光斑应尽可能小;
  4. 焦点不应偏离目标太远。

这就给出一个评价函数:

M=wspot(RMS)2+wfocus(zztargetztarget)2 M = w_\text{spot}(\mathrm{RMS})^2 + w_\text{focus}\left(\frac{z-z_\text{target}}{z_\text{target}}\right)^2

代码:

def focus_merit_with_position_penalty(v, y, u, z_target=50.0):
    z_image = v[0]

    y_image = ray_heights_at_plane(y, u, z_image)
    rms = rms_spot_1d(y_image)

    focus_error = (z_image - z_target) / z_target

    w_spot = 1.0
    w_focus = 0.2

    return w_spot * rms**2 + w_focus * focus_error**2

运行:

result_penalized = minimize(
    focus_merit_with_position_penalty,
    initial_guess,
    args=(y, u),
    method="Nelder-Mead"
)

best_focus_penalized = result_penalized.x[0]

print("Best focus without focus penalty:", best_focus)
print("Best focus with focus penalty:", best_focus_penalized)

结果可能向目标焦点位置移动。

这个例子显示了评价函数如何编码权衡。

如果焦点惩罚低,优化器主要最小化光斑尺寸。

如果焦点惩罚高,优化器就会更靠近目标焦点,即使光斑尺寸稍差。

两者没有哪个是绝对正确的。

这取决于设计目标。


19. 观察优化路径

为了教学目的,记录优化器的路径很有帮助。

SciPy允许对许多方法使用回调函数。

history = []


def callback(v):
    history.append(float(v[0]))


history.clear()

result = minimize(
    focus_merit,
    initial_guess,
    args=(y, u),
    method="Nelder-Mead",
    callback=callback
)

history = np.array(history)

plt.figure(figsize=(6, 4))
plt.plot(history, "o-")
plt.xlabel("Iteration")
plt.ylabel("Focus position")
plt.title("Optimization path")
plt.grid(True)
plt.tight_layout()
plt.show()

这张图通常不会出现在最终光学报告中,但对学习非常有用。

它提醒我们优化是一系列尝试,而非一次性动作。

对于一个简单的单变量问题,路径很容易理解。对于有许多变量的真实镜头,路径更难可视化,但原理不变:

优化器反复评估设计并在变量空间中移动。

当优化出现异常时,历史记录能帮助诊断。

是否碰到了边界?是否跳到了一个荒谬的区域?评价函数是否停止改善?变量是否剧烈变化?某个操作数是否改善而另一个变糟?

这些问题比简单地说“优化失败”更有用。


20. Optiland在此阶段的贡献

Optiland在这里很有价值,因为它将真实光学系统数据与分析及优化工具连接起来。

真正的优化工作流不使用我们的玩具函数 generate_teaching_rays()。它使用一个光学系统:

表面
材料
孔径
视场
波长
变量
分析操作数
优化方法

从概念上讲,Optiland工作流看起来像这样:

# Version-checked workflow outline.
# Check your installed Optiland version for exact API names.

# 1. Build or load an optical system.
# optic = ...

# 2. Select variables:
# - surface radii
# - thicknesses
# - image plane position
# - aspheric coefficients
# optic.set_variable(...)

# 3. Define merit operands:
# - effective focal length target
# - RMS spot size
# - distortion
# - MTF targets
# - thickness constraints
# merit = ...

# 4. Choose optimizer and settings.
# optimizer = ...

# 5. Run optimization.
# result = optimizer.optimize()

# 6. Analyze before and after:
# - layout
# - spot diagram
# - wavefront
# - PSF
# - MTF
# - geometry constraints

准确的API可能跨版本变化,但概念是稳定的。

Optiland不仅仅是按下一个优化按钮。它是一个Python环境,你可以在其中检查允许哪些变量改变、包含了哪些操作数、使用了什么权重,以及设计如何变化。

这种可检查性具有教学价值。

封闭的工作流可能只显示最终的评价函数值。而可检查的工作流让你能够询问这个值意味着什么。


21. 现实的优化报告应显示的不只是评价函数值

较低的评价函数值是不够的。

优化之后,你应该从多个角度检查设计。

至少包括:

优化前后的布局
优化前后的处方
点列图
光线像差曲线
波前图
PSF
MTF
畸变
场曲
面曲率
厚度和空气间隔
孔径间隙

为什么这么多?

因为评价函数值是压缩的。

它可能隐藏了哪些操作数改善、哪些变差。

设计可能减小了RMS光斑但增加了畸变。可能改善了轴上MTF但损害了视场性能。可能改善了一个波长但损害了另一个。可能在光学上看起来不错但厚度不可能实现。

所以不要只问:

评价函数值下降了吗?

而要问:

设计对于实际成像任务真的变好了吗?

第二个问题需要光学判断。

这是软件强大但不够充分的又一处。


22. 示例:分别报告操作数

在我们玩具般的焦点示例中,我们可以报告优化前后的RMS光斑。

def report_focus_solution(y, u, z_values):
    for label, z in z_values:
        y_image = ray_heights_at_plane(y, u, z)
        rms = rms_spot_1d(y_image)

        print(f"{label}")
        print(f"  focus position: {z:.6f}")
        print(f"  RMS spot:       {rms:.6f}")
        print()


report_focus_solution(
    y,
    u,
    [
        ("Initial focus", 50.0),
        ("Optimized focus", best_focus),
        ("Penalized optimized focus", best_focus_penalized),
    ]
)

这比只报告最终评价函数值更好。

对于真实镜头,你会报告每个重要操作数:

EFL误差
各视场RMS光斑
各视场RMS波前
选定频率处的MTF
畸变
最小厚度
总长

没有操作数分解的最终评价函数值,就像一场考试只看总分而不看哪些题做错了一样。

它可能有用,但不足以理解设计。


23. 优化你喜欢的图的风险

一旦我们知道如何计算MTF,就很容易想要不断优化直到MTF图看起来更好。

这是可以理解的,但我们需要小心。

一张图是选定数据的视图。它可能代表:

  • 一个波长;
  • 一个视场点;
  • 一个调焦设置;
  • 一个方向;
  • 一个频率范围;
  • 一种归一化;
  • 一种孔径;
  • 一个采样条件。

改善那张图并不自动改善整个光学系统。

例如,优化某视场点的MTF可能损害另一个视场点。优化一个空间频率可能不会改善另一个。优化单色MTF可能使彩色性能很差。

所以更安全的规则是:

不要优化一个显示图。
优化一组定义好的光学需求。

显示图帮助你检查结果,但它不应无声地定义整个设计目标。


24. 优化与可制造性

一个纯粹的光学评价函数可能产生数学上诱人但实际不讨喜的设计。

例子:

  • 非常陡的曲率;
  • 极薄的元件;
  • 微小的空气间隔;
  • 过大的元件;
  • 强烈的非球面偏离;
  • 昂贵或无法获得的玻璃选择;
  • 对公差高度敏感;
  • 难以测试的面形;
  • 机械封装冲突。

可制造性不会自动包含在光学性能中。

如果它重要,就必须以约束、边界、惩罚、玻璃目录、公差分析或后期的设计评审形式出现。

镜头不会因为评价函数值低而完成。

只有当光学性能、机械可行性、制造限制、成本和公差行为整体合理时,镜头才更接近可用。

本书侧重于计算,而非完整的生产工程。然而,警告仍应放在这里,因为优化很容易产生过度自信。

数值最优并不意味着实际最优。

25. 评价函数设计是一个渐进过程

在实际工作中,你很少第一次就能写出完美的最终评价函数。

更现实的顺序是:

从简单的目标开始
→ 优化
→ 检查失效模式
→ 添加操作数或约束
→ 重新优化
→ 再次检查

例如:

  1. 首先目标焦距。
  2. 然后减小轴上光斑。
  3. 然后包含轴外视场。
  4. 然后包含多个波长。
  5. 然后约束厚度和曲率。
  6. 然后添加畸变目标。
  7. 然后添加MTF目标。
  8. 然后检查公差。

这个渐进过程并不是设计师困惑的标志,而是问题被定义清晰的方式。

随着你了解设计在获得自由度时会如何行动,评价函数也随之演变。

这是一种非常实用的心态:

第一个评价函数揭示问题。
后面的评价函数塑造设计。

26. 基于梯度与无导数优化

有许多优化方法。

在高层次上,区分两个家族很有用。

无导数方法

像Nelder-Mead这样的方法不需要梯度。

它们容易使用,对于粗略问题很有用,但随着变量数量增加可能变慢。

它们的问题:

如果我尝试附近的点,评价函数会变好吗?

基于梯度的方法

像L-BFGS-B这样的方法使用导数信息或近似导数行为。

对于平滑问题,它们可以快得多,特别是变量很多时。

它们的问题:

哪个方向看起来最快地减小评价函数?

传统的光学设计常常使用最小二乘和阻尼最小二乘风格的方法。现代的微分光学框架可以计算通过部分光学系统的梯度,这为更直接的基于梯度的优化打开了大门。

这就是通往第17章的桥梁。

目前,记住实际区别:

优化方法影响搜索如何进行。
评价函数定义了搜索试图在减少什么。

不要将两者混淆。


27. 为什么优化放在PSF和MTF之后

本章接在PSF、OTF、MTF、采样和归一化之后是有原因的。

如果你不理解度量标准,优化它就是有风险的。

假设你的MTF频率轴错了。你可能在为“50线对/毫米”优化,而实际上评估着不同的频率。

假设你的PSF归一化不一致。你可能在奖励能量缩放而不是更好的图像形成。

假设你的弧矢和子午方向标错了。你可能在改善错误的方向行为。

假设你的光瞳掩模错了。你可能在优化一个方形孔径,却以为它是圆的。

优化会放大这些错误。

它不会纠正它们。

所以安全的链条是:

理解计算
→ 验证度量
→ 然后优化该度量

这从长远看并不慢。它可以防止优雅的荒谬。


28. 一个完整的最小优化脚本

下面是一个用于焦点优化的紧凑脚本。

它足够小,可以运行、检查和修改。

import numpy as np
import matplotlib.pyplot as plt
from scipy.optimize import minimize


def generate_teaching_rays(n_rays=41, pupil_radius=1.0, f0=50.0, spherical=4.0):
    y = np.linspace(-pupil_radius, pupil_radius, n_rays)

    focus_distance = f0 + spherical * y**2

    u = -y / focus_distance

    return y, u


def ray_heights_at_plane(y, u, z_image):
    return y + z_image * u


def rms_spot_1d(y_image):
    return np.sqrt(np.mean(y_image**2))


def focus_merit(v, y, u):
    z_image = v[0]

    y_image = ray_heights_at_plane(y, u, z_image)

    rms = rms_spot_1d(y_image)

    return rms**2


def report_focus_solution(y, u, z_values):
    for label, z in z_values:
        y_image = ray_heights_at_plane(y, u, z)
        rms = rms_spot_1d(y_image)

        print(f"{label}")
        print(f"  focus position: {z:.6f}")
        print(f"  RMS spot:       {rms:.6f}")
        print()


def plot_focus_merit(y, u, best_focus):
    z_values = np.linspace(45.0, 58.0, 300)
    merit_values = np.array([
        focus_merit([z], y, u)
        for z in z_values
    ])

    plt.figure(figsize=(6, 4))
    plt.plot(z_values, merit_values)
    plt.axvline(best_focus, linestyle="--", label="Optimized focus")
    plt.xlabel("Image plane position z")
    plt.ylabel("Merit: RMS spot squared")
    plt.title("Focus merit function")
    plt.grid(True)
    plt.legend()
    plt.tight_layout()
    plt.show()


def plot_ray_intercepts(y, u, z_before, z_after):
    y_before = ray_heights_at_plane(y, u, z_before)
    y_after = ray_heights_at_plane(y, u, z_after)

    ray_index = np.arange(len(y))

    plt.figure(figsize=(7, 4))
    plt.plot(ray_index, y_before, "o", label=f"Before: z={z_before:.2f}")
    plt.plot(ray_index, y_after, "o", label=f"After: z={z_after:.2f}")
    plt.axhline(0.0, linewidth=1)
    plt.xlabel("Ray index")
    plt.ylabel("Ray height at image plane")
    plt.title("Ray intercepts before and after focus optimization")
    plt.grid(True)
    plt.legend()
    plt.tight_layout()
    plt.show()


# Build teaching rays
y, u = generate_teaching_rays(
    n_rays=41,
    pupil_radius=1.0,
    f0=50.0,
    spherical=4.0
)

# Optimize image plane position
initial_focus = 50.0

result = minimize(
    focus_merit,
    np.array([initial_focus]),
    args=(y, u),
    method="Nelder-Mead"
)

best_focus = result.x[0]

# Report
report_focus_solution(
    y,
    u,
    [
        ("Initial focus", initial_focus),
        ("Optimized focus", best_focus),
    ]
)

# Plot
plot_focus_merit(y, u, best_focus)
plot_ray_intercepts(y, u, initial_focus, best_focus)

代码本身并不重要,结构才重要。

它包含:

变量:像面位置
操作数:RMS光斑半径
目标:尽可能小
评价函数:RMS光斑平方
优化器:数值搜索
报告:优化前后的RMS

这就是光学优化的骨架。

真实的光学设计添加更多变量和更多操作数,但不会跳出这个逻辑。


29. 这在完整计算链中的位置

现在我们可以将优化加入链条:

镜头处方
→ 变量
→ 光线追迹 / 波前 / PSF / MTF分析
→ 操作数
→ 评价函数
→ 优化器更改变量
→ 更新的镜头处方

这不是一条直线,而是一个循环:

处方
→ 分析
→ 评价
→ 变量更新
→ 新处方
→ 再分析

该循环不断重复,直到优化器停止。

它可能因为以下原因停止:

  • 评价函数值足够低;
  • 改善变得微小;
  • 达到迭代限制;
  • 变量碰到边界;
  • 计算失败;
  • 用户停止它。

一次完成的优化运行不自动意味着设计完成。

它是一个必须接受检查的候选设计。

这是成熟解读优化输出的方式:

优化器产生了较低的评价函数值。
现在我们必须问什么改变了,什么改善了,什么恶化了,以及结果在光学和实际意义上是否仍然合理。

30. 本章为什么做准备

第16章将用一个完整示例连接整本书。

重点将不仅仅是展示库克三片式镜头,而是走完整个计算路线:

处方
→ 布局
→ 光线
→ 光斑
→ 光线像差曲线
→ OPD
→ 光瞳函数
→ PSF
→ MTF
→ 优化
→ 前后对比

本章为我们提供了那个最终案例研究所需的词汇量。

当我们在第16章看到优化后的设计时,我们会知道该问什么:

  • 允许哪些变量改变?
  • 包含了哪些操作数?
  • 使用了什么目标?
  • 使用了什么权重?
  • 哪些约束或边界是激活的?
  • 评价函数值下降了吗?
  • 光学性能实际改善了吗?
  • 有什么东西变得不那么实际了吗?
  • 哪些图确认了改善?

这就是看着软件优化与理解优化之间的区别。


本章摘要

优化不是自动的光学智慧,而是由评价函数引导的数值搜索。

常见的最小二乘评价函数形式为:

M(x)=iwifi(x)2 M(\mathbf{x}) = \sum_i w_i f_i(\mathbf{x})^2

其中 x\mathbf{x} 包含设计变量,fif_i 是操作数误差,wiw_i 是权重。

主要组成部分是:

变量:优化器可以改变什么
操作数:设计评价什么
目标:期望值是什么
权重:什么更重要或更不重要
边界:允许什么值
约束或惩罚:什么必须被阻止或不鼓励

一个简单的焦点优化可以通过改变像面位置来最小化RMS光斑尺寸。一个简单的薄透镜示例可以优化曲率以匹配目标焦距,同时惩罚过大的曲率。这些示例不是完整的镜头设计,但它们揭示了机制。

最重要的实际教训是:

优化器跟随评价函数,而不是你的意图。

因此,设计师的任务是将光学目标转化为准确、缩放、加权、有边界且可检查的评价函数。

优化应放在PSF和MTF之后,因为糟糕的度量标准在被优化时会变得更糟。在优化之前,我们必须知道计算量意味着什么。

下一章将使用一个完整的光学示例将整个链条连接起来。

生成的验证图形

第15章评价函数检查生成的焦点评价函数曲线

来源与验证说明

本章中的最小二乘评价函数是一个教学模型。商业和研究性的镜头设计工作流可能使用多种操作数类型、约束、边界、公差项、可制造性惩罚以及算法选择。这里保留的事实主张较窄:优化器遵循明确编码的目标和约束,而不是未明说的设计意图。

第16章:库克三片式镜头:贯穿整个计算链的实例演示

库克三片式镜头看起来并不复杂。

三个镜片。前方一个正透镜,中间一个负透镜,后方再跟一个正透镜。与现代多镜片相机镜头相比,它几乎显得很简单。

而这种简单,恰恰是它在这里的价值所在。

库克三片式镜头复杂到足以展现出真实的光学行为:

  • 球差;
  • 彗差;
  • 像散;
  • 场曲;
  • 畸变;
  • 色差校正;
  • 视场相关的像质;
  • 优化中的权衡取舍。

但它又足够小巧,让我们能将整个设计一览无余。

本章将库克三片式镜头作为一个全链条的实例。我们并不打算巨细无遗地讲解三片式镜头的每一处历史与商业细节,而是将其作为一个测试台。

在这里,我们将走完本书一直在构建的路线:

光学处方
→ 表面与材料
→ 光线追迹
→ 点列图
→ 光扇图
→ 光程差与波前
→ 光瞳函数
→ 点扩散函数(PSF)
→ 光学传递函数(OTF)
→ 调制传递函数(MTF)
→ 评价函数
→ 优化
→ 优化前后对比

如果前面各章是工作台上摆放的单件工具,那么本章就是用它们进行流水作业的地方。

这是一次检验:当你看到最终的 MTF 曲线时,你能说清楚在这条曲线出现之前,经历了哪些步骤吗?

到本章结尾,答案应当是肯定的。

在开始之前,有一点需要说明:本章采用一套固定的教学用光学处方,而不是将库克三片式镜头作为一个模糊的占位符。数据经过有意四舍五入,并用于教学目的,并非声称来自某个历史专利或量产镜头。这一区别很重要。一套固定的教学处方足以让计算链得到检视。而量产处方还需要玻璃库验证、公差分析、可制造性检查以及版本锁定的工程评审。


1. 为什么库克三片式镜头是良好的测试案例

单片薄透镜过于简单,无法承载完整的光学设计故事。它能展示焦距和基本像差,但无法承载太多的设计权衡。

复杂的现代摄影镜头则面临相反的问题。它可能包含众多镜片、非球面、特殊玻璃、浮动组、机械约束和专有设计选择。它很真实,但对于首次全链条演练来说,信息量太大。

库克三片式镜头位于两者之间,恰到好处。

它拥有足够的自由度,使优化有意义:

曲率半径
厚度
空气间隙
玻璃选择
光阑位置
像面位置

它拥有足够的像差行为,使分析有意义:

轴上与轴外点列图各不相同
光扇图展现出结构
波前图随视场变化
PSF 随视场和焦面变化
MTF 因视场和方向而异

而且它足够小巧,我们仍能以表格形式理解其处方。

这就是光学设计教育常常回过头来研究三片式镜头的原因。它们不是玩具,而是真实设计逻辑的紧凑范例。

对本书而言,库克三片式镜头还有另一个优势:它能让我们展示开放式计算工作流的教育价值。

我们不仅能问:

MTF 是什么?

还能问:

是哪一份处方产生的 MTF?
追迹的是哪些光线?
使用的是哪个视场点?
使用的是哪个波长?
哪个波前成为了光瞳函数?
哪个 PSF 产生了 OTF?
哪个评价函数改变了设计?

这才是关键所在。


2. 我们将做什么,不做什么

本章是一次贯穿流程的演示,而不是一个完整的工业级镜头设计项目。

我们不会假装几页纸的代码能替代专业的光学设计软件。我们不会推导每一个三级像差项。我们不会宣称一个在教学脚本中优化过的三片式镜头就可以投产。

我们要做的更加专注:

选择一个完整的镜头
执行主要分析
将每一幅图与其计算起源联系起来
进行一次小型优化
比较优化前后

这就足以证明本书的主要承诺。

现在我们可以跟踪一个镜头,从处方数据一直走到像质指标。

库克三片式镜头并非主角,计算链才是。


3. 本章的双轨方法

我们将沿用贯穿全书的双轨方法。

A轨:极简 Python 理解

A轨用于理解骨架。它采用小段代码片段,展示正在传递何种数据。

A轨不在此处实现一个完整的生产级光线追迹器。我们在此前的章节中已经构建了许多组件:

  • 光线表示;
  • 表面相交;
  • 矢量折射;
  • 光瞳采样;
  • 光程差到光瞳函数;
  • 通过 FFT 计算 PSF;
  • 从 PSF 计算 OTF 和 MTF;
  • 评价函数优化。

在本章中,A轨主要提醒我们每个阶段意味着什么。

B轨:Optiland 适配器边界

B轨不允许成为一堆半拉子代码。它需要一个清晰的边界。本书正文展示工作流;配套代码应锁定安装的库版本,并围绕实际 API 实现一个小型适配层。

阅读本章中的 Optiland 示例时,请按以下两种方式之一理解:

  • 纯 Python 代码块 旨在作为语法有效的教学代码。
  • 适配器草图 描述的是必须在配套仓库中为固定的 Optiland 版本实现的调用。它们被标记为草图,而非可直接运行的片段。

实用的配套仓库应包含:

pyproject.toml
notebooks/16_cooke_triplet_full_chain.ipynb
src/optics_examples/cooke_triplet.py
src/optics_examples/analysis_adapters.py
tests/test_chapter_code_syntax.py
tests/test_cooke_triplet_smoke.py

适配器的约定很简单:

make_cooke_triplet()          -> 光学系统对象
plot_layout(system)           -> 布局图形或坐标轴
run_spot_diagram(system, ...) -> 点列图数据及图形
run_ray_fan(system, ...)      -> 光扇图数据及图形
run_wavefront(system, ...)    -> OPD图、光瞳掩膜、元数据
run_psf(system, ...)          -> PSF数组、坐标元数据
run_mtf(system, ...)          -> 频率轴与MTF曲线
optimize_triplet(system, ...) -> 优化后系统及优化日志

这个适配器并不隐藏计算过程。它防止印刷本书籍依赖于不稳定的方法名,同时仍要求配套代码证明工作流可行。


4. 光学处方:作为数据的镜头

在绘制图形之前,先有处方。

光学处方不仅仅是文档,它是计算用的输入数据。

一个简化的三片式镜头处方可以用表格表示:

面序号曲率半径到下一面的厚度材料半孔径注释
0无穷大物距空气物体
1玻璃厚度冕牌玻璃孔径前方镜片第一面
2空气间隙空气孔径前方镜片第二面
3玻璃厚度火石玻璃孔径中间镜片第一面
4空气间隙空气孔径中间镜片第二面
5玻璃厚度冕牌玻璃孔径后方镜片第一面
6后焦距空气孔径后方镜片第二面
7无穷大像面像面

在本章中,我们使用一套固定的教学处方。下面的数值是经四舍五入的教学用值,单位为毫米。它们不作为历史专利处方展示,也不是用于生产的处方。它们的作用是让整个计算链变得具体。

在代码中,它可能如下所示:

from dataclasses import dataclass
from typing import Optional


@dataclass
class SurfaceRecord:
    radius: float              # mm; 平面用 np.inf
    thickness: float           # mm 到下一表面
    material_after: str        # 该面之后的材料
    semi_aperture: float       # mm
    comment: str = ""


cooke_triplet_prescription = [
    SurfaceRecord(radius=  23.7, thickness=4.0,  material_after="CROWN", semi_aperture=8.0, comment="前方镜片 S1"),
    SurfaceRecord(radius= -80.0, thickness=6.0,  material_after="AIR",   semi_aperture=8.0, comment="前方镜片 S2"),
    SurfaceRecord(radius= -22.0, thickness=2.0,  material_after="FLINT", semi_aperture=6.5, comment="中间镜片 S1"),
    SurfaceRecord(radius=  22.0, thickness=7.0,  material_after="AIR",   semi_aperture=6.5, comment="中间镜片 S2"),
    SurfaceRecord(radius=  80.0, thickness=4.0,  material_after="CROWN", semi_aperture=7.5, comment="后方镜片 S1"),
    SurfaceRecord(radius= -23.7, thickness=38.0, material_after="AIR",   semi_aperture=7.5, comment="后方镜片 S2"),
]

这些数值现在就是本章固定的教学处方。配套仓库应以机器可读形式保存同一张表格,这样本章中的每一幅图都可以从同一组初始数据重新生成。

关键在于:

所有后续图形都起源于这样一张表格。

点列图不是凭空画出的。波前图不是猜出来的。MTF曲线不是软件生成的魔法。

它们全都起源于表面、距离、材料、孔径、波长、视场和离焦设置。


5. 在 Optiland 中构建或加载三片式镜头

在固定的 Optiland 环境中,您可以加载一个示例三片式镜头,也可以逐面构建。

已验证的配套工作流如下:

# 已验证的封装工作流。
# 在配套包中为 optiland==0.6.0 实现。

optic = make_cooke_triplet()

plot_layout(optic)

如果手动构建,概念步骤如下:

# 概念上的构建步骤。

optic = OpticalSystem()

optic.add_surface(radius=23.7,  thickness=4.0,  material="CROWN", semi_aperture=8.0)
optic.add_surface(radius=-80.0, thickness=6.0,  material="AIR",   semi_aperture=8.0)
optic.add_surface(radius=-22.0, thickness=2.0,  material="FLINT", semi_aperture=6.5)
optic.add_surface(radius=22.0,  thickness=7.0,  material="AIR",   semi_aperture=6.5)
optic.add_surface(radius=80.0,  thickness=4.0,  material="CROWN", semi_aperture=7.5)
optic.add_surface(radius=-23.7, thickness=38.0, material="AIR",   semi_aperture=7.5)

optic.set_aperture(...)
optic.set_fields(...)
optic.set_wavelengths(...)
optic.set_image_plane(...)

本手稿配套包针对 optiland==0.6.0 实现了封装调用。若将示例迁移到其他版本,请在信任这些图形之前重新运行验证测试。

重要的是结构:

先定义表面
再定义孔径
然后定义视场
然后定义波长
然后定义像面
然后进行分析

不要从 MTF 开始。从镜头本身开始。


6. 选择视场和波长

一个镜头没有一个通用的性能值。

性能取决于视场点和波长。

对于一个简单的三片式镜头示例,我们可以使用三个视场:

轴上视场
中等视场
边缘视场

以及三个波长:

蓝光
绿光
红光

例如:

fields = [
    {"name": "axis", "angle_deg": 0.0},
    {"name": "mid",  "angle_deg": 7.0},
    {"name": "edge", "angle_deg": 14.0},
]

wavelengths = [
    {"name": "blue",  "wavelength_m": 486.1e-9, "weight": 1.0},
    {"name": "green", "wavelength_m": 587.6e-9, "weight": 1.0},
    {"name": "red",   "wavelength_m": 656.3e-9, "weight": 1.0},
]

这些是手稿配套包中使用的验证用波长。不同的设计目标可能需要不同的波长或权重。

单色设计问题与多色成像问题不同。

仅轴上优化与全视场优化不同。

慢速镜头与快速镜头不同。

因此,在进行分析之前,请写下配置:

哪些视场点?
哪些波长?
什么孔径?
哪个对焦平面?
什么性能目标?

如果没有这些答案,“这个镜头的 MTF”便是一个不完整的表述。

更好的表述是:

该镜头在此视场、此波长组、此孔径、此对焦设定、此方向下的 MTF。

虽然更长,但它是诚实的。


7. 第一幅图:布局图

布局图是第一项合理性检查。

optic = make_cooke_triplet()

plot_layout(
    optic,
    fields=fields,
    wavelengths=wavelengths
)

布局图应当回答以下基本问题:

  • 表面是否按预期顺序排列?
  • 各镜片是否正确地分隔?
  • 光阑在你认为的位置吗?
  • 光线是否通过了通光孔径?
  • 像面位置看起来大致合理吗?
  • 是否存在明显的光线追迹失败?

布局图不是性能图,它不能证明镜头是否优良。

但它能及早发现严重的建模错误。

如果布局图是错的,后续的每一项分析都值得怀疑。

一个好习惯是:

在查看点列图之前,永远不要轻信布局图。
在查看处方和布局图之前,永远不要轻信 MTF 曲线。

这听起来可能有点慢,但比从一个错误模型中调试出一幅漂亮的 MTF 曲线要快得多。


8. 近轴检查:焦距与 F 数

在进行实际光线分析之前,先检查近轴骨架。

三片式镜头是一个实际镜头,但近轴量仍然重要:

  • 有效焦距;
  • 后焦距;
  • F 数;
  • 入瞳;
  • 像距;
  • 若为有限共轭,近似的放大倍率。

已验证的封装调用:

# 适用于 optiland==0.6.0 的已验证封装工作流。

paraxial = optic.paraxial_analysis()

print("有效焦距:", paraxial.efl)
print("后焦距:", paraxial.bfl)
print("F数:", paraxial.f_number)
print("入瞳直径:", paraxial.entrance_pupil_diameter)

如果您想要的镜头焦距约为 50 mm,而近轴分析显示为 5000 mm,那就不必继续计算 PSF 了。

近轴检查是系统的骨架。

它告诉您一阶行为是否大致正确,然后您再去询问具体的像差。

这与第8章直接相关:

近轴光学不是过时的装饰;
它是整个系统的第一项一致性检查。

9. 实际光线追迹:几何在此变得真实

现在追迹实际光线。

在一个完整的三片式镜头中,实际光线并不严格遵循简单的近轴模型。它们在弯曲表面上的实际点处相交,使用局部表面法线进行折射,并积累误差。

已验证的封装调用:

# 适用于 optiland==0.6.0 的已验证封装工作流。

ray_bundle = optic.trace_rays(
    field=fields[1],
    wavelength=wavelengths[1],
    pupil_sampling="grid",
    num_rays=21,
)

print(ray_bundle.summary())

实际光线追迹是后续许多图形的引擎。

从追迹到的光线我们可以计算:

  • 点列图;
  • 光扇图;
  • 横向像差;
  • 主光线行为;
  • 渐晕;
  • 光程长度;
  • 波前误差。

这就是本书中间部分花了大量笔墨讲解光线的原因。

如果光线追迹是错误的,后续的波动光学将不可信。

计算链依赖于它:

处方
→ 表面交点
→ 折射
→ 像面光线束

这便是几何基础。


10. 点列图:像质初览

点列图通常是第一项可视化的性能检查。

已验证的封装调用:

# 适用于 optiland==0.6.0 的已验证封装工作流。

spot_results = run_spot_diagram(
    optic,
    fields=fields,
    wavelengths=wavelengths,
    pupil_sampling="grid",
    num_rays=21,
)

spot_results.plot()
spot_results.report()

点列图回答了:

追迹光线落在像面上的什么位置?

对于三片式镜头,您应该预料到视场依赖性。

轴上光斑可能相对紧凑。轴外光斑可能会拉伸、倾斜或增大。不同波长的光可能落在略微不同的位置,或者散布程度不同。

这告诉我们几件事:

  • 球差影响轴上光斑;
  • 彗差和像散在轴外表现得更明显;
  • 当波长分离时,出现了色差;
  • 焦面选择改变了分布。

但点列图仍然是基于光线的。

它不显示衍射环。它不直接显示 MTF。它不能替代 PSF。

它在计算链中的角色是:

实际光线追迹
→ 像面光线拦截点
→ 点列图

这是基于几何光学的像质视图。

很有用,但不完整。


11. 解读点列图,不过度解读

一个紧凑的光斑通常意味着几何成像效果更好。一个大光斑意味着更多的模糊。

但也有其局限性。

一个小的光斑仍然可能比衍射极限的艾里斑大。比艾里斑还小的光斑可能影响不大,因为此时衍射占主导地位。接近衍射极限的点列图可能不如波前、PSF 或 MTF 信息量大。

因此,对于每个视场,将光斑尺寸与艾里斑尺度进行比较。

一个简单的计算:

def airy_radius_um(wavelength_m, f_number):
    return 1.22 * wavelength_m * f_number * 1e6


green = 587.6e-9
f_number = 4.0  # 示例;使用实际系统的近轴结果

print("艾里斑第一暗环半径 [um]:", airy_radius_um(green, f_number))

如果光斑的 RMS 半径远大于艾里斑尺度,几何像差占主导。

如果光斑与艾里斑尺度相当或更小,衍射分析就变得尤为重要。

此时正好可以回顾本书反复强调的一点:

点列图是计算得出的结果,并非最终的真理。

它能很好地回答一个问题,但不能回答所有的问题。


12. 光扇图:光斑内部的结构

点列图显示光线落在何处。光扇图显示光线误差如何随光瞳坐标变化。

已验证的封装调用:

# 适用于 optiland==0.6.0 的已验证封装工作流。

ray_fan = run_ray_fan(
    optic,
    fields=fields,
    wavelengths=wavelengths,
    pupil_coordinate="normalized",
)

ray_fan.plot()

光扇图之所以有用,是因为它们揭示了结构。

点列图可能显示一团模糊光斑。光扇图可以表明这种模糊来自:

  • 类球差行为;
  • 类彗差非对称性;
  • 像散差异;
  • 色差分离;
  • 离焦;
  • 视场相关的不平衡。

这与第9章衔接。

光扇图问的是:

横向光线误差如何在光瞳上变化?

对于三片式镜头,比较不同视场下的光扇图。

在轴上,光扇图可能主要显示对称的像差图案。

在轴外,光扇图可能变得不对称,或具有方向依赖性。

这就是库克三片式镜头成为良好教学实例的原因之一。它虽小,但并不单调。


13. 波前与光程差:从光线跨越到相位

现在我们从光线拦截点转向波前误差。

光程差图回答的是:

在光瞳的每一点上,实际波前相对于参考波前是超前还是滞后了多少?

已验证的封装调用:

# 适用于 optiland==0.6.0 的已验证封装工作流。

wavefront = run_wavefront(
    optic,
    field=fields[1],
    wavelength=wavelengths[1],
    pupil_sampling=128,
)

opd = wavefront.opd
mask = wavefront.pupil_mask

wavefront.plot_opd(units="waves")
wavefront.report()

光程差是计算链特性发生变化的地方。

点列图和光扇图是基于几何光学的视图。光程差则为衍射计算做好准备。

这是从第10章架起的桥梁:

实际光线追迹
→ 光程长度
→ 与参考波前比较
→ 光程差图

对于三片式镜头,按视场检查光程差。

优质的轴上波前可能基本对称。轴外波前可能显示出彗差状或像散状的结构。由于折射率的变化,不同波长可能表现出不同的光程差行为。

这不仅仅是另一幅图,它还是光瞳函数的输入。


14. 光瞳函数:将波前编码为复振幅场

由光程差我们构建光瞳函数:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x,y)=A(x,y)\exp\left(i\frac{2\pi W(x,y)}{\lambda}\right)

这是第11章的内容在一个真实镜头工作流中的回归。

如果 Optiland 的波前工具为我们提供了光程差和光瞳掩膜,教学转换过程如下:

import numpy as np


def pupil_function_from_opd(opd, mask, wavelength, amplitude=None):
    if amplitude is None:
        amplitude = mask.astype(float)

    phase = 2.0 * np.pi * opd / wavelength

    pupil = amplitude * np.exp(1j * phase)
    pupil[~mask] = 0.0 + 0.0j

    return pupil, phase

然后:

pupil, phase = pupil_function_from_opd(
    opd=opd,
    mask=mask,
    wavelength=wavelengths[1]["wavelength_m"],
)

此时,库克三片式镜头不再只是一张表面表格或光线图。

它已变成一个光瞳上的复振幅场。

这是表征上的重大转变:

表面处方
→ 光线几何
→ 波前误差
→ 复光瞳函数

这恰恰是通常隐藏在分析软件内部的那一步转换。

现在我们能亲眼看到了。


15. PSF:三片式镜头的点像

PSF 是由光瞳函数计算得到的:

PSF=F{P(x,y)}2 \mathrm{PSF}=|\mathcal{F}{P(x,y)}|^2

一个最小实现如下:

def pad_array_centered(array, pad_factor=4):
    if pad_factor == 1:
        return array

    n, m = array.shape
    new_n = n * pad_factor
    new_m = m * pad_factor

    padded = np.zeros((new_n, new_m), dtype=array.dtype)

    start_n = (new_n - n) // 2
    start_m = (new_m - m) // 2

    padded[start_n:start_n + n, start_m:start_m + m] = array

    return padded


def compute_psf_from_pupil(pupil, pad_factor=4):
    padded = pad_array_centered(pupil, pad_factor=pad_factor)

    field = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(padded)
        )
    )

    psf = np.abs(field) ** 2
    psf = psf / np.sum(psf)

    return psf, field

然后:

psf, image_field = compute_psf_from_pupil(
    pupil,
    pad_factor=4
)

Optiland 也可能直接提供 PSF 分析:

# 适用于 optiland==0.6.0 的已验证封装工作流。

psf_result = run_psf(
    optic,
    field=fields[1],
    wavelength=wavelengths[1],
    sampling=128,
    padding=4,
)

psf_result.plot(scale="linear")
psf_result.plot(scale="log")

双轨对比具有教育意义。

Optiland 的 PSF 是实际分析结果。NumPy PSF 展示了计算骨架。

如果它们不一致,不要立刻慌张。检查:

  • 视场是否相同;
  • 波长是否相同;
  • 焦面是否相同;
  • 光瞳采样是否相同;
  • 孔径掩膜是否相同;
  • 光程差符号约定是否相同;
  • 补零方式是否相同;
  • 归一化方式是否相同;
  • 坐标缩放是否相同。

这份检查清单正是第14章在发挥作用。


16. 解读三片式镜头的 PSF

对于轴上视场,校正良好的三片式镜头可能有一个紧凑的 PSF,但不一定是完美的艾里斑图案。

对于轴外视场,PSF 可能变得不对称。能量可能从中心核区向外漂移。可能出现拖尾。对数尺度视图可能会显示出线性尺度下不明显的微弱结构。

分层解读 PSF:

中心核区

问:

主要能量有多集中?

这关系到锐度和类斯特列尔行为。

周围结构

问:

能量流向了哪里?

衍射环、拖尾和定向扩散可能在视觉上影响很大。

视场依赖性

问:

PSF 从中心到边缘如何变化?

这至关重要。一个镜头可能在轴上表现良好,而在边缘很弱。

波长依赖性

问:

不同波长是否产生了不同的 PSF 或焦面位置?

这指向了色差行为。

PSF 是镜头开始看起来像一个成像系统,而不仅是一个光线追迹模型的地方。


17. OTF 与 MTF:三片式镜头作为频率响应

现在从 PSF 计算 OTF 和 MTF:

def compute_otf_mtf(psf):
    otf = np.fft.fftshift(
        np.fft.fft2(
            np.fft.ifftshift(psf)
        )
    )

    center = (otf.shape[0] // 2, otf.shape[1] // 2)

    if np.abs(otf[center]) > 0:
        otf = otf / otf[center]

    mtf = np.abs(otf)

    return otf, mtf

然后:

otf, mtf = compute_otf_mtf(psf)

Optiland 风格的分析:

# 适用于 optiland==0.6.0 的已验证封装工作流。

mtf_result = run_mtf(
    optic,
    fields=fields,
    wavelengths=wavelengths,
    frequencies=[10, 20, 40, 80],
    directions=["sagittal", "tangential"],
)

mtf_result.plot()
mtf_result.report()

现在,MTF 不再神秘。

它是:

PSF
→ 傅里叶变换
→ OTF
→ 幅度
→ MTF

对于三片式镜头,MTF 曲线应当随视场和方向而变化。

一个典型的模式可能是:

  • 中心视场性能优于边缘视场;
  • 轴外时,弧矢和子午曲线分离;
  • 较高频率下降更快;
  • 多色 MTF 可能低于单色 MTF。

确切的结果取决于实际的处方、焦面、孔径和优化状态。

解读时,应始终将其与配置联系在一起。

不能说:

这是库克三片式镜头的 MTF。

而应说:

这是此三片式处方,
在这些视场、波长、孔径、焦面设定和频率方向下的 MTF。

这样才能避免对曲线过度解读。


18. 一个紧凑的分析运行器

对于一个真实的配套仓库,将全部分析封装到一个函数里很有用。

这个封装器不应隐藏链条,而应组织它。

# 已验证的适配器结构。

def analyze_triplet(optic, fields, wavelengths):
    """
    运行库克三片式镜头的主要分析。

    这是一个工作流大纲。实际实现应调用
    固定光学设计库的精确 API。
    """
    results = {}

    results["layout"] = plot_layout(
        optic,
        fields=fields,
        wavelengths=wavelengths
    )

    results["paraxial"] = optic.paraxial_analysis()

    results["spot"] = run_spot_diagram(
        optic,
        fields=fields,
        wavelengths=wavelengths
    )

    results["ray_fan"] = run_ray_fan(
        optic,
        fields=fields,
        wavelengths=wavelengths
    )

    results["wavefront"] = run_wavefront(
        optic,
        field=fields[1],
        wavelength=wavelengths[1]
    )

    results["psf"] = run_psf(
        optic,
        field=fields[1],
        wavelength=wavelengths[1]
    )

    results["mtf"] = run_mtf(
        optic,
        fields=fields,
        wavelengths=wavelengths
    )

    return results

然后:

before = analyze_triplet(
    optic,
    fields=fields,
    wavelengths=wavelengths
)

返回的字典不仅方便,它迫使我们记住,“镜头性能”是复数。

不存在一幅单一的图表能讲述全部故事。

一个严谨的优化前后对比应当包含若干视图。


19. 优化前:写下诊断

在优化前,做一份简短的诊断。

例如:

优化前:
- 有效焦距接近目标,但不精确。
- 轴上光斑可以接受。
- 边缘视场光斑被拉伸。
- 光扇图显示轴外不对称性。
- 中等视场波前显示出像散结构。
- PSF 向边缘拓宽。
- 在较高频率处,子午 MTF 比弧矢 MTF 下降更快。

这份诊断很重要,因为它告诉我们试图解决什么问题。

如果我们跳过诊断,直接进行优化,我们可能在未理解设计的情况下提升某个数字。

这是工程版本的简单医学法则:

在诊断之前不要开处方。

用光学设计的话说:

在决定哪种性能缺陷最重要之前,不要开始优化。

对于一个教学用三片式镜头,一个合理的目标可能是:

在保持焦距、合理厚度和实用曲率的同时,改善轴外像质。

现在我们可以设计评价函数了。


20. 选择变量

三片式镜头有许多可能的变量。

对于首次优化,不要把所有变量都释放。

从一个可控的集合开始,例如:

选定的表面曲率半径
选定的空气间隙
像面位置

首先固定玻璃不变。固定半孔径。保持整体结构可识别。

已验证的封装调用:

# 适用于 optiland==0.6.0 的已验证封装工作流。

variables = [
    optic.surface(1).radius,
    optic.surface(2).radius,
    optic.surface(3).radius,
    optic.surface(4).radius,
    optic.surface(5).radius,
    optic.surface(6).radius,
    optic.gap(2).thickness,
    optic.gap(4).thickness,
    optic.image_plane.position,
]

for variable in variables:
    variable.set_variable(True)

同样,具体的 API 名称取决于库版本。

这个概念是稳定的:

优化器只能改变你释放的变量。

如果你不释放像面位置,它就不能重新对焦。

如果你不释放曲率,它就不能改变镜片的光焦度分布。

如果你过早释放太多变量,设计可能变得不稳定或不切实际。

变量选择是一种设计判断。


21. 选择操作数

现在定义我们想要改进的内容。

一个教学用评价函数可能包括:

有效焦距目标
各视场 RMS 光斑尺寸
色差焦点控制
畸变控制
最小厚度约束
曲率惩罚
选定频率下的 MTF 目标

一个简化的评价函数结构:

M=wEFLfEFL2+wspotfspot2+wdistfdist2+wgeomfgeom2+wMTFfMTF2 M = w_\text{EFL} f_\text{EFL}^2 + w_\text{spot}\sum f_\text{spot}^2 + w_\text{dist}\sum f_\text{dist}^2 + w_\text{geom} f_\text{geom}^2 + w_\text{MTF}\sum f_\text{MTF}^2

如果您正在学习,不要一次性加入所有项。

分阶段优化更容易理解:

第一阶段

将镜头保持在期望的一阶布局附近:

有效焦距
后焦距
合理的厚度

第二阶段

改善几何成像:

RMS 光斑
光线像差
视场平衡

第三阶段

改善波动/像质指标:

波前 RMS
PSF 集中度
选定频率的 MTF

这种分阶段的方法不是胆怯,而是清晰。

优化器遵循评价函数。一个分阶段的评价函数有助于您理解它在遵循什么。


22. 验证用评价函数

这是一个伪代码形式的简化评价函数。

它不是为了不经过 API 调用调整就直接复制到 Optiland 中。它旨在展示设计逻辑。

def triplet_merit(optic, fields, wavelengths):
    """
    库克三片式镜头示例的验证用评价函数。

    越小越好。
    """
    merit = 0.0

    # 1. 一阶目标
    paraxial = optic.paraxial_analysis()

    target_efl = 50.0  # mm
    efl_error = (paraxial.efl - target_efl) / target_efl

    merit += 100.0 * efl_error**2

    # 2. 几何像质
    for field in fields:
        spot = run_spot_diagram(
            optic,
            fields=[field],
            wavelengths=wavelengths,
            return_data=True
        )

        rms_spot_um = spot.rms_radius_um

        allowed_spot_um = 20.0
        spot_error = rms_spot_um / allowed_spot_um

        merit += 1.0 * spot_error**2

    # 3. 畸变控制
    for field in fields[1:]:
        distortion_percent = optic.distortion(field=field)

        allowed_distortion = 2.0
        distortion_error = distortion_percent / allowed_distortion

        merit += 0.5 * distortion_error**2

    # 4. 几何惩罚
    geometry_penalty = optic.geometry_penalty(
        min_thickness=1.0,
        min_air_gap=0.5,
        max_abs_curvature=0.2,
    )

    merit += 10.0 * geometry_penalty

    return merit

这是一个验证用评价函数,而不是一个通用的三片式镜头设计目标。

这是一个翻译示例:

光学目标
→ 计算所得量
→ 归一化误差
→ 权重
→ 标量评价值

一个真实的库可能提供内置的评价操作数和优化器,因此您可能并不会完全按这种方式编写评价函数。但即便如此,其逻辑是相同的。


23. 运行优化

已验证的 Optiland 适配器工作流:

# 适用于 optiland==0.6.0 的已验证封装工作流。

optimizer_settings = {
    "method": "damped_least_squares",
    "max_iterations": 100,
    "tolerance": 1e-6,
}

optimized_optic, optimization_log = optimize_triplet(
    optic,
    variables=variables,
    merit_function=triplet_merit,
    fields=fields,
    wavelengths=wavelengths,
    settings=optimizer_settings,
)

如果使用更通用的 Python 优化器,其结构如下:

# 已验证的适配器结构。

from scipy.optimize import minimize


def pack_variables(optic):
    # 从光学系统中提取变量值。
    ...


def unpack_variables(optic, x):
    # 将变量值写回光学系统。
    ...


def merit_from_vector(x, optic, fields, wavelengths):
    unpack_variables(optic, x)
    return triplet_merit(optic, fields, wavelengths)


x0 = pack_variables(optic)

result = minimize(
    merit_from_vector,
    x0,
    args=(optic, fields, wavelengths),
    method="Nelder-Mead",
)

unpack_variables(optic, result.x)
optimized_optic = optic

对于真实的光学设计,专用优化器和库原生评价函数可能更好。这里的重点在于概念:

优化器改变变量。
每个新设计被分析。
评价函数对其评分。
优化器搜索更低的分数。

这个循环就是引擎。


24. 优化后:再次运行相同的分析

不要使用不同的分析设置来对比优化前后。

使用相同的视场、波长、孔径、焦面约定、采样和绘图尺度。

after = analyze_triplet(
    optimized_optic,
    fields=fields,
    wavelengths=wavelengths
)

现在进行比较。

compare_layout(before["layout"], after["layout"])
compare_spot_diagrams(before["spot"], after["spot"])
compare_ray_fans(before["ray_fan"], after["ray_fan"])
compare_wavefronts(before["wavefront"], after["wavefront"])
compare_psfs(before["psf"], after["psf"])
compare_mtfs(before["mtf"], after["mtf"])

这些封装函数名在配套验证包中针对 optiland==0.6.0 实现。

比较应当严格:

相同的视场
相同的波长
相同的频率轴
相同的归一化
相同的显示尺度

否则,您可能是在比较分析设置,而不是镜头性能。


25. 改善可能的样子

优化之后,您可能看到:

处方

曲率改变了,但保持在边界内。

空气间隙发生了移动。

像面轻微移动。

三片式镜头的形状仍然可识别。

点列图

轴上光斑保持紧凑。

中等视场和边缘视场光斑缩小。

波长分离可能减小。

某些视场可能比其他视场改善更多。

光扇图

曲线变得平坦或更平衡。

较大的非对称误差减少。

残余结构仍然存在,因为设计仍然是一个紧凑的三片式镜头。

波前

所选视场的光程差 RMS 降低。

波前图变得更平滑或不再那么极端。

某些像差项与其他项之间发生权衡。

PSF

中心能量变得更加集中。

轴外 PSF 不那么拉伸。

对数尺度的拖尾可能减小。

MTF

选定频率得到改善。

弧矢和子午曲线可能变得更接近。

边缘视场 MTF 可能上升,尤其是在中等频率处。

但并非一切事物都同等改善。

这是正常的。

优化是权衡管理,不是创造奇迹。

一份成熟的报告不仅要说:

什么改善了

还要说:

什么没有改善,或者什么变差了

这种坦诚是技术可靠性的一部分。


26. 优化前后报告表

固定的配套代码当前出此优化摘要:

指标优化前优化后备注
光斑 RMS 评价值 [mm^2]1.6625847615e-41.5389416611e-4降低了 7.44%
优化的变量R1, R3, R4, R6, BFDR1, R3, R4, R6, BFD有界 L-BFGS-B 方法
迭代次数020历史记录已保存于配套输出中

这些数值来自固定的 optiland==0.6.0 配套运行结果。评价值是 Optiland 的 SpotDiagram 中主波长 RMS 光斑半径的均方值。

报告格式很重要,因为最终的评价值本身并不足够。

它也让我们得以追问:

  • 我们所针对的指标改善了吗?
  • 重要的未指定指标是否降级了?
  • 几何形状是否仍然实用?
  • 视场平衡是否改善了?
  • 设计是否变得更敏感了?

这就是优化如何变成设计审查的。


27. MTF 为何改变

当 MTF 改善时,不要只停留在曲线上。

要问为什么。

一个可能的链条可能是:

曲率改变了
→ 轴外光线像差减小
→ 波前 OPD 减小
→ PSF 能量变得更集中
→ OTF 在选定频率处的幅度增大
→ MTF 曲线上升

这就是全链路的解释。

这比以下说法好得多:

优化器改善了 MTF。

那句话是正确的,但很浅薄。

更深入的解释将最终指标联系回物理和计算的原因。

例如:

  • 如果光扇图变得更平坦,横向像差就改善了。
  • 如果 OPD RMS 降低了,相位误差就减小了。
  • 如果 PSF 中心核区获得了能量,点像集中度就改善了。
  • 如果选定频率的 OTF 幅度增大了,对比度传递就改善了。

现在,MTF 曲线不再是孤立的。

它是你可检验的链条中的最后一环。


28. 当优化使某些东西变得更差

有时,优化后的设计评价值更低,却带来了意外的糟糕情况。

例如:

边缘厚度变得太小
畸变增加
一个视场改善,而另一个视场变差
色差行为恶化
表面曲率变得极端
MTF 在某一频率处改善,但在另一频率处下降

这不一定意味着软件故障。

这可能意味着评价函数没有包含正确的惩罚项、权重、视场、波长或约束。

回到评价函数。

问:

糟糕的行为被测量了吗?
它被赋予了足够的权重吗?
它受到约束了吗?
变量空间过度自由了吗?
初始设计是否很差?

然后进行修改。

例如:

  • 增加最小厚度惩罚;
  • 包含更多的视场点;
  • 添加波长权重;
  • 添加畸变操作数;
  • 约束曲率;
  • 改变优化阶段;
  • 如果三片式结构无法达到目标,改变镜头架构。

这才是真正的循环:

分析
→ 诊断
→ 修订评价函数
→ 优化
→ 再次分析

优化器不能替代这个循环,它只是其中一部分。


29. 库克三片式镜头作为全链路图谱

现在让我们将整个三片式工作流写成一幅地图。

1. 处方
   表面、厚度、材料、孔径

2. 配置
   视场、波长、孔径、焦面、采样

3. 近轴分析
   有效焦距、后焦距、F数、光瞳量

4. 实际光线追迹
   表面交点、折射、像面拦截点

5. 点列图
   按视场和波长的光线落点分布

6. 光扇图
   作为光瞳坐标函数的横向误差

7. 波前 / 光程差
   光瞳上的光程差

8. 光瞳函数
   振幅掩膜和来自光程差的相位

9. PSF
   复光瞳的傅里叶变换,幅度平方

10. OTF
    PSF 的傅里叶变换

11. MTF
    OTF 的幅度,按方向和频率截取

12. 评价函数
    来自所选操作数的加权误差

13. 优化
    降低评价值的变量更新循环

14. 优化前后审查
    比较所有相关图形和约束

这就是本书的列表概览。

每一项之前都已出现过。三片式镜头只不过迫使它们协同工作。

这种协同才是真正的成就。


30. 完整验证脚本大纲

验证包包含以下高层脚本结构。封装函数针对 optiland==0.6.0 实现,库版本若变化应重新验证。

# cooke_triplet_full_chain.py
#
# 验证脚本大纲。
# 封装函数针对 optiland==0.6.0 实现。

from optics_examples import (
    make_cooke_triplet,
    set_fields,
    set_wavelengths,
    plot_layout,
    run_paraxial_analysis,
    run_spot_diagram,
    run_ray_fan,
    run_wavefront,
    run_psf,
    run_mtf,
    set_triplet_variables,
    build_triplet_merit_function,
    run_optimization,
    compare_reports,
)


def main():
    # 1. 构建系统
    optic = make_cooke_triplet()

    # 2. 定义配置
    fields = [
        {"name": "axis", "angle_deg": 0.0},
        {"name": "mid",  "angle_deg": 7.0},
        {"name": "edge", "angle_deg": 14.0},
    ]

    wavelengths = [
        {"name": "blue",  "wavelength_m": 486.1e-9, "weight": 1.0},
        {"name": "green", "wavelength_m": 587.6e-9, "weight": 1.0},
        {"name": "red",   "wavelength_m": 656.3e-9, "weight": 1.0},
    ]

    set_fields(optic, fields)
    set_wavelengths(optic, wavelengths)

    # 3. 优化前分析
    before = {}

    before["layout"] = plot_layout(optic)
    before["paraxial"] = run_paraxial_analysis(optic)
    before["spot"] = run_spot_diagram(optic, fields, wavelengths)
    before["ray_fan"] = run_ray_fan(optic, fields, wavelengths)
    before["wavefront"] = run_wavefront(optic, fields, wavelengths)
    before["psf"] = run_psf(optic, fields, wavelengths)
    before["mtf"] = run_mtf(optic, fields, wavelengths)

    # 4. 定义变量与评价函数
    variables = set_triplet_variables(
        optic,
        vary_radii=True,
        vary_air_spaces=True,
        vary_image_plane=True,
        keep_glass_fixed=True,
    )

    merit_function = build_triplet_merit_function(
        optic,
        fields=fields,
        wavelengths=wavelengths,
        target_efl_mm=50.0,
        max_distortion_percent=2.0,
        min_thickness_mm=1.0,
        mtf_targets=[
            {"field": "axis", "frequency_cyc_per_mm": 40, "target": 0.60},
            {"field": "edge", "frequency_cyc_per_mm": 40, "target": 0.30},
        ],
    )

    # 5. 优化
    optimized_optic, log = run_optimization(
        optic,
        variables=variables,
        merit_function=merit_function,
        max_iterations=100,
    )

    # 6. 优化后分析
    after = {}

    after["layout"] = plot_layout(optimized_optic)
    after["paraxial"] = run_paraxial_analysis(optimized_optic)
    after["spot"] = run_spot_diagram(optimized_optic, fields, wavelengths)
    after["ray_fan"] = run_ray_fan(optimized_optic, fields, wavelengths)
    after["wavefront"] = run_wavefront(optimized_optic, fields, wavelengths)
    after["psf"] = run_psf(optimized_optic, fields, wavelengths)
    after["mtf"] = run_mtf(optimized_optic, fields, wavelengths)

    # 7. 对比
    compare_reports(before, after, optimization_log=log)


if __name__ == "__main__":
    main()

这个脚本并非故弄玄虚,而是刻意保持可读性。

它的结构就是全链路方法:

构建
→ 配置
→ 分析
→ 优化
→ 再次分析
→ 对比

这正是本书一直在瞄准的实用工作流。


31. 图形集中应包含什么

对于本章最终版本或配套笔记本,应包含一组固定的图形。

图 1:布局图

目的:

展示三片式镜头的几何形状和光线路径。

图 2:优化前的点列图

目的:

按视场和波长展示几何像质。

图 3:优化前的光扇图

目的:

揭示光瞳上的像差结构。

图 4:光程差图

目的:

展示将变为光瞳相位的波前误差。

图 5:优化前后的 PSF

目的:

展示点像能量分布如何变化。

图 6:优化前后的 MTF

目的:

按视场和方向展示对比度传递的改善。

图 7:处方变化表

目的:

展示优化器实际改变了什么。

图 8:操作数报告

目的:

展示哪些目标改善了,哪些约束仍然是限制性的。

这组图形可防止本章内容变成一堆互不相干的图表。

每一幅图都有其作用。


32. 最重要的比较:相同设置

在比较优化前后时,保持分析设置完全相同。

这一点非常重要,值得重复。

使用相同的:

视场
波长
孔径
焦面约定
光瞳采样
PSF 补零
MTF 频率轴
归一化
图形界限
尽可能的色彩比例

如果优化前后使用不同的设置,对比就变得很弱。

例如,如果优化前的 PSF 以对数尺度显示,而优化后的 PSF 以线性尺度显示,视觉对比就变得具有误导性。

如果优化前的 MTF 用的是单色绿光,而优化后的 MTF 用的是多色加权,这些曲线回答的就不是同一个问题。

如果优化前的边缘视场是 14 度,而优化后的边缘视场是 10 度,这种改善可能是虚假的。

一个好的对比是乏味地严格的。

正是这种乏味的严格,使得结果值得信赖。


33. 本章证明了什么

本章并不证明库克三片式镜头是最好的镜头。

它不证明我们的教学用评价函数可以投入生产。

它不证明每一个光学问题都应该用相同的工作流来解决。

它证明的是对本书更核心的一点:

光学设计软件的输出可以连接到一个可见的计算链上。

MTF 曲线不是孤立的。

PSF 不是孤立的。

波前图不是孤立的。

点列图不是孤立的。

每一个都是由更早的数据和更早的计算产生的。

一旦你了解了这个链条,就能更聪明地审视结果。

你可以问:

  • 处方正确吗?
  • 视场和波长合适吗?
  • 光线追迹正确吗?
  • 波前参考明确吗?
  • 光瞳函数是用正确的相位和掩膜构建的吗?
  • PSF 采样正确吗?
  • MTF 归一化和标注正确吗?
  • 评价函数衡量的是我们所关心的吗?
  • 优化是改进了设计,还是仅仅提高了分数?

这些问题才是实用技能。


34. 从 MTF 回溯到处方的最终演练

让我们练习一下反向解读。

假设优化后的三片式镜头在 40 线对/毫米处,边缘视场的子午 MTF 得到改善。

不要就此止步。

向后走:

MTF 改善
→ 该频率和方向的 OTF 幅度改善
→ 对于该视场,PSF 变得更好
→ 光瞳相位误差发生了变化
→ OPD 图改善
→ 光线像差发生了变化
→ 表面曲率和间隔发生了变化
→ 优化器根据评价函数改变了变量

这种反向解读能力是很强大的。

它将一幅图转化为一个关于计算和设计的故事。

它还能帮助你挑战这个结果。

如果 MTF 改善了,但 OPD 没有改善,这是为什么?如果光斑改善了,但 MTF 没有改善,这是为什么?如果评价值下降,但边缘 PSF 变差了,评价函数忽略了什么?

问出这些问题的能力,就是使用光学软件与理解光学计算之间的区别。


35. 我们此刻所处的位置

现在,我们已经完成了本书开篇所承诺的经典计算路径。

这条路径始于一个问题:

一条 MTF 曲线从哪里来?

现在,我们能够详细回答。

它来自 OTF。

OTF 来自 PSF。

PSF 来自复光瞳函数。

光瞳函数来自振幅和相位。

相位来自光程差。

光程差来自通过镜头计算的光程差。

那些光程来自追迹光线。

那些光线来自表面、材料、波长、视场和孔径。

而优化改变了处方,使得选定的被计算量朝着选定的目标移动。

这就是那条链。

它并不短,但不再隐藏。

下一章将向前展望。现代计算光学越来越希望这整条链是可微分的、可编程的,并连接到更大的成像系统。这便是自动微分和可微光线追迹进入的领域。

但在到达那里之前,我们需要这一章。

一个可微分的光学系统仍然是一个光学系统。

如果你不理解这条经典链路,自动微分只会给你一条更快的途径来产生困惑。

现在,我们已将此链握在手中。


本章可复现性检查清单

配套代码应通过四项检查:

1. 根据本章表格重建教学处方。
2. 从同一系统生成布局图、点列图、光扇图、光程差图、PSF 和 MTF 输出。
3. 使用相同的视场、波长、孔径、焦面、采样、归一化和绘图设置进行优化前后分析。
4. 用固定代码生成的数值替换任何示意性的报告数字。

这是刻意保持严格的。本章是本书的全链路测试。它应让人感觉像一次审查,而不是一个装饰性的例子。


本章小结

库克三片式镜头是一个有用的全链条实例,因为它足够小,便于检视,但又足够丰富,能展示真实的光学设计行为。

完整的工作流是:

处方
→ 配置
→ 近轴分析
→ 实际光线追迹
→ 点列图
→ 光扇图
→ 光程差 / 波前
→ 光瞳函数
→ PSF
→ OTF
→ MTF
→ 评价函数
→ 优化
→ 优化前后对比

处方是起始数据。视场、波长、孔径和焦面设定定义了分析配置。实际光线追迹产生点列图和光扇图。光程差产生光程差图。光程差变成光瞳相位。光瞳函数产生 PSF。PSF 产生 OTF 和 MTF。

优化改变选定变量以降低评价函数。它不会自动知道“更好”是什么意思。评价函数定义了目标。

一项可信赖的优化前后对比必须使用相同的视场、波长、采样、归一化和绘图设置。更低的评价值并不足够;设计必须通过布局图、处方、点列图、光扇图、波前、PSF、MTF 和几何约束来审查。

主要经验简单而重要:

最终的 MTF 曲线是一条漫长计算链中最后可见的一环。

本章使用一个完整的镜头走过了这一链条。

生成的验证图形

从第 16 章全链路检查生成的库克三片式镜头光程差、PSF 和 MTF 图

从第 16 章有界优化运行生成的库克三片式镜头评价优化历史

按迭代记录的评价历史被记录在 ../figures/chapter_16/cooke_optimization_history.csv 文件中。

来源与验证说明

本章是手稿的全链路验证锚点。所报告的优化前后评价值来自固定的配套运行结果,使用的是 optiland==0.6.0;不应手动编辑。如果处方、边界、变量、视场、波长、采样或 Optiland 版本发生变化,请重新运行验证,并替换本章图形以及记录的评价历史。

第17章:为什么可微光学重要?

上一章我们了解了经典的光学设计循环。

我们有一个处方。我们追迹光线。我们计算了点列图、波前图、PSF、MTF曲线,以及一个评价函数。然后优化器改变变量,试图降低那个评价函数。

这个循环已经很强大了:

镜头处方
→ 分析
→ 评价函数
→ 优化器
→ 更新处方

但“优化器”这个词背后隐藏着一个问题。

优化器是如何知道该往哪个方向移动的?

在一个简单的单变量聚焦问题中,它可以尝试附近的焦点位置,看看评价是否变好。而在一个有着许多半径、厚度、非球面系数、材料和像质指标的真实镜头中,盲目的试错就变得昂贵了。

这就是梯度变得重要的地方。

梯度告诉我们,当一个参数变化时,结果会怎样变化。

例如:

如果我稍微增加这个面的曲率,
RMS光斑是变好还是变差?

如果我将这个元件移动0.01毫米,
40线对/毫米处的MTF是上升还是下降?

如果我改变这个相位掩模参数,
最终重建图像的损失是减小还是增大?

可微光学就是要让这些问题变得可计算。

它不会取代光学。它不会使光线追迹变得多余。它也不会消除对物理判断的需求。

它增加了一项强大的能力:

计算机不仅能计算光学结果,
还能计算这个结果如何随设计参数变化。

这就是自动微分、可微光线追迹,以及现代端到端计算成像的入口。


1. 这里“可微”的含义

如果一个函数输入的微小变化能在输出中产生可由导数描述的变化,那么这个函数就是可微的。

在光学设计中,我们通常有如下的链条:

设计参数
→ 光线追迹
→ 波前
→ PSF
→ 像质指标
→ 损失

这个链条的可微版本允许我们计算:

当每个设计参数变化时,损失如何变化

在数学上,如果我们将设计参数收集到一个向量中:

x=[x1,x2,,xn] \mathbf{x} = [x_1, x_2, \ldots, x_n]

并且评价函数或损失为:

L(x) L(\mathbf{x})

那么梯度就是:

L=[Lx1,Lx2,,Lxn] \nabla L = \left[ \frac{\partial L}{\partial x_1}, \frac{\partial L}{\partial x_2}, \ldots, \frac{\partial L}{\partial x_n} \right]

每个分量告诉我们损失对一个变量的局部敏感度。

如果:

Lx3>0 \frac{\partial L}{\partial x_3} > 0

那么增加 x3x_3 倾向于会使损失局部增大。

如果:

Lx3<0 \frac{\partial L}{\partial x_3} < 0

那么增加 x3x_3 倾向于会使损失局部减小。

一个基于梯度的优化器可以利用这些信息,比盲目搜索更聪明地进行移动。

这是第一个关键思想:

可微光学为优化提供了方向信息。

优化器不再只需要评价函数的值了。它还能知道哪些变量在使评价函数变大,哪些在使其变小。


2. 自动微分并非数值猜测

估计导数有几种方法。

一种简单的方法是有限差分。

假设我们有一个函数:

L(x) L(x)

我们可以通过两次计算函数值来估计导数:

dLdxL(x+ϵ)L(x)ϵ \frac{dL}{dx} \approx \frac{L(x+\epsilon)-L(x)}{\epsilon}

这很容易理解,但它存在问题:

  • 对很多变量来说,速度可能很慢;
  • 依赖于选择一个好的 ϵ\epsilon
  • 可能是带噪声的;
  • 需要很多次重复计算;
  • 在大型计算图中变得笨拙。

自动微分(automatic differentiation,常简称为 autodiff)的工作方式则不同。

它记录用于计算输出的操作,然后通过这些操作运用链式法则。

如果计算是:

xabL x \rightarrow a \rightarrow b \rightarrow L

那么 autodiff 会通过链式法则计算导数:

dLdx=dLdbdbdadadx \frac{dL}{dx} = \frac{dL}{db} \frac{db}{da} \frac{da}{dx}

这在传统意义上并不是符号代数。它也不是一个粗糙的有限差分估计。它是通过实际执行的操作来对导数进行计算。

现代机器学习库,例如 PyTorch 和 JAX,就是围绕这个思想建立的。

对于光学计算来说,这之所以重要,是因为我们的计算是成链状的:

半径
→ 面交点
→ 折射
→ 光线位置
→ 光斑尺寸
→ 损失

如果每一步都是用可微操作实现的,那么 autodiff 就能计算损失如何随着半径变化。

这就是它的基本承诺。


3. 一个小巧的 PyTorch 示例

在接触光学量之前,我们先看一个标量示例。

我们希望 xx 变成 3,因此我们定义:

L(x)=(x3)2 L(x) = (x-3)^2

其导数为:

dLdx=2(x3) \frac{dL}{dx}=2(x-3)

x=0x=0 处,导数为:

6 -6

那意味着增大 xx 将减小损失。

在 PyTorch 中:

import torch

x = torch.tensor([0.0], requires_grad=True)

loss = (x - 3.0) ** 2

loss.backward()

print("x:", x.item())
print("loss:", loss.item())
print("d loss / d x:", x.grad.item())

输出的梯度应该接近于:

-6

现在我们可以手动进行梯度下降:

import torch

x = torch.tensor([0.0], requires_grad=True)

learning_rate = 0.1

for step in range(30):
    loss = (x - 3.0) ** 2

    loss.backward()

    with torch.no_grad():
        x -= learning_rate * x.grad

    x.grad.zero_()

print("optimized x:", x.item())

变量应该会朝着 3 移动。

这个例子虽然小,但它包含了整个模式:

定义变量
→ 计算损失
→ 计算梯度
→ 更新变量
→ 重复

可微光学使用相同的模式,但损失来自于光学计算。


4. 一个可微的薄透镜示例

现在,让我们把这个例子变成光学的。

我们将使用空气中的薄透镜模型:

Φ=(n1)(c1c2) \Phi = (n-1)(c_1-c_2)

其中:

  • Φ\Phi 是光焦度;
  • nn 是折射率;
  • c1c_1c2c_2 是表面曲率。

焦距为:

f=1Φ f = \frac{1}{\Phi}

假设我们想要一个 50 毫米的焦距。

我们可以让 c1c_1c2c_2 成为可训练的变量。

import torch

# 可训练的曲率,单位 1/mm
c1 = torch.tensor([0.02], requires_grad=True)
c2 = torch.tensor([-0.02], requires_grad=True)

n = 1.5
target_focal_length = 50.0

optimizer = torch.optim.SGD([c1, c2], lr=0.001)

for step in range(200):
    optimizer.zero_grad()

    power = (n - 1.0) * (c1 - c2)
    focal_length = 1.0 / power

    relative_error = (focal_length - target_focal_length) / target_focal_length

    curvature_penalty = 0.01 * (c1**2 + c2**2)

    loss = relative_error**2 + curvature_penalty

    loss.backward()
    optimizer.step()

print("c1:", c1.item())
print("c2:", c2.item())
print("focal length:", focal_length.item())
print("loss:", loss.item())

这是一个玩具模型,但它展示了机制。

PyTorch 计算了损失如何依赖于 c1c_1c2c_2。然后优化器更新了那些曲率。

没有手动编写有限差分循环。

计算图是:

c1, c2
→ 光焦度
→ 焦距
→ 焦距误差
→ 损失

Autodiff 在这个图中计算梯度。

这就是最简版本的可微光学设计。


5. 一个可微的傍轴光线束

前面的例子只优化了焦距。让我们朝成像再靠近一步。

在傍轴光学中,薄透镜会大致按如下方式改变光线斜率:

uout=uinyf u_\text{out}=u_\text{in}-\frac{y}{f}

其中:

  • yy 是透镜处的光线高度;
  • uu 是光线角度或斜率;
  • ff 是焦距。

传播距离 zz 后,光线高度变为:

yimage=y+zuout y_\text{image}=y+z u_\text{out}

我们可以用 PyTorch 构建一个可微的光线束。

import torch

dtype = torch.float64

# 透镜处的光线高度,单位 mm
y = torch.linspace(-5.0, 5.0, 41, dtype=dtype)

# 入射的准直光线
u_in = torch.zeros_like(y)

# 可训练的透镜曲率和像面位置
c1 = torch.tensor([0.02], dtype=dtype, requires_grad=True)
c2 = torch.tensor([-0.02], dtype=dtype, requires_grad=True)
z_image = torch.tensor([50.0], dtype=dtype, requires_grad=True)

n = 1.5

optimizer = torch.optim.Adam([c1, c2, z_image], lr=0.01)

for step in range(1000):
    optimizer.zero_grad()

    power = (n - 1.0) * (c1 - c2)
    focal_length = 1.0 / power

    u_out = u_in - y / focal_length

    y_image = y + z_image * u_out

    rms_spot = torch.sqrt(torch.mean(y_image**2))

    target_focal_length = 50.0
    focal_error = (focal_length - target_focal_length) / target_focal_length

    curvature_penalty = 0.001 * (c1**2 + c2**2)

    loss = rms_spot**2 + 10.0 * focal_error**2 + curvature_penalty

    loss.backward()
    optimizer.step()

print("c1:", c1.item())
print("c2:", c2.item())
print("z_image:", z_image.item())
print("focal length:", focal_length.item())
print("rms spot:", rms_spot.item())
print("loss:", loss.item())

这仍然是傍轴且简化的。但它具有可微光线优化的结构:

可训练的光学参数
→ 光线计算
→ 像面截距
→ RMS光斑损失
→ 梯度
→ 参数更新

梯度告诉优化器如何改变曲率和像面位置以减少损失。

这就是核心思想。


6. 这与第15章有什么不同

在第15章中,我们对评价函数使用了 scipy.optimize.minimize。那已经是优化了。

那么这里有什么新东西呢?

区别不在于评价函数的存在。两种方法都有评价函数。

区别在于导数的信息是如何获得和使用的。

一个无导数的优化器可以通过多次计算评价函数的值来推断该在哪里搜索。

而一个基于梯度的可微工作流是通过光学计算来计算梯度的。

这意味着我们可以直接询问:

Lc1,Lc2,Lz \frac{\partial L}{\partial c_1}, \quad \frac{\partial L}{\partial c_2}, \quad \frac{\partial L}{\partial z}

对于大型系统,这可能非常有价值。

区别是:

传统评价函数优化:
评估评价函数,搜索更低的值。

可微优化:
评估评价函数,并通过计算过程计算梯度。

两者都可以有用。

可微光学并没有让传统优化过时。它给了我们另一套工具,尤其是在光学系统是更大可微流水线的一部分的时候。


7. 可微光线追迹

一个可微光线追迹器就是一个以某种方式编写的、能让导数流过其操作的光线追迹器。

一个普通的光线追迹器会计算:

表面参数 + 入射光线
→ 交点
→ 表面法线
→ 折射方向
→ 出射光线

一个可微的光线追迹器还允许计算诸如以下的导数:

如果表面半径改变,出射光线会怎样变化
如果厚度改变,像面截距会怎样变化
如果某个非球面系数改变,损失会怎样变化

光线追迹的操作必须是可微的,或者需要小心处理:

  • 光线与面的交点;
  • 表面法线的计算;
  • 折射;
  • 传播;
  • 光程;
  • 光线截距误差;
  • 波前误差。

对于球面,交点涉及求解一个二次方程。只要我们避开诸如未命中交点或分支改变之类的不连续性,这个解就可以是可微的。

对于折射光线,矢量斯涅尔公式只要避免发生全反射或小心处理,就可以是可微的。

因此,可微光线追迹链可能看起来像:

表面半径
→ 交点距离
→ 交点
→ 法向量
→ 折射方向
→ 像面截距
→ 损失
→ 相对于半径的梯度

这在概念上和我们之前构建的光线追迹没有不同。

它只是让同样的物理规律变得与梯度计算兼容了。


8. 一个最小化的可微折射示意

我们来勾勒一下矢量归一化和折射的可微版本。

这不是一个完整、稳健的生产实现。这只是一个教学片段。

import torch


def normalize(v, eps=1e-12):
    return v / torch.sqrt(torch.sum(v * v, dim=-1, keepdim=True) + eps)


def refract_torch(direction, normal, n1, n2):
    """
    可微的矢量折射示意。

    direction 和 normal 是形状为 (..., 3) 的张量。
    两者都应已归一化。

    此函数假定没有发生全反射。
    """
    direction = normalize(direction)
    normal = normalize(normal)

    eta = n1 / n2

    cos_i = -torch.sum(normal * direction, dim=-1, keepdim=True)

    sin_t2 = eta**2 * (1.0 - cos_i**2)

    cos_t = torch.sqrt(1.0 - sin_t2)

    refracted = eta * direction + (eta * cos_i - cos_t) * normal

    return normalize(refracted)

这里的操作是 PyTorch 的张量操作。如果 directionnormal,或者用于计算它们的表面参数需要梯度,PyTorch 就能让导数流经这个函数。

但请注意这个警告:

此函数假定没有发生全反射。

如果:

1sin2θt<0 1-\sin^2\theta_t < 0

那么平方根对于折射就变成无效的了。

一个生产级的可微光线追迹器必须处理那种情形。它可以使用掩膜、惩罚项、替代分支,或者小心限定域。

这是一个反复出现的主题:

让光学可微并不会消除光学的边界情况。
它会让那些边界情况变得更加重要。

9. 可微性变得困难的地方

上面这些清晰的例子是平滑的。

从优化器的视角看,真实的光学系统并不总是平滑的。

有几件事会造成困难。

光线未命中与孔径裁切

如果一条光线在某个参数值下能通过孔径,而在另一个值下被阻挡,那么损失就可能发生不连续的改变。

这条光线要么存在,要么不存在。

那不是平滑的改变。

全反射

当入射角穿过临界角时,折射可能突然变得不可能。

这在计算中形成了一个分支。

表面交点分支改变

一个光线-球面的交点可能有两个数学根。在普通光线追迹中选择物理上正确的那个通常是直截了当的,但分支的选择可能会带来导数方面的复杂问题。

渐晕

与视场相关的光线裁切可能引入硬边界。

微小的参数改变可能导致光线的出现或消失。

玻璃选择

从玻璃目录中选择一种玻璃是离散的。除非引入一个连续的代理模型,否则你无法平滑地从一种玻璃类型微分到另一种。

带有硬阈值的评价函数

像这样的一个损失并非处处平滑:

penalty = max(0.0, value - limit)

它虽然可以使用,但导数在阈值处会有一个转折点。

FFT 幅度和相位问题

PSF 和 MTF 的计算可以是可微的,但幅度、归一化、采样和相位行为仍然需要注意。

结论并不是“可微光学太脆弱了”。

结论是:

当计算路径是本着梯度思想来设计时,可微光学才能发挥最佳作用。

那意味着平滑的损失函数、谨慎的掩膜、稳定的参数化,以及良好的物理约束。


10. 平滑的损失函数通常优于硬性规则

在第15章中,我们讨论了硬约束和软惩罚。

可微优化通常更偏好平滑的惩罚项。

例如,假设一个厚度应该保持在 1 毫米以上。

一个硬性失败判定可以是:

if thickness < 1.0:
    loss = huge_number

这对于剔除不可能的设计可能有用,但它给出的梯度信息很差。

一个更平滑的惩罚项是:

penalty = torch.relu(1.0 - thickness) ** 2

这会惩罚低于限值的厚度,同时在违规发生时给出梯度。

对于一个更平滑的版本,可以使用类似softplus的函数:

def soft_lower_bound_penalty(value, lower, sharpness=20.0):
    violation = lower - value
    return torch.nn.functional.softplus(sharpness * violation) / sharpness

这并不能代替硬性的工程限制。但它在搜索时给了优化器一个更平滑的路径。

一个良好的实践模式是:

用平滑的惩罚项来引导优化。
用硬性检查来剔除最终无效的设计。

这在优化具有许多变量的光学系统时尤其有帮助。


11. 可微的 PSF 和 MTF

前面几章的 PSF 和 MTF 计算也可以成为可微流水线的一部分。

一个简化的 PyTorch PSF 计算如下所示:

import torch


def pupil_function_torch(opd, mask, wavelength):
    phase = 2.0 * torch.pi * opd / wavelength

    pupil = mask * torch.exp(1j * phase)

    return pupil


def psf_from_pupil_torch(pupil):
    field = torch.fft.fftshift(
        torch.fft.fft2(
            torch.fft.ifftshift(pupil)
        )
    )

    psf = torch.abs(field) ** 2
    psf = psf / torch.sum(psf)

    return psf


def mtf_from_psf_torch(psf):
    otf = torch.fft.fftshift(
        torch.fft.fft2(
            torch.fft.ifftshift(psf)
        )
    )

    center = (otf.shape[0] // 2, otf.shape[1] // 2)

    otf = otf / otf[center]

    mtf = torch.abs(otf)

    return mtf

如果 opd 依赖于可训练的参数,那么由 mtf 计算出的损失就能将梯度反向传播经过:

MTF
→ OTF
→ PSF
→ 光瞳函数
→ OPD
→ 光学参数

这非常强大。

例如,一个损失可以将目标设定为在一个选定的频率上获取高 MTF:

def mtf_loss_at_sample(mtf, fy, fx, target=0.5):
    value = mtf[fy, fx]

    error = torch.relu(target - value)

    return error**2

这只是一个采样的教学示例。一个真实的实现需要正确的频率坐标、视场处理、波长处理、采样和归一化。

但思路是明确的:

如果 PSF 和 MTF 是用可微的张量运算计算出来的,
它们就可以在基于梯度的优化中使用。

这直接把第13章和第14章的内容与现代优化联系了起来。


12. 一个微小的可微相位掩模示例

我们再构建一个更接近计算成像而非经典透镜弯曲的示例。

假设我们有一个光瞳,带有一个可训练的、类似离焦的相位系数。我们希望 PSF 的能量集中在中心。

这不是一个完整的透镜设计。这是一个小型的可微傅里叶光学示例。

import torch
import matplotlib.pyplot as plt

dtype = torch.float64
device = "cpu"

n = 128
coord = torch.linspace(-1.0, 1.0, n, dtype=dtype, device=device)
yy, xx = torch.meshgrid(coord, coord, indexing="ij")

rho = torch.sqrt(xx**2 + yy**2)
mask_real = (rho <= 1.0).to(dtype)

mask = mask_real.to(torch.complex128)

# 可训练的离焦系数,以波长为单位
defocus_waves = torch.tensor([0.5], dtype=dtype, requires_grad=True)

optimizer = torch.optim.Adam([defocus_waves], lr=0.05)

for step in range(200):
    optimizer.zero_grad()

    opd_waves = defocus_waves * (2.0 * rho**2 - 1.0)
    phase = 2.0 * torch.pi * opd_waves

    pupil = mask * torch.exp(1j * phase.to(torch.complex128))

    field = torch.fft.fftshift(
        torch.fft.fft2(
            torch.fft.ifftshift(pupil)
        )
    )

    psf = torch.abs(field) ** 2
    psf = psf / torch.sum(psf)

    center = (n // 2, n // 2)

    # 我们想最大化中心强度,所以最小化负的峰值。
    loss = -psf[center]

    loss.backward()
    optimizer.step()

print("optimized defocus coefficient [waves]:", defocus_waves.item())
print("center intensity:", psf[center].item())

优化器应该将离焦系数推向一个能增加中心 PSF 能量的值,通常是朝着减少离焦的方向。

这是一个微小的例子,但它很重要,因为整个路径都是可微的:

相位系数
→ 光瞳相位
→ FFT 场
→ PSF
→ 中心强度损失
→ 梯度
→ 相位系数更新

这种计算风格正是让现代可微光学具有吸引力的原因。

我们不再局限于只优化光线截距或简单的标量操作数。我们可以通过像差计算过程来优化。


13. 端到端计算成像

经典的透镜设计常常将镜头视为主要对象。

现代计算成像常常将整个成像系统视为对象:

场景
→ 光学
→ 传感器
→ 噪声
→ 图像处理
→ 最终图像或任务损失

在一个端到端的可微流水线中,这些阶段中的一部分或全部可能是可微的。

这允许了一种非常不同的优化方式。

我们不再只问:

这个镜头有高的 MTF 吗?

而是可以问:

整个成像系统,在考虑了光学、传感器采样、噪声和重建之后,
是否产生了最佳的最终结果?

例如,一个计算相机可能故意使用一个按经典标准看起来很奇怪的 PSF 的光学元件,因为一个重建算法能很好地解码它。

一个波前编码系统可能刻意让 PSF 对离焦不那么敏感。

一个超表面或衍射光学元件可能和一个神经网络重建网络一起被设计。

一个镜头和图像处理模型可能一起为一个特定任务进行训练。

可微光学在这里变得尤为重要。

如果整个流水线是可微的,我们就可以计算从最终损失一路返回到光学参数的梯度。

链条就变成了:

最终图像损失
→ 重建算法
→ 传感器模型
→ PSF 或波动光学模型
→ 光学参数

这就是端到端的优化。

但这种力量带有一个警告。

如果模拟是不切实际的,那么优化出的系统可能在模拟中表现良好,而在现实中表现很差。

这有时被称为模拟到现实的差距。

对于光学来说,这种差距可能来自:

  • 不精确的材料模型;
  • 制造误差;
  • 对准公差;
  • 温度效应;
  • 传感器噪声假设;
  • 衍射模型的简化;
  • 偏振效应;
  • 杂散光;
  • 镀膜行为;
  • 不完美的标定;
  • 重建模型的假设。

可微性并不能消除这些问题。它甚至可能把它们隐藏在漂亮的训练曲线后面。

一个谨慎的说法是:

端到端的可微优化只和它所优化的物理模型及损失函数一样可信。

每当这种方法显得神奇时,这句话都应该被放在近旁。


14. 经典指标仍然重要

可微光学可以直接优化图像损失、任务损失或是学习到的感知指标。

但这并没有让经典光学指标过时。

点列图、波前图、PSF、MTF、畸变、场曲和公差分析依然重要,因为它们揭示了物理行为。

一个神经重建指标可能会说最终图像看起来不错,而光学系统却存在:

  • 严重的视场相关模糊;
  • 对离焦高度敏感;
  • 易碎的对准;
  • 糟糕的颜色表现;
  • 不可接受的畸变;
  • 低光效率;
  • 在训练分布之外的强烈伪影。

经典指标不是该被扔掉的旧家具。

它们是诊断仪器。

一个好的现代工作流可能同时包含两者:

经典光学指标
+
可微的端到端损失

经典指标告诉我们光学在做什么。

端到端的损失告诉我们,整个成像系统在某个特定任务上的表现如何。

两者相关但并不等同。

这是一种思考可微光学的成熟方式:

它扩展了设计空间。
它并未取消理解光学的必要性。

15. 可微并不意味着物理上无约束

基于梯度的优化能够发现出人意料的设计。

这可能是好事。

它也可能创造出难以制造、难以对准或难以解释的设计。

例如,如果一个相位掩模是用自由像素来表示的,优化器可能会创造出一个高度振荡的相位图案。它在模拟中可能表现良好,但却无法制造,或对波长过于敏感。

如果一个非球面有太多自由系数,优化器可能会创造出一个斜率变化很大或具有难测要求的面形。

如果一个材料参数被允许连续变化,优化器可能会选择一个不对应于任何真实玻璃的折射率。

因此,参数化很重要。

参数化就是我们描述设计变量的方式。

例如:

表面半径
圆锥常数
非球面系数
泽尼克相位系数
自由曲面系数
衍射区高度
像素化的相位掩模值
玻璃插值变量
机械间隔

一个好的参数化在自由有用时给优化器自由,在必要时给优化器限制。

这是设计判断的另一种形式。

优化器只跟随梯度。除非可制造性出现在模型或约束中,否则它并不知道哪些形状是可制造的。


16. 稳定的参数化:一个例子

假设我们想优化一个相位掩模。

一种诱人的参数化是:

每个像素一个自由的相位值

这给了极大的自由度。

它也允许产生带噪声、快速变化的图案。

一个更平滑的替代方法是将相位表示为低阶多项式或类似泽尼克项的和:

ϕ(ρ,θ)=a1Z1(ρ,θ)+a2Z2(ρ,θ)++akZk(ρ,θ) \phi(\rho,\theta) = a_1 Z_1(\rho,\theta) + a_2 Z_2(\rho,\theta) + \cdots + a_k Z_k(\rho,\theta)

现在,可训练的变量就是系数:

a1,a2,,ak a_1, a_2, \ldots, a_k

这减少了自由度,但提高了稳定性和可解释性。

在代码中,一个简单的离焦加像散相位模型可能是:

def phase_model(rho, theta, coeffs):
    """
    简单的可微相位模型。

    coeffs[0]:类似离焦的项
    coeffs[1]:类似像散的项
    coeffs[2]:用于教学的类似彗差的项
    """
    defocus = coeffs[0] * (2.0 * rho**2 - 1.0)
    astig = coeffs[1] * rho**2 * torch.cos(2.0 * theta)
    coma = coeffs[2] * (3.0 * rho**3 - 2.0 * rho) * torch.cos(theta)

    return defocus + astig + coma

这不是一个完整的泽尼克实现,但它展示了这个思路。

优化器改变的是少数有意义的系数,而不是成千上万个互不相关的像素。

这常常让问题变得更容易,也使结果更容易理解。

一条好的法则是:

给优化器提供那些能描述合理光学变化的变量。

而不仅仅是那些容易被放进张量里的变量。


17. 可微损失是一种设计表述

在第15章中,我们说过评价函数就是一种设计论证。

这里也是如此。

一个可微的损失函数会告诉优化器什么才是重要的。

如果损失函数最大化 PSF 中心强度,优化器就会试着将能量集中到中心。

如果损失函数以选定的频率上的 MTF 为目标,它就会试着改善那些频率。

如果损失函数将重建图像与真值图像进行比较,它就会试着改善那个重建。

如果损失函数是一个神经网络任务损失,它就可能为分类或检测性能而非人眼视觉质量进行优化。

这些中没有哪个是普遍正确的。

损失必须与成像任务相匹配。

例如:

一个条形码扫描仪,与一个肖像镜头,对光学的优先级需求就不一样。
一个显微镜,与一个手机摄像头,对光学的优先级需求就不一样。
一个星敏感器,与一个内窥镜,对光学的优先级需求也不一样。

可微光学并没有消除定义任务的必要性。

它只是让任务的定义更直接,也更具影响力。


18. 梯度下降也可能被愚弄

梯度告诉我们一个局部的方向。

它并不能保证一个全局最佳的设计。

梯度下降仍然可能受以下问题之苦:

  • 局部极小值;
  • 平坦的区域;
  • 糟糕的缩放;
  • 不稳定的学习率;
  • 不佳的参数化;
  • 不切实际的损失函数;
  • 未在模型中表示的约束;
  • 对初始化的敏感性。

例如,如果学习率太高,优化可能会震荡或发散。

如果学习率太低,它可能几乎不移动。

如果初始设计物理上很差,光线可能错过表面或击中无效区域。

如果损失函数的地形有很多谷地,优化器可能在一个局部折中方案中停驻。

因此,老的经验依然成立:

优化只是工具,不是保证。

可微性给出了更好的信息。它并没有给出智慧。


19. 缩放仍然重要

梯度依赖于尺度。

如果一个参数是用毫米度量的,而另一个是极小的、以每毫米为倒数的曲率,它们的梯度大小可能相差极大。

如果一个损失项的数值在 1000 左右,而另一个大约为 0.001,那么大的那一项就可能占据主导。

因此,可微光学仍然需要:

  • 归一化的变量;
  • 归一化的损失;
  • 合理的权重;
  • 稳定的单位;
  • 有界的参数;
  • 良好的初始化。

这直接与第14章联系起来。

采样和归一化并没有因为我们迁移到了 PyTorch 中就不再重要。

它们变得更重要了,因为梯度依赖于它们。

一个 MTF 损失中错误的频率轴意味着优化器会改善错误的频率。

一个错误的 PSF 归一化意味着梯度可能奖励的是能量缩放,而不是图像集中度。

一个错误的光瞳掩膜意味着优化器为一个错误的物理系统做设计。

安全的链条是:

正确的计算
→ 正确的损失
→ 有意义的梯度
→ 有用的优化

不要跳过前两步。


20. 可微光学如何与 Optiland 连接

一个现代的开放光学设计框架可以以两种方式提供价值。

首先,它可以提供经典的分析:

光线追迹
点列图
波前
PSF
MTF
优化

其次,如果它支持可微的或面向机器学习的工作流,它就能帮助将光学模型与张量库以及基于梯度的优化连接起来。

一个可微的工作流轮廓大致如下:

# 版本校验的工作流轮廓。
# 请检查已安装的 Optiland 版本和配套代码仓库,
# 以获取准确的 API 名称。

# 1. 构建或加载一个光学系统。
# optic = make_system(...)

# 2. 如果支持,选择可微后端或张量模式。
# optic.enable_differentiable_backend("torch")

# 3. 将参数标记为可训练。
# optic.surface(1).radius.requires_grad = True
# optic.surface(2).radius.requires_grad = True
# optic.image_plane.position.requires_grad = True

# 4. 追迹光线或计算波动光学结果。
# result = optic.trace(...)

# 5. 计算损失。
# loss = result.rms_spot() + mtf_penalty(...)

# 6. 反向传播。
# loss.backward()

# 7. 优化器更新参数。
# optimizer.step()

具体的 API 可能不同。结构才是重要的部分。

重要的是,光学参数要参与到一个可微的计算图中。

本书前面提到的链条依然适用:

表面
→ 光线
→ 波前
→ PSF
→ MTF

可微的附加部分是:

损失
→ 沿着链条返回梯度
→ 参数更新

因此,可微光学并不是一个分离的宇宙。它就是附着上梯度的同一个计算链条。


21. 为什么开放工具在这里很重要

开放工具的教育价值在可微光学中变得尤为清晰。

如果一个工具只给出最终结果,你可以使用这个结果,但无法轻易检查计算图。

如果一个工具将系统以代码的形式暴露出来,你就可以问:

  • 哪些参数需要梯度?
  • 哪些操作是可微的?
  • 在哪里使用了掩膜或硬分支?
  • 正在优化哪个损失函数?
  • 光线是如何被采样的?
  • 无效的光线是如何被处理的?
  • PSF 是如何被归一化的?
  • MTF 的频率轴是如何定义的?
  • 哪些参数实际上改变了?

这不是在崇拜开源。

它关乎的是可检查性。

对该主张更尖锐的表述是:

对于学习可微光学来说,可检查性不是奢侈品。
它是理解该方法的一部分。

如果一个人看到的只是一个训练损失曲线,那么一个可微的流水线甚至可以比一个经典流水线更加不透明。

开放的、可检查的代码有助于防止这种情况。


22. 一个小型的梯度检查习惯

在进行可微光学工作时,要检查梯度。

不是每一步都检查,也不是永远都检查,但要足够频繁以便能发现问题。

例如:

loss.backward()

print("c1 grad:", c1.grad)
print("c2 grad:", c2.grad)
print("z_image grad:", z_image.grad)

问以下问题:

梯度是有限的吗?
它们是否极大?
它们是否恰好为零?
当预期它们应改变符号时,它们改变了吗?
有些变量是否没有收到梯度?

如果一个变量没有梯度,那么它可能没有与损失相连。

如果梯度是 nan,可能是一个平方根、除法、无效光线或是归一化出了问题。

如果梯度巨大,缩放可能不佳。

如果梯度极小,问题可能是在平坦区域、饱和了,或是缩放不好。

这就是检查中间光学图的可微版本。

正如我们检查光瞳振幅和 PSF 居中情况一样,我们也要检查梯度的健康状况。

一个实用的调试检查清单:

[ ] 损失是有限的吗?
[ ] 梯度是有限的吗?
[ ] 可训练的变量是否实际影响着损失?
[ ] 单位和缩放合理吗?
[ ] 掩膜是否阻挡了梯度?
[ ] 硬阈值是否制造了死区?
[ ] 一个小的手动参数改变是否按预期影响了损失?

这能够节省时间。


23. 可微光学与 MTF:一个精细的例子

假设我们想改善在某个选定频率上的 MTF。

一个天真的损失函数可能是:

loss = -mtf[fy, fx]

这最大化了一个单一的 MTF 采样点。

作为玩具示例,这可能可行,但在真正的设计中,它太狭窄了。

它可能鼓励系统改善一个点,而忽略附近的频率或其他视场。

一个更好的教学级损失可能会在一个小的频带上求平均值:

def band_mtf_loss(mtf, fy_slice, fx_slice, target=0.4):
    band = mtf[fy_slice, fx_slice]

    mean_mtf = torch.mean(band)

    error = torch.relu(target - mean_mtf)

    return error**2

一个更好的设计损失还可能包括:

多个视场
多个波长
多个频带
几何惩罚
畸变惩罚
制造约束

可微方法给了我们梯度。它并不会自动为我们选择一个聪明的损失函数。

设计问题依然是:

对于这个系统来说,哪些像质是重要的?

这个问题不能被委托给 autodiff。


24. 可微渲染与学习重建

在计算成像中,光学系统可能会与重建软件一起被优化。

一个简化的训练循环可能看起来像:

真值图像
→ 光学仿真
→ 传感器仿真
→ 重建模型
→ 重建图像
→ 相对于真值的损失
→ 梯度更新光学和重建参数

在代码结构上:

# 仅为概念结构。

for image in training_images:
    optimizer.zero_grad()

    psf = optical_model(trainable_optical_parameters)

    sensor_image = simulate_sensor(image, psf, noise_model)

    reconstructed = reconstruction_model(sensor_image)

    loss = image_loss(reconstructed, image)

    loss.backward()

    optimizer.step()

这比经典的镜头优化要广泛得多。

损失可能不是 RMS 光斑或 MTF。它可能是:

  • 均方图像误差;
  • 感知损失;
  • 分类损失;
  • 检测损失;
  • 深度估计损失;
  • 任务特定的重建损失。

这就是为什么可微光学对于现代成像很重要。

光学设计可以为了图像的最终用途而优化,而不仅仅是为了经典的中间指标。

但同样,这既强大又充满风险。

如果训练图像不具有代表性,光学系统可能会过拟合。

如果传感器模型过于干净,设计可能在真实噪声下失效。

如果制造差异被忽略,设计可能是脆弱的。

如果重建网络太过强大,它可能在模拟中隐藏光学的弱点,而在现实中产生伪影。

优化越趋向端到端,物理上的验证就必须越仔细。


25. 验证并未消失

一个可微的设计仍然需要在训练循环之外进行验证。

对于一个镜头或计算相机来说,验证可能包括:

经典的光学分析
独立的光线追迹
PSF 和 MTF 检查
公差分析
制造约束
传感器采样检查
噪声鲁棒性
视场和波长扫描
训练集之外的测试图像
物理原型测量

如果一个设计只在其被训练时所使用的精确可微仿真中有效,那它还不是一个鲁棒的光学设计。

这是一种看待可微光学的健康方式:

用可微性来搜索。
用光学分析来理解。
用验证来信任。

三者缺一不可。


26. 为什么这对本书的读者很重要

本书花了许多章节来剖析经典计算。

那并非弯路。

那是理解可微光学所需的基础。

如果有人说:

我们通过一个可微成像流水线端到端地优化镜头。

你现在可以问:

  • 光学模型是什么?
  • 它追迹的是真实光线,还是使用傍轴近似?
  • 它是否包含了衍射?
  • 光瞳函数是如何表示的?
  • PSF 是如何计算的?
  • 传感器模型是否被包含在内?
  • 优化的损失是什么?
  • 哪些光学参数是可训练的?
  • 材料是离散的还是连续的?
  • 孔径和渐晕是如何处理的?
  • 梯度稳定吗?
  • 之后检查了哪些经典指标?

这些问题本身并不是为了怀疑而怀疑。

它们是理解该主张所必须问的问题。

可微光学之所以令人兴奋,恰恰是因为它扩展了我们已经构建起来的链条。没有那条链条,它就只是另一个黑盒。


27. 一个小小的并排对比

对三种优化风格进行比较,可能会有帮助。

风格它需要什么优势风险
无导数搜索评价函数值使用简单;能处理别扭的函数可能较慢;可能需要很多次计算
经典梯度 / 最小二乘优化评价函数值和类似导数的信息对平滑的光学设计问题有效依赖于缩放、局部行为、操作数设计
Autodiff 可微光学可微的计算图能通过光线、波动、传感器和重建进行优化需要仔细的可微建模和验证

没有哪种是普遍最好的。

一个小的镜头设计问题用传统的光学优化可能就很好。

一个波前编码的计算相机可能从端到端的可微优化中获益。

一个坚固的工程设计可能仍然需要经典公差分析和人工评审。

实用的教训是:

选择与问题相匹配的优化方法,
而不是因为某个方法听起来更现代。

28. 主要的概念升级

本章的主要升级,并不是“使用 PyTorch”。

主要的升级是:

光学计算可以成为一个可微程序的一部分。

一旦做到这一点,光学变量就可以与其他可微组件一起被优化。

镜头可以连接到:

  • 相位掩模;
  • 衍射元件;
  • 传感器模型;
  • 重建算法;
  • 神经网络;
  • 任务特定的损失。

这就是为什么可微光学很重要。

它改变了设计问题的边界。

经典设计常问:

什么样的镜头能给出好的光学指标?

可微计算成像可能会问:

什么样的光学系统和计算重建一同能给出最好的最终结果?

那个问题更大。

如果你漫不经心地提问,它也是一个更危险的问题。

优化循环越大,理解链条中的每一环节就越重要。


29. 哪些不应该相信

因为可微光学与机器学习相关,所以很容易出现夸大的说法。

对如下的说法要保持谨慎:

网络会学到光学。
优化器会发现最好的镜头。
物理约束不再必要了。
MTF 已经过时了。
经典光学设计已死。

这些说法都太松散了。

更好的看法是:

可微优化可以搜索那些手动很难探索的设计空间。
但结果仍然依赖于物理建模、约束、损失、数据和验证。

这个看法保留了兴奋感,却又不失判断力。

本书的立场不是反现代。它是反神秘化。

可微光学值得学习,因为它很强大。

也正因为它强大,它才值得被解密。


30. 为最后一章做准备

我们现在准备回到读者的实际任务。

在本书的开头,问题是:

一条 MTF 曲线是从哪儿来的?

我们已经回答了这个问题。

现在一个新问题出现了:

当光学软件给我任何一个输出时,
我该如何阅读它?

这包括经典输出:

  • 布局图;
  • 处方;
  • 点列图;
  • 光线扇形图;
  • OPD;
  • PSF;
  • MTF;
  • 评价函数值。

它也包括现代的输出:

  • 梯度;
  • 训练损失;
  • 优化后的相位掩模;
  • 可微光线追迹结果;
  • 端到端的重建分数。

同样的习惯依然适用:

不要停留在输出上。
要追溯产生它的计算过程。

这个习惯是本书一直在教的真正技艺。

最后一章将把这个技艺收拢成一个实用的阅读检查清单。


本章总结

可微光学意味着构建光学计算的过程,使得梯度能够关于设计参数被计算出来。

自动微分通过执行过的操作运用链式法则。它与通过有限差分手动估计导数的做法不同。

一个可微的光学链可能看起来像:

光学参数
→ 光线追迹或波动光学
→ PSF / MTF / 图像仿真
→ 损失
→ 梯度
→ 参数更新

PyTorch 示例展示了基本模式:

定义可训练的变量
计算光学结果
计算损失
调用 backward()
更新变量

可微光线追迹通过允许导数流过交点、法线、折射、传播和像面误差,扩展了普通光线追迹。

可微的 PSF 和 MTF 计算可以用张量 FFT 操作构建,从而允许基于像质或频率响应的损失。

这种方法对现代计算成像来说是很强大的,因为光学、传感器、重建算法和任务损失可以被一起优化。

但是,可微光学并不是魔法。它仍然需要正确的物理、稳定的采样、有意义的损失、良好的参数化、约束、可制造性检查,以及训练循环之外的验证。

核心思想是:

可微光学并不会取代经典的计算链条。
它是在那个链条上附加上梯度。

这使得那座链条更强大,但也使得理解它变得更加重要。

生成的验证图表

从第17章梯度下降检查中生成的可微损失曲线

来源与验证说明

本章是一篇定位性章节,而非对整个可微光学文献的综述。其所保留的主张是有限的:当操作是以可微数值框架实现时,自动微分能够将梯度附加到光学计算链条上。实际设计仍然需要物理建模、约束、采样检查,以及训练循环之外的验证。

第18章:如何阅读光学软件输出

本书开头,MTF曲线看起来像一个成品。

由某个软件生成。曲线随频率下降。包含弧矢和子午方向的曲线。有视场位置、波长,可能还有衍射极限。看起来非常正式。

现在,我们可以用不同的方式解读这条曲线了。

它不再是软件给出的神秘判决。

它是一条链的末端:

镜头处方
→ 表面和材料
→ 光线追迹
→ 光程长度
→ OPD
→ 光瞳函数
→ PSF
→ OTF
→ MTF

如果涉及优化,这条链就变成了一个循环:

镜头处方
→ 分析
→ 评价函数
→ 优化器更改变量
→ 新处方
→ 再次分析

这就是本书希望带来的主要转变。

不是说要你停止使用光学设计软件,恰恰相反。优秀的光学设计软件之所以强大,正是因为它能可靠地执行大量复杂的计算。

你不再需要把输出视为一份不能改变的裁决。

你可以追问它的来源。

你可以检查它的输入。

你可以审视它的假设。

你可以将它和系统的其他部分进行比较。

这就是“善于阅读光学软件输出”的含义。


1. 第一个习惯:绝不孤立地看图

一幅图不是不言自明的。

点列图、光线扇形图、波前图、PSF或MTF曲线总是属于某个特定的配置。

在解读任何输出之前,先问:

是哪个光学系统产生了这个结果?
哪个视场点?
哪个波长或哪组波长?
什么孔径?
什么聚焦设置?
什么采样设置?
什么归一化方式?
什么坐标约定?

一开始可能觉得慢,其实不然。这是避免自我欺骗的最短路径。

没有配置的图就像一个没有单位的数字。

它可能看起来很精确,但你并不知道它意味着什么。

例如,这个表述很弱:

MTF是0.45。

这个表述更好:

在40 cycles/mm处,对于边缘视场、绿光波长、
子午方向、全孔径、当前聚焦设置,
归一化MTF是0.45。

它更长,因为它更诚实。

光学上的大多数误解,都始于一个掩盖了配置信息的简短陈述。


2. 从镜头处方入手

看到软件输出时,不要从欣赏图形开始。

先问是哪个镜头处方产生了它。

镜头处方包含了基本的计算对象:

表面半径
厚度
材料
半孔径
圆锥或非球面项
光阑位置
像平面

这不是文书工作,这就是系统本身。

后续的每一项计算都依赖于它。

一个实用的阅读顺序是:

1. 镜头处方
2. 系统布局图
3. 近轴特性
4. 实际光线分析
5. 波前分析
6. PSF / MTF
7. 优化报告

如果处方错了,后面的每一个输出都是这个错误的下游结果。

所以第一项检查很简单:

这个模型代表了我所认为的那个光学系统吗?

留意简单的错误:

  • 半径符号错误;
  • 玻璃材料错误;
  • 厚度单位错误;
  • 缺少光阑;
  • 像距错误;
  • 半孔径太小;
  • 表面顺序错误;
  • 波长定义不匹配;
  • 视场输入的单元错误。

这些不是激动人心的错误,却是常见的错误。

光学计算充满了高等数学,但许多糟糕的结果都始于普通的错误输入。


3. 然后看系统布局图

系统布局图是第一个视觉上的健全性检查。

它告诉你,处方是否变成了一个物理上合理的系统。

要问:

元件顺序是否正确?
光线是否通过了孔径?
光阑是否出现在预期的位置?
光线是否到达了像平面?
是否有明显的光线追迹失败?
比例看起来合理吗?

系统布局图无法证明成像质量。一个不好的镜头也可以有一个看起来很合理的布局。

但是,一个明显错误的布局会让之后的图都不可信。

这是一条有用的规则:

不要相信一个你还没检查过系统布局的镜头的漂亮 MTF 曲线。

系统布局图是抵御隐藏建模错误的第一道防线。


4. 将近轴结果作为骨架检查

近轴分析不是老套的装饰,它是光学系统的骨架。

在阅读详细的像质图之前,先检查:

有效焦距
后焦距
F数
数值孔径
光瞳直径
放大率
主光线行为

这些是一阶量。它们告诉你,系统大致上是不是你打算构建的那个镜头。

如果你预期是一个50毫米镜头,而近轴分析给出的焦距是500毫米,那就不要继续解读PSF了。上游出错了。

如果你预期是f/4,而计算出的孔径行为像f/16,那么你的衍射尺度和MTF截止频率就会不同。

如果入瞳不在你预期的地方,视场行为和孔径采样可能会让你惊讶。

近轴结果不是最终真理。实际光线会偏离它。

但它提供了参考框架。

近轴分析告诉你光学系统的一阶特性。
实际光线分析告诉你现实如何偏离了那个特性。

按这个顺序来阅读它们。


5. 点列图:将其理解为光线交点统计

点列图不是波动光学意义上的模糊斑图。

它是像平面上光线交点的分布。

它回答:

采样的光线落到了哪里?

阅读点列图时,要问:

这是哪个视场点?
包含了哪些波长?
光瞳光线是如何采样的?
图中的点是以主光线、质心还是理想像点为参考显示的?
比例尺是多少?
是否显示了艾里斑?

艾里斑的比较尤其有用。

如果几何光斑远大于艾里斑,几何像差可能很重要。

如果几何光斑远小于艾里斑,衍射可能占主导,光斑尺寸本身可能无法说明全部问题。

不要在接近衍射极限时过度解读点列图。

点列图可以非常有用,但它仍然是几何的。

它不显示衍射环。

它不直接显示对比度传递。

它不能替代PSF或MTF。

一个谨慎的解读是:

此点列图显示了针对该视场、该组波长、该孔径和该像平面的采样光线的几何落点模式。

这句话把图放在了适当的位置。


6. 光线扇形图:寻找结构,而不只是尺寸

光线扇形图显示了光线误差作为光瞳坐标的函数。

它回答:

光线误差如何在光瞳内变化?

这使得它在许多情况下比点列图更具诊断价值。

阅读光线扇形图时,要问:

曲线是对称还是非对称的?
它是否显示出类似球差的行为?
它是否显示出类似彗差的行为?
子午和弧矢扇形图是否不同?
不同波长是否分离?
优化后曲线是否变得平坦?

点列图可能显示一团点,而光线扇形图可以解释为什么这团点呈现那种形状。

把它当作一种结构诊断工具。

例如:

  • 轴上对称的扇形图可能暗示球差或类似离焦的行为;
  • 轴外不对称的扇形图可能暗示类似彗差的行为;
  • 子午和弧矢行为不同可能指向像散;
  • 颜色分离的扇形图指向色差效应。

光线扇形图不只是另一幅图。

它是观察实际光线如何偏离理想参考的最佳方式之一。


7. 波前和OPD:问清楚所用的参考

OPD是连接光线光学和衍射成像的桥梁。

它回答:

与参考波前相比,光瞳上每一点是超前还是滞后?

阅读OPD或波前图时,要问:

参考波前是什么?
使用的波长是多少?
单位显示的是长度还是波数?
是否去除了平移(piston)?
是否去除了倾斜(tilt)?
是否去除了离焦(defocus)?
使用的光瞳坐标是什么?
光瞳是否被渐晕或剪切?

这些问题很重要。

同一个物理波前,根据是否去除了平移、倾斜或离焦,看起来可能会不同。这不意味着软件在说谎,而是说明波前图是参考量。

以米或微米为单位的OPD是物理的光程差。

以波数(waves)为单位的OPD是按波长归一化的:

Wwaves=Wλ W_\text{waves} = \frac{W}{\lambda}

所以,一个在微米尺度上很小的波前误差,在短波长处以波数衡量时,可能仍然很大。

这就是单位如此重要的原因。

一个好的阅读习惯是:

在解读波前图之前,先确定波长、参考、去除项和光瞳定义。

然后才能判断图说明了什么。


8. 光瞳函数:记住振幅和相位

光瞳函数结合了孔径振幅和波前相位:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x,y)=A(x,y)\exp\left(i\frac{2\pi W(x,y)}{\lambda}\right)

当软件工具通过衍射计算PSF或MTF时,通常会涉及与此等效的表示。

所以,在阅读衍射结果时,要问:

使用了什么孔径掩模?
是否存在渐晕?
振幅是均匀的吗?
是否存在变迹?
是什么OPD产生了相位?
使用的波长是多少?
使用的是什么符号约定?

这一点很重要,因为PSF的变化不仅仅来自波前像差。

也可能来自光瞳振幅:

  • 中心遮挡;
  • 渐晕;
  • 孔径剪切;
  • 变迹;
  • 透过率变化;
  • 衍射或相位元件。

一个像差清晰的镜头可能振幅均匀但相位不均匀。

一个有遮挡的系统可能相位平坦但振幅不均匀。

两者都会影响PSF。

不要将PSF的每个特征都解读为“像差”,有些特征是孔径结构。


9. PSF:把它当作能量分布来读

PSF回答:

一个理想点源会在像平面产生怎样的强度分布?

阅读PSF图时,要问:

显示是线性还是对数的?
PSF是按能量还是按峰值归一化的?
像平面坐标单位是什么?
它代表哪个视场和波长?
是单色还是多色?
中心峰是否居中?
图像是否被裁剪?
采样程度如何?

线性和对数PSF显示讲述不同的视觉故事。

线性图强调中心能量集中度。

对数图揭示微弱的环、尾部和光晕。

两者都不是“真实的那一个”,它们是同一数据的不同显示方式。

可能的话,两种都读。

还要区分能量归一化和峰值归一化。

能量归一化:

PSF=1 \sum \mathrm{PSF}=1

对于比较能量如何分布很有用。

峰值归一化:

max(PSF)=1 \max(\mathrm{PSF})=1

对于在视觉上比较形状很有用。

但经过峰值归一化的PSF不能直接用于斯特列尔比(Strehl)比较,因为峰值已经被强制设为了1。

一个谨慎的PSF解读是:

在这个视场、波长、孔径、聚焦、采样和归一化条件下,
点源的能量去了哪里?

这就是PSF回答的问题。


10. MTF:把它当作频率响应的一种选定视图

MTF是OTF的幅值:

OTF=F{PSF} \mathrm{OTF}=\mathcal{F}{\mathrm{PSF}}

MTF=OTF \mathrm{MTF}=|\mathrm{OTF}|

它回答:

系统在不同空间频率下传递对比度的能力有多强?

但绘制的MTF曲线通常不是完整的二维MTF。

它是一条选定切片或一组切片。

阅读MTF曲线时,要问:

哪个视场点?
哪个波长或哪种波长加权?
什么孔径?
什么聚焦?
什么方向:弧矢、子午、径向、水平、垂直?
频率单位是什么?
是衍射MTF还是几何MTF?
在零频率处是否归一化为1?
对应用来说,哪个频率范围重要?

方向问题很重要。

弧矢和子午方向是相对于视场几何定义的。除非知道坐标系统,否则它们不会自动与水平和垂直阵列切片相同。

频率单位也很重要。

cycles/mm、cycles/degree和归一化频率不能互换。

一条在归一化轴上看起来很不错的曲线,放到传感器的像素间距下,意义可能就不同了。

一个更好的MTF阅读习惯是:

不要只问曲线高不高。
要问在哪个频率、对哪个视场、在哪个方向、在什么波长和聚焦条件下高。

这就是“读”曲线和仅仅“看”曲线的区别。


11. 始终将MTF与PSF关联起来

MTF有时会让人感觉抽象。当你有这种感觉时,就逆向回溯。

MTF
← OTF
← PSF
← 光瞳函数
← OPD
← 光线追迹
← 镜头处方

如果MTF在高频处很低,就问:

PSF是什么样的?

如果弧矢和子午MTF分离了,就问:

PSF或光线扇形图是否显示了方向性的模糊?

如果边缘视场的MTF很弱,就问:

在边缘视场,点列图、光线扇形图和OPD图显示了什么?

MTF曲线之所以强大,是因为它概括了对比度传递。它的局限在于,它压缩了模糊的空间结构。

把它和PSF一起用。

一对健康的阅读搭档是:

PSF显示点能量去了哪里。
MTF显示对比度如何按空间频率留存下来。

如果你把这两个视图放在一起,MTF就会变得不那么神秘。


12. 把优化输出当作“优化前/后”的论据来读

当软件说一个设计已经被优化时,不要只问:

评价函数值下降了吗?

要问:

哪些变量发生了改变?
包含了哪些操作数?
使用了哪些目标值?
使用了哪些权重?
哪些约束或边界处于激活状态?
什么改进了?
什么变差了?
什么没有被衡量?

评价函数值是一个压缩的分数。

较低的评价函数值意味着优化器降低了所定义的目标值。这并不自动意味着这个设计在每个有意义的方面都更好了。

一份谨慎的优化报告应该展示:

优化前后的镜头处方
优化前后的系统布局图
优化前后的操作数表格
点列图
光线扇形图
波前图
PSFs
MTFs
几何约束

这看起来可能很多,但这是理解发生了什么的最低要求。

优化器遵循的是评价函数,而不是你的意图。

所以,优化之后,要把结果当作一个论据来读:

设计在这些方面发生了变化。
这些指标改进了。
这些约束保持在可接受的范围内。
这些指标没有改进或没有被检查。
因此,对于这个已定义的任务,设计是更好的。

这比下面这种说法有力得多:

优化后的镜头,评价函数值更低。

13. 以同样的方式阅读可微光学的输出

可微光学增加了梯度,但没有消除解读的必要。

阅读可微优化结果时,要问:

哪些参数是可训练的?
优化的是哪种损失函数?
使用了什么物理模型?
包含了光线、波动光学、传感器采样和重建吗?
约束是平滑的、硬性的还是缺失的?
梯度是有限且稳定的吗?
结果是否在训练损失之外进行了验证?

一个下降的训练损失,不是一个完整的光学论据。

它只是一部分证据。

如果一个相位掩模、自由曲面或计算相机经过了端到端优化,仍然要要求进行经典检查:

PSF
MTF
视场行为
波长行为
公差
可制造性
传感器采样
噪声鲁棒性
分布外图像

可微光学令人兴奋,因为它为光学链附加了梯度。

但这条链仍然需要在物理上有意义。

一个稳妥的阅读习惯是:

用梯度来搜索。
用光学分析来理解。
用验证来信任。

这句话值得记住。


14. 完整的阅读检查清单

以下是你在阅读光学软件输出时可以使用的一份实用检查清单。

A. 系统定义

[ ] 镜头处方是什么?
[ ] 单位清楚吗?
[ ] 表面符号正确吗?
[ ] 材料正确吗?
[ ] 孔径光阑被定义了吗?
[ ] 半孔径或通光孔径合理吗?
[ ] 像平面在预期位置吗?
[ ] 视场和波长定义了吗?

B. 布局和一阶检查

[ ] 系统布局图在物理上看起来合理吗?
[ ] 光线通过了预期的孔径吗?
[ ] 有光线追迹失败吗?
[ ] 有效焦距接近预期值吗?
[ ] F数或数值孔径正确吗?
[ ] 光瞳量合理吗?
[ ] 如果相关,放大率正确吗?

C. 基于光线的分析

[ ] 点列图显示的是哪个视场和波长?
[ ] 比例尺清楚吗?
[ ] 艾里斑显示了还是单独计算的?
[ ] 光线扇形图能否解释光斑形状?
[ ] 弧矢和子午行为是否分开?
[ ] 色分离是否可见?

D. 波前分析

[ ] 使用了什么OPD参考?
[ ] 是否去除了平移、倾斜或离焦?
[ ] 单位是波数还是长度?
[ ] 使用了什么波长?
[ ] 光瞳掩模清楚吗?
[ ] OPD模式是否与光线扇形图诊断匹配?

E. PSF分析

[ ] PSF是从复数光瞳函数还是其他方法计算得出的?
[ ] 是单色还是多色?
[ ] 显示是线性还是对数?
[ ] 归一化是基于能量还是基于峰值?
[ ] 坐标轴是物理的、角度的还是基于像素的?
[ ] 采样是否充分?
[ ] 艾里斑尺度合理吗?

F. MTF分析

[ ] MTF是从PSF/OTF计算的还是通过其他近似方法?
[ ] 在零频率处归一化了吗?
[ ] 频率单位是什么?
[ ] 显示了哪些视场点?
[ ] 使用了哪些波长或权重?
[ ] 显示了哪些方向?
[ ] 截止频率合理吗?
[ ] 曲线是否在相同设置下进行比较?

G. 优化分析

[ ] 释放了哪些变量?
[ ] 使用了哪些操作数?
[ ] 分配了哪些目标值和权重?
[ ] 哪些边界和约束处于活动状态?
[ ] 评价函数值下降了吗?
[ ] 哪些个体操作数改进了?
[ ] 是否有任何重要指标变差?
[ ] 几何形状是否保持实用?
[ ] 结果是否在被优化指标之外进行了检查?

这份清单的目的不是让你谨慎到瘫痪。

它旨在让你的信心始终与证据挂钩。


15. 更简短的紧急检查清单

当你时间非常有限时,可以使用这个简短版本。

1. 哪个系统和配置产生了这个输出?
2. 绘制的确切量是什么?
3. 单位和归一化方式是什么?
4. 使用了哪些假设或参考?
5. 上游的哪一步计算产生了它?
6. 哪张其他的图应该能证实或质疑它?
7. 什么情况下这种解读会是错的?

最后一个问题尤其有用。

一个有说服力的解读应该是可证伪的。

例如:

解读:
边缘MTF低是因为轴外像散扩散了PSF。

检查项:
光线扇形图应该显示子午/弧矢分离。
OPD应该显示视场相关的像散结构。
PSF应该呈方向性扩散。

如果这些检查不通过,就修正解读。

这就是你如何从“看图”转变到“光学推理”。


16. 一个简短的输出阅读模板

对于你自己的实际工作,可以为每张重要的图写一个简短报告。

使用这个模板:

输出:
配置:
输入数据:
计算路径:
单位和归一化:
主要观察:
交叉验证:
局限性:
下一步行动:

例如:

输出:
第16章 Cooke Triplet 优化报告。

配置:
使用固定的 optiland==0.6.0 配套代码,对三视场、三波长 Cooke Triplet 进行分析。

输入数据:
Cooke Triplet 镜头处方,选择半径和后焦距作为优化变量。

计算路径:
镜头处方 -> 光线追迹 -> 光斑RMS评价函数 -> 有界 L-BFGS-B 优化。

单位和归一化:
光斑RMS评价函数,单位为 mm²。当所选视场的RMS光斑半径更低时,该评价函数值更低。

主要观察:
第16章配套运行将光斑RMS评价函数值从 1.6625847615e-4 降低到了 1.5389416611e-4,降低了 7.44%。

交叉验证:
优化历史记录在 figures/chapter_16/cooke_optimization_history.csv 中,评价函数值曲线图记录在 figures/chapter_16/cooke_merit_optimization.png 中。

局限性:
本次运行证明了有界优化路径可以执行并降低了定义的评价函数。它不能替代完整的设计验收评审。

下一步行动:
在接受该设计之前,检查系统布局图、光斑、光线扇形图、波前、PSF、MTF和几何约束。

这种报告一开始可能会觉得冗长,但却非常有效。

它迫使输出位于计算链之内。


17. 你现在能做到的事

如果你跟上了本书,你现在已经具备了一些具体的能力。

你能阅读镜头处方

你知道镜头处方是一种数据结构:

表面
半径
厚度
材料
孔径
非球面项
像平面

你知道计算是从这里开始的。

你能够从概念上追迹一条光线

你知道一条光线有原点、方向、波长,有时还有强度或偏振态。

你知道它的传播方式为:

r(t)=r0+td \mathbf{r}(t)=\mathbf{r}_0+t\mathbf{d}

你知道它根据表面法线和折射率与表面相交并发生折射。

你能够理解实际光线与近轴光线

你知道近轴光学提供了一阶骨架。

你知道实际光线揭示了像差。

你知道两者都很重要。

你能够解读点列图和光线扇形图

你知道点列图是光线交点的分布。

你知道光线扇形图显示了误差在光瞳上的结构。

你知道两者都不等于衍射PSF。

你能够理解OPD和波前误差

你知道OPD将几何路径计算与波前相位联系起来。

你知道在解读OPD时,必须考虑波长、参考波前和去除项。

你能够构建光瞳函数

你知道光瞳函数是:

P(x,y)=A(x,y)exp(i2πW(x,y)λ) P(x,y)=A(x,y)\exp\left(i\frac{2\pi W(x,y)}{\lambda}\right)

你知道它结合了孔径振幅和相位。

你能够从光瞳函数计算PSF

你知道PSF来自于傅里叶变换后的光瞳场幅值的平方:

PSF=F{P}2 \mathrm{PSF}=|\mathcal{F}{P}|^2

你知道采样、补零、居中和归一化都很重要。

你能够从PSF计算MTF

你知道:

OTF=F{PSF} \mathrm{OTF}=\mathcal{F}{\mathrm{PSF}}

MTF=OTF \mathrm{MTF}=|\mathrm{OTF}|

你知道MTF曲线通常是二维频率响应的一个切片。

你能够理解优化

你知道评价函数通常是操作数误差的加权和。

你知道变量、目标值、权重、边界和约束定义了优化器能做什么。

你知道更低的评价函数值并不自动等同于更好的设计。

你能够在合适的层面上理解可微光学

你知道可微光学为光学计算链附加了梯度。

你知道如果计算路径是可微的,自动微分可以通过光线、波动、传感器和重建模型进行优化。

你也知道梯度并不能消除对物理、约束、采样和验证的需求。

这是一个相当可观的工具箱。


18. 你不应得出的结论

同样重要的是,要知道本书没有宣称什么。

本书本身不能让你成为专业的光学设计师。

它不能替代多年的设计经验、制造知识、公差分析、杂散光控制、镀膜设计、光机设计或系统工程。

它不是说最小的Python脚本就足够用于生产工作。

它不是说Optiland或任何开源工具就应该在每个工程环境中盲目地替代成熟的商业工具。

它不是说MTF不好。

它不是说经典光学设计已经过时了。

本书的主张更具体,也更有用:

如果你能看到光学软件输出背后的计算链,
你就能更快地学习,更好地调试,并以更好的理由信任结果。

这就是承诺。


19. 如何继续学习

接下来有几个自然的路径。

路径1:更深入的几何光学

学习更完整的像差理论:

三级像差
光瞳像差
场曲
畸变
色差校正
光阑移动效应

这将使光线扇形图和波前图更有意义。

路径2:傅里叶光学

更深入地钻研:

相干成像
非相干成像
部分相干
光瞳自相关
采样理论
传递函数
相位传递

这将使PSF、OTF和MTF感觉不那么像孤立的公式。

路径3:光学设计实践

过一遍实际的镜头示例:

单透镜
双胶合透镜
Cooke三片组
类似天塞镜头的形式
简化的显微镜物镜
长焦或广角布局

在优化之前先分析,然后优化,再进行比较。

路径4:数值方法

学习:

求根
最小二乘法
梯度下降
自动微分
条件数
局部极小值
参数缩放

这将使优化不那么神秘。

路径5:计算成像

探索:

波前编码
衍射光学
相位掩模
传感器模型
去卷积
学习式重建
端到端可微设计

这是经典光学和现代计算交汇的地方。

根据你想构建的东西选择一条路径。

本书的基础应该对所有这些路径都有所帮助。


20. 最终回到MTF曲线

让我们回到一开始的那条曲线。

想象一下,你现在正在看一张MTF图。

旧的阅读方式是:

曲线高或是低。
镜头好或是坏。

新的阅读方式是:

这条曲线是OTF幅值的一个选定切片。
OTF来自PSF的傅里叶变换。
PSF来自复数光瞳函数。
光瞳函数编码了孔径振幅和OPD相位。
OPD来自通过系统的光程差。
这些光程来自追迹的光线。
这些光线来自镜头处方、材料、视场、波长、孔径和聚焦设置。

这要长得多的读法。

但这也更加有用。

现在你可以问:

哪个视场弱?
哪个方向弱?
哪个频率重要?
这个弱点在PSF中可见吗?
波前能解释它吗?
光线扇形图显示了像差结构吗?
优化能改进它吗?
改进它的代价会是什么?

这条曲线不再是一个终点。

它是回到系统内部的一扇门。

这就是本书旨在促成的实际转变。


21. 知晓计算链的从容自信

理解计算链,会带来一种特别的从容自信。

它不是记住名称的自信。

不是按下一个按钮然后希望软件正确的自信。

也不是假装复杂事物很简单的自信。

它比那些更沉静。

听起来是这样的:

我可能还不知道每一个细节,
但我知道这个结果需要什么输入,
是什么计算产生了它,
它依赖于哪些假设,
以及我接下来应该检查什么。

这足以让你继续下去。

光学设计充满细节。没有哪一本书能抹去这一点。

但一旦计算链变得可见,细节就变得可以驾驭了。

你可以把每个新概念都放到合适的位置:

这是关于镜头处方的吗?
这是关于光线追迹的吗?
这是关于近轴结构的吗?
这是关于像差的吗?
这是关于波前的吗?
这是关于衍射的吗?
这是关于采样的吗?
这是关于优化的吗?
这是关于验证的吗?

这幅地图是本书真正的成果。


22. 最终的能力检查清单

下面是蓝图所承诺的最终能力清单。

在读完本书后,你应该能够:

[ ] 将一个基本的镜头处方作为一个计算数据表来阅读。
[ ] 解释一条光线在代码中如何被表示。
[ ] 描述一条光线如何在一个表面处相交并折射。
[ ] 理解为什么实际光线与近轴光线不同。
[ ] 解读焦距、F数、数值孔径和其他一阶量。
[ ] 解释点列图计算了什么。
[ ] 解释光线扇形图揭示了什么。
[ ] 解释OPD和波前误差的含义。
[ ] 将OPD转换为光瞳相位。
[ ] 从振幅和相位构建复数光瞳函数。
[ ] 解释如何从光瞳函数计算PSF。
[ ] 解释如何从PSF计算OTF和MTF。
[ ] 检查采样、归一化、FFT平移和频率轴问题。
[ ] 理解评价函数是什么。
[ ] 将优化输出作为一个“之前/之后”的设计论据来阅读。
[ ] 理解自动微分为光学优化增添了哪些能力。
[ ] 当光学软件给你一幅图时,提出更好的问题。

你不需要立即在所有方面都做到完美。

但它们都不应该再像魔法一样了。


23. 关于工具的最后几句话

本书使用Optiland作为一个开放的参考点,因为开放工具使计算更容易检查。

但主角从来不是某一个工具。

主角是那条计算链

当你知道该向它们询问什么时,商业软件、开源库和你自己的教学脚本都是有价值的。

一种对待工具的成熟态度是:

使用强大的软件。
尊重它的复杂性。
检查它的输入。
理解它的输出。
在需要学习时,重现一些小部分。
不要混淆界面和计算。

这就是平衡。

你不需要从零开始重建每一个光学设计工具。

但是,重建一些小部分,能教会你工具在做什么。

一旦你这样做了,软件输出就会变得不那么令人生畏,而更具信息性。

这正是我们想要到达的地方。


章节摘要

这最后一章将本书的计算链转化为一种阅读光学软件输出的方法。

核心规则是:

绝不孤立地读图。
要阅读其背后的系统、配置、计算路径、单位、归一化和假设。

一个镜头输出应被回溯到:

镜头处方
→ 系统布局图
→ 近轴分析
→ 实际光线追迹
→ 点列图和光线扇形图
→ OPD和波前
→ 光瞳函数
→ PSF
→ OTF
→ MTF
→ 评价函数和优化

点列图显示光线交点。光线扇形图显示光瞳上的误差结构。OPD显示相对于参考的波前误差。光瞳函数编码振幅和相位。PSF显示点源能量分布。MTF显示按空间频率的对比度传递。优化通过更改变量来降低定义的评价函数。可微光学将梯度附加到这条相同的链上。

对于本书开篇的问题,最终答案现在很清楚了:

一条MTF曲线并非来自软件按钮。
它是一条漫长光学计算链的最终可见结果。

一旦你能够看到那条链,你就能以更多的耐心、更多的怀疑和更多的从容自信来阅读光学软件输出。

生成的验证图

从第18章验证检查生成的输出阅读检查清单

配套代码

仓库包含用于检查正文数值结论和生成插图的实现。该 Python 包支持 Python 3.10 至 3.13,并将 Optiland 固定为 0.6.0。

配置环境

安装 uv,克隆仓库后运行:

uv sync --locked
uv run pytest -q

测试覆盖独立光学计算、固定版本的 Optiland 适配器、正文中的 Python 代码块、生成图片以及完整的库克三片式镜头示例。

重新生成验证图片

uv run python scripts/generate_chapter_validation.py

该命令会重写共享的 figures/ 目录,并在 validation/ 下生成验证报告。此命令供维护者使用;阅读或构建本书不要求运行它。

可以在 src/optics_examples 中查看实现。

资料来源与延伸阅读

本书主要使用下列资料核对光学原理、材料数据、数值方法和特定软件版本的行为。

软件与验证

  • PyPI 上的 Optiland 0.6.0:固定版本的软件包信息和 Python 版本要求。
  • Optiland 文档:用于了解功能范围;在线文档不是本书固定版本的 API 契约。
  • 本仓库的测试与生成图片:用于检查正文中的数值结果。

几何光学与镜头设计

  • Warren J. Smith, Modern Optical Engineering
  • Warren J. Smith, Modern Lens Design
  • Rudolf Kingslake 与 R. Barry Johnson, Lens Design Fundamentals
  • John E. Greivenkamp, Field Guide to Geometrical Optics

傅里叶光学与成像质量

  • Joseph W. Goodman, Introduction to Fourier Optics
  • Max Born 与 Emil Wolf, Principles of Optics
  • Virendra N. Mahajan, Optical Imaging and Aberrations

材料与数值计算