做结构光三维测量或者干涉测量的人,对“四步相移+相位解包裹”这个组合应该都不陌生。四步相移负责把四幅条纹图变成一张包裹相位图,相位解包裹负责把这张锯齿状的相位图恢复成连续的物理量场。这两步串起来,才算是拿到了真正能用的相位数据。我手头有挺多C#上位机项目都在做这类计算,最开始用MATLAB验证算法,落地到C#的时候踩了不少坑,这篇就把完整的实现思路、代码细节、常见问题一起理清楚。适合正在做光学测量软件、需要把相移算法搬进C#工程里的同学参考。
1. 四步相移到底在算什么:条纹图到相位图
1.1 四幅条纹图携带着什么信息
结构光测量里,投影仪往被测物体表面投正弦条纹,相机同步采集反射回来的光强。对同一个像素点,它的光强可以写成这样:
I(x, y) = I'(x, y) + I''(x, y) * cos[φ(x, y) + δ]
其中 I'(x, y) 是背景光强,I''(x, y) 是调制强度,φ(x, y) 就是我们想求的相位,δ 是人为引入的相移量。问题的关键在于:我们只能看到光的强度 I,但背景光和调制光会混在信号里,直接拿单幅条纹图是解不出相位的。四步相移的做法很简单,就是在 δ 分别等于 0、π/2、π、3π/2 时各采集一幅图,得到四个方程:
I1 = I' + I'' * cos(φ)
I2 = I' + I'' * cos(φ + π/2) = I' - I'' * sin(φ)
I3 = I' + I'' * cos(φ + π) = I' - I'' * cos(φ)
I4 = I' + I'' * cos(φ + 3π/2) = I' + I'' * sin(φ)
联立一下就能把 I' 和 I'' 全部消掉,只剩下纯粹的相位项。原理上三步相移就够了,但四步的好处特别明显:首先是左右两项正好对称,背景光被完全抵消;其次是对相移器的非线性误差有一定抑制能力,是用得最广的配置。
1.2 包裹相位是怎么来的,为什么是锯齿状
从上面四个方程直接求相位,用反正切函数:
φ = atan2(I4 - I2, I1 - I3)
C# 里的 Math.Atan2 返回的范围是 (-π, π],注意不是 (0, 2π)。这意味着不管真实相位是多少度,反正切算出来的结果都只会落在这个区间里。如果真实相位超过 π,就会被“折叠”回来,形成锯齿状的条纹图案。这种折叠后的相位,专业叫法就是包裹相位(wrapped phase)。
我可以用一个生活化的类比帮助理解:相位就像钟表的时针,走完一圈(2π)就归零重新开始,你看到表盘上永远只有 0 到 12,但如果知道指针转了多少圈,才能还原真实的累计时间。解包裹干的事情,就相当于根据相邻位置指针的连续性,把隐藏的“圈数”找回来。
所以从四步相移得到的相位图,天然带着无数条跳变边界,每一条跳变都是相位从 π 突变成 -π 的 2π 跳变。这种图直接用于三维重建或者形变测量是不行的,必须经过相位解包裹处理。
2. C#实现四步相移:像素级计算的工程细节
2.1 图像加载与灰度数组转换
C# 里做图像处理,第一步是把位图转成像素数组。我最开始直接用 Bitmap.GetPixel 逐点取颜色,小图测试还行,一旦到了工业相机常见的 1200 万像素图,速度直接慢到没法忍。工程上要么用 System.Drawing 的 LockBits,要么直接接 OpenCvSharp 的 Mat。
这里提供一个用 LockBits 读取灰度图的工具方法,转换结果是 double[,],因为后面要参与浮点运算,用 float 精度在某些强噪声场景会有一点点误差累积,double 更稳妥:
public static double[,] BitmapToGrayDouble(Bitmap bmp) { int width = bmp.Width; int height = bmp.Height; var result = new double[height, width]; var rect = new Rectangle(0, 0, width, height); var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); unsafe { byte* ptr = (byte*)data.Scan0; int stride = data.Stride; for (int y = 0; y < height; y++) { byte* row = ptr + y * stride; for (int x = 0; x < width; x++) { byte b = row[x * 3]; byte g = row[x * 3 + 1]; byte r = row[x * 3 + 2]; result[y, x] = r * 0.299 + g * 0.587 + b * 0.114; } } } bmp.UnlockBits(data); return result; }如果你电脑上装了 OpenCvSharp,也可以直接把 Mat 转成 double 数组,代码量更少。重要的是统一数据类型,四幅图一定要保证尺寸完全一致,采集过程中相机不能有哪怕一个像素的偏移,否则相移公式就全错位了。
2.2 Atan2计算相位主值,别踩这几个坑
四步相移的核心计算代码其实很短,一个双层循环遍历像素:
public static double[,] ComputeWrappedPhase( double[,] i1, double[,] i2, double[,] i3, double[,] i4) { int height = i1.GetLength(0); int width = i1.GetLength(1); var phase = new double[height, width]; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { double sinPart = i4[y, x] - i2[y, x]; double cosPart = i1[y, x] - i3[y, x]; // 注意:Atan2的第一个参数是y方向(sin分量),第二个是x方向(cos分量) phase[y, x] = Math.Atan2(sinPart, cosPart); } } return phase; }这里有几个非常容易翻车的细节。第一,Atan2 的参数顺序是 (y, x),也就是 (sin分量, cos分量),写反了相位会变成 π - φ,整张相位图就错了。第二,计算出来的相位范围是 [-π, π],如果需要 [0, 2π) 的表示形式,对负值手动加 2π 就行。第三,相位计算前最好对每一幅图做饱和检测,只要有一个像素过曝或全黑,算出来的相位就不可信。
2.3 调制度:判断像素可不可信的第一道关
四步相移除了能得到相位,还能顺手算出一个非常重要的量——调制度(modulation):
γ = sqrt((I1 - I3)² + (I4 - I2)²) / 2
调制度反映的是这个像素上条纹的对比度。阴影区域、高光区域、黑色吸光物体表面,条纹对比度都很低,调制度也低,这些地方的相位是不可信的。我的习惯是每算完包裹相位,同时计算调制度图,然后用一个阈值生成掩膜(mask),把低质量像素从后续处理中排除掉。阈值怎么取?可以先算调制度图的直方图,选择双峰之间的谷底作为阈值,或者简单粗暴地取最大调制度的 10% 到 20%。这个掩膜在解包裹阶段要一直带进去,后面会细说。
3. 相位解包裹的核心原理:把2π跳变缝起来
3.1 解包裹的本质:求解整数k
包裹相位图里每个像素的真实相位可以表示为:
Φ(x, y) = φ(x, y) + 2π * k(x, y)
其中 k 是整数,代表这个像素经历了多少次 2π 折叠。解包裹算法要做的就是解出每个像素的 k。怎么解?依靠的是相位场的连续性假设:对于一个真实连续的物理量场,相邻像素之间的相位差不会太大。如果两个相邻像素的包裹相位差接近 π,那基本可以断定这里发生了折叠,需要在其中一个像素上加上或减去 2π 来修正。
这个过程为什么难?因为 k 的估计是一个路径依赖问题。你用不同的路径去遍历像素,得到的 k 可能不同,尤其在噪声大、有断点或者被测表面不连续的区域,错误会沿着展开路径一路传播。这就像拼拼图,某一块拼错了,后面所有依赖这一块的块都会跟着错。
3.2 先从最直观的行列扫描法开始
最容易理解的解包裹算法是行列扫描法。思路是:先沿每一行从左到右展开,再以第一行为基准把行与行之间对齐。实现代码如下:
public static double[,] UnwrapByRowColumn(double[,] wrapped) { int height = wrapped.GetLength(0); int width = wrapped.GetLength(1); var unwrapped = new double[height, width]; // 第一步:每一行单独解包裹 for (int y = 0; y < height; y++) { double offset = 0; unwrapped[y, 0] = wrapped[y, 0]; for (int x = 1; x < width; x++) { double diff = wrapped[y, x] - wrapped[y, x - 1]; if (diff > Math.PI) offset -= 2 * Math.PI; else if (diff < -Math.PI) offset += 2 * Math.PI; unwrapped[y, x] = wrapped[y, x] + offset; } } // 第二步:以第一行为基准,对齐每一行的整体偏移 for (int y = 1; y < height; y++) { double diff = unwrapped[y, 0] - unwrapped[0, 0]; double shift = Math.Round(diff / (2 * Math.PI)) * 2 * Math.PI; for (int x = 0; x < width; x++) unwrapped[y, x] -= shift; } return unwrapped; }这里有一个关键细节:判断 diff 时用的是原始包裹相位 wrapped[y, x] - wrapped[y, x-1],而不是已经加过 offset 的值。offset 是逐步累积的 2π 倍数修正量。我见过很多初版实现把判断写错,导致在某些行上每过一个像素就多跳一次 2π,结果展开出来全是斜条纹。写这个算法时建议单独用一个测试用例,构造相位从 -10π 到 10π 线性变化的一维数组,展开后看是否恢复成一条直线,这一关过了再上真实图像。
3.3 为什么行列扫描法会翻车
行列扫描法最大的问题在于误差传播是确定性的。只要某一行中间有 2 个相邻像素的相位差刚好接近 ±π,因为噪声影响判断错了一次,那么从这一点开始,右侧所有像素的 k 就全错了。更麻烦的是,错的不只这一行,第二步行对齐时如果参考的第一行本身就带着错误,错误还会跨行传染。
实际测量里噪声是无法避免的。投影仪或者屏幕的 gamma 非线性、镜头的轻微畸变、环境光的微小波动,都会在包裹相位里引入误差。一旦误差达到 π 附近,行列扫描法就开始在整幅图上制造“裂缝”。所以这个算法我只用来做原理演示和快速验证,绝不直接上产线。工程上还是得用下面这种带质量图引导的展开方式。
4. 质量图引导的洪水填充解包裹:工程可靠实现
4.1 质量图怎么构建:相位导数方差
质量图是一个二维数组,每个像素的值表示该位置的相位数据可靠程度。构造质量图的思路很多,最简单实用的是相位导数方差(Phase Derivative Variance,PDV):在一个小窗口内,统计水平方向和垂直方向相位导数的方差。方差小说明这个区域相位变化平滑、一致度高,可信度高;方差大说明这里要么有噪声,要么有断点,可信度低。
这里有一个细节:计算相位导数时,两个像素的包裹相位差一定要先做包裹归一化处理,把差值映射回 (-π, π],否则窗口跨越 2π 跳变时,导数会被错误地算成一个很大的数。归一化代码如下:
public static double WrapToPi(double value) { while (value > Math.PI) value -= 2 * Math.PI; while (value < -Math.PI) value += 2 * Math.PI; return value; }然后逐像素计算可靠度。窗口半径我常用 3,也就是 7x7 的窗口,计算量适中,平滑效果也够。窗口太小扛不住噪点,窗口太大又会把真实的细微结构抹掉,分辨率损失比较多。计算结果是方差,我进一步转成可靠度值:
reliability = 1 / (1 + variance)
这样每个像素的可靠度都在 (0, 1] 范围内:variance 为 0 的平滑区域可靠度接近 1,噪声区域可靠度趋近 0。
4.2 洪水填充展开流程
有了质量图,解包裹的过程就很清晰了。核心思想是:从质量最高的像素开始,沿着质量从高到低的顺序,像洪水一样向外扩散。每一步扩散时,待处理像素从已经展开完成的邻居里选一个质量最高、最可靠的作为参考,计算这个像素的 2π 偏移量,然后展开。这样做的好处很明显:误差总是从可靠区域向不可靠区域传播,而不可靠区域的误差对最终结果的影响也最小。
展开流程分这么几步:
- 找到全图可靠度最高的像素,作为种子点,直接展开(k=0)。
- 将种子点的四邻域加入一个优先队列,按可靠度从高到低排序(实际上用最大堆)。
- 循环弹出优先级最高的像素。在它已经展开完成的四邻域中,挑选可靠度最高者作为参考像素。
- 计算参考像素的展开相位与当前像素包裹相位的差值,四舍五入得到 2π 的整数倍,加上这个倍数完成展开。
- 把当前像素标记为已展开,将其未处理的四邻域加入优先队列。
- 重复直到队列为空。
4.3 完整C#代码与关键优化
.NET 6 及以上版本才内置 PriorityQueue,老工程的建议自己写一个简单的最小堆,或者用 List 加排序方式替代,不过性能差一些。我这里展示用 PriorityQueue 的完整实现。注意我用的是 -reliability 作为优先级,因为 PriorityQueue 是最小堆,取负后反而变成最大堆效果,保证每次弹出的是可靠度最高的像素。
public static double[,] UnwrapQualityGuided(double[,] wrapped, double[,] reliability) { int height = wrapped.GetLength(0); int width = wrapped.GetLength(1); var unwrapped = new double[height, width]; var state = new byte[height, width]; // 0=未访问, 1=在队列, 2=已展开 // 1. 找可靠度最高点作为种子 double bestReliability = double.MinValue; int seedX = 0, seedY = 0; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { if (reliability[y, x] > bestReliability) { bestReliability = reliability[y, x]; seedX = x; seedY = y; } } } unwrapped[seedY, seedX] = wrapped[seedY, seedX]; state[seedY, seedX] = 2; var pq = new PriorityQueue<(int x, int y), double>(); int[] dx = { 1, -1, 0, 0 }; int[] dy = { 0, 0, 1, -1 }; // 2. 种子点四邻域入队 for (int i = 0; i < 4; i++) { int nx = seedX + dx[i]; int ny = seedY + dy[i]; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; if (state[ny, nx] != 0) continue; pq.Enqueue((nx, ny), -reliability[ny, nx]); state[ny, nx] = 1; } // 3. 主循环 while (pq.Count > 0) { var p = pq.Dequeue(); int x = p.x; int y = p.y; // 在已展开的四邻域里找可靠度最高的参考像素 double reference = 0; bool hasReference = false; double bestRefReliability = double.MinValue; for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; if (state[ny, nx] != 2) continue; if (reliability[ny, nx] > bestRefReliability) { bestRefReliability = reliability[ny, nx]; reference = unwrapped[ny, nx]; hasReference = true; } } if (hasReference) { double diff = reference - wrapped[y, x]; double k = Math.Round(diff / (2 * Math.PI)); unwrapped[y, x] = wrapped[y, x] + k * 2 * Math.PI; } else { // 理论上不会出现,保险起见直接用包裹相位 unwrapped[y, x] = wrapped[y, x]; } state[y, x] = 2; // 未访问的四邻域入队 for (int i = 0; i < 4; i++) { int nx = x + dx[i]; int ny = y + dy[i]; if (nx < 0 || nx >= width || ny < 0 || ny >= height) continue; if (state[ny, nx] != 0) continue; pq.Enqueue((nx, ny), -reliability[ny, nx]); state[ny, nx] = 1; } } return unwrapped; }这段代码有几个容易写错的地方。第一,入队时就标记 state = 1 而不是在出队时标记,防止同一个像素被多个邻居重复入队;出队后再标记 state = 2。第二,找参考像素时一定要找 state == 2 的邻居,也就是已经完成展开计算的像素,如果你是照着一个嵌入式平台的伪代码改的,很多实现里在这里会简化成“找个已访问像素”,那就会引用还没算出来的值,产生脏数据。第三,Math.Round 默认是银行家舍入,这里其实无所谓,因为偏移量在 π 附近的整数倍附近正常情况不会正好处于 0.5 的边界。
4.4 性能优化与多线程
对于 1920x1080 的相位图,上面的实现用了 4 邻域优先队列展开,实测在普通配置下大概需要 200 到 500 毫秒,取决于质量图和噪声情况。作为对比,行列扫描法基本是秒出,所以如果只是离线验证算法,怎么快怎么来;如果要用到实时三维测量流水线里,就要抠性能了。
二维数组的索引访问有边界检查,在循环体内部频繁访问会产生不小开销。我的优化方式是先把 double[,] 转成一维 double[],按 y * width + x 的索引方式访问,循环内部少了很多 GetLength 和边界检查。C# 里一维数组的遍历性能远好于多维数组,尤其在 Release 编译时差距更明显。另一个优化点是把优先队列的元素打包成一个结构体而不是元组,避免大量装箱和元组分配。元素量在百万级时,这种细节能把展开时间干掉一半。
5. 常见问题排查与调试经验
5.1 解包裹后出现条纹残留
展开完之后,相位图在某些区域还残留着明显的条状跳变,这种问题基本都能归到两类原因:一类是这些区域的真实相位本身就不连续,比如被测物体有一个明显的台阶,物理上相位就是突变的,空间解包裹算法在这里无法还原;另一类是噪声导致的,局部像素的包裹相位误差超过了 π/2,展开时被错误地加上或减去了一个 2π。
排查办法很简单:把解包裹结果减去包裹相位,得到的 2π*k 图可视化。如果 k 图里在正常平滑区域出现孤立的梯度突变,说明是噪声问题;如果突变沿着物体边缘形成一条线,那大概率是物理不连续。噪声问题可以通过提高质量图窗口尺寸、降低掩膜阈值、预处理滤波来解决;物理不连续就只能换时间相位解包裹方案,代码不管怎么写都救不回来。
5.2 坏点和掩膜的处理顺序
实际图像里背景区域、阴影区域、高反光区域都是没有有效条纹的,这些区域的包裹相位是随机噪声,相位导数方差极大,可靠度极低。如果完全不管,洪水填充最终会尽力穿过这些区域,但穿过时参考了一个噪声像素,就可能把整片区域的 k 都带错。
我实践下来的标准流程是:先计算包裹相位和调制度,用调制度阈值生成一个二值掩膜,把这个掩膜传入可靠度计算中,把不可信像素的可靠度强制设为 0。然后在洪水填充时,从种子点开始的扩展会被可靠度为 0 的像素挡在边界上,天然不会跨过低质量区域。这种情况下,每个连通的质量良好区域会被独立展开,但它们之间的 k 基准可能不一致。如果整幅图只需要相对相位(比如形变测量),这没问题;如果需要绝对相位基准,就要人为指定一个参考平面连通各区域。
5.3 从C#工程角度看性能与集成
C#做算法验证和集成非常舒服,毕竟上位机、UI、相机 SDK、数据存储都是 C# 生态,算法直接嵌进去就完事。但如果你的相机是 GigE 工业相机,采集频率 60 帧每秒,四步相移的每一组图像都要实时计算,这种情况下建议把相移和解包裹放到后台线程里算,用 Channel 或者 BlockingCollection 做队列,界面线程只负责显示结果。我在一个项目里就是这么干的,采集线程把四幅图塞进队列,计算线程取出来算完再发到显示线程,整个流水线能跑到 12 帧左右。后来进一步优化把图像缩放成半分辨率来做展开,速度翻了一倍,精度损失也还能接受。
5.4 问题速查表
| 现象 | 可能原因 | 处理方案 |
|---|---|---|
| 解包裹后大量斜条纹 | 行列扫描法的误差传播 | 换质量图引导展开 |
| 局部出现孤立 2π 台阶 | 相位噪声导致 k 判断错误 | 增大质量图窗口,预处理去噪 |
| 物体边缘辐射状错乱 | 边缘低调制度区域干扰 | 用调制度生成掩膜,强制可靠度为 0 |
| 展开结果整体偏置 | 种子点或参考基准不同 | 用统一参考平面校准绝对值 |
| 计算慢,卡顿明显 | 二维数组遍历 + 队列分配 | 转一维数组,使用结构体队列 |
| 相位图显示全是黑白噪点 | 未做归一化显示 | 将相位归一化到 0-255 再显示 |
6. 一点横向扩展:空间展开之外的选择
6.1 什么场景不适用空间解包裹
前面讲的洪水填充法属于空间相位解包裹,它的前提是被测相位场连续。一旦碰到物体表面有大台阶、深孔、遮挡,或者想测一块由多个独立部件组成的物体时,空间连续性假设就不成立了。精度要求很高的工业测量场景经常遇到这个问题,比如测量一个带凹槽的金属件,凹槽两侧的相位差可能好几个 2π,空间算法根本分不清。这种时候就得换思路。
6.2 时间相位解包裹思路
时间相位解包裹和空间解包裹的区别在于:不再依赖空间邻域,而是对每个像素单独处理。常见做法是投射多种不同频率的条纹(比如双频外差),每个像素在不同频率下得到不同的包裹相位,通过频率之间的差值来直接计算出这个像素的绝对相位。这个方法对表面不连续完全不敏感,因为每个像素的计算都是独立的。代价是要额外投射几组条纹图,采集时间增加,不适合高速动态场景。
我个人的结论是:如果项目里被测物体表面连续、环境受控,空间解包裹完全够用,先试质量图引导法;如果后期上了更复杂的工件,再考虑双频外差也不迟。两者的 C# 代码可以共用前面写的四步相移部分,只是后面展开环节的实现分支不同。
最后再分享一个我自己调试时的习惯:解包裹前先对包裹相位做一次复数域中值滤波。把每个像素的相位转成 (cosφ, sinφ),把这两个分量当作复数图像做中值滤波,然后再用 Atan2 转回相位。这个操作能有效去掉极性噪声,又不会像直接对相位值做中值滤波那样在 2π 跳变处产生失真。我用这个技巧在好多张真实条纹图上验证过,展开的成功率提升明显,代码量也不大,强烈建议你试试。