【OpenCV实战】双目相机 + 多频相移结构光三维重建:从条纹相位到高精度点云
前言
在前面的文章中,我们介绍了单目相机标定、双目相机标定,以及单目相机 + 条纹结构光的三维重建方法。
如果希望进一步提高三维重建精度,可以使用:
双目相机 + 多频相移结构光
这种方案结合了双目视觉和结构光编码的优点:
- 双目相机提供稳定的几何约束
- 结构光条纹提供高精度像素匹配
- 多频相移解决相位周期歧义问题
- 最终可以得到更稳定、更稠密的三维点云
本文主要介绍双目相机 + 多频相移结构光重建的基本流程,包括:
- 双目相机标定
- 多频正弦条纹生成
- 四步相移法计算包裹相位
- 多频相位展开
- 左右相机相位匹配
- 双目三角测量
- 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. 投影仪需要标定吗?
如果使用“双目相机 + 投影仪辅助纹理”的方案,三维重建主要由左右相机完成,投影仪可以不参与三角测量。
如果使用“相机 + 投影仪”作为几何重建系统,则需要标定投影仪,并求出投影仪与相机之间的外参。
总结
本文介绍了双目相机 + 多频相移结构光三维重建的基本流程。整体可以概括为:
- 对双目相机进行标定和立体校正
- 投影多组不同频率的正弦条纹图
- 左右相机同步采集条纹图
- 使用四步相移法计算包裹相位
- 使用多频方法展开高频相位
- 通过左右相位匹配得到视差图
- 结合双目标定得到的
Q矩阵恢复三维点云
双目结构光的核心思想是:
用结构光解决匹配问题,用双目几何解决三维重建问题。
相比普通双目视觉,它对低纹理物体更加友好;相比单目结构光,它又可以利用双目系统提供更稳定的空间几何约束。实际项目中,只要双目标定准确、采集同步稳定、条纹曝光合适,多频相移结构光可以获得比较高精度的三维重建结果。
更多推荐


所有评论(0)