【OpenCV实战】双相机 + 格雷码 + 多频相移结构光:针对光滑物体的三维重建
前言
在普通双目视觉中,三维重建依赖左右图像匹配。
但对于光滑物体、白色物体、低纹理物体来说,图像中几乎没有稳定特征点,传统双目匹配很容易失败。
为了解决这个问题,可以使用主动结构光方案:
双相机 + 格雷码 + 多频相移
它的核心思想是:
投影仪给物体表面增加编码纹理
格雷码负责确定条纹周期编号
多频相移负责提供高精度相位
双相机负责通过视差恢复三维坐标
相比单纯双目视觉,这种方式对低纹理物体更加友好;相比单纯格雷码,它又能获得更细腻的点云精度。
一、为什么光滑物体难重建?
光滑物体常见问题如下:
表面纹理少,左右图像难匹配
局部高光反射,图像容易过曝
条纹在反光区域发生断裂
黑色或透明区域调制度很低
边缘区域容易出现飞点
普通双目算法依赖自然纹理,而光滑物体表面没有足够纹理。
结构光的作用就是主动制造可识别的图案,让物体表面“临时拥有纹理”。
对于光滑物体,比较推荐的组合是:
格雷码 + 多频相移
格雷码解决“属于哪个条纹周期”的问题,多频相移解决“周期内部精确位置”的问题。
二、整体重建流程
完整流程如下:
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_left 和 code_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. 光滑金属物体能直接重建吗?
难度比较高。
强反光金属表面会导致条纹严重过曝或畸变,建议使用偏振片、多曝光,必要时对物体表面做消光处理。
总结
本文介绍了面向光滑物体的双相机 + 格雷码 + 多频相移结构光三维重建方案。
整体流程可以概括为:
- 双相机标定和立体校正
- 投影格雷码图案和多频相移条纹
- 左右相机同步采集图像
- 使用格雷码确定条纹周期编号
- 使用多频相移计算高精度绝对相位
- 根据编码和相位进行左右匹配
- 得到视差图并通过
Q矩阵恢复三维点云
这套方案的核心是:
格雷码解决稳定性
多频相移解决精度
双相机解决三维几何
调制度和曝光控制解决光滑表面噪声
对于光滑、低纹理物体,普通双目视觉往往难以稳定匹配,而主动结构光可以显著提高匹配质量。实际项目中,标定精度、同步采集、曝光控制和反光抑制,是决定最终点云质量的关键。
更多推荐


所有评论(0)