前言

在前面的文章中,我们介绍了单目相机标定、双目相机标定,以及单目相机 + 条纹结构光的三维重建方法。

如果希望进一步提高三维重建精度,可以使用:

双目相机 + 多频相移结构光

这种方案结合了双目视觉和结构光编码的优点:

  • 双目相机提供稳定的几何约束
  • 结构光条纹提供高精度像素匹配
  • 多频相移解决相位周期歧义问题
  • 最终可以得到更稳定、更稠密的三维点云

本文主要介绍双目相机 + 多频相移结构光重建的基本流程,包括:

  • 双目相机标定
  • 多频正弦条纹生成
  • 四步相移法计算包裹相位
  • 多频相位展开
  • 左右相机相位匹配
  • 双目三角测量
  • Python 和 C++ 核心代码

一、为什么双目相机还需要结构光?

普通双目相机可以通过左右图像匹配计算视差,然后恢复深度。

但是在实际场景中,传统双目匹配容易遇到这些问题:

低纹理区域难匹配
重复纹理容易误匹配
反光区域匹配不稳定
边缘区域视差跳变明显

结构光的作用就是主动给物体表面增加纹理。
投影仪投射正弦条纹后,物体表面每个位置都会带有特定的相位信息。

这样一来,左右相机不再只依赖自然纹理,而是可以通过相位进行高精度匹配。

可以简单理解为:

双目相机负责三角测量
结构光条纹负责建立稳定匹配
多频相移负责消除相位歧义

二、整体重建流程

双目相机 + 多频相移结构光的完整流程如下:

1. 标定左相机和右相机
2. 获取左右相机内参、畸变参数、R、T
3. 对双目相机进行立体校正
4. 生成多组不同频率的正弦条纹图
5. 投影仪依次投射条纹图
6. 左右相机同步采集条纹图
7. 分别计算左右相机的包裹相位
8. 使用多频方法进行相位展开
9. 根据展开相位建立左右像素匹配
10. 使用双目三角测量恢复三维点云

和普通双目重建相比,这里的关键变化是:

传统双目:通过图像灰度或特征点匹配
结构光双目:通过相位值匹配

相位匹配通常比普通灰度匹配更稳定,尤其适合表面纹理较少的物体。

三、多频相移法原理

四步相移法可以计算单一频率下的包裹相位。
但是包裹相位范围只有:

[-π, π]

它只能表示一个周期内的位置,无法判断当前点属于第几个周期。

如果只使用一个频率,条纹周期越小,精度越高,但相位歧义越严重;条纹周期越大,相位歧义越少,但精度又会降低。

所以工程中常用多频相移:

低频条纹:用于确定大范围位置
中频条纹:用于辅助展开
高频条纹:用于获得高精度相位

例如可以设置三个周期:

period1 = 128
period2 = 64
period3 = 32

低频相位比较稳定,高频相位精度更高。
通过低频相位逐级展开高频相位,就可以得到高精度的绝对相位。

四、Python 生成多频条纹图

下面代码生成三组不同周期的竖直正弦条纹,每组包含四张相移图。

import cv2
import numpy as np
import os

width = 1280
height = 720

periods = [128, 64, 32]
phase_shifts = [0, np.pi / 2, np.pi, 3 * np.pi / 2]

output_dir = "multi_frequency_patterns"
os.makedirs(output_dir, exist_ok=True)

x = np.arange(width)
xx = np.tile(x, (height, 1))

for period in periods:
    for i, shift in enumerate(phase_shifts):
        img = 127.5 + 127.5 * np.cos(2 * np.pi * xx / period + shift)
        img = img.astype(np.uint8)

        filename = f"{output_dir}/vertical_p{period}_{i}.png"
        cv2.imwrite(filename, img)

如果需要同时获得水平方向相位,也可以生成水平条纹:

y = np.arange(height)
yy = np.tile(y.reshape(-1, 1), (1, width))

for period in periods:
    for i, shift in enumerate(phase_shifts):
        img = 127.5 + 127.5 * np.cos(2 * np.pi * yy / period + shift)
        img = img.astype(np.uint8)

        filename = f"{output_dir}/horizontal_p{period}_{i}.png"
        cv2.imwrite(filename, img)

在双目结构光中,如果主要通过左右图像的行方向进行匹配,竖直条纹通常更加常用。

五、Python 计算包裹相位

假设左相机采集到某个频率下的四张条纹图:

left_p32_0.png
left_p32_1.png
left_p32_2.png
left_p32_3.png

