从零到一:用Python和OpenCV构建你的双目视觉系统
最近在做一个机器人避障的小项目,需要让机器“看见”并理解周围环境的深度。市面上深度相机不少,但要么价格不菲,要么对环境光敏感。于是,我把目光投向了双目立体视觉——这个模拟人眼原理,用两个普通摄像头就能获取深度信息的技术。听起来很酷,对吧?但真正动手时,才发现从标定到重建,每一步都有不少“坑”。
这篇文章,就是我这段时间折腾的完整记录。它不是一篇理论综述,而是一份面向Python开发者的实战手册。我会带你一步步搭建一个可运行的双目视觉系统,重点不是复现论文,而是解决那些教程里很少提及的实操问题:比如标定板到底怎么摆拍效果最好?立体匹配算法参数调起来像玄学怎么办?生成的3D点云为什么全是噪点?我会把踩过的坑、试出来的有效参数,以及能直接跑起来的代码,毫无保留地分享给你。
无论你是想为机器人添加一双“慧眼”,还是对3D重建感兴趣,希望这篇文章能成为你上手路上的一块坚实垫脚石。
1. 环境搭建与数据准备:万事开头“细”
在敲下第一行代码前,把环境和数据准备好,能避免后面一大堆莫名其妙的错误。这里我推荐使用 Anaconda 来管理环境,它能很好地处理Python包依赖的“地狱”问题。
1.1 创建并配置专属环境
打开你的终端(或Anaconda Prompt),我们创建一个名为 stereo_vision 的独立环境,并安装核心依赖。
# 创建Python 3.9环境(3.8-3.11均可)
conda create -n stereo_vision python=3.9 -y
# 激活环境
conda activate stereo_vision
# 安装核心科学计算和视觉库
pip install opencv-contrib-python==4.8.1.78 # 包含主模块和contrib扩展
pip install numpy==1.24.3 # 数值计算基础
pip install matplotlib==3.7.2 # 用于可视化
pip install open3d==0.17.0 # 强大的3D点云处理与可视化库
注意:安装
opencv-contrib-python而非opencv-python,因为一些高级立体视觉功能(如更丰富的标定选项)在contrib版本中。版本号尽量固定,避免新版本API变动导致代码报错。
安装完成后,可以写个简单的脚本来验证:
# test_env.py
import cv2
import numpy as np
print(f"OpenCV Version: {cv2.__version__}")
print(f"NumPy Version: {np.__version__}")
# 尝试导入Open3D,非必须但推荐
try:
import open3d as o3d
print("Open3D imported successfully.")
except ImportError as e:
print(f"Open3D import failed: {e}")
1.2 获取与制作标定数据
双目系统的精度,很大程度上取决于相机标定的质量。而标定质量,又依赖于你采集的标定板图像。你需要一个棋盘格标定板。你可以自己打印,但更推荐使用表面覆亚光膜的标定板,减少反光。
采集图像时,务必牢记以下几点:
- 数量与姿态:至少准备15-20对(左右相机各一张为一对)图像。标定板在画面中应呈现多种姿态:平放、倾斜、旋转、远近、占据画面不同位置。
- 清晰与完整:确保棋盘格的所有内角点都清晰可见,没有模糊或遮挡。
- 同步性:理想情况下,左右相机应同时拍摄。如果无法硬件同步,尽量保持标定板静止,快速连续拍摄左右图像。
- 文件管理:建议建立清晰的目录结构,例如:
/stereo_calib_data
├── left/ # 存放左相机拍摄的所有标定图像 left_01.jpg, left_02.jpg...
├── right/ # 存放右相机拍摄的所有标定图像 right_01.jpg, right_02.jpg...
└── pairs.txt # (可选)记录成对的图像文件名
我这里提供了一个脚本,可以快速预览并筛选出角点检测成功的图像对,避免无效数据进入标定流程。
# preview_calibration_images.py
import cv2
import numpy as np
import os
from glob import glob
def find_chessboard_corners(img_path, pattern_size=(9, 6)):
"""检测单张图像的棋盘格角点"""
img = cv2.imread(img_path)
if img is None:
return False, None
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# pattern_size 是内角点数量,即棋盘格内部黑白格子的交点数
ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)
if ret:
# 亚像素级角点精确化
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
corners_refined = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
return True, corners_refined
return False, None
def main():
left_imgs = sorted(glob('./stereo_calib_data/left/*.jpg'))
right_imgs = sorted(glob('./stereo_calib_data/right/*.jpg'))
pattern_size = (9, 6) # 根据你的标定板修改
valid_pairs = []
for i, (l_path, r_path) in enumerate(zip(left_imgs, right_imgs)):
l_found, l_corners = find_chessboard_corners(l_path, pattern_size)
r_found, r_corners = find_chessboard_corners(r_path, pattern_size)
if l_found and r_found:
valid_pairs.append((l_path, r_path))
print(f"Pair {i+1}: SUCCESS - {os.path.basename(l_path)} / {os.path.basename(r_path)}")
# 可选:可视化角点
# img_l = cv2.drawChessboardCorners(cv2.imread(l_path), pattern_size, l_corners, l_found)
# img_r = cv2.drawChessboardCorners(cv2.imread(r_path), pattern_size, r_corners, r_found)
# cv2.imshow('Left', img_l); cv2.imshow('Right', img_r)
# if cv2.waitKey(500) & 0xFF == ord('q'): break
else:
print(f"Pair {i+1}: FAILED - {os.path.basename(l_path)} / {os.path.basename(r_path)}")
if not l_found:
print(f" -> Left image corners not found.")
if not r_found:
print(f" -> Right image corners not found.")
print(f"\nTotal valid pairs: {len(valid_pairs)}/{len(left_imgs)}")
# 将有效的图像对保存下来,供后续标定使用
with open('./stereo_calib_data/valid_pairs.txt', 'w') as f:
for l, r in valid_pairs:
f.write(f"{l},{r}\n")
if __name__ == '__main__':
main()
2. 相机标定:为你的双目系统“测量视力”
标定,就是精确测量两个相机各自的“视力参数”(内参)以及它们之间的“相对位置”(外参)。这是所有后续步骤的基石,标定不准,三维重建的结果就会失真。
2.1 理解标定参数:内参与外参
在开始写代码前,花两分钟理解我们要获取的参数,这能帮你更好地解读结果和调试。
| 参数类型 | 具体参数 | 物理意义 | 在OpenCV中的常见形态 |
|---|---|---|---|
| 内参 (Intrinsics) | 焦距 (fx, fy) | 相机镜头将3D点投影到2D图像平面的能力。fx, fy可能略有不同。 | 3x3矩阵 K 的主对角线元素 |
| 主点 (cx, cy) | 图像平面的光学中心,通常是图像中心附近。 | 3x3矩阵 K 的第三列前两个元素 | |
| 畸变系数 (k1, k2, p1, p2, k3...) | 描述镜头引起的图像扭曲,如桶形畸变、枕形畸变。 | 1x5或1x4的向量 dist | |
| 外参 (Extrinsics) | 旋转矩阵 (R) | 描述右相机坐标系相对于左相机坐标系的旋转。 | 3x3矩阵 |
| 平移向量 (T) | 描述右相机原点相对于左相机原点的位置偏移。其模长即为双目基线距离。 | 3x1向量 |
双目标定,就是同时求解左右相机的内参、畸变,以及它们之间的R和T。
2.2 实战标定代码与关键技巧
下面是我在实际项目中使用的标定代码,其中包含了一些提高标定精度的技巧。
# stereo_calibrate.py
import cv2
import numpy as np
import json
from pathlib import Path
class StereoCalibrator:
def __init__(self, pattern_size=(9, 6), square_size=0.025):
"""
初始化标定器
:param pattern_size: 棋盘格内角点数量 (width, height)
:param square_size: 每个棋盘格方块的物理尺寸(单位:米)
"""
self.pattern_size = pattern_size
self.square_size = square_size
self.objp = None # 3D世界坐标点
self.img_size = None
self._prepare_object_points()
def _prepare_object_points(self):
"""生成棋盘格角点的世界坐标 (Z=0)"""
objp = np.zeros((self.pattern_size[0] * self.pattern_size[1], 3), np.float32)
# mgrid生成网格,乘以square_size得到实际物理坐标
objp[:, :2] = np.mgrid[0:self.pattern_size[0], 0:self.pattern_size[1]].T.reshape(-1, 2)
objp *= self.square_size
self.objp = objp
def calibrate(self, left_img_paths, right_img_paths):
"""
执行双目标定
:return: 标定结果字典
"""
# 1. 角点检测与收集
objpoints = [] # 世界坐标系中的3D点
left_imgpoints = []
right_imgpoints = []
all_left_imgs = []
all_right_imgs = []
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
print("Detecting chessboard corners...")
for i, (l_path, r_path) in enumerate(zip(left_img_paths, right_img_paths)):
l_img = cv2.imread(str(l_path))
r_img = cv2.imread(str(r_path))
if l_img is None or r_img is None:
print(f"Warning: Cannot read image pair {i}")
continue
if self.img_size is None:
self.img_size = (l_img.shape[1], l_img.shape[0]) # (width, height)
elif self.img_size != (l_img.shape[1], l_img.shape[0]):
raise ValueError(f"Image size mismatch at pair {i}")
l_gray = cv2.cvtColor(l_img, cv2.COLOR_BGR2GRAY)
r_gray = cv2.cvtColor(r_img, cv2.COLOR_BGR2GRAY)
# 查找角点
l_ret, l_corners = cv2.findChessboardCorners(l_gray, self.pattern_size, None)
r_ret, r_corners = cv2.findChessboardCorners(r_gray, self.pattern_size, None)
if l_ret and r_ret:
# 亚像素精确化
l_corners_refined = cv2.cornerSubPix(l_gray, l_corners, (11, 11), (-1, -1), criteria)
r_corners_refined = cv2.cornerSubPix(r_gray, r_corners, (11, 11), (-1, -1), criteria)
objpoints.append(self.objp)
left_imgpoints.append(l_corners_refined)
right_imgpoints.append(r_corners_refined)
all_left_imgs.append(l_img)
all_right_imgs.append(r_img)
print(f" Pair {i+1}: OK")
else:
print(f" Pair {i+1}: Failed (L:{l_ret}, R:{r_ret})")
if len(objpoints) < 10:
raise ValueError(f"Only {len(objpoints)} valid pairs found. Need at least 10 for reliable calibration.")
print(f"\nStarting calibration with {len(objpoints)} valid image pairs...")
# 2. 单独标定左右相机(获取初始内参)
print("Calibrating individual cameras...")
rms_left, K_left, dist_left, _, _ = cv2.calibrateCamera(
objpoints, left_imgpoints, self.img_size, None, None)
rms_right, K_right, dist_right, _, _ = cv2.calibrateCamera(
objpoints, right_imgpoints, self.img_size, None, None)
print(f" Left camera RMS error: {rms_left:.4f} pixels")
print(f" Right camera RMS error: {rms_right:.4f} pixels")
# 3. 双目标定(优化所有参数)
print("Performing stereo calibration...")
flags = cv2.CALIB_FIX_INTRINSIC # 使用单目标定的内参作为初始值,但允许微调
# 也可以尝试 CALIB_USE_INTRINSIC_GUESS 让算法自己优化
# flags = 0
rms_stereo, K_left, dist_left, K_right, dist_right, R, T, E, F = cv2.stereoCalibrate(
objpoints, left_imgpoints, right_imgpoints,
K_left, dist_left, K_right, dist_right,
self.img_size, criteria=criteria, flags=flags)
print(f" Stereo RMS error: {rms_stereo:.4f} pixels")
print(f" Rotation matrix (R):\n{R}")
print(f" Translation vector (T): {T.T} (Baseline: {np.linalg.norm(T):.4f} meters)")
# 4. 计算重投影误差,评估标定质量
mean_error = self._compute_reprojection_error(objpoints, left_imgpoints, right_imgpoints,
K_left, dist_left, K_right, dist_right, R, T)
print(f" Mean reprojection error: {mean_error:.4f} pixels")
# 保存结果
calib_result = {
'img_size': self.img_size,
'K_left': K_left.tolist(),
'dist_left': dist_left.tolist(),
'K_right': K_right.tolist(),
'dist_right': dist_right.tolist(),
'R': R.tolist(),
'T': T.tolist(),
'rms_stereo': rms_stereo,
'baseline_m': float(np.linalg.norm(T))
}
return calib_result
def _compute_reprojection_error(self, objpoints, imgpoints_l, imgpoints_r,
K_l, dist_l, K_r, dist_r, R, T):
"""计算平均重投影误差,这是评估标定精度的关键指标"""
total_error = 0
total_points = 0
for i in range(len(objpoints)):
# 将世界点投影到左图像
imgpoints_l_proj, _ = cv2.projectPoints(objpoints[i], np.eye(3), np.zeros((3,1)), K_l, dist_l)
error_l = cv2.norm(imgpoints_l[i], imgpoints_l_proj, cv2.NORM_L2) / len(imgpoints_l_proj)
# 将世界点投影到右图像(需要考虑旋转和平移)
imgpoints_r_proj, _ = cv2.projectPoints(objpoints[i], R, T, K_r, dist_r)
error_r = cv2.norm(imgpoints_r[i], imgpoints_r_proj, cv2.NORM_L2) / len(imgpoints_r_proj)
total_error += (error_l + error_r)
total_points += 2
return total_error / total_points
# 使用示例
if __name__ == '__main__':
data_dir = Path('./stereo_calib_data')
left_paths = sorted(data_dir.glob('left/*.jpg'))[:20] # 使用前20对
right_paths = sorted(data_dir.glob('right/*.jpg'))[:20]
calibrator = StereoCalibrator(pattern_size=(9, 6), square_size=0.025)
result = calibrator.calibrate(left_paths, right_paths)
# 保存标定结果
with open('stereo_calibration_result.json', 'w') as f:
json.dump(result, f, indent=2)
print("\nCalibration results saved to 'stereo_calibration_result.json'")
运行这段代码后,重点关注几个输出:
- RMS误差:理想情况下应小于0.5像素。如果大于1,可能需要检查标定板图像质量或增加有效图像对数量。
- 平移向量T:其模长就是两个相机光心之间的距离,即基线长度。这个值应该和你实际测量的两个相机间距大致相符,是验证标定结果合理性的重要依据。
- 重投影误差:这是更严格的精度指标,同样越小越好。
3. 立体校正与匹配:从二维图像中“抠”出深度
拿到准确的标定参数后,下一步就是处理实际拍摄的场景图。立体校正的目的是“摆正”两张图,让匹配搜索从二维降为一维,极大简化问题。立体匹配则是核心算法,目的是为每个像素找到其在另一张图中的对应点,计算视差。
3.1 立体校正:让图像行对齐
校正会生成两个映射表(map),后续只需用cv2.remap函数即可快速校正任何新图像。
# stereo_rectify.py
import cv2
import numpy as np
import json
class StereoRectifier:
def __init__(self, calib_file='stereo_calibration_result.json'):
with open(calib_file, 'r') as f:
calib = json.load(f)
self.img_size = tuple(calib['img_size'])
self.K_left = np.array(calib['K_left'])
self.dist_left = np.array(calib['dist_left'])
self.K_right = np.array(calib['K_right'])
self.dist_right = np.array(calib['dist_right'])
self.R = np.array(calib['R'])
self.T = np.array(calib['T'])
self._compute_rectification_maps()
def _compute_rectification_maps(self, alpha=0.0):
"""
计算立体校正映射
:param alpha: 缩放参数,0表示裁剪无效区域,1保留所有原图像素(可能有黑边)。
推荐-1,让OpenCV自动优化。
"""
# 立体校正
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
self.K_left, self.dist_left,
self.K_right, self.dist_right,
self.img_size, self.R, self.T,
alpha=alpha, flags=cv2.CALIB_ZERO_DISPARITY
)
# 计算映射
self.map_left_x, self.map_left_y = cv2.initUndistortRectifyMap(
self.K_left, self.dist_left, R1, P1, self.img_size, cv2.CV_32FC1)
self.map_right_x, self.map_right_y = cv2.initUndistortRectifyMap(
self.K_right, self.dist_right, R2, P2, self.img_size, cv2.CV_32FC1)
self.Q = Q # 视差转深度矩阵,用于后续3D重建
self.roi_left = roi1
self.roi_right = roi2
def rectify(self, left_img, right_img):
"""校正一对图像"""
left_rect = cv2.remap(left_img, self.map_left_x, self.map_left_y, cv2.INTER_LINEAR)
right_rect = cv2.remap(right_img, self.map_right_x, self.map_right_y, cv2.INTER_LINEAR)
return left_rect, right_rect
def draw_epilines(self, left_rect, right_rect, num_lines=20):
"""绘制极线,可视化校正效果(校正后应为水平线)"""
left_gray = cv2.cvtColor(left_rect, cv2.COLOR_BGR2GRAY)
right_gray = cv2.cvtColor(right_rect, cv2.COLOR_BGR2GRAY)
# 使用ORB等特征检测器找点
orb = cv2.ORB_create()
kp_left, des_left = orb.detectAndCompute(left_gray, None)
# 匹配
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
kp_right, des_right = orb.detectAndCompute(right_gray, None)
matches = bf.match(des_left, des_right)
matches = sorted(matches, key=lambda x: x.distance)[:num_lines]
# 画线
left_vis = left_rect.copy()
right_vis = right_rect.copy()
for m in matches:
pt_left = tuple(map(int, kp_left[m.queryIdx].pt))
pt_right = tuple(map(int, kp_right[m.trainIdx].pt))
color = tuple(np.random.randint(0, 255, 3).tolist())
cv2.circle(left_vis, pt_left, 5, color, -1)
cv2.circle(right_vis, pt_right, 5, color, -1)
# 在右图对应行画水平线
cv2.line(right_vis, (0, pt_right[1]), (right_vis.shape[1], pt_right[1]), color, 1)
return cv2.hconcat([left_vis, right_vis])
# 使用示例
if __name__ == '__main__':
rectifier = StereoRectifier()
left_img = cv2.imread('scene_left.jpg')
right_img = cv2.imread('scene_right.jpg')
left_rect, right_rect = rectifier.rectify(left_img, right_img)
# 可视化校正结果
vis = rectifier.draw_epilines(left_rect, right_rect)
cv2.imwrite('rectified_epilines.jpg', vis)
print("Rectification complete. Check 'rectified_epilines.jpg'.")
# 校正后,左右图中对应的特征点应该基本在同一水平线上。
3.2 立体匹配:算法选择与参数调优“心法”
立体匹配是双目视觉的灵魂,也是最耗计算资源的部分。OpenCV主要提供了两种算法:BM(Block Matching) 和 SGBM(Semi-Global Block Matching)。简单来说,BM快但精度低、噪声大;SGBM慢一些,但效果通常好得多,是实际应用的首选。
调参是个经验活,下面这个SGBM参数表是我经过大量测试总结的,可以作为你的起点:
| 参数 | 含义 | 推荐初始值/范围 | 调整策略 |
|---|---|---|---|
minDisparity | 最小视差 | 0 | 如果物体都在远处,可设为正数。 |
numDisparities | 视差搜索范围 | 必须是16的整数倍,如64, 128, 256 | 越大,能探测的深度范围越广,但计算量也越大。根据基线长度和最近物体距离估算。 |
blockSize | 匹配块大小 | 奇数,如3, 5, 7, 15 | 小值对细节敏感但噪声大;大值平滑但边缘模糊。纹理丰富区域用小值,平滑区域用大值。 |
P1 | 惩罚系数1 | 8*3*blockSize**2 | 控制视差平滑度的参数。P1是相邻像素视差变化为1时的惩罚。 |
P2 | 惩罚系数2 | 32*3*blockSize**2 | 控制视差平滑度的参数。P2是相邻像素视差变化大于1时的惩罚。P2通常远大于P1。 |
disp12MaxDiff | 左右一致性检查最大容差 | 1 | 值越小,检查越严格,能消除一些错误匹配。 |
uniquenessRatio | 唯一性比率 | 5-15 | 值越大,对匹配唯一性要求越高,能过滤模糊匹配。 |
speckleWindowSize | 斑点滤波器窗口大小 | 50-200 | 过滤小的孤立噪点群。设为0禁用。 |
speckleRange | 斑点滤波器视差变化阈值 | 1-2 | 窗口内视差变化小于此值的区域被视为同一连通域。 |
下面是一个封装好的视差计算类,包含了BM和SGBM,并提供了视差图后处理(滤波)功能。
# disparity_engine.py
import cv2
import numpy as np
class DisparityEngine:
def __init__(self, method='sgbm', **kwargs):
self.method = method
if method == 'bm':
self.stereo = self._create_bm_matcher(**kwargs)
elif method == 'sgbm':
self.stereo = self._create_sgbm_matcher(**kwargs)
else:
raise ValueError("Method must be 'bm' or 'sgbm'")
def _create_bm_matcher(self, num_disparities=128, block_size=15):
"""创建BM匹配器"""
stereo = cv2.StereoBM_create(numDisparities=num_disparities, blockSize=block_size)
# 可以设置一些预处理滤波器(OpenCV 4.x+)
# stereo.setPreFilterType(1)
# stereo.setPreFilterSize(5)
# stereo.setPreFilterCap(31)
return stereo
def _create_sgbm_matcher(self, min_disparity=0, num_disparities=128, block_size=5,
p1=None, p2=None, disp12_max_diff=1,
uniqueness_ratio=10, speckle_window_size=100,
speckle_range=2, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY):
"""创建SGBM匹配器,参数设置更灵活"""
if p1 is None:
p1 = 8 * 3 * block_size ** 2
if p2 is None:
p2 = 32 * 3 * block_size ** 2
stereo = cv2.StereoSGBM_create(
minDisparity=min_disparity,
numDisparities=num_disparities,
blockSize=block_size,
P1=p1,
P2=p2,
disp12MaxDiff=disp12_max_diff,
uniquenessRatio=uniqueness_ratio,
speckleWindowSize=speckle_window_size,
speckleRange=speckle_range,
mode=mode
)
return stereo
def compute(self, left_img, right_img, apply_filter=True):
"""
计算视差图
:param apply_filter: 是否应用WLS滤波器进行后处理
"""
if len(left_img.shape) == 3:
left_gray = cv2.cvtColor(left_img, cv2.COLOR_BGR2GRAY)
right_gray = cv2.cvtColor(right_img, cv2.COLOR_BGR2GRAY)
else:
left_gray = left_img
right_gray = right_img
# 计算原始视差
if self.method == 'bm':
disparity = self.stereo.compute(left_gray, right_gray)
else: # sgbm
disparity = self.stereo.compute(left_gray, right_gray).astype(np.float32) / 16.0
# 可选:WLS滤波,能显著平滑视差图并保持边缘
if apply_filter and self.method == 'sgbm':
# 创建右视差图(用于左右一致性检查)
right_matcher = cv2.ximgproc.createRightMatcher(self.stereo)
disparity_right = right_matcher.compute(right_gray, left_gray).astype(np.float32) / 16.0
# 创建WLS滤波器
wls_filter = cv2.ximgproc.createDisparityWLSFilter(self.stereo)
wls_filter.setLambda(8000.0) # 平滑度权重
wls_filter.setSigmaColor(1.5) # 颜色相似度权重
disparity = wls_filter.filter(disparity, left_img, disparity_map_right=disparity_right)
# 将无效视差(如匹配失败)设为0
disparity[disparity < 0] = 0
return disparity
def visualize_disparity(self, disparity, colormap=cv2.COLORMAP_JET):
"""将视差图转换为伪彩色图,便于观察"""
disp_vis = disparity.copy()
# 归一化到0-255范围
if self.method == 'sgbm':
# SGBM视差已经是浮点数,需要先归一化
disp_min, disp_max = disparity.min(), disparity.max()
if disp_max > disp_min:
disp_vis = (255 * (disparity - disp_min) / (disp_max - disp_min)).astype(np.uint8)
else:
disp_vis = np.zeros_like(disparity, dtype=np.uint8)
else:
# BM视差是整数,通常需要除以16(但compute里已处理)
disp_vis = np.uint8(disparity)
disp_color = cv2.applyColorMap(disp_vis, colormap)
return disp_color
# 使用示例
if __name__ == '__main__':
# 加载校正后的图像
left_rect = cv2.imread('rectified_left.jpg')
right_rect = cv2.imread('rectified_right.jpg')
# 尝试SGBM
engine_sgbm = DisparityEngine(method='sgbm', num_disparities=128, block_size=5)
disparity_sgbm = engine_sgbm.compute(left_rect, right_rect, apply_filter=True)
disparity_color_sgbm = engine_sgbm.visualize_disparity(disparity_sgbm)
# 尝试BM(对比用)
engine_bm = DisparityEngine(method='bm', num_disparities=128, block_size=15)
disparity_bm = engine_bm.compute(left_rect, right_rect, apply_filter=False)
disparity_color_bm = engine_bm.visualize_disparity(disparity_bm)
# 并排显示
comparison = np.hstack([disparity_color_bm, disparity_color_sgbm])
cv2.imwrite('disparity_comparison.jpg', comparison)
print("Disparity maps computed. 'disparity_comparison.jpg' shows BM (left) vs SGBM+WLS (right).")
运行后,你会看到BM生成的视差图噪点很多,而SGBM结合WLS滤波后的结果则平滑、连贯得多。在实际项目中,我几乎总是使用SGBM。
4. 三维重建与应用:从视差到真实世界
得到视差图后,我们离最终目标——三维点云,只有一步之遥。这一步在数学上是直接的,但如何让点云更干净、更有用,则需要一些技巧。
4.1 生成与优化点云
直接根据视差图和Q矩阵重投影得到的点云往往包含大量噪点(来自误匹配、图像边缘、无纹理区域)。我们需要进行过滤。
# pointcloud_builder.py
import cv2
import numpy as np
import open3d as o3d
class PointCloudBuilder:
def __init__(self, Q_matrix):
"""
:param Q_matrix: 从立体校正得到的重投影矩阵
"""
self.Q = Q_matrix
def reproject_to_3d(self, disparity, left_img):
"""
将视差图重投影为3D点云
:return: 点云坐标 (N, 3), 颜色 (N, 3)
"""
# 使用reprojectImageTo3D函数进行重投影
points_3d = cv2.reprojectImageTo3D(disparity, self.Q)
# 创建掩码,过滤无效视差(通常视差为0或极小值为无效)
mask = disparity > disparity.min() + 1 # 简单的阈值过滤
# 应用掩码
points = points_3d[mask]
colors = left_img[mask]
# 转换颜色顺序 BGR -> RGB (如果使用Open3D可视化)
colors_rgb = colors[:, :, ::-1] / 255.0
return points, colors_rgb
def filter_pointcloud(self, points, colors,
z_range=(0.1, 10.0),
outlier_std_ratio=2.0):
"""
过滤点云:去除太近/太远的点,并移除统计离群点
"""
# 1. 基于深度(Z)的过滤
z = points[:, 2]
depth_mask = (z > z_range[0]) & (z < z_range[1])
points = points[depth_mask]
colors = colors[depth_mask]
if len(points) == 0:
return points, colors
# 2. 统计离群点移除 (移除远离点云主体的孤立点)
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.colors = o3d.utility.Vector3dVector(colors)
# 使用统计离群点移除
cl, ind = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=outlier_std_ratio)
filtered_pcd = pcd.select_by_index(ind)
return np.asarray(filtered_pcd.points), np.asarray(filtered_pcd.colors)
def create_open3d_visualizer(self, points, colors, window_name="3D Point Cloud"):
"""创建并返回一个Open3D可视化窗口对象"""
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.colors = o3d.utility.Vector3dVector(colors)
# 可选:下采样,使可视化更流畅
if len(points) > 50000:
pcd = pcd.voxel_down_sample(voxel_size=0.01)
# 计算法线,用于渲染着色(非必须,但看起来更立体)
pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.1, max_nn=30))
return pcd
# 主流程示例
if __name__ == '__main__':
# 加载数据
left_img = cv2.imread('rectified_left.jpg')
disparity = np.load('disparity_sgbm.npy') # 假设之前保存了视差图
# 加载Q矩阵 (从标定结果或rectifier对象获取)
# 这里假设我们从rectifier对象获取
from stereo_rectify import StereoRectifier
rectifier = StereoRectifier()
Q = rectifier.Q
# 重建点云
builder = PointCloudBuilder(Q)
raw_points, raw_colors = builder.reproject_to_3d(disparity, left_img)
print(f"Raw point cloud size: {len(raw_points)}")
# 过滤点云
filtered_points, filtered_colors = builder.filter_pointcloud(
raw_points, raw_colors, z_range=(0.3, 5.0))
print(f"Filtered point cloud size: {len(filtered_points)}")
# 可视化
pcd = builder.create_open3d_visualizer(filtered_points, filtered_colors)
o3d.visualization.draw_geometries([pcd], window_name="Reconstructed Scene")
# 保存点云为PLY格式,可用于MeshLab、CloudCompare等软件进一步处理
o3d.io.write_point_cloud("reconstructed_scene.ply", pcd)
print("Point cloud saved as 'reconstructed_scene.ply'")
4.2 实际应用场景与性能考量
一个能跑通的demo只是开始。要让双目视觉系统在实际项目中发挥作用,你需要考虑更多。
1. 实时性优化 如果你的应用需要实时处理(如机器人SLAM、交互式AR),纯Python+OpenCV的SGBM可能难以达到高帧率。可以考虑以下策略:
- 降低分辨率:将输入图像缩放至原图的1/2或1/4,能极大提升速度,但会损失深度精度和有效距离。
- 调整视差范围:精确估算场景的最近和最远距离,减少
numDisparities。 - 使用硬件加速:OpenCV部分函数支持OpenCL或CUDA。可以尝试编译支持GPU的OpenCV版本,或探索专用硬件(如Intel RealSense SDK、NVIDIA的VisionWorks库)。
- 算法替代:对于特定场景(如室内结构化环境),可以考虑更快的局部匹配算法,或使用基于深度学习的轻量级立体匹配网络(如
AnyNet、StereoNet)。
2. 精度提升技巧
- 高分辨率标定:使用更高分辨率的图像进行标定,并确保标定板覆盖整个视野。
- 亚像素优化:在立体匹配后,对视差图进行亚像素级优化(
cv2.ximgproc.disparityRefinement)。 - 多帧融合:对于静态场景,可以采集多帧图像,计算视差后取中值或均值,能有效抑制随机噪声。
- 光照鲁棒性:确保左右相机曝光一致。如果环境光变化大,可以考虑使用归一化互相关(ZNCC)等对光照变化不敏感的匹配代价函数,但OpenCV的SGBM默认使用BT代价,对此有一定容忍度。
3. 常见问题与排查清单 当你发现三维重建结果不理想时,可以按以下清单排查:
| 问题现象 | 可能原因 | 排查与解决思路 |
|---|---|---|
| 点云整体扭曲或拉伸 | 相机标定不准,特别是基线长度T测量不准。 | 重新标定,确保标定板多角度、全覆盖。检查标定重投影误差。 |
| 点云在物体边缘出现“膨胀”或“拖尾” | 立体匹配在遮挡区域或深度不连续处出错。 | 尝试调整SGBM的P1、P2平滑惩罚参数。使用WLS后处理滤波器。 |
| 点云噪声大,像“雪花” | 图像纹理弱、噪声多,或匹配算法参数不当。 | 增加blockSize。尝试speckleWindowSize和speckleRange滤波。确保输入图像清晰、纹理丰富。 |
| 重建的物体表面凹凸不平 | 镜头畸变校正不彻底,或相机同步不好导致运动模糊。 | 检查标定时的畸变系数是否合理。确保拍摄时相机与场景无相对运动。 |
| 视差图有大片黑色区域(无效点) | 该区域在另一视图中被遮挡,或纹理重复导致匹配失败。 | 这是双目视觉的固有难题。可尝试用图像修复(inpainting)填补,或通过后处理插值。 |
最后,分享一个我最近在室内机器人项目中的小经验:对于地面平坦的室内场景,在得到点云后,可以先用RANSAC算法拟合一个地平面,然后把地面点云滤除。这样剩下的就是墙壁、家具等障碍物,直接用于机器人路径规划,效果非常直接。实现这个功能的代码片段如下:
def segment_ground_plane(points, colors, distance_threshold=0.02, max_iterations=1000):
"""使用RANSAC从点云中分割地面"""
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.colors = o3d.utility.Vector3dVector(colors)
# 使用RANSAC拟合平面
plane_model, inliers = pcd.segment_plane(distance_threshold=distance_threshold,
ransac_n=3,
num_iterations=max_iterations)
[a, b, c, d] = plane_model
print(f"Plane equation: {a:.2f}x + {b:.2f}y + {c:.2f}z + {d:.2f} = 0")
print(f"Number of inliers (ground points): {len(inliers)}")
# 分割点云
ground = pcd.select_by_index(inliers)
obstacles = pcd.select_by_index(inliers, invert=True)
# 给地面点云上色(绿色)以便可视化
ground.paint_uniform_color([0.0, 1.0, 0.0])
return np.asarray(ground.points), np.asarray(obstacles.points), np.asarray(obstacles.colors)
把这个函数接入到前面的点云处理流程中,你就能得到一个分离了地面和障碍物的点云,离真正的应用又近了一步。双目视觉的乐趣就在于,从两个二维的图像矩阵中,通过一系列计算,竟然能还原出我们所在的三维世界。这个过程虽然繁琐,但当看到第一个粗糙但可辨的点云出现时,那种成就感是无可替代的。希望这份指南能帮你少走些弯路,更快地体验到这种乐趣。如果在实践过程中遇到具体问题,不妨从标定数据和质量这个根源再仔细检查一遍,很多时候问题就出在那里。
189


&spm=1001.2101.3001.11974&articleId=153243875&d=1&t=3&u=0ff69bae5a9941abb491cb2dbeadf51e)

被折叠的 条评论
为什么被折叠?



