ARTICLE DETAIL

资讯详情

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

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

OpenCV双目立体视觉:从相机标定到三维点云生成实战 双目立体视觉这个方向我最早是被一个需求拉进来的——给机器人加一双眼睛让它能估算前方障碍物的距离。做过一圈调研后发现OpenCV里的双目立体流程是成本最低、最容易上手的一条路两个普通USB摄像头、一块棋盘格标定板、几百行代码就能从两张图像算出深度信息甚至生成三维点云。这也是97个OpenCV实例系列第三十讲的主题从标定到点云。这篇文章我把完整流程拆开讲不光说步骤还把每一步的原理、参数选择和实际踩坑记录一并交代清楚适合正在用OpenCV做双目测距或三维重建的开发者参考。1. 项目整体链路拆解为什么是标定→校正→视差→点云1.1 双目视觉比其他方案强在哪说到获取深度信息市面上主流无非三种方案消费级深度相机比如结构光的Kinect、飞行时间ToF、主动立体RealSense、激光雷达、以及纯视觉的双目立体。深度相机和激光雷达好用但都有局限。深度相机受环境光影响大在室外强光下经常直接失明激光雷达精度高但价格让人肉疼而且对反射面、透明物体也会翻车。双目立体最大的优势是便宜又皮实——两个普通RGB摄像头加一套标定流程就能在户外环境里获得稠密的深度估计。它在近距离几米到十几米的精度对于测距、避障、简单三维重建来说足够用而且你可以自由选择摄像头参数适配不同视野和基线长度的需求。当然双目也不是万能的。它对场景纹理敏感面对白墙这类低纹理区域基本算不出视差计算量与图像分辨率成正比对嵌入式平台是个压力标定质量直接决定后面的精度这也是为什么我把标定单独拉出来重点讲。理解这些特性你才好在实际项目里做取舍——比如要不要补结构光、要不要加IMU辅助、要不要降分辨率跑视差。1.2 四个环节的数据流整个项目可以压缩成一句话通过两幅有视差的图像恢复每个像素的三维坐标。但这句话落地成程序需要经历四个明确的环节相机标定确定左右相机各自的内参焦距、主点、畸变系数以及相机之间的相对位置关系旋转矩阵和平移向量。立体校正把两幅图像重投影到一个公共平面上使对应点在水平方向严格对齐这样视差搜索从二维降到一维。视差计算在左右图中找到每个匹配点计算它们之间的水平像素差也就是视差。视差越小说明物体越远视差越大说明物体越近。点云生成利用标定得到的Q矩阵把像素坐标视差值变换成三维坐标最后保存成点云文件用于可视化和后续处理。这四个环节是一环扣一环的前面标定的误差会在后面放大稍后我就会讲到重投影误差这个关键指标。我在项目里始终遵循一个原则每一步先做可视化验证确认无误再进下一步。比如标定完先undistort几张图像看看边缘的直线是不是变直了校正完把左右图并排看同一特征点是否在同一水平线上。这样做的好处是问题早暴露不会等到生成点云后才发现坐标全歪了。1.3 环境准备与OpenCV版本选择我用的环境是Python 3.9 OpenCV 4.8.0opencv-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], flagsflags, criteriacriteria )重点解释几个矩阵R是右相机相对左相机的旋转矩阵T是平移向量它们描述了两台相机的空间关系。E是本质矩阵只在归一化坐标下成立包含R和T的信息F是基础矩阵直接建立在像素坐标上。后面立体校正时不直接用E和F但可以用它们验证标定的合理性——比如对极几何约束左右图像上的匹配点必须落在对应的极线上。flags参数建议从CALIB_FIX_INTRINSIC开始就是把单目标定得到的内参固定住只优化R和T。如果你觉得单目标定不准也可以换成CALIB_USE_INTRINSIC_GUESS让优化过程微调内参。我个人习惯先FIX跑一版看RMS如果RMS太大再放开内参联合优化一轮。2.4 标定结果评估的经验标准标定做完千万别急着往下走先看三个指标重投影误差RMS单目标定低于0.3像素算良好低于0.5像素可以接受双目标定一般会比单目标定稍高但超过1像素就说明某张图质量有问题需要排查。主点坐标合理性cx、cy理论上应该在图像中心附近。如果图像分辨率是1280×720主点却跑到400 500这种离谱位置说明标定退化了。畸变矫正效果用undistort把标定板图像矫正一下棋盘格边缘的直线应该是一条直线不再弯曲。另外还有一个更直观的验证方法在标定数据里进行单应性检查取一对左右图用F矩阵约束验证角点是否满足极线方程。偏差太大就说明配对有问题及时回炉重做别带着问题往下跑。3. 立体校正把两张图像掰到同一水平线3.1 Bouguet算法到底在干什么双目标定得到的两个相机之间的关系是任意旋转和平移的。如果我们直接把左图和右图都画出来同一个三维点在两幅图像中的位置既可能有水平差异也可能有垂直差异。计算视差时理论上需要在二维平面上搜索匹配点非常慢而且容易误匹配。Bouguet算法的目标就是通过一个重投影变换把左右图像变换到虚拟的共面且行对齐状态。通俗讲歪着的两个相机被掰正成一台虚拟的双目相机——左右像素坐标系高度一致同一个点在两图的坐标只差一个水平偏移也就是极线被拉成了一条水平线。OpenCV用一个函数完成R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( K1, d1, K2, d2, size, R, T, alpha0, flagscv2.CALIB_ZERO_DISPARITY )输出参数含义要记住R1、R2是左右相机的校正旋转矩阵P1、P2是新的投影矩阵包含校正后的内参和基线信息Q是4×4的视差转深度矩阵后面生成点云直接依赖它。alpha0表示裁剪掉黑色边角alpha1则保留全图。我一般用alpha0输出图像比原图略小但无用的无效边缘少。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( minDisparity0, numDisparities64, blockSize15, P18 * 3 * blockSize ** 2, P232 * 3 * blockSize ** 2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32, modecv2.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_rightdispR)注意这个WLS接口在opencv-contrib-python里如果你装的是纯opencv-python可能没有ximgproc这个子模块。装的时候直接一条命令装opencv-contrib-python即可。使用WLS后视差图的干净程度明显上一个档次后续生成的点云不会有那么多飞点这个优化强烈建议加上。4.3 reprojectImageTo3D把视差变成三维坐标现在到了最激动人心的一步——把二维像素坐标和视差值还原成三维点坐标。这一步的理论基础是三角测量两个相机观察同一点根据视差和基线长度即可算出物体到相机的距离。OpenCV把这条数学捷径封装在reprojectImageTo3D里threeD cv2.reprojectImageTo3D(disp, Q, handleMissingValuesTrue)这里面Q矩阵就是前面stereoRectify返回的4×4矩阵。它的含义可以直观理解为给定一个像素坐标(x, y)和该点的视差值d经过Q变换就能得到三维坐标(X, Y, Z)其中Z方向的距离与1/d成正比。换句话说物体越近视差越大视差为零表示无穷远或无效区域。handleMissingValuesTrue会把无效点比如无穷远点置为一个大数或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, colorsNone): with open(filename, w) as f: n points.shape[0] f.write(ply\n) f.write(format ascii 1.0\n) f.write(felement 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值范围过滤例如把Z0.1或者最远的5%的点全去掉。尺度不对八成是标定板格尺寸填错了这是最隐蔽的坑。我曾经标定板实际格距是23mm代码里写了25mm点云整体的尺寸就放大了将近9%看起来不算离谱但一旦要拿去测量就彻底露馅。所以标定前把物理尺寸记牢别想当然。5.5 性能优化建议如果点云应用要在实时场景里跑CPU版的SGBM在720P分辨率下大概每帧几百毫秒明显不够用。两条路可以走一是降低输入分辨率用半分辨率跑视差再把视差图放大回原尺寸精度损失可控二是编译opencv-contrib的CUDA版本用cv2.cuda_StereoSGM或cv2.cuda.cvtColor做加速我实测同样参数下速度能提升近一个数量级。另外如果场景基线固定且相机固定可以把校正映射表、Q矩阵全部预先算好避免每帧重复计算。这套流程我自己跑下来最大的体会是标定环节省掉的功夫都会在点云环节加倍还回来。少拍几张图、像素失真、格尺寸填错这些问题一开始都很隐蔽到点云一出错才发现前面哪一步都不确定只能重新熬一遍标定流程。后来我学乖了每采集完一组图像马上自动检查内角点检测率、打印重投影误差有个异常就当场处理。双目立体真正妙的点在于它把深度感知的门槛拉得极低两颗普通摄像头加一枚棋盘格就能构建出完整的三维理解。下一步我打算把生成的密集点云接到自监督深度估计模型上做交叉验证——用传统方法生成稀疏伪标签再训练一个单目深度网络这条路既能继承传统立体视觉的可靠性又能享受深度模型的速度优势。如果你正在做双目相关项目我建议先别急着上深度学习把这套经典流程吃透你不光能踩稳地基更能在点云可视化的那一刻真正理解深度是怎么被算出来的。
返回列表