拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenCV双目立体视觉:从相机标定到三维点云生成实战

OpenCV双目立体视觉:从相机标定到三维点云生成实战

双目立体视觉这个方向,我最早是被一个需求拉进来的——给机器人加一双"眼睛",让它能估算前方障碍物的距离。做过一圈调研后发现,OpenCV里的双目立体流程是成本最低、最容易上手的一条路:两个普通USB摄像头、一块棋盘格标定板、几百行代码,就能从两张图像算出深度信息,甚至生成三维点云。这也是"97个OpenCV实例"系列第三十讲的主题:从标定到点云。这篇文章我把完整流程拆开讲,不光说步骤,还把每一步的原理、参数选择和实际踩坑记录一并交代清楚,适合正在用OpenCV做双目测距或三维重建的开发者参考。

1. 项目整体链路拆解:为什么是"标定→校正→视差→点云"

1.1 双目视觉比其他方案强在哪

说到获取深度信息,市面上主流无非三种方案:消费级深度相机(比如结构光的Kinect、飞行时间ToF、主动立体RealSense)、激光雷达、以及纯视觉的双目立体。深度相机和激光雷达好用,但都有局限。深度相机受环境光影响大,在室外强光下经常直接"失明";激光雷达精度高但价格让人肉疼,而且对反射面、透明物体也会翻车。双目立体最大的优势是便宜又皮实——两个普通RGB摄像头加一套标定流程,就能在户外环境里获得稠密的深度估计。它在近距离(几米到十几米)的精度,对于测距、避障、简单三维重建来说足够用;而且你可以自由选择摄像头参数,适配不同视野和基线长度的需求。

当然,双目也不是万能的。它对场景纹理敏感,面对白墙这类低纹理区域基本算不出视差;计算量与图像分辨率成正比,对嵌入式平台是个压力;标定质量直接决定后面的精度,这也是为什么我把标定单独拉出来重点讲。理解这些特性,你才好在实际项目里做取舍——比如要不要补结构光、要不要加IMU辅助、要不要降分辨率跑视差。

1.2 四个环节的数据流

整个项目可以压缩成一句话:通过两幅有视差的图像,恢复每个像素的三维坐标。但这句话落地成程序,需要经历四个明确的环节:

  1. 相机标定:确定左右相机各自的内参(焦距、主点)、畸变系数,以及相机之间的相对位置关系(旋转矩阵和平移向量)。
  2. 立体校正:把两幅图像重投影到一个公共平面上,使对应点在水平方向严格对齐,这样视差搜索从二维降到一维。
  3. 视差计算:在左右图中找到每个匹配点,计算它们之间的水平像素差,也就是视差。视差越小说明物体越远,视差越大说明物体越近。
  4. 点云生成:利用标定得到的Q矩阵,把"像素坐标+视差值"变换成"三维坐标",最后保存成点云文件用于可视化和后续处理。

这四个环节是一环扣一环的,前面标定的误差会在后面放大,稍后我就会讲到重投影误差这个关键指标。我在项目里始终遵循一个原则:每一步先做可视化验证,确认无误再进下一步。比如标定完,先undistort几张图像看看边缘的直线是不是变直了;校正完,把左右图并排看同一特征点是否在同一水平线上。这样做的好处是问题早暴露,不会等到生成点云后才发现坐标全歪了。

1.3 环境准备与OpenCV版本选择

我用的环境是Python 3.9 + OpenCV 4.8.0(opencv-python)+ NumPy 1.24。有一个细节要提醒:OpenCV从4.5版本开始对SGBM、立体校正的接口做了一些调整,如果你用的是旧版2.x代码仓库里的写法,可能跑到一半发现函数参数对不上。建议直接用新安装的opencv-python,依赖就NumPy一个,别的不需要。

