SLAM常见功能代码段整理汇总2

Sep 10,2025   37988 words   136 min

Tags: SLAM

1.ORB特征点提取和匹配

ORB特征提取和匹配可参考这篇博客。ORB特征提取主要包含FAST角点提取和BRIEF描述子计算两个步骤。匹配则采用简单的暴力匹配即可。Python代码(依赖OpenCV)如下:

import cv2

# 程序参数
num_features = 500  # ORB特征点数量
disTh = 20.0    # 距离阈值

# Step1. 读取影像
img1 = cv2.imread('frame01.jpg')
img2 = cv2.imread('frame02.jpg')

# Step2. 获取ORB特征点
orb = cv2.ORB_create(nfeatures=num_features)
kps1, des1 = orb.detectAndCompute(img1, None)
kps2, des2 = orb.detectAndCompute(img2, None)

# Step3. 匹配特征点
# Step3.1 使用BFMatcher进行暴力匹配
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des1, des2)

# Step3.2 获取当前匹配最小距离
min_dis = 10000
for item in matches:
    dis = item.distance
    if dis < min_dis:
        min_dis = dis

# Step3.3 根据距离预置进一步筛选匹配
filtered_matches = []
for match in matches:
    if match.distance <= max(1.1 * min_dis, disTh):
        filtered_matches.append(match)

# Step3.4 提取匹配点坐标
good_kps1 = []
good_kps2 = []
for i in range(len(filtered_matches)):
    good_kps1.append(kps1[filtered_matches[i].queryIdx].pt)
    good_kps2.append(kps2[filtered_matches[i].trainIdx].pt)

# 检查并可视化匹配结果
from HaveFun import vision
print("Number of good matches", len(good_kps1))
img_match = vision.drawMatches(img1, good_kps1, img2, good_kps2)
cv2.imwrite('Matches.png', img_match)

2.位姿估计

2.1 帧间运动估计(2D-2D)

基于两帧的2D特征点匹配结果2D(Image Feature)-2D(Image Feature),求解两帧之间的空间运动(旋转和平移),相关理论参考这篇博客这篇博客

总体而言,在获取到两帧间的同名点(2D-2D)后,可利用对极几何求解两帧之间的运动(旋转和平移)。进一步细分为两步:变换矩阵求解和矩阵分解求位姿。

对于变换矩阵求解,包括本质矩阵(适用非平面场景、解算需相机内参)、基础矩阵(适用非平面场景、解算不需相机内参)和单应矩阵(适用平面场景、解算不需相机内参),在OpenCV中都有相应函数可以调用:cv2.findEssentialMat()cv2.findFundamentalMat()cv2.findHomography()

对于矩阵分解求运动,三种变换均需要知道相机的内参矩阵。OpenCV提供了针对本质矩阵和单应矩阵的分解函数:cv2.recoverPose()cv2.decomposeHomographyMat()。对于基础矩阵,可以先通过内参矩阵将其转化为本质矩阵,再调用本质矩阵分解函数。矩阵分解求运动的核心是SVD方法。

总体而言,2D-2D的帧间运动估计多用于SLAM的初始化阶段,从而快速建立三维地图点和后序的运动尺度参考。

2.1.1 基于本质矩阵求解

本质矩阵核心利用的是对极几何约束构造了多个方程。由于匹配得到的点对个数远大于未知数个数,因此,对于本质矩阵分解得到平移和旋转,核心是对其进行SVD分解寻求最小二乘解。具体是,对系数矩阵进行SVD分解,然后找到奇异值矩阵中最小奇异值所对应的奇异向量,即为方程的最小二乘解。最后,基于分解得到的U、Σ、V,按照公式计算旋转和平移。详细理论推导可参考这篇博客这篇博客。Python代码如下:

import cv2
import numpy as np

# 程序参数
num_features = 500  # ORB特征点数量
disTh = 20.0    # 距离阈值
K = np.array([[100, 0, 960], 
                [0, 100, 540], 
                [0, 0, 1]]) # 影像大小为1920x1080,取理论内参

# Step1. 读取影像
img1 = cv2.imread('frame01.jpg')
img2 = cv2.imread('frame02.jpg')

# Step2. 获取ORB特征点
orb = cv2.ORB_create(nfeatures=num_features)
kps1, des1 = orb.detectAndCompute(img1, None)
kps2, des2 = orb.detectAndCompute(img2, None)

# Step3. 匹配特征点
# Step3.1 使用BFMatcher进行暴力匹配
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des1, des2)

# Step3.2 获取当前匹配最小距离
min_dis = 10000
for item in matches:
    dis = item.distance
    if dis < min_dis:
        min_dis = dis

# Step3.3 根据距离预置进一步筛选匹配
filtered_matches = []
for match in matches:
    if match.distance <= max(1.1 * min_dis, disTh):
        filtered_matches.append(match)

# Step3.4 提取匹配点坐标
good_kps1 = []
good_kps2 = []
for i in range(len(filtered_matches)):
    good_kps1.append(kps1[filtered_matches[i].queryIdx].pt)
    good_kps2.append(kps2[filtered_matches[i].trainIdx].pt)

# 对于本质矩阵,调用函数分解
E, _ = cv2.findEssentialMat(np.float32(good_kps1), np.float32(good_kps2), K, cv2.RANSAC)
_, R_E, t_E, _ = cv2.recoverPose(E, np.float32(good_kps1), np.float32(good_kps2), K)
print("Essential Matrix:\n", E)
print("Rotation from Essential Matrix:\n", R_E)
print("Translation from Essential Matrix:\n", t_E)
2.1.2 基于基础矩阵求解

前面也提到,基础矩阵包含相机内参矩阵和本质矩阵。所以我们可以先将其转化为本质矩阵,再按照2.1.1部分本质矩阵的分解得到旋转和平移,可以参考这篇博客。Python代码如下:

import cv2
import numpy as np

# 程序参数
num_features = 500  # ORB特征点数量
disTh = 20.0    # 距离阈值
K = np.array([[100, 0, 960], 
                [0, 100, 540], 
                [0, 0, 1]]) # 影像大小为1920x1080,取理论内参

# Step1. 读取影像
img1 = cv2.imread('frame01.jpg')
img2 = cv2.imread('frame02.jpg')

# Step2. 获取ORB特征点
orb = cv2.ORB_create(nfeatures=num_features)
kps1, des1 = orb.detectAndCompute(img1, None)
kps2, des2 = orb.detectAndCompute(img2, None)

# Step3. 匹配特征点
# Step3.1 使用BFMatcher进行暴力匹配
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des1, des2)

# Step3.2 获取当前匹配最小距离
min_dis = 10000
for item in matches:
    dis = item.distance
    if dis < min_dis:
        min_dis = dis

# Step3.3 根据距离预置进一步筛选匹配
filtered_matches = []
for match in matches:
    if match.distance <= max(1.1 * min_dis, disTh):
        filtered_matches.append(match)

# Step3.4 提取匹配点坐标
good_kps1 = []
good_kps2 = []
for i in range(len(filtered_matches)):
    good_kps1.append(kps1[filtered_matches[i].queryIdx].pt)
    good_kps2.append(kps2[filtered_matches[i].trainIdx].pt)

# 对于基础矩阵,无直接分解函数,可以先转为本质矩阵再分解
F, _ = cv2.findFundamentalMat(np.float32(good_kps1), np.float32(good_kps2), cv2.RANSAC)
E_F = np.matmul(K.T, np.matmul(F, K))
_, R_F, t_F, _ = cv2.recoverPose(E_F, np.float32(good_kps1), np.float32(good_kps2), K)
print("Fundamental Matrix:\n", F)
print("Rotation from Fundamental Matrix:\n", R_F)
print("Translation from Fundamental Matrix:\n", t_F)
2.1.3 基于单应矩阵求解

对于单应矩阵的分解,可能存在多种情况。需要结合三角化做进一步判断,可以参考这篇博客这篇博客。Python代码如下:

import cv2
import numpy as np

# 程序参数
num_features = 500  # ORB特征点数量
disTh = 20.0    # 距离阈值
K = np.array([[100, 0, 960], 
                [0, 100, 540], 
                [0, 0, 1]]) # 影像大小为1920x1080,取理论内参

# Step1. 读取影像
img1 = cv2.imread('frame01.jpg')
img2 = cv2.imread('frame02.jpg')

# Step2. 获取ORB特征点
orb = cv2.ORB_create(nfeatures=num_features)
kps1, des1 = orb.detectAndCompute(img1, None)
kps2, des2 = orb.detectAndCompute(img2, None)

# Step3. 匹配特征点
# Step3.1 使用BFMatcher进行暴力匹配
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des1, des2)

# Step3.2 获取当前匹配最小距离
min_dis = 10000
for item in matches:
    dis = item.distance
    if dis < min_dis:
        min_dis = dis

# Step3.3 根据距离预置进一步筛选匹配
filtered_matches = []
for match in matches:
    if match.distance <= max(1.1 * min_dis, disTh):
        filtered_matches.append(match)

