【OpenCV实战】双目相机 + 格雷码条纹三维重建:从编码投影到点云生成
前言
在普通双目视觉中,三维重建的核心问题是左右图像匹配。
如果物体表面纹理丰富,双目匹配相对容易;但如果物体表面是白色、光滑、低纹理区域,传统匹配就很容易失败。
为了解决这个问题,可以引入结构光投影。
本文介绍一种常见方案:
双目相机 + 格雷码条纹
它的核心思想是:投影仪向物体表面投射格雷码条纹,让物体表面带有主动编码;左右相机分别采集这些编码图案,再根据编码结果建立左右像素匹配,最后使用双目三角测量恢复三维点云。
本文主要包括:
- 双目相机标定
- 格雷码条纹生成
- 左右相机同步采集
- 格雷码解码
- 左右图像编码匹配
- 视差图生成
- 三维点云重建
- 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_left 和 code_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_left 和 mask_right 可以反映哪些像素解码可靠。
如果有效区域太少,通常说明:
投影亮度不足
环境光太强
相机曝光不合适
物体表面反光
反码图差异太小
3. 看视差图
视差图应该整体连续。
如果出现大量孤立点,说明匹配条件太宽,或者无效点没有过滤干净。
4. 看点云
点云中重点观察:
平面是否弯曲
边缘是否破碎
是否有明显飞点
尺寸是否接近真实尺寸
深度方向是否存在分层
格雷码条纹由于本身是离散编码,点云精度通常不如相移法细腻。
如果需要更高精度,可以使用“格雷码 + 相移法”的组合方案。
十一、常见问题
1. 格雷码条纹和多频相移有什么区别?
格雷码是离散编码,主要优点是稳定、抗歧义能力强。
多频相移是连续相位编码,主要优点是精度高、点云更细腻。
简单理解:
格雷码:稳定确定区域编号
相移法:精确计算亚像素位置
2. 为什么要投影反码图?
反码图可以帮助判断每个 bit 是否可靠。
如果某个像素在原图和反码图中的亮度差很小,就说明这个位置不适合解码。
3. 只投影竖直格雷码够吗?
如果使用双目立体校正后的左右匹配,竖直格雷码通常就可以用于行方向匹配。
如果需要恢复完整投影仪二维坐标,或者使用相机 + 投影仪三角测量,则还需要投影水平格雷码。
4. 为什么点云精度不高?
常见原因包括:
格雷码条纹宽度太大
相机标定误差较大
双目立体校正不准确
左右相机采集不同步
投影边界模糊
没有做亚像素优化
5. 格雷码适合什么场景?
格雷码适合需要稳定匹配、物体纹理较少、对速度要求不是特别高的场景。
如果追求高精度三维测量,建议使用:
格雷码 + 四步相移
总结
本文介绍了双目相机 + 格雷码条纹三维重建的基本流程。整体可以概括为:
- 对双目相机进行标定和立体校正
- 生成格雷码条纹图和反码图
- 左右相机同步采集投影图案
- 分别对左右图像进行格雷码解码
- 根据相同编码在同一极线上进行匹配
- 计算视差图
- 使用
reprojectImageTo3D()恢复三维点云
双目格雷码结构光的核心思想是:
用投影仪主动制造可识别纹理
用格雷码建立稳定对应关系
用双目几何恢复真实三维坐标
相比普通双目视觉,它对低纹理物体更加友好;相比纯相移法,它的解码更直观、更稳定。实际项目中,如果想兼顾稳定性和精度,通常会把格雷码和相移法结合起来使用。
更多推荐


所有评论(0)