前言

在普通双目视觉中,三维重建的核心问题是左右图像匹配。
如果物体表面纹理丰富,双目匹配相对容易;但如果物体表面是白色、光滑、低纹理区域,传统匹配就很容易失败。

为了解决这个问题,可以引入结构光投影。

本文介绍一种常见方案:

双目相机 + 格雷码条纹

它的核心思想是:投影仪向物体表面投射格雷码条纹,让物体表面带有主动编码;左右相机分别采集这些编码图案,再根据编码结果建立左右像素匹配,最后使用双目三角测量恢复三维点云。

本文主要包括:

  • 双目相机标定
  • 格雷码条纹生成
  • 左右相机同步采集
  • 格雷码解码
  • 左右图像编码匹配
  • 视差图生成
  • 三维点云重建
  • Python 和 C++ 核心代码

一、为什么使用格雷码条纹?

普通二进制编码在相邻区域切换时,可能会有多个 bit 同时变化。
例如:

3 = 011
4 = 100

从 3 到 4,三个 bit 都发生变化。
如果图像边缘、投影模糊或曝光不稳定,就很容易解码错误。

格雷码的特点是:相邻编码之间只有一个 bit 不同。

例如:

000
001
011
010
110
111
101
100

这样即使在条纹边界附近,也能降低突变带来的误码风险。

简单理解:

格雷码条纹负责给物体表面编号
双目相机负责根据编号进行匹配
Q 矩阵负责把视差转换成三维点

二、整体重建流程

双目相机 + 格雷码条纹重建流程如下:

1. 标定双目相机
2. 获取左右相机内参、畸变参数、R、T、Q
3. 对左右图像进行立体校正
4. 生成格雷码条纹图和反码图
5. 投影仪依次投射格雷码图案
6. 左右相机同步采集每一张图
7. 对左右相机图像分别解码
8. 根据格雷码结果建立左右像素匹配
9. 计算视差图
10. 使用 reprojectImageTo3D() 生成点云

这里需要注意一点:
在这个方案中,投影仪主要用于提供主动编码,三维坐标仍然由双目相机三角测量得到。

三、Python 生成格雷码条纹

假设投影仪分辨率为:

1280 x 720

为了增强鲁棒性,可以设置一个条纹宽度,比如每 4 个像素作为一个编码单元:

stripe_width = 4

代码如下:

import cv2
import numpy as np
import os
import math

width = 1280
height = 720
stripe_width = 4

output_dir = "graycode_patterns"
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_pattern = 255 - pattern

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

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

为什么要生成反码图?

原图:判断某个 bit 是 0 还是 1
反码:辅助判断该像素是否可靠

如果原图和反码亮度差很小,说明这个位置可能处于阴影、反光或低对比度区域,可以直接标记为无效点。

四、Python 格雷码解码

假设左相机采集到:

left_gray_0.png
left_gray_0_inv.png
left_gray_1.png
left_gray_1_inv.png
...

解码代码如下:

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 = 9

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

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

code_left, mask_left = decode_graycode(left_normal_paths, left_inverse_paths)
code_right, mask_right = decode_graycode(right_normal_paths, right_inverse_paths)

解码后的 code_leftcode_right 表示每个像素对应的投影条纹编号。

五、左右图像格雷码匹配

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

(x_left, y)

只需要在右图同一行搜索相同编码的点:

(x_right, y)

视差为:

disparity = x_left - x_right

示例代码如下:

def match_by_graycode(code_left, code_right, mask_left, mask_right,
                      min_disparity=1, max_disparity=256):
    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 mask_left[y, x] == 0:
                continue

            target_code = code_left[y, x]

            x_start = max(0, x - max_disparity)
            x_end = max(0, x - min_disparity)

            candidates = []

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

                if code_right[y, xr] == target_code:
                    candidates.append(xr)

            if len(candidates) == 0:
                continue

            xr_best = int(np.mean(candidates))

            disparity[y, x] = x - xr_best
            valid[y, x] = 255

    return disparity, valid

这个版本是基础写法,主要用于理解流程。
实际项目中通常还会加入:

视差范围限制
左右一致性检查
编码边界过滤
连通域过滤
中值滤波
亚像素优化

格雷码本身更偏“整数级编码”,如果想获得更高精度,常见做法是:

格雷码 + 相移法

格雷码负责确定周期编号,相移法负责提供亚像素精度。

六、由视差图恢复三维点云

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

import cv2
import numpy as np

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("graycode_stereo.ply", point_cloud)

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

七、C++ 生成格雷码条纹

下面是 C++ 版本生成格雷码条纹的核心代码。

#include <opencv2/opencv.hpp>
#include <iostream>
#include <cmath>
#include <filesystem>

int binaryToGray(int n)
{
    return n ^ (n >> 1);
}

int main()
{
    int width = 1280;
    int height = 720;
    int stripeWidth = 4;

    int numCodes = static_cast<int>(std::ceil(width * 1.0 / stripeWidth));
    int numBits = static_cast<int>(std::ceil(std::log2(numCodes)));

    std::filesystem::create_directories("graycode_patterns");

    for (int bit = 0; bit < numBits; bit++)
    {
        cv::Mat pattern(height, width, CV_8UC1);

        int shift = numBits - 1 - bit;

        for (int y = 0; y < height; y++)
        {
            for (int x = 0; x < width; x++)
            {
                int codeIndex = x / stripeWidth;
                int gray = binaryToGray(codeIndex);
                int value = (gray >> shift) & 1;

                pattern.at<uchar>(y, x) = value ? 255 : 0;
            }
        }

        cv::Mat inversePattern = 255 - pattern;

        cv::imwrite("graycode_patterns/gray_" + std::to_string(bit) + ".png", pattern);
        cv::imwrite("graycode_patterns/gray_" + std::to_string(bit) + "_inv.png", inversePattern);
    }

    std::cout << "格雷码位数: " << numBits << std::endl;

    return 0;
}