# Step3.4 提取匹配点坐标
good_kps1 = []
good_kps2 = []
for i in range(len(filtered_matches)):
    good_kps1.append(kps1[filtered_matches[i].queryIdx].pt)
    good_kps2.append(kps2[filtered_matches[i].trainIdx].pt)

# 对于单应矩阵,调用函数分解
good_kps1_mat = np.float32(good_kps1)
good_kps2_mat = np.float32(good_kps2)

H, _ = cv2.findHomography(good_kps1_mat, good_kps2_mat, cv2.RANSAC)
_, R_H, t_H, normal_H = cv2.decomposeHomographyMat(H, K)
print("Homography Matrix:\n", H)
print("Rotation from Homography Matrix:\n", R_H)
print("Translation from Homography Matrix:\n", t_H)

# 检查分解结果
best_solution = None
max_valid_ratio = -1

for i in range(len(R_H)):
    cur_R = R_H[i]
    cur_t = t_H[i]
    cur_n = normal_H[i]

    # 条件1:旋转矩阵有效性
    flag_rotation = np.allclose(cur_R.T @ cur_R, np.eye(3), atol=1e-3) and abs(np.linalg.det(cur_R) - 1) < 1e-3
    if not flag_rotation:
        continue

    # 条件2:三角化正深度
    # 三角化3D点
    P1 = np.matmul(K, np.hstack((np.eye(3), np.zeros((3,1)))))
    P2 = np.matmul(K, np.hstack((cur_R, cur_t)))
    points_4d = cv2.triangulatePoints(P1, P2, good_kps1_mat.T.astype(np.float32), good_kps2_mat.T.astype(np.float32))
    
    # 转换为非齐次坐标并计算深度
    points_3d = cv2.convertPointsFromHomogeneous(points_4d.T)[:,0,:]
    depths = points_3d[:,2]
    
    # 计算正深度点比例
    valid_mask = depths > 0
    valid_ratio = np.mean(valid_mask)
    
    # 计算重投影误差
    reproj_pts = cv2.projectPoints(points_3d[valid_mask], cur_R, cur_t, K, None)[0].squeeze()
    errors = np.linalg.norm(good_kps2_mat[valid_mask,:2] - reproj_pts, axis=1)
    mean_error = np.mean(errors) if len(errors) > 0 else float('inf')

    print("Valid Ratio:", valid_ratio, "Mean Error:", mean_error)

    if valid_ratio > max_valid_ratio:
        max_valid_ratio = valid_ratio
        best_solution = (cur_R, cur_t, cur_n)

print("Best Solution:", best_solution)
2.2 帧间运动估计(2D-3D)

和前面2D-2D求解帧间运动类似。如果我们找到了某一帧上的特征点和三维地图点之间的关系2D(Image Feature)-3D(Point Cloud),也可以求解运动(位姿),这便是PnP问题(Perspective-n-Point),可以参考这篇博客。假设二维特征点为x1,三维空间点位X1,将它们各自增加一维写成齐次。它们通过一个3×4的矩阵P(Projection Matrix)变换,λ表示从相机光心到真实三维点的距离。与单应矩阵H类似的,矩阵P中包含了内参K以及外参R、t,只要我们求得了P并且知道K,就可以恢复出R、t。PnP问题常见包括直接线性变换(DLT)解法、P3P解法、EPnP解法等。在OpenCV中,有专门的cv2.solvePnP()函数可以使用(支持多种常用算法)。Python代码如下:

import numpy as np
import cv2


if __name__ == "__main__":
    # 1. 生成模拟数据
    # 模拟8个立方体角点的3D坐标 (单位:米)
    object_points = np.array([
        [0, 0, 0], [1, 0, 0], [1, 1, 0], [0, 1, 0],
        [0, 0, 1], [1, 0, 1], [1, 1, 1], [0, 1, 1]
    ], dtype=np.float32)

    # 模拟相机内参 (假设640x480分辨率)
    camera_matrix = np.array([
        [800, 0, 320],
        [0, 800, 240],
        [0, 0, 1]
    ], dtype=np.float32)

    # 模拟相机位姿 (旋转和平移)
    rvec = np.array([0.3, 0.5, 0.2], dtype=np.float32)
    tvec = np.array([0.5, -0.3, 2.0], dtype=np.float32)

    # 投影生成2D点
    image_points, _ = cv2.projectPoints(
        object_points, rvec, tvec, camera_matrix, None)
    
    obj_pts = object_points
    img_pts = image_points.squeeze()
    K = camera_matrix
    dist_coeffs = np.zeros(4)  # 假设无畸变

    # 2. 使用EPnP算法求解
    success, rvec, tvec = cv2.solvePnP(
        obj_pts, img_pts, K, dist_coeffs,
        flags=cv2.SOLVEPNP_EPNP
    )

    if not success:
        raise RuntimeError("PnP求解失败")

    # 3. 结果转换与输出
    R, _ = cv2.Rodrigues(rvec)
    print("=== 真实位姿 ===")
    true_rvec, _ = cv2.Rodrigues(np.array([0.3, 0.5, 0.2]))
    print("真实旋转矩阵:\n", true_rvec)
    print("真实平移向量:\n", np.array([0.5, -0.3, 2.0]))

    print("\n=== 估计位姿 ===")
    print("旋转矩阵:\n", R)
    print("平移向量:\n", tvec)

    # 4. 计算重投影误差
    reprojected, _ = cv2.projectPoints(obj_pts, rvec, tvec, K, dist_coeffs)
    error = np.linalg.norm(reprojected.squeeze() - img_pts, axis=1).mean()
    print("\n平均重投影误差:", error, "像素")
2.3 帧间运动估计(3D-3D)

最后,在SLAM位姿估计中还有一种常见的情况,就是已知3D(Point Cloud)-3D(Point Cloud)的关联,求解相机的运动(这里认为不存在尺度上的变化,是三维刚体变换)。这个场景在LiDAR SLAM中很常见(比如在第一帧扫描到50个点,第二帧扫描到80个点,利用描述子匹配等操作,得到30个三维匹配点)。这里需要明确的是,我们的已知量是两帧观测得到的三维点,变换未知。更简洁来说就是,已知状态A和状态B,求解A到B的变换。而在之前这篇博客里提到的三维空间变换则稍有区别。那里我们介绍的是,已知状态A和变换T,求解状态B。

对于3D-3D的运动求解,核心是基于关联关系构建方程。经典解法是SVD方法,在其基础上,针对不同场景,进一步又衍生出ICP方法和RANSAC方法。

2.3.1 经典SVD方法求解

由于方程数远远多于需要的点对(3对),所以可以用矩阵SVD分解的方法进行求解,然后基于分解的U、Σ、V,按照公式解算旋转和平移。简单来说包含:计算质心、去中心化、构建协方差矩阵、SVD分解、计算旋转矩阵、计算平移向量这几个步骤。进一步可以参考这个博客这个博客。Python代码如下:

import numpy as np


if __name__ == "__main__":
    # 1. 生成测试数据
    source = np.array([
        [0,0,0], [1,0,0], [1,1,0], [0,1,0],
        [0.5, 0.5, 0.5], [0.3, 0.2, 0.5], [0.7, 0.8, 0.5]])

    R_true = np.array([[0.866, -0.5, 0], [0.5, 0.866, 0], [0,0,1]])  # 30度旋转
    t_true = np.array([0.3, -0.2, 0.5])
    target = (R_true @ source.T).T + t_true
    
    # 2. SVD求解变换矩阵
    # 计算质心
    centroid_src = np.mean(source, axis=0)
    centroid_tgt = np.mean(target, axis=0)
    
    # 去中心化
    src_centered = source - centroid_src
    tgt_centered = target - centroid_tgt
    
    # 构建协方差矩阵
    H = src_centered.T @ tgt_centered
    
    # SVD分解
    U, _, Vt = np.linalg.svd(H)
    
    # 计算旋转矩阵
    R_est = Vt.T @ U.T
    
    # 处理反射情况(当点云存在镜像对称性时,配准结果出现镜像翻转现象)
    if np.linalg.det(R_est) < 0:
        Vt[-1,:] *= -1
        R_est = Vt.T @ U.T
    
    # 计算平移向量
    t_est = centroid_tgt - R_est @ centroid_src

    # 输出结果
    print("Estimated Rotation Matrix:\n", R_est)
    print("Estimated Translation Vector:\n", t_est)

    # 3. 评估结果
    aligned = (R_est @ source.T).T + t_est
    distances = np.linalg.norm(aligned - target, axis=1)
    mean_err = np.mean(distances)
    max_err = np.max(distances)

    print("旋转矩阵误差:", np.linalg.norm(R_true - R_est))
    print("平移向量误差:", np.linalg.norm(t_true - t_est))
    print("平均点距离:", mean_err)
    print("最大点距离:", max_err)
2.3.2 ICP方法求解