计算包裹相位代码如下:

import cv2
import numpy as np

def compute_wrapped_phase(image_paths):
    imgs = []

    for path in image_paths:
        img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
        imgs.append(img.astype(np.float32))

    I1, I2, I3, I4 = imgs

    wrapped_phase = np.arctan2(I4 - I2, I1 - I3)

    modulation = 0.5 * np.sqrt((I4 - I2) ** 2 + (I1 - I3) ** 2)

    return wrapped_phase, modulation

这里除了计算相位,还计算了调制度 modulation
调制度可以用来过滤无效区域:

阴影区域
过暗区域
过曝区域
低对比度区域

例如:

wrapped_phase, modulation = compute_wrapped_phase(paths)

mask = modulation > 10

只在有效区域内进行后续相位展开和三维重建,可以明显减少飞点。

六、多频相位展开

多频相位展开的思想是用低频相位辅助高频相位展开。

假设低频绝对相位为 phase_low,高频包裹相位为 phase_high_wrapped,两个频率的周期分别为:

period_low
period_high

可以通过下面的方式估计高频相位所在的周期编号:

def unwrap_with_reference(phase_high_wrapped, phase_low_unwrapped, period_low, period_high):
    scale = period_low / period_high

    k = np.round((scale * phase_low_unwrapped - phase_high_wrapped) / (2 * np.pi))

    phase_high_unwrapped = phase_high_wrapped + 2 * np.pi * k

    return phase_high_unwrapped

完整的三频展开示例:

periods = [128, 64, 32]

phase_128, mod_128 = compute_wrapped_phase([
    "left_p128_0.png",
    "left_p128_1.png",
    "left_p128_2.png",
    "left_p128_3.png"
])

phase_64, mod_64 = compute_wrapped_phase([
    "left_p64_0.png",
    "left_p64_1.png",
    "left_p64_2.png",
    "left_p64_3.png"
])

phase_32, mod_32 = compute_wrapped_phase([
    "left_p32_0.png",
    "left_p32_1.png",
    "left_p32_2.png",
    "left_p32_3.png"
])

phase_128_unwrapped = np.unwrap(phase_128, axis=1)

phase_64_unwrapped = unwrap_with_reference(
    phase_64,
    phase_128_unwrapped,
    128,
    64
)

phase_32_unwrapped = unwrap_with_reference(
    phase_32,
    phase_64_unwrapped,
    64,
    32
)

mask = (mod_128 > 10) & (mod_64 > 10) & (mod_32 > 10)

最终 phase_32_unwrapped 就是高频展开相位。
它既保留了高频条纹的精度,又通过低频条纹解决了周期歧义问题。

七、左右相机相位匹配

双目相机经过立体校正后,左右图像的对应点理论上位于同一行。

因此,对于左图中的某个像素点:

(x_left, y)

只需要在右图同一行寻找相位相同或最接近的点:

(x_right, y)

视差为:

disparity = x_left - x_right

示例代码如下:

def phase_match_row(phase_left, phase_right, mask_left, mask_right):
    h, w = phase_left.shape
    disparity = np.zeros((h, w), np.float32)
    valid = np.zeros((h, w), np.uint8)

    for y in range(h):
        right_phase_row = phase_right[y]
        right_mask_row = mask_right[y]

        for x in range(w):
            if not mask_left[y, x]:
                continue

            target_phase = phase_left[y, x]

            candidates = np.where(right_mask_row > 0)[0]
            if len(candidates) == 0:
                continue

            phase_diff = np.abs(right_phase_row[candidates] - target_phase)
            best_index = np.argmin(phase_diff)
            xr = candidates[best_index]

            if x > xr:
                disparity[y, x] = x - xr
                valid[y, x] = 255

    return disparity, valid

实际工程中,为了提高速度和稳定性,通常会加入:

搜索范围限制
视差范围限制
左右一致性检查
相位差阈值
亚像素插值

例如,如果知道视差范围大概在 10 ~ 200,就不需要在整行搜索。

八、由视差恢复三维坐标

完成双目立体校正后,OpenCV 会得到一个重投影矩阵 Q
有了视差图,就可以直接恢复三维坐标:

points_3d = cv2.reprojectImageTo3D(disparity, Q)

z = points_3d[:, :, 2]

valid_depth = (valid > 0) & (disparity > 0) & (z > 0) & (z < 2000)

point_cloud = points_3d[valid_depth]

如果想保存为 PLY 点云文件:

