1. 双目视觉入门:从人眼到机器的三维感知
你有没有想过,为什么我们人类能轻松判断一个物体离自己有多远?秘密就藏在我们的两只眼睛里。当你闭上一只眼睛,试着去抓桌上的水杯,是不是感觉没那么准了?这就是双目视差在起作用。我们的大脑通过比较左右眼看到的图像差异,就能瞬间计算出物体的距离。
双目相机就是模仿这个原理的机器之眼。它由两个并排的、参数基本一致的摄像头组成,就像给机器装上了一双眼睛。这双“眼睛”同时拍摄同一场景,得到两幅略有差异的图片。通过计算这两幅图片中对应像素点的位置差异(也就是视差),我们就能反推出物体到相机的距离,从而获得场景的深度信息。
听起来很酷,对吧?但这里有个大前提:你得先知道你这双“机器眼睛”的“视力”到底怎么样。每台相机的镜头都有微小的畸变,两个相机之间的相对位置(谁高谁低、谁偏左谁偏右)也不可能完全理想。如果不把这些参数搞清楚,计算出来的距离就会谬以千里。这个搞清楚相机“内在特性”(如焦距、畸变)和“外在关系”(两个相机的位置姿态)的过程,就是相机标定。
所以,整个基于OpenCV的双目三维重建流程,可以概括为三步走:标定 -> 匹配 -> 重建。标定是打好地基,匹配是找到左右图的对应关系,重建则是把二维图像信息转换成三维空间点。我刚开始玩这个的时候,觉得标定最麻烦,但后来发现,只要工具和步骤对了,它反而是最稳的一步。后面我会手把手带你走一遍,从准备棋盘格到拿到最终的点云,把每个坑都提前告诉你。
2. 实战第一步:搭建你的开发环境
工欲善其事,必先利其器。一个干净、版本匹配的Python环境是成功的一半。我强烈建议你使用 Anaconda 来管理环境,它能完美解决不同项目之间库版本冲突的破事。
2.1 创建并配置Python虚拟环境
打开你的终端(Windows用Anaconda Prompt,Mac/Linux用终端),我们一步步来。
首先,创建一个新的虚拟环境,我习惯用Python 3.10,比较稳定:
conda create -n stereo_opencv python=3.10
激活这个环境:
conda activate stereo_opencv
接下来安装核心的OpenCV。注意,我们这里需要安装 opencv-contrib-python,因为它包含了OpenCV主包之外的一些额外模块,比如我们后面会用到的stereoCalibrate等立体视觉相关函数。
pip install opencv-contrib-python
为了后续的点云可视化,我们还需要安装 Open3D。这个库处理三维点云显示非常方便。
pip install open3d
另外,为了处理数据方便,我们再装上经典的数值计算和科学计算库:
pip install numpy matplotlib
环境就绪后,你可以写个简单的测试脚本验证一下:
import cv2
import numpy as np
print(f"OpenCV版本: {cv2.__version__}")
print("环境检查通过!")
如果没报错,恭喜你,基础环境搞定。
2.2 获取并理解标定代码
网上有很多双目标定的代码,质量参差不齐。我踩过几次坑之后,发现GitHub上一个叫 stereo-calib 的项目非常不错,它把整个流程封装得很好,而且用的是我们刚装的OpenCV。我们直接把它克隆下来作为基础。
git clone https://github.com/shubhamwagh/stereo-calib
cd stereo-calib
这个项目使用 Poetry 进行依赖管理,比直接用pip更规范。我们先安装Poetry,然后用它来安装项目所需的所有依赖。
pip install poetry
poetry config virtualenvs.in-project true
poetry install
poetry install 这个命令会根据项目里的 pyproject.toml 文件,自动安装所有指定版本的库。这能确保你用的库版本和我教程里的一致,避免一些诡异的版本兼容性问题。安装完成后,这个项目就自带了一个独立的虚拟环境在 .venv 文件夹里,你可以用 poetry run python your_script.py 来运行脚本。
3. 核心环节:双目相机标定全流程详解
标定是整个过程里最需要耐心的一步,但每一步都有明确的目的。别怕,跟着我做,准没错。
3.1 制作与拍摄标定板
标定需要一个已知尺寸的、高对比度的图案来让相机“看清”。我们常用的是 Charuco棋盘格,它结合了棋盘格和ArUco码的优点,比传统棋盘格更鲁棒,即使部分被遮挡也能识别。项目里自带了一个PDF文件(在 dataset/ 目录下),你可以直接打印出来。
打印注意事项:
- 纸张尺寸:作者推荐A1,但实测A4纸也完全能用。关键是打印时选择“实际大小”或“100%缩放”,千万别让打印机自动适应页面。
- 检查清晰度:打印后,用手机相机放大看看每个黑色方块和ArUco码的边缘是否清晰锐利,没有重影或模糊。这是保证标定精度的基础。
- 平整粘贴:把打印好的纸贴在一块平整、坚硬的板子上(比如亚克力板或硬纸板),确保标定板表面没有弯曲或褶皱。
接下来就是拍摄标定照片。你可以用电脑自带摄像头,也可以用USB摄像头。我建议用USB摄像头,分辨率更高更稳定。拍摄时要注意:
- 多角度:将标定板在双目相机前上下、左右、前后移动,倾斜、旋转,尽可能覆盖相机视野的各个角落。建议拍摄15-25张。
- 完整与部分:这个Charuco板的优点是,不需要整个板子都出现在画面里。有些照片可以只拍板子的一部分,这能帮助算法更好地估计畸变。但至少要保证每张照片里有足够多的角点(比如超过10个)。
- 清晰稳定:对焦要清晰,避免手抖。如果相机支持手动对焦,最好固定焦距。
- 左右同步:确保左右相机同时拍到标定板。如果是单个可移动摄像头,需要先固定相机位置,拍摄左图;然后保持标定板绝对不动,移动相机到右侧位置,再拍摄右图。这对操作要求很高,所以最好还是用固定的双目摄像头模组。
拍好的照片如果是左右拼接在一起的一整张图(比如我的摄像头输出1280x480的图像,左半部分是左视图,右半部分是右视图),我们需要把它切割开。把所有的原始拼接图放在 ./dataset/all/ 目录下,运行下面的切割脚本:
import os
import cv2
input_dir = './dataset/all'
left_dir = './dataset/left'
right_dir = './dataset/right'
os.makedirs(left_dir, exist_ok=True)
os.makedirs(right_dir, exist_ok=True)
image_files = sorted([f for f in os.listdir(input_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg'))])
for idx, filename in enumerate(image_files):
filepath = os.path.join(input_dir, filename)
image = cv2.imread(filepath)
if image is None:
print(f"无法读取: {filename}")
continue
height, width = image.shape[:2]
mid = width // 2
left_img = image[:, :mid] # 左半部分
right_img = image[:, mid:] # 右半部分
left_path = os.path.join(left_dir, f"L_{idx:03d}.jpg")
right_path = os.path.join(right_dir, f"R_{idx:03d}.jpg")
cv2.imwrite(left_path, left_img)
cv2.imwrite(right_path, right_img)
print(f"已保存: {left_path}, {right_path}")
注意:切割后左图文件名必须是
L_xxx.jpg,右图是R_xxx.jpg,且索引号在最后。如果顺序不对,后续标定代码会报错。
3.2 运行标定与参数解读
在运行标定前,还有关键一步:修改标定板的实际物理尺寸。打开 stereo_calib/charuco/constants.py 文件,找到 SQUARE_LENGTH 和 MARKER_LENGTH。这两个值需要你用尺子测量你打印出来的标定板。
SQUARE_LENGTH:棋盘格上每个白色/黑色方块的边长(单位:米)。MARKER_LENGTH:ArUco码的边长(单位:米)。
比如你测量出方块边长是9毫米,ArUco码边长是6.3毫米,就修改为:
SQUARE_LENGTH: float = 0.009
MARKER_LENGTH: float = 0.0063
这一步至关重要! 它告诉算法“现实世界的一米对应图像中的多少个像素”,是所有计算的基础。量得越准,标定结果越可靠。
万事俱备,运行标定命令:
poetry run python -m examples.perform_calibration --data-path "./dataset"
如果一切顺利,你会看到终端滚动输出检测到的角点信息,最后在 ./results/ 目录下生成一个 calibration_results.json 文件。这个文件就是标定的所有成果,里面包含了左右相机的内参、畸变系数、以及两个相机之间的旋转和平移矩阵(合称外参)。
我们来解读一下里面几个最重要的参数:
camera_matrix_left/right:相机的内参矩阵K。它包含了焦距fx, fy和主点坐标cx, cy。你可以理解为相机的“身份证”,描述了它的成像特性。dist_coeffs_left/right:畸变系数。镜头不是理想的,会导致图像边缘的直线变弯。这个参数就是用来矫正这种畸变的。R和T:这是核心!R是旋转矩阵,T是平移向量。它们定义了右相机相对于左相机的空间位置关系。T向量的第一个值(通常是Tx)的绝对值,就是两个相机光心之间的距离,也就是基线长度。这个值直接决定了深度测量的量程和精度。E和F:本质矩阵和基础矩阵,描述了左右视图之间的极几何约束关系,在特征匹配时会用到。Q:视差-深度映射矩阵。这是从标定结果推导出的一个4x4矩阵,是后续将视差图转换为深度图的关键。
拿到这个json文件,标定阶段就圆满成功了。你可以把文件备份好,以后只要相机物理位置不变,就可以一直用这套参数。
4. 从二维到三维:视差、深度与点云生成
标定完成后,我们就有了将二维像素坐标映射到三维空间的“地图”。接下来就是实时计算视差,并生成震撼的三维点云。
4.1 立体匹配与视差图计算
这是双目视觉中最核心的计算步骤。目标是:对于左图中的每一个像素,在右图中找到它的对应点。这两个对应点在水平方向上的坐标差,就是视差。离相机近的物体视差大,远的物体视差小。
OpenCV提供了多种立体匹配算法,主要分两类:
- 局部方法:如
StereoBM(块匹配)和StereoSGBM(半全局块匹配)。速度快,但纹理稀疏区域效果差。 - 全局方法:如
StereoGC,效果更好但速度极慢,不适合实时。
我们通常用 StereoSGBM,它在速度和精度上取得了很好的平衡。下面是计算视差图的函数封装:
import cv2
import numpy as np
def compute_disparity_SGBM(imgL, imgR):
# 初始化SGBM参数
window_size = 5
min_disp = 0
num_disp = 16 * 6 # 必须是16的整数倍
stereo = cv2.StereoSGBM_create(
minDisparity=min_disp,
numDisparities=num_disp, # 最大视差减去最小视差
blockSize=window_size, # 匹配块大小,奇数
P1=8 * 3 * window_size ** 2, # 控制视差平滑度的参数
P2=32 * 3 * window_size ** 2,
disp12MaxDiff=1,
uniquenessRatio=15,
speckleWindowSize=100,
speckleRange=32,
preFilterCap=63,
mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY
)
# 计算视差
disp = stereo.compute(imgL, imgR).astype(np.float32) / 16.0 # SGBM返回的视差值是16倍整数
return disp
提示:
numDisparities(视差搜索范围)是最重要的参数。它设定了算法寻找匹配点的范围。值设得越大,能探测的最近距离越近,但计算量也越大,且可能引入更多噪声。需要根据你的相机基线和焦距来调整。
计算出的视差图是一个灰度图,每个像素的灰度值代表该点的视差大小。但它是“非度量”的,我们需要用标定得到的 Q 矩阵将其转换为有物理意义的深度图。
4.2 深度图生成与点云重建
有了视差图 disp 和重投影矩阵 Q,转换到深度图只需一行代码:
depth_map = cv2.reprojectImageTo3D(disp, Q)
这里 cv2.reprojectImageTo3D 返回的是一个三维坐标图(3通道),其中每个像素位置存放的是 (X, Y, Z) 坐标。我们通常只取Z通道,它就是深度值(即点到相机平面的垂直距离)。
depth = depth_map[:, :, 2] # 提取Z坐标作为深度
mask = disp > disp.min() # 创建一个有效视差的掩码,排除无效点
depth_valid = depth.copy()
depth_valid[~mask] = 0 # 将无效区域的深度设为0
为了可视化,我们常把深度图归一化到0-255并用彩色映射(Jet)显示,这样看起来更直观。
最后,也是最激动人心的一步:生成彩色点云。我们将有效的三维点 (X, Y, Z) 和它们对应的左图颜色 (B, G, R) 提取出来,交给Open3D进行可视化。
def generate_point_cloud(disp, color_img, Q):
# 1. 通过Q矩阵计算三维坐标
points_3d = cv2.reprojectImageTo3D(disp, Q)
# 2. 创建有效点掩码(视差大于最小值的点)
mask = disp > disp.min()
# 3. 提取有效点的坐标和颜色
points = points_3d[mask]
colors = color_img[mask] # color_img是左图,形状为[H, W, 3]
# 4. 创建Open3D点云对象
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.colors = o3d.utility.Vector3dVector(colors / 255.0) # 颜色归一化到[0,1]
# 5. (可选)简单的离群点去除,让点云更干净
cl, ind = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)
pcd_inlier = pcd.select_by_index(ind)
return pcd_inlier
调用 o3d.visualization.draw_geometries([pcd]),一个可以旋转、缩放、从任意角度观察的三维点云窗口就会弹出来。你会看到你面前的场景被重建成了三维空间中的一堆彩色点,这就是双目视觉的魅力所在。
5. 整合与实时三维重建实战
我们把前面所有步骤串起来,写一个完整的实时双目三维重建程序。这个程序会打开你的双目摄像头(或读取视频文件),实时显示深度图,并在退出时显示最后一帧的三维点云。
import cv2
import numpy as np
import open3d as o3d
import json
# 1. 加载标定参数
with open('results/calibration_results.json', 'r') as f:
calib_data = json.load(f)
# 提取所需的矩阵,注意json中键的名称可能需根据实际文件调整
cameraMatrixL = np.array(calib_data['camera_matrix_left'])
cameraMatrixR = np.array(calib_data['camera_matrix_right'])
distCoeffsL = np.array(calib_data['dist_coeffs_left'])
distCoeffsR = np.array(calib_data['dist_coeffs_right'])
R = np.array(calib_data['R'])
T = np.array(calib_data['T'])
Q = np.array(calib_data['perspective_transformation_matrix_Q']) # 关键!
# 2. 初始化立体校正映射
# 立体校正是为了将左右图像“对齐”,使得匹配点只在水平线上搜索,极大简化计算。
rectify_scale = 0 # 0表示裁剪掉校正后无有效值的区域
R1, R2, P1, P2, Q_calc, roi1, roi2 = cv2.stereoRectify(
cameraMatrixL, distCoeffsL,
cameraMatrixR, distCoeffsR,
(640, 480), # 输入图像尺寸,根据你的摄像头调整
R, T,
alpha=rectify_scale
)
# 生成校正查找映射表
mapL1, mapL2 = cv2.initUndistortRectifyMap(cameraMatrixL, distCoeffsL, R1, P1, (640,480), cv2.CV_16SC2)
mapR1, mapR2 = cv2.initUndistortRectifyMap(cameraMatrixR, distCoeffsR, R2, P2, (640,480), cv2.CV_16SC2)
# 3. 初始化摄像头(假设是左右拼接的单视频流)
cap = cv2.VideoCapture(0) # 如果是USB双目摄像头,尝试0, 1, 2...
# 如果是左右分屏的视频,设置合适的分辨率,例如1280x480
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
# 4. 初始化SGBM匹配器(使用优化过的参数)
stereo = cv2.StereoSGBM_create(
minDisparity=4,
numDisparities=16*6,
blockSize=5,
P1=8*3*5**2,
P2=32*3*5**2,
disp12MaxDiff=1,
uniquenessRatio=10,
speckleWindowSize=100,
speckleRange=32,
mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY
)
print("按 'ESC' 键退出,按 's' 键保存当前点云...")
latest_pcd = None
while True:
ret, frame = cap.read()
if not ret:
break
# 切割左右图
height, width = frame.shape[:2]
mid = width // 2
imgL_raw = frame[:, :mid]
imgR_raw = frame[:, mid:]
# 立体校正
imgL_rect = cv2.remap(imgL_raw, mapL1, mapL2, cv2.INTER_LINEAR)
imgR_rect = cv2.remap(imgR_raw, mapR1, mapR2, cv2.INTER_LINEAR)
# 转换为灰度图进行匹配
grayL = cv2.cvtColor(imgL_rect, cv2.COLOR_BGR2GRAY)
grayR = cv2.cvtColor(imgR_rect, cv2.COLOR_BGR2GRAY)
# 计算视差
disp = stereo.compute(grayL, grayR).astype(np.float32) / 16.0
# 计算深度图并可视化
depth_3d = cv2.reprojectImageTo3D(disp, Q)
depth_map = depth_3d[:, :, 2]
depth_vis = cv2.applyColorMap(
cv2.convertScaleAbs(depth_map, alpha=255/(np.percentile(depth_map[depth_map>0], 95) if np.any(depth_map>0) else 1)),
cv2.COLORMAP_JET
)
# 显示原始左图和深度图
cv2.imshow('Rectified Left', imgL_rect)
cv2.imshow('Depth Map', depth_vis)
key = cv2.waitKey(1) & 0xFF
if key == 27: # ESC
# 退出前,用最后一帧生成点云
mask = disp > disp.min()
points = depth_3d[mask]
colors = imgL_rect[mask]
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.colors = o3d.utility.Vector3dVector(colors / 255.0)
latest_pcd = pcd
break
elif key == ord('s'):
# 保存当前点云
mask = disp > disp.min()
points = depth_3d[mask]
colors = imgL_rect[mask]
pcd = o3d.geometry.PointCloud()
pcd.points = o3d.utility.Vector3dVector(points)
pcd.colors = o3d.utility.Vector3dVector(colors / 255.0)
o3d.io.write_point_cloud(f"pointcloud_{int(time.time())}.ply", pcd)
print("点云已保存!")
cap.release()
cv2.destroyAllWindows()
# 5. 显示最终的三维点云
if latest_pcd is not None:
print("正在显示三维点云...")
o3d.visualization.draw_geometries([latest_pcd],
window_name="双目三维重建点云",
width=1024,
height=768)
运行这个脚本,你应该能看到实时的深度图窗口。调整摄像头前的物体,观察深度图的变化。按下ESC键后,程序会弹出一个Open3D窗口,展示你最后看到的那一帧场景的三维点云。你可以用鼠标拖拽旋转,用滚轮缩放,从各个角度审视你的三维重建结果。第一次看到自己桌面上的键盘、水杯变成点云出现在屏幕上时,那种成就感,试过就知道。

1万+

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



