C#生成正弦光栅投影图:多频相位三维重建核心实现
做结构光三维重建的人十有八九绕不开这么一步自己写代码生成一组正弦条纹投影图。很多同学算法公式背得滚瓜烂熟结果卡在怎么把数学公式变成一张能投影的图片上。这篇博文就把这块完全展开用C#实现多频相位三维重建里的正弦光栅投影图生成从灰度公式到内存写入从单张条纹到批量序列最后解码阶段怎么衔接也一并讲清楚。这个项目的定位是这样向被测物体投射一组频率不同、相位依次平移的正弦条纹相机同步采集变形条纹图通过相位变化反推物体表面高度。它适合正在做相位测量轮廓术PMP或者整体结构光三维重建的同学参考尤其适合那些已经在用C#写上位机、需要把光栅生成模块集成进现有采集程序的情况。代码部分可以直接拿走改改参数就能跑节省你从零造轮子的时间。1. 为什么多频相位方案绕不开高质量正弦光栅1.1 相位法为什么成为主流方案先梳理结构光三维重建的路线选择。最直观的方式是二值化编码也就是投射黑白格雷码图案让每个像素拿到一串二进制ID再通过查表建立匹配关系。这个方案有个显著短板编码粒度取决于投影图案的数量想要亚毫米级精度就得投射几十上百张编码图而且二值边界在物体表面会发生畸变重建出来的点云边缘经常带着锯齿状的毛刺。相位法的思路完全不同。它不直接告诉每个像素“你是第几号”而是用连续的余弦波把相位信息编码进整个视场借助正弦波在空间上的连续性让每个像素都能解算出一个平滑的相位值。有了相位值再通过标定把相位差映射到实际高度或深度坐标。相位法最大的优势是效率高通常三张或四张不同相移的条纹图就能恢复一张包裹相位图精度可以达到亚像素级别。这里有一个绕不开的前提相位精度直接取决于投影出去的正弦光栅是否真的“正弦”。灰度不均匀、量化粗糙、频率设计失误都会直接污染解算得到的相位图。所以光栅生成这一步不是随手画几条条纹而是整套多频相位方案的精度地基。地基歪了后面算法再漂亮也救不回来。1.2 单频条纹的先天缺陷与多频补偿策略如果只投射一组单频正弦条纹相机拍回来做相位解算时会遇到一个经典问题相位被atan2函数限制在负π到π之间也就是常说的包裹相位。真实物体表面高度差超过一个条纹周期后相位会反复折叠你无法直接判断某个点处于第几个周期。打个比方这就像钟表只能看到时针当前在表盘哪个位置却判断不了现在是上午还是下午。多频相位就是为了消除这种歧义。常见思路分两种。一种是多频分层先投射频率很低的条纹一个周期覆盖整个视场这段相位本身没有歧义然后用它去引导稍高频率的相位展开逐级往上推最终得到既无歧义又能体现细节的绝对相位。另一种是多频外差也叫双频外差投射两个频率非常接近的条纹让它们的包裹相位相减得到一个周期被大大拉长的“拍频相位”同样可以消除歧义。不管用哪种方案最终精度都落在最高频率那组条纹上而每一组条纹都是从正弦光栅生成代码开始的。频率怎么排、相位怎么移、灰度怎么量化这些参数直接决定整套系统的重建上限。我见过有人在解码阶段反复调试一周最后才发现问题出在生成光栅时周期数设成了非整数导致每张条纹图的首尾相位对不齐。2. 正弦光栅的数学模型与参数规划2.1 灰度公式逐项拆解A、B、f、φ正弦光栅的灰度公式看起来很简单但真到自己写代码容易忽略每一项的实际作用。I(x, y) A B * cos(2π * f * x φ)其中A是直流分量也叫背景光强决定了条纹图最暗处不会低于这个值。B是调制幅度也就是条纹对比度B不能大于A否则灰度会越过0到255的边界。实际工程里最常用的归一化配置是A等于0.5、B等于0.5换算到8位灰度就是127.5加上127.5乘以余弦值。频率f的单位是周期每像素通常用周期数除以图像宽度得到。相位φ是初始相位多步相移时每张图之间依次增加一个固定的相位偏移量。生成代码的核心循环非常简单double val 0.5 0.5 * Math.Cos(2 * Math.PI * freq * x phase); int gray (int)Math.Round(val * 255);注意这里必须用Math.Round四舍五入如果直接强转int等于截断小数部分条纹灰度会出现可察觉的阶梯状变化。别小看这一点多频相位重建时它会变成相位噪声的来源虽然单张图肉眼看不出问题但最终点云上会多出一层细密的高频噪声。直流分量和对比度也需要根据实际环境调整。标准条纹用0到255全幅但真实投影环境中全幅条纹很容易让相机在物体高光区域过曝。投影仪的暗场不纯黑、亮场不纯白所以我通常把灰度范围压缩到16到240甚至32到224。牺牲一点动态范围换来的是采集时不会饱和。这个经验是我在吃了几次高光过曝的亏之后总结出来的。条纹方向也要在生成时就确定。沿x方向做余弦变化得到的是竖直条纹用于编码x方向的相位沿y方向做余弦变化得到水平条纹。三维重建系统通常两个方向都会用到但单次实验可以先固定一个方向跑通流程。2.2 频率、周期数与相移步数的搭配原则生成光栅前先确定周期数也就是整幅图像包含多少个完整条纹周期。周期数乘以图像宽度才得到真正的频率f。第一个硬性约束是周期数尽量取整数原因有两个。第一投影仪和相机的像素网格是离散的非整数周期会让条纹在图像边缘出现半周期断层肉眼可能看不出来但解缠算法会在这个位置产生系统性偏差。第二多步相移恢复包裹相位时如果每张条纹图的相位基准不一致最终相位图会出现奇怪的倾斜条纹。频率不能设得太高。投影仪和相机的光学系统都有带宽限制周期只有两三个像素的细条纹投射出来直接糊成均匀灰色完全失去相位信息。按我的经验条纹周期至少要在10像素以上推荐区间是20到70像素。周期太小丢失细节周期太大又会导致视场内只有两三个完整周期高频信息不足所以实际中都靠多组频率搭配来兼顾覆盖范围和分辨率。相移步数常用三步和四步。三步相移每次移动三分之二π速度快公式简洁适合动态场景。四步相移每次移动二分之一π对投影非线性误差更不敏感适合静态高精度测量。代码层面两者的差别只是相移量数组不同建议把相移序列定义成数组生成时遍历数组即可以后想换步数只改配置。多频组合的常用方案列个表方便直接参考策略频率组合示例特点适用场景多频分层周期数 1 : 8 : 64 : 512逐级解缠级数越多越稳高精度静态测量双频外差周期 30 : 27 像素差频周期约270像素动态测量、投影张数少三频外差周期 28 : 24 : 21 像素两两外差后周期继续拉大精度与可靠性平衡数值不是金科玉律但思路要讲透。频率比越接近1外差放大倍数越大但噪声也被成倍放大。频率比太大差频周期不够长消歧义又不彻底。工程上外差频率比通常控制在1.1到1.3以内分层法的单级比例取4到8倍比较舒服。2.3 投影仪Gamma失真与文件格式选择投影仪不是理想的线性输出设备。给投影仪输入100灰度值它投射出来的实际光强并不是输入值除255的线性结果而是一条幂函数曲线典型Gamma值在2.2左右。这会导致理想的正弦波被压缩成类似方波的形状谐波分量大幅增加解算出来的相位带有周期性的波浪形误差。应对办法是在生成阶段做Gamma预校正按输出等于输入的1/Gamma次方反向补偿。如果不知道投影仪具体的Gamma值可以先投射一组灰阶图给相机拍再拟合灰度到光强的响应曲线取倒数作为校正表。这套方法不复杂但很多人会跳过直到重建平面出现波浪纹才回头处理。文件格式方面有一条明确建议光栅图不要存成JPG。JPG的有损压缩会在条纹边缘引入块状伪影相位图上直接表现为横向波纹。用PNG或者BMP无损格式全分辨率序列图也没多大体积硬盘完全不在乎这点空间。位深方面常见的商用投影仪支持8位灰度高端型号支持12位。代码里默认按8位写但灰度上限独立成一个参数以后换12位投影仪只需修改这个值。同时要留意生成程序连续跑几百张图如果以24位RGB方式存储内存占用会变为8位灰度图的三倍批量生成时容易触发内存压力。3. C#生成正弦光栅投影图的核心实现3.1 SetPixel为什么不靠谱第一次写光栅生成最自然的思路是调用Bitmap.SetPixel逐点写像素。1920乘1080的图SetPixel要调用超过两百万次托管方法每次都伴随边界检查和颜色转换跑下来一张图需要好几秒。生成一组三频三相移序列就是九张图几分钟耗进去这还只是整套系统的一小步。正确的做法是用LockBits锁定Bitmap的内存区域拿到指向像素缓冲区的指针然后直接通过字节数组写入数据。整个过程只做一次托管代码和本机代码的互操作性能提升几十倍。高分辨率下批量生成几百张序列图也能保持流畅。C#还有Parallel.For可以进一步并行化不过LockBits版本的性能已经足够满足日常需求。另外如果需要在多线程环境里生成图案不要直接并发操作Bitmap对象Bitmap内部状态不是线程安全的。推荐的做法是每个线程独立计算灰度字节数组最后统一拷贝到锁定缓冲区。3.2 完整可运行的C#核心代码下面是核心生成函数建议直接复制到你的项目里跑通。using System; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; public static class SinusoidalGrating { public static Bitmap Generate(int width, int height, double cyclesPerWidth, double phaseRad, double gamma 1.0, int minGray 16, int maxGray 240) { if (width 0 || height 0) throw new ArgumentException(图像尺寸必须为正数); if (cyclesPerWidth 0) throw new ArgumentException(周期数必须大于0); Bitmap bmp new Bitmap(width, height, PixelFormat.Format8bppIndexed); // 8位灰度图必须手动设置调色板 ColorPalette palette bmp.Palette; for (int i 0; i 256; i) { palette.Entries[i] Color.FromArgb(i, i, i); } bmp.Palette palette; Rectangle rect new Rectangle(0, 0, width, height); BitmapData bmpData bmp.LockBits(rect, ImageLockMode.WriteOnly, PixelFormat.Format8bppIndexed); int stride bmpData.Stride; byte[] buffer new byte[stride * height]; double frequency cyclesPerWidth / width; for (int y 0; y height; y) { int rowStart y * stride; for (int x 0; x width; x) { double val 0.5 0.5 * Math.Cos(2 * Math.PI * frequency * x phaseRad); if (Math.Abs(gamma - 1.0) 1e-6) val Math.Pow(val, 1.0 / gamma); val Math.Max(0.0, Math.Min(1.0, val)); int gray minGray (int)Math.Round(val * (maxGray - minGray)); buffer[rowStart x] (byte)Math.Max(0, Math.Min(255, gray)); } } Marshal.Copy(buffer, 0, bmpData.Scan0, buffer.Length); bmp.UnlockBits(bmpData); return bmp; } }几个关键点单独解释。Format8bppIndexed是8位灰度图的标准格式比24位RGB节省三分之二内存投影时也不会出现三个通道不同步的问题。调色板必须手动设置成线性0到255否则默认调色板颜色是乱序的生成的图显示出来会偏色。stride是每行扫描线的字节数。Bitmap为了内存对齐每行可能补满到4字节倍数写数据时必须用stride做行偏移不能直接用width乘y不然图像会变成斜的。minGray和maxGray默认是16到240这是按我自己的设备特性设置的。如果你用的投影仪和相机线性度好可以改回0到255但需要先做一次灰阶测试。gamma参数默认1.0表示不校正。如果开启传入1.8到2.4之间的值即可。这个值与投影仪本身实测的Gamma相关不要盲目套2.2。3.3 批量生成多频相移序列多频相位重建不会只生成一张图常规配置是三组频率、每组三步相移一共九张。写一个专门的批量函数是必要的。public static void GenerateSequence(string outputDir, int width, int height, double[] cyclesPerWidthArray, int phaseSteps, double gamma 1.0, int minGray 16, int maxGray 240) { if (Directory.Exists(outputDir)) Directory.Delete(outputDir, true); Directory.CreateDirectory(outputDir); for (int f 0; f cyclesPerWidthArray.Length; f) { double cycles cyclesPerWidthArray[f]; for (int p 0; p phaseSteps; p) { double phase 2 * Math.PI * p / phaseSteps; using (Bitmap bmp Generate(width, height, cycles, phase, gamma, minGray, maxGray)) { string fileName Path.Combine(outputDir, $freq{f:00}_phase{p:00}.png); bmp.Save(fileName, ImageFormat.Png); } } } }调用示例string baseDir D:\ProjectorPatterns\myProject; int width 1920, height 1080; double[] cycles { 1, 8, 64 }; int phaseSteps 3; GenerateSequence(baseDir, width, height, cycles, phaseSteps);这九张图里周期数为1的图案基本是整幅渐变肉眼看不到明显的条纹这是正常的它的作用是提供无歧义的初始相位。周期数为64的高频图才能清楚看到密集的正弦明暗条纹。文件名里带上频率和相移序号非常关键。以后相机采集、算法读取、数据保存都用这一套命名规则不容易错。序号要统一补零否则超过10帧后字典序会乱低频组和高频组的文件顺序会错位。批量生成时务必把Bitmap放进using块或者手动Dispose。一张图两兆多几十张下来如果释放不及时内存占用会持续上涨长时间运行的采集程序容易出现莫名其妙的卡顿和崩溃。4. 从光栅图到三维点云的多频相位衔接很多同学生成完光栅就不知道下一步该干嘛了。这里把解码链路完整梳理一遍重点解释每个参数在后面是怎么影响结果的。4.1 相移法恢复包裹相位对每个像素利用N步相移的灰度序列可以恢复该点的包裹相位。以四步相移为例四张图的光强分别是I1 C D cos(φ)I2 C D cos(φ π/2)I3 C D cos(φ π)I4 C D cos(φ 3π/2)化简后得到φ atan2(I4 - I2, I1 - I3)C#里直接用Math.Atan2结果落在负π到π之间这就是包裹相位。三步相移的公式则是φ atan2(√3 * (I1 - I3), 2 * I2 - I1 - I3)三步方案相位分辨率略低但对运动不敏感适合物体有一定动态变化的场景。四步方案精度高但多拍一张图且要求场景尽量静止。不管是几步相移生成的包裹相位图看起来像一圈一圈的等高线相邻区域之间存在2π跳变。生成光栅时选择的相移步数必须与这里解算时使用的方法严格对应否则公式算出来的相位全是错的。4.2 分层解缠与外差解缠公式拿到包裹相位后多频解包裹的核心就是求出每个像素的周期编号n然后补上2π乘以n。多频分层法的计算公式很直接Φ_high_unwrapped φ_high 2π * round( (k * Φ_low_unwrapped - φ_high) / (2π) )其中k等于高频频率除以低频频率Φ_low_unwrapped是低频已经解好的绝对相位。这个公式对每一级逐步应用1到8到64最终得到最高频条纹的绝对相位。这里有个关键提醒k的取值必须和生成光栅时的频率比严格一致。生成时周期数选了1、8、64解缠时k就要用8和64这两个倍率。我之前见过有人生成时周期数不小心填成1、7、64解缠代码里仍按8倍算最后重建出来全是跳变的台阶状断层排查了三天才定位到频率比不一致。外差法的思路建立在两个包裹相位的差上。φ12等于φ1减φ2再模2π当两个频率很接近时这个差值的等效波长被拉长许多倍。比如周期30和27像素的两个条纹差频周期约270像素这个尺度下视场中的周期数大大减少歧义基本消除。但外差会放大相位噪声频率比越接近1放大越明显所以实际工程上要折中选择。4.3 绝对相位映射到高度绝对相位换算成高度或三维坐标通常有两条路径。一种是标定查找表法测一个平面在不同高度下的相位分布拟合出相位到高度的映射曲线这种方法实现简单适合桌面级测量系统和固定工作距离。另一种是两步标定法分别标定投影仪和相机的内外参数再通过三角测量公式计算三维坐标灵活性更高适合任意姿态的测量。这一步已经超出光栅生成的范围但有一点要明确光栅的频率组合直接决定测量精度上限。位移精度大体对应“一个条纹周期换算多少毫米”的细化程度。高频条纹周期越小相位对高度变化越敏感系统精度越高。但如果高频条纹的信噪比不足解缠一旦出错整体精度反而会崩溃。多频方案本质上就是在“周期足够细”和“解缠足够稳”之间寻找平衡点。5. 实际操作中踩过的坑与排查实录5.1 重建表面周期性波纹先查投影链路最典型的故障现象重建一个标准平面点云上出现波浪状的起伏波纹频率和投射的条纹频率恰好一致。这几乎可以断定是投影仪Gamma失真造成的。正弦波被投影仪的非线性响应扭曲后解算出来的相位不再符合理想余弦模型。排查方法很有讲究。先不要急着改解缠算法而是验证光路中的波形成分。把相机对准纯平面投射一张均匀灰度和一张正弦条纹用FFT分析拍摄图像的频谱。如果基频之外出现了明显的二次、三次谐波基本可以确定是Gamma问题。解决方案就是在生成阶段做预校正或者在系统里维护一张自适应的灰度-光强查找表。5.2 高频条纹摩尔纹与频率上限当条纹周期小到接近投影仪或相机的像素尺寸时采样不足会引起摩尔纹和混叠。常见错误是盲目追求高频把周期压到四像素以下结果拍出来的条纹几乎无法辨认。我的工程准则是周期数不超过图像宽度的十分之一。1920宽的图周期数不超过192即最细条纹周期不小于10像素。如果确实需要更细的相位分辨率优先考虑升级投影仪或相机的分辨率而不是硬把条纹频率往上顶。硬件采样的极限就摆在那里软件再优化也突破不了。5.3 文件命名引起的相移顺序错乱批量生成时文件名如果写成freq1_phase10这种格式排序会出问题。序号10会排在序号2的前面因为字符串按字典序比较时“10”小于“2”。一旦采集程序按文件名排序加载相移顺序就会错乱解出来的相位完全无法使用。解决办法是生成时统一补零比如freq01_phase01到freq03_phase03代码里用D2格式化字符串即可。这个细节虽然小但在批量脚本里坑过很多人排查起来极其浪费时间。5.4 相位图出现圆环假象与频率单位错误如果你在验证包裹相位图时看到图像里出现同心圆而不是正常的平行条纹状跳变多半是生成代码把“周期数”直接当成数学公式中的f用了。周期数没有除以图像宽度导致实际频率比设计值大width倍条纹密集到无法辨认。处理办法很简单记住公式里f的单位是周期每像素周期数除以图像宽度才是真正的f。生成代码里已经做了这步换算如果你自己写的版本没有对比一下就能发现问题。5.5 生成步数与采集帧数不匹配生成代码里三步相移生成三张图相机采集程序却写死了采集四帧或者反过来。这类错误几乎在每个做结构光的群里都出现过。最稳妥的解决办法是让光栅图元信息以文件名或配置文件的方式传给采集程序而不是在采集端硬编码相移步数。花十分钟写一个简单的配置文件可以省下几个通宵的调试时间。文件名里已经带了freq和phase标记采集程序解析文件名就能自动知道这套序列有多少帧、每个文件对应的相移是多少。下面的速查表可以收藏遇到问题先对照一遍现象可能原因快速处置平面点云有周期性波浪纹投影仪Gamma非线性做Gamma预校正高频条纹出现摩尔纹周期太短采样不足降低频率或换硬件文件排序混乱序号未补零文件名统一补零包裹相位图呈同心圆频率单位用错周期数除以图像宽度相位图完全对不上生成与采集步数不一致用配置文件统一元信息做这个项目时我个人最深的体会是光栅生成代码本身不难难的是清楚知道每一条参数在整套系统里的角色。正弦光栅是多频相位三维重建的第一公里但这一公里走得稳后面整个链路都会顺畅。拿到一个新的测量环境我第一件事永远是投影一张光栅图到白色平面上用相机拍下来肉眼检查条纹的均匀性确认没有混叠、没有过曝然后才敢继续往下做标定和解算。这个习惯帮我挡掉了大量所谓的“疑难算法bug”。如果你还没把光栅生成这块吃透建议先照着上面代码把生成和验证流程跑通再往多频解缠的方向深入那会比直接堆算法代码高效得多。