前言

在普通双目视觉中,三维重建依赖左右图像匹配。
但对于光滑物体、白色物体、低纹理物体来说,图像中几乎没有稳定特征点,传统双目匹配很容易失败。

为了解决这个问题,可以使用主动结构光方案:

双相机 + 格雷码 + 多频相移

它的核心思想是:

投影仪给物体表面增加编码纹理
格雷码负责确定条纹周期编号
多频相移负责提供高精度相位
双相机负责通过视差恢复三维坐标

相比单纯双目视觉,这种方式对低纹理物体更加友好;相比单纯格雷码,它又能获得更细腻的点云精度。

一、为什么光滑物体难重建?

光滑物体常见问题如下:

表面纹理少,左右图像难匹配
局部高光反射,图像容易过曝
条纹在反光区域发生断裂
黑色或透明区域调制度很低
边缘区域容易出现飞点

普通双目算法依赖自然纹理,而光滑物体表面没有足够纹理。
结构光的作用就是主动制造可识别的图案,让物体表面“临时拥有纹理”。

对于光滑物体,比较推荐的组合是:

格雷码 + 多频相移

格雷码解决“属于哪个条纹周期”的问题,多频相移解决“周期内部精确位置”的问题。

二、整体重建流程

完整流程如下:

1. 标定双相机
2. 获取左右相机内参、畸变参数、R、T、Q
3. 对左右图像进行立体校正
4. 生成格雷码图案和反码图案
5. 生成多频正弦相移条纹
6. 投影仪依次投射所有图案
7. 左右相机同步采集图像
8. 对左右图像分别进行格雷码解码
9. 对左右图像分别计算包裹相位
10. 使用格雷码和多频信息展开相位
11. 根据绝对相位进行左右匹配
12. 生成视差图
13. 使用 Q 矩阵恢复三维点云

这里投影仪主要承担“主动编码”的作用。
最终三维坐标仍然由双相机的几何关系计算得到。

三、格雷码 + 多频相移的作用

1. 格雷码的作用

格雷码用于确定条纹所在的大区域编号。

例如:

像素点 A 属于第 35 个编码区域
像素点 B 属于第 36 个编码区域

它的优点是稳定、抗歧义能力强,但缺点是精度通常只能达到条纹宽度级别。

2. 多频相移的作用

多频相移用于计算连续相位。

常见四步相移公式为:

phase = atan2(I4 - I2, I1 - I3)

相移法可以获得亚像素级别的位置,但单频相位存在周期歧义。

3. 二者结合

组合之后可以理解为:

格雷码:告诉你在哪个周期
相移法:告诉你在周期内部的精确位置
多频:让相位展开更加稳定

这对光滑物体非常有用,因为光滑表面没有自然纹理,只能依靠主动投影图案建立匹配关系。

四、Python 生成格雷码图案

import cv2
import numpy as np
import os
import math

width = 1280
height = 720
stripe_width = 8

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

num_codes = math.ceil(width / stripe_width)
num_bits = math.ceil(math.log2(num_codes))

def binary_to_gray(n):
    return n ^ (n >> 1)

x = np.arange(width)
code_index = x // stripe_width
gray_code = binary_to_gray(code_index)

for bit in range(num_bits):
    shift = num_bits - 1 - bit

    bit_values = (gray_code >> shift) & 1
    pattern = np.tile(bit_values, (height, 1)) * 255
    pattern = pattern.astype(np.uint8)

    inverse = 255 - pattern

    cv2.imwrite(f"{output_dir}/gray_{bit}.png", pattern)
    cv2.imwrite(f"{output_dir}/gray_{bit}_inv.png", inverse)

print("格雷码位数:", num_bits)
print("图案数量:", num_bits * 2)

反码图的作用是判断当前 bit 是否可靠。
如果原图和反码图亮度差很小,说明该位置可能在阴影、反光或低对比度区域,可以标记为无效点。

五、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 = "patterns_phase"
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)

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

对于双相机结构光重建,竖直条纹比较常用,因为立体校正后左右匹配主要沿水平方向搜索。

六、采集图像时的注意事项

针对光滑物体,采集质量非常关键。

建议:

降低相机曝光,避免高光过曝
关闭自动曝光和自动白平衡
投影仪亮度不要过高
尽量减少环境光干扰
左右相机必须同步采集
每张投影图案都要采集左图和右图
必要时使用偏振片降低反光

采集目录可以设计为:

