
简介基于深度学习和Shack-Hartmann波前传感器的波前重建Matlab仿真项目面向计算机、电子信息工程、数学等专业的学生与科研人员服务于课程设计、期末大作业和毕业设计解决波前相位估计、畸变校正与重建仿真的核心问题。压缩包共46个文件包含20个.m仿真脚本、7个.py辅助脚本、5个.pyc编译文件、6张结果图片以及MAT数据、XML配置和Markdown说明等总大小仅1.84MB结构紧凑便于快速部署。代码采用参数化编程关键参数可灵活修改注释详细清楚并附赠可直接运行的案例数据用户只需替换输入数据即可复现波前重建实验新手也能快速上手。项目将Shack-Hartmann传感器原理与深度学习如ResUNet充分结合涵盖光斑阵列模拟、波前相位重建、模型训练与结果可视化等完整流程并包含Python辅助脚本用于模型扩展。目前已有477人学习下载适合光学仿真或深度学习方向的学生参考使用。1. 深度学习 Shack-Hartmann 波前重建这个仿真解决的是实测中哪一截误差做过自适应光学或者测量人眼像差的人大概率遇到过这个场景Shack-Hartmann 波前传感器采回来一帧光斑点阵按教科书流程做质心提取、最小二乘重建出来的相位面看着挺像回事可一到闭环校验就露馅RMS 误差总比预期大一个量级。问题往往不在重建算法本身而在仿真阶段没有把光斑生成、斜率标定、Zernike 模式定义这些前置环节钉死。这个标题要解决的就是在 Matlab 里把「波前相位 → 光斑点阵 → 质心/特征 → 相位重建」整条链路仿真化再用深度学习替换最容易引入人为误差的环节用来预研算法、标定参数、批量造训练数据。适合做自适应光学、激光、显微成像和光学检测的仿真工程师也适合正在把深度学习塞进传统光学算法流程的研究生。2. Shack-Hartmann 传感器仿真光斑是怎么从波前算出来的2.1 波前怎么变成光斑点阵子孔径斜率到光斑偏移Shack-Hartmann 波前传感器核心是一个微透镜阵列加一块 CCD/CMOS。每个微透镜对应一个子孔径入射波前在子孔径内近似成一段倾斜平面波。这段倾斜平面波经过微透镜聚焦后焦点位置会相对于理想平面波的焦点发生横向偏移偏移量与子孔径内波前的平均斜率成正比。所以仿真里真正要建的不是“光斑转相位”这一步而是先想清楚怎么从相位面算出每个子孔径的光斑位置。仿真里最常用的做法是把波前相位用网格表示对每个子孔径区域求相位梯度并取均值得到两个方向上的斜率然后给每个子孔径生成一个高斯型光斑光斑中心在参考位置基础上加一个与斜率成正比的偏移。这样生成的图像在视觉上已经很接近真实 Shack-Hartmann 传感器的输出。这里有几个参数会影响整个仿真的可信度子孔径数量决定空间采样密度光斑大小决定质心提取的精度下限偏移系数 k 决定动态范围噪声水平决定深度模型能不能泛化。下面的参数表是常用的起步配置后面会逐项解释。参数推荐值范围影响每边子孔径数 N816决定空间采样和可重建 Zernike 项数上限网格大小 gridSizeN×8 以上每个子孔径内至少 8 像素否则光斑没法采样光斑 sigma spotSize0.81.5 像素越大质心提取越稳但子孔径边界越容易串扰偏移系数 k先反标定决定动态范围k 太大会让光斑越界加性噪声 sigma00.05归一化后过大会直接毁掉质心提取2.2 仿真生成一张含像差的光斑图最小可用 Matlab 代码在没有真实传感器的情况下生成训练数据只能靠仿真。下面的函数输入一个相位面输出一张光斑阵列图。它是整个深度学习训练数据管线的基础。function img generateSH(phi, N, gridSize, k, spotSize, noiseSigma) % 生成带像差相位 phi 的 Shack-Hartmann 光斑阵列图 % phi : [gridSize x gridSize] 相位分布单位 rad % N : 每边子孔径数量阵列 N x N % k : 光斑偏移系数像素/斜率需要用已知倾斜量反标 % spotSize : 高斯光斑 sigma单位像素 % noiseSigma: 加性高斯噪声标准差单位绝对强度 img zeros(gridSize, gridSize); subSize gridSize / N; % 每个子孔径边长像素 [cols, rows] meshgrid(1:gridSize, 1:gridSize); % cols 是 xrows 是 y for r 1:N for c 1:N % 当前子孔径像素范围 row0 round((r-1)*subSize) 1; row1 round(r*subSize); col0 round((c-1)*subSize) 1; col1 round(c*subSize); subPhi phi(row0:row1, col0:col1); % 平均相位梯度就是该子孔径感知到的波前斜率 [dPhiRow, dPhiCol] gradient(subPhi); % 行向/列向梯度 slopeRow mean(dPhiRow(:)); % dphi/dy像素坐标 slopeCol mean(dPhiCol(:)); % dphi/dx % 子孔径中心参考位置 centerRow (r-0.5)*subSize; centerCol (c-0.5)*subSize; % 光斑质心偏移 斜率 * 标定系数 k spotRow centerRow k * slopeRow; spotCol centerCol k * slopeCol; % 在当前子孔径内画高斯光斑避免光斑串到其他子孔径 winRows max(round(spotRow)-3*spotSize, row0):min(round(spotRow)3*spotSize, row1); winCols max(round(spotCol)-3*spotSize, col0):min(round(spotCol)3*spotSize, col1); [WX, WY] meshgrid(winCols, winRows); img(winRows, winCols) img(winRows, winCols) ... exp(-((WX - spotCol).^2 (WY - spotRow).^2) / (2*spotSize^2)); end end % 归一化后加读出噪声光子数少时实际更接近泊松分布 % 但高斯近似在仿真起步阶段够用 img img / max(img(:)); if noiseSigma 0 img img noiseSigma * randn(gridSize, gridSize); img max(img, 0); end end这段代码的关键是有意把每个光斑用高斯窗限制在所属子孔径内而不是让高斯函数在整个画面上展开。真实传感器中光斑会有一点溢出但这个限制能让质心提取时不被相邻子孔径的光斑干扰仿真初版这样处理能少踩很多坑。关于 k 的标定这里提醒一句k 是把相位梯度换成像素偏移的比例系数它和微透镜焦距、波长、CCD 像素尺寸绑定仿真里别凭感觉填。常见做法是先给一个已知倾斜量的纯倾斜相位跑一次上面的函数量出中心光斑实际偏移了多少像素再反推出 k。这个反标定步骤不能省否则后面重建出的 Zernike 系数会和真值差好多倍。2.3 质心提取先算对这一步再谈深度学习不管后面接传统重建还是深度学习质心提取都是整个链路里最容易被低估的一环。传统 Shack-Hartmann 重建的第一步就是得到每个子孔径的光斑位置然后减去参考位置得到偏移量。偏移量的精度直接决定波前重建精度。强度加权质心是最通用的方法但直接对整个子孔径窗做平方质心会导致噪声敏感必须加窗和阈值。下面这个函数是带参考中心的阈值质心可用于传统重建也可以用来给深度学习路线准备物理特征标签。function [cx, cy] weightedCentroid(im, refRow, refCol, winR, thrRatio) % 在参考点附近窗口内做强度加权质心 % im : 子孔径图像或全图 % refRow/refCol : 参考光斑中心通常是理想微透镜聚焦点 % winR : 窗口半径一般取 2~3 倍 spotSize % thrRatio : 阈值比例相对窗口内最大强度用于压低背景噪声 [cols, rows] meshgrid(1:size(im,2), 1:size(im,1)); mask (rows - refRow).^2 (cols - refCol).^2 winR^2; imWin im .* mask; imThr imWin - thrRatio * max(imWin(:)); imThr(imThr 0) 0; s sum(imThr(:)); cx sum(cols(:) .* imThr(:)) / s; cy sum(rows(:) .* imThr(:)) / s; end注意 meshgrid 的输出很容易搞反。[cols, rows] meshgrid(1:size(im,2), 1:size(im,1))里第一个输出是列索引第二个才是行索引。很多初学 Matlab 的人在这里把 X/Y 互换结果质心偏了一个像素重建的波前会整体倾斜。窗口半径 winR 建议取 23 个光斑 sigmathrRatio 一般取 0.050.2噪声越大阈值越高。实测中发现阈值设太高会把光斑边缘削掉导致质心偏向峰值设太低噪声又会把质心拉偏这是个需要肉眼检查的调参点。3. Zernike 模式法与最小二乘把光斑偏移变成相位面3.1 Zernike 多项式选哪套排序Noll 序和 Fringe 序不能混用光斑偏移得到的是离散子孔径斜率而我们需要连续相位面。波前重建最主流的做法是模式法把相位展开成一组 Zernike 多项式基函数的线性组合再用各子孔径斜率观测去拟合系数。这里第一个坑是 Zernike 排序。光学圈子里最常见的两套排序是 Noll 序和 Fringe 序前几项完全一样但从第 7 项左右开始顺序就乱了。如果你拿别人论文里给出的系数和自带 Zernike 函数重建相位却发现形状对不上大概率是排序定义不一致。仿真代码里应该把采用的排序固定写死并且在文件头部注释里标明。另一个坑是归一化。Zernike 多项式定义在单位圆内孔径半径 R 要先从像素坐标归一化到 1。下面给出前 6 项 Noll 序的解析表达式和导数这个表在做解析 Z 矩阵时非常有用。Noll 序号表达式dZ/dxdZ/dy110022x2032y024sqrt(3)(2x^22y^2-1)4sqrt(3)x4sqrt(3)y5sqrt(6)(x^2-y^2)2sqrt(6)x-2sqrt(6)y62sqrt(6)xy2sqrt(6)y2sqrt(6)x表中的 x、y 都是归一化坐标即像素坐标除以 R。如果你不想手推每个高阶项导数一个更省事的方案是对 Zernike 相位网格直接调 gradient 数值求导。虽然引入一点数值误差但比解析符号写错导致重建系统误差要安全得多我一般推荐仿真里统一用数值梯度。3.2 从斜率观测到 Zernike 系数的矩阵构造与最小二乘解模式法重建的核心是把斜率观测写成线性方程组。假设有 M 个子孔径每个子孔径给两个方向斜率观测向量 s 的长度是 2M。Z 矩阵 A 的每一行对应一个斜率观测每一列对应一个 Zernike 基函数。系数 c 满足 A * c ≈ s然后用最小二乘解。下面这段代码用子孔径中心处的 Zernike 导数构建 A 矩阵加一点正则化后再解系数最后合成相位面。function [coeff, phiRec] zernikeReconstruct(slopeX, slopeY, ... subCenterRow, subCenterCol, R, nTerms, xGrid, yGrid) % slopeX/slopeY: 光斑偏移换算后的相位梯度单位 rad/px % subCenterRow/Col: 子孔径中心坐标像素 % R : 孔径半径像素 % nTerms : 需要拟合的 Zernike 项数 M numel(slopeX); A zeros(2*M, nTerms); for t 1:nTerms [~, dzdx, dzdy] zernikeTerm(xGrid, yGrid, t, R); for m 1:M % 找距离子孔径中心最近的网格点取该点导数值 [~, idx] min((xGrid(:) - subCenterCol(m)/R).^2 ... (yGrid(:) - subCenterRow(m)/R).^2); A(m, t) dzdx(idx); A(M m, t) dzdy(idx); end end s [slopeX(:); slopeY(:)]; % 正则化最小二乘lambda 取 1e-3 级别抑制高阶项震荡 lambda 1e-3; coeff (A*A lambda*eye(nTerms)) \ (A*s); phiRec zeros(size(xGrid)); for t 1:nTerms [z, ~, ~] zernikeTerm(xGrid, yGrid, t, R); phiRec phiRec coeff(t) * z; end end这个实现的边界条件要说清楚A 矩阵用的是子孔径中心点的 Zernike 导数而光斑偏移严格对应子孔径内平均斜率。孔径小的时候两者差别不大但子孔径内像差变化明显时就会引入系统误差。如果你发现重建的离焦系数偏低先怀疑这里换成求子孔径区域内的平均导数再对比一次。正则化参数 lambda 也要注意。太小起不到抑制病态的作用太大会把所有系数往零压导致重建相位明显变平坦。判断标准很简单对比残差相位图的 PV 和 RMS如果残差出现高频条纹先把 lambda 调大到 1e-2 试如果系数整体被压扁再降回 1e-4。3.3 重建精度指标RMS、PV 和残差相位图怎么算很多人仿真跑完只看一张相位图觉得“长得像”就收工了这是最容易丢掉目标的时候。波前重建算法的评价必须落到数值上。三个指标是标配RMS均方根误差、PV峰谷差、残差相位图。残差相位图是重建值减去真值后的相位面。如果残差图里有明显的 Zernike 项形状说明该项拟合出了问题。RMS 是最常用的总误差度量但所有指标都要注意先把活塞项去掉。Zernike 第 1 项是常数它对光斑偏移没有影响系数本来不可观测直接算 RMS 会把活塞误差混进去。我习惯在仿真里固定写一个评价函数先给真值系数生成相位并模拟光斑再重建系数然后把两个相位面都减去均值最后比较 RMS 和 PV。这个函数会贯穿整个调试过程后面第 6 章会给出具体代码。4. 深度学习模型接入重建链路训练数据的坑比网络结构更影响结果4.1 深度学习进入波前重建的三种常见路线选哪种由你的端到端目标决定深度学习在 Shack-Hartmann 波前重建里的角色不只有一个。最常见的有三条路线。路线 A 是真正端到端输入整幅光斑阵列图输出 Zernike 系数跳过质心提取和 Z 矩阵构造网络自己学光斑到相位的映射。优点是简单缺点是不可解释且对传感器参数极敏感同样是这个网络换一个微透镜阵列规格大概率失效。路线 B 是深度网络替代质心提取输入每个子孔径的局部光斑图输出光斑在 x、y 方向的偏移后面仍然接传统 Zernike 最小二乘重建。这条路线保留了物理可解释性网络只学它最擅长的特征回归偏移量仍然是传统重建算法的合法输入训练数据需求量也比路线 A 小得多。路线 C 是补偿式先用传统质心估算偏移再用一个小网络学习质心误差的残差。适合已有成熟重建算法、只想压那 10% 精度损失的情况。如果是新起项目我会用路线 B 起步。理由很直接它能复用第 3 章的所有重建代码而且一旦出问题能清晰地区分是网络没学好还是重建矩阵有问题不用对着一个黑匣子猜。4.2 用 CNN 从光斑图回归 Zernike 系数一个能跑通的 Matlab 网络在 Matlab 里搭一个能直接跑的最小 CNN 其实只需要四五层。下面这段代码用了 Deep Learning Toolbox 的 layerGraph 思路输入是固定大小的光斑图输出是 nTerms 个 Zernike 系数。inputSize [gridSize gridSize 1]; nTerms 12; layers [ imageInputLayer(inputSize, Normalization, none) convolution2dLayer(3, 16, Padding, same) reluLayer maxPooling2dLayer(2, Stride, 2) convolution2dLayer(3, 32, Padding, same) reluLayer maxPooling2dLayer(2, Stride, 2) fullyConnectedLayer(64) reluLayer fullyConnectedLayer(nTerms) regressionLayer ]; options trainingOptions(adam, ... InitialLearnRate, 1e-3, ... MaxEpochs, 30, ... MiniBatchSize, 32, ... Shuffle, every-epoch, ... L2Regularization, 1e-4, ... Verbose, false); net trainNetwork(XTrain, YTrain, layers, options);XTrain 的维度是 gridSize × gridSize × 1 × NYTrain 是 N × nTerms。这里一个常见问题是输出系数范围很小尤其是高阶项初始学习率 1e-3 很容易在回归问题上震荡。我一般会先把 Zernike 系数的训练标签做标准化也就是每个维度减均值除标准差训练完再反归一化。否则 loss 曲线看着在降实际系数输出全是错的。另一个很容易被忽略的问题是图像尺寸。网络输入必须和生成的传感器图像尺寸一致不要为了迁就网络结构把光斑图 resize 到 224×224。resize 会改变光斑相对子孔径的偏移比例网络学到的偏移标定和物理场景对不上换到真实数据时直接失效。4.3 训练数据生成策略让模型见过噪声它才敢上实测深度学习在波前重建里最容易翻车的地方不是网络结构而是训练数据分布偏离真实工况。如果训练时只生成无噪声的理想光斑模型对噪声的鲁棒性等于零。生成训练集必须把噪声、光斑大小、像差范围全部参数化并让每个样本独立随机化。function [X, Y] generateTrainingSet(Nsamples, gridSize, N, nTerms, opts) % opts 需包含k, spotSize, noiseSigma, ampRange X zeros(gridSize, gridSize, 1, Nsamples); Y zeros(Nsamples, nTerms); [xGrid, yGrid] meshgrid(linspace(-1,1,gridSize), linspace(-1,1,gridSize)); R 1; % 这里直接使用归一化坐标 for i 1:Nsamples coeff opts.ampRange .* (2*rand(1, nTerms) - 1); coeff(1) 0; % 活塞项不影响光斑直接置零 % 合成相位面并模拟光斑图 phi zernikeSum(coeff, xGrid, yGrid, R); img generateSH(phi, N, gridSize, opts.k, opts.spotSize, opts.noiseSigma); X(:,:,1,i) img; Y(i,:) coeff; end endampRange 的取值是这里最有讲究的参数。直接把每个阶数的幅度都设置在同一个范围内训练出的模型会偏向重建训练集中的中等像差。实际波前像差分布通常低阶项幅度显著大于高阶项所以低阶项比如离焦、像散的 ampRange 应当比高阶项大 23 倍。反过来如果高阶项完全不覆盖模型到实测会被高频成分彻底击穿。噪声方面每个样本都独立生成噪声千万不要在循环外固定一个随机种子。深度学习网络能记住训练集里的噪声模式如果所有样本都复用同一帧噪声验证集上 loss 会虚低一上实测立刻打回原形。5. 仿真翻车避坑清单从光斑生成到模型训练常见的 5 个坑5.1 Zernike 排序混乱导致系数对不上现象用文献里的系数同样的一批相位贡献重建出来波前形状对但 PV/RMS 和文献差很远或者有几项系数明显存在固定的倍数关系。原因Noll 序和 Fringe 序的排列不同模态归一化系数也不一样更隐蔽的是别人可能用的是非归一化 Zernike各项表达式差一个整数倍。你拿到的系数根本没法和你的 Z 矩阵对齐。解决代码里固定一套排序和归一化方式并写一个自检函数验证低阶项。比如用第 2 项 Zernike 生成纯 x 方向倾斜相位它的系数应该映射到重建结果的 Z2 上而不是跑到 Z3 或 Z5 里。这个自检跑一遍只需要几十毫秒值得每次改代码后都执行。5.2 质心提取在噪声下的偏移现象无噪声时重建误差 1% 不到把 noiseSigma 调到 0.03RMS 误差直接飙到 15%而且残差相位图呈随机颗粒状。原因强度加权质心对窗口外的噪声非常敏感。只要窗口稍微大一点暗电流和读出噪声的积分权重就可能超过光斑边缘的强度质心被拉向任意方向。解决窗口半径从 3 倍光斑 sigma 开始收窄阈值比例调到 0.1 左右还压不住就改用平方加权质心或高斯拟合法。高斯拟合精度高但计算量成倍增加仿真阶段建议把两种方法都实现画一条“噪声水平–重建误差”曲线再决定用哪个。5.3 训练集和验证集同分布导致模型自我感觉良好现象训练 loss 和验证 loss 都收敛得很漂亮但一旦把验证集中的像差幅度范围扩大 20%误差立刻翻倍。原因训练集和验证集是从同一个随机生成逻辑里切出来的分布几乎完全重叠。网络只学会了这个范围内的插值没有学到更广的泛化。解决生成数据时预留一部分离群组合做验证比如专门生成超过训练集幅度上限 20% 的离焦样本、加 2 倍噪声的样本、甚至随机遮挡几个子孔径光斑的样本。这些样本不进训练集只用来做“压力测试”。如果网络在这里表现不佳说明你的训练数据覆盖不足。5.4 光斑越过子孔径边界图上有两个光斑黏在一起现象仿真图上某个子孔径的光斑明显不在自己格子里甚至和相邻子孔径的光斑融合成一个椭圆质心提取结果完全失真。原因像差太大子孔径内斜率乘上 k 之后的偏移量超过了子孔径半径。这就相当于传感器超出了动态范围。解决在生成训练集前先估算允许的最大 Zernike 系数。一个近似规则是每个子孔径允许的最大偏移约为子孔径边长的一半按当前 k 值换算成斜率再除以最大阶数的导数峰值得到系数上限。ampRange 必须按这个上限来设计。如果你非要覆盖大像差就得提高子孔径数量或减小 k。5.5 模式数取得太多矩阵发毛现象Zernike 项数从 10 加到 15重建系数开始剧烈震荡残差相位图出现棋盘状高频条纹PV 比真值大了好几倍。原因子孔径数是有限的能提供的信息也是有限的。拟合项数超过可辨识空间后Z 矩阵条件数急剧变大最小二乘解会把观测噪声放大到高阶项上。解决先用 cond(A) 检查矩阵条件数超过 1000 就删项或加正则。我的经验是 8×8 子孔径最多稳定重建到 1014 项16×16 可以到 20 项出头。别追求高项数波前重建的精度瓶颈往往在低阶项上。6. 怎么验证一个波前重建仿真真的可信6.1 已知像差的闭环验证仿真里最有力的验证方式就是闭环给定一组已知 Zernike 系数生成相位面和光斑图走完质心提取、重建、深度学习预测的全流程再拿重建结果和真值对比。这段代码可以直接复用到每次改动之后。trueCoeff [0; 0.3; -0.2; 0.15; 0.08; -0.05]; % 前 6 项 phiTrue zernikeSum(trueCoeff, xGrid, yGrid, R); img generateSH(phiTrue, N, gridSize, k, spotSize, 0.02); % 重建流程 [coeff, phiRec] reconstructFromImage(img, ...); % 你的重建函数 % 去掉活塞后比较 phiTrue phiTrue - mean(phiTrue(:)); phiRec phiRec - mean(phiRec(:)); rmsErr sqrt(mean((phiTrue - phiRec).^2, all)); pvErr max(phiTrue(:)-phiRec(:)) - min(phiTrue(:)-phiRec(:)); fprintf(RMS error: %.4f rad, PV error: %.4f rad\n, rmsErr, pvErr);RMS 误差能压到 0.02 rad 以下说明链路基本干净。如果 RMS 一直在 0.05 rad 量级先别怀疑深度学习回头查 k 标定和 Z 矩阵的口径一致性。6.2 随机批量测试看分布而不是看一个点单组系数验证只能证明代码没写崩不能证明算法稳定。我会固定跑 200 组随机像差统计误差分布。这里不用传言里的 95 分位函数直接排序取值即可。errs zeros(200, 1); for i 1:200 coeff ampRange .* (2*rand(1,nTerms)-1); coeff(1) 0; phiTrue zernikeSum(coeff, xGrid, yGrid, R); img generateSH(phiTrue, N, gridSize, k, spotSize, noiseSigma); coeffPred predict(net, img); phiPred zernikeSum(coeffPred, xGrid, yGrid, R); phiTrue phiTrue - mean(phiTrue(:)); phiPred phiPred - mean(phiPred(:)); errs(i) sqrt(mean((phiTrue - phiPred).^2, all)); end errsSorted sort(errs); fprintf(RMS mean: %.4f, p95: %.4f, max: %.4f\n, ... mean(errs), errsSorted(round(0.95*numel(errs))), max(errs));均值要看但更值得关注的是 p95 和最大值。仿真里如果 p95 比均值大一倍以上说明有相当比例的极端样本网络在真实数据上大概率会在这个区间翻车。6.3 留一份极端工况当压力测试我会在项目一开始就固化一个压力测试集大离焦、大像散、高噪声、光斑亮度减半、随机遮挡两个子孔径。这个数据集一旦生成就不再改动每次算法迭代都拿它跑一遍。因为仿真参数多改一次代码很容易让前面的结果看似变好实际只是把问题藏到没测过的角落。我自己踩过最深的坑就是所有测试都集中在理想工况里觉得自己模型做得挺好后来把噪声源换成泊松分布整个系统崩了。现在我的习惯是每次跑完标准测试都强制看一眼压力测试的 RMS 变化只要数值比上次差 20% 以上就先别急着往下加模块回头查这次改了什么。这套做法帮我把不少玄学问题变成了可定位的数值问题希望也能帮到你少走这段弯路。本文还有配套的精品资源点击获取