简介:面向结构光三维测量与光栅投影技术研究人员,这份资源以四步相移法为核心,提供3种频率下的相移算法实现,解决了多频外差相位解算与三维重建的编程落地问题。压缩包仅含1个MATLAB脚本(.m),大小1KB,代码精简,适合作为算法验证或二次开发的起点。目前已有1003人学习,参考价值较高。脚本通过生成并处理四幅相位差依次为0、π/2、π、3π/2的条纹图,结合3种频率组合,可完成相位主值计算与展开,为后续高度映射和点云生成提供基础。对于从事光学测量、数字光栅投影或结构光成像的科研与工程人员,该文件能帮助快速理解四步相移算法流程,减少重复coding成本,同时便于按需修改频率参数,适配不同测量场景。
1. 3种频率4步相移法:结构光测量里最耐用的三维重建路径
做着做着就会遇到“相位展开炸了”这种事。对精度要求高的测量场景,比如反光零件表面、带深沟槽的工件,单频四步相移解出来的包裹相位会在2π跳变处把三维结果撕成一层一层,换个空间展开算法也只是把错误沿路径传得更远。3种频率4步相移法把这个问题压到了采集端:对每个频率各投4张正弦光栅图,用四步相移解包裹相位,再用3个频率做层级展开得到连续相位,最后结合标定参数映射出三维坐标。它比格雷码加相移少投一半图案,又比单频多频混合方案更容易调通,在结构光测量、光栅测量这类工程里属于长期能用的基线方案。这篇按能复现的最小闭环来写:相位怎么解、频率怎么定、标定怎么做、东西出来怎么验。
2. 四步相移的光强模型与包裹相位计算
2.1 为什么四步相移是最常用的采集策略
相移法的基础假设是正弦光栅经物体表面调制后,相机看到的灰度仍近似正弦。投影图案相位每平移2π,相机灰度序列就相当于对固定相位点做了多次采样。这些采样值在三角函数正交性下能解出相位,采样点数决定了谐波误差的抑制能力。三步相移每帧间隔2π/3,四步相移间隔π/2,十二步相移间隔π/6。采样越多抗噪越好,代价是采集时间线性增加,投影仪和相机同步的时间窗也越紧。四步相移的特别之处在于:它只需要4帧,就能在相位域里抵消二次、三次和五次谐波误差,这些谐波主要来自投影仪非线性Gamma响应和相机传感器响应不一致。所以在结构光测量相关的工程里,“4步”大概率是默认值,先按4帧跑通,再根据噪声水平考虑是否加到8步或12步。
单频四步相移本身解决不了大深度范围测量。包裹相位每隔2π折叠一次,表面深度一旦超过一个相位周期,重建结果就出现整条断层。于是3种频率4步相移法把精度和范围拆开:低频负责无歧义范围,高频负责细分辨率,中间靠层级展开桥接。第一步要做的仍然是把单频的四步相移算对,再谈多频展开。
2.2 用NumPy实现四步相移:从光强帧到包裹相位
四步相移4帧的光强模型写作:
I1 = A + B cos(φ) I2 = A + B cos(φ + π/2) I3 = A + B cos(φ + π) I4 = A + B cos(φ + 3π/2)
A是背景和物体表面反射的直流分量,B是光栅调制幅度,φ就是需要恢复的相位。两两相减消去直流项后,φ = arctan2(I4 - I2, I1 - I3)。实现时把4帧堆成数组一次算完,避免在Python里做像素级循环。
import numpy as np def four_step_phase(frames): """ frames: (4, H, W) float32 按相移 0, pi/2, pi, 3pi/2 顺序排列 返回: wrapped: 包裹相位,范围 (-pi, pi] modulation: 调制幅度,等于 B 的估计值 """ I1, I2, I3, I4 = frames.astype(np.float32) numerator = I4 - I2 # 2 * B * sin(phi) denominator = I1 - I3 # 2 * B * cos(phi) wrapped = np.arctan2(numerator, denominator) modulation = 0.5 * np.hypot(numerator, denominator) return wrapped, modulation这段代码的核心是用arctan2而不是arctan,因为只有arctan2才能根据分子分母的符号把相位落到(-π, π]的四个象限里,单独用arctan会丢失符号信息,造成整行相位错位。modulation是比相位本身更值得先看的输出:它是B的估计值,反映该像素上光栅条纹的对比度。黑色表面、阴影、过曝区域的B会显著偏低。实际操作中我会统计整幅modulation的分位数,把低于20%分位的像素直接置为无效区,防止后面做频率展开时把坏点噪声当成有效相位。
2.3 光栅条纹生成与投影参数设置
条纹周期数和相移量必须在生成阶段就和后续算法对齐。生成正弦条纹时,用周期数而不是像素周期来描述更稳,因为结构光测量里分辨率直接和投影仪投出的条纹周期挂钩。
def generate_fringe(width, height, period_px, phase_shift): """ 生成竖直正弦条纹,宽度width,高度height period_px 为一个周期占据的像素数 phase_shift 为相移量,四步相移取 0, pi/2, pi, 3pi/2 """ x = np.arange(width) phase = (2.0 * np.pi * x / period_px + phase_shift) % (2.0 * np.pi) pattern = (0.5 + 0.5 * np.cos(phase)) * 255.0 return np.tile(pattern.reshape(1, -1), (height, 1)).astype(np.uint8) # 1280x800 投影分辨率,周期16像素,共80个周期 for k in range(4): fringe = generate_fringe(1280, 800, 16, k * np.pi / 2.0) # cv2.imwrite(f"fringe_p16_shift{k}.png", fringe)period_px一旦定下来,一个频率的总条纹数就是width / period_px,这个数必须是整数,否则图案本身在边界处闭合不了,解出来的相位在左右两端自带台阶。四步相移的phase_shift取0、π/2、π、3π/2,和2.2节的公式一一对应。如果采集时发现条纹变淡,优先怀疑投影仪或相机开启了自动增益,先关闭自动曝光和自动增益再重新采。相位测量到底选几步,取决于系统误差环境,常用组合对比如下。
| 相移方式 | 每频率图案数 | 误差抑制特点 | 适用场景 |
|---|---|---|---|
| 三步相移 | 3 | 对谐波误差较敏感,计算量小 | 快速预览、低精度点位 |
| 四步相移 | 4 | 可抵消3阶、5阶谐波误差 | 多数测量场景默认方案 |
| 八步/十二步相移 | 8或12 | 随机噪声进一步压低,耗时翻倍 | 高精度计量、静态测量 |
四步相移之所以能抵消3阶和5阶谐波,是因为这两类谐波在π/2均匀采样下满足正交消去条件。换成三步相移,就需要单独估计谐波系数才能达到同等效果,工程上很少有人愿意为这个复杂度买单。
3. 3种频率的相位展开:为什么是3种而不是2种或5种
3.1 单频与双频的局限
单频四步相移解出的包裹相位在每一行上呈锯齿状,展开就是把这些锯齿拼接成连续斜坡。空间邻域展开算法比如质量引导法,依赖相邻像素相位差小于π这个假设。工件表面一旦有台阶、孔洞、深槽或者强反射边缘,这个假设立刻失效,误差会沿展开路径像流水一样蔓延到整个平面。所以测量类结构光系统很少用单频空间展开。
双频方案通过两个频率的包裹相位差得到拍频相位。频率f1和f2的拍频等效频率是f2 - f1,误差放大系数是f2 / (f2 - f1)。比如频率6和7拍频,展开误差放大7倍,两个频率的相位噪声稍大一点,展开结果就布满毛刺。三频率层级展开则把放大过程拆成两级,每级的频率比控制在1.1以内,误差放大被压到十几倍而非几十上百倍。这就是“3种频率”在结构光测量里的真实意义:用两个邻频组成一组可靠的对,再把展开结果作为下一组的低频基准。
3.2 层级展开算法与频率组合参数表
层级展开的基本公式是:由低频率的展开相位φunwrapped,low和高频率包裹相位φwrapped,high,求高频条纹级次k = round((ratio·φunwrapped,low − φwrapped,high) / 2π),然后φunwrapped,high = φwrapped,high + 2πk。
def hierarchical_unwrap(wrapped_high, unwrapped_low, ratio): """ 用低频连续相位展开高频包裹相位 ratio 必须是 f_high / f_low wrapped_high 和 unwrapped_low 均为 (H, W) 数组 """ diff = unwrapped_low * ratio - wrapped_high k = np.round(diff / (2.0 * np.pi)) return wrapped_high + 2.0 * np.pi * k def multi_frequency_unwrap(wrapped_list, freq_list): """ wrapped_list: 按频率从低到高排列的包裹相位列表 freq_list: 对应条纹总数列表,例如 [6, 7, 8] 返回连续展开相位,形状与输入一致 """ unwrapped = wrapped_list[0].copy() for low, high, f_low, f_high in zip( wrapped_list[:-1], wrapped_list[1:], freq_list[:-1], freq_list[1:]): unwrapped = hierarchical_unwrap(high, unwrapped, f_high / f_low) return unwrapped这里的ratio必须严格等于相邻频率条纹总数之比,而且两端频率在整数上互质。常见错误是把某个频率设成另一频率的整数倍,比如16和32,那样拍频周期退化成低频自己,展开会整片错乱。频率组合我一般按测量深度和噪声水平来选择。
| 频率组合 | 展开误差放大 | 无歧义范围 | 推荐场景 |
|---|---|---|---|
| 6、7、8 | 7倍与8倍 | 全幅1个完整条纹 | 毛坯件、深槽结构 |
| 10、11、12 | 11倍与12倍 | 全幅1个完整条纹 | 常规机械零件 |
| 30、31、32 | 31倍与32倍 | 全幅1个完整条纹 | 低噪声光路、高精度表面 |
频率差越小,单级误差放大越小,展开越稳,代价是对相位噪声更敏感:噪声稍微超界,取整到错误的k,出现2π级跳变。选择频率时需要综合评估相机的信噪比和投影仪的对比度。这里还要注意无歧义范围:最高的频率组合不是越大越好,当高频条纹太密、投影仪分辨率有限,条纹对比度下降会反过来放大量化误差。
3.3 展开失败时的排查顺序
层级展开最典型的失败现象是重建三维点云上出现“撕裂”:某一小块区域的深度整体错了一个周期。先用modulation掩膜检查是不是阴影或吸光材料被错误纳入计算,再把没有掩膜、各频率的包裹相位逐列画出来对比,看是否在某个边界处出现了超过半个周期的扰动。还有一条常被忽略:投影图案本身如果经过了Gamma校正,解出的包裹相位会带系统性的非正弦误差。比较直接的规避方法是在投影之前对条纹做反Gamma校正,或者在标定阶段把这部分误差吸收进系统误差里。3种频率的图案总数是12张,相比格雷码加相移的30多张在采集速度上有明显优势;但如果现场光照变化剧烈,多投几帧的冗余反而成为负担,这时把展开容错优先级提到最高,选频差更小的组合比如30、31、32会更容易展开成功。
4. 从包裹相位到三维坐标:结构光系统的标定与投影流程
4.1 把投影仪当作逆相机来标定
拿到展开相位之后,三维重建还差最后一步:建立“像素坐标+相位”到三维点的映射。在这个体系里投影仪被当作一台“逆相机”:它的成像过程是相位到像素坐标的转换。标定时需要对投影仪生成一套标定图案,常见的做法是竖直方向投影正弦条纹,水平方向投影格雷码或者另一组正交条纹,这样相机画面里的每一个点都能解出对应的投影仪列坐标。配上已知的棋盘格或圆点标定板姿态,就能得到投影仪的内参、畸变系数和相对于相机的位姿。
相机和投影仪之间的几何关系等价于一个双目系统。相机图像坐标提供一条视线,投影仪列坐标提供该点对应的投影仪视线,两条直线在空间中的交点就是三维坐标。相位在这里的作用是亚像素级地给出投影仪坐标:一个条纹周期被相位细分到远小于一个像素的精度,因此结构光测量能达到的深度分辨率通常远高于单纯格雷码或二值条纹。
4.2 相机-投影仪标定的最小代码骨架
基于OpenCV做标定时,用stereoCalibrate把相机和投影仪当作左右目处理,需要先分别准备两边的角点。投影仪坐标对应的“图像点”是由相位换算出来的,不是直接读出来的。
import cv2 import numpy as np # obj_points: 三维标定板角点 # cam_corners: 相机图像角点 # proj_corners: 由相位换算得到的投影仪图像角点 # 两个内参初值由单目标定得到 ret, K_cam, dist_cam, K_proj, dist_proj, R, T = cv2.stereoCalibrate( obj_points, cam_corners, proj_corners, K_cam_init, dist_cam_init, K_proj_init, dist_proj_init, image_size, flags=cv2.CALIB_USE_INTRINSIC_GUESS )关键点是proj_corners必须和cam_corners一一对应,两个坐标系下的角点来自同一次采集。如果投影仪画面上某些区域处于相机盲区,对应的点对要整体剔除,否则立体标定会把这些离群点当成正常约束,导致旋转矩阵R出现可见的偏斜。标定完成后保存K_cam、dist_cam、K_proj、dist_proj、R、T六组数据,重建时直接加载。常见做法是把这些数据封装成一个校准类,在每次系统启动时重新加载,而不是每次重建都重新标定。
4.3 标定和重建中的关键参数与误差来源
重建流程中需要关心的参数不只有内参和外参,还有投影分辨率、条纹频率和标定板姿态数。标定板至少要覆盖视场中心、边缘和四个角,姿态在深度方向分散开,才能把外参的旋转和平移分量都约束住。常见做法是采集15到25组姿态,每组都同时完成相机和投影仪角点的自动提取。如果重建结果在视场中心准、边缘飞,多半是标定板没有覆盖到边缘区域;如果深度方向整体有系统性弯曲,优先怀疑畸变系数没有收敛。
| 误差来源 | 典型表现 | 排查建议 |
|---|---|---|
| 投影仪Gamma非线性 | 包裹相位出现周期性波纹 | 预标定Gamma并做逆变换 |
| 相机自动增益 | 条纹对比度浮动 | 固定曝光时间和增益 |
| 投影仪散焦 | 高频条纹对比度低 | 缩小频率组合或减少投影距离 |
| 标定板表面平整度 | 重建点云局部拱起 | 换陶瓷或石英标定板 |
投影仪的非线性响应在四步相移里虽然能消掉部分谐波,但残差仍然存在。误差表现是相位图中叠了一层低频波纹,展开后变成三维点云的面形弯曲。判断这类问题的方法是放一个高精度平面镜或平晶,重建后拟合平面看残差图,如果残差呈同心圆状或条纹状,就基本上可以确认是投影光路或者Gamma问题。这一类系统性误差很难完全消除,但可以在标定之后用平面残差做一次多项式面形补偿,补偿系数存在标定文件里。
5. 验证相位展开质量的3个检查项
5.1 跳变点统计:批量检测2π级错误
展开相位在正确情况下沿条纹方向应该是连续变化的,只有物体真实边缘才会让相位发生剧烈变化。要区分真实边缘和展开错误,可以在每个频率层单独做差分,统计差分绝对值超过π的像素数。
def count_phase_jumps(unwrapped_phase, axis=1): diff = np.diff(unwrapped_phase, axis=axis) jumps = np.abs(diff) > np.pi # 返回每行跳变数,方便进一步定位列位置 return np.sum(jumps, axis=axis)统计结果要和modulation掩膜交叉验证。真实边缘处的modulation往往因为遮挡而降得很低,展开错误的像素则通常落在有效区域内。如果跳变点分布在平坦表面内部,先降低modulation阈值重算一遍,再决定是不是频率组合选得太激进。
5.2 平面残差检验:用平晶给系统“照镜子”
重建一块标准平面,拟合出空间平面方程,统计每个点到拟合平面的距离RMS。RMS在0.05mm以下说明系统基本处于健康状态,0.1mm以上就需要检查投影仪温度漂移和标定参数是否过期。平晶覆盖区域至少占视场中央一半面积,这样拟合平面才不会被边缘畸变带偏。残差呈现同心圆状条纹时,优先怀疑投影光路引入的低频面形误差。
5.3 单点毛刺过滤:中值滤波只打坏点不打边缘
展开正确但仍有孤立的椒盐噪声时,常见做法是把重建点云里的单点毛刺按邻域中值替换掉。滤波内核选3x3,因为5x5会对真实锐边造成可感知的圆角。先用modulation掩膜把低置信像素排除,只对有效深度做中值,再对深度图做一次拉普拉斯检测,确认边缘区域没有被抹平。把每次标定的跳变点数量导成CSV存下来,连续几天做趋势对比,跳变数超过正常运行范围1.5倍时,优先检查投影光机温度和被测表面反光状态变化。
本文还有配套的精品资源,点击获取