ICP(Iterative Closest Point)是SLAM中用于点云配准的核心技术,通过迭代计算两组点云之间的最优变换矩阵(旋转和平移)实现环境建模和定位。ICP通过循环执行“匹配→求解→变换”流程逐步逼近最优解,其核心思想是匹配源点云和目标点云的对应点,然后最小化对应点之间的距离残差以求解变换矩阵。ICP算法的优点是简单高效,缺点是对初始值比较敏感,若两个点集存在较大初始变换,容易陷入局部最优。因此,ICP方法适用于两个点云变换不大的情况,一般要求点云存在部分重叠且初始位姿偏差不宜过大(旋转角<30°)‌,否则可以考虑先进行粗配准。

此外,这里需要特别说明ICP方法和SVD方法的异同。前面介绍的SVD方法它的使用前提条件之一是两个点云之间一一对应且关系明确,而ICP算法不要求两个点集之间有一一对应关系(通过迭代搜索最近邻点建立)。在三维点云配准中,尽管SVD可直接求解已知对应点的变换矩阵,但ICP方法的核心价值在于解决‌对应点未知‌这一关键问题,并通过迭代优化提升鲁棒性。这种情况在实际场景中更加常见,因此,ICP算法被广泛应用于LiDAR SLAM的前端中,用于进行初始位姿和运动估计。Python代码如下:

import numpy as np
import matplotlib.pyplot as plt

def visualize_3d(src, dst, transformed=None):
    fig = plt.figure(figsize=(10, 8))
    ax = fig.add_subplot(111, projection='3d')
    ax.scatter(dst[:,0], dst[:,1], dst[:,2], c='blue', label='Target', alpha=0.6)
    ax.scatter(src[:,0], src[:,1], src[:,2], c='red', label='Source', alpha=0.6)
    if transformed is not None:
        ax.scatter(transformed[:,0], transformed[:,1], transformed[:,2], 
                  c='green', label='Aligned', alpha=0.6)
    ax.set_xlabel('X')
    ax.set_ylabel('Y')
    ax.set_zlabel('Z')
    ax.legend()
    plt.show()

def nearest_neighbor_3d(src, dst):
    indices = []
    for i in range(src.shape[0]):
        dist = np.linalg.norm(dst - src[i], axis=1)
        indices.append(np.argmin(dist))
    return np.array(indices)

def icp_3d(src, dst, max_iter=50, tolerance=1e-5):
    T = np.eye(4)
    src_h = np.column_stack([src, np.ones(len(src))])
    
    for i in range(max_iter):
        # 最近邻搜索
        indices = nearest_neighbor_3d(src_h[:,:3], dst)
        correspondences = dst[indices]
        
        # 计算变换矩阵
        src_centered = src_h[:,:3] - np.mean(src_h[:,:3], axis=0)
        dst_centered = correspondences - np.mean(correspondences, axis=0)
        H = src_centered.T @ dst_centered
        
        # SVD分解求旋转
        U, _, Vt = np.linalg.svd(H)
        R = Vt.T @ U.T
        if np.linalg.det(R) < 0:  # 反射修正
            Vt[2,:] *= -1
            R = Vt.T @ U.T
        
        # 计算平移
        t = np.mean(correspondences, axis=0) - R @ np.mean(src_h[:,:3], axis=0)
        
        # 构造齐次变换矩阵
        T_current = np.eye(4)
        T_current[:3,:3] = R
        T_current[:3,3] = t
        
        # 更新变换
        T = T_current @ T
        src_h = (T_current @ src_h.T).T
        
        # 收敛判断
        error = np.mean(np.linalg.norm(src_h[:,:3] - correspondences, axis=1))
        if error < tolerance:
            print(f"Converged at iteration {i} with error {error:.6f}")
            break
            
    return T[:3], src_h[:,:3]

if __name__ == "__main__":
    # 1. 构造测试数据:三维立方体
    src_cube = np.array([[0,0,0], [1,0,0], [0,1,0], [0,0,1], 
                        [1,1,0], [1,0,1], [0,1,1], [1,1,1]])

    # 定义三维旋转矩阵
    theta_z = np.radians(10)
    theta_x = np.radians(5)
    Rz = np.array([[np.cos(theta_z), -np.sin(theta_z), 0],
                [np.sin(theta_z), np.cos(theta_z), 0],
                [0, 0, 1]])
    Rx = np.array([[1, 0, 0],
                [0, np.cos(theta_x), -np.sin(theta_x)],
                [0, np.sin(theta_x), np.cos(theta_x)]])
    R_true = Rz @ Rx
    t_true = np.array([0.5, 0.2, 0.3])  # 三维平移

    dst_cube = (R_true @ src_cube.T).T + t_true

    # 2. 执行配准
    transform, aligned = icp_3d(src_cube, dst_cube)
    rotation_matrix = transform[:3, :3]
    translation_vector = transform[:3, 3]
    print("Estimated transform:\n", transform)
    print("Estimated rotation matrix:\n", rotation_matrix)
    print("Estimated translation vector:\n", translation_vector)

    # 3. 可视化
    visualize_3d(src_cube, dst_cube, aligned)
2.3.3 RANSAC方法求解

最后,介绍RANSAC方法(随机采样一致性)。前面说了,SVD方法适合于3D-3D一一对应的场景。但在实际中,可能出现错误的对应关系,进而影响整个SVD方法求解的最后结果。针对这种场景,可以将传统SVD方法与RANSAC方法进行结合,解决离群点问题。Python代码如下:

import numpy as np
import random

def estimate_rigid_transform(src_points, dst_points):
    # 计算质心
    src_centroid = np.mean(src_points, axis=0)
    dst_centroid = np.mean(dst_points, axis=0)
    
    # 中心化点集
    src_centered = src_points - src_centroid
    dst_centered = dst_points - dst_centroid
    
    # 计算H矩阵
    H = np.dot(src_centered.T, dst_centered)
    
    # SVD分解
    U, _, Vt = np.linalg.svd(H)
    R = np.dot(Vt.T, U.T)
    
    # 处理反射情况
    if np.linalg.det(R) < 0:
        Vt[-1,:] *= -1
        R = np.dot(Vt.T, U.T)
    
    # 计算平移
    t = dst_centroid - np.dot(R, src_centroid)
    
    # 组合变换矩阵
    transform = np.column_stack((R, t))
    return transform

def ransac_rigid_transform(src_points, dst_points, max_iters=1000, inlier_thresh=5.0, min_inlier_ratio=0.5):
    best_transform = None
    best_inliers = []
    best_error = float('inf')
    
    num_points = src_points.shape[0]
    min_sample = 3  # 最小样本数
    
    for _ in range(max_iters):
        # 随机采样
        sample_indices = random.sample(range(num_points), min_sample)
        sample_src = src_points[sample_indices]
        sample_dst = dst_points[sample_indices]
        
        # 估计变换
        try:
            transform = estimate_rigid_transform(sample_src, sample_dst)
        except:
            continue
        
        # 应用变换
        ones = np.ones((num_points, 1))
        src_homo = np.hstack((src_points, ones))
        transformed = np.dot(src_homo, transform.T)
        
        # 计算误差
        errors = np.linalg.norm(transformed - dst_points, axis=1)
        inliers = np.where(errors < inlier_thresh)[0]
        inlier_ratio = len(inliers) / num_points
        
        # 更新最佳结果
        if inlier_ratio > min_inlier_ratio:
            current_error = np.mean(errors[inliers])
            if current_error < best_error:
                best_error = current_error
                best_transform = transform
                best_inliers = inliers
    
    # 使用所有内点重新估计最终变换
    if best_transform is not None and len(best_inliers) >= min_sample:
        best_transform = estimate_rigid_transform(src_points[best_inliers], dst_points[best_inliers])
    
    return best_transform, best_inliers

if __name__ == "__main__":
    # 1. 构造测试数据:三维立方体
    src_cube = np.array([[0,0,0], [1,0,0], [0,1,0], [0,0,1], 
                        [1,1,0], [1,0,1], [0,1,1], [1,1,1]])

    # 定义三维旋转矩阵
    theta_z = np.radians(10)
    theta_x = np.radians(5)
    Rz = np.array([[np.cos(theta_z), -np.sin(theta_z), 0],
                [np.sin(theta_z), np.cos(theta_z), 0],
                [0, 0, 1]])
    Rx = np.array([[1, 0, 0],
                [0, np.cos(theta_x), -np.sin(theta_x)],
                [0, np.sin(theta_x), np.cos(theta_x)]])
    R_true = Rz @ Rx
    t_true = np.array([0.5, 0.2, 0.3])  # 三维平移

    dst_cube = (R_true @ src_cube.T).T + t_true

    # 添加离群点
    dst_cube[0] += 10
    dst_cube[5] += 5


    # 使用RANSAC估计变换
    transform, inliers = ransac_rigid_transform(src_cube, dst_cube)
    rotation = transform[:3, :3]
    translation = transform[:3, 3]
    
    print("估计的变换矩阵:\n", transform)
    print("旋转矩阵:\n", rotation)
    print("平移向量:\n", translation)
    print("内点数量:", len(inliers))
    print("内点索引:", inliers)

