前言
在应用光学的学习与工程实践中,商用光学仿真软件(如 Zemax、CodeV)虽能快速输出仿真结果,但存在黑箱化问题,难以直观体现从光学公式到数值实现的核心逻辑;而课本中的公式推导又缺乏与工程化代码的直接映射。
本项目是一款面向光学专业人员的轻量级 Python 单透镜仿真系统,完全基于几何光学基本定律实现,无复杂封装,所有核心算法均与光学公式一一对应。项目实现了 3D 光线追迹、光路图 / 点列图 /光扇图可视化、成像质量量化分析(RMS 光斑)等功能,并采用模块化架构设计,可直接扩展至双胶合透镜、消色差系统、色散模拟等更复杂的光学场景。
本文将从光学专业视角,详细拆解项目的整体逻辑架构、核心模块的公式映射与代码实现、模块间调用关系,以及仿真结果的解读,为光学专业人员理解 “几何光学公式→数值计算→可视化呈现” 的完整流程提供参考。
一、项目定位与核心价值
1.1定位
本项目是几何光学框架下的单透镜数值仿真轻量化实现,聚焦几何光线追迹与成像质量基础分析,不涉及衍射、波前分析、复杂镜头优化等高阶内容,核心目标是让光学公式在代码中可落地、可追溯。
1.2设计原则
① 光学专业约定:所有参数定义、符号规则、公式实现均遵循光学行业通用标准(如曲率半径符号、光轴约定、Snell 定律矢量形式);
② 模块化解耦:按 “物理实体抽象→算法实现→质量分析→可视化→场景验证” 拆分模块,符合光学系统的分析逻辑;
③ 可扩展性:核心类 / 函数预留扩展接口(如材料折射率支持 Sellmeier 方程、透镜系统支持多表面扩展);
④ 量化分析:除可视化外,提供 RMS 光斑尺寸等定量评价指标,与光学工程中的成像质量评价标准一致。
1.3功能(光学专业视角)
功能模块 | 光学核心依据 | 工程价值 |
3D 矢量光线追迹 | 光线参数方程、Snell 定律 | 还原光线在光学系统中的真实传播 |
圆形光束均匀采样 | 入瞳光阑的光束能量分布 | 生成接近实际的光束模型 |
多视场追迹 | 视场光阑、离轴成像原理 | 分析场曲、像散等离轴像差 |
点列图可视化 | 光线像面聚焦分布原理 | 直观判断像差类型与大小 |
光扇图可视化 | 瞳坐标 - 像面偏差的对应关系 | 定量分析球差、彗差、像散 |
RMS 光斑计算 | 成像质量量化评价标准 | 客观对比不同光学系统的成像性能 |
二、项目整体逻辑架构与模块调用关系
项目采用分层模块化架构,按 “物理实体抽象→核心算法实现→成像质量分析→可视化渲染→工程场景验证” 的逻辑链路设计,所有模块均基于 Python 基础库(numpy、matplotlib)实现,无第三方光学仿真依赖,代码可直接调试、修改。
2.1整体架构图
singlet_optics/ __init__.py core/ # 核心光学数据结构 │ __init__.py │ ray.py # 光线 Ray │ surface.py # 表面 Surface(曲率、厚度、材料、孔径) │ material.py # 材料 Material(折射率) │ lens.py # 单透镜 / 系统 Lens trace/ # 几何光线追迹算法 │ __init__.py │ geometrical.py # Snell 折射、直线传播 │ tracer.py # RayTracer:按顺序追迹系统中所有表面 visual/ # 绘图与可视化 │ __init__.py │ rayplot.py # 光路图(系统剖面 + 光线,使用实际透镜曲率和孔径绘制透镜外形) │ spotplot.py # 点列图(像面光斑) │ ray_fan_plot.py # 光扇图(子午光扇、弧矢光扇、合并光扇) analysis/ # 简单分析工具 │ __init__.py │ spot.py # 计算光斑 RMS 等 │ ray_fan.py # 计算光扇图数据 examples/ # 使用示例 │ single_lens_demo.py # 单透镜光线追迹 + 光路图 + 点列图 + 光扇图 │ ray_fan_utils.py # 光扇图光线生成工具 │ field_utils.py # 多视场光线生成工具 │ multi_field_demo.py # 多视场光线追迹 + 光路图 + 点列图 + 光扇图 tests/ # 单元测试 │ test_core.py │ test_trace.py README.md # 项目说明文档 .gitignore # Git 忽略文件 PROJECT_STRUCTURE.md # 项目结构说明 |
2.2模块功能与输入输出关系
模块 | 核心功能 | 输入(光学实体 / 数据) | 输出(仿真结果 / 数据) | 依赖模块 |
core | 光学物理实体的代码抽象 | 光学参数(焦距、曲率、折射率、厚度、半孔径) | Ray/Surface/Material/LensSystem 类实例 | 无 |
trace | 几何光线追迹核心算法 | core 层的实体类实例、光线初始参数(原点、方向、波长) | 追迹后的光线(含传播路径 path) | core |
analysis | 成像质量量化分析 | trace 层的追迹后光线数据、视场角 | RMS 光斑尺寸、光扇图像差数据、主光线像点位置 | core、trace |
visual | 光学结果可视化 | trace/analysis 层的仿真数据 | 光路图、点列图、光扇图 | core、trace、analysis |
field_utils | 多视场光线生成工具 | 视场角、入瞳参数(半径、采样方式) | 带视场标记的 3D 平行光束 | core |
ray_fan_utils | 光扇光线生成工具 | 光瞳半径、采样点数、视场角 | 子午/弧矢光扇光线 | core |
examples | 工程场景演示 | 所有底层模块 | 完整仿真结果(图表 + 量化数据) | 所有模块 |
tests | 单元测试 | core/trace 层核心函数 | 算法正确性验证结果 | core、trace |
2.3数据流转逻辑
光线参数是整个系统的核心流转数据,其生命周期为:
光线初始生成(field_utils/examples)→3D矢量定义(core/Ray)→几何追迹(trace)→路径记录(Ray.path)→质量分析(analysis,提取像面点)→可视化(visual,提取路径/像面点数据)
所有可视化图表和量化分析结果,均来自追迹后 Ray 类的 path 属性(光线传播的离散 3D 坐标点),保证了数据的统一性和可追溯性。
三、核心光学实体的代码抽象(core 模块)
光学系统的本质是光线在不同介质的光学表面上的传播与折射,core模块的核心作用是将光线、光学表面、材料、透镜系统这些物理实体,按几何光学规则抽象为可被代码计算的类,所有类的参数定义、符号规则均遵循光学行业通用标准,是后续算法实现的基础。
3.1 Ray 类:3D 矢量光线的抽象(core/ray.py)
3.1.1光学依据
在几何光学中,光线被视为一条有向直线,其在三维空间中的轨迹可用参数方程表示为:
其中:
:光线起点的位置矢量;
:光线的单位方向矢量(),决定光线在空间中的传播方向;
:标量参数,在 为单位向量的前提下,可直接理解为从起点沿方向 传播的距离;表示沿光线传播方向前进。
单透镜以及大多数成像系统通常为轴对称系统,因此本项目采用光学设计中通用的右手直角坐标系约定:
约定 z 轴为光轴,光线整体从左向右沿方向传播(物方 → 像方);
x-y 平面为垂直于光轴的横向平面,用于描述光线在光瞳/像面的横向分布;
在可视化时,常取 y–z 子午面投影,将光线在 y 方向的偏离随 z 的变化画出,便于直观展示单透镜的会聚过程。
在此约定下,一条 3D 光线可完全由「起点 + 单位方向 」唯一确定,后续所有几何追迹(与球面/平面的交点计算、表面法线计算、Snell 折射等)都建立在这一 3D 参数方程之上。因此,Ray 类在代码层面对上述物理量进行显式建模:
origin 对应 ,记录光线初始位置;
direction对应,在构造/更新时强制归一化,保证 (t) 与物理传播距离一致,有利于数值稳定;
path 用于离散记录光线在各光学表面及像面上的交点坐标 ((x,y,z)),为后续点列图、光扇图和 RMS 光斑计算提供统一数据源;
field_angle记录该光线所属的视场角(单位:度),在多视场仿真中用于区分轴上(0°)与离轴光线。
通过上述抽象,几何光学中「一条 3D 有向直线」的物理概念,被完整映射为可计算、可追踪、可视化的 Ray 对象,为后续 trace、analysis、visual 模块的所有算法提供了统一的光线基础描述。
3.1.2代码实现与光学映射
class Ray: """三维光线:x, y, z 坐标系,z 为光轴""" origin: Vec3# (x, y, z) direction: Vec3# 单位方向向量 (dx, dy, dz) wavelength: float = 587.6e-9
# 追迹过程中累计的路径点:[(x0, y0, z0), (x1, y1, z1), ...] path: List[Vec3] = field(default_factory=list) field_angle: float = 0.0# 这条光线对应的视场角(度)
def __post_init__(self): if not self.path: self.path.append(self.origin)
def add_point(self, point: Vec3): self.path.append(point)
def normalize_direction(self): d = np.array(self.direction, dtype=float) d /= np.linalg.norm(d) self.direction = (float(d[0]), float(d[1]), float(d[2])) |
3.1.3属性的光学意义
origin/direction:严格遵循光线参数方程,direction 为单位矢量(保证光线追迹的数值计算精度);
wavelength:默认取 d 光(587.6nm),为目视光学系统的基准波长,预留波长接口用于后续色散模拟;
path:追迹过程中记录的光线与各光学表面的交点 + 像面点,是可视化和分析的核心数据来源,每个元素为 (x,y,z) 3D 坐标;
field_angle:标记光线的离轴视场角,为多视场追迹的视场区分依据,0° 表示轴上视场。
3.2 Surface 类:光学表面的抽象(core/surface.py)
3.2.1光学依据
单透镜可以看作由两个光学表面(球面或平面)组成,光线在各表面处发生折射。每个表面的核心光学参数包括:曲率半径、有效通光口径、入射侧与出射侧介质以及到下一个表面的中心厚度。通常约定光线沿+z方向(从左向右)传播,在此约定下,曲率半径 (R) 的符号规定为:
:表面凸向光线传播方向,即球心位于表面顶点的 一侧;
:表面凹向光线传播方向,即球心位于表面顶点的 (-z) 一侧;
:表示平面表面,此时曲率为0。
3.2.2代码实现与光学映射
class Surface: radius: float# 曲率半径,>0 凸向右,<0 凹向右,=inf 为平面 thickness: float# 到下一个表面的距离(mm) material_before: Material# 入射侧介质 material_after: Material# 出射侧介质 semi_diameter: float# 半孔径(mm) is_stop: bool = False # 是否光阑面(本最小版可暂不使用)
@property def curvature(self) -> float: if abs(self.radius) < 1e-9 or self.radius == float('inf'): return 0.0 return 1.0 / self.radius |
3.2.3属性的光学意义
radius:曲率半径的符号规则是光线追迹正确性的关键,直接决定球面的球心位置;
thickness:轴向厚度为z 轴方向的距离,是光学系统结构尺寸的核心参数,薄透镜可设为极小值(如 1e-3mm);
material_before/material_after:为 Snell 定律提供两侧介质的折射率,是折射计算的核心参数;
semi_diameter:通光口径的半值,用于判断光线是否超出通光范围(可扩展挡光判断逻辑);
is_stop:光阑面标记,为后续入瞳 / 出瞳计算、光束限制提供依据,单透镜系统可默认设为 False。
3.3 Material 类:光学材料的抽象(core/material.py)
3.3.1光学依据
在几何光学中,介质的折射率 (n)是决定光线如何在不同材料界面发生折射的核心参数。Material 类的作用,就是在代码层面为每一种光学材料提供一个统一的、可计算的折射率模型。
从物理上看,可以分两种典型情形:
单色光(monochromatic)近似在很多基础几何光学仿真中,只关心某一固定波长(比如 d 线 587.6 nm)下的成像情况,此时可以将材料的折射率视为常数:
例如:BK7 在 d 光(587.6 nm)下的折射率约为 1.5168,在仅做单色轴上成像分析时,使用这个常数已经足够。
复色光与色散(polychromatic & dispersion)当考虑白光或多波长成像时,折射率会随波长变化,即色散。常用的经验公式是Sellmeier 方程,它给出了折射率随波长变化的高精度拟合:

