做这套“红外相机 + RGB 相机外参对齐”项目,最初是因为一个多光谱融合的需求:需要把热成像画面准确地叠到可见光画面上。当时第一反应是去打印一张棋盘格标定板走常规流程,结果发现红外相机分辨率普遍低,正常距离下棋盘格角点根本提不出来,远距离又需要定制超大标定板,成本高还折腾。后来干脆换了个思路——不用标定板,纯靠自然场景里的特征点匹配解外参,配合 Python 采集脚本和 OpenCV 就能跑通整条链路。
这篇文章就记录一下这套免标定板外参对齐方案的完整实操过程,包括原理、采集脚本、求解代码和调试避坑记录。适合做深度相机(RealSense、Kinect)、红外热成像与 RGB 视觉融合、机器人多传感器标定的朋友参考。
1. 内容整体设计与思路拆解
1.1 标定板外参标定为什么会卡壳
先理清一个概念:外参标定要解决的问题,是把两个相机坐标系之间的旋转矩阵 R 和平移向量 t 求出来,也就是找到“同一个三维点在两个相机坐标系下如何互相转换”的变换关系。
常规做法是使用标定板。棋盘格或圆点阵列标定板的角点在物理空间中的位置已知,两个相机同时拍到标定板后,就建立了一组“同一标定板坐标系下的 3D 点对应到两个相机图像中 2D 点”的约束,通过解 PnP 或最小化重投影误差就能得到外参。这个方法本身很成熟,张正友标定法也是基于类似的思路。
但在实际项目中,我遇到三个很现实的问题:
- 红外相机的分辨率通常远低于 RGB 相机。比如常见的测温模组只有 160×120 或 320×240,棋盘格角点在这么低的分辨率下糊成一团,角点检测的成功率非常低。
- 工作距离远的时候,标定板必须做得非常大才能在画面里占足够大的比例,否则角点提取不稳定。我当时测的场景最远有五六米,按经验公式估算,标定板尺寸至少得做到 A1 以上,打印、携带、固定都很麻烦。
- 有些安装环境根本不允许摆标定板。比如相机装在机械臂末端或无人机下方,标定板没法平稳放置在公共视场内。
所以,不需要外部标定板、直接用场景固有纹理求解外参,就成了一个非常实用的备选方案。
1.2 免标定板方案的核心思路
免标定板方案的本质:用自然场景中的特征点代替标定板角点。只要场景里有足够丰富的纹理(墙角、桌面边缘、键盘、窗框、书本上的文字图案),两个相机都能拍到这些特征点,就能构建约束方程。
整个技术路线分两条分支:
- 两个相机都是普通单目视觉:提取特征点并匹配,计算基础矩阵 F 或本质矩阵 E,再分解出 R 和 t。这种方案适合红外热像仪 + RGB 相机的组合,因为热红外图虽然纹理弱,但往往仍有明显的轮廓和局部热点区域可以作为特征。
- 其中一个相机是深度相机(例如 RealSense 的 D435i):红外相机和深度相机的坐标系一致,可以直接从深度图取得特征点对应的 3D 坐标,然后在 RGB 图上找到对应 2D 点,用 PnP 求解外参。这类方案更加稳健,因为 3D-2D 对应关系直接由深度测量提供,不依赖基础矩阵分解的尺度模糊。
在 RealSense 这类深度相机场景中,相机本身的 SDK 已经提供了 IR 相机与 RGB 相机的对齐功能,官方标定的外参可以直接读取。但如果你使用的是非 RealSense 的红外模组 + RGB 相机组合,或者需要自定义安装位置,就需要自己完成这套标定流程。
1.3 方案选型对比与我的选择
我整理了不同方案的适用场景和优劣势:
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 棋盘格标定 | 近距离、高分辨率、可放置标定板 | 成熟、精度高 | 中远距离和低分辨率下角点检测难 |
| 自然特征点 + 基础/本质矩阵分解 | 两个普通相机、有纹理场景 | 无需任何标定物 | 只有相对尺度,t 方向受噪声影响 |
| 深度图辅助 PnP | 深度相机 + RGB 相机 | 尺度确定、稳定性强 | 需要深度图有效数据,测量距离有限 |
| 点云 ICP 对齐 | 两个都能获得稠密点云的相机 | 直接对齐深度数据 | 对初始位姿敏感,纹理弱时效果差 |
最终我的做法是组合方案:先用“自然特征点 + 本质矩阵分解”求一个初值,如果其中一个相机能提供深度信息,再用深度图辅助 PnP 精化。这样既不需要标定板,又能保证解的稳定性。
2. 核心细节解析与实操要点
2.1 图像采集的同步性问题
外参标定的前提是两张图像拍到的是同一时刻、同一场景。如果两个相机不同步,匹配的特征点在三维空间中没有对应关系,后面的数学计算全部没有意义。
RealSense 这类相机通过硬件同步,RGB 和 IR 流的采集时刻非常接近,基本可以忽略误差。普通 USB 摄像头只能通过软件尽量对齐时间戳,我的经验是:采集时相机和场景都保持静止,然后让相机小幅转动或移动位置,拍多组不同角度的图像对。这样即使时间戳不完全一致,静态场景下的误差也不会太大。
采集的姿势也很重要。要尽量让两个相机的公共视场区域覆盖整个图像的大半部分,不要只对着空旷的白墙。纹理太少会导致后面特征匹配直接失败。
2.2 红外图像的特征提取技巧
红外图像和可见光图像有本质区别。RGB 图纹理丰富,直接用 ORB、SIFT 都能提取出大量特征点。但红外热像仪输出的灰度图通常很“平”,对比度低,直接提取特征点会得到大量无效点。
我的经验是先做 CLAHE 自适应直方图均衡化,增强局部对比度,再提取特征点。这一步骤在实际项目中能大幅提升匹配数量。另一个技巧是如果红外图像分辨率太低,可以先对红外图像做双线性插值放大,再做特征提取,虽然不会增加实际信息,但能提高特征点坐标的亚像素精度,对后续分解矩阵有正向帮助。
2.3 本质矩阵分解为什么需要筛选四种解
本质矩阵 E 与 R、t 之间存在如下关系:
E = [t]× R
其中 [t]× 是平移向量 t 的反对称矩阵。当两个相机的内参 K 已知时,可以通过匹配点对求出 E,然后对 E 做 SVD 分解,得到四个可能的 (R, t) 组合。这四个解中有且只有一个满足“特征点在两个相机前方”的物理约束。
具体筛选方法:对每一组候选的 R 和 t,把匹配点对进行三角化,得到三维点的坐标,然后检查这个三维点在两个相机坐标系下的深度是否都为正。深度为正的点越多,说明这个候选解越符合实际场景。实际代码中,我会计算所有匹配点的正深度比例,选择比例最高的一组。
这里有一个容易踩的坑是 t 和 R 的符号约定。不同教材里 E 矩阵的分解公式可能存在细微差别(比如 R 是 R^T 还是 R),结果会导致旋转方向或平移方向整体反转。所以不要只取第一组解,四个都试一遍再筛选。
2.4 尺度问题与坐标系约定
自然特征点加本质矩阵分解得到的 t 是在“归一化坐标”意义下的,只有方向没有绝对尺度。也就是说,解出来的平移向量 t 的实际单位可能是“某个任意长度”,并不是米、厘米等单位。
这在纯视觉定位场景中如果没有绝对尺度信息,需要额外引入参照物或深度信息。对于深度相机方案,因为可以直接读取深度值,转为 3D 点后再做 PnP,所以解出来的 R 和 t 就是真实物理尺度下的外参,这也解释了为什么我最终选择用深度辅助 PnP 而不是纯单目分解作为最终方案。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
我使用 Python 3.8 环境,依赖库包括:
- opencv-python
- numpy
- pyrealsense2(针对 RealSense 相机,可选)
安装命令:
pip install opencv-python numpy pyrealsense2如果使用的是其他品牌红外相机,比如常见的 USB 热像仪,一般用厂商 SDK 拿到图像流后转成 numpy 数组即可,核心算法部分是通用的。
3.2 Python 采集脚本实现
先提供 RealSense 场景下的采集脚本。这个脚本的作用是同时取 RGB、红外、深度三路流,并保存为图像对和深度 npy 文件:
import pyrealsense2 as rs import cv2 import numpy as np import time import os os.makedirs("data", exist_ok=True) pipeline = rs.pipeline() config = rs.config() config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) config.enable_stream(rs.stream.infrared, 1, 640, 480, rs.format.y8, 30) config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) pipeline.start(config) try: for i in range(300): frames = pipeline.wait_for_frames() color_frame = frames.get_color_frame() ir_frame = frames.get_infrared_frame() depth_frame = frames.get_depth_frame() if not color_frame or not ir_frame or not depth_frame: continue color = np.asanyarray(color_frame.get_data()) ir = np.asanyarray(ir_frame.get_data()) depth = np.asanyarray(depth_frame.get_data()) timestamp = str(int(round(time.time() * 1000))) cv2.imwrite(f"data/color_{timestamp}.png", color) cv2.imwrite(f"data/ir_{timestamp}.png", ir) np.save(f"data/depth_{timestamp}.npy", depth) print(f"Saved frame {i}: color_{timestamp}.png") finally: pipeline.stop()如果是普通 USB 双摄像头,用 OpenCV 的 VideoCapture 同时打开两个设备:
import cv2 cap_left = cv2.VideoCapture(0) cap_right = cv2.VideoCapture(1) # 建议手动设置分辨率,保持两个相机一致 cap_left.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap_left.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap_right.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap_right.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) frame_id = 0 while True: ret_left, frame_left = cap_left.read() ret_right, frame_right = cap_right.read() if not ret_left or not ret_right: break combined = cv2.hconcat([frame_left, frame_right]) cv2.imshow("Left | Right", combined) key = cv2.waitKey(1) & 0xFF if key == ord('s'): cv2.imwrite(f"data/left_{frame_id}.png", frame_left) cv2.imwrite(f"data/right_{frame_id}.png", frame_right) print(f"Saved pair {frame_id}") frame_id += 1 elif key == 27: break cap_left.release() cap_right.release() cv2.destroyAllWindows()采集时的操作建议:先固定相机,保持画面中有明显的纹理区域(比如书架、桌面、墙上的海报),然后每按一次保存键,缓慢移动相机或旋转一个小角度。一组标定数据至少需要 20 到 30 对不同视角的图像。不要在一个位置拍太多张,那样视角变化太小,解算出来的外参约束不足。
3.3 标定求解核心流程
拿到图像对之后,标定求解分为六个步骤:
第一步,读取图像对并做预处理。红外图像先做 CLAHE:
import cv2 import numpy as np def preprocess_ir(img): clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)) if len(img.shape) == 3: img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) return clahe.apply(img)RGB 图像可以直接转灰度,不需要额外的增强处理。
第二步,提取并匹配 SIFT 特征点。SIFT 虽然比 ORB 慢,但匹配效果更稳定,尤其在低分辨率红外图上优势明显:
def extract_match(ir_img, rgb_img): sift = cv2.SIFT_create(nfeatures=5000) kp1, des1 = sift.detectAndCompute(ir_img, None) kp2, des2 = sift.detectAndCompute(rgb_img, None) bf = cv2.BFMatcher() matches = bf.knnMatch(des1, des2, k=2) good_matches = [] for match_pair in matches: if len(match_pair) != 2: continue m, n = match_pair if m.distance < 0.75 * n.distance: good_matches.append(m) src_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2) dst_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2) return src_pts, dst_pts, good_matches匹配完成后,用 RANSAC 剔除误匹配,同时计算基础矩阵 F 或本质矩阵 E:
def compute_essential(src_pts, dst_pts, K1, K2): # 用 RANSAC 估计基础矩阵,然后通过内参矩阵转换为本质矩阵 F, inlier_mask = cv2.findFundamentalMat( src_pts, dst_pts, cv2.FM_RANSAC, ransacReprojThreshold=1.0, confidence=0.99 ) E = K2.T @ F @ K1 return E, inlier_mask这里要注意:如果两个相机是同一个型号、内参相同,也可以直接用 cv2.findEssentialMat 一步到位,传入归一化坐标下的点即可。
第三步,对 E 做 SVD 分解得到四组候选 (R, t)。OpenCV 提供了 decomposeEssentialMat 函数,直接返回四个解:
def decompose_and_select(src_pts, dst_pts, K1, K2, E): R1, R2, t = cv2.decomposeEssentialMat(E) candidates = [(R1, t), (R1, -t), (R2, t), (R2, -t)] inliers_src = src_pts.reshape(-1, 2) inliers_dst = dst_pts.reshape(-1, 2) best_R, best_t = None, None max_positive_depth = -1 for R, tvec in candidates: positive_count = 0 for i in range(inliers_src.shape[0]): p1 = inliers_src[i] p2 = inliers_dst[i] # 三角化 p1_h = np.array([p1[0], p1[1], 1.0]) p2_h = np.array([p2[0], p2[1], 1.0]) M1 = np.hstack((np.eye(3), np.zeros((3, 1)))) M2 = np.hstack((R, tvec.reshape(3, 1))) # 归一化坐标 x1 = np.linalg.inv(K1) @ p1_h x2 = np.linalg.inv(K2) @ p2_h A = np.array([ x1[0] * M1[2, :] - M1[0, :], x1[1] * M1[2, :] - M1[1, :], x2[0] * M2[2, :] - M2[0, :], x2[1] * M2[2, :] - M2[1, :] ]) _, _, Vt = np.linalg.svd(A) X = Vt[-1] X = X / X[3] # 检查深度 depth1 = (M1 @ X)[2] depth2 = (M2 @ X)[2] if depth1 > 0 and depth2 > 0: positive_count += 1 if positive_count > max_positive_depth: max_positive_depth = positive_count best_R, best_t = R, tvec return best_R, best_t虽然 OpenCV 的 decomposeEssentialMat 内部已经做了正深度约束,但实际使用中发现返回的第一个解有时并不符合物理场景,所以手动再筛选一遍更稳妥。
第四步,用深度图辅助 PnP 精化。这一步是深度相机场景下的关键一步。思路是:用红外图上的特征点查询深度图,得到每个点在红外相机坐标系下的 3D 坐标,再和 RGB 图像上的 2D 点建立对应,用 solvePnPRansac 求解:
def refine_with_depth(ir_pts, rgb_pts, depth_img, K_ir, K_rgb, R_init, t_init): object_points = [] image_points = [] for i in range(ir_pts.shape[0]): u, v = int(round(ir_pts[i][0])), int(round(ir_pts[i][1])) if u < 0 or v < 0 or u >= depth_img.shape[1] or v >= depth_img.shape[0]: continue z = depth_img[v, u] * 0.001 # RealSense 深度单位是毫米,转成米 if z <= 0 or z > 10: continue x = (u - K_ir[0, 2]) * z / K_ir[0, 0] y = (v - K_ir[1, 2]) * z / K_ir[1, 1] object_points.append([x, y, z]) image_points.append(rgb_pts[i]) if len(object_points) < 15: return R_init, t_init object_points = np.array(object_points, dtype=np.float64).reshape(-1, 3) image_points = np.array(image_points, dtype=np.float64).reshape(-1, 1, 2) _, rvec, tvec, inliers = cv2.solvePnPRansac( object_points, image_points, K_rgb, None, rvec=cv2.Rodrigues(R_init)[0], tvec=t_init.reshape(3), useExtrinsicGuess=True, iterationsCount=100, reprojectionError=2.0, confidence=0.99 ) R_refined, _ = cv2.Rodrigues(rvec) return R_refined, tvec.reshape(3, 1)第五步,如果有必要,用非线性优化(BA)进一步精化。这一步更复杂,一般项目中用 solvePnPRansac 已经足够。如果追求更高精度,可以引入多对图像联合优化,把各帧图像的外参一起作为变量迭代优化,但这需要自己实现雅可比矩阵,普通项目收益不大。
第六步,评估结果。计算重投影误差:
def compute_reprojection_error(R, t, K_rgb, object_points, image_points): projected, _ = cv2.projectPoints(object_points, cv2.Rodrigues(R)[0], t, K_rgb, None) errors = np.linalg.norm(projected.reshape(-1, 2) - image_points.reshape(-1, 2), axis=1) return np.mean(errors), np.max(errors)我的实际经验是,室内场景、位置在两三米范围内,平均重投影误差能跑到 1 个像素以内;室外远距离则通常在 2 到 3 个像素。对多数视觉融合应用来说,这个精度已经够用。
3.4 对齐效果可视化验证
标定完成后,最直观的验证方式是把两路图像叠加到同一个坐标系下。一个简单的方式是:用外参把红外图像上的点投影到 RGB 图像上,生成一个红外到 RGB 的映射图,然后与 RGB 图做半透明叠加:
def visualize_alignment(R, t, K_ir, K_rgb, ir_img, rgb_img): h_ir, w_ir = ir_img.shape[:2] h_rgb, w_rgb = rgb_img.shape[:2] map_x = np.zeros((h_rgb, w_rgb), dtype=np.float32) map_y = np.zeros((h_rgb, w_rgb), dtype=np.float32) for v in range(0, h_ir, 2): # 隔行采样,提升速度 for u in range(0, w_ir, 2): # 假定一个固定深度,比如 2 米,因为这里只是可视化 z = 2.0 x_ir = (u - K_ir[0, 2]) * z / K_ir[0, 0] y_ir = (v - K_ir[1, 2]) * z / K_ir[1, 1] p_rgb = np.linalg.inv(K_rgb) @ (R @ np.array([x_ir, y_ir, z]) + t.flatten()) if p_rgb[2] <= 0: continue u_rgb = int(round((p_rgb[0] / p_rgb[2]) * K_rgb[0, 0] + K_rgb[0, 2])) v_rgb = int(round((p_rgb[1] / p_rgb[2]) * K_rgb[1, 1] + K_rgb[1, 2])) if 0 <= u_rgb < w_rgb and 0 <= v_rgb < h_rgb: map_x[v_rgb, u_rgb] = u map_y[v_rgb, u_rgb] = v remapped_ir = cv2.remap(ir_img, map_x, map_y, cv2.INTER_NEAREST) overlay = cv2.addWeighted(rgb_img, 0.6, cv2.cvtColor(remapped_ir, cv2.COLOR_GRAY2BGR), 0.4, 0) return overlay注意这种可视化依赖一个假定的深度值,只能用来检查外参的大致方向是否正确,真正的像素级对齐验证还是要把点云投影到 RGB 图上看边缘重合度。
4. 常见问题与排查技巧实录
4.1 特征点太少或匹配失败
这是我遇到最多的问题,尤其是红外热像仪,画面里只有几个热斑,SIFT 也提不出足够特征。
排查方向有三个:
- 场景是否有足够纹理?白墙、天空、纯色地面都不行。解决办法是换一个办公室角落或室外有明显建筑边缘的角度重新采集。
- 红外图对比度是否经过增强?CLAHE 的 clipLimit 从 2.0 到 5.0 都试一下,太小增强不够,太大容易出现噪点伪特征。
- 匹配阈值是否太严格?0.75 是经验值,如果特征点总数少,可以放宽到 0.85,但需要后续 RANSAC 剔除误匹配。
如果匹配点对少于 15 对,E 矩阵分解的结果基本不可信。我的标准是低于 30 对就会放弃当前图像对,换一组图。
4.2 外参解出来有翻转或整体错位
出现这种问题,首先检查四组候选解的筛选逻辑。如果正深度点数量相差不大,说明匹配点分布太集中或三角化精度不够,需要重新采集不同角度、不同距离的图像。
另一个常见问题是相机内参不对。外参标定的前提是内参准确,如果直接用标称内参(比如从相机参数表抄的 fx、fy、cx、cy),误差会传导到 E 矩阵计算上。我的建议是先用棋盘格对两个相机的内参分别做一次完整标定,或者至少用 OpenCV 的 calibrateCamera 快速验证内参是否准确。
还有一个隐蔽问题是图像坐标系方向理解错误。OpenCV 的图像坐标系是 x 向右、y 向下,而一些厂商 SDK 返回的数据可能是自上而下扫描,坐标定义可能不同。处理前先打印几个特征点的像素坐标和深度值,确认方向一致再跑算法。
4.3 t 的尺度对不上
纯单目方案求出的 t 是归一化尺度,这并不是 bug,而是数学上必然的结果。如果业务需要真实物理尺度,有两种解决方式:
- 用深度相机的深度图恢复尺度,就是我前面的 PnP 方案,直接输出米制结果。
- 场景中放一个已知长度的物体,计算 t 的模长与真实距离的比值作为尺度因子,校准后乘回去。
这里要提醒一点:即便用深度图辅助 PnP,深度图的误差也会直接影响外参精度。RealSense 在近距离(1 米内)深度误差通常能控制在几毫米内,但远距离(超过 5 米)深度误差会明显增大,所以标定距离尽量控制在 1 到 3 米。
4.4 图像同步问题导致匹配错位
两个独立摄像头之间如果时间不同步,在有运动物体或相机移动时,匹配点会出现系统性偏移。
我的建议是采集时保持相机固定,场景中不要有人走动,然后每拍一帧后手动旋转相机到新角度。当两路视频流的帧率不一致时(比如 RGB 是 30fps、红外是 15fps),采集程序的读取时间要错开,尽量用硬件触发方式采集,或至少用采集卡同时触发两个摄像头。
如果同步实在解决不了,一个技巧是降低对时间同步的依赖:采集一段较长时间的视频流,然后根据时间戳在离线阶段寻找最接近的图像帧匹配。但外参精度会打折。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 匹配点太少 | 场景纹理不足、红外图对比度低 | 换丰富纹理场景;CLAHE 增强;放宽匹配阈值到 0.8~0.85 |
| R、t 方向明显不对 | 四组解筛选错误;内参不准 | 手动检查正深度点数量;重新标定内参 |
| 重投影误差 5 像素以上 | 特征点误匹配;同步误差;深度图噪声 | 加大 RANSAC 阈值;提高 SIFT 阈值;换近距离数据重新解算 |
| t 尺度偏大或偏小 | 单目本质矩阵解的尺度模糊 | 使用深度图 PnP 或在场景中放置已知长度参照物 |
| 叠加后边缘有 2~3 像素偏移 | 外参精度尚可,但内参与畸变未矫正 | 先对图像做去畸变处理,再重新求解外参 |
| 不同图像对求解结果不一致 | 某个图像对特征匹配质量差 | 逐对检查匹配质量,移除质量差的图像对后重新联合优化 |
4.6 实时对齐的轻量化处理
标定完成后,如果要在实时视频流中做红外和 RGB 对齐,要注意性能优化。逐帧做特征匹配是不现实的,正确做法是把标定得到的 R、t 固化成配置参数,运行时通过预计算的映射表做重映射。
映射表只需要在启动时计算一次,之后每帧用 cv2.remap 就能完成图像对齐,在普通 CPU 上也能跑到 30fps 以上。逻辑是:把红外图像上的每个像素按外参转换到 RGB 坐标系,生成两个方向的映射表(map_x、map_y),之后运行时直接查表即可。
预计算映射表的代码思路:
def build_remap_table(R, t, K_ir, K_rgb, ir_shape, rgb_shape, depth_fixed): map_x = np.full((rgb_shape[0], rgb_shape[1]), -1, dtype=np.float32) map_y = np.full((rgb_shape[0], rgb_shape[1]), -1, dtype=np.float32) for v in range(ir_shape[0]): for u in range(ir_shape[1]): # 固定深度假设,用于平面场景近似对齐 z = depth_fixed x = (u - K_ir[0, 2]) * z / K_ir[0, 0] y = (v - K_ir[1, 2]) * z / K_ir[1, 1] p = K_rgb @ (R @ np.array([x, y, z]) + t.flatten()) if p[2] <= 0: continue u_rgb = int(round(p[0] / p[2])) v_rgb = int(round(p[1] / p[2])) if 0 <= u_rgb < rgb_shape[1] and 0 <= v_rgb < rgb_shape[0]: map_x[v_rgb, u_rgb] = u map_y[v_rgb, u_rgb] = v return map_x, map_y如果场景深度起伏较大,平面的近似对齐会出现局部偏移。更通用的做法是在运行时拿到深度图,每个像素用真实深度计算投影点,这在 GPU 上实现非常高效,CPU 上则建议降低输出分辨率来保证帧率。
最后再分享一个小技巧:采集数据时,尽可能让两个相机的位置差异大一些(增加平移分量),这样特征点的深度差异更明显,三角化精度和 E 矩阵分解的稳定性都会提升。不要为了省事把两个相机装得几乎重合,那样 t 的求解会出现很大的相对误差。我自己做这个项目踩过几次坑之后,最后把相机间距固定在大约 5 到 8 厘米,整个标定流程才算真正稳定下来。后续你如果也做多光谱融合或深度相机外参对齐,可以按这个思路先跑通初版,再用工程数据不断修正。