我第一次在实验室看到投影仪投出几道黑白相间的条纹,屏幕上一点点浮现出物体的三维点云时,说实话是被震住的。这就是结构光三维重建最迷人的地方:用“看起来只是一张条纹图”的光,把物体表面的高度信息一格格解出来。而三频四步相移法,是这条路线上最经典、也最适合入门的一套方案。
这篇文章直接用MATLAB把整条链路跑通:条纹生成、图像采集(带仿真模式)、四步相移解包裹相位、三频外差展开绝对相位、再到三维点云重建,完整代码全部贴出。无论你是做毕设、入门三维视觉,还是想在工业项目里快速验证一个方案,这套代码都能当成一个可复现的参考管线。代码只依赖MATLAB基础函数,我用的是R2021a,理论上R2018b之后的版本都能跑,不需要额外装专业工具箱,安装好环境后直接复制运行即可。
1. 为什么是三频四步?方案选型背后的讲究
1.1 四步相移是精度与效率的平衡点
结构光测量的本质,是把“投影仪列坐标”编码进光的相位里。投影仪投出一组灰度按正弦变化的条纹,相机拍到的每一点灰度可以写成:
$$I_k = A + B\cos(\phi + \delta_k)$$
其中A是环境光和表面反射率决定的背景项,B是条纹对比度,φ是我们真正关心的相位,δ_k是第k次相移的步进量。一个方程里有A、B、φ三个未知数,所以理论上最少三幅图就能解出相位,这就是三步相移。但实际工程里,四步相移才是绝对的默认配置。
四步相移的相移量取0、π/2、π、3π/2,四幅条纹图代入之后会得到一个非常漂亮的结果:
$$\phi = \text{atan2}(I_3 - I_1, I_0 - I_2)$$
注意,背景项A被完全消掉了,而且分子分母都是对称差分形式,对相机固定噪声、环境光偏置都有很好的抑制能力。相比三步相移,四步在噪声抑制上略胜一筹;相比五步、六步相移,四步又少拍一到两幅图,速度更快。在需要兼顾精度和采集帧率的场景下,四步就是那个最甜的平衡点。
还有一个容易被忽略的点:四步相移因为采样点对称,对投影仪伽马非线性引起的部分谐波误差有抑制作用。当然,伽马问题是结构光的大坑,后面我单独说,这不是四步能完全解决的。
1.2 相位展开为什么不能用空间展开硬扛
四步相移解出来的φ是包裹相位,范围被反正切函数限制在[-π, π)之间。真实相位可能是10π、20π甚至100π,我们看到的只是它“取余”后的结果。把包裹相位还原成真实相位的过程,叫相位展开。
最朴素的想法是空间展开:在图像上逐像素扫描,发现相邻像素相位跳变超过π,就补上一个2π。这个方法在光滑连续表面确实能用,但一旦遇到以下情况就会崩掉:
- 物体表面有台阶、断裂,真实相位本身就跳变;
- 噪声点把相邻相位差推到π附近,产生误判;
- 被遮挡或阴影区域没信号,展开路径上出现空洞。
我曾经在实验室用一个带凹陷的零件测试空间展开,结果展开后的相位图跟等高线地图一样,全是断层的“梯田”。从那之后我就明白了,工业级的相位展开必须引入额外约束,而不能靠像素邻域的“猜”。
时间相位展开是另一条路:投影多组不同频率的条纹,用低频相位作为“圈数计数器”,去指导高频相位的展开。格雷码加相移就是一种典型方案,格雷码负责粗定位周期序号,相移负责细定位相位。但格雷码需要额外投影多幅二值编码图,帧数多,而且码字边界处的亚像素对齐问题很让人头疼。
三频外差法则不同:它把所有约束都放在正弦条纹本身,不投影任何二值码,帧数固定(三个频率各四步,一共12幅图)。而且外差合成过程在数学上很优雅,通过频率间的差频,合成一个等效周期极大、全场无包裹的绝对相位基准,然后逐级引导展开。这就是三频法的核心优势。
1.3 频率组怎么选:关键在外差后的等效周期
三频外差并不是随便选三个频率就能用。频率之间的差值,直接决定了展开能不能成功。
我最常用的频率组是70、64、59像素每周期。为什么选这组?我们算一下外差周期。两个频率的相位相减,等效周期为:
$$t_{lm} = \frac{T_l T_m}{|T_l - T_m|}$$
70和64外差后的等效周期约746像素,64和59外差后的等效周期约755像素。这两个等效周期再相减(二级外差),等效周期为:
$$t_{lms} = \frac{t_{lm} t_{ms}}{|t_{lm} - t_{ms}|}$$
代入算出来大约是66000像素,远远大于图像宽度1280像素。这意味着二级外差相位在整个视场范围内单调变化,不会出现包裹跳变,可以直接作为全场绝对相位的基准。这就是三频法能用、而双频法在某些场景下不够稳的根本原因。
如果频率差选得太大,比如70、50、30,一级外差周期只有175和75像素,二级外差周期才一百多像素,展开时照样会撞上包裹跳变,算法直接失效。所以选频率组的原则很简单:先算二级外差周期,确保它至少大于视场宽度的1.2倍,再谈其他。
2. 核心原理拆解:从条纹到绝对相位
2.1 一帧条纹图里到底藏了什么信息
先用大白话把测量逻辑讲透。投影仪向物体投射正弦条纹,条纹是沿横向变化的,也就是投影仪的每个“列坐标”都对应一个唯一的相位值。相机从另一个角度拍摄,物体表面的某个点,在相机图像里落在某个像素上;这个像素看到的亮度,是由物体表面反射的投影条纹决定的。
关键来了:物体表面是凹凸不平的,同样一个相机像素,在参考平面和物体表面上看到的投影条纹相位不同。因为物体表面把投影光线“顶”起来了,导致相机像素看到的投影仪列号发生了偏移。这个相位偏移量,就携带了物体高度的信息。
所以整个结构光重建可以拆成三步:第一,从相机拍到的条纹图中解出每个像素对应的相位;第二,把包裹相位展开成绝对相位,得到每个像素对应的投影仪列坐标;第三,利用相位偏移量或者三角几何关系,把二维像素坐标转换成三维点坐标。
理解这条主线之后,三频四步相移法的每一个步骤都不会觉得孤立了。
2.2 四步相移的数学推导:一个式子就够
四步相移的推导非常干净。设四幅条纹图的相移量分别为0、π/2、π、3π/2,代入观察方程:
- I0 = A + B cosφ
- I1 = A - B sinφ
- I2 = A - B cosφ
- I3 = A + B sinφ
由第二式减第四式得 I3 - I1 = 2B sinφ,第一式减第三式得 I0 - I2 = 2B cosφ。两者相除得到正切,再用atan2恢复出带象限信息的相位:
$$\phi = \text{atan2}(I_3 - I_1, I_0 - I_2)$$
这就是整个相移解算的核心。代码里我写了一个通用N步相移函数,四步只是它的特例。通用N步公式也很简洁:
$$\phi = \text{atan2}\left(-\sum I_k \sin\delta_k,\ \sum I_k \cos\delta_k\right)$$
之所以用通用公式而不是只写四步专用式,是为了方便你以后改造:想降成三步提速,或者升成五步提精度,只需要改一个参数,不用换函数。
调制幅度B也有大用处:B = 0.5*sqrt((I1-I3)^2 + (I0-I2)^2)。B越大,说明这个像素上条纹对比度越高,测量越可靠;B趋近于0,说明该点被遮挡、阴影覆盖,或者表面反光太弱。用B做掩膜可以滤掉大量无效像素,这是工程上必备操作。
2.3 三频外差展开:就是一个“找圈数”的过程
展开相位的本质,是确定每个包裹相位点需要加上多少个2π。这个“圈数”可以从另一个低频相位来推断。假设我们有低频相位作为引导值guide,包裹相位是wrapped,真实绝对相位的计算公式是:
$$k = \text{round}\left(\frac{guide - wrapped}{2\pi}\right)$$
$$\Phi = wrapped + 2\pi k$$
这里用round是因为绝对相位与包裹相位之间一定差2π的整数倍,只要引导值和真实值之间的误差不超过π,round就能正确找回那个整数倍。这个公式就是你理解三频外差展开的钥匙。
三频外差就是把这个“引导”过程做了两级串联。频率从大到小命名为T_large、T_mid、T_small后,过程是:
- 对三组包裹相位做外差:θ_lm = wrap(φ_large - φ_mid),θ_ms = wrap(φ_mid - φ_small);
- 再做二级外差:θ_lms = wrap(θ_lm - θ_ms),它的等效周期极大,全场几乎无包裹,直接得到最粗糙但绝对可信的相位基准Φ_lms;
- 用Φ_lms引导展开θ_ms,得到Φ_ms;
- 用Φ_ms引导展开φ_small,得到最终需要的高频绝对相位Φ_small。
整个公式链条里,每一级都在做同一件事:低频告诉高频“你现在是第几圈”,高频再把精细位置确定下来。这和GPS定位是一个道理:卫星先告诉你大概在城市哪个区(低频),城市的详细地图再帮你定位到哪条街(高频)。
3. 完整MATLAB实现:可直接复制运行的代码
3.1 代码结构与运行方式
整套代码包含一个主脚本和三个函数文件,文件结构如下:
main_three_freq_phase_shift.m # 主流程,直接运行 calculate_wrapped_phase.m # 通用N步相移解包裹相位 unwrap_three_frequency.m # 三频外差展开绝对相位 read_hardware_fringes.m # 真实硬件图像读取(预留)主脚本里内置了仿真模式:程序会自动生成一个高斯鼓包作为待测物体,模拟投影仪投射条纹、相机拍摄的过程,并加上高斯噪声。这样即使你手上没有投影仪和相机,也能完整跑通整个算法流程,看到最终的三维重建结果。等以后上了真实设备,只需要把use_hardware改成true,并按约定文件名把采集的条纹图放进文件夹即可。
仿真模式是我特别推荐的入门方式。因为算法的每一步都可以和“真值”对比:我知道物体真实高度是什么,算法重建出来是什么,误差一目了然。这种闭环验证的能力,在实际设备上反而很难得到,因为很难获得物体的精确真值。
3.2 主脚本:仿真数据生成与重建全流程
%% ============================================================ % main_three_freq_phase_shift.m % 三频四步相移结构光三维重建:仿真教学版 / 硬件切换版 % 运行环境:MATLAB R2018b+,基础函数即可 % ============================================================ clear; clc; close all; rng(42); %% 1. 运行模式与全局参数 use_hardware = false; % true: 读取真实采集图像; false: 仿真生成 H = 720; % 图像高度 W = 1280; % 图像宽度 T_large = 70; % 低频条纹周期(像素/周期) T_mid = 64; % 中频条纹周期 T_small = 59; % 高频条纹周期 T_list = [T_large, T_mid, T_small]; N = 4; % 四步相移 delta = (0:N-1)*pi/2; % 相移量 [0 pi/2 pi 3pi/2] I_min = 40; % 条纹灰度下限 I_max = 220; % 条纹灰度上限 k_phase_to_height = 10; % 相位-高度映射系数,仿真中已知;实际需标定 %% 2. 仿真数据生成(use_hardware=false 时执行) if ~use_hardware [x, y] = meshgrid(1:W, 1:H); x_norm = x / W; y_norm = y / H; % 待测物体:一个高斯鼓包,模拟凸起表面 obj_z = 30 * exp(-((x_norm - 0.6).^2 + (y_norm - 0.45).^2) / 0.02); phase_offset = obj_z / k_phase_to_height; % 生成参考平面与物体的四步相移条纹 fringes_ref = zeros(H, W, 3, N); fringes_obj = zeros(H, W, 3, N); for f = 1:3 T = T_list(f); Phi_ref_f = (2*pi/T) * x; % 参考平面绝对相位 Phi_obj_f = Phi_ref_f + phase_offset; % 物体绝对相位 for n = 1:N fringes_ref(:,:,f,n) = I_min + (I_max - I_min)/2 * (1 + cos(Phi_ref_f + delta(n))); fringes_obj(:,:,f,n) = I_min + (I_max - I_min)/2 * (1 + cos(Phi_obj_f + delta(n))); end end % 加入高斯噪声模拟真实采集 sigma_noise = 2.0; fringes_obj = fringes_obj + sigma_noise * randn(size(fringes_obj)); fringes_ref = fringes_ref + sigma_noise * randn(size(fringes_ref)); % 预览某个频率下的四步条纹图 figure('Name','条纹图预览'); for n = 1:4 subplot(2,2,n); imagesc(fringes_obj(:,:,3,n)); axis image; colormap gray; title(sprintf('Object fringe, step %d', n-1)); end else % 真实硬件模式:从文件夹读取预先采集的条纹图 % 文件名规则:T70_s0.png, T70_s1.png, ..., T59_s3.png fringes_ref = read_hardware_fringes('fringes/ref'); fringes_obj = read_hardware_fringes('fringes/obj'); [H, W, ~, N] = size(fringes_obj); end %% 3. 解包裹相位(对三种频率分别计算) phi_ref = zeros(H, W, 3); phi_obj = zeros(H, W, 3); for f = 1:3 phi_ref(:,:,f) = calculate_wrapped_phase(squeeze(fringes_ref(:,:,f,:))); phi_obj(:,:,f) = calculate_wrapped_phase(squeeze(fringes_obj(:,:,f,:))); end figure('Name','包裹相位'); subplot(1,2,1); imagesc(phi_ref(:,:,3)); axis image; colorbar; title('Reference wrapped phase (T=59)'); subplot(1,2,2); imagesc(phi_obj(:,:,3)); axis image; colorbar; title('Object wrapped phase (T=59)'); %% 4. 三频外差展开绝对相位 Phi_ref = unwrap_three_frequency(phi_ref(:,:,1), phi_ref(:,:,2), phi_ref(:,:,3), ... T_large, T_mid, T_small); Phi_obj = unwrap_three_frequency(phi_obj(:,:,1), phi_obj(:,:,2), phi_obj(:,:,3), ... T_large, T_mid, T_small); figure('Name','展开后绝对相位'); subplot(1,2,1); imagesc(Phi_ref); axis image; colorbar; title('Reference abs phase'); subplot(1,2,2); imagesc(Phi_obj); axis image; colorbar; title('Object abs phase'); %% 5. 调制幅度掩膜:滤掉阴影和低质量区域 I0 = squeeze(fringes_obj(:,:,3,1)); I1 = squeeze(fringes_obj(:,:,3,2)); I2 = squeeze(fringes_obj(:,:,3,3)); I3 = squeeze(fringes_obj(:,:,3,4)); B = 0.5 * sqrt((I1 - I3).^2 + (I0 - I2).^2); valid = B > 0.3 * (I_max - I_min) / 2; % 调制阈值取最大幅度的30% %% 6. 相位->深度重建(参考平面式相位-高度映射) depth_map = zeros(H, W); depth_map(valid) = k_phase_to_height * (Phi_obj(valid) - Phi_ref(valid)); depth_map(depth_map < -5 | depth_map > 80) = NaN; %% 7. 结果分析与可视化 if ~use_hardware err = depth_map - obj_z; idx = find(valid & ~isnan(depth_map)); rmse = sqrt(mean((depth_map(idx) - obj_z(idx)).^2)); fprintf('RMSE = %.4f (单位高度)\n', rmse); end figure('Name','重建结果'); subplot(1,3,1); imagesc(obj_z); axis image; colorbar; title('Ground Truth'); subplot(1,3,2); imagesc(depth_map); axis image; colorbar; title('Reconstructed'); subplot(1,3,3); imagesc(abs(err)); axis image; colorbar; title('Abs Error'); figure('Name','三维显示'); surf(x, y, depth_map, 'EdgeColor', 'none'); colormap jet; axis equal; title('3D Reconstruction (surf)'); xlabel('x / pixel'); ylabel('y / pixel'); zlabel('depth / unit'); % 画一维相位剖面,直观看到包裹->展开的变化 figure('Name','相位剖面'); row = round(H*0.45); plot(phi_obj(row,:,3), 'DisplayName', 'wrapped'); hold on; plot(Phi_obj(row,:) / max(Phi_obj(row,:)) * pi, 'DisplayName', 'unwrapped (normalized)'); legend; xlabel('x / pixel'); ylabel('phase / rad'); title('Phase profile before and after unwrapping');3.3 核心函数一:四步相移解包裹相位
function phi = calculate_wrapped_phase(images) % calculate_wrapped_phase 通用N步相移解包裹相位 % 输入 images: H x W x N,N幅相移条纹图 % 输出 phi : H x W,包裹相位,范围 [-pi, pi) % % 四步专用公式:phi = atan2(I3 - I1, I0 - I2) % 通用N步公式:phi = atan2(-sum(I_n*sin(delta_n)), sum(I_n*cos(delta_n))) [H, W, N] = size(images); delta = (0:N-1) * (2*pi/N); y_num = zeros(H, W); x_den = zeros(H, W); for n = 1:N y_num = y_num + double(images(:,:,n)) * sin(delta(n)); x_den = x_den + double(images(:,:,n)) * cos(delta(n)); end phi = atan2(-y_num, x_den); end这段代码里有个小细节值得说明:输出范围是[-π, π),而不是[0, 2π)。atan2的返回值本来就带象限信息,范围天然是[-π, π)。后面的外差展开、wrap操作都建立在这个约定上,所以不要随意把相位映射到[0, 2π)。
3.4 核心函数二:三频外差展开绝对相位
function Phi_small = unwrap_three_frequency(phi_large, phi_mid, phi_small, ... T_large, T_mid, T_small) % unwrap_three_frequency 三频外差法展开绝对相位 % 输入三种频率(周期递减)的包裹相位,以及对应条纹周期 % 返回最小周期(最高频)的绝对相位 Phi_small % % 公式链: % theta_lm = wrap(phi_large - phi_mid) 等效周期 t_lm % theta_ms = wrap(phi_mid - phi_small) 等效周期 t_ms % theta_lms = wrap(theta_lm - theta_ms) 等效周期 t_lms % 当 t_lms > 视场宽度时,theta_lms 全场无包裹,作为绝对基准 % 然后逐级引导展开:theta_lms -> theta_ms -> phi_small t_lm = T_large * T_mid / abs(T_large - T_mid); t_ms = T_mid * T_small / abs(T_mid - T_small); t_lms = t_lm * t_ms / abs(t_lm - t_ms); theta_lm = wrap_pi(phi_large - phi_mid); theta_ms = wrap_pi(phi_mid - phi_small); theta_lms = wrap_pi(theta_lm - theta_ms); % 第一级:theta_lms 等效周期远大于视场,unwrap后即可作为绝对相位 % 这里沿x方向展开;仿真中相位连续,结果稳定 Phi_lms = unwrap(theta_lms, [], 2); % 沿列方向(x)展开 % 第二级:用 Phi_lms 引导展开 theta_ms Phi_ms = theta_ms + 2*pi*round((Phi_lms * t_lms / t_ms - theta_ms) / (2*pi)); % 第三级:用 Phi_ms 引导展开 phi_small Phi_small = phi_small + 2*pi*round((Phi_ms * t_ms / T_small - phi_small) / (2*pi)); end function p = wrap_pi(p) p = mod(p + pi, 2*pi) - pi; end这里我故意没用MATLAB自带的wrapToPi函数,而是自己写了个wrap_pi。因为wrapToPi在部分早期版本里归属于Mapping Toolbox,不少机器上没装。用mod实现的版本在任何版本都能跑。
注意unwrap函数的方向参数:这里用了unwrap(theta_lms, [], 2),表示沿x方向展开。这是有前提的:条纹沿x方向变化,物体表面相对光滑,相位没有局部剧烈跳变。真实场景如果物体带台阶,这种一维展开方式可能出错,更稳妥的做法是在整个二维空间上用引导关系逐像素展开,或者对每一行重复三维信息校验。仿真教学阶段,先保持简单。
3.5 核心函数三:硬件图像读取模板
function fringes = read_hardware_fringes(folder_path) % read_hardware_fringes 从文件夹读取12幅条纹图 % 文件名约定:T70_s0.png, T70_s1.png, ..., T59_s3.png % 适用于黑白全局快门相机保存的单通道图像 T_list = [70, 64, 59]; N = 4; first = imread(fullfile(folder_path, sprintf('T%d_s%d.png', T_list(1), 0))); [H, W] = size(first); fringes = zeros(H, W, 3, N); for f = 1:3 for n = 1:N img = imread(fullfile(folder_path, sprintf('T%d_s%d.png', T_list(f), n-1))); fringes(:,:,f,n) = double(img); end end end不同相机的SDK差异很大,这个函数只做最简单的文件读取。实际项目里你只需要把这一处替换成自己相机的采集接口,后面整个算法链路完全不用动。
3.6 标定相位-高度系数K的工具函数
function k = calibrate_phase_to_height(phi_obj, phi_ref, h_block, mask) % calibrate_phase_to_height 用已知高度块标定相位-高度系数 % 输入:物体相位、参考平面相位、已知块高度、有效区域mask % 输出:系数 k,满足 h = k * (phi_obj - phi_ref) dphi = mean(phi_obj(mask) - phi_ref(mask)); k = h_block / dphi; end仿真里k是已知的,但真实系统必须自己做标定。操作方法是:在参考平面上放一个高度精确已知的平面块(比如标准量块),用同样的条纹采集流程测出块的相位差,高度除以平均相位差就得到k。建议至少用三个不同高度的块做多点标定,然后拟合出一条更准的曲线,而不是只用一个点。
4. 实验结果分析与参数调优心得
4.1 仿真跑通后你应该看到什么
程序运行完应该能看到四个窗口:条纹图预览、包裹相位图、展开后绝对相位图、重建结果和三维曲面。其中包裹相位图是典型的“彩虹条纹”,每个颜色周期代表2π;展开后的绝对相位图则是连续渐变的彩色图,不再有周期性的颜色跳变。这一步就说明相位展开成功了。
一维剖面图是最直观的验证:包裹相位线在[-π, π]之间锯齿状震荡,展开后变成一条平滑上升的曲线。如果展开出问题,这里会看到展开曲线在某处忽然掉下去或者跳上来。
在默认噪声sigma=2的条件下,仿真的重建RMSE大约在0.1到0.2单位高度左右。物体的最大高度是30单位,相当于相对误差不到1%。这个结果说明算法在理想情况下的精度是足够的。
4.2 噪声对重建精度的影响规律
我建议你改一改代码里的sigma_noise参数,从0.5逐步调到5,观察RMSE的变化。你会发现相位噪声和灰度噪声呈近线性关系,原因可以从四步相移的公式推导出来。在调制幅度B固定的情况下,相位噪声近似等于:
$$\sigma_\phi \approx \frac{\sigma_I}{\sqrt{2}B}$$
也就是说,抗噪性能主要靠提高条纹对比度B。这也是为什么结构光现场一定要控制环境光、提高投影亮度、降低相机增益的原因。把环境光压下去,把B顶上去,比你在算法里做一百遍滤波都管用。
4.3 三个关键参数的调节心得
高频条纹周期T_small是最直接影响重建精度的参数。周期越小,同样的相位噪声引起的深度误差越小,但周期太小编码条纹会超出相机分辨能力,条纹对比度下降,反而引入新的误差。我常用的范围是8到20像素,具体取多少要看相机的分辨率和投影仪的投影清晰度。
调制阈值B的阈值是个经验活。阈值设低了,阴影、反光区域会被当成有效点,重建结果出现大量飞点;阈值设高了,边缘和暗部细节被吃掉。建议先输出B的直方图,看看双峰分布,取峰谷作为阈值起点。
滤波强度也要控制。unwrap之前可以做一个轻度的高斯平滑,但核别超过3×3,否则会把相位里的真实细节一起抹掉。我更推荐的做法是:相位展开成功后,在深度域做median滤波,这样既能去飞点又不伤边缘。
5. 真实设备部署:别被仿真骗了
5.1 硬件选型:相机和投影仪怎么搭
仿真模式跑通之后,你会跃跃欲试想上真设备。硬件选型方面我提几个最关键的坑。
相机优先选黑白全局快门,而不是彩色卷帘快门。黑白相机的量子效率更高,同样的光强下信噪比更好;全局快门能保证拍摄瞬间整幅图像没有卷帘畸变,避免条纹在运动或曝光过程中被撕裂。分辨率不是越高越好,和投影仪分辨率匹配才合理。比如投影仪是912×1140,相机200万像素足够了,再高只会增加计算压力而不提升编码精度。
投影仪选DLP,不要选LCD。DLP的切换速度快、拖影小,适合和相机做硬件同步。LCD投影的液晶响应慢,投射快速切换的条纹时会有残影,直接导致相位误差。
5.2 采集流程和同步问题
12幅条纹图虽然数量不多,但每一幅都必须在同一个物体状态下采集。如果物体在动,或者相机投影仪没同步,条纹之间的相位关系就乱了,展开直接失败。
最稳妥的方案是投影仪和相机用硬件触发同步。投影仪切换下一幅条纹的同时,触发相机曝光。这样每幅条纹和对应的图像严格对齐,可以支持一定速度的运动物体。
如果设备不支持硬件触发,只能软件串行投影和采集,那就要保证被扫描物体完全静止。这种情况下做不了动态重建,只适合工件放在台面上那种场景。
采集顺序也有讲究:先拍参考平面的12张,再放上物体拍12张。参考平面就是一块干净的白色平面板,最好经过漫反射处理,不能反光。参考平面一旦移动,整个系统的基准就变了,重建高度会全部偏掉。
5.3 从绝对相位到三维坐标的工程路径
仿真代码里用的是相位-高度映射,这个模型假设相机、投影仪的光轴有一个简单的平行关系,在实验室教学场景下演示范例没问题,但真实系统的几何关系要复杂得多。工业项目的标准做法是:
- 第一步,相机标定。用标定板做张正友标定,得到相机内参和畸变系数;
- 第二步,投影仪标定。把投影仪当成一台“逆向相机”,投射特殊编码条纹,通过相位映射建立投影仪像素坐标和世界坐标的对应关系,得到投影仪的等效内参;
- 第三步,双目标定。确定相机坐标系和投影仪坐标系之间的旋转和平移;
- 第四步,三角化。对每个像素,已知它在相机中的坐标(u_c, v_c),以及由绝对相位推出的投影仪列坐标(u_p, v_p),两条光线在空间中交汇,最小二乘求出三维点。
核心三角化代码其实很短:
function P3d = triangulate_camera_projector(u_c, v_c, u_p, v_p, M_c, M_p) % M_c, M_p: 3x4投影矩阵,P = [X; Y; Z; 1] A = [u_c*M_c(3,:) - M_c(1,:); v_c*M_c(3,:) - M_c(2,:); u_p*M_p(3,:) - M_p(1,:); v_p*M_p(3,:) - M_p(2,:)]; [~, ~, V] = svd(A); P_h = V(:, end); P3d = P_h(1:3) / P_h(4); end这是DLT线性三角化,简单直接。有了这一步,就能把相位信息真正变成三维点云,这也是“结构光三维重建”里“三维”二字落地的关键。
6. 常见问题与排查技巧实录
6.1 典型问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 包裹相位图有大量椒盐噪声 | 光照不足、曝光不当、B过小 | 调节曝光,增大调制阈值,提高投影亮度 |
| 展开相位出现平行条纹断层 | 频率组设计不当,t_lms不够大 | 换更接近的频率组,先算等效周期 |
| 重建结果出现波浪形周期性误差 | 投影仪伽马非线性导致条纹非正弦 | 做gamma LUT校正,或尝试更高步数相移 |
| 物体边缘出现一团飞点 | 背景、阴影、环境光干扰 | 调制掩膜,深度域median滤波,边缘腐蚀 |
| 运动物体重建出现重影 | 相机和投影仪未同步 | 硬件触发,缩短曝光,减少帧间运动 |
| 参考平面相位残留下细条纹 | 条纹图间存在像素级错位 | 多帧平均,使用更高位深图像采集 |
6.2 我在实际调试中踩过的三个坑
第一个坑是投影仪伽马问题。我最早用的是一台普通办公投影仪,投射出来的条纹在电脑上看着是正弦,实际投影出来已经被伽马曲线扭曲成带平顶的“伪方波”。四步相移解出来的相位出现了周期性的波浪误差,重建后的物体表面像蒙了一层水波纹。折腾了快两天才定位到是伽马问题。解决方法是在投影前做一个灰度查找表校正:采集一系列已知灰度的投影图像,拟合出输出灰度对输入灰度的关系,然后反向做LUT。
第二个坑是物体表面局部过曝。零件上有一块高光反光区域,某幅条纹图在这里达到了灰度饱和,导致B计算异常,重建结果在该区域出现一个明显的凹陷。后来我在解相位之前先检查灰度是否饱和,把饱和像素直接标记为无效,才算解决。
第三个坑是参考平面和物体相位差超出标定范围。我用一个高度块标定k系数,随后扫描一个更高物体,结果物体顶部深度明显偏离。原因是相位-高度映射在小范围内近似线性,但超出标定范围后误差会累积。所以现在我做项目都坚持用多个已知高度的块做多点标定,而不是只用一个高度。
6.3 一个容易被忽略的工程细节:条纹方向
全文都在讲横向条纹,也就是条纹沿投影仪列方向变化,用来编码列坐标。这种方案只能得到投影仪的一个坐标,完整的三维重建还需要另一个坐标。工程上常见的做法是再投射一组纵向条纹,用同样的四步相移解出另一个方向的绝对相位,从而完整恢复投影仪的(u_p, v_p)坐标。
如果你的场景比较简单,也可以用“横向条纹+极线约束”省掉纵向条纹这组投影。但代价是代码复杂度明显增加。我的建议是第一次做项目,老老实实投双向条纹,东西先跑通了,再考虑优化帧数。
最后说点个人体会
我最早接触三频四步相移法,是在一个零件尺寸测量的项目上。当时花了整整一个多星期在unwrap上挣扎,问题一直不是算法理解不透,而是实际采到的条纹一点都不“正弦”——伽马畸变、环境光、投影仪噪声在里面搅和,教科书里干净的数学问题变得一团糟。后来我才意识到,结构光项目里真正的难点永远在设备和光路,算法反而是最可控的部分。
所以我特别推荐你现在就用这套仿真代码把整条链路跑通。先上调参的感觉,理解包裹相位和绝对相位长什么样,知道噪声是怎么一步步传播到深度误差里的。等上了真实设备,你至少能确定问题出在算法还是硬件,不至于像我当初一样四处乱撞。
下一步可以尝试的方向也很明确:把三频扩展成更多频率,或者换成互补相移法来进一步压制伽马误差;把静态场景的代码改成实时采集和重建;甚至把相位展开的引导逻辑换成深度学习的方法,这些都是可以继续玩下去的点。但万变不离其宗,先把这篇里的公式和代码吃透,结构光你就已经入了门。