当然,理论上也可以将RANSAC方法与ICP方法结合。

3.RANSAC方法

前面我们使用RANSAC方法对离群点进行了简单的去除。事实上,采用Least Square+SVD的方法可以十分有效地解决问题。Least Square用于构造方程组,SVD则用于解方程组。但这里有个问题,最小二乘方法虽然很棒,但是对离群点十分敏感,可能个别离群点会导致解的不稳定。Random Sample Consensus,随机采样一致性,是去除离群点(Outlier)的好办法。核心思想是找到一个适合绝大多数样本的模型,对于离群点则选择忽略,详细可以参考这个博客这个博客。RANSAC主要流程如下:

  • 从数据点中随机选择指定数量的点构建模型
  • 将全部数据点带入模型中计算与真实数据差异,根据误差阈值判定内点或外点
  • 统计内点个数并记录对应模型参数
  • 重复上述步骤指定次数得到多个结果,选取内点数量最多对应的模型参数即认为正确模型

演示Python代码如下,主要参考这篇博客

import numpy as np
import random
from matplotlib import pyplot as plt
import math


def getPointPairData(x_start=0, y_start=0, x_end=60, y_end=60, point_num=100, offset_x=35, offset_y=40):
    x_data1 = []
    y_data1 = []
    x_data2 = []
    y_data2 = []
    for i in range(point_num):
        x_data1.append(random.randint(x_start, x_end))
        y_data1.append(random.randint(y_start, y_end))
        x_data2.append(x_data1[i] + offset_x)
        y_data2.append(y_data1[i] + offset_y)
    return x_data1, y_data1, x_data2, y_data2


def addNoiseToPointPair(x_data1, y_data1, x_data2, y_data2, mu=0, sigma=1):
    noise_x_data1 = []
    noise_y_data1 = []
    noise_x_data2 = []
    noise_y_data2 = []
    for i in range(len(x_data1)):
        noise_x_data1.append(x_data1[i] + random.gauss(mu, sigma))
        noise_y_data1.append(y_data1[i] + random.gauss(mu, sigma))
        noise_x_data2.append(x_data2[i] + random.gauss(mu, sigma))
        noise_y_data2.append(y_data2[i] + random.gauss(mu, sigma))
    return noise_x_data1, noise_y_data1, noise_x_data2, noise_y_data2


def addOutliersToPointPair(x_data1, y_data1, x_data2, y_data2, num_outlier, mu=0, sigma=15):
    noise_x_data1 = x_data1[:]
    noise_y_data1 = y_data1[:]
    noise_x_data2 = x_data2[:]
    noise_y_data2 = y_data2[:]
    indices = []
    for i in range(num_outlier):
        index = random.randint(0, len(x_data1) - 1)
        indices.append(index)
        noise_x_data1[index] = x_data1[index] + random.gauss(mu, sigma)
        noise_y_data1[index] = y_data1[index] + random.gauss(mu, sigma)
        noise_x_data2[index] = x_data2[index] + random.gauss(mu, sigma)
        noise_y_data2[index] = x_data2[index] + random.gauss(mu, sigma)
    return noise_x_data1, noise_y_data1, noise_x_data2, noise_y_data2, indices


def drawMatchPairsWithOutliers(x_data1, y_data1, x_data2, y_data2, indices, color="lightgreen"):
    plt.title("Point pairs")
    plt.scatter(x_data1, y_data1, label="original point")
    plt.scatter(x_data2, y_data2, label="matched point")
    for i in range(len(x_data1)):
        plt.plot([x_data1[i], x_data2[i]], [y_data1[i], y_data2[i]], color=color)
    for i in range(len(indices)):
        plt.plot([x_data1[indices[i]], x_data2[indices[i]]], [y_data1[indices[i]], y_data2[indices[i]]], color="red")
    plt.grid()
    plt.tight_layout()
    plt.legend()
    plt.show()


def lsp_svd(x_data1, y_data1, x_data2, y_data2):
    # 按照Ax = 0构造方程,拼接参数矩阵A
    # 详见https://zhaoxuhui.top/blog/2019/10/28/robotics-perception-assignment-2.html#5assignment-image-projection-using-homographies
    A = []
    for i in range(len(x_data1)):
        ax = [-x_data1[i], -y_data1[i], -1, 0, 0, 0, x_data1[i] * x_data2[i], y_data1[i] * x_data2[i], x_data2[i]]
        ay = [0, 0, 0, -x_data1[i], -y_data1[i], -1, x_data1[i] * y_data2[i], y_data1[i] * y_data2[i], y_data2[i]]
        A.append(ax)
        A.append(ay)

    # 使用SVD解方程
    A = np.array(A)
    u, s, vt = np.linalg.svd(A)
    v = np.transpose(vt)
    solution = v[:, -1]
    h = np.zeros([3, 3], dtype = float)
    h[0, 0] = solution[0]
    h[0, 1] = solution[1]
    h[0, 2] = solution[2]
    h[1, 0] = solution[3]
    h[1, 1] = solution[4]
    h[1, 2] = solution[5]
    h[2, 0] = solution[6]
    h[2, 1] = solution[7]
    h[2, 2] = solution[8]
    return h


def checkAccuracy(x_data1, y_data1, x_data2, y_data2, homo):
    error_x = []
    error_y = []
    error = []
    for i in range(len(x_data1)):
        p1 = np.ones([3], float)
        p1[0] = x_data1[i]
        p1[1] = y_data1[i]

        p2 = np.matmul(homo, p1)
        p2_normal = p2 / p2[2]

        reprojection_error_x = abs(x_data2[i] - p2_normal[0])
        reprojection_error_y = abs(y_data2[i] - p2_normal[1])
        reprojection_error = math.sqrt(math.pow(reprojection_error_x, 2) + math.pow(reprojection_error_y, 2))
        error_x.append(reprojection_error_x)
        error_y.append(reprojection_error_y)
        error.append(reprojection_error)
    return error_x, error_y, error


def randomSampling(x_data1, y_data1, x_data2, y_data2, n=4):
    x_data1_s = []
    y_data1_s = []
    x_data2_s = []
    y_data2_s = []
    for i in range(int(n)):
        tmp_index = random.randint(0, len(x_data1) - 1)
        x_data1_s.append(x_data1[tmp_index])
        y_data1_s.append(y_data1[tmp_index])
        x_data2_s.append(x_data2[tmp_index])
        y_data2_s.append(y_data2[tmp_index])
    return x_data1_s, y_data1_s, x_data2_s, y_data2_s


