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

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

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

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

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

软件需要一份处方。

起初,镜片处方看起来平淡得令人失望。它通常是一个表格:表面编号、曲率半径、厚度、材料、孔径,或许还有圆锥常数,或许还有非球面系数,或许还有注释。与光线追迹布局或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