left/gray_0.png
left/gray_0_inv.png
left/phase_p128_0.png

right/gray_0.png
right/gray_0_inv.png
right/phase_p128_0.png

左右图像必须一一对应,否则后续相位匹配和视差计算都会出错。

七、Python 格雷码解码

import cv2
import numpy as np

def gray_to_binary(gray):
    binary = gray.copy()
    shift = 1

    while True:
        shifted = binary >> shift
        if not np.any(shifted):
            break

        binary ^= shifted
        shift <<= 1

    return binary

def decode_graycode(normal_paths, inverse_paths, threshold=15):
    first = cv2.imread(normal_paths[0], cv2.IMREAD_GRAYSCALE)
    h, w = first.shape

    gray_value = np.zeros((h, w), np.int32)
    valid_mask = np.ones((h, w), np.uint8) * 255

    for normal_path, inverse_path in zip(normal_paths, inverse_paths):
        normal = cv2.imread(normal_path, cv2.IMREAD_GRAYSCALE).astype(np.int16)
        inverse = cv2.imread(inverse_path, cv2.IMREAD_GRAYSCALE).astype(np.int16)

        diff = np.abs(normal - inverse)
        bit = normal > inverse

        valid_mask[diff < threshold] = 0

        gray_value = (gray_value << 1) | bit.astype(np.int32)

    binary_value = gray_to_binary(gray_value)

    return binary_value, valid_mask

使用方式:

num_bits = 8

left_normal = [f"left/gray_{i}.png" for i in range(num_bits)]
left_inverse = [f"left/gray_{i}_inv.png" for i in range(num_bits)]

right_normal = [f"right/gray_{i}.png" for i in range(num_bits)]
right_inverse = [f"right/gray_{i}_inv.png" for i in range(num_bits)]

code_left, mask_gray_left = decode_graycode(left_normal, left_inverse)
code_right, mask_gray_right = decode_graycode(right_normal, right_inverse)

code_leftcode_right 表示每个像素对应的格雷码周期编号。

八、Python 计算包裹相位和调制度

def compute_wrapped_phase(paths):
    imgs = []

    for path in 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

调制度可以用来过滤光滑物体上的低质量区域。

phase_128_l, mod_128_l = compute_wrapped_phase([
    "left/phase_p128_0.png",
    "left/phase_p128_1.png",
    "left/phase_p128_2.png",
    "left/phase_p128_3.png"
])

phase_64_l, mod_64_l = compute_wrapped_phase([
    "left/phase_p64_0.png",
    "left/phase_p64_1.png",
    "left/phase_p64_2.png",
    "left/phase_p64_3.png"
])

phase_32_l, mod_32_l = compute_wrapped_phase([
    "left/phase_p32_0.png",
    "left/phase_p32_1.png",
    "left/phase_p32_2.png",
    "left/phase_p32_3.png"
])

mask_phase_left = (mod_128_l > 10) & (mod_64_l > 10) & (mod_32_l > 10)

对于光滑物体,调制度阈值很重要。
阈值太低会保留大量噪声点;阈值太高会丢失有效区域。

九、多频相位展开

多频展开的基本思路是:用低频相位辅助高频相位。

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

三频展开示例:

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

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

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

如果结合格雷码,也可以用格雷码周期编号直接构造绝对相位:

absolute_phase = phase_32_l + 2 * np.pi * code_left

实际项目中更推荐:

格雷码确定大周期
多频相移修正展开结果
调制度 mask 过滤低质量点

这样对光滑物体会更稳定。

十、左右相位匹配生成视差图

双相机完成立体校正后,左右对应点位于同一行。
对于左图像素:

(x_left, y)

在右图同一行搜索相同编码、相位最接近的像素:

(x_right, y)

视差为:

disparity = x_left - x_right

示例代码:

def match_gray_phase(code_left, code_right,
                     phase_left, phase_right,
                     mask_left, mask_right,
                     min_disp=1, max_disp=256,
                     phase_threshold=0.2):
    h, w = code_left.shape

    disparity = np.zeros((h, w), np.float32)
    valid = np.zeros((h, w), np.uint8)

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

            target_code = code_left[y, x]
            target_phase = phase_left[y, x]

            x_start = max(0, x - max_disp)
            x_end = max(0, x - min_disp)

            best_x = -1
            best_diff = 1e9

            for xr in range(x_start, x_end + 1):
                if not mask_right[y, xr]:
                    continue

                if code_right[y, xr] != target_code:
                    continue

                diff = abs(phase_right[y, xr] - target_phase)

                if diff < best_diff:
                    best_diff = diff
                    best_x = xr

            if best_x >= 0 and best_diff < phase_threshold:
                disparity[y, x] = x - best_x
                valid[y, x] = 255

    return disparity, valid

