光学设计仿真这件事,过去十几年的主流做法是在图形界面里点鼠标:改一个曲率半径,看光斑缩了没有,再改一个厚度,看后焦对不对得上。这种“手眼循环”做三五次还行,做三百次就是折磨。Rayoptics是一个用纯 Python 写的序列光学系统建模与追迹库,它把一套镜头拆成“表面列表 + 间隙列表”这两条最朴素的数据结构,把近轴一阶量计算、实际光线追迹、评价函数优化、镜头文件读写这些事情全部暴露成函数和对象。换句话说,你可以在脚本里把一个镜头搭起来、追迹几千条光线、算出焦距和球差、挂上优化器跑一轮,全程不打开任何窗口,结果直接落成数组塞进你自己的流程里。
这篇内容适合两类读者:一类是有光学背景、天天在设计软件里做重复劳动的工程师,想把这些动作自动化;另一类是有扎实编程基础、但没系统做过光学的开发者,想搞清楚“追迹光线”在代码层面到底是一堆什么运算。我会从选型思路讲到模型结构,再走一遍单片镜头的完整实操,最后给双片消色差镜头的优化实录和一整套排查手册。所有参数都是可以拿计算器复算的,凡是“我这边实测”的地方我也会标出来,方便你对照。
1. 先想清楚定位:为什么把仿真搬到脚本里
1.1 光学设计里真正耗时的环节不是“设计”而是“重复”
做过镜头的人都有体会,真正的设计灵感往往在头两个小时就定了,剩下的时间全花在重复动作上:把某个面的曲率从 42.3 扫到 45.1,每变一次就看一眼点列图;或者把镜片厚度按 0.1mm 步长扫一遍,统计后焦变化;再或者做几十组装调误差的蒙特卡洛,每组都手动记录 RMS 光斑。这些动作有个共同特征——判断标准是明确的,操作流程是机械的,但界面只提供“一次改一个参数”的交互方式。
脚本化能带来三个直接收益。第一是参数空间可穷举,一个三元参数网格,界面里点一天,代码里跑两分钟。第二是结果可留存,每次仿真的输入输出都能落成 CSV,三个月后回查“当时那个版本为什么不用”不会两手空空。第三是可与外部逻辑耦合,比如你想让优化目标不只看光斑,还要兼顾加工成本、镜片数量、装配公差敏感度,这些东西界面软件很难表达,但在 Python 里就是几行加权求和。Rayoptics 恰好把这三件事的基础件都提供了,且是纯 Python,不需要编译、不需要许可证、不会在服务器上装不上。
1.2 三条技术路线的取舍
在做自动化之前我评估过三条路,这里把结论摊开讲,方便你按自己的场景选。
| 路线 | 上手成本 | 模型完整度 | 二次开发自由度 | 适用场景 |
|---|---|---|---|---|
| 纯 Python 开源库(Rayoptics 这类) | 中,需要懂一点光学概念 | 序列系统完整,含近轴、追迹、优化 | 很高,所有对象可读写 | 批量扫描、算法研究、教学、嵌入自有流程 |
| 商业软件 + 脚本接口 | 低,界面所见即所得 | 很高,含杂散光、公差、热分析 | 中,受接口能力限制 | 正式产品设计、需要交付图纸 |
| 自己写矩阵计算 | 高,要自己处理面型与折射 | 低,通常只能做近轴 | 极高 | 教学演示、单一薄透镜快速估算 |
我最终选第一条路,理由不是“开源免费”这种表面原因,而是模型的可见性。在 Rayoptics 里,一个镜头的全部信息就是ifcs(接口/表面列表)和gaps(间隙列表),你可以直接遍历、直接改、直接序列化。而商业软件的脚本接口通常是“命令 + 属性名”的黑盒,改一个面的曲率要记住一串路径字符串,一旦版本升级就可能失效。做批量计算时,这种脆弱性会不断消耗你的时间。
代价也要说清楚:开源库不提供图纸输出、不提供完整的公差分析模块、不保证和加工厂的图纸规范对齐。所以我的实际做法是两条腿走路——脚本里做探索、扫描、算法验证,定稿之后再进商业软件做正式出图和公差。别指望一个库包打天下,那是幻想。
1.3 Rayoptics 的模型结构决定了它的玩法
理解这个库的关键,是先接受它的世界观:一个光学系统就是一串有序的表面,相邻表面之间隔着一段介质。这套抽象极其古老(可以追溯到上世纪的光线追迹程序),但正因为它简单,才容易被代码表达。
模型对象里最重要的三个成员是:seq_model(序列模型,装着表面和间隙)、optical_spec(孔径、视场、波长这套“观测条件”)、parax_model(近轴模型,负责一阶量)。你后续所有的操作,本质上都是在改这三样东西,然后调用一次更新,让派生数据重算。
提示:这个库的模块划分比函数命名稳定得多。
rayoptics.elem(元件与面型)、rayoptics.seq(序列模型)、rayoptics.parax(近轴)、rayoptics.raytr(追迹)、rayoptics.optim(优化)、rayoptics.gui(界面)这几个包名基本可以放心记。具体到某个类的方法名,不同小版本之间有漂移,遇到AttributeError时用dir(obj)探一下比翻文档快。
2. 环境搭建与第一个能跑通的镜头
2.1 装依赖这一步别图省事
这个库依赖科学计算栈(数值计算、绘图、表格处理),还依赖一个独立的玻璃数据库包。用虚拟环境隔离,别往系统 Python 里灌。
python -m venv .opt # Linux / macOS source .opt/bin/activate # Windows # .opt\Scripts\activate pip install --upgrade pip pip install rayoptics opticalglassopticalglass是配套的玻璃库,装完之后你才能用牌号字符串(比如N-BK7)去取折射率和色散系数,否则只能自己手填折射率。装完顺手验证一下:
import rayoptics from opticalglass import glassfactory as gfact glasses = gfact.get_glass_db('glasscat') print(len(glasses)) # 能打印出几百个牌号就说明库装好了如果你的环境里有多个科学计算栈版本,注意numpy的大版本跳跃,这个库对numpy的旧接口有一定依赖,遇到np.float之类的报错,通常是版本错配,锁一下版本即可。我第一次装的时候就是因为环境里同时存在两套numpy,排查了快一个小时,后来养成习惯:装完先跑一遍import和最小示例,别等写完一大段代码才发现环境是坏的。
界面也可以启起来看看,用来交叉核对脚本结果:
python -m rayoptics.gui.app_manager长得不算精致,但它能把模型结构树摊开给你看,对你理解“表面-间隙”这套抽象帮助很大。我通常的做法是:脚本跑出来的模型存成文件,用界面临时打开看一眼,确认表面数和光阑位置没搞错,再回去继续算。
2.2 用二十行代码搭一个 f=50 的单片镜
我们从最经典的例子入手:一片对称双凸透镜,材料用常见的冕牌玻璃(折射率约 1.517、阿贝数约 64,通用牌号 N-BK7),设计焦距 50mm,厚度 6mm。
先用薄透镜制造者公式手算曲率半径:
[ \frac{1}{f} = (n-1)\left(\frac{1}{R_1} - \frac{1}{R_2}\right) ]
对称双凸时 (R_2 = -R_1),代入 (f=50)、(n=1.5168):
[ \frac{1}{50} = 0.5168 \times \frac{2}{R_1} \Rightarrow R_1 = 0.5168 \times 2 \times 50 = 51.68\text{ mm} ]
所以 (R_1 = +51.68)、(R_2 = -51.68)。把表面数据排成一张表:
| 面序号 | 说明 | 曲率半径 R/mm | 到下一面的厚度/mm | 材料 | 半口径/mm |
|---|---|---|---|---|---|
| 0 | 物面 | 无穷 | 无穷 | 空气 | — |
| 1 | 透镜前表面 | +51.68 | 6.0 | N-BK7 | 6.5 |
| 2 | 透镜后表面 | −51.68 | 44.0(初值) | 空气 | 6.0 |
| 3 | 像面 | 无穷 | — | — | — |
半口径那一列是我按 F/5 估的:入瞳直径 10mm,边缘光线在透镜处高度 5mm,再留 1.5mm 装配余量,所以取 6.5mm。这个“余量”很关键,很多新手直接把通光口径设成光线高度,结果优化一动口径就挡光,追迹直接失败。
代码侧的主干逻辑是这样(方法名随版本有微调,我在注释里标了自检位置):
import numpy as np from rayoptics.optical.opticalmodel import OpticalModel from rayoptics.elem.surface import Surface from rayoptics.elem.profiles import Spherical from rayoptics.parax.firstorder import first_order_data opm = OpticalModel() # 建一个空模型 sm = opm['seq_model'] # 序列模型:表面 + 间隙 # 依次把四个面塞进去,每加一个面就配一个到下一面的间隙 # 注意:具体插入方法各版本写法略有差异,先 dir(sm) 找 insert / add 一类的入口 opm.update_model() # 改完模型必须刷一次,派生量才会重算 efl_f, bfl_f = first_order_data(opm)[:2] # 取一阶焦距和后焦距 print(f"EFL={efl_f:.4f} mm, BFL={bfl_f:.4f} mm")这段代码短,但里面有两个坑值得提前讲。第一个是update_model()——这个库是“惰性求值”的设计,你改了曲率半径、没刷模型,后面取出来的近轴量全是旧值,我第一次就因此对着一个怎么改都不变的焦距愣了半小时。第二个是间隙的定义,它挂在前一个面上,代表“从本面顶点到下一面顶点”的距离,物面的间隙是无穷远,最后一个面没有间隙。理解这一点,后面加镜片才不会错位。
2.3 一阶量校验:别拿薄透镜公式当最终答案
跑完上面那段代码你会发现,EFL 不是 50,而是51.01mm,后焦距是48.99mm。这不是库算错了,是薄透镜公式本身忽略了厚度。用手算的 (y)-(nu) 追迹可以验证:
边缘光线在面 1 的高度 (y_1 = 5),入射平行光 (u_1 = 0),面 1 的光焦度 (\phi_1 = (n-1)/R_1 = 0.5168/51.68 = 0.01):
[ u_1' = \frac{0 - 5 \times 0.01}{1.5168} = -0.032965 ]
[ y_2 = 5 + 6 \times (-0.032965) = 4.8022 ]
面 2 的光焦度 (\phi_2 = (1-1.5168)/(-51.68) = 0.01),于是:
[ u_2' = -0.05 - 4.8022 \times 0.01 = -0.098022 ]
后焦距 (BFL = -y_2/u_2' = 4.8022/0.098022 = 48.99) mm,系统光焦度 (\Phi = \phi_1 + \phi_2 - (t/n)\phi_1\phi_2 = 0.02 - 0.000396 = 0.019604),焦距 (1/\Phi = 51.01) mm。完全对得上。
| 量 | 薄透镜估算 | 近轴追迹(含厚度) | 差值 |
|---|---|---|---|
| 有效焦距 EFL | 50.00 mm | 51.01 mm | +1.01 mm |
| 后焦距 BFL | 50.00 mm | 48.99 mm | −1.01 mm |
| 后主面位置 | 顶点处 | 顶点左侧 2.02 mm | — |
这张表就是脚本化仿真最朴素的价值:它把“手工容易算错的那部分”接管了。也提醒一件事,做镜头时光焦度分配一定要用实际模型跑,别在草稿纸上用薄透镜公式分配完就直接定结构,6mm 的厚度就能带来 2% 的焦距偏差,十个面叠起来偏差会很可观。
2.4 实际光线追迹与第一次看光斑
近轴量只是骨架,真正判断成像质量要看实际光线。设定 F/5 和轴上视场,追迹边缘光线和一条中间带光线,再看它们的交点和近轴焦点差多少,这个差值就是球差。代码骨架大致是:
from rayoptics.raytr.trace import trace_base # 模块路径稳定,函数名以本地为准 # 归一化视场 (0,0) 表示轴上,光瞳坐标 (0,0)~(1,0) 是从中心到边缘 # 实际调用时把波长索引和视场索引一并传入 ray = trace_base(opm, pupil=(0.0, 0.0), fld=(0.0, 0.0), wvl=0)我实测这一片镜头的结论很明确:轴上弥散斑半径在几十微米量级,而同样 F 数下的艾里斑半径是
[ r_{\text{Airy}} = 1.22 \lambda F/{#} = 1.22 \times 0.5876\mu m \times 5.1 \approx 3.7 \mu m ]
差了整整一个数量级。这意味着这片透镜的像质完全由几何像差主宰,谈不上任何“接近衍射极限”。很多新手第一次看光斑图会觉得“挺圆的啊”,圆不圆和好不好是两件事,必须和艾里斑量级对比才有意义。这条判断标准我会在后面反复用到,它比任何肉眼观察都可靠。
顺带说一句色差。单片镜的轴向色差可以用 (f/V) 粗估,(V \approx 64),(f \approx 51),得到约0.8mm。也就是说从红到蓝,焦点位置相差接近 0.8 毫米。这就是为什么后面要做双片胶合——不是追求完美,而是色差这一项太大,不做掉它别的优化都是白搭。
3. 拆开模型看结构:表面、间隙、孔径三件套
3.1 为什么是“表面列表 + 间隙列表”而不是对象嵌套
很多第一次接触这类库的人会疑惑:为什么不设计成“透镜对象里包含前后表面”这种直观的树形结构,非要拉平成两条平行列表?我最初也不适应,用了一段时间才明白这是序列光学的本质决定的。
序列系统的核心假设是:光线按照固定顺序依次穿过每一个面,不回头、不串门。既然是严格的顺序,那么“第 i 个面”和“第 i 个面到第 i+1 个面的距离”就是天然的一一对应关系,用两条同长度的列表表达最省事,也最好做向量化计算——追迹一批光线时,你只需要按索引循环表面,每个面上做一次统一的折射运算,整个流程可以写成很紧凑的循环甚至批量矩阵运算。
这个设计的另一个好处是插入和删除极其方便。你想在第三面后面加一个镜片,就是在第 4 个位置插入两个 surface 和两个 gap,索引自动后移,所有依赖索引的逻辑(光阑位置、评价函数引用)也会跟着平移。如果换成嵌套对象,你得先找到父节点、再插子节点、再修正兄弟关系,麻烦得多。
我用这套结构做了一个临时检查脚本,遍历sm.ifcs打印每个面的曲率、口径、材料,再遍历sm.gaps打印厚度,两个列表对齐着一看,模型对不对立马清楚。这个“十行自检脚本”后来成了我的标准动作,每搭完一个镜头就跑一次,比在界面里数面数靠谱。
注意:头的第一个面通常是物面(曲率为无穷、间隙为物距),最后一个面是像面。做优化时不要把像面的位置当变量随便放,它是结果不是输入——但“最后一面到像面的间隙”也就是后工作距,是可以作为约束目标的。
3.2 面型参数:曲率半径、圆锥常数和非球面系数
面型对象决定了光线打到这个面上怎么偏折。最常用的是球面,它只用一个参数:曲率(c = 1/R)。注意这里存的是曲率不是半径,无穷远平面就是 (c=0)。符号约定是光线从左向右传播,曲率中心在顶点右侧为正,所以双凸镜前表面正、后表面负。
再往上一层是二次曲面,多一个圆锥常数(k):
| (k) 值 | 面型 | 典型用途 |
|---|---|---|
| (k = 0) | 球面 | 绝大多数常规镜片 |
| (-1 < k < 0) | 椭球面 | 需要减小球差的场合 |
| (k = -1) | 抛物面 | 无穷远成像无球差,准直镜常用 |
| (k < -1) | 双曲面 | 大相对孔径聚光、显微物镜 |
| (k > 0) | 扁椭球面 | 特殊整形需求 |
非球面则是在二次曲面的基础上叠加径向多项式项,项数越多自由度越大,但加工和检测成本也越高。我在实际项目里的经验是:能用球面解决就不要上非球面。一个偶次非球面能把球差压到接近零,代价是单件成本翻几倍、面形误差敏感度大幅上升、装配偏心容差变紧。真要上,通常也只上在最关键的那一片上,而不是每个面都上。
这里有个容易踩的坑:非球面系数的归一化方式各家不一样,有的用顶点曲率半径做归一化,有的用口径做归一化,混用会导致你把一组设计好的系数搬到另一个工具里跑出来完全不对。我的做法是永远在当前工具里做参数扫描验证,不跨工具直接搬系数。
3.3 孔径、视场、波长:三件套一起才算“观测条件”
同样一个镜头,在 F/2 和 F/8 下评价完全不同,在 0° 视场和 20° 视场下也不同,在可见光和近红外下更是两回事。所以模型里的“观测条件”必须显式定义清楚,这三样东西就是孔径、视场、波长。
孔径通常用入瞳直径或者像方 F 数来定义。二者关系是 (D = f/F#)。用 F 数定义的好处是和焦距绑定,焦距优化时 F 数自动保持;用入瞳直径定义的好处是物理意义明确,边缘光线高度直接可算。我一般用 F 数定义,因为设计指标通常写的是“F/2.8、焦距 35mm”。
视场定义方式有三种:角度、物高、近轴像高。对无穷远物(比如望远、摄影镜头)用角度;对有限距离物(比如显微、投影)用物高。转换关系是 (y = f \tan\theta)(角度视场)或者 (y = f \cdot \theta) 的近似。这里要注意别把半视场和全视场搞混,全视场 40° 的话半视场就是 20°,代码里通常填的是半视场。
波长至少要给三个:一个中心波长、一个短波、一个长波,用来算色差。默认权重我一般给中心波长 1.0、两个边缘 0.5,如果做的是特定波段的系统(比如只看某个窄带),权重再按实际调整。
3.4 材料:玻璃库和色散模型
玻璃这件事,索引库里存的不只是一个折射率数值,而是一整套色散曲线参数(通常用几个特定波长的折射率来定义,比如 d 线、F 线、C 线)。阿贝数就是由这几条线的折射率算出来的:
[ V_d = \frac{n_d - 1}{n_F - n_C} ]
阿贝数越大,色散越小。常见冕牌玻璃阿贝数在 60 以上,火石玻璃在 30 到 40 之间。消色差的基本思路就是用一片低色散的正镜配一片高色散的负镜,让两者的色散贡献相互抵消。
取材料的方式大致是这样:
from opticalglass import glassfactory as gfact # 按牌号和厂商目录取玻璃,返回的对象带色散参数 bk7 = gfact.create_glass('N-BK7', 'glasscat') # 光在不同波长下的折射率可以直接问它要 n_d = bk7.n # 中心波长折射率材料这一环最容易出的问题是牌号对不上。市面上同一种玻璃,不同厂商的牌号前缀不同,折射率在小数点后第四位可能有差异。做高精度系统时这点差异不能忽略,我的做法是:设计阶段用通用牌号,定稿前用供货商提供的实测折射率数据替换,再跑一遍确认性能没有明显偏移。
4. 优化:把“设计意图”翻译成评价函数
4.1 评价函数的结构决定优化能不能收敛
优化器本身不聪明,它只会做一件事:让评价函数的数值变小。所以能不能得到有用的结果,八成取决于你怎么写评价函数。我一般把它拆成三类项:
第一类是性能项,比如各视场各孔径的弥散斑半径、波前误差。这些项直接反映成像质量,权重最大。
第二类是一阶量约束项,比如焦距必须等于 50mm、后工作距必须大于 40mm 以留出机械空间、放大率必须等于 −1。这类项本质上是等式或不等式约束,权重给得要够硬,否则优化器会把焦距优化跑偏去换像质。
第三类是加工性约束项,比如中心厚度不小于 2mm、边缘厚度不小于 1mm、曲率半径不小于某个值(太小加工不了)、面间距不小于 0.5mm。这类项如果不加,优化器会给你一堆数学上最优、实际上做不出来的解——薄如纸片的镜片、几乎干涉的间距,我都见过。
4.2 变量、上下限与阻尼最小二乘
优化变量通常选曲率半径和厚度,非球面系统再放开非球面系数。变量的上下限非常关键,它把优化限制在物理可行域内。我的经验是:上下限别给太宽,也别给太窄。太宽的话优化器会跑到一个完全不同的解空间分支