《自动驾驶与机器人中的slam技术:从理论到实践》笔记——ch8(1)

8.紧耦合LIO系统

自动驾驶对SLAM的应用主要集中在定位与建图这两大模块。在定位模块中,也常常需要使用DR或LIO进行局部的位置估计。

8.1紧耦合的原理和优点

在进行学习之前,我们先来回顾一下什么是松耦合,松耦合和紧耦合的区别。

首先什么是松耦合,松耦合是将IMU得到的位姿估计以及点云匹配输出的位姿进行优化,将IMU和轮速计提供惯性和速度方面的观测源,点云匹配的结果为这个系统提供位姿数据的观测源,是对IMU和轮速计的位姿匹配结果进行优化。

什么是紧耦合呢?所谓紧耦合LIO系统,就是把点云的残差方程直接作为观测方程,写入观测模型中,这种做法相当于在滤波器或者优化算法中内置了一个ICP或NDT。因为ICP和NDT需要迭代来更新它们的最近邻,所以相应的滤波器也应该使用可以迭代的版本。可以看做直接将IMU得到的数据与点云数据进行融合。

紧耦合比松耦合好在哪里?以松耦合LIO系统来说,如果车辆经过了某段退化区域,那么单独以点云匹配方式推算自身运动的雷达里程计容易失效,它的解空间存在额外的自由度,给出错误的位姿估计,融合之后就会带偏整个系统。而紧耦合LIO系统中的状态还会受到其他传感器的约束,这些自由度会被别的模块约束在一个固定范围内,使系统仍然工作在有效状态中。简而言之,紧耦合对结果进行约束,使其固定好在一个范围内,即使遇到意外情况也能正常工作。

紧耦合和松耦合的核心区别:

8.2基于IEKF的LIO系统

8.2.1IEKF状态变量与运动方程

在紧耦合LIO系统中,IMU与点云模型使用相同的状态模型、运动方程与观测方程。最直接的方式是将它们全都写到EKF模型中,IMU提供运动过程的约束,点云提供观测方程的约束。由于点云匹配需要多次最近邻迭代之后才能得到正确的解,也需要将传统的EKF滤波器改成它的迭代版本IEKF。

运动过程包括以下两个部分。

由于雷达频率通常比IMU频率低,所以两个雷达数据之间会存在多个IMU读数方便起见,笔者不再使用上下标来记录多次递推的过程,而是把递推之后的状态估计记为。在使用观测方程进行修正之前,得到的估计均值和协方差就是这两个量。

8.2.2观测方程中的迭代方程

在紧耦合LIO系统中,把雷达的ICP、NDT残差作为观测方程,写入EKF的模型中。然而,

由于ICP和NDT都需要迭代才会收敛,因此应该为EKF的观测过程增加迭代过程。这个迭代

过程有以下几个要点。

1.迭代的核心逻辑:IEKF 是给 "基准状态"(从初始预测值开始)加 "状态修正量",每次修正量都不同;一开始修正量是 0,状态的 "靠谱程度" 直接用初始预测值的靠谱程度。

2.观测与增益的更新:因为要反复迭代,每次都得重算卡尔曼增益、观测关系;当前的基准状态,是从初始点累加每次修正量得到的 ------ 可以理解为:"这次迭代的基准状态是 ,算完修正后误差就清零,下次从新基准继续"。

3.状态靠谱程度(用协方差矩阵来表示)的小技巧:迭代没结束时,不用每次都重算状态的 "靠谱程度",只需要把初始的 "靠谱程度" 转到当前的计算维度就行;等最后一次迭代,再统一更新这个 "靠谱程度",并转到新基准对应的维度。

4.IEKF和EKF的核心区别:EKF 只在 "初始预测的基准状态" 处简化一次非线性关系;IEKF 每次迭代都要在 "当前的基准状态" 处重新简化,更新流程和 EKF 不一样,代码写起来也有明显差别。