组合 mask:

mask_left = (mask_gray_left > 0) & mask_phase_left
mask_right = (mask_gray_right > 0) & mask_phase_right

disparity, valid = match_gray_phase(
    code_left,
    code_right,
    phase_32_unwrapped_left,
    phase_32_unwrapped_right,
    mask_left,
    mask_right
)

实际工程中还可以加入:

左右一致性检查
视差范围限制
相位差阈值
中值滤波
连通域过滤
亚像素拟合

十一、由视差恢复三维点云

双目标定和立体校正后,可以得到重投影矩阵 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]}\n")

save_ply("smooth_object_gray_phase.ply", point_cloud)

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

十二、C++ 四步相移相位计算

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

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

    for (const auto& path : paths)
    {
        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::magnitude(numerator, denominator, modulation);
    modulation = modulation * 0.5;

    return wrappedPhase;
}

十三、C++ 多频相位展开

cv::Mat unwrapWithReference(
    const cv::Mat& phaseHighWrapped,
    const cv::Mat& phaseLowUnwrapped,
    double periodLow,
    double periodHigh
)
{
    cv::Mat result(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));

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

    return result;
}

十四、C++ 视差转点云

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]);
    }
}

十五、针对光滑物体的优化建议

1. 控制曝光

光滑物体最容易出现局部高光。
一旦过曝,相位和格雷码都会失效。

建议:

关闭自动曝光
降低投影亮度
缩短相机曝光时间
避免图像灰度接近 255

2. 使用偏振片

可以在投影仪和相机前加偏振片,降低镜面反射。
这对塑料、金属、陶瓷等光滑表面比较有帮助。

3. 使用调制度 mask

调制度低的区域不要参与重建。
这些区域通常是阴影、反光、黑色表面或条纹对比度不足的位置。

4. 增加多曝光采集

如果物体同时存在亮区和暗区,可以采集多组曝光图:

低曝光保留高光区域
中曝光保留普通区域
高曝光保留暗色区域

然后选择每个像素最可靠的曝光结果参与相位计算。

5. 做后处理过滤

点云生成后可以进行:

中值滤波
半径滤波
统计离群点滤波
深度范围过滤
连通域过滤

这样可以明显减少光滑物体边缘和反光区域的飞点。

十六、常见问题

1. 为什么光滑物体重建出来有洞?

通常是因为局部过曝、反光或调制度太低。
这些区域无法可靠解码,所以会被 mask 过滤掉。

2. 格雷码和多频相移都需要吗?

如果只用格雷码,结果稳定但精度不够细。
如果只用相移法,精度高但容易出现周期歧义。
二者结合更适合光滑物体的稳定高精度重建。

3. 为什么点云边缘飞点多?

边缘区域容易同时受到阴影、遮挡、相位断裂和左右视角差异影响。
建议加入视差范围限制、左右一致性检查和连通域过滤。

4. 投影仪是否需要标定?

如果三维坐标由双相机视差计算,投影仪可以不参与几何重建。
如果使用相机 + 投影仪进行三角测量,则需要标定投影仪。

5. 光滑金属物体能直接重建吗?

难度比较高。
强反光金属表面会导致条纹严重过曝或畸变,建议使用偏振片、多曝光,必要时对物体表面做消光处理。

总结

本文介绍了面向光滑物体的双相机 + 格雷码 + 多频相移结构光三维重建方案。

整体流程可以概括为:

  1. 双相机标定和立体校正
  2. 投影格雷码图案和多频相移条纹
  3. 左右相机同步采集图像
  4. 使用格雷码确定条纹周期编号
  5. 使用多频相移计算高精度绝对相位
  6. 根据编码和相位进行左右匹配
  7. 得到视差图并通过 Q 矩阵恢复三维点云

这套方案的核心是:

格雷码解决稳定性
多频相移解决精度
双相机解决三维几何
调制度和曝光控制解决光滑表面噪声

对于光滑、低纹理物体,普通双目视觉往往难以稳定匹配,而主动结构光可以显著提高匹配质量。实际项目中,标定精度、同步采集、曝光控制和反光抑制,是决定最终点云质量的关键。

Logo

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

更多推荐