如果你打算用CUDA加速视差计算,则需要手动编译opencv-contrib,开启CUDA模块,然后把StereoSGBM换成cuda版的StereoSGM。实话说,对学习阶段这个加速意义不大,先把CPU版跑通、把算法调明白,后面再考虑工程化。

另外建议准备两块标定板或者至少一张高质量打印的棋盘格,棋盘格角点数量要选好,常见的是8×6(内角点)或9×6。这节不做过多展开,下一章会详细说标定板怎么用才不容易失败。

2. 相机标定:整个流程最容易翻车的环节

2.1 标定板的准备与图像采集规范

标定板这件事,听起来简单,栽跟头的人却最多。先明确一点:OpenCV里的findChessboardCorners识别的是内角点,比如一张棋盘格如果打印出来是10×7个格子,那内角点就是9×6。用cv2.CALIB_CB_ADAPTIVE_THRESH参数能一定程度缓解光照不均,但最好别指望它拯救拍摄质量。

标定板的物理尺寸要量准。格子边长多少就是多少,用卡尺量到毫米级。因为fx的单位是像素,它和格子的物理尺寸无关,但畸变系数、外参的平移向量需要真实的物理尺度,否则最后点云会带着一个未知的缩放因子。我见过不少新手,格子边长写错一毫米,点云出来的物体尺寸怎么都对不上,这就是根源。

采集图像时注意几点,都是我试过最稳的做法:

  • 左右相机用同一块标定板,不要中途换板子;
  • 拍摄时板子要布满视野——四个角和中心区域都要有,同时转动不同的俯仰角、偏航角,让标定板在图像中呈现明显的透视变形;
  • 每对图像保存时保持左右一一对应,我习惯用checkerboard_left_001.png / checkerboard_right_001.png这样规整的命名;
  • 建议采集20~30对,少于15对容易解算出不稳定的结果;
  • 拍摄时相机和标定板都不能动,最好用三脚架把相机固定好,板子拿在手里变换姿态就行,快门速度要够快,防止手抖糊图。

有一个很坑的地方:如果你用视频帧抽取的方式标定,一定要确保左右视频时间戳对齐,否则两帧拍到的板子位置不一致,角点坐标配对就是错的。要么用静态拍摄,要么用支持硬件同步的双目相机,不要图省事。

2.2 单目标定:内参和畸变到底在算啥

OpenCV的标定核心是张正友标定法,原理是拿到多张平面棋盘格的图像,建立"图像角点"和"物理角点"的对应关系,再通过单应矩阵约束,迭代求解内参矩阵和畸变系数。说得直白点,它在拟合一个数学模型:一个真实世界的点,通过相机镜头投影到图像平面上,这个变换可以写成:

图像坐标 ≈ 内参矩阵 × 外参矩阵 × 世界坐标

内参矩阵就是刚说的fx、fy、cx、cy,它刻画了相机的"焦距"和"光心在图像中的位置";畸变系数包括径向畸变k1、k2、k3和切向畸变p1、p2,刻画了镜头形状带来的桶形或枕形变形。用数学语言说,这一步求的就是把像素坐标"矫正"到理想针孔模型下所需的参数。

单目标定的代码框架如下:

import cv2 import numpy as np criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) CHECKERBOARD = (9, 6) # 内角点数量 objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2) objpoints = [] imgpoints_left = [] imgpoints_right = [] for lpath, rpath in zip(list_left, list_right): imgL = cv2.imread(lpath) imgR = cv2.imread(rpath) grayL = cv2.cvtColor(imgL, cv2.COLOR_BGR2GRAY) grayR = cv2.cvtColor(imgR, cv2.COLOR_BGR2GRAY) retL, cornersL = cv2.findChessboardCorners(grayL, CHECKERBOARD, None) retR, cornersR = cv2.findChessboardCorners(grayR, CHECKERBOARD, None) if retL and retR: objpoints.append(objp) cornersL2 = cv2.cornerSubPix(grayL, cornersL, (11, 11), (-1, -1), criteria) cornersR2 = cv2.cornerSubPix(grayR, cornersR, (11, 11), (-1, -1), criteria) imgpoints_left.append(cornersL2) imgpoints_right.append(cornersR2) # 单目标定 retL, mtxL, distL, rvecsL, tvecsL = cv2.calibrateCamera( objpoints, imgpoints_left, grayL.shape[::-1], None, None) retR, mtxR, distR, rvecsR, tvecsR = cv2.calibrateCamera( objpoints, imgpoints_right, grayR.shape[::-1], None, None)