5.LIO的特殊点:紧耦合 LIO 中,每次迭代都要重新找激光点的匹配对象,导致观测数据的数量会变(不像普通 EKF 是固定的);观测数据太多时,得把 EKF 的公式变形,才能处理数据量太大的问题。

当我们遇到实际环境问题时如在弯曲的状态空间里,准确地一点点调准机器人的位置和朝向。下面是例子以及解决方案。

但这里有个麻烦:机器人的 "朝向(旋转)" 不是 "平坦的平面"(比如朝东→朝东南不是简单的加法),而是 "弯曲的空间"(像地球表面,走直线其实是曲线)。所以 "状态的误差范围" 不能直接算,得做特殊处理 ------ 这就是下面要解决的核心问题。(好难看不懂)

从初始状态开始调 ,在计算过程中,首先是需要进行微调,即:从初始状态开始调,从出发不断迭代修正量,更新直至收敛。

迭代更新,累加修正量:

**计算误差范围:**由于我们这里是要对弯曲空间进行计算,需要将弯曲空间转为平面近似。

计算修正量,让预测匹配观测:

示意图的作用:

8.2.3高维观测的等效处理

上面就是利用公式进行降维,降低计算量。

NDT和卡尔曼滤波器的联系:

考虑一组点云的NDT配准。设点云一共有N个点,当考虑第j个点的配准情况时,它应该

落在被配准的目标点云中的一个体素内,并产生一个与NDT相关的残差

主要是在于将点云的残差带入到卡尔曼增益公式的误差部分,以及协方差更新中,构成观测方程。

8.3实现基于IEKF的LIO系统

基于IEKF的LIO系统的整个实现流程:

1.传感器数据输入和预处理:输入IMU原始数据(角速度、加速度)以及激光雷达原始点云,由于IMU和激光雷达的频率不一样,需要通过硬件触发 / 软件插值,将 IMU 数据对齐到激光点云的每个采样时刻,之后进行IMU校准,比如去零漂、补偿重力得到真实角速度,最后进行点云去畸变,利用 IMU 的高频姿态信息,修正激光扫描过程中因机器人运动导致的点云变形,得到 "帧起始时刻静止坐标系下的无畸变点云"。

2.IMU预积分计算:相邻激光帧()间的校准后IMU数据,对其进行预积分,计算相对运动增量。

3.IEKF预测步(IMU驱动状态预测)

4.激光观测(生成观测残差)

输入:无畸变激光点云 + 局部地图(由历史帧点云拼接而成,或者上一帧点云)

其中第一步的先验位姿是:用 IMU 预测的位姿(先验状态),把激光自己坐标系的点,换算成全局世界系的坐标

5.IEKF迭代更新步(消除IMU漂移)

6.最优状态与局部地图更新

上述步骤会随每帧激光数据重复执行,形成"预处理→预积分→预测→观测→迭代修正→地图更新"的循环,最终实现"IMU高频跟踪+激光高精度修正"的紧耦合LIO位姿与地图估计。

代码实现:

我们希望从NDT中直接获取雅可比矩阵和信息矩阵,所以在IEKF实

NDT观测函数:

