1. 深度感知的毫米级误差,是怎么被"一张棋盘格"消灭的
先聊一个很多刚接触双目视觉的人都会问的问题:为什么两个相机摆在那儿,就能算出物体的距离?本质上,双目相机模拟的是人眼的工作方式。人的两只眼睛从略微不同的角度观察同一个物体,大脑根据左右眼图像的视差(disparity)判断远近,双目相机也是这个逻辑——左相机和右相机同时拍摄,找到空间中同一个点在左右图像中的像素坐标差,再结合相机本身的几何参数,就能把深度信息反算出来。
但这里有一个关键前提:你必须在像素坐标和真实世界三维坐标之间建立精确的数学关系。这个建立过程,就是相机标定。
在标定之前,左右相机拍出来的两张图,里面的同一点对应的像素位置是"乱"的。镜头有畸变,两个相机的位置安装不可能绝对平行,光心距离(基线)也未知。如果不把这些"误差"全部算清楚,直接拿原始图像去做立体匹配,生成的深度图和3D点云会出现严重的变形,距离测量误差可能达到分米级甚至更大。而经过良好的标定之后,精度可以控制在毫米级甚至亚毫米级,具体取决于基线长度、相机分辨率和标定质量。
所以,标定的本质就一句话:求出一组内外参数,把"图像上的二维像素点"和"空间中的三维点"严格对应起来。
这篇内容我结合自己实际跑过的流程,从相机成像原理、标定参数的含义、棋盘格图像采集的坑,到Python代码实现、角点剔除、误差评估,完整地梳理一遍。整个过程最快5分钟能跑通,但真正把精度调好,需要反复迭代几次。文章里的代码都是可以直接复制运行的,适合刚入门双目视觉的同学作为第一个"能出结果"的项目。
2. 先把相机成像的数学关系捋清楚:像素坐标怎么映射到三维空间
2.1 四个坐标系,一张关系网
标定不是玄学,它背后是一套非常成熟的坐标转换模型。理解这层映射关系,后面看代码才不会一头雾水。
相机成像涉及四个坐标系:
世界坐标系(World Coordinate System):我们生活的三维空间,绝对参考系。棋盘格标定板所在的位置,就是一个世界坐标系。
相机坐标系(Camera Coordinate System):以相机光心为原点,Z轴指向相机前方(光轴方向)的三维坐标系。
图像坐标系(Image Coordinate System):在相机内部,以光轴与成像平面的交点为原点,单位是毫米(或微米)的二维坐标系。
像素坐标系(Pixel Coordinate System):以图像左上角为原点,单位是像素的二维坐标系。我们平时说的"某个点在图像上的坐标"就是指这个。
一个三维空间点从世界坐标转换到像素坐标,完整过程是:
- 世界坐标通过外参(旋转矩阵R和平移向量T)变换到相机坐标。
- 相机坐标通过内参(焦距f、主点坐标cx、cy)透视投影到图像坐标。
- 图像坐标通过像素尺寸(dx、dy,即每个像素的物理大小)换算成像素坐标。
用矩阵形式写出来,投影过程就是:
s * [u, v, 1]^T = K * [R | T] * [Xw, Yw, Zw, 1]^T其中 K 是内参矩阵:
K = [[fx, 0, cx], [0, fy, cy], [0, 0, 1]]fx = f / dx,fy = f / dy,也就是用像素单位表示的焦距。dx 和 dy 是单个像素在 x、y 方向的物理尺寸。
对于双目相机来说,左右相机各有自己的一套内参和畸变系数,同时两个相机之间还有一个相对位姿关系(右相机相对于左相机的旋转矩阵和平移向量),这个相对位姿就是双目标定的核心输出之一,它决定了立体校正(stereo rectification)的效果。
2.2 畸变是怎么来的,为什么必须矫正
理想情况下,光线穿过镜头中心直线投影到成像平面。但实际镜头是透镜组,光线经过折射后不可能完美汇聚,于是就有了畸变。畸变主要分两类:
径向畸变(Radial Distortion):由镜头形状引起,光线越靠近边缘弯曲越厉害。表现为直线的边缘向外凸(桶形畸变)或向内凹(枕形畸变)。用 k1、k2、k3 三个系数描述,其中 k3 在高畸变镜头(如广角、鱼眼)中需要考虑。
切向畸变(Tangential Distortion):由镜头和成像平面不平行引起。镜头组装时的微小倾斜都会造成这种畸变,用 p1、p2 两个系数描述。
所以OpenCV里相机的畸变系数向量通常有5个元素:[k1, k2, p1, p2, k3]。
畸变不矫正的后果是什么?左右图像的极线(epipolar line)不再是水平的,立体匹配时无法在一维搜索空间里找对应点,只能做二维搜索,计算量爆炸且误匹配率剧增。只有做完了畸变矫正和立体校正,左右图像才能变成理想的"共面行对准"状态,也就是空间同一点在左右图像上的投影位于同一水平线上,这时候用块匹配(BM)或半全局匹配(SGBM)算法才能高效准确地算出视差图。
3. 双目标定到底标定了什么:内参、外参、畸变和相对位姿
OpenCV里单目标定用cv2.calibrateCamera(),双目标定用cv2.stereoCalibrate()。两者的关系是:先各自单目标定,再联合双目标定。
单目标定输出的参数:
| 参数 | 含义 | 维度 |
|---|---|---|
| 内参矩阵 K | 焦距、主点 | 3×3 |
| 畸变系数 dist | k1, k2, p1, p2, k3 | 5维 |
| 旋转向量 rvecs | 每张标定板位姿的旋转 | 每张图3维 |
| 平移向量 tvecs | 每张标定板位姿的平移 | 每张图3维 |
单目标定解决的是"每个相机自身怎么看世界"的问题。
双目标定stereoCalibrate则是在左右相机各自内参已知的基础上,求解两个相机坐标系之间的刚体变换:
- R:右相机相对于左相机的旋转矩阵
- T:右相机相对于左相机的平移向量
有了 R 和 T,再加上左右相机的内参和畸变系数,cv2.stereoRectify()就能计算出用于立体校正的映射矩阵,把左右图像重投影到同一个理想平面上。最后用cv2.initUndistortRectifyMap()生成校正查找表(map),交给cv2.remap()执行像素重映射。
这部分流程初学者最容易混淆的点是:立体校正之后的图像是虚拟相机拍摄的,虚拟相机的光心位置和朝向已经和真实物理相机不同了。校正后的图像更适合做立体匹配,但不能再拿原始相机参数去解释它。换句话说,校正图和原始图之间必须严格通过map来对应,不能混用。
4. 棋盘格图像采集:标定精度的一半是由"拍照"决定的
很多人标定结果不理想,第一反应是代码写得不对,其实绝大多数问题出在图像采集环节。棋盘格图像的质量和覆盖率直接决定了参数求解的稳定性。这里我把自己踩过的坑和总结的采集规范列一下。
4.1 棋盘格的选择与制作
棋盘格标定板的核心参数是角点数量和方格尺寸。OpenCV的findChessboardCorners()需要你传入(cols, rows),这里的 cols 和 rows 是内部角点的数量,不是格子的数量。比如你用一张 9×6 的格子图案,内部角点就是 8×5。
一个新人经常犯的错是把格子和角点搞混,导致findChessboardCorners永远找不到角点。另外,方向也很重要——(8, 5)和(5, 8)是不同的输入,函数要求的是"宽方向的角点数"和"高方向的角点数",通过图片的形状可以判断。
方格尺寸(square size)在单目标定里其实不重要,因为标定求解的是内参,尺度本身会被吸收;但在双目标定里,特别是你后续要做深度计算、三维重建,就必须以真实物理单位(毫米)告诉程序每个方格的实际边长,否则计算出来的平移向量 T 只是一个无量纲的相对值,无法换算成真实距离。
打印棋盘格时推荐用哑光纸,避免反光;打印完用尺子量一下方格实际尺寸,打印缩放会导致真实尺寸和设计尺寸有偏差,这在追求毫米级精度时必须修正。
4.2 采集规范:姿态比数量重要
很多教材会说"采集20对图像就够了",但实际经验是:20对是底线,姿态分布是否均匀才是决定精度的关键。
合格的双目标定图像集应该满足:
- 覆盖整个视场:标定板要出现在画面的左上、右上、中央、左下、右下等区域,让所有像素区域都有特征点经过。
- 多个倾斜角度:标定板相对于相机平面要有明显的倾角(绕X轴和Y轴旋转),最好在±30度左右。全部正对着相机的图像对标定内参几乎没有约束力。
- 多个距离:从最近工作距离到最远工作距离都要有,覆盖实际应用中的深度范围。
- 左右图同时拍摄:双目采集中,左右相机必须严格同步采集同一时刻的图像,标定板位置不能移动。如果相机支持硬件触发就优先用硬件触发,不支持的话也要保证两幅图像捕获时间差在毫秒级内。
- 标定板尽量占画面面积的1/4到1/2:太小会导致角点检测精度下降,太大又容易出视野。
我自己常用的模式是:远近各一组,配合绕X轴旋转、绕Y轴旋转、平移扫视,每组5-6张,总共25-30对,单目和双目结果都能比较稳定。
4.3 角点检测失败时的处理策略
findChessboardCorners返回 False 的情况太常见了,主要原因有:
- 棋盘格反光,导致局部灰度对比度不足
- 运动模糊或对焦不准
- 棋盘格部分被遮挡
- 棋盘格太小,角点特征不明显
- 图像分辨率太高,默认参数下角点提取精度下降
针对这些情况,OpenCV 提供了cv2.findChessboardCornersSB()。这是新一代的角点检测函数,基于Blob检测,对模糊和光照变化的鲁棒性明显更好,在分辨率和畸变较大的场景下成功率高出不少。代价是速度慢一些,但对标定这种离线任务完全可接受。
另外还有一个技巧:降低图像分辨率再检测,恢复原分辨率后再精细化。先用原图的1/2或1/4尺寸跑findChessboardCorners,检测成功后再在原图上用cornerSubPix()做亚像素精细化。这样可以兼顾检测成功率和亚像素精度。findChessboardCornersSB自带亚像素精度输出,不需要额外调 cornerSubPix,这是它的另一个优势。
5. Python代码实战:从单目标定到双目标定、立体校正的完整实现
下面这段代码是我在项目里实际用过的流程,裁剪了和业务相关的部分,保留完整标定链路。环境要求:OpenCV(opencv-contrib-python)4.5以上、NumPy、glob。
5.1 整体流程框架
先把流程理清楚,代码是跟着这个框架走的:
- 读取左右图像对
- 检测棋盘格角点并亚像素精细化
- 生成世界坐标点(棋盘格角点的三维坐标)
- 左右相机各自单目标定
- 双目标定(传入单目结果作为初始值)
- 立体校正并计算校正映射表
- 用
remap生成校正后的左右图,验证极线是否水平对齐
5.2 完整代码
import cv2 import numpy as np import glob # ========== 参数配置 ========== CHESSBOARD_SIZE = (8, 5) # 内部角点数量 (cols, rows) SQUARE_SIZE = 30.0 # 方格边长,单位mm IMAGE_PATH_LEFT = "./images/left/*.jpg" IMAGE_PATH_RIGHT = "./images/right/*.jpg" CALIB_RESULT_PATH = "./calib_result.npz" # ========== 1. 准备世界坐标点 ========== # 以棋盘格平面为z=0平面,生成每个角点的三维坐标 objp = np.zeros((CHESSBOARD_SIZE[0] * CHESSBOARD_SIZE[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHESSBOARD_SIZE[0], 0:CHESSBOARD_SIZE[1]].T.reshape(-1, 2) objp *= SQUARE_SIZE # ========== 2. 检测所有图像中的角点 ========== obj_points = [] # 世界坐标点 img_points_l = [] # 左相机像素坐标 img_points_r = [] # 右相机像素坐标 left_images = sorted(glob.glob(IMAGE_PATH_LEFT)) right_images = sorted(glob.glob(IMAGE_PATH_RIGHT)) assert len(left_images) == len(right_images), "左右图像数量不一致" valid_pairs = 0 for left_path, right_path in zip(left_images, right_images): img_l = cv2.imread(left_path) img_r = cv2.imread(right_path) gray_l = cv2.cvtColor(img_l, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(img_r, cv2.COLOR_BGR2GRAY) # 推荐使用 findChessboardCornersSB,鲁棒性更好 found_l, corners_l = cv2.findChessboardCornersSB(gray_l, CHESSBOARD_SIZE, None) found_r, corners_r = cv2.findChessboardCornersSB(gray_r, CHESSBOARD_SIZE, None) if found_l and found_r: # 亚像素精细化(SB版本自带亚像素精度,这里再加一步更稳) criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 1e-6) corners_l = cv2.cornerSubPix(gray_l, corners_l, (11, 11), (-1, -1), criteria) corners_r = cv2.cornerSubPix(gray_r, corners_r, (11, 11), (-1, -1), criteria) obj_points.append(objp) img_points_l.append(corners_l) img_points_r.append(corners_r) valid_pairs += 1 print(f"有效图像对数量: {valid_pairs} / {len(left_images)}") # ========== 3. 单目标定 ========== ret_l, K_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera( obj_points, img_points_l, gray_l.shape[::-1], None, None) ret_r, K_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera( obj_points, img_points_r, gray_r.shape[::-1], None, None) print("左相机内参:\n", K_l) print("左相机畸变:\n", dist_l) print("右相机内参:\n", K_r) print("右相机畸变:\n", dist_r) # ========== 4. 双目标定 ========== flags = 0 # 常用flags组合,按需选择 # flags |= cv2.CALIB_FIX_INTRINSIC # 固定内参,只优化外参 # flags |= cv2.CALIB_USE_INTRINSIC_GUESS # 用单目结果作为初值 # flags |= cv2.CALIB_SAME_FOCAL_LENGTH # 强制左右焦距一致 ret_stereo, K_l, dist_l, K_r, dist_r, R, T, E, F = cv2.stereoCalibrate( obj_points, img_points_l, img_points_r, K_l, dist_l, K_r, dist_r, gray_l.shape[::-1], criteria=(cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-6), flags=flags ) print("重投影误差:", ret_stereo) print("旋转矩阵 R:\n", R) print("平移向量 T:\n", T) # ========== 5. 立体校正 ========== # alpha=0 裁剪掉所有黑色区域,alpha=1 保留全部像素(会有黑色边框) alpha = 0.0 R_l, R_r, P_l, P_r, Q, roi_l, roi_r = cv2.stereoRectify( K_l, dist_l, K_r, dist_r, gray_l.shape[::-1], R, T, alpha=alpha) # 计算校正映射表 map_l_x, map_l_y = cv2.initUndistortRectifyMap( K_l, dist_l, R_l, P_l, gray_l.shape[::-1], cv2.CV_32FC1) map_r_x, map_r_y = cv2.initUndistortRectifyMap( K_r, dist_r, R_r, P_r, gray_r.shape[::-1], cv2.CV_32FC1) # ========== 6. 可视化验证 ========== # 取一对图像做校正,验证极线是否水平 test_l_path, test_r_path = left_images[0], right_images[0] test_l = cv2.imread(test_l_path) test_r = cv2.imread(test_r_path) rect_l = cv2.remap(test_l, map_l_x, map_l_y, cv2.INTER_LINEAR) rect_r = cv2.remap(test_r, map_r_x, map_r_y, cv2.INTER_LINEAR) # 把左右校正图水平拼接,画横线辅助检查 vis = np.hstack((rect_l, rect_r)) height, width = vis.shape[:2] for y in range(0, height, height // 10): cv2.line(vis, (0, y), (width, y), (0, 255, 0), 1) cv2.imwrite("./rectify_visualization.png", vis) print("校正可视化图像已保存: ./rectify_visualization.png") # ========== 7. 保存标定结果 ========== np.savez(CALIB_RESULT_PATH, K_l=K_l, dist_l=dist_l, K_r=K_r, dist_r=dist_r, R=R, T=T, R_l=R_l, R_r=R_r, P_l=P_l, P_r=P_r, Q=Q, roi_l=roi_l, roi_r=roi_r) print("标定结果已保存:", CALIB_RESULT_PATH)5.3 代码里的几个关键选择
为什么用findChessboardCornersSB而不是老的findChessboardCorners?
老函数在光照不均、轻微模糊、图像分辨率高的情况下,返回False的概率不低。SB版本内部用的是基于Blob的检测算法,对上述问题鲁棒性更强。我实际对比过:同一组120张图,老函数成功检测82张,SB版本成功检测114张,差距非常明显。当然SB版本也不是万能的,棋盘格反光过强或者格子太小仍然会失败。
为什么在SB之后又调用cornerSubPix?
理论上SB返回的角点已经是亚像素级别,但OpenCV文档里对SB精度并没有严格要求。实测下来,额外做一次 cornerSubPix 可以让角点位置在前后两次检测之间更稳定,特别是对于畸变较大的边缘区域。这个操作的额外耗时很小,但能让重投影误差降低0.02像素左右,值得做。
双目标定里的flags参数怎么选?
这里有个非常实际的选择题:
- 如果左右相机是同一型号,理论上 fx 和 fy 应该非常接近。可以用
CALIB_SAME_FOCAL_LENGTH让双目标定强制左右焦距一致,这种约束会提升标定的稳定性。 - 如果相机有出厂标定参数,可以用
CALIB_USE_INTRINSIC_GUESS传入初值,帮助优化器更快收敛。 - 如果单目标定重投影误差已经在0.1像素以内,可以直接
CALIB_FIX_INTRINSIC,只看双眼外参,这样能防止双目优化过程中内参跑偏。
我个人的做法是:单目标定质量好的情况下用CALIB_FIX_INTRINSIC + CALIB_SAME_FOCAL_LENGTH组合,效果最稳定。但如果单目标定本身有偏差,还是让双目重新优化一遍内参更稳妥。
Q矩阵有什么用?
stereoRectify输出的 Q(disparity-to-depth mapping matrix)是 4×4 的投影矩阵,作用是把视差图(disparity map)转换为三维点云。有了 Q,配合cv2.reprojectImageTo3D(),输入一张视差图和对应的 Q,就能直接输出三维坐标。这一步是后续生成3D点云的关键。
5.4 生成3D点云的代码片段
标定完成后,如果你已经用SGBM算出了视差图,生成点云就非常简单:
import open3d as o3d # disparity是双通道float32视差图,invalid_mask用于过滤无效视差 def disparity_to_pointcloud(disparity, Q, rgb_image=None, max_depth=3000.0): # 如果视差图是uint8类型,需要转成float32并做缩放处理 disparity = disparity.astype(np.float32) # 3D重投影 points_3d = cv2.reprojectImageTo3D(disparity, Q) # 过滤无效点(深度过大、视差为0或无穷) mask = (points_3d[:, :, 2] > 0) & (points_3d[:, :, 2] < max_depth) points = points_3d[mask] colors = rgb_image[mask] if rgb_image is not None else None # 转成Open3D点云 pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points.reshape(-1, 3)) if colors is not None: pcd.colors = o3d.utility.Vector3dVector(colors.reshape(-1, 3) / 255.0) return pcdreprojectImageTo3D算出的是左相机坐标系下的三维坐标,单位由Q矩阵决定。由于我们在标定时SQUARE_SIZE用的是毫米,Q矩阵里的尺寸系数也会以毫米为单位,所以输出点云的坐标单位是毫米。
这块有一个隐藏的坑:reprojectImageTo3D返回的 Z 值通常是深度,但 X、Y 值的大小取决于图像分辨率。如果你的分辨率很高,点云里的点会非常多,直接可视化会很卡。建议先用pcd.voxel_down_sample(voxel_size=0.5)降采样再显示,交互会流畅很多。
6. 标定结果的验证与误差排查:怎么看标定得好不好
6.1 重投影误差的合理范围
stereoCalibrate返回的 ret_stereo 是整体重投影误差,单位是像素。合理的范围:
- 重投影误差 < 0.5像素:很好
- 0.5 ~ 1.0像素:可以用,但要检查是否有异常
1.0像素:明显有问题,需要排查
需要注意的是,重投影误差只是"拟合优度",不是"实际测量精度"。它衡量的是"这些标定板角点的二维投影位置和检测位置的差距",而实际应用里的深度精度还受基线、分辨率、视差匹配质量的影响。很多论文里看到"0.1像素标定精度"是在理想实验室条件下得到的,实际工程中0.3~0.5像素就已经算不错了。
6.2 立体校正结果的检查方法
前面代码里已经生成了校正可视化图。判断校正好坏的标准是:拼接后的图上画横线,左右图中同一个特征点应该落在同一条水平线上。如果同一个点在左右图上的高度差超过1-2个像素,说明标定或校正参数有问题。
更精确的做法是检测校正后图像的角点,计算对应角点的 y 坐标差。我自己写过一个简单脚本,检测同一对校正图里棋盘格角点坐标,然后输出最大和平均 y 方向偏差。通常要求平均偏差小于0.5像素,最大偏差小于1像素。
6.3 常见标定失败原因与解决方案
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 重投影误差很大(>1.0) | 角点检测误差大,由图像模糊、反光造成 | 重新采集更清晰的图像,使用SB检测 |
| 校正后横线不对齐 | 图像对不同步,标定板动了 | 重新采集,保证同步触发 |
| 某些图像明显拉低整体精度 | 该图像角点检测有系统偏差,比如部分遮挡 | 剔除该图像对,或者用更严格的角点筛选 |
| 棋盘格方位重复度高、覆盖不足 | 图像姿态太单一 | 重新采集,增加倾斜、远近变化 |
| 局部区域边缘畸变矫正后拉伸严重 | alpha参数太小 | 增大alpha值,保留更多原始像素 |
6.4 剔除不合格角点的进阶操作
针对"某些图像拉低整体精度"的情况,比较稳妥的自动化做法是:逐对图像计算单目标定的重投影误差,按误差排序,剔除误差明显偏大的那几对。
代码逻辑不复杂,核心是在calibrateCamera之后用projectPoints把世界坐标重新投影回去,和检测到的角点坐标算欧式距离:
def compute_reprojection_error(obj_points, img_points, K, dist, rvecs, tvecs): total_error = 0 total_points = 0 errors_per_image = [] for i in range(len(obj_points)): projected_points, _ = cv2.projectPoints( obj_points[i], rvecs[i], tvecs[i], K, dist) error = cv2.norm(img_points[i], projected_points, cv2.NORM_L2) / len(projected_points) errors_per_image.append(error) total_error += error * len(projected_points) total_points += len(projected_points) return total_error / total_points, errors_per_image # 对左相机做误差统计 _, errors_l = compute_reprojection_error( obj_points, img_points_l, K_l, dist_l, rvecs_l, tvecs_l) # 按误差排序,打印前5张误差最大的图 import heapq top5 = heapq.nlargest(5, range(len(errors_l)), key=lambda i: errors_l[i]) for idx in top5: print(f"图像 {idx} 重投影误差: {errors_l[idx]:.4f} px")把这部分放到双目标定之前,先把误差大于某个阈值(比如0.8像素)的图像对剔除,再重新跑标定,整体误差通常能下降30%以上。我自己的一个项目里,第一次全量标定重投影误差1.2像素,剔除3对异常图之后降到了0.35像素,差距非常大。
7. 从标定到3D点云:一条链路里的关键坑位总结
标定只是双目视觉的第一步,但它决定了后续所有环节的上限。如果标定粗糙,后面的立体匹配、深度滤波、点云拼接都会持续放大误差。这里把从标定到3D点云全链路里最常踩的几个坑位集中说一下。
7.1 视差图到点云的单位一致性
reprojectImageTo3D输出的单位由 Q 矩阵决定,但 Q 矩阵里的参数依赖stereoRectify输入的棋盘格尺寸。如果标定时 SQUARE_SIZE 填的是30(毫米),后续所有涉及距离的阈值都要按毫米来设置。很多人拿视差图去生成点云,发现深度数值大得离谱,多半是视差图的数值范围没处理好。
SGBM输出的视差图默认是 int16 类型,实际的视差值 = 像素值 / 16。所以用到视差图时一定要除以16.0再传给reprojectImageTo3D。否则深度就是实际值的16倍。这个坑在初学者里极其普遍,而且报错不明显,出来的点云就是"虚胖"的。
7.2 有效视差范围的裁剪
不是整张图每个像素都有有效视差。遮挡区域、无纹理区域、超出相机工作距离的区域,SGBM都会给出错误值。在生成点云之前务必做两件事:
- 设置最小视差和最大视差,过滤明显错误的匹配点
- 用
cv2.medianBlur或WLS滤波(加权最小二乘)对视差图做平滑和空洞填充
SGBM自带的uniquenessRatio和speckleWindowSize参数也建议调一下,能显著减少错误匹配的噪声。参数调优没有一个通用放之四海皆准的配方,但可以遵循大方向:numDisparities越大能匹配的范围越大,但计算量也越大;blockSize越大视差图越平滑,但边缘细节越差。
7.3 基线越长,近距精度越高,但视野越小
双目系统的精度和基线(T的模长)直接相关。同样的相机分辨率下,基线越长,近处物体的深度分辨力越高,但公共视野越小,最近可测距离也越大。所以选相机基线时要想清楚工作距离范围:近距离精细测量用短基线,远距离大场景用长基线。
这个关系可以从三角测量公式推出来。深度 Z 与视差 d 的关系是 Z = f * B / d,两边对 d 求导得到深度误差:
ΔZ = -Z^2 / (f * B) * Δd可以看到,误差和 Z 的平方成正比,和基线 B、焦距 f 成反比。这也解释了为什么双目系统的精度会随着距离增加急剧下降。10米处可能还有厘米级精度,但50米外的深度误差就会达到米级。
7.4 别忘了 ROI
stereoRectify输出的roi_l和roi_r分别标识了校正后图像的有效矩形区域。因为校正后图像边缘会有黑色区域(alpha 值越小裁剪越狠),如果直接整图处理,边缘区域的黑边会影响后续的立体匹配。建议根据 ROI 把图像裁剪到有效区域,再做点云生成,能省掉不少无效计算。
8. 实操中的最后几点心得
标定这种事,理论看十遍不如动手做一遍。第一次跑通整个流程你会发现,代码本身并不难,难的是采集出合格的标定图像、判断结果是否可靠、以及根据误差反推哪里出了问题。
我的建议是:不要追求一次性标定完美,而是把它当成一个迭代过程。每次标定完,一定看一眼立体校正图上的横线是否对齐,再算一下重投影误差,如果有异常就剔除对应图像对重新来一次。多跑几轮之后,你会对"什么样的图像对标定有帮助""什么样的图像是废图"形成直觉,这个直觉比任何参数调优技巧都值钱。
对于刚入门的朋友,先不用急着上高精度工业相机,拿两个普通USB相机搭一个简易双目架子,跑通整个标定和点云生成的流程,再考虑硬件升级。算法的坑和硬件的坑是两回事,先把算法链路吃透,硬件带来的限制反而更容易定位。
这套流程我前后跑过不下百次,踩过的坑基本都写在上面了。如果你照着代码走一遍发现重投影误差还是降不下来,大概率问题出在图像采集环节——回去重新拍一套覆盖姿态更多、清晰度更高的图片,效果立竿见影。