这里calibrateCamera返回的ret就是重投影误差RMS,单位是像素。我建议每张图的RMS都打印出来看一下,如果某一对图片特别大,比如超过1像素,直接删掉这对图不参与标定,比硬着头皮用效果好得多。

2.3 双目标定:求解相机间的位置关系

单目标定完,每个相机各自的内参和畸变知道了,但两个相机之间差了多少旋转角度、平移了多少距离还没定。双目标定就是利用同一时刻看到的同一块标定板,求解这个相对位姿。

OpenCV的入口是stereoCalibrate,完整调法如下:

flags = cv2.CALIB_FIX_INTRINSIC ret, K1, d1, K2, d2, R, T, E, F = cv2.stereoCalibrate( objpoints, imgpoints_left, imgpoints_right, mtxL, distL, mtxR, distR, grayL.shape[::-1], flags=flags, criteria=criteria )

重点解释几个矩阵:R是右相机相对左相机的旋转矩阵,T是平移向量,它们描述了两台相机的空间关系。E是本质矩阵,只在归一化坐标下成立,包含R和T的信息;F是基础矩阵,直接建立在像素坐标上。后面立体校正时不直接用E和F,但可以用它们验证标定的合理性——比如对极几何约束:左右图像上的匹配点必须落在对应的极线上。

flags参数建议从CALIB_FIX_INTRINSIC开始,就是把单目标定得到的内参固定住,只优化R和T。如果你觉得单目标定不准,也可以换成CALIB_USE_INTRINSIC_GUESS,让优化过程微调内参。我个人习惯先FIX跑一版,看RMS;如果RMS太大,再放开内参联合优化一轮。

2.4 标定结果评估的经验标准

标定做完,千万别急着往下走,先看三个指标:

  1. 重投影误差RMS:单目标定低于0.3像素算良好,低于0.5像素可以接受;双目标定一般会比单目标定稍高,但超过1像素就说明某张图质量有问题,需要排查。
  2. 主点坐标合理性:cx、cy理论上应该在图像中心附近。如果图像分辨率是1280×720,主点却跑到(400, 500)这种离谱位置,说明标定退化了。
  3. 畸变矫正效果:用undistort把标定板图像矫正一下,棋盘格边缘的直线应该是一条直线,不再弯曲。

另外还有一个更直观的验证方法,在标定数据里进行单应性检查:取一对左右图,用F矩阵约束验证角点是否满足极线方程。偏差太大就说明配对有问题,及时回炉重做,别带着问题往下跑。

3. 立体校正:把两张图像"掰到同一水平线"

3.1 Bouguet算法到底在干什么

双目标定得到的两个相机之间的关系,是任意旋转和平移的。如果我们直接把左图和右图都画出来,同一个三维点在两幅图像中的位置既可能有水平差异,也可能有垂直差异。计算视差时,理论上需要在二维平面上搜索匹配点,非常慢而且容易误匹配。

Bouguet算法的目标,就是通过一个重投影变换,把左右图像变换到"虚拟的共面且行对齐"状态。通俗讲,歪着的两个相机被"掰正"成一台虚拟的双目相机——左右像素坐标系高度一致,同一个点在两图的坐标只差一个水平偏移,也就是极线被拉成了一条水平线。

OpenCV用一个函数完成:

R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( K1, d1, K2, d2, size, R, T, alpha=0, flags=cv2.CALIB_ZERO_DISPARITY )

输出参数含义要记住:R1、R2是左右相机的校正旋转矩阵;P1、P2是新的投影矩阵,包含校正后的内参和基线信息;Q是4×4的视差转深度矩阵,后面生成点云直接依赖它。alpha=0表示裁剪掉黑色边角,alpha=1则保留全图。我一般用alpha=0,输出图像比原图略小,但无用的无效边缘少。

3.2 用映射表重映射图像

拿到校正参数后,还需要把每个像素的映射关系算出来,这一步用initUndistortRectifyMap,生成mapx和mapy,然后remap一次性完成重映射:

mapxL, mapyL = cv2.initUndistortRectifyMap(K1, d1, R1, P1, size, cv2.CV_32FC1) mapxR, mapyR = cv2.initUndistortRectifyMap(K2, d2, R2, P2, size, cv2.CV_32FC1) rectifiedL = cv2.remap(imgL, mapxL, mapyL, cv2.INTER_LINEAR) rectifiedR = cv2.remap(imgR, mapxR, mapyR, cv2.INTER_LINEAR)

重映射后的图像要立刻验证:把两张图上下拼接显示,或者在同一张画布上叠加半透明视图,找画面中明显的边缘或角点,用鼠标点几个特征点,看它们的y坐标是否一致。不一致就回头检查校正流程,这里不值得花太多时间自动排查,肉眼扫一遍比啥都快。

一条实用技巧:mapx/mapy计算一次就可以缓存下来。如果你的双目相机是固定的,标定和校正映射只需要在程序启动时算一次,后续每帧直接remap,节省大量重复计算。

4. 视差计算与点云生成:从像素差到三维坐标

4.1 SGBM参数详解与调参策略

OpenCV里经典的视差算法有两个:BM(块匹配)和SGBM(半全局块匹配)。BM速度快但效果糙,一会一个裂缝;SGBM在纹理适中、光照一致场景下效果明显更好,代价是慢一点。本文用StereoSGBM_create,先看参数:

stereo = cv2.StereoSGBM_create( minDisparity=0, numDisparities=64, blockSize=15, P1=8 * 3 * blockSize ** 2, P2=32 * 3 * blockSize ** 2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, mode=cv2.STEREO_SGBM_MODE_SGBM ) disp = stereo.compute(rectifiedL, rectifiedR).astype(np.float32) / 16.0

逐个说参数怎么理解:

  • numDisparities必须能被16整除的整数,代表搜索的视差范围。范围越大能搜到越近的物体,但计算量线性增加。先用64起步,不够再加。
  • blockSize是匹配窗口边长,必须是奇数。越大对噪声越鲁棒,但会抹平细节,特别是细小物体的边缘。常见取值11~21。
  • P1、P2是平滑惩罚项,P2一般设为P1的4~8倍。它们控制视差图的光滑程度:P1惩罚相邻像素的小幅视差变化,P2惩罚大幅变化。用83blockSize^2这个经验公式起步比较稳。
  • uniquenessRatio是唯一性比率,某个像素的匹配代价要比次优匹配小多少才算有效匹配。设得太低会引入错误匹配,太高则产生大量空洞,10~15比较常用。
  • speckleWindowSize和speckleRange是斑块滤波器,用来剔除小的孤立噪声区域,百来像素的小斑点会被当噪声抹掉。
  • disp12MaxDiff是左右一致性检查阈值,用来消除遮挡区域的错误匹配,0表示禁用,1~2合适。

调参原则永远是一组一组试。我把同一场景的原始左图和视差图并排看,先调numDisparities看物体的轮廓是否立体,再调blockSize和uniquenessRatio看纹理区域的漏匹配情况。现实中十次有九次问题出在光照不均或低纹理区域,这时候再好的参数也白搭。

4.2 用WLS滤波提升视差质量