八、C++ 格雷码解码

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

int grayToBinary(int gray)
{
    int binary = gray;

    while (gray >>= 1)
    {
        binary ^= gray;
    }

    return binary;
}

void decodeGraycode(
    const std::vector<std::string>& normalPaths,
    const std::vector<std::string>& inversePaths,
    cv::Mat& code,
    cv::Mat& validMask,
    int threshold = 15
)
{
    cv::Mat first = cv::imread(normalPaths[0], cv::IMREAD_GRAYSCALE);

    code = cv::Mat::zeros(first.size(), CV_32S);
    validMask = cv::Mat::ones(first.size(), CV_8U) * 255;

    int numBits = static_cast<int>(normalPaths.size());

    std::vector<cv::Mat> normalImgs;
    std::vector<cv::Mat> inverseImgs;

    for (int i = 0; i < numBits; i++)
    {
        normalImgs.push_back(cv::imread(normalPaths[i], cv::IMREAD_GRAYSCALE));
        inverseImgs.push_back(cv::imread(inversePaths[i], cv::IMREAD_GRAYSCALE));
    }

    for (int y = 0; y < first.rows; y++)
    {
        for (int x = 0; x < first.cols; x++)
        {
            int grayValue = 0;
            bool valid = true;

            for (int bit = 0; bit < numBits; bit++)
            {
                int normal = normalImgs[bit].at<uchar>(y, x);
                int inverse = inverseImgs[bit].at<uchar>(y, x);

                if (std::abs(normal - inverse) < threshold)
                {
                    valid = false;
                    break;
                }

                int bitValue = normal > inverse ? 1 : 0;
                grayValue = (grayValue << 1) | bitValue;
            }

            if (valid)
            {
                code.at<int>(y, x) = grayToBinary(grayValue);
                validMask.at<uchar>(y, x) = 255;
            }
            else
            {
                validMask.at<uchar>(y, x) = 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. 看解码图

可以把 code_left 归一化显示出来:

show = cv2.normalize(code_left, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8)
cv2.imwrite("left_code_show.png", show)

正常情况下,编码图应该呈现连续变化。
如果出现大量断裂、黑块或跳变,说明解码质量不好。

2. 看有效区域 mask

mask_leftmask_right 可以反映哪些像素解码可靠。
如果有效区域太少,通常说明:

投影亮度不足
环境光太强
相机曝光不合适
物体表面反光
反码图差异太小

3. 看视差图

视差图应该整体连续。
如果出现大量孤立点,说明匹配条件太宽,或者无效点没有过滤干净。

4. 看点云

点云中重点观察:

平面是否弯曲
边缘是否破碎
是否有明显飞点
尺寸是否接近真实尺寸
深度方向是否存在分层

格雷码条纹由于本身是离散编码,点云精度通常不如相移法细腻。
如果需要更高精度,可以使用“格雷码 + 相移法”的组合方案。

十一、常见问题

1. 格雷码条纹和多频相移有什么区别?

格雷码是离散编码,主要优点是稳定、抗歧义能力强。
多频相移是连续相位编码,主要优点是精度高、点云更细腻。

简单理解:

格雷码:稳定确定区域编号
相移法:精确计算亚像素位置

2. 为什么要投影反码图?

反码图可以帮助判断每个 bit 是否可靠。
如果某个像素在原图和反码图中的亮度差很小,就说明这个位置不适合解码。

3. 只投影竖直格雷码够吗?

如果使用双目立体校正后的左右匹配,竖直格雷码通常就可以用于行方向匹配。
如果需要恢复完整投影仪二维坐标,或者使用相机 + 投影仪三角测量,则还需要投影水平格雷码。

4. 为什么点云精度不高?

常见原因包括:

格雷码条纹宽度太大
相机标定误差较大
双目立体校正不准确
左右相机采集不同步
投影边界模糊
没有做亚像素优化

5. 格雷码适合什么场景?

格雷码适合需要稳定匹配、物体纹理较少、对速度要求不是特别高的场景。
如果追求高精度三维测量,建议使用:

格雷码 + 四步相移

总结

本文介绍了双目相机 + 格雷码条纹三维重建的基本流程。整体可以概括为:

  1. 对双目相机进行标定和立体校正
  2. 生成格雷码条纹图和反码图
  3. 左右相机同步采集投影图案
  4. 分别对左右图像进行格雷码解码
  5. 根据相同编码在同一极线上进行匹配
  6. 计算视差图
  7. 使用 reprojectImageTo3D() 恢复三维点云

双目格雷码结构光的核心思想是:

用投影仪主动制造可识别纹理
用格雷码建立稳定对应关系
用双目几何恢复真实三维坐标

相比普通双目视觉,它对低纹理物体更加友好;相比纯相移法,它的解码更直观、更稳定。实际项目中,如果想兼顾稳定性和精度,通常会把格雷码和相移法结合起来使用。

Logo

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

更多推荐