其中:
:光在真空中的波长(工程上常用 µm 或 nm 表示,使用时需统一单位)
:材料的 Sellmeier系数(与色散强度相关)
:材料的 Sellmeier系数(与色散特征波长相关)
这组 系数由玻璃厂商提供,典型如Schott 给出的 BK7、SF11 等光学玻璃数据表。对给定波长,代入上式即可求得该波长处的折射率:
在本项目当前的单透镜几何光学仿真中:
默认采用单色光近似,用一个常数 描述材料折射率,例如 BK7取 (d 光);
Material 类的n(wavelength) 方法预留了波长参数,后续只需在该方法内部将常数形式替换为 Sellmeier 方程,即可无缝扩展到多波长追迹和色散分析(如模拟色差、设计消色差透镜等),而无需修改 trace、analysis、visual等其他模块的代码结构。
3.3.2代码实现与光学映射
class Material: name: str n_const: float# 简化:不考虑色散,常数折射率
def n(self, wavelength: float) -> float: """将来可扩展成 Sellmeier,这里先返回常数。""" return self.n_const
# 常用实例AIR = Material("AIR", 1.0)
def glass_bk7() -> Material: return Material("BK7", 1.5168) |
3.3.3设计的光学考量
n_const:为单色光仿真提供简化模型,取光学玻璃的基准折射率(如 BK7 的 d 光折射率 1.5168),符合单透镜基础仿真的需求;
n () 方法:预留波长入参和返回值,后续仅需修改该方法的实现即可支持色散模拟,无需改动其他模块的代码,保证了架构的可扩展性;
玻璃实例化函数:按光学玻璃牌号封装,符合工程中 “按牌号选择玻璃” 的习惯。
3.4 LensSystem 类:单透镜系统的抽象(core/lens.py)
3.4.1 光学依据
单透镜是最基础的成像光学系统,其光路可以抽象为:
物面 → 前表面 → 后表面 → 像面
在几何光学中通常约定光线沿着 +z 方向从左向右传播,因此在代码中也要求光学系统按这一顺序组织。LensSystem 类正是以「顺序系统」的思想,将所有光学表面按光线实际通过的先后次序依次存入列表,使后续的光线追迹可以简单地从左到右遍历这些表面,从而与物理传播过程一一对应。
在一阶成像理论中,单透镜的焦距可由经典的透镜制造者公式(Lensmaker’s Equation)给出。对折射率为 (n)、前后表面曲率半径分别为 、中心厚度为 (d) 的厚透镜,其有效焦距 (f) 满足:

其中符号约定为:
光线沿 +z 方向(从左到右)传播;
:第一表面的曲率半径,顶点在该表面上,若球心位于顶点右侧(朝 +z)则 ,表示表面凸向光线传播方向;
:第二表面的曲率半径,定义方式同上,通常对对称双凸单透镜有;
(d):两表面顶点之间的中心厚度;
当 时,对应平面表面,曲率为 0。
当厚度 时,上式自然退化为薄透镜公式:
这就是通常意义下「薄透镜的焦距公式」。在一阶近似中,给定材料折射率 (n) 以及所需焦距 (f),可以反过来设计 (例如对称取 ),从而完成单透镜的初始参数设计。
本项目的 LensSystem 设计遵循如下原则:
① 顺序存储光学表面将物方起始面(如物面或入瞳平面)、透镜前表面、透镜后表面、像面等,按光线从左到右的顺序依次存入 surfaces 列表。这样,几何追迹算法只需按列表顺序迭代,便可以完成整个系统的光线传播仿真,逻辑与物理过程高度一致。
② 用一阶公式指导结构,用数值追迹验证焦距
在构造单透镜(如 build_single_lens(f=50.0))时,可以利用上述薄透镜公式,在给定目标焦距 (f) 和材料折射率 (n) 的前提下,反推出合适的曲率半径组合 ,完成「按焦距设计透镜」的一阶结构初选;
但在实际仿真与结果验证时,本项目并不依赖公式直接计算像距或焦距,而是采用 数值光线追迹:对平行入射光束进行全程追迹,再根据光线在像面附近的会聚位置,反推出系统的实际焦点位置与有效焦距,并用点列图、光扇图、RMS 光斑尺寸等手段评价成像质量。
③ 贴近真实光学系统的工程思路采用「一阶公式做初设 + 数值追迹做验证与优化」的方式,有以下优势:
避免只停留在理想薄透镜模型,能够自然涵盖厚度、非对称曲率等实际结构带来的偏差;
后续扩展到更复杂的系统(如双胶合透镜、消色差透镜、多片镜头)时,依然可以沿用同样的LensSystem + RayTracer 框架,而不必为每一种结构重新推导封闭形式的焦距公式;
更贴近光学工程中「先做一阶近似设计,再用光线追迹和像差分析修正结构」的实际工作流程。
LensSystem 在光学上相当于把「单透镜的前后表面及相关空间面」按传播顺序组织成一个有序的光学系统,并以一阶透镜制造者公式为理论参照,通过数值追迹来验证和理解该系统的真实成像行为和有效焦距,从而在代码层面自然地连接了理论公式与工程仿真。
3.4.2代码实现与光学映射
class LensSystem: """顺序光学系统:按从左到右的顺序存放表面""" surfaces: List[Surface] = field(default_factory=list) name: str = "SingleLens"
def add_surface(self, surface: Surface): self.surfaces.append(surface)
def build_single_lens(f: float = 50.0) -> LensSystem: """ 构造一个简单单透镜系统: - 左侧无限远物(用近似平行光射入表示) - 一片 BK7 薄透镜,前后曲率对称,厚度忽略或给一个小值 - 右侧像面为 z = image_plane_z """ lens = LensSystem(name="SingleLensDemo") glass = glass_bk7()
# 修改参数:增大半孔径和厚度,使10度光线能在透镜口径范围内 # 焦距约 50mm 的透镜,R1 = 100mm, R2 = -100mm, 中心厚 10mm # 介质:左侧空气 -> 玻璃 -> 空气 surface1 = Surface(radius=100.0, thickness=10.0, material_before=AIR, material_after=glass, semi_diameter=30.0)# 增大半孔径到30mm surface2 = Surface(radius=-100.0, thickness=50.0, material_before=glass, material_after=AIR, semi_diameter=30.0)# 增大半孔径到30mm
lens.add_surface(surface1) lens.add_surface(surface2)
return lens |
3.4.3设计的光学考量
表面顺序:按 ** 光线传播方向(从左到右)** 存储,与光线追迹的顺序一致,是追迹算法的基础;
build_single_lens 函数:封装薄透镜的表面构造逻辑,根据目标焦距自动计算曲率半径,符合 “按焦距设计透镜” 的工程需求;
对称透镜:前后表面曲率半径取R1=−R2,是最常用的单透镜结构,可减少像差(如球差)。
四、几何光线追迹的核心算法实现(trace 模块)
光线追迹是光学仿真的核心算法,trace 模块基于几何光学基本定律,实现了光线与球面/平面的交点计算、表面法线计算、Snell 定律的 3D 矢量折射、批量光线追迹四大核心功能,所有算法均为数值精确实现,无近似(除薄透镜的厚度近似),是后续所有分析和可视化的基础。
本模块所有函数均与core层实体类深度联动,直接调用Ray/Surface/LensSystem的光学参数,追迹结果也直接写入Ray类的path属性,保证了项目数据流转的统一性;同时算法实现严格遵循光学行业的符号规则和矢量运算规范,与 Zemax、CodeV 等商用软件的追迹逻辑一致,确保仿真结果的专业性和可比性。
4.1 光线与球面 / 平面的交点计算(trace/geometrical.py - intersect_spherical_surface)
4.1.1 完整光学公式推导
(1)光线的 3D 参数方程
在本项目约定的右手坐标系下,z 轴为光轴,光线整体沿方向从左向右传播。一条三维光线可写为参数方程:

其中:
:光线起点位置矢量;
:光线单位方向矢量,满足 ;
(t) 为标量参数,表示沿光线传播方向前进,在 单位化的前提下可直接视为“传播距离”。
(2)球面方程与曲率半径符号约定
考虑光轴为 z 轴的旋转对称球面,其轴上顶点位于:
光学行业对曲率半径 (R) 采用统一符号约定(光线沿方向从左到右传播):
:表面凸向光线传播方向(凸向右),球心在顶点的 一侧;
:表面凹向光线传播方向(凹向右),球心在顶点的 (-z) 一侧;
:平面表面,曲率为 0。
在这一约定下,球心坐标统一写为:
无论 (R) 正负,都自动给出球心在顶点左/右的位置。
球面方程为:
即:球心到表面上任意点的距离恒为 。
(3)代入光线方程,推导二次方程系数
将 代入球面方程:
令 ,则:
展开:

整理为关于 (t) 的一元二次方程:
其中系数严格推导为:

又因为本项目在 Ray 类中强制 direction 为单位向量,即:
故有:
这保证了参数 (t) 的数值就是几何意义上的“传播距离”,既方便物理理解,又有利于数值稳定性。这也是 Ray.normalize_direction() 的核心意义所在:只有在 单位化的前提下,求得的 (t) 才有明确的物理尺度含义。4.1.2解的物理筛选规则
形式上,二次方程的解为:
但在光学追迹中,仅保留有物理意义的有效交点。本项目采用如下筛选规则:
① 判别式条件:确保有实交点
若 :光线与该球面无任何实交点,直接判定为无交点;
若 :光线与球面相切,只有一个交点;
若 :光线与球面有两个数学交点,需要进一步物理筛选。
② 传播方向限制:只保留 的解
才表示交点位于光线传播方向上;
实际数值运算中,由于浮点误差,极小的正值或接近 0 的数值有可能被算成微小负数,因此本项目引入一个容差:
这样可以避免将本应视为“当前点附近、几乎不前进”的交点误判为“反方向上的负解”。
③ 最近交点优先:取最小的正解
当有两个正解 时:
选择:
对应光线从当前起点出发,在传播方向上首先遇到的表面交点;
这符合真实物理过程:光线总是先撞到离自己最近的那一段表面。
④ 通光口径校验:判断是否落在有效孔径内
对最终候选解 (t),计算交点坐标:
检查其相对于光轴的横向距离:

若满足:交点在表面有效通光口径内,视为有效交点;
若不满足:说明该光线打在了透镜外缘之外(被机械装调或光阑挡住),在几何仿真中应视为无有效交点(即该光线不继续参与后续传播与像质计算)。
综合以上 4 条规则,intersect_spherical_surface 在数学上保证“解存在且实数”,在物理上保证“交点在传播方向最近且落于有效孔径内”,从而实现了既正确又符合工程实际的交点判定逻辑。
4.1.3平面特例的算法优化
当表面为平面时,曲率半径 ,曲率 ,球面方程在轴向上退化为简单的平面方程:
此时无需再解一元二次方程,可以直接从光线的 z 分量出发,推导交点参数:
其中 (dz) 为光线方向矢量的 z 分量。
物理与数值筛选规则如下:
① 平行性判断
若 (例如 ):说明光线方向几乎完全平行于平面 ,则它与该平面无交点(或在数值上交点远在无穷远处),可直接返回“无交点”。
② 传播方向判断
若计算得 :交点在光线反向或刚好在起点之前,无物理意义;
仅当 时,才认为光线在传播方向上真正“走到”了该平面。
③ 通光口径校验
对有效 (t) 计算交点坐标:

同样检查:

不满足时,认为光线在该平面上的交点超出了有效通光范围,视作无有效交点。
④ 与球面情况的统一性
在代码实现层面,可以采用如下统一策略:
若 足够大(例如 )或显式标记为平面(如radius == float("inf")),则走“平面专用分支”,使用上述线性交点公式;
否则按一般球面逻辑,构造 (A,B,C) 并解一元二次方程。
这样既保留了理论上的严格性,又在工程实现上避免了在平面上解退化二次方程导致的数值不稳定;大幅简化了平面交点计算,提升运算效率;让“球面 / 平面”在同一 intersect_spherical_surface 接口下自然统一,便于扩展和维护。
4.1.4 代码实现与光学映射
函数输入直接调用core层Ray类的origin/direction和Surface类的radius/semi_diameter/顶点,输出为交点有效性标记、参数、3D交点坐标,同时嵌入通光口径校验逻辑,代码与公式一一对应:
def intersect_spherical_surface(ray: Ray, surface: Surface, z_surface: float) -> Tuple[float, float, float]: """ 计算光线与球面/平面交点 (x, y, z) 3D,光线方程 x = x0 + t*dx, y = y0 + t*dy, z = z0 + t*dz 球面中心: (0, 0, z_c),半径 R(系统轴对称) """ x0, y0, z0 = ray.path[-1] dx, dy, dz = ray.direction
R = surface.radius if surface.curvature == 0: # 平面:z = z_surface t = (z_surface - z0) / dz return x0 + t * dx, y0 + t * dy, z0 + t * dz
# 球心坐标(系统轴对称,球心在光轴上) zc = z_surface + R# 约定:表面顶点在 z_surface,球心在其左右 R xc, yc = 0.0, 0.0
# 解 |P(t) - C|^2 = R^2 # (x0 + dx t - xc)^2 + (y0 + dy t - yc)^2 + (z0 + dz t - zc)^2 = R^2 A = dx**2 + dy**2 + dz**2 B = 2 * (dx * (x0 - xc) + dy * (y0 - yc) + dz * (z0 - zc)) C = (x0 - xc)**2 + (y0 - yc)**2 + (z0 - zc)**2 - R**2
disc = B**2 - 4 * A * C if disc < 0: raise RuntimeError("光线与球面无实交点")
t1 = (-B - np.sqrt(disc)) / (2 * A) t2 = (-B + np.sqrt(disc)) / (2 * A) t = min(t1, t2) if t1 > 0 else t2# 取向前的交点
return x0 + dx * t, y0 + dy * t, z0 + dz * t |
4.2 表面法线计算(trace/(geometrical.py - calculate_surface_normal)
4.2.1 光学依据
在几何光学中,光线在光学表面上发生折射或反射时,入射角、折射角(或反射角)都是相对于表面法线来定义的。对于本项目采用的 3D 矢量 Snell 定律形式,表面法线必须以单位矢量形式参与计算,因此在每个光线与表面的交点处,精确、统一地定义表面法矢量是折射计算的前提。
在约定光线沿 方向(从左向右)传播、光轴为z 轴的坐标系中,表面法线在光学上的标准定义为:
① 球面法线
对于曲率半径为 (R) 的旋转对称球面,其球心位于
其中 为该表面顶点在 z 轴上的坐标(与交点计算中完全一致),(R) 的符号遵循行业约定:
:表面凸向光线传播方向,球心在顶点的 一侧;
:表面凹向光线传播方向,球心在顶点的 (-z) 一侧。
设光线与表面的 3D 交点为
则球面法线定义为过球心与交点的有向直线,从球心指向交点的方向。相应的法矢量为:
这种定义与球面几何完全一致:
为球心指向表面交点的半径矢量;
归一化后即为单位法矢量,垂直于该点处的球面切平面。
② 平面法线
当表面为平面时,曲率半径 ,曲率为 0,表面方程可写为:
对于这种与光轴垂直的平面,其几何法线沿 z 轴方向。根据本项目“光线从左向右沿 传播”的约定,将平面法线统一取为指向方向的单位矢量:
这对应于「平面为 时,法矢量为((0,0,1)),指向光线传播方向」。
③ 法矢量统一指向光线入射侧
为了在 3D 矢量 Snell 定律中获得一致、无歧义的符号规则,本项目对所有表面的法矢量采用统一约定:
在每一个光学表面上,表面法矢量 的方向始终定义为指向光线入射侧的单位矢量。
这样做的意义在于:
入射角 一律通过来计算,其中 为入射光线单位方向矢量,指向入射侧,无需在代码中额外区分“哪一面是物方/像方”以及“法线是否需要翻转”;
全反射判据、折射方向计算等公式均基于 的符号和大小,若法线方向不统一,就会导致同一公式在不同表面上出现符号反转甚至物理解读错误;
将法线统一指向入射侧,相当于为 Snell 定律建立了一个全局固定的“正方向”参考,使折射方向的求解在所有表面上都遵循同一套符号规则,有利于保证追迹结果的物理正确性和代码实现的一致性。
综上,表面法线的光学定义可以概括为:
球面:以球心为起点、指向交点的单位半径矢量;
平面:沿光轴方向的单位向量(在本项目中统一取为((0,0,1)),指向光线传播方向一侧);
所有表面的法矢量在使用前必须归一化为单位矢量,并且其方向统一视为「指向光线入射侧」,以为后续 Snell 定律的 3D 矢量折射计算提供稳定、一致的几何基础。
4.2.2 代码实现与光学映射
函数输入为光线与表面的交点、表面参数,输出为归一化的3D单位法矢量,直接为后续Snell定律折射提供输入,与光学定义完全一致:
def surface_normal(surface: Surface, point: Tuple[float, float, float], z_surface: float) -> np.ndarray: """计算表面在交点处的法线(指向入射介质一侧或出射一侧,可约定)""" x, y, z = point if surface.curvature == 0: # 平面,法线沿 +z 或 -z,这里取 +z n = np.array([0.0, 0.0, 1.0]) else: R = surface.radius zc = z_surface + R xc, yc = 0.0, 0.0 n = np.array([x - xc, y - yc, z - zc])# 从球心指向表面 # 归一化 return n / np.linalg.norm(n) |
4.3 Snell 定律的3D 矢量折射(trace/geometrical.py- refract_ray)
4.3.1 光学依据
几何光学中常见的 Snell 定律标量形式
只适用于子午面内的2D 情形,即光线、法线和光轴共面的情况。本项目采用的是3D 矢量光线追迹,光线和表面法线在空间中可指向任意方向,因此需要使用Snell 定律的 3D 矢量形式来计算折射方向。
① 3D 矢量 Snell 定律的基本形式
在入射介质折射率为 、出射介质折射率为 的界面上,设:
:入射光线的单位方向矢量;
:折射光线的单位方向矢量;
:表面交点处的单位法矢量,且方向统一定义为指向入射侧。
Snell 定律的 3D 矢量形式可以写成:
上式含义是:在界面处,入射光线和折射光线在切向方向上的分量(即与法线垂直的部分)满足 Snell 定律。
在此基础上,可以推导出折射光线方向矢量 的显式表达式:

其中:
:光线入射侧介质折射率(Surface.material_before.n());
:光线出射侧介质折射率(Surface.material_after.n());
:入射光线单位方向矢量(Ray.direction,在 Ray.normalize_direction() 中已归一化);
:折射光线单位方向矢量(refract_ray计算输出);
:表面交点处的单位法矢量(由calculate_surface_normal 计算,方向统一为指向入射侧)。
这正是 trace/geometrical.py - refract_ray 函数中折射方向计算公式的理论依据。
② 入射角的定义与计算
入射角 定义为入射光线方向 与表面法线 之间的夹角。在法线已经统一为指向入射侧的前提下,有:
由于 与 都是单位矢量,故有:

这与项目中使用的入射角计算完全一致,为后续的全反射判断提供了直接依据。
③ 全反射判断条件
当光线从光密介质射向光疏介质时(即 ),可能发生全反射。经典 Snell 定律给出全反射条件为:
结合上面对 的表达,有:
等价地,
一旦上述条件成立,将不再存在实数的折射方向解,物理上对应光线完全被反射。因此在代码实现中:
当判断出满足全反射条件时,refract_ray 应返回“无折射光”(例如 (False, None));
上层追迹逻辑据此判定:该光线在此表面不再产生透射部分,可视为追迹终止(或进入反射分支,若后续扩展反射模型)。
④ 与项目实现的一致性说明
结合上述光学依据与项目中已有实现,可以总结出refract_ray 的关键约定与行为:
输入量:
入射光线:Ray 实例,提供单位方向矢量 direction 和波长 wavelength;
光学表面:Surface 实例,提供入射/出射侧材料(material_before / material_after),从而得到;
表面法线:由calculate_surface_normal 计算的单位法矢量 ,方向统一为指向入射侧。
内部计算:
通过 计算入射角的余弦;
利用 与 判定是否发生全反射;
若无全反射,则按 3D 矢量 Snell 定律公式计算折射方向 ,并对结果再归一化,保证仍为单位矢量。
输出量:
has_refract:布尔量,表示是否存在折射光(即是否未发生全反射);
d2:折射光线的 3D 单位方向矢量,用于更新 Ray.direction 并继续后续表面的追迹。
通过以上设计,refract_ray 在数值上与 Snell 定律的 3D 矢量形式一一对应,同时配合统一的法线方向约定和全反射判定,使得整个追迹流程在任意 3D 几何配置下都具有明确、正确的物理含义。
4.3.2 代码实现与光学映射
函数输入直接调用core层Surface类的介质属性(获取)、入射光线方向矢量、表面法矢量,输出为折射有效性标记和3D折射单位方向矢量,嵌入全反射判断逻辑,算法与3D矢量Snell定律完全对应:
def refract(direction: np.ndarray, normal: np.ndarray, n1: float, n2: float) -> np.ndarray: """Snell 折射,direction 为入射方向(指向传播方向)""" # 3D 版向量折射 d = direction / np.linalg.norm(direction) n = normal / np.linalg.norm(normal)
# 使法线指向入射介质 if np.dot(d, n) > 0: n = -n
cos_i = -np.dot(d, n) sin2_i = max(0.0, 1.0 - cos_i**2) n_ratio = n1 / n2 sin2_t = n_ratio**2 * sin2_i
if sin2_t > 1.0: # 全反射,这里可以先抛出异常或返回反射方向 raise RuntimeError("全反射发生")
cos_t = np.sqrt(1.0 - sin2_t) # 折射方向公式 t = n_ratio * d + (n_ratio * cos_i - cos_t) * n return t / np.linalg.norm(t) |
4.4 批量光线追迹(trace/tracer.py - RayTracer)
前 3 个功能(光线与球面/平面交点、表面法线、Snell 折射)为原子算法,实现单步的几何运算;RayTracer 类则将这些原子算法工程化封装为完整的追迹流程,实现:
单条光线在整个透镜系统中的连续传播(实例方法 trace_ray);
多条光线的批量追迹(实例方法 trace_rays)。
它是 trace 模块对外提供的核心接口,直接对接后续的 analysis 和 visual 模块:所有的 RMS 光斑、点列图、光扇图等分析与可视化,最终都只依赖于 Ray.path 中记录的 3D 坐标序列。
整体追迹流程仍然严格遵循光学系统的光线传播顺序:从左到右遍历 LensSystem 中的所有光学表面,对每条光线依次执行「交点计算 → 法线计算 → 折射计算」,并将每一表面的交点写入 Ray.path,最终再传播到像面并记录像面交点坐标。
4.4.1 RayTracer 类与光学系统坐标
当前工程实现采用一个面向对象的追迹器类 RayTracer,而不是若干个独立的函数。其核心结构如下:
class RayTracer: def __init__(self, lens: LensSystem, z0: float = 0.0): """ z0: 第一表面的顶点位置(例如 z=0) 系统中每个 surface.thickness 用来累加 z 位置 """ self.lens = lens self.z0 = z0 self.surface_positions = self._compute_surface_positions()
def _compute_surface_positions(self) -> List[float]: """计算每个 surface 顶点的绝对 z 坐标""" positions = [] z = self.z0 for s in self.lens.surfaces: positions.append(z) z += s.thickness return positions |
z0 作为第一表面顶点的 z 坐标,确定了整个光学系统在全局坐标中的位置。通常可取 z0 = 0.0。
_compute_surface_positions()通过累加 surface.thickness,得到每一个表面顶点在 z 轴上的绝对坐标 z_surf,后续所有的交点/法线计算都在这一全局坐标系中进行。
RayTracer持有一个 LensSystem 实例,后续对所有光线的追迹都共享同一套表面数据和空间布局。
4.4.2单条光线完整追迹(trace_ray 实例方法)
RayTracer.trace_ray对单条光线执行完整 3D 追迹。当前实现采用实例方法,并且像面位置采用简单的“固定距离”策略,而不是显式传入image_plane_z 参数。
def trace_ray(self, ray: Ray) -> Ray: """ 单条光线在当前 LensSystem 中的完整 3D 追迹,追迹结果写入 ray.path。
:param ray: 待追迹光线,core/Ray 实例 - ray.origin:光线起点 3D 坐标 - ray.direction:单位方向矢量 - ray.wavelength:波长(用于查材料折射率) :return: 更新后的 Ray 实例(与输入是同一个对象)追迹完成后: - ray.path 中依次记录了光线在各表面以及像面上的 3D 交点坐标: [起点?, 表面1交点, 表面2交点, ..., 像面交点] (是否包含起点取决于上层在调用前是否先 add_point) """ current_ray = ray # 第一阶段:依次穿过所有光学表面 for surface, z_surf in zip(self.lens.surfaces, self.surface_positions): # 1) 光线与当前表面的交点 p_int = intersect_spherical_surface(current_ray, surface, z_surf) current_ray.add_point(p_int)
# 2) 计算交点处表面法线 n = surface_normal(surface, p_int, z_surf)
# 3) 根据 Snell 定律计算折射方向 n1 = surface.material_before.n(current_ray.wavelength) n2 = surface.material_after.n(current_ray.wavelength) d_new = refract(np.array(current_ray.direction), n, n1, n2) current_ray.direction = (float(d_new[0]), float(d_new[1]), float(d_new[2]))
# 第二阶段:通过最后一个面后,直线传播到像面 z_last = self.surface_positions[-1] + self.lens.surfaces[-1].thickness z_img = z_last + 50.0# 像面位置,可根据需要调整/优化x0, y0, z0 = current_ray.path[-1] dx, dy, dz = current_ray.direction t = (z_img - z0) / dz p_img = (x0 + dx * t, y0 + dy * t, z0 + dz * t) current_ray.add_point(p_img)
return current_ray |
4.4.3多光线批量追迹(trace_rays 实例方法)
批量追迹的当前实现同样是 RayTracer 的实例方法:
def trace_rays(self, rays: List[Ray]) -> List[Ray]: """ 多光线批量追迹: :param rays: 待追迹光线列表,可包含多视场、多孔径光线 :return: 追迹完成后的光线列表(与输入列表中的对象相同)
对每条光线依次调用 trace_ray,并将追迹结果写入对应 Ray.path。 当前实现不筛选“失败光线”(无出瞳/全反射判断),所有输入光线都会被返回。 """ return [self.trace_ray(r) for r in rays] |
4.4.4 trace 模块的联动机制
trace模块作为算法追迹层,是连接 core 实体层和 analysis/visual 应用层的核心桥梁,其与各层的联动严格遵循项目的数据流转逻辑,无任何冗余数据定义,保证了项目的模块化和解耦性。
与 core 层的联动:
trace 模块直接读取 Ray、Surface、LensSystem 的光学参数(如光线起点与方向、曲率半径、折射率、厚度等),不对这些物理量做任何形式的“二次封装”或重定义。追迹结果仅通过统一的数据出口写入 Ray.path 属性,记录光线在各光学表面及像面上的 3D 交点坐标,从而保证 core 实体层的唯一性与一致性。
与 analysis 层的联动:
analysis 模块只依赖追迹完成后的 Ray 对象,不直接调用 trace 模块的内部函数。它直接从 Ray.path 中提取像面交点坐标,用于计算 RMS 光斑尺寸、光扇图像差等像质指标。这样一来,analysis 层对 trace 的依赖完全通过“数据接口”完成,而不是通过算法实现细节,便于后续替换或扩展追迹实现。
与 visual 层的联动:
visual 模块同样只依赖 Ray.path:
从 Ray.path 提取光线传播路径上的 3D 坐标,并在 y–z 子午面或其他投影平面上绘制 2D 光路图;
从 Ray.path 的像面交点提取 (x, y) 坐标,绘制点列图、光扇图等成像结果可视化。
整个可视化过程只关心“轨迹数据本身”,与具体的交点求解、法线计算、折射实现等追迹细节完全解耦。
五、成像质量量化分析(analysis 模块)
analysis 模块基于 trace 模块追迹后的 Ray.path 数据,实现基础的成像质量定量分析。当前版本聚焦两个最核心、也是工程中最常用的单透镜评价数据:
像面 RMS 光斑尺寸(analysis/spot.py)
子午/弧矢光扇数据(analysis/ray_fan.py)
模块本身不参与任何光线追迹,只读取已经由 RayTracer 写入的 Ray.path,对这些像面坐标做数值处理;计算结果又可直接被 visual 模块消费,用于点列图、光扇图等可视化,实现“定量分析 + 定性可视化”的闭环。
5.1分析指标的光学依据
本项目在单透镜场景下,围绕以下几个最基础的像质指标展开分析,这些指标都建立在“像面交点分布”的基础上:
RMS 光斑尺寸
所有光线在像面上的交点 相对于理想像点 有一定偏差。
RMS 光斑尺寸(Root Mean Square Spot Size)定义为这些偏差距离的均方根,是衡量像斑“扩散程度”的标量指标,RMS 越小,光斑越集中,系统单色像差越小,成像越清晰。
子午/弧矢光扇数据
光扇图从“瞳坐标 → 像面坐标”的映射角度来刻画单色像差:
子午光扇(meridional fan):在子午面(通常是 y–z 面)内扫描不同入瞳高度的光线,记录它们在像面上的 y 坐标随“光瞳归一化坐标”的变化;
弧矢光扇(sagittal fan):在弧矢方向上(对应 x 方向)记录同一批光线的 x 坐标 随瞳坐标的变化。
这些“瞳坐标–像面坐标”数据是后续计算“像面偏差(= 实际−理想像点)”并绘制标准光扇图的基础,用于分析球差、彗差、像散等单色像差。
5.2 RMS 光斑尺寸计算(analysis/spot.py - rms_spot_size)
5.2.1光学公式
理论上,RMS 光斑尺寸的定义为:

其中:
N:追迹成功、到达像面的光线数量;
:第 (i) 条光线在像面上的横向坐标(mm),即 Ray.path[-1] 的((x,y)) 分量;
:理想像点的横向坐标:
轴上视场时,理想像点位于光轴上,;
离轴视场时,理想像点由视场角和系统焦距结合高斯成像理论推导得到。
在当前实现中,为了简化接口,rms_spot_size 使用光斑的实际中心作为参考点,而不是显式传入。也就是说:先根据所有像面点的均值 计算光斑中心;然后将 视作参考点,计算每个点到该中心的距离的均方根。
从几何意义看,这相当于度量光斑自身的“扩散程度”,而不关心光斑整体是否偏离理想像点——这正是很多工程场景中最关心的“斑点大小”。5.2.2代码实现
对应的实现位于 analysis/spot.py:
def spot_metrics(rays: List[Ray], ideal_x: float = 0.0, ideal_y: float = 0.0) -> Tuple[float, float, float, float]: if not rays: return 0.0, 0.0, 0.0, 0.0 points = np.array([r.path[-1] for r in rays]) xs_mm = points[:, 0] ys_mm = points[:, 1] center_x_mm = float(np.mean(xs_mm)) center_y_mm = float(np.mean(ys_mm)) distances_sq_ideal = (xs_mm - ideal_x)**2 + (ys_mm - ideal_y)**2 rms_mm = float(np.sqrt(np.mean(distances_sq_ideal))) rms_um = rms_mm * 1e3 distances_ideal = np.sqrt(distances_sq_ideal) geom_mm = float(np.max(distances_ideal)) geom_um = geom_mm * 1e3 return rms_um, geom_um, center_x_mm, center_y_mm
def get_chief_ray_center(rays: List[Ray]) -> tuple[float, float]: # 根据 field_angle 和 origin≈(0,0,*) 筛选 chief ray for r in rays: if abs(r.path[0][0]) < 1e-3 and abs(r.path[0][1]) < 1e-3: x_chief, y_chief, _ = r.path[-1] return x_chief, y_chief # fallback pts = np.array([r.path[-1] for r in rays]) return float(np.mean(pts[:,0])), float(np.mean(pts[:,1]))
def rms_spot_size(rays: List[Ray]) -> float: points = np.array([r.path[-1] for r in rays]) xs_mm = points[:, 0] ys_mm = points[:, 1] x_mean = np.mean(xs_mm) y_mean = np.mean(ys_mm) distances_sq = (xs_mm - x_mean)**2 + (ys_mm - y_mean)**2 rms_mm = np.sqrt(np.mean(distances_sq)) rms_um = rms_mm * 1e3 return float(rms_um) |
函数输入是追迹成功的光线列表 rays: List[Ray],每条 Ray 的 path[-1] 被认为是像面交点;
将所有像面点打包成二维数组 points,分别取出 x、y 坐标(单位 mm);
用 np.mean(xs_mm) 和np.mean(ys_mm) 求出光斑中心坐标 ;
计算每个点到中心的平方距离并平均,再开方得到 RMS 半径(mm);
最后将单位从 mm 转换为 μm 返回。
5.3光扇图数据计算(analysis/ray_fan.py)
根据使用场景,将光扇数据的生成拆分为两个方向:
子午光扇:compute_meridional_ray_fan
弧矢光扇:compute_sagittal_ray_fan
这两个函数都位于 analysis/ray_fan.py,它们本质上做两件事:
调用examples.ray_fan_utils 中的光线生成工具,构造一批在不同瞳高处通过系统的光线;
使用 RayTracer 追迹这些光线,并提取像面坐标作为光扇数据。
5.3.1子午光扇数据(compute_meridional_ray_fan)
def compute_meridional_ray_fan(tracer: RayTracer, pupil_radius: float, n_rays: int, field_angle: float = 0.0) -> Tuple[List[float], List[float]]: """ 计算子午光扇数据 - tracer: RayTracer实例 - pupil_radius: 入瞳半径(mm) - n_rays: 采样点数 - field_angle: 视场角(度) 返回: (PY_list, EY_mm) PY_list: 归一化光瞳坐标 [-1, 1] EY_mm: 子午方向像差(mm),相对于主光线像点 """ PY_list, rays = generate_meridional_fan_rays(pupil_radius, n_rays, field_angle) traced_rays = tracer.trace_rays(rays)
# 找到主光线(光瞳中心,PY=0)的像点作为参考 y_chief = 0.0 for i, PY in enumerate(PY_list): if abs(PY) < 1e-6:# 主光线,PY=0 x_img, y_chief, z_img = traced_rays[i].path[-1] break
EY_mm = [] for ray in traced_rays: x_img, y_img, z_img = ray.path[-1] EY_mm.append(y_img - y_chief)
return PY_list, EY_mm |
generate_meridional_fan_rays 负责在给定入瞳半径、采样数和视场角下,生成一条沿子午面扫描的光扇:返回的 PY_list 是归一化的光瞳坐标(通常是 区间的均匀采样),rays 是对应的 Ray 列表;
RayTracer.trace_rays 对这组 rays 做完整几何追迹,ray.path[-1] 即为像面交点;
将每条光线的 y_img 组成EY_list 返回,单位为 mm。
这里返回的 EY_list 是像面绝对 y 坐标,不是已经减去理想像点的“偏差”。
5.3.2弧矢光扇数据(compute_sagittal_ray_fan)
def compute_sagittal_ray_fan(tracer: RayTracer, pupil_radius: float, n_rays: int, field_angle: float = 0.0) -> Tuple[List[float], List[float]]: """ 计算弧矢光扇数据 - tracer: RayTracer实例 - pupil_radius: 入瞳半径(mm) - n_rays: 采样点数 - field_angle: 视场角(度) 返回: (PX_list, EX_mm) PX_list: 归一化光瞳坐标 [-1, 1] EX_mm: 弧矢方向像差(mm),相对于主光线像点 """ PX_list, rays = generate_sagittal_fan_rays(pupil_radius, n_rays, field_angle) traced_rays = tracer.trace_rays(rays)
# 找到主光线(光瞳中心,PX=0)的像点作为参考 x_chief = 0.0 for i, PX in enumerate(PX_list): if abs(PX) < 1e-6:# 主光线,PX=0 x_chief, y_img, z_img = traced_rays[i].path[-1] break
EX_mm = [] for ray in traced_rays: x_img, y_img, z_img = ray.path[-1] EX_mm.append(x_img - x_chief)
return PX_list, EX_mm |
PX_list 同样是归一化的光瞳坐标;
EX_list 是每条光线在像面的 x 坐标(mm),代表弧矢方向的像面坐标;
这样,analysis/ray_fan.py 提供的是标准化的“瞳坐标–像面坐标”数据,visual 模块则在 ray_fan_plot.py 中将它们统一转换为 μm,并绘制出子午/弧矢光扇图。
六、光学结果可视化(visual 模块)
visual 模块基于 trace 模块追迹后的 Ray.path 数据,以及 analysis 模块输出的基础量化指标,实现光路图、点列图、光扇图三大核心光学图表的绘制。所有图表均采用光学工程中常见的可视化规范,与 Zemax 等商用软件的图表样式在视觉上保持一致,使几何追迹结果更直观、更易解读。
本模块完全基于 matplotlib 实现,所有绘图函数均为独立的纯可视化接口:
只读取 Ray.path 或analysis 输出的数值,不参与任何光线追迹和像差计算;
支持标题后缀、图形尺寸、颜色映射等自定义配置;
通过中文字体设置,支持中英文混排标注。
6.1光路图绘制(visual/rayplot.py)
6.1.1光学可视化规范
光路图直接展示光线在光学系统中传播的路径,是理解透镜成像行为的第一视角。本项目中,单透镜系统的光路图采用 y–z 子午面投影,遵循如下规范:
① 坐标系约定
z 轴为光轴方向(从左到右);
y 轴为子午方向高度;
在图中统一采用「横轴 z、纵轴 y」的 2D 投影方式。
② 透镜几何表示
根据每个表面的曲率半径radius 和半孔径 semi_diameter 精确绘制表面轮廓;
对球面,通过曲率符号和球心位置计算截面曲线;
对平面或近似平面(R→∞ 或极小半径),绘制垂直于光轴的直线段。
③ 光线路径表示
所有光线都来自Ray.path 中的 3D 轨迹点,取其中的 (z, y) 分量进行 2D 绘制;
可绘制多条光线,形成光束的整体传播形态;
在基础光路图中统一使用蓝色线条,在视场分色光路图中对不同视场角使用不同颜色。
④ 视场信息展示
对于具有field_angle 属性的光线,可以按视场角分配颜色,并在图例中显示,例如 “0°、5°、10°”;
图例自动去重,避免重复标签。
⑤ 图形设置
使用set_aspect('equal', 'box'),保证 z–y 比例真实;
打开网格线,便于读数和比较;
使用中文字体设置支持中文标题。
6.1.2代码实现
visual/rayplot.py 中包含三个核心函数:
(1)透镜表面轮廓绘制:draw_lens_surfaces
def draw_lens_surfaces(ax, lens: LensSystem, surface_positions: List[float]): """ 在 y–z 子午面上,根据曲率半径和孔径画出每个表面的轮廓。 只画表面线条(不填充整个透镜实体)。 """ for s, z_s in zip(lens.surfaces, surface_positions): R = getattr(s, "radius", None) semi_dia = getattr(s, "semi_diameter", None) if semi_dia is None: continue# 没有孔径信息就不画
# 平面或近似平面 if (R is None) or (abs(R) < 1e-6) or (abs(R) > 1e6): ys = np.linspace(-semi_dia, semi_dia, 50) zs = np.full_like(ys, z_s) ax.plot(zs, ys, color="k", linewidth=1.0) continue
# 球面:根据 R 计算球心,并在给定 y 范围内求 z(y) z_c = z_s + R# 球心 z ys = np.linspace(-semi_dia, semi_dia, 200)
inside = ys**2 <= R**2 ys_valid = ys[inside] if ys_valid.size == 0: continue
# 选择靠近透镜内部的一侧(以 z_s 为参考) if R > 0: zs_valid = z_c - np.sqrt(R**2 - ys_valid**2)# 球心在右侧,取左侧弧 else: zs_valid = z_c + np.sqrt(R**2 - ys_valid**2)# 球心在左侧,取右侧弧
ax.plot(zs_valid, ys_valid, color="k", linewidth=1.0) |
该函数只负责画出每一个表面的二维轮廓,不填充内部实体,既保留几何精度又保证绘图简洁。
(2)基础光路图:plot_ray_path
def plot_ray_path(rays: List[Ray], lens: LensSystem, surface_positions: List[float], title_suffix: str = ""): """二维光路图,z 为横轴,y 为纵轴""" fig, ax = plt.subplots(figsize=(10, 4))
# 先画透镜表面实际轮廓 draw_lens_surfaces(ax, lens, surface_positions)
# 再画光线 for r in rays: zs = [p[2] for p in r.path] ys = [p[1] for p in r.path] ax.plot(zs, ys, 'b-', alpha=0.5)
ax.set_xlabel("z (mm)") ax.set_ylabel("y (mm)") ax.set_title(f"Ray Path - {lens.name} {title_suffix}") ax.grid(True) ax.set_aspect('equal', 'box') # 显式设置 y 轴范围为 ±40mm if lens.surfaces: semi_dia = getattr(lens.surfaces[0], "semi_diameter", 40.0) ax.set_ylim(-semi_dia * 1.1, semi_dia * 1.1) plt.tight_layout() plt.show() |
(3)按视场上色的光路图:plot_ray_path_by_field
def plot_ray_path_by_field(rays: List[Ray], lens: LensSystem, surface_positions: List[float], title_suffix: str = ""): """按视场角自动上色的光路图,z 为横轴,y 为纵轴""" fig, ax = plt.subplots(figsize=(10, 4))
# 先画透镜表面实际轮廓 draw_lens_surfaces(ax, lens, surface_positions)
# 不同 field_angle 用不同颜色 colors = plt.cm.tab10.colors
# 先收集所有的视场角 field_angles = set() for ray in rays: field_angles.add(ray.field_angle)
# 为每个视场角分配颜色 angle_list = sorted(field_angles) angle_color_map = {} for i, angle in enumerate(angle_list): angle_color_map[angle] = colors[i % len(colors)]
# 绘制光线 for ray in rays: zs = [p[2] for p in ray.path] ys = [p[1] for p in ray.path] # 获取该视场角对应的颜色 color = angle_color_map[ray.field_angle] ax.plot(zs, ys, color=color, alpha=0.7, linewidth=1, label=f"{ray.field_angle}°")
ax.set_xlabel("z (mm)") ax.set_ylabel("y (mm)") ax.set_title(f"光路图{title_suffix}") ax.set_aspect('equal', 'box') ax.grid(True) # 显式设置 y 轴范围为 ±40mm if lens.surfaces: semi_dia = getattr(lens.surfaces[0], "semi_diameter", 40.0) ax.set_ylim(-semi_dia * 1.1, semi_dia * 1.1)
# 图例去重 handles, labels = ax.get_legend_handles_labels() by_label = dict(zip(labels, handles)) ax.legend(by_label.values(), by_label.keys(), loc="upper right")
plt.tight_layout() plt.show() |
适用于:
多视场综合展示,如 0°、5°、10° 视场光线同时显示;
对比不同视场下光线在系统中的折射路径差异。
6.2点列图绘制(visual/spotplot.py)
6.2.1光学可视化规范
点列图通过展示像面上各光线的交点分布,直观体现成像质量和像差类型。当前实现遵循如下规范:
① 坐标含义
横轴:像面 x 坐标;
纵轴:像面 y 坐标;
单位统一换算为 µm,以便观察微小光斑结构。
② 参考中心
不显式画出“理论理想像点”,而以主光线(chief ray)的像点作为几何中心;
所有光线终点相对于主光线像点做平移,得到相对坐标,保证不同仿真条件下的点列图均以主光线为中心对齐。
③ 视觉风格
使用蓝色 + 号标记,与 Zemax 点列图默认风格一致;
坐标轴等比例,保证光斑形状不会被拉伸;
自动根据光斑外接范围设置坐标轴限,使光斑居中显示。
6.2.2代码实现
def plot_spot_diagram(rays: List[Ray], title_suffix: str = ""): """像面处点列图:取每条光线的最后一个点,画x-y平面,以主光线为中心""" # 获取所有光线的像平面位置 image_points = np.array([r.path[-1] for r in rays]) xs_mm = image_points[:, 0] ys_mm = image_points[:, 1]
# 寻找主光线(光瞳中心出发的光线,即原点出发的光线) chief_ray = None for ray in rays: # 检查光线原点是否接近光瞳中心 (0, 0, -100) origin = ray.path[0] if abs(origin[0]) < 1e-3 and abs(origin[1]) < 1e-3: chief_ray = ray break
# 计算主光线在像平面上的位置作为参考点 if chief_ray: chief_x_mm, chief_y_mm, chief_z_mm = chief_ray.path[-1] # 将所有光线的像平面位置减去主光线的位置,得到相对主光线的偏移 xs_mm -= chief_x_mm ys_mm -= chief_y_mm
# mm -> μm xs_um = xs_mm * 1e3 ys_um = ys_mm * 1e3
fig, ax = plt.subplots(figsize=(6, 6)) # 使用 '+' 标记,与 Zemax 风格一致,调整大小和颜色 ax.plot(xs_um, ys_um, 'b+', markersize=5, alpha=0.7)
# 设置坐标轴范围,使点列图居中 if len(xs_um) > 0 and len(ys_um) > 0: max_val = max(max(abs(xs_um)), max(abs(ys_um))) * 1.2 ax.set_xlim(-max_val, max_val) ax.set_ylim(-max_val, max_val)
# 添加网格线,便于观察光斑分布 ax.grid(True, linestyle='--', alpha=0.3)
# 获取视场角信息,用于标题 field_angle = None if rays: field_angle = rays[0].field_angle
# 构建标题 title = "Spot Diagram" if field_angle is not None: title += f" (Field: {field_angle}°)" if title_suffix: title += f" {title_suffix}"
ax.set_xlabel("x (μm)") ax.set_ylabel("y (μm)") ax.set_title(title) ax.set_aspect('equal', 'box') plt.tight_layout() plt.show() |
该函数只依赖:
ray.path[0]:判断主光线(原点出发);
ray.path[-1]:像面交点坐标;
ray.field_angle:用于标题标注视场角。
上层若需要数值 RMS 光斑尺寸,可由analysis/spot.py 的 rms_spot_size 计算,再自行叠加到标题或图中文本中。本函数保持纯绘图职责。
6.3光扇图绘制(光扇图绘图库)
6.3.1光学可视化规范
光扇图用于分析单色像差在瞳面上的分布趋势,当前实现中:
① 输入数据形式
横坐标:归一化光瞳坐标(PX或 PY),范围 ;
纵坐标:像面坐标(EX 或 EY,单位 mm),代表光线在像面的x 或 y 坐标。
② 偏差基准
在子午/弧矢单图中,函数内部会以“光瞳中心(PX 或 PY 最接近 0 的点)对应的像面坐标”为参考,将所有数据平移,使该点作为 0 基准,形成交对称的像差曲线;
在合并光扇图中,直接以 µm为单位绘制 EX、EY 两组数据,纵坐标标签统一为“像差 (μm)”。
③ 绘图风格
使用连续曲线 + 散点叠加的形式,同时展示趋势和采样点;
使用蓝色表示子午光扇、红色表示弧矢光扇;
添加横纵零线,明确像差为 0的理想基线位置;
横坐标范围固定为 ,纵坐标自动调整。
6.3.2代码实现
(1)子午光扇图:plot_meridional_fan
def plot_meridional_fan(PY: List[float], EY_mm: List[float], title_suffix: str = "", y_lim: float = None): """ 绘制子午光扇图 - PY: 归一化光瞳坐标 [-1, 1] - EY_mm: 子午方向像差(mm) - title_suffix: 标题后缀 - y_lim: 纵坐标范围(μm),如果为None则自动计算 """ EY_um = [ey * 1e3 for ey in EY_mm]
fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(PY, EY_um, 'b-', linewidth=2, alpha=0.7) ax.scatter(PY, EY_um, c='b', s=20, alpha=0.7)
ax.axhline(y=0, color='k', linestyle='--', linewidth=1, alpha=0.5) ax.axvline(x=0, color='k', linestyle='--', linewidth=1, alpha=0.5)
ax.set_xlabel('归一化光瞳坐标 PY') ax.set_ylabel('像差 (μm)') if title_suffix: ax.set_title(f'子午光扇图 {title_suffix}') else: ax.set_title('子午光扇图') ax.grid(True, alpha=0.3) ax.set_xlim(-1.1, 1.1) if y_lim is not None: ax.set_ylim(-y_lim, y_lim) else: # 自动 y 轴范围 y_min = min(EY_um) y_max = max(EY_um) y_pad = (y_max - y_min) * 0.1 if y_max != y_min else 0.1 ax.set_ylim(y_min - y_pad, y_max + y_pad) plt.tight_layout() plt.show() |
(2)弧矢光扇图:plot_sagittal_fan
def plot_sagittal_fan(PX: List[float], EX_mm: List[float], title_suffix: str = "", y_lim: float = None): """ 绘制弧矢光扇图 - PX: 归一化光瞳坐标 [-1, 1] - EX_mm: 弧矢方向像差(mm) - title_suffix: 标题后缀 - y_lim: 纵坐标范围(μm),如果为None则自动计算 """ EX_um = [ex * 1e3 for ex in EX_mm]
fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(PX, EX_um, 'r-', linewidth=2, alpha=0.7) ax.scatter(PX, EX_um, c='r', s=20, alpha=0.7)
ax.axhline(y=0, color='k', linestyle='--', linewidth=1, alpha=0.5) ax.axvline(x=0, color='k', linestyle='--', linewidth=1, alpha=0.5)
ax.set_xlabel('归一化光瞳坐标 PX') ax.set_ylabel('像差 (μm)') if title_suffix: ax.set_title(f'弧矢光扇图 {title_suffix}') else: ax.set_title('弧矢光扇图') ax.grid(True, alpha=0.3) ax.set_xlim(-1.1, 1.1) if y_lim is not None: ax.set_ylim(-y_lim, y_lim) else: # 自动 y 轴范围 y_min = min(EX_um) y_max = max(EX_um) y_pad = (y_max - y_min) * 0.1 if y_max != y_min else 0.1 ax.set_ylim(y_min - y_pad, y_max + y_pad) plt.tight_layout() plt.show() |
(3)合并光扇图:plot_both_fans
def plot_both_fans(PX: List[float], EX_mm: List[float], PY: List[float], EY_mm: List[float], title_suffix: str = ""): """ 绘制合并光扇图 - PX, EX_mm: 弧矢光扇数据(mm) - PY, EY_mm: 子午光扇数据(mm) - title_suffix: 标题后缀 """ EX_um = [ex * 1e3 for ex in EX_mm] EY_um = [ey * 1e3 for ey in EY_mm]
fig, ax = plt.subplots(figsize=(8, 5)) ax.plot(PY, EY_um, 'b-', linewidth=2, alpha=0.7, label='子午光扇') ax.scatter(PY, EY_um, c='b', s=20, alpha=0.7) ax.plot(PX, EX_um, 'r-', linewidth=2, alpha=0.7, label='弧矢光扇') ax.scatter(PX, EX_um, c='r', s=20, alpha=0.7)
ax.axhline(y=0, color='k', linestyle='--', linewidth=1, alpha=0.5) ax.axvline(x=0, color='k', linestyle='--', linewidth=1, alpha=0.5)
ax.set_xlabel('归一化光瞳坐标') ax.set_ylabel('像差 (μm)') if title_suffix: ax.set_title(f'合并光扇图 {title_suffix}') else: ax.set_title('合并光扇图') ax.grid(True, alpha=0.3) ax.set_xlim(-1.1, 1.1) ax.legend()
all_data = EX_um + EY_um y_min = min(all_data) y_max = max(all_data) y_pad = (y_max - y_min) * 0.1 if y_max != y_min else 0.1 ax.set_ylim(y_min - y_pad, y_max + y_pad)
plt.tight_layout() plt.show() |
七、验证与对比
7.1 单透镜(单视场)
本项目提供examples模块中,可以实现100mm焦距、0.5876um单波长、单视场、BK7对称单透镜的仿真验证,涵盖从参数输入、光线生成、追迹到分析、可视化的全流程,仿真结果与Zemax交叉验证。
光路图对比:


点列图:


光扇图:



7.2单透镜(多视场)
本项目提供examples模块中,可以实现100mm焦距、0.5876um单波长、多视场、BK7对称单透镜的仿真验证,涵盖从参数输入、光线生成、追迹到分析、可视化的全流程,仿真结果与Zemax交叉验证。
光路图对比:


点列图:






光扇图:









八、项目扩展方向
本项目的模块化架构和预留接口让其具备极强的可扩展性,可基于现有模块快速扩展至更复杂的光学仿真场景,核心扩展方向包括:
色散模拟:修改Material类的n()方法,实现Sellmeier方程的折射率计算,支持复色光(红/绿/蓝)的追迹和色差分析;
厚透镜/双胶合透镜仿真:扩展LensSystem类,支持多表面、多玻璃的透镜系统,实现双胶合透镜的消色差仿真;
高级像差分析:扩展analysis模块,加入球差、彗差、像散、场曲、畸变的定量计算,实现单色像差的完整分析;
三维可视化:基于mpl_toolkits.mplot3d实现3D光路图和3D点列图,更直观地体现光线的3D传播规律;
交互化界面:基于Streamlit/PyQt实现可视化交互界面,支持用户实时调整透镜参数,实时查看仿真结果;
镜头优化:结合遗传算法/梯度下降算法,实现基于RMS光斑尺寸的透镜参数自动优化,满足工程化的设计需求。