SGBM直接输出的视差图往往带有明显的"牙膏状"噪声,尤其在物体边缘。OpenCV提供createDisparityWLSFilter做加权最小二乘滤波,可以显著改善平滑度和边缘保持,代码也很短:

wls = cv2.ximgproc.createDisparityWLSFilter(stereo) right_matcher = cv2.ximgproc.createRightMatcher(stereo) dispL = stereo.compute(rectifiedL, rectifiedR) dispR = right_matcher.compute(rectifiedR, rectifiedL) dispL = wls.filter(dispL, rectifiedL, disparity_map_right=dispR)

注意这个WLS接口在opencv-contrib-python里,如果你装的是纯opencv-python可能没有ximgproc这个子模块。装的时候直接一条命令装opencv-contrib-python即可。使用WLS后,视差图的干净程度明显上一个档次,后续生成的点云不会有那么多飞点,这个优化强烈建议加上。

4.3 reprojectImageTo3D:把视差变成三维坐标

现在到了最激动人心的一步——把二维像素坐标和视差值还原成三维点坐标。这一步的理论基础是三角测量:两个相机观察同一点,根据视差和基线长度即可算出物体到相机的距离。OpenCV把这条数学捷径封装在reprojectImageTo3D里:

threeD = cv2.reprojectImageTo3D(disp, Q, handleMissingValues=True)

这里面Q矩阵就是前面stereoRectify返回的4×4矩阵。它的含义可以直观理解为:给定一个像素坐标(x, y)和该点的视差值d,经过Q变换就能得到三维坐标(X, Y, Z),其中Z方向的距离与1/d成正比。换句话说,物体越近,视差越大;视差为零,表示无穷远或无效区域。

handleMissingValues=True会把无效点(比如无穷远点)置为一个大数或NaN,方便后面过滤。我记得不同OpenCV版本对无效值的处理略有差异,稳妥的做法是生成后手动过滤一次:

mask = np.logical_and(np.isfinite(threeD[:, :, 2]), threeD[:, :, 2] > 0) valid_points = threeD[mask]

这样得到的valid_points是一个N×3的数组,每一行是一个有效三维点。要想测距,只需要对某个区域内的所有Z值取平均,比直接用单点稳定得多。

4.4 点云的保存、可视化与应用

生成的三维点可以写进PLY文件,这个格式简洁通用,MeshLab、Open3D、PCL都能直接打开。我写了一个简单的PLY写入函数,核心就是把点云数据按PLY文本格式输出:

def write_ply(filename, points, colors=None): with open(filename, 'w') as f: n = points.shape[0] f.write('ply\n') f.write('format ascii 1.0\n') f.write(f'element vertex {n}\n') f.write('property float x\n') f.write('property float y\n') f.write('property float z\n') if colors is not None: f.write('property uchar red\n') f.write('property uchar green\n') f.write('property uchar blue\n') f.write('end_header\n') for i in range(n): f.write(f'{points[i,0]:.6f} {points[i,1]:.6f} {points[i,2]:.6f}') if colors is not None: f.write(f' {int(colors[i,0])} {int(colors[i,1])} {int(colors[i,2])}') f.write('\n')

为了直观,我通常把原图的RGB颜色也带进点云,可视化时一眼能看出物体结构。加载时用Open3D几行就能显示:

import open3d as o3d pcd = o3d.io.read_point_cloud("output.ply") o3d.visualization.draw_geometries([pcd])

点云的用途很广泛:简单测距、平面拟合测体积、给机器人做障碍物检测、或者作为三维重建的输入源。我在这个系列里做到点云这一步就算完成了一个"最小闭环",后面接什么业务完全看需求。

5. 常见问题与排查技巧实录

5.1 棋盘格角点检测不稳定