def save_ply(filename, points):
    with open(filename, "w") as f:
        f.write("ply\n")
        f.write("format ascii 1.0\n")
        f.write(f"element vertex {len(points)}\n")
        f.write("property float x\n")
        f.write("property float y\n")
        f.write("property float z\n")
        f.write("end_header\n")

        for p in points:
            f.write(f"{p[0]} {p[1]} {p[2]} {p[2]}\n")

save_ply("structured_light_stereo.ply", point_cloud)

生成的点云可以使用 CloudCompare 或 MeshLab 打开查看。

九、C++ 版本相位计算代码

下面是 C++ 版本四步相移法计算包裹相位的代码。

#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>

cv::Mat computeWrappedPhase(const std::vector<std::string>& imagePaths, cv::Mat& modulation)
{
    std::vector<cv::Mat> imgs;

    for (const auto& path : imagePaths)
    {
        cv::Mat img = cv::imread(path, cv::IMREAD_GRAYSCALE);
        if (img.empty())
        {
            std::cout << "读取失败: " << path << std::endl;
            return cv::Mat();
        }

        img.convertTo(img, CV_32F);
        imgs.push_back(img);
    }

    cv::Mat I1 = imgs[0];
    cv::Mat I2 = imgs[1];
    cv::Mat I3 = imgs[2];
    cv::Mat I4 = imgs[3];

    cv::Mat numerator = I4 - I2;
    cv::Mat denominator = I1 - I3;

    cv::Mat wrappedPhase;
    cv::phase(denominator, numerator, wrappedPhase, false);

    cv::Mat a = I4 - I2;
    cv::Mat b = I1 - I3;
    cv::magnitude(a, b, modulation);
    modulation = modulation * 0.5;

    return wrappedPhase;
}

使用方式:

int main()
{
    cv::Mat modulation;

    std::vector<std::string> paths = {
        "left_p32_0.png",
        "left_p32_1.png",
        "left_p32_2.png",
        "left_p32_3.png"
    };

    cv::Mat wrappedPhase = computeWrappedPhase(paths, modulation);

    cv::Mat phaseShow;
    cv::normalize(wrappedPhase, phaseShow, 0, 255, cv::NORM_MINMAX);
    phaseShow.convertTo(phaseShow, CV_8U);

    cv::imwrite("wrapped_phase_cpp.png", phaseShow);

    return 0;
}

十、C++ 多频相位展开核心代码

cv::Mat unwrapWithReference(
    const cv::Mat& phaseHighWrapped,
    const cv::Mat& phaseLowUnwrapped,
    double periodLow,
    double periodHigh
)
{
    cv::Mat phaseHighUnwrapped(
        phaseHighWrapped.size(),
        CV_32F
    );

    double scale = periodLow / periodHigh;

    for (int y = 0; y < phaseHighWrapped.rows; y++)
    {
        for (int x = 0; x < phaseHighWrapped.cols; x++)
        {
            float high = phaseHighWrapped.at<float>(y, x);
            float low = phaseLowUnwrapped.at<float>(y, x);

            float k = std::round((scale * low - high) / (2.0 * CV_PI));

            phaseHighUnwrapped.at<float>(y, x) = high + 2.0f * CV_PI * k;
        }
    }

    return phaseHighUnwrapped;
}

三频展开流程:

cv::Mat mod128, mod64, mod32;

cv::Mat phase128 = computeWrappedPhase(paths128, mod128);
cv::Mat phase64 = computeWrappedPhase(paths64, mod64);
cv::Mat phase32 = computeWrappedPhase(paths32, mod32);

// 简化示例:真实工程中建议使用更稳定的二维展开或格雷码辅助
cv::Mat phase128Unwrapped = phase128.clone();

for (int y = 0; y < phase128.rows; y++)
{
    cv::Mat row = phase128Unwrapped.row(y);

    for (int x = 1; x < row.cols; x++)
    {
        float prev = row.at<float>(0, x - 1);
        float curr = row.at<float>(0, x);

        if (curr - prev > CV_PI)
        {
            row.at<float>(0, x) -= 2.0f * CV_PI;
        }
        else if (curr - prev < -CV_PI)
        {
            row.at<float>(0, x) += 2.0f * CV_PI;
        }
    }
}

cv::Mat phase64Unwrapped = unwrapWithReference(
    phase64,
    phase128Unwrapped,
    128.0,
    64.0
);

cv::Mat phase32Unwrapped = unwrapWithReference(
    phase32,
    phase64Unwrapped,
    64.0,
    32.0
);