cpp 复制代码
    /**
     * NDT观测函数,输入一个SE3 Pose, 返回本书(8.10)中的几个项
     * HT V^{-1} H
     * H^T V{-1} r
     * 二者都可以用求和的形式来做
     */
    using CustomObsFunc = std::function<void(const SE3& input_pose, Eigen::Matrix<S, 18, 18>& HT_Vinv_H,
                                             Eigen::Matrix<S, 18, 1>& HT_Vinv_r)>;

    /// 使用自定义观测函数更新滤波器
    bool UpdateUsingCustomObserve(CustomObsFunc obs);

    /// accessors
    /// 全量状态
    NavStateT GetNominalState() const { return NavStateT(current_time_, R_, p_, v_, bg_, ba_); }

    /// SE3 状态
    SE3 GetNominalSE3() const { return SE3(R_, p_); }

    void SetX(const NavStated& x) {
        current_time_ = x.timestamp_;
        R_ = x.R_;
        p_ = x.p_;
        v_ = x.v_;
        bg_ = x.bg_;
        ba_ = x.ba_;
    }

    void SetCov(const Mat18T& cov) { cov_ = cov; }
    Vec3d GetGravity() const { return g_; }

   private:
    void BuildNoise(const Options& options) {
        double ev = options.acce_var_;
        double et = options.gyro_var_;
        double eg = options.bias_gyro_var_;
        double ea = options.bias_acce_var_;

        double ev2 = ev;  // * ev;
        double et2 = et;  // * et;
        double eg2 = eg;  // * eg;
        double ea2 = ea;  // * ea;

        // set Q
        Q_.diagonal() << 0, 0, 0, ev2, ev2, ev2, et2, et2, et2, eg2, eg2, eg2, ea2, ea2, ea2, 0, 0, 0;

        double gp2 = options.gnss_pos_noise_ * options.gnss_pos_noise_;
        double gh2 = options.gnss_height_noise_ * options.gnss_height_noise_;
        double ga2 = options.gnss_ang_noise_ * options.gnss_ang_noise_;
        gnss_noise_.diagonal() << gp2, gp2, gh2, ga2, ga2, ga2;
    }

    /// 更新名义状态变量
    void Update() {
        p_ += dx_.template block<3, 1>(0, 0);
        v_ += dx_.template block<3, 1>(3, 0);
        R_ = R_ * SO3::exp(dx_.template block<3, 1>(6, 0));

        if (options_.update_bias_gyro_) {
            bg_ += dx_.template block<3, 1>(9, 0);
        }

        if (options_.update_bias_acce_) {
            ba_ += dx_.template block<3, 1>(12, 0);
        }
        g_ += dx_.template block<3, 1>(15, 0);
    }

    double current_time_ = 0.0;

    // nominal state
    SO3 R_;
    VecT p_ = VecT::Zero();
    VecT v_ = VecT::Zero();
    VecT bg_ = VecT::Zero();
    VecT ba_ = VecT::Zero();
    VecT g_{0, 0, -9.8};

    // error state
    Vec18T dx_ = Vec18T::Zero();

    // covariance
    Mat18T cov_ = Mat18T::Identity();

    // noise
    MotionNoiseT Q_ = MotionNoiseT::Zero();
    GnssNoiseT gnss_noise_ = GnssNoiseT::Zero();

    Options options_;
};

using IESKFD = IESKF<double>;
using IESKFF = IESKF<float>;

