简介:面向深度学习与光学交叉方向学习者,这份压缩包提供了一个完整的波前重建系统实现,适用于期末大作业、毕业设计或科研入门。系统以卷积神经网络和残差U-Net为核心,利用仿真数据训练模型,实现从波前传感器数据到畸变波前的准确重建,弥补传统Zernike拟合等方法依赖先验知识、对噪声敏感的不足。压缩包共46个文件,大小1.83MB,包含20个MATLAB脚本(负责波前模拟、Zernike系数计算与梯度差分等)、7个Python脚本(覆盖模型定义、训练、测试与结果输出),另有6张结果示意图、模型缓存及相关配置文件,目录结构清晰。资源已吸引41人学习,说明其具备一定参考价值。通过SH_simulation与SH_resUNet_demo两大模块,使用者可复现从模拟波前生成、数据集构建到残差U-Net训练评估的完整链路,并在此基础上改进网络结构或扩展真实数据实验,是一份兼具教学与工程意义的实战资源。
1. 为什么拿到「基于深度学习的波前重建系统.zip」别急着跑,先读这三处
波前重建在自适应光学、定量相位成像和显微成像里是绕不开的一步。传统 Shack–Hartmann 处理链路要把光斑质心偏移换算成子孔径斜率,再解一个规模不小的线性方程组,低信噪比时矩阵条件数很差,重建结果容易振铃。基于深度学习的波前重建系统,把这条链路压缩成一个端到端网络:输入光斑图像或质心偏移向量,输出 Zernike 系数,甚至直接输出二维相位图。标题里带.zip,说明它是以项目包形式分发的,通常含训练脚本、仿真数据生成器、权重文件和运行说明。适合有光学或成像背景的工程师与研究生,想用 PyTorch 快速跑通一个可复现的端到端方案,不想从头推导矩阵重建那套数学。
2. 从波前传感器到网络输出:先定三条路线,再谈损失函数
深度学习改造波前重建的前提,是把物理模型拆清楚。常见的入口是 Shack–Hartmann 波前传感器,有时也会直接面对哈特曼光斑图、全息强度图或相位恢复问题。我一般不是一上来就写网络,而是先回答一个问题:网络到底要输出什么,才能接上后续的波前校正或定量分析。
2.1 Shack–Hartmann 传感器的传统重建为什么依赖高信噪比
在 SH 传感器里,微透镜阵列把入射波前切成若干子孔径,每个子孔径内的光斑在探测器上形成一个质心。波前存在畸变时,光斑质心会相对理想平面波位置偏移。若相位图为 φ,子孔径焦距为 f,照明波长为 λ,那么 x 方向的质心位移近似为:
Δx = f·(λ / 2π)·(∂φ/∂x)
再除以像素尺寸,就得到像素单位的偏移量。传统重建的第一步是质心提取,第二步是把所有子孔径斜率装进一个向量 g,然后用模式法解系数:
a = (D^T D + αI)^(-1) D^T g
其中 D 是响应矩阵,每一列是某个 Zernike 模式在子孔径位置上的斜率分布,α 是 Tikhonov 正则化系数。这个流程在亮场、高信噪比下很稳,但当光斑被噪声淹没、子孔径处有坏点、或者湍流很强导致光斑超出子孔径范围时,质心提取出现野值,后面矩阵求逆就会把误差放大。深度学习在这种场景下的价值,不是替代质心提取,而是把「光斑图直接映射到系数」这个高维非线性关系学出来,并且在仿真数据里把噪声分布也囊括进去。
2.2 深度模型到底替换了哪一段:三种常见切法
我把基于深度学习的波前重建系统按输入输出切成三种路线。
第一种:从整幅光斑图回归 Zernike 系数。输入是 SH 传感器拍到的光斑阵列图,输出是前 10~20 阶 Zernike 系数。这种方式替代了「质心提取 + 斜率重建 + 模式拟合」的整条链路,鲁棒性最强,但网络必须自己学会定位光斑,训练数据量需求也最大。
第二种:从质心偏移向量回归 Zernike 系数。先把光斑质心提取出来,得到一个 (2×N) 的向量,再用 MLP 或小型 CNN 回归系数。这种方式只替代后端模式重构,模型小、训练快,适合要部署到嵌入式设备的场景。缺点是前置质心提取出错,后面网络再强也救不回来。
第三种:从单帧强度图重建二维相位图。这通常对应的是定量相位成像或傅里叶叠层成像里的相位恢复问题,网络输出一个与输入同尺寸的相位图。这个方向更接近图像翻译任务,常用 UNet 结构。
我自己的选型建议是:如果手里有稳定的 SH 硬件,优先做第一种端到端,因为它对质心野值天然不敏感;如果是纯仿真验证和快速发论文,第二种就够用;如果目标是显微成像里的定量相位恢复,第三种才是主赛道。
2.3 输出 Zernike 系数还是二维相位图,决定项目复杂度
Zernike 系数输出的优点是维度低、物理意义明确,且直接能当自适应光学闭环的控制信号。缺点是把波前限定在有限阶模态里,存在截断误差。二维相位图输出能保留高空间频率细节,但逐像素重建的维度很高,训练时需要更强的正则化,而且孔径外的无效区域会白白增加模型负担。
实际工程里我常做双分支:主干网络共享特征,一个分支回归低阶 Zernike 系数,另一个分支用轻量解码器输出残余相位图。这样闭环控制用系数分支,定量分析用相位图分支,两不误。不过刚上手时别做这么复杂,先从单分支回归 Zernike 系数开始,把数据管线和训练流跑通,再考虑加输出头。
3. 造数据:从 Zernike 相位仿真到压缩成可复现的数据集 zip
深度学习的波前重建系统最划算的地方,就是训练数据可以用物理仿真无限生成,不需要真实硬件拍成千上万张光斑图。但仿真代码必须和实验配置对齐,否则模型在实验室数据上会直接翻车。这一章我把仿真到打包的过程拆开写。
3.1 用 Zernike 多项式生成波前相位图
第一步是生成带标签的波前相位。常用做法是随机采样一组 Zernike 系数,然后合成相位图。需要注意 Noll 索引顺序,不同论文里 Zernike 排列顺序不一样,如果从网上东拼西凑代码,很容易在顺序上踩坑。下面的 Python 代码生成低阶 Zernike 模式的二维图,并把系数加权合成波前相位:
import numpy as np from math import factorial # 前 15 阶 Noll 序号对应的 (n, m),顺序是固定的 NOLL_TABLE = [ (0, 0), (1, 1), (1, -1), (2, 0), (2, -2), (2, 2), (3, -1), (3, 1), (3, -3), (3, 3), (4, 0), (4, 2), (4, -2), (4, 4), (4, -4), ] def zernike_map(n, m, size=128, pupil_radius=0.95): """生成单个 Zernike 模式,返回 (size, size) 的归一化相位图""" yy, xx = np.mgrid[-1:1:size*1j, -1:1:size*1j] r = np.sqrt(xx**2 + yy**2) theta = np.arctan2(yy, xx) mask = r <= pupil_radius r_safe = np.where(mask, r, 0.0) theta_safe = np.where(mask, theta, 0.0) R = np.zeros_like(r) m_abs = abs(m) for k in range((n - m_abs) // 2 + 1): c = (-1)**k * (factorial(n - k) / (factorial(k) * factorial((n + m_abs)//2 - k) * factorial((n - m_abs)//2 - k))) R += c * r_safe ** (n - 2*k) if m == 0: z = np.sqrt(n + 1) * R elif m > 0: z = np.sqrt(2 * (n + 1)) * R * np.cos(m_abs * theta_safe) else: z = np.sqrt(2 * (n + 1)) * R * np.sin(m_abs * theta_safe) return z * mask def compose_phase(coeffs, size=128): """把前 15 阶系数合成相位图,单位是弧度""" phase = np.zeros((size, size)) for j, a in enumerate(coeffs, start=1): n, m = NOLL_TABLE[j - 1] phase += a * zernike_map(n, m, size=size) return phase这段代码有两点要注意。第一,pupil_radius=0.95不是 1.0,是为了避免圆形孔径边界正好落在像素边缘时产生半个像素的归属争议;第二,theta 的零点方向是 x 轴,如果后续和光斑仿真或实验装置的对轴方向不一致,重建出的像散方向会差 90 度。我习惯在生成每组数据后,顺手把合成相位图存一份 PNG 预览,用眼睛确认模式方向没问题再大规模生成。
3.2 根据相位图模拟 SH 光斑图
有了相位图,下一步是模拟 Shack–Hartmann 光斑图。这一步的核心是把局部相位梯度换算成光斑位移。下面的代码用两层循环遍历子孔径,把每个光斑画成高斯型亮斑,并加上可配置的噪声:
def simulate_spot_image(phase, subap_pitch=16, focal_len=20e-3, pixel_size=5e-6, wavelength=0.85e-6, snr=10): """ 由相位图生成一张模拟 SH 光斑图。 phase: 弧度制相位图; subap_pitch: 子孔径间距(像素); focal_len: 微透镜焦距(m); pixel_size: 探测器像元(m); wavelength: 波长(m); snr: 信噪比(倍数)。 """ h, w = phase.shape img = np.zeros((h, w), dtype=np.float32) # 子孔径中心按网格排布,但要避开边界 centers = range(subap_pitch // 2, h - subap_pitch // 2, subap_pitch) gy, gx = np.mgrid[-1:1:subap_pitch*1j, -1:1:subap_pitch*1j] for cy in centers: for cx in centers: # 取子孔径内的相位,用中心差分求平均斜率 sub = phase[cy - subap_pitch//2 : cy + subap_pitch//2, cx - subap_pitch//2 : cx + subap_pitch//2] dy, dx = np.gradient(sub) slope_x = dx.mean() slope_y = dy.mean() # 位移公式:dx_px = f * (lambda / 2pi) * slope / pixel_size dx_px = focal_len * wavelength / (2 * np.pi) * slope_x / pixel_size dy_px = focal_len * wavelength / (2 * np.pi) * slope_y / pixel_size # 高亮斑本身大约 3 像素,放在子孔径中心偏移后的位置 sy = cy + int(round(dy_px)) sx = cx + int(round(dx_px)) if 0 <= sy < h and 0 <= sx < w: img[sy-1:sy+2, sx-1:sx+2] += \ np.exp(-((gx)**2 + (gy)**2) / (2 * 1.0**2)) # 加泊松噪声的近似:以 snr 控制信号与噪声的比例 img = img / img.max() noise = np.random.poisson(img * snr) / snr return np.clip(noise, 0, 1).astype(np.float32)位移公式里最容易出错的地方,是slope_x的单位。np.gradient在网格间距为 1 时返回的是“每像素弧度”,而公式里的斜率应该是“每米弧度”,所以严格说还要除以实际采样间距。如果你在实验里光斑图是真实相机拍到的,这条仿真代码的标定就需要用实测标定数据校正一次。我的习惯是仿真阶段先以“相对像素位移”为准,把常数全部约掉,等部署到实验台再标定绝对尺度。
3.3 数据打包:把训练集、验证集和配置一起 zip 封存
数据集不是一堆.npy堆在文件夹里就完事,必须带上能复现的配置。常见目录结构是这样:
wavefront_dataset/ ├── train/ │ ├── 000000_spot.npy │ ├── 000000_phase.npy │ └── 000000_zernike.npy ├── val/ ├── test/ ├── config.yaml # 波长、焦距、像素、Zernike 阶数 ├── split.json # 数据划分索引 └── sha256sum.txt把目录压成 zip 时,我一般用不带换行的绝对路径,并在打包后立刻做完整性校验:
# 打包当前实验目录,排除日志和缓存 zip -r wavefront_dataset.zip train val test config.yaml split.json \ -x "*.log" -x "__pycache__/*" # 查看包内前若干条路径,确认没有把无关文件打进去 unzip -l wavefront_dataset.zip | head -n 30 # 做完整性测试并生成校验值 unzip -t wavefront_dataset.zip sha256sum wavefront_dataset.zip > sha256sum.txt这一套下来,压缩包解压后就能直接复现训练。有一点血泪经验:数据包里的config.yaml比代码还重要。很多 zip 项目过几个月再打开,代码能跑但谁都不记得当时用的是 532 nm 还是 850 nm 激光,仿真模型跟实验台对不上,模型自然失效。
4. PyTorch 实现:一个能用的 CNN 波前重建模型有多简单
很多人以为波前重建的深度学习模型要很复杂,实际上一个小型 CNN 就够用。因为 SH 光斑图具有很强的规律性:光斑位置本身就是局域信息,卷积网络天然适合提取。真正决定效果的往往不是网络深度,而是输入归一化、输出编码和损失函数。
4.1 输入光斑图、输出 Zernike 系数的轻量 CNN
我一般从 15 阶 Zernike 系数回归起步。输入是单通道 128×128 光斑图,输出是长度为 15 的向量。网络结构用三层卷积加一个全连接回归头:
import torch import torch.nn as nn class SpotZernikeNet(nn.Module): def __init__(self, n_zernike=15, dropout=0.3): super().__init__() self.backbone = nn.Sequential( nn.Conv2d(1, 16, 5, padding=2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(16, 32, 5, padding=2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.AdaptiveAvgPool2d(6), ) self.regressor = nn.Sequential( nn.Flatten(), nn.Linear(64 * 6 * 6, 128), nn.ReLU(), nn.Dropout(dropout), nn.Linear(128, n_zernike), ) def forward(self, x): return self.regressor(self.backbone(x))这里的关键在AdaptiveAvgPool2d(6)。它把任意输入尺寸的特征图统一池化到 6×6,模型就能接受 64×64 或 128×128 的输入,不用改结构。第二个关键点是回归头不要加 Tanh 之类的激活函数,Zernike 系数的取值范围不稳定,末尾直接线性输出,让网络自己学幅度。
4.2 损失函数必须考虑系数量级差异
如果直接对全部 15 个系数的预测值和真值做均方误差,低阶像差(离焦、像散)的量级经常比高阶像差大几倍,模型会优先拟合低阶项,高阶项被忽略。常见做法是用训练集统计每个系数的标准差,把误差归一化后再求平均:
import numpy as np import torch # 训练集中所有样本的 Zernike 系数,shape = (N, 15) coeff_sigma = np.std(training_coeffs, axis=0) coeff_sigma[coeff_sigma < 1e-6] = 1.0 coeff_sigma_t = torch.tensor(coeff_sigma, dtype=torch.float32) def zernike_loss(pred, target): # 逐系数归一化的均方误差 return ((pred - target) ** 2 / coeff_sigma_t).mean()这种损失函数直观且稳定。但注意它有一个坑:如果某个高阶系数在训练集里几乎不变,标准差接近 0,强行归一化会让该系数主导梯度。所以我设置了下限 1e-6。另一个做法是做系数标准化,也就是把每个样本的系数减均值除标准差再回归,效果等同,但推理时要记得反标准化。
4.3 训练参数:学习率、批量、验证策略的常规取值
这套网络参数量不大,用 GPU 训练几十个 epoch 就能收敛。我常用的配置是:Adam 优化器,初始学习率 1e-3 到 3e-4,批量大小 64,配合余弦退火或 StepLR 在 30 个 epoch 后降学习率。
model = SpotZernikeNet(n_zernike=15) optimizer = torch.optim.Adam(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) for epoch in range(50): model.train() for spots, coeffs in train_loader: pred = model(spots) loss = zernike_loss(pred, coeffs) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 每个 epoch 结束后在验证集上算一次相位图残差注意验证集千万别按随机划分了事。波前重建的难点是泛化到更强的像差,如果训练和验证都落在同一个湍流强度范围,模型可能只是记住了振幅分布。我会按湍流强度分层采样:低强度样本放训练,中高强度放验证,更极端的放测试,这样指标才有参考价值。
5. 避坑记录:复现深度学习波前重建系统最常见的 5 个坑
这一章是纯踩坑汇总。以下每一条都是我在给光学项目组排查模型失效时反复遇到的,按「现象 → 原因 → 解决」写清楚。
5.1 质心坐标单位不统一,预测结果系统性偏差
现象:训练和验证 loss 都正常,但把模型放到另一次仿真数据上,重建出的像散方向完全不对,幅值整体偏小。
原因:仿真光斑图用的是相对像素位移,但训练标签里的 Zernike 系数是按弧度制相位生成的。位移公式里焦距、像素尺寸、波长的单位换算出错,导致模型输入分布与标签分布不一致。
解决:先把位移计算收敛到“像素差异”这种纯几何量,所有常数约掉,代码里不出现混合单位。具体做法是让位移公式里的focal_len * wavelength / (2 * np.pi * pixel_size)保持为一个标定常数,单独写在 config 里,不要散落在各处。
5.2 孔径掩膜中心错半格,低阶像差被污染
现象:模型单独预测离焦系数时还算准,但合成的相位图带明显的一阶像散条纹,肉眼可见不对称。
原因:np.mgrid[-1:1:128j]产生的像素网格关于 0 对称,但离散像素计数时孔径掩膜的中心在 (63.5, 63.5),不是 (63, 63)。这个半像素偏移在 Zernike 多项式里等效于引入一个小量 tilt,训练时会被模型吸收成系统偏差。
解决:生成所有 Zernike 模式时,先用np.roll或重新构造网格把孔径中心对齐到整数像素坐标size // 2。更稳妥的办法是直接用像素坐标做网格,例如x = (np.arange(size) - (size - 1) / 2) / (size / 2),保证中心在零点。
5.3 用相位图 RMSE 训练,却用 Strehl 比做验收
现象:训练时损失一直接近收敛,但逐渐逼近相位图整体偏移,导致评价指标不佳。
原因:相位图在很多优化问题里存在全局常数项和 tilt 自由度,MSE 无法分辨无物理意义的整体偏移。如果模型输出的相位图和真值差一个常数,MSE 不小,但 Strehl 这类指标几乎不受影响;反过来,模型拟合了伪影但 Strehl 下降,用 MSE 看却没什么变化。
解决:训练损失里显式去掉 piston、tip、tilt 三个分量再算 MSE,或者验证时把评价指标和训练目标统一起来。我自己的默认做法是:训练用 Zernike 系数回归,评价用相位图残差 RMSE 和 Strehl 同时报,两套指标放一张表里看。
5.4 输入归一化参数没有随项目打包
现象:模型在仿真验证集上跑得很好,一接到实验台相机数据,输出直接爆掉,预测系数比正常值大两个量级。
原因:训练时对输入光斑图做了(x - mean) / std的归一化,但没把这两组统计量存进权重包。测试数据范围完全不同,仍按训练时的 mean/std 处理,输入分布失调。
解决:把归一化参数写进模型配置文件,或者干脆在模型内部用一个输入预处理层持有均值和方差。这样导出 TorchScript 或 ONNX 时,归一化逻辑跟着模型走,部署端不需要再手动处理。
5.5 zip 解压后路径过长或文件名被改动
现象:Windows 下解压后,数据加载脚本报目录不存在,检查发现系统把一部分文件路径截断了。
原因:项目 zip 内部目录层级很深,加上训练数据文件名带时间戳,总路径长度超过 Windows 默认 260 字符限制;另外某些解压工具会自作主张展开嵌套目录,把文件挪到别处。
解决:解压前先看unzip -l列出的最深层路径,判断是否接近 260 字符。Windows 上解压到一个短路径根目录,比如C:\wf\,而不是放在桌面层层嵌套。Linux 上解压后先清理__MACOSX等系统残留,再跑数据脚本。
6. 最后一关:把模型从 zip 里验出来、导出去
网络训练完不是终点,真正的考验是“盲测和部署”这两件事。我见过太多项目在训练集里指标漂亮,换一套湍流仿真就现原形。所以我的习惯是:所有验证都在独立的盲测集上做。
6.1 盲测不只看 loss,要看相位残差
重新生成一批训练流程中没见过的湍流样本,湍流强度跨度加大,Zernike 系数取值范围比训练集宽 30%。模型推理得到系数后,合成预测相位图,和真值相位图做差分:
pred_phase = compose_phase(pred_coeffs[i].cpu().numpy(), size=128) true_phase = compose_phase(target_coeffs[i].cpu().numpy(), size=128) residual = pred_phase - true_phase residual = residual * pupil_mask # 只统计孔径内 rmse = np.sqrt(np.mean(residual**2))如果盲测 RMSE 比验证集高一半以上,说明模型过拟合了训练参数分布,不要直接投向生产环境。
6.2 导出 TorchScript 或 ONNX,让部署端不再依赖训练代码
训练完的模型必须能脱离训练框架运行,否则实验台的采控程序很难调用。PyTorch 导出有两种常用方式:TorchScript 适合 PyTorch 星系内的部署,ONNX 适合跨语言调用。导出前记得把归一化层一起带上:
scripted = torch.jit.script(model.cpu()) scripted.save("wavefront_recon.pt")导出 ONNX 时固定输入尺寸为 1×1×128×128:
dummy = torch.randn(1, 1, 128, 128) torch.onnx.export(model.cpu(), dummy, "wavefront_recon.onnx", opset_version=17, input_names=["spot"], output_names=["zernike"])6.3 一份最有效的验证检查表
我做这类项目时,最后收尾流程固定是五步:先看包内 config 是否与实验参数一致;再在原始光斑图预览上确认子孔径网格和孔径掩膜;然后跑盲测集,看相位残差云图而不是只看数字;接着在 ONNX Runtime 或 TorchScript 上做一次与 PyTorch 前向结果的数值一致性比对;最后把训练随机种子、归一化统计量和模型 md5 一起写进备注文件封存进 zip。
我第一次复现别人的波前重建 zip 项目时,在坐标单位上翻了车,整整排查了两天。后来养成一个习惯,凡是拿到手的项目包,第一件事先创建一份config/unit.yaml,把波长、像素尺寸、子孔径间距、微透镜焦距、Zernike 阶数全部抄进去,再开始动网络。这个习惯帮我省下的排错时间,远比写网络的时间更多。希望上面这些坑点和检查流程,能帮你在自己的波前重建系统上少走几步弯路。
本文还有配套的精品资源,点击获取