十一、C++ 根据视差恢复点云

假设已经通过相位匹配得到视差图 disparity,并且双目标定时已经得到重投影矩阵 Q

cv::Mat points3D;

cv::reprojectImageTo3D(
    disparity,
    points3D,
    Q,
    true
);

std::vector<cv::Point3f> pointCloud;

for (int y = 0; y < points3D.rows; y++)
{
    for (int x = 0; x < points3D.cols; x++)
    {
        float d = disparity.at<float>(y, x);
        cv::Vec3f p = points3D.at<cv::Vec3f>(y, x);

        if (d <= 0)
        {
            continue;
        }

        if (p[2] <= 0 || p[2] > 2000)
        {
            continue;
        }

        pointCloud.emplace_back(p[0], p[1], p[2]);
    }
}

保存 PLY 文件:

#include <fstream>

void savePLY(const std::string& filename, const std::vector<cv::Point3f>& points)
{
    std::ofstream ofs(filename);

    ofs << "ply\n";
    ofs << "format ascii 1.0\n";
    ofs << "element vertex " << points.size() << "\n";
    ofs << "property float x\n";
    ofs << "property float y\n";
    ofs << "property float z\n";
    ofs << "end_header\n";

    for (const auto& p : points)
    {
        ofs << p.x << " " << p.y << " " << p.z << "\n";
    }

    ofs.close();
}

十二、重建效果怎么判断?

1. 看调制度图

调制度越高,说明条纹对比度越明显。
如果调制度图大面积偏暗,说明条纹采集质量不好。

常见原因包括:

投影亮度不足
环境光太强
物体表面过暗
相机曝光不合适

2. 看包裹相位图

包裹相位应该呈现规律的周期变化。
如果出现大量断裂、噪声、黑块,说明相位计算不稳定。

3. 看展开相位图

展开相位应该整体连续。
如果展开相位突然出现明显跳变,通常说明多频展开失败。

4. 看视差图

相位匹配得到的视差图应该连续、平滑。
如果视差图有很多孤立点,通常说明匹配阈值太宽或无效区域没有过滤干净。

5. 看点云

点云中需要重点观察:

平面是否弯曲
边缘是否破碎
是否有大量飞点
深度是否有明显分层
尺寸是否接近真实值

十三、常见问题

1. 双目结构光和单目结构光有什么区别?

单目结构光通常把投影仪当成一个反向相机,需要标定相机和投影仪之间的几何关系。
双目结构光则主要依靠左右相机进行三角测量,投影仪更多用于提供稳定纹理和相位编码。

2. 多频相移相比单频相移有什么优势?

单频相移只能得到包裹相位,容易出现周期歧义。
多频相移可以利用低频条纹确定大范围位置,再用高频条纹获得更高精度。

3. 为什么左右相机要同步采集?

因为每一张条纹图对应一个确定的相移状态。
如果左右相机采集不同步,左图和右图对应的条纹相位就不一致,后续匹配会出错。

4. 为什么点云会有很多飞点?

常见原因包括:

相机标定不准确
双目立体校正误差较大
条纹图过曝或欠曝
物体表面反光
多频相位展开错误
左右相位匹配错误
视差范围没有限制

5. 投影仪需要标定吗?

如果使用“双目相机 + 投影仪辅助纹理”的方案,三维重建主要由左右相机完成,投影仪可以不参与三角测量。
如果使用“相机 + 投影仪”作为几何重建系统,则需要标定投影仪,并求出投影仪与相机之间的外参。

总结

本文介绍了双目相机 + 多频相移结构光三维重建的基本流程。整体可以概括为:

  1. 对双目相机进行标定和立体校正
  2. 投影多组不同频率的正弦条纹图
  3. 左右相机同步采集条纹图
  4. 使用四步相移法计算包裹相位
  5. 使用多频方法展开高频相位
  6. 通过左右相位匹配得到视差图
  7. 结合双目标定得到的 Q 矩阵恢复三维点云

双目结构光的核心思想是:
用结构光解决匹配问题,用双目几何解决三维重建问题。

相比普通双目视觉,它对低纹理物体更加友好;相比单目结构光,它又可以利用双目系统提供更稳定的空间几何约束。实际项目中,只要双目标定准确、采集同步稳定、条纹曝光合适,多频相移结构光可以获得比较高精度的三维重建结果。

Logo

免费领 150 小时云算力,进群参与显卡、AI PC 幸运抽奖

更多推荐