template <typename S>
bool IESKF<S>::Predict(const IMU& imu) {
    /// Predict 部分与ESKF完全一样,不再解释
    assert(imu.timestamp_ >= current_time_);

    double dt = imu.timestamp_ - current_time_;
    if (dt > (5 * options_.imu_dt_) || dt < 0) {
        LOG(INFO) << "skip this imu because dt_ = " << dt;
        current_time_ = imu.timestamp_;
        return false;
    }

    VecT new_p = p_ + v_ * dt + 0.5 * (R_ * (imu.acce_ - ba_)) * dt * dt + 0.5 * g_ * dt * dt;
    VecT new_v = v_ + R_ * (imu.acce_ - ba_) * dt + g_ * dt;
    SO3 new_R = R_ * SO3::exp((imu.gyro_ - bg_) * dt);

    R_ = new_R;
    v_ = new_v;
    p_ = new_p;

    Mat18T F = Mat18T::Identity();
    F.template block<3, 3>(0, 3) = Mat3T::Identity() * dt;
    F.template block<3, 3>(3, 6) = -R_.matrix() * SO3::hat(imu.acce_ - ba_) * dt;
    F.template block<3, 3>(3, 12) = -R_.matrix() * dt;
    F.template block<3, 3>(3, 15) = Mat3T::Identity() * dt;
    F.template block<3, 3>(6, 6) = SO3::exp(-(imu.gyro_ - bg_) * dt).matrix();
    F.template block<3, 3>(6, 9) = -Mat3T::Identity() * dt;

    cov_ = F * cov_ * F.transpose() + Q_;
    current_time_ = imu.timestamp_;
    return true;
}
template <typename S>
bool IESKF<S>::UpdateUsingCustomObserve(IESKF::CustomObsFunc obs) {
    // H阵由用户给定

    SO3 start_R = R_;
    Eigen::Matrix<S, 18, 1> HTVr;
    Eigen::Matrix<S, 18, 18> HTVH;
    Eigen::Matrix<S, 18, Eigen::Dynamic> K;
    Mat18T Pk, Qk;

    for (int iter = 0; iter < options_.num_iterations_; ++iter) {
        // 调用obs function
        obs(GetNominalSE3(), HTVH, HTVr);

        // 投影P
        Mat18T J = Mat18T::Identity();
        J.template block<3, 3>(6, 6) = Mat3T::Identity() - 0.5 * SO3::hat((R_.inverse() * start_R).log());
        Pk = J * cov_ * J.transpose();

        // 卡尔曼更新
        Qk = (Pk.inverse() + HTVH).inverse();  // 这个记作中间变量,最后更新时可以用
        dx_ = Qk * HTVr;
        // LOG(INFO) << "iter " << iter << " dx = " << dx_.transpose() << ", dxn: " << dx_.norm();

        // dx合入名义变量
        Update();

        if (dx_.norm() < options_.quit_eps_) {
            break;
        }
    }

    // update P
    cov_ = (Mat18T::Identity() - Qk * HTVH) * Pk;

    // project P
    Mat18T J = Mat18T::Identity();
    Vec3d dtheta = (R_.inverse() * start_R).log();
    J.template block<3, 3>(6, 6) = Mat3T::Identity() - 0.5 * SO3::hat(dtheta);
    cov_ = J * cov_ * J.inverse();

    dx_.setZero();
    return true;
}
cpp 复制代码
void IncNdt3d::ComputeResidualAndJacobians(const SE3& input_pose, Mat18d& HTVH, Vec18d& HTVr) {
    assert(grids_.empty() == false);
    SE3 pose = input_pose;

    // 大部分流程和前面的Align是一样的,只是会把z, H, R三者抛出去而非自己处理
    int num_residual_per_point = 1;
    if (options_.nearby_type_ == NearbyType::NEARBY6) {
        num_residual_per_point = 7;
    }

    std::vector<int> index(source_->points.size());
    for (int i = 0; i < index.size(); ++i) {
        index[i] = i;
    }

    int total_size = index.size() * num_residual_per_point;

    std::vector<bool> effect_pts(total_size, false);
    std::vector<Eigen::Matrix<double, 3, 18>> jacobians(total_size);
    std::vector<Vec3d> errors(total_size);
    std::vector<Mat3d> infos(total_size);

    // gauss-newton 迭代
    // 最近邻,可以并发
    std::for_each(std::execution::par_unseq, index.begin(), index.end(), [&](int idx) {
        auto q = ToVec3d(source_->points[idx]);
        Vec3d qs = pose * q;  // 转换之后的q

        // 计算qs所在的栅格以及它的最近邻栅格
        Vec3i key = CastToInt(Vec3d(qs * options_.inv_voxel_size_));

        for (int i = 0; i < nearby_grids_.size(); ++i) {
            Vec3i real_key = key + nearby_grids_[i];
            auto it = grids_.find(real_key);
            int real_idx = idx * num_residual_per_point + i;
            /// 这里要检查高斯分布是否已经估计
            if (it != grids_.end() && it->second->second.ndt_estimated_) {
                auto& v = it->second->second;  // voxel
                Vec3d e = qs - v.mu_;

                // check chi2 th
                double res = e.transpose() * v.info_ * e;
                if (std::isnan(res) || res > options_.res_outlier_th_) {
                    effect_pts[real_idx] = false;
                    continue;
                }

                // build residual
                Eigen::Matrix<double, 3, 18> J;
                J.setZero();
                J.block<3, 3>(0, 0) = Mat3d::Identity();                   // 对p
                J.block<3, 3>(0, 6) = -pose.so3().matrix() * SO3::hat(q);  // 对R

                jacobians[real_idx] = J;
                errors[real_idx] = e;
                infos[real_idx] = v.info_;
                effect_pts[real_idx] = true;
            } else {
                effect_pts[real_idx] = false;
            }
        }
    });

    // 累加Hessian和error,计算dx
    double total_res = 0;
    int effective_num = 0;

    HTVH.setZero();
    HTVr.setZero();

    const double info_ratio = 0.01;  // 每个点反馈的info因子

    for (int idx = 0; idx < effect_pts.size(); ++idx) {
        if (!effect_pts[idx]) {
            continue;
        }

        total_res += errors[idx].transpose() * infos[idx] * errors[idx];
        effective_num++;

        HTVH += jacobians[idx].transpose() * infos[idx] * jacobians[idx] * info_ratio;
        HTVr += -jacobians[idx].transpose() * infos[idx] * errors[idx] * info_ratio;
    }

    LOG(INFO) << "effective: " << effective_num;
}
cpp 复制代码
 // 后续的scan,使用NDT配合pose进行更新
    LOG(INFO) << "=== frame " << frame_num_;

    ndt_.SetSource(current_scan_filter);
    ieskf_.UpdateUsingCustomObserve([this](const SE3 &input_pose, Mat18d &HTVH, Vec18d &HTVr) {
        ndt_.ComputeResidualAndJacobians(input_pose, HTVH, HTVr);
    });

    auto current_nav_state = ieskf_.GetNominalState();

    // 若运动了一定范围,则把点云放入地图中
    SE3 current_pose = ieskf_.GetNominalSE3();
    SE3 delta_pose = last_pose_.inverse() * current_pose;

    if (delta_pose.translation().norm() > 1.0 || delta_pose.so3().log().norm() > math::deg2rad(10.0)) {
        // 将地图合入NDT中
        CloudPtr current_scan_world(new PointCloudType);
        pcl::transformPointCloud(*current_scan_filter, *current_scan_world, current_pose.matrix());
        ndt_.AddCloud(current_scan_world);
        last_pose_ = current_pose;
    }