最典型的报错是findChessboardCorners返回False,明明板子就在画面里。排查方向有四个:一是棋盘格内角点尺寸写错,比如实际是9×6却填成8×6;二是图像分辨率太大导致角点搜索失败,可以先缩放到1000像素宽再检测;三是光照太强导致反光,把灰度图模糊一下、做自适应阈值往往能缓解;四是棋盘格部分出界,角点没拍完整。我自己踩得最多的是第四个,拍的时候只顾着倾斜的角度,没注意边缘已经出画面了,这种图及时删掉,别硬用。

5.2 标定重投影误差很大怎么办

如果单目标定RMS飙到1像素以上,优先怀疑是某一对图像的角点定位不准。可以打印出每张图的RMS,找出最大的那张,看一下是不是有运动模糊、反光或者遮挡。删掉异常图重新标定,大概率RMS就降下来了。还有,如果相机分辨率很高,比如1200万像素,标定板格子又小,角点提取的亚像素精度会受影响——这时候把图像适当缩小或者选更大格子的标定板会有帮助。

5.3 视差图全是噪点或者大片黑洞

视差图黑掉的基本都是低纹理区域,比如白墙、天空、纯色桌面。SGBM本身对纹理极度敏感,解决办法是提高numDisparities找到正确的深度范围,同时把P1、P2调大让视差更连续。还有一种情况要特别注意:左右图没有经过立体校正,或者校正没成功,导致极线没对齐,SGBM会在纹理区域产生大量条纹噪声。这时候不用调参,回去做校正验证,把行对齐问题解决了再说。

5.4 点云翻转、坐标错乱、尺度不对

点云生成后,如果发现模型是镜像的或者坐标轴方向错乱,第一件事不是改代码,而是检查Q矩阵里基线和视差的符号。还有一个非常常见的坑:视差图里无效区域(比如没有匹配到的地方)赋了0值,reprojectImageTo3D会把视差0当作"无穷远",产生大量坐标极小或极大的离群点,显示出来就是一条条的放射线。解决的思路就是前面说的:生成点云后,用Z值范围过滤,例如把Z<0.1或者最远的5%的点全去掉。

尺度不对八成是标定板格尺寸填错了,这是最隐蔽的坑。我曾经标定板实际格距是23mm,代码里写了25mm,点云整体的尺寸就放大了将近9%,看起来不算离谱,但一旦要拿去测量就彻底露馅。所以标定前把物理尺寸记牢,别想当然。

5.5 性能优化建议

如果点云应用要在实时场景里跑,CPU版的SGBM在720P分辨率下大概每帧几百毫秒,明显不够用。两条路可以走:一是降低输入分辨率,用半分辨率跑视差,再把视差图放大回原尺寸,精度损失可控;二是编译opencv-contrib的CUDA版本,用cv2.cuda_StereoSGM或cv2.cuda.cvtColor做加速,我实测同样参数下速度能提升近一个数量级。另外,如果场景基线固定且相机固定,可以把校正映射表、Q矩阵全部预先算好,避免每帧重复计算。

这套流程我自己跑下来最大的体会是:标定环节省掉的功夫,都会在点云环节加倍还回来。少拍几张图、像素失真、格尺寸填错,这些问题一开始都很隐蔽,到点云一出错才发现前面哪一步都不确定,只能重新熬一遍标定流程。后来我学乖了,每采集完一组图像马上自动检查内角点检测率、打印重投影误差,有个异常就当场处理。双目立体真正妙的点在于它把"深度感知"的门槛拉得极低,两颗普通摄像头加一枚棋盘格就能构建出完整的三维理解。下一步我打算把生成的密集点云接到自监督深度估计模型上做交叉验证——用传统方法生成稀疏伪标签,再训练一个单目深度网络,这条路既能继承传统立体视觉的可靠性,又能享受深度模型的速度优势。如果你正在做双目相关项目,我建议先别急着上深度学习,把这套经典流程吃透,你不光能踩稳地基,更能在点云可视化的那一刻真正理解深度是怎么被算出来的。

返回列表