if __name__ == '__main__':
    start_x = 0
    end_x = 60
    start_y = 0
    end_y = 60
    num_points = 20
    offset_x = 35
    offset_y = 40
    num_outliers = 3
    noise_sigma = 0.5
    outlier_sigma = 2
    ep = 5
    w = 1.0 * (num_points - num_outliers) / num_points
    n = 4.0
    p = 0.995

    # 生成点对数据
    x_data1, y_data1, x_data2, y_data2 = getPointPairData(start_x, start_y,
                                                          end_x, end_y,
                                                          num_points,
                                                          offset_x, offset_y)
    x_data1_n, y_data1_n, x_data2_n, y_data2_n = addNoiseToPointPair(x_data1, y_data1, x_data2, y_data2,
                                                                     sigma=noise_sigma)
    x_data1_o, y_data1_o, x_data2_o, y_data2_o, indices = addOutliersToPointPair(x_data1_n, y_data1_n,
                                                                                 x_data2_n, y_data2_n,
                                                                                 num_outlier=num_outliers,
                                                                                 sigma=outlier_sigma)
    drawMatchPairsWithOutliers(x_data1_o, y_data1_o, x_data2_o, y_data2_o, indices)

    good_coefs = []
    good_nums = []
    errors = []
    inliers_pts = []
    k = int(math.log(1 - p) / math.log(1 - math.pow(w, n))) + 1
    print("=>To estimate correct model with " + round(p * 100, 2).__str__() + \
          "% certainty,you should iterate for", k, "times at least")

    # RANSAC迭代
    for it in range(k):
        # 随机采样4个点对
        sample_x1, sample_y1, sample_x2, sample_y2 = randomSampling(x_data1_o, y_data1_o, x_data2_o, y_data2_o, n=n)
        print("\nTime", it + 1, "/", k)
        print("selected point pairs:")
        for j in range(len(sample_x1)):
            print(sample_x1[j], sample_y1[j], "->", sample_x2[j], sample_y2[j])

        # 基于采样点对计算单应性矩阵
        h = lsp_svd(sample_x1, sample_y1, sample_x2, sample_y2)
        print("Homography matrix:\n", h)

        # 统计内点个数和误差
        tmp_error = 0
        counter = 0
        good_pts = []
        pts_est_x = []
        pts_est_y = []
        for i in range(len(x_data1_o)):
            p1 = np.ones([3], float)
            p1[0] = x_data1_o[i]
            p1[1] = y_data1_o[i]

            p2 = np.matmul(h, p1)
            p2_normal = p2 / p2[2]
            pts_est_x.append(p2_normal[0])
            pts_est_y.append(p2_normal[1])

            reprojection_error_x = abs(x_data2_o[i] - p2_normal[0])
            reprojection_error_y = abs(y_data2_o[i] - p2_normal[1])
            reprojection_error = math.sqrt(math.pow(reprojection_error_x, 2) + math.pow(reprojection_error_y, 2))
            tmp_error += reprojection_error
            if reprojection_error <= ep:
                counter += 1
                good_pts.append(i)
        mean_error = tmp_error / len(x_data1_o)
        errors.append(mean_error)
        good_coefs.append(h)
        good_nums.append(counter)
        print("good point number:\n", counter, "/", len(x_data1_o))
        print("mean error:\n", mean_error, "\n")

        if counter > len(inliers_pts):
            inliers_pts = []
            for i in range(len(good_pts)):
                inliers_pts.append(good_pts[i])

        # 绘制结果
        plt.scatter(x_data1_o, y_data1_o, label="original point")
        plt.scatter(x_data2_o, y_data2_o, label="matched point")
        plt.scatter(pts_est_x, pts_est_y, label='estimate point')
        for i in range(len(x_data1_o)):
            if i == 0:
                plt.plot([x_data2_o[i], pts_est_x[i]], [y_data2_o[i], pts_est_y[i]], color="pink",
                         label="estimate error")
                plt.plot([x_data1_o[i], x_data2_o[i]], [y_data1_o[i], y_data2_o[i]], color="lightgreen",
                         label="unselected pair")
            else:
                plt.plot([x_data2_o[i], pts_est_x[i]], [y_data2_o[i], pts_est_y[i]], color="pink")
                plt.plot([x_data1_o[i], x_data2_o[i]], [y_data1_o[i], y_data2_o[i]], color="lightgreen")
        for i in range(len(good_pts)):
            if i == 0:
                plt.plot([x_data1_o[good_pts[i]], x_data2_o[good_pts[i]]],
                         [y_data1_o[good_pts[i]], y_data2_o[good_pts[i]]],
                         color="red", label="selected pair")
            else:
                plt.plot([x_data1_o[good_pts[i]], x_data2_o[good_pts[i]]],
                         [y_data1_o[good_pts[i]], y_data2_o[good_pts[i]]],
                         color="red")
        plt.title("Time " + (it + 1).__str__() + "/" + k.__str__() + " inliers=" + counter.__str__().zfill(2))
        plt.grid()
        plt.tight_layout()
        plt.legend()
        plt.show()

        tmp_error = 0
        counter = 0
        good_pts = []
        pts_est = []

    best = good_nums.index(max(good_nums))
    print("======Result======")
    print("best consensus point number:\n", max(good_nums), "/", len(x_data1_o))
    print("best homography:\n", good_coefs[best])
    print("inliers:")
    for i in range(len(inliers_pts)):
        print("inlier", (i + 1).__str__().zfill(3), \
            x_data1_o[inliers_pts[i]], \
            y_data1_o[inliers_pts[i]], "->", \
            x_data2_o[inliers_pts[i]], \
            y_data2_o[inliers_pts[i]])

    # 绘制最终结果
    plt.scatter(x_data1_o, y_data1_o, label="original data")
    plt.scatter(x_data2_o, y_data2_o, label="match data")
    for i in range(len(x_data1_o)):
        if i == 0:
            plt.plot([x_data1_o[i], x_data2_o[i]], [y_data1_o[i], y_data2_o[i]], color="lightgreen", label="outliers")
        else:
            plt.plot([x_data1_o[i], x_data2_o[i]], [y_data1_o[i], y_data2_o[i]], color="lightgreen")
    for i in range(len(inliers_pts)):
        if i == 0:
            plt.plot([x_data1_o[inliers_pts[i]], x_data2_o[inliers_pts[i]]],
                     [y_data1_o[inliers_pts[i]], y_data2_o[inliers_pts[i]]], color="red", label="inliers")
        else:
            plt.plot([x_data1_o[inliers_pts[i]], x_data2_o[inliers_pts[i]]],
                     [y_data1_o[inliers_pts[i]], y_data2_o[inliers_pts[i]]], color="red")

    plt.grid()
    plt.tight_layout()
    plt.legend()
    plt.show()

4.SLAM中的误差计算

其实在SLAM中的误差计算,本质上是已知A、B两个状态的位姿,求解它们之间的变换。这其实就是类似于2.3部分提到的3D-3D帧间运动估计。

4.1 绝对位姿误差

Absolute Pose Error(绝对位姿误差)指代的是在一个坐标系下,估计位姿和真值之间的差异,多个APE求均方根误差(RMSE)即可得到Absolute Trajectory Error(绝对轨迹误差)。APE可以反映SLAM轨迹与真值的整体一致性。

对于A、B两个状态,其对应的位姿(位置和姿态写在一起变成变换的形式)分别为\(T_{A}\)、\(T_{B}\)。现在需要求解AB之间的变换\(T_{x}\),则有\(T_{B} = T_{x}T_{A}\)。进一步可以得到\(APE = T_{x} = T_{A}^{-1}T_{B}\)。得到总体的变换矩阵后,可以分别取旋转部分和平移部分用于误差评价。更具体而言:

  • 平移部分:误差等于求解平移部分的模长,对于三维空间也即三维的欧氏距离。
  • 旋转部分:旋转部分有两种不同评价方式,分别是角误差和矩阵误差。
    • 角误差:将旋转矩阵通过罗德里格斯公式转化为对应旋转向量,然后以旋转向量表示的角度代表误差(弧度角度表示都可以)。旋转向量的模长表示旋转角度的大小(弧度),旋转向量的方向表示旋转轴的单位向量。详细可参考这篇博客
    • 矩阵误差:计算旋转矩阵减去单位阵结果的F范数(理论上如果没有旋转,旋转矩阵应该为单位阵)。F范数是把矩阵中每个元素的平方求和后开根号。详细可以参考这篇博客
  • 总体误差:我们也可以对整个变换矩阵进行评价,评价方式是将变换矩阵减去对应的单位阵,然后结果计算F范数(和角度的矩阵误差方法一样)。

对于一对位姿变换按照上面的方法即可计算误差。而对于一段轨迹,重复上述步骤,即可计算得到多个APE,我们可以对其计算RMSE。这个RMSE结果就是这段轨迹的绝对轨迹误差(ATE),如下:

\[ATE = \sqrt{\frac{1}{N} \sum_{i=1}^{N} APE_{i}^{2}}\]

一个简单的计算ATE的代码如下:

import numpy as np
import math

def composeTransMat(pose):
    trans_mat = np.array([[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 1]], float)

    qx = pose[0]
    qy = pose[1]
    qz = pose[2]
    qw = pose[3]
    dx = pose[4]
    dy = pose[5]
    dz = pose[6]
    
    # 求四元数的模长
    qmod = math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw)
    # 四元数归一化
    qx = qx / qmod
    qy = qy / qmod
    qz = qz / qmod
    qw = qw / qmod

    # 计算旋转矩阵
    rot = np.array([[0, 0, 0], [0, 0, 0], [0, 0, 0]], float)
    rot[0, 0] = 1 - 2 * (qy * qy + qz * qz)
    rot[0, 1] = 2 * (qx * qy - qw * qz)
    rot[0, 2] = 2 * (qx * qz + qw * qy)
    rot[1, 0] = 2 * (qx * qy + qw * qz)
    rot[1, 1] = 1 - 2 * (qx * qx + qz * qz)
    rot[1, 2] = 2 * (qy * qz - qw * qx)
    rot[2, 0] = 2 * (qx * qz - qw * qy)
    rot[2, 1] = 2 * (qy * qz + qw * qx)
    rot[2, 2] = 1 - 2 * (qx * qx + qy * qy)
    
    # 旋转矩阵(与Matlab quat2dcm函数保持一致)
    rot = rot.transpose()

    # 组装变换矩阵
    trans_mat[0, 0] = rot[0, 0]
    trans_mat[0, 1] = rot[0, 1]
    trans_mat[0, 2] = rot[0, 2]
    trans_mat[1, 0] = rot[1, 0]
    trans_mat[1, 1] = rot[1, 1]
    trans_mat[1, 2] = rot[1, 2]
    trans_mat[2, 0] = rot[2, 0]
    trans_mat[2, 1] = rot[2, 1]
    trans_mat[2, 2] = rot[2, 2]
    trans_mat[0, 3] = dx
    trans_mat[1, 3] = dy
    trans_mat[2, 3] = dz

    return trans_mat