IEKF会为NDT提供当前的状态估计值,然后NDT会计算前文所述的两个矩阵,返回给IEKF,最后IEKF再去更新自己内部的状态估计。这样就得到了本次激光扫描对应的位姿。另外,如果车辆运动超过1米或角度超过10°,就把当前的激光扫描数据合入NDT地图中,这样可以防止车辆在静止时一直花时间更新地图。

ps:感觉这个很难,需要把前面的知识串起来,还有好多数学运算,好难好难,幸好有豆包帮我,现在理解了。,我真的哭死。

相关推荐
c76917 小时前
【文献笔记】Mixture-of-Agents Enhances Large Language Model Capabilities
人工智能·笔记·语言模型·自然语言处理·论文笔记·提示工程
HXR_plume17 小时前
【Web信息处理与应用课程笔记8】知识图谱与图计算
人工智能·笔记·知识图谱
孙严Pay1 天前
快捷支付:高效安全的在线支付新选择
笔记·科技·计算机网络·其他·微信
じ☆冷颜〃1 天前
黎曼几何驱动的算法与系统设计:理论、实践与跨领域应用
笔记·python·深度学习·网络协议·算法·机器学习
才兄说1 天前
机器人租赁服务中的确定性:从客户反馈看现场支持安排
机器人
数据皮皮侠AI1 天前
上市公司股票名称相似度(1990-2025)
大数据·人工智能·笔记·区块链·能源·1024程序员节
yuhaiqun19891 天前
学服务器训练AI模型:5步路径助力高效入门
运维·服务器·人工智能·笔记·机器学习·ai
雍凉明月夜1 天前
深度学习网络笔记Ⅳ(Transformer + VIT)
笔记·深度学习·transformer
无我19871 天前
养殖厂机器人清淤施工哪家专业生产厂家
机器人