if __name__ == '__main__':
    # Poses: qx, qy, qz, qw, x, y, z
    pose_ref1 = composeTransMat([-0.757610, -0.348629, -0.497711, 0.238261, 4.460675, -1.680515, 0.579614])
    pose_ref2 = composeTransMat([-0.518605, -0.636519, -0.358444, 0.444310, 3.704039, 1.424990, 1.403680])
    pose_ref3 = composeTransMat([-0.514605, -0.636519, -0.358444, 0.444310, 3.504039, 1.444990, 1.443680])
    pose_ref4 = composeTransMat([-0.510605, -0.636519, -0.358444, 0.444310, 3.404039, 1.454990, 1.453680])
    pose_ref5 = composeTransMat([-0.507605, -0.636519, -0.358444, 0.444310, 3.204039, 1.464990, 1.463680])

    pose_est1 = composeTransMat([-0.757610, -0.348629, -0.497711, 0.238261, 4.460675, -1.680515, 0.579614]) # 旋转平移完全一致
    pose_est2 = composeTransMat([-0.518605, -0.636519, -0.358444, 0.444310, 3.704039, 1.434990, 1.413680])  # 旋转一致,位置有误差
    pose_est3 = composeTransMat([-0.534605, -0.636519, -0.258444, 0.454310, 3.504039, 1.444990, 1.443680])  # 旋转有误差,位置一致
    pose_est4 = composeTransMat([-0.520605, -0.626519, -0.458444, 0.424310, 3.404039, 1.444990, 1.463680])  # 旋转位置都有误差
    pose_est5 = composeTransMat([-0.557605, -0.616519, -0.358444, 0.414310, 3.204039, 1.454990, 1.453680])  # 旋转位置都有误差

    # 计算绝对位姿误差(APE)
    ape1 = np.matmul(np.linalg.inv(pose_ref1), pose_est1)
    ape2 = np.matmul(np.linalg.inv(pose_ref2), pose_est2)
    ape3 = np.matmul(np.linalg.inv(pose_ref3), pose_est3)
    ape4 = np.matmul(np.linalg.inv(pose_ref4), pose_est4)
    ape5 = np.matmul(np.linalg.inv(pose_ref5), pose_est5)

    # 利用F范数计算总体的APE误差
    error1 = np.linalg.norm(ape1 - np.eye(4), ord='fro')
    error2 = np.linalg.norm(ape2 - np.eye(4), ord='fro')
    error3 = np.linalg.norm(ape3 - np.eye(4), ord='fro')
    error4 = np.linalg.norm(ape4 - np.eye(4), ord='fro')
    error5 = np.linalg.norm(ape5 - np.eye(4), ord='fro')
    
    # 利用均方根误差RMSE计算ATE
    ate = np.sqrt((error1**2 + error2**2 + error3**2 + error4**2 + error5**2) / 5)

    print("Errors:")
    print("APE Error1:", error1)
    print("APE Error2:", error2)
    print("APE Error3:", error3)
    print("APE Error4:", error4)
    print("APE Error5:", error5)
    print("ATE Error:", ate)
4.2 相对位姿误差

Relative Pose Error(相对位姿误差)不像APE一样,计算A、B两个绝对位姿之间的差异,它计算的是绝对位姿差异之间的差异。举例来说,有A、B、C、D四个状态,APE分别为\(APE_{AB}\)、\(APE_{BC}\)、\(APE_{CD}\),RPE计算的是\(APE_{AB}\)和\(APE_{BC}\)间的差异\(RPE_{AC}\)、\(APE_{BC}\)和\(APE_{CD}\)间的差异\(RPE_{BD}\),\(RPE_{i,j} = APE_{i}^{-1}APE_{j}\)。RPE可以反映SLAM轨迹与真值的局部一致性。

明白RPE的计算规则后,其具体的计算也十分简单了。因为APE依旧是一个4x4的变换矩阵,所以和前面APE的计算公式相同。详细可以参考这篇博客。各部分如下:

  • 平移部分:误差等于平移部分模长,三维空间即三维欧氏距离。
  • 旋转部分:旋转部分有两种不同评价方式,分别是角误差和矩阵误差。
    • 角误差:将旋转矩阵通过罗德里格斯公式转化为对应旋转向量,然后以旋转向量表示的角度代表误差(弧度角度表示都可以)。
    • 矩阵误差:计算旋转矩阵减去单位阵结果的F范数(理论上如果没有旋转,旋转矩阵应该为单位阵)。F范数是把矩阵中每个元素的平方求和后开根号。
  • 总体误差:对整个变换矩阵进行评价,评价方式是将变换矩阵减去对应的单位阵,然后结果计算F范数(和角度的矩阵误差方法一样)。

对于一段轨迹,重复上述步骤,即可计算得到多个RPE,我们可以对其计算RMSE。这个RMSE结果就是这段轨迹的相对轨迹误差(RTE),如下:

\[RTE = \sqrt{\frac{1}{N} \sum_{i=1}^{N} RPE_{i}^{2}}\]

一个简单的计算RTE的代码如下:

import numpy as np
import math

def composeTransMat(pose):
    trans_mat = np.array([[0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 1]], float)

    qx = pose[0]
    qy = pose[1]
    qz = pose[2]
    qw = pose[3]
    dx = pose[4]
    dy = pose[5]
    dz = pose[6]
    
    # 求四元数的模长
    qmod = math.sqrt(qx * qx + qy * qy + qz * qz + qw * qw)
    # 四元数归一化
    qx = qx / qmod
    qy = qy / qmod
    qz = qz / qmod
    qw = qw / qmod

    # 计算旋转矩阵
    rot = np.array([[0, 0, 0], [0, 0, 0], [0, 0, 0]], float)
    rot[0, 0] = 1 - 2 * (qy * qy + qz * qz)
    rot[0, 1] = 2 * (qx * qy - qw * qz)
    rot[0, 2] = 2 * (qx * qz + qw * qy)
    rot[1, 0] = 2 * (qx * qy + qw * qz)
    rot[1, 1] = 1 - 2 * (qx * qx + qz * qz)
    rot[1, 2] = 2 * (qy * qz - qw * qx)
    rot[2, 0] = 2 * (qx * qz - qw * qy)
    rot[2, 1] = 2 * (qy * qz + qw * qx)
    rot[2, 2] = 1 - 2 * (qx * qx + qy * qy)
    
    # 旋转矩阵(与Matlab quat2dcm函数保持一致)
    rot = rot.transpose()

    # 组装变换矩阵
    trans_mat[0, 0] = rot[0, 0]
    trans_mat[0, 1] = rot[0, 1]
    trans_mat[0, 2] = rot[0, 2]
    trans_mat[1, 0] = rot[1, 0]
    trans_mat[1, 1] = rot[1, 1]
    trans_mat[1, 2] = rot[1, 2]
    trans_mat[2, 0] = rot[2, 0]
    trans_mat[2, 1] = rot[2, 1]
    trans_mat[2, 2] = rot[2, 2]
    trans_mat[0, 3] = dx
    trans_mat[1, 3] = dy
    trans_mat[2, 3] = dz

    return trans_mat


if __name__ == '__main__':
    # Poses: qx, qy, qz, qw, x, y, z
    pose_ref1 = composeTransMat([-0.757610, -0.348629, -0.497711, 0.238261, 4.460675, -1.680515, 0.579614])
    pose_ref2 = composeTransMat([-0.518605, -0.636519, -0.358444, 0.444310, 3.704039, 1.424990, 1.403680])
    pose_ref3 = composeTransMat([-0.514605, -0.636519, -0.358444, 0.444310, 3.504039, 1.444990, 1.443680])
    pose_ref4 = composeTransMat([-0.510605, -0.636519, -0.358444, 0.444310, 3.404039, 1.454990, 1.453680])
    pose_ref5 = composeTransMat([-0.507605, -0.636519, -0.358444, 0.444310, 3.204039, 1.464990, 1.463680])

    pose_est1 = composeTransMat([-0.757610, -0.348629, -0.497711, 0.238261, 4.460675, -1.680515, 0.579614]) # 旋转平移完全一致
    pose_est2 = composeTransMat([-0.518605, -0.636519, -0.358444, 0.444310, 3.704039, 1.434990, 1.413680])  # 旋转一致,位置有误差
    pose_est3 = composeTransMat([-0.534605, -0.636519, -0.258444, 0.454310, 3.504039, 1.444990, 1.443680])  # 旋转有误差,位置一致
    pose_est4 = composeTransMat([-0.520605, -0.626519, -0.458444, 0.424310, 3.404039, 1.444990, 1.463680])  # 旋转位置都有误差
    pose_est5 = composeTransMat([-0.557605, -0.616519, -0.358444, 0.414310, 3.204039, 1.454990, 1.453680])  # 旋转位置都有误差

    # 计算绝对位姿误差(APE)
    ape1 = np.matmul(np.linalg.inv(pose_ref1), pose_est1)
    ape2 = np.matmul(np.linalg.inv(pose_ref2), pose_est2)
    ape3 = np.matmul(np.linalg.inv(pose_ref3), pose_est3)
    ape4 = np.matmul(np.linalg.inv(pose_ref4), pose_est4)
    ape5 = np.matmul(np.linalg.inv(pose_ref5), pose_est5)

    # 计算相对位姿误差(RPE)
    rpe1 = np.matmul(np.linalg.inv(ape1), ape2)
    rpe2 = np.matmul(np.linalg.inv(ape2), ape3)
    rpe3 = np.matmul(np.linalg.inv(ape3), ape4)
    rpe4 = np.matmul(np.linalg.inv(ape4), ape5)

    # 利用F范数计算总体的RPE误差
    error1 = np.linalg.norm(rpe1 - np.eye(4), ord='fro')
    error2 = np.linalg.norm(rpe2 - np.eye(4), ord='fro')
    error3 = np.linalg.norm(rpe3 - np.eye(4), ord='fro')
    error4 = np.linalg.norm(rpe4 - np.eye(4), ord='fro')
    
    # 利用均方根误差RMSE计算RTE
    rte = np.sqrt((error1**2 + error2**2 + error3**2 + error4**2) / 4)

    print("Errors:")
    print("RPE Error1:", error1)
    print("RPE Error2:", error2)
    print("RPE Error3:", error3)
    print("RPE Error4:", error4)
    print("RTE Error:", rte)

5.卡尔曼滤波器

关于卡尔曼滤波器的详细介绍和使用说明见这篇博客,这里只是简单介绍。

5.1 什么是卡尔曼滤波

卡尔曼滤波是一种实时状态估计算法,用于在噪声环境中通过结合传感器观测值和预测模型,输出最优系统状态(如位置或速度)。其核心思想是动态平衡预测值和观测值,通过权重(卡尔曼增益)调整估计结果,确保方差最小化以提高精度。卡尔曼滤波的核心价值在于其数学最优性(如协方差最小化)和适应性,广泛应用于需要高精度、低延迟状态估计的场景。

5.2 它能用来解决什么问题

详见这篇博客。卡尔曼滤波作为一种最优状态估计算法,主要解决‌动态系统中的噪声干扰问题‌,通过融合预测模型与传感器观测值输出高精度状态估计。更具体说包含两类场景:

  • 感兴趣的变量无法直接观测,需要通过一个或多个带有噪声的观测量计算得到,通过卡尔曼滤波可给出感兴趣变量的最优估计。
  • 有多个传感器同时观测,需要做传感器数据的动态融合,通过卡尔曼滤波可给出最优融合结果。
5.3 卡尔曼滤波步骤

详见这篇博客。Kalman Filter包含两步:状态预测(Predict)与测量更新(Update),进一步分为5个步骤:

  • Step1. 根据上一个状态,结合给定模型,预测当前时刻状态:
\[\mathbf{x}_{t} = \mathbf{F}\cdot \mathbf{x}_{t-1} + \mathbf{B}\cdot \mathbf{u}_{t}\]
  • Step2. 根据给定误差和上一状态误差,计算当前时刻预测状态对应的误差:
\[\mathbf{P}_{t} = \mathbf{F}\cdot \mathbf{P}_{t-1} \cdot \mathbf{F}^{T}+\mathbf{Q}\]
  • Step3. 根据上一状态预测误差、给定的观测误差和观测矩阵,计算卡尔曼增益(这里括号外的-1对于标量表示分数,对于矩阵表示求逆):
\[\mathbf{K}_{t} = \mathbf{P}_{t} \cdot \mathbf{H}^{T} \cdot (\mathbf{H}\cdot \mathbf{P}_{t} \cdot \mathbf{H}^{T}+\mathbf{R})^{-1}\]
  • Step4. 根据当前预测状态、实际观测和观测矩阵,结合卡尔曼增益平衡预测与观测权重,修正状态预测:
\[\mathbf{x}_{t} = \mathbf{x}_t + \mathbf{K}_{t} \cdot (\mathbf{z}_{t} - \mathbf{H} \cdot \mathbf{x}_{t})\]
  • Step5. 根据计算得到的卡尔曼增益、上一个状态预测误差、给定的观测矩阵,计算修正后状态对应的误差:
\[\mathbf{P}_{t} = (\mathbf{I} - \mathbf{K}_{t} \cdot \mathbf{H}) \cdot \mathbf{P}_{t}\]

这里简单展示利用卡尔曼滤波估算温度的例子:需要测量火箭发动机内部的温度,但因为温度太高无法直接测量,只能间接测量外部温度。现在需要基于观测预测对应时刻最优的内部温度。完整示例见这里。代码如下:

import numpy as np

# 给定参数
H = 0.5 # 观测矩阵
F = 1.0 # 状态转移矩阵
B = 100.0   # 控制输入矩阵
Q = 0.1 # 过程噪声协方差矩阵
R = 0.5 # 测量噪声协方差矩阵

# 指定理论值,方便评估精度,实际无法获得
ideal_value = np.zeros(12) + 200

# 系列观测与控制输入
z = [102, 104, 98, 99.7, 102, 101.5, 100.4, 101.6, 103.8, 97, 102.1, 99.3]
u = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

# 给状态赋初值
prev_state = z[0] / H
prev_error = 1.0

# 用于存放估计的状态
predict_states = []
measure_states = []

# 开始卡尔曼滤波
for i in range(len(z)):
    # 预测 - 状态
    predict_state = F * prev_state + B * u[i]
    # 预测 - 误差
    predict_error = F * prev_error * F + Q
    
    # 更新 - 卡尔曼增益
    kalman_gain  = (predict_error * H) / (H * predict_error * H + R)
    
    # 更新 - 状态
    update_state = predict_state + kalman_gain * (z[i] - H * predict_state)
    # 更新 - 误差
    update_error = (1 - kalman_gain * H) * predict_error
    
    # 变量赋值开启下次迭代
    prev_state = update_state
    prev_error = update_error
    
    # 将预测状态保存起来
    predict_states.append(update_state)
    measure_states.append(z[i] / H)

# 精度评价
measure_errors = np.abs(measure_states - ideal_value)
predict_errors = np.abs(predict_states - ideal_value)

for i in range(len(predict_errors)):
    print(i+1, "/", len(predict_errors), "predict error:", round(predict_errors[i], 5), "measure error:", round(measure_errors[i], 5))

print("mean measure", round(np.mean(measure_errors), 5))
print("mean predict", round(np.mean(predict_errors), 5))

6.IMU惯导

本部分主要参考这篇博客

6.1 常见IMU噪声

详见这篇博客,这里简单总结。IMU主要由陀螺仪和加速度计组合而成,陀螺仪主要测量角速度,加速度计测量线性加速度。其主要包含以下几种噪声:

  • 常量偏置(Constant Bias):偏置是指传感器没有经历任何运动的情况下,陀螺仪/加速度计的平均输出。因为理论上,传感器没有经历任何旋转,输出的角速度以及一段时间内的角速度均值都为0、加速度也为重力加速度保持不变。消除方式:可以将传感器静止放置在某处,记录其长时间的输出并对其求平均。这个平均值就被作为该IMU的常量偏置。对于每个IMU输出,都减去这个偏置量即可。

  • 热机械白噪声/随机游走(Thermo-Mechanical White Noise/Random Walk):传感器会受到一些热机械噪声的影响,它的频率远比传感器的采样频率要高。这种机械噪声会给计算结果引入一种零均值随机游走误差,角度随机游走噪声的标准差与时间的平方根成比例、加速度随机游走噪声的标准差与时间的3/2次方成比例。随机游走噪声可以看作是热机械噪声的表现,反过来,热机械噪声是随机游走噪声的成因。

  • 闪变噪声/偏置稳定性(Flicker Noise/Bias Stability):由于电子器件的闪变噪声(Flicker Noise)影响,MEMS陀螺仪的偏置也不会一直不变,而是随时间游走。

  • 温度影响(Temperature Effects):由于气温变化导致的误差随时间线性增长。对于MEMS陀螺仪来说,偏置与温度的关系通常是高度非线性的。有些IMU自带了温度传感器,可以用于校正。

  • 校正误差(Calibration Errors):主要指尺度因子、对齐、线性化等误差。这些误差通常只有在IMU启动的时候才会被观测到,并且会导致累积漂移,它们的幅度与运动的速度和时长成比例。

6.2 IMU位姿推理

此部分参考这篇博客内容。

一个惯性系统相对于全局参考系的朝向/姿态可以通过来自系统速率陀螺仪测量的角速度信号\(\omega_{b}(t) = (\omega_{bx}(t),\omega_{by}(t),\omega_{bz}(t))^{T}\)积分获得(原理是角动量守恒)。对于一个IMU而言,其陀螺仪的观测是x、y、z方向上的角速度。因此,利用陀螺仪的测量推算位姿,其实就是在求某一时刻的旋转矩阵。理论公式:

\[C(t) = C(0) \cdot exp(\int_{0}^{t}\Omega(t) dt )\]

对于\(t\)时刻的姿态,我们只需要知道起始时刻的姿态,以及当前\(t\)时刻的角速度,并对其从0到\(t\)做积分,并与初始姿态相乘即可得到当前t时刻的姿态\(C(t)\)。

在实际解算中,对上式进行一系列离散化与线性化(泰勒展开),最终公式如下:

\[C(t+ \delta t) = C(t) (I+\frac{sin\sigma}{\sigma}B+\frac{1-cos\sigma}{\sigma^2}B^2)\]

其中,陀螺仪的相邻两次观测的时间间隔为\(\delta t\),\(\sigma = |w_{b}\delta t|\),\(\omega_{b} = (\omega_{bx},\omega_{by},\omega_{bz})^{T}\)即为陀螺仪观测。\(B = \begin{bmatrix} 0 & -w_{bz}\delta t & w_{by}\delta t\\ w_{bz}\delta t & 0 & -w_{bx}\delta t\\ -w_{by}\delta t & w_{bx}\delta t & 0 \end{bmatrix}\)。

在这个式子中,我们拿到陀螺仪观测和时间间隔以后,就可以直接计算了。

下面给出按照上述理论进行位姿推理的代码:

import numpy as np
from math import sin, cos

def skew_symmetric(w):
    """构建反对称矩阵"""
    return np.array([[0, -w[2], w[1]],
                    [w[2], 0, -w[0]],
                    [-w[1], w[0], 0]])

def update_rotation_matrix(C, gyro, dt):
    """更新方向余弦矩阵"""
    w = gyro * dt  # 角速度积分
    sigma = np.linalg.norm(w)
    B = skew_symmetric(w)
    
    if sigma < 1e-6:
        return C  # 无显著旋转
    
    # 泰勒展开近似
    C_new = C @ (np.eye(3) + 
                (sin(sigma)/sigma)*B + 
                ((1-cos(sigma))/sigma**2)*B@B)
    return C_new / np.linalg.norm(C_new)  # 归一化

def rotation_to_euler(C):
    """转换为欧拉角(弧度)"""
    pitch = np.arcsin(-C[2,0])
    roll = np.arctan2(C[2,1], C[2,2])
    yaw = np.arctan2(C[1,0], C[0,0])
    return np.array([roll, pitch, yaw])


# 初始化参数
C = np.eye(3)  # 初始姿态矩阵
gyro_bias = np.array([0.01, -0.005, 0.003])  # 陀螺仪零偏
dt = 0.01  # 采样周期(秒)

# 模拟陀螺仪数据流
gyro_readings = [
    np.array([0.1, 0.05, 0.02]),  # rad/s
    np.array([0.1, 0.05, 0.02]),
    np.array([0.1, 0.05, 0.02]),
    np.array([0.1, 0.05, 0.02]),
    np.array([0.12, 0.03, 0.01]),
    np.array([0.12, 0.03, 0.01]),
    np.array([0.12, 0.03, 0.01]),
    np.array([0.12, 0.03, 0.01]),
    np.array([-0.05, 0.1, 0.03]),
    np.array([-0.05, 0.1, 0.03]),
    np.array([-0.05, 0.1, 0.03]),
    np.array([-0.05, 0.1, 0.03])
]

for gyro in gyro_readings:
    # 1. 去除零偏
    gyro_corrected = gyro - gyro_bias
    
    # 2. 更新旋转矩阵
    C = update_rotation_matrix(C, gyro_corrected, dt)
    
    # 3. 转换为欧拉角显示
    angles = np.degrees(rotation_to_euler(C))
    print(f"Roll: {angles[0]:.2f}°, Pitch: {angles[1]:.2f}°, Yaw: {angles[2]:.2f}°")
6.3 IMU位置推理

此部分参考这篇博客内容。

在\(t\)时刻,我们只要知道了此时的姿态\(C(t)\)(由陀螺仪测量、计算)以及加速度计测量的加速度\(a_{b}(t)=(a_{bx}(t),a_{by}(t),a_{bz}(t))^{T}\)就可以一步步求得全局坐标系下的位置:

  • 先将加速度转化到全局坐标系,再减去重力,对剩余部分积分得到速度
  • 再将初始速度加到积分的速度上得到总速度
  • 最后对速度再做一次积分,得到位移
  • 再加上初始位置,即为最终在全局坐标系下的位置

计算公式如下:

\[s_{g}(t) = s_{g}(0) + \int_{0}^{t}(v_{g}(0) + \int_{0}^{t}(C(t)a_{b}(t) - g_{g}(t)) dt)dt\]

在实际实现时,对积分过程按照矩形法则线性化和离散化,最终公式如下:

\[\left\{\begin{align} & a_{g}(t) = C(t)a_{b}(t) \\ & v_{g}(t+\delta t) = v_{g}(t) + \delta t \cdot (a_{g}(t+\delta t) - g_{g}) \\ & s_{g}(t+\delta t) = s_{g}(t) + \delta t \cdot v_{g}(t+\delta t) \end{align}\right.\]

依据上述公式,利用Python实现代码如下:

import numpy as np
from math import sin, cos, sqrt

def initialize_navigation():
    # 初始化导航参数
    C = np.eye(3) # 方向余弦矩阵(初始为单位矩阵)
    v = np.zeros(3) # 速度向量(全局坐标系)
    s = np.zeros(3) # 位置向量(全局坐标系)
    g = np.array([0, 0, 9.8]) # 重力加速度
    gyro_bias = np.array([0.01, -0.02, 0.005]) # 陀螺仪零偏
    accel_bias = np.array([0.05, -0.03, 0.1]) # 加速度计零偏
    return C, v, s, g, gyro_bias, accel_bias

def update_attitude(C, gyro, dt):
    # 罗德里格斯旋转公式更新姿态
    w = gyro * dt
    sigma = np.linalg.norm(w)

    if sigma < 1e-6:
        return C
        
    # 构建反对称矩阵
    B = np.array([
        [0, -w[2], w[1]],
        [w[2], 0, -w[0]],
        [-w[1], w[0], 0]
    ])

    # 旋转矩阵更新
    C_new = C @ (np.eye(3) + 
                (sin(sigma)/sigma)*B + 
                ((1-cos(sigma))/sigma**2)*B@B)

    # 正交化处理
    U, _, Vt = np.linalg.svd(C_new)
    return U @ Vt

def update_position(C, v, s, accel, g, dt):
    # 坐标系转换和位置更新
    a_global = C @ accel - g # 转换到全局坐标系并消除重力
    v_new = v + a_global * dt # 速度积分
    s_new = s + v_new * dt # 位置积分
    return v_new, s_new

def inertial_navigation(gyro_data, accel_data, sample_rate=100):
    # 主处理流程
    C, v, s, g, gyro_bias, accel_bias = initialize_navigation()
    dt = 1/sample_rate

    positions = []
    attitudes = []

    for i in range(len(gyro_data)):
        # 传感器校准
        gyro = gyro_data[i] - gyro_bias
        accel = accel_data[i] - accel_bias
        
        # 更新姿态
        C = update_attitude(C, gyro, dt)
        
        # 更新位置
        v, s = update_position(C, v, s, accel, g, dt)
        
        # 存储结果
        positions.append(s.copy())
        attitudes.append(C.copy())

    return np.array(positions), np.array(attitudes)

if __name__ == "__main__":
    # 模拟传感器数据(100Hz采样率)
    duration = 5 # 秒
    sample_rate = 100 # Hz
    t = np.linspace(0, duration, duration*sample_rate)
    
    # 模拟陀螺仪数据(rad/s)
    gyro_x = 0.3 * np.sin(2*np.pi*0.5*t)
    gyro_y = 0.2 * np.cos(2*np.pi*0.3*t)
    gyro_z = 0.4 * np.sin(2*np.pi*0.2*t)
    gyro_data = np.vstack((gyro_x, gyro_y, gyro_z)).T

    # 模拟加速度计数据(m/s^2)
    accel_x = 0.1 * np.sin(2*np.pi*0.4*t)
    accel_y = 0.15 * np.cos(2*np.pi*0.6*t)
    accel_z = 9.8 + 0.2 * np.sin(2*np.pi*0.1*t)
    accel_data = np.vstack((accel_x, accel_y, accel_z)).T


    # 主处理流程
    C, v, s, g, gyro_bias, accel_bias = initialize_navigation()
    dt = 1/sample_rate

    positions = []
    attitudes = []

    for i in range(len(gyro_data)):
        # 传感器校准
        gyro = gyro_data[i] - gyro_bias
        accel = accel_data[i] - accel_bias
        
        # 更新姿态
        C = update_attitude(C, gyro, dt)
        
        # 更新位置
        v, s = update_position(C, v, s, accel, g, dt)
        
        # 存储结果
        positions.append(s.copy())
        attitudes.append(C.copy())
        
        # 打印展示
        print("position:", s)
        print("orientation:", C)

可以看到,只需要知道加速度计的观测以及此时在全局坐标系下的姿态,即可以推导位置。总体而言,位置计算是依赖于陀螺仪的积分结果的。因为设计到某时刻本体坐标系和全局坐标系之间的转换关系,这个转换关系只有通过陀螺仪才能得到。这样做的结果是,如果陀螺仪有了比较大的误差,那么位置的估计也不会好。

本文作者原创,未经许可不得转载,谢谢配合

返回顶部