59、C++项目实战:SLAM算法入门

说实话,SLAM(Simultaneous Localization and Mapping)这个词,我第一次听到是在研究生实验室。当时觉得这玩意儿太玄乎了——一边定位一边建图,听起来像科幻片。后来真正上手做项目才发现,它其实就是让机器人回答两个问题:“我在哪?”“周围长啥样?”

今天咱们就用C++来拆解一个最基础的SLAM核心模块。别怕,我不会一上来就甩给你几百行的图优化代码。咱们从最朴素的思路开始。

SLAM到底在干什么?

我习惯把SLAM比作一个人蒙着眼睛在陌生房间里走路。你每走一步,能感觉到自己移动了多少(里程计),也能伸手摸到旁边的墙壁(传感器观测)。但问题是——你的脚步估计有误差,手摸到的位置也不准。SLAM要做的,就是把这些不靠谱的信息揉在一起,算出靠谱的轨迹和地图。

在C++里实现SLAM,核心就三件事:

  • 前端:处理传感器数据,估计帧间运动
  • 后端:优化全局轨迹和地图
  • 回环检测:认出“我来过这里”,修正累积误差

嗯,咱们今天重点讲前端里的ICP(迭代最近点)算法。这是最经典的激光SLAM配准方法。

ICP算法的核心思想

说白了,ICP就是解决这样一个问题:你有两堆点云,一堆是上一帧的,一堆是当前帧的。你想找到一个旋转矩阵R和平移向量t,让当前帧的点云旋转平移后,和上一帧尽可能重合。

数学上,我们要最小化这个误差:

E(R, t) = Σ || p_i - (R * q_i + t) ||²

其中p_i是上一帧的点,q_i是当前帧对应的点。

我在项目中遇到过一个问题:如果初始位置猜得不好,ICP很容易陷入局部最优。所以实际工程里,我们通常会先用IMU或者轮式里程计给一个粗略的初值,再用ICP做精细配准。

C++实现一个轻量级ICP

咱们直接上代码。这里我写了一个简化版的ICP,只处理2D情况,方便你理解核心逻辑。

#include <vector>
#include <cmath>
#include <Eigen/Dense>

struct Point2D {
    double x, y;
};

class ICP2D {
public:
    // 输入:源点云、目标点云、初始位姿
    // 输出:优化后的旋转矩阵R和平移向量t
    void align(const std::vector<Point2D>& src,
               const std::vector<Point2D>& dst,
               Eigen::Matrix2d& R,
               Eigen::Vector2d& t) {
        int max_iter = 50;
        double tolerance = 1e-6;
        
        for (int iter = 0; iter < max_iter; ++iter) {
            // 1. 找对应点(最近邻)
            std::vector<std::pair<Point2D, Point2D>> correspondences;
            for (const auto& p : src) {
                Point2D transformed_p = applyTransform(p, R, t);
                Point2D nearest = findNearest(transformed_p, dst);
                correspondences.push_back({transformed_p, nearest});
            }
            
            // 2. 计算质心
            Point2D src_centroid = computeCentroid(src);
            Point2D dst_centroid = computeCentroid(dst);
            
            // 3. 去质心化
            std::vector<Point2D> src_demean, dst_demean;
            for (size_t i = 0; i < correspondences.size(); ++i) {
                src_demean.push_back({
                    correspondences[i].first.x - src_centroid.x,
                    correspondences[i].first.y - src_centroid.y
                });
                dst_demean.push_back({
                    correspondences[i].second.x - dst_centroid.x,
                    correspondences[i].second.y - dst_centroid.y
                });
            }
            
            // 4. 构建协方差矩阵并SVD分解
            Eigen::Matrix2d H = Eigen::Matrix2d::Zero();
            for (size_t i = 0; i < src_demean.size(); ++i) {
                Eigen::Vector2d a(src_demean[i].x, src_demean[i].y);
                Eigen::Vector2d b(dst_demean[i].x, dst_demean[i].y);
                H += a * b.transpose();
            }
            
            Eigen::JacobiSVD<Eigen::Matrix2d> svd(H, Eigen::ComputeFullU | Eigen::ComputeFullV);
            Eigen::Matrix2d U = svd.matrixU();
            Eigen::Matrix2d V = svd.matrixV();
            
            // 5. 计算最优旋转
            Eigen::Matrix2d R_new = V * U.transpose();
            if (R_new.determinant() < 0) {
                V.col(1) *= -1;
                R_new = V * U.transpose();
            }
            
            // 6. 计算最优平移
            Eigen::Vector2d t_new(dst_centroid.x, dst_centroid.y);
            t_new -= R_new * Eigen::Vector2d(src_centroid.x, src_centroid.y);
            
            // 7. 检查收敛
            double delta = (R_new - R).norm() + (t_new - t).norm();
            R = R_new;
            t = t_new;
            
            if (delta < tolerance) break;
        }
    }

private:
    Point2D applyTransform(const Point2D& p, const Eigen::Matrix2d& R, const Eigen::Vector2d& t) {
        Eigen::Vector2d v(p.x, p.y);
        v = R * v + t;
        return {v.x(), v.y()};
    }
    
    Point2D findNearest(const Point2D& p, const std::vector<Point2D>& cloud) {
        // 暴力最近邻,实际工程用KD-Tree
        double min_dist = 1e9;
        Point2D nearest = cloud[0];
        for (const auto& q : cloud) {
            double dx = p.x - q.x;
            double dy = p.y - q.y;
            double dist = dx*dx + dy*dy;
            if (dist < min_dist) {
                min_dist = dist;
                nearest = q;
            }
        }
        return nearest;
    }
    
    Point2D computeCentroid(const std::vector<Point2D>& cloud) {
        double cx = 0, cy = 0;
        for (const auto& p : cloud) {
            cx += p.x;
            cy += p.y;
        }
        return {cx / cloud.size(), cy / cloud.size()};
    }
};
我的小建议: 这段代码里最关键的其实是SVD分解那一步。你想想看,为什么协方差矩阵的SVD能给出旋转矩阵?因为它在数学上等价于最小二乘下的最优旋转。我在做第一个SLAM项目时,花了整整两天才把这一步的推导吃透。

ICP的完整流程

为了让你看得更清楚,我画了一张流程图。这张图展示了ICP从输入到输出的完整数据流。

ICP算法流程图 输入:源点云 + 目标点云 步骤1:初始位姿估计(里程计/IMU) 步骤2:最近邻搜索 步骤3:SVD求解R和t 收敛? 输出:R, t 迭代更新

关键点: 注意看那个“否”的回环。我曾经调试一个ICP算法,发现它总是在10次迭代内就收敛了,但结果却是错的。后来排查发现,是最近邻搜索写错了——我把源点和目标点的索引搞反了。这种bug特别隐蔽,你写代码时一定要小心。

工程中的避坑指南

做SLAM项目,坑真的不少。我把自己踩过的几个大坑列出来,你遇到了能少走弯路。

我曾经踩过的坑:
  • 最近邻搜索用暴力法:点云一多,速度直接崩。我有个项目里点云有10万个点,暴力搜索一次要200ms,整个ICP跑下来要几十秒。后来换成KD-Tree,直接降到5ms。
  • 忘记去质心化:SVD求解旋转矩阵前,必须先把点云减去质心。我第一次写ICP时忘了这步,结果算出来的R和t完全不对,折腾了两天才发现。
  • 初始值给得太差:ICP对初值敏感。如果初始位姿偏差超过30度,基本就收敛到错误解了。我现在的习惯是:先用轮式里程计给一个初值,再用ICP微调。

性能对比:暴力搜索 vs KD-Tree

为了让你直观感受优化的重要性,我列个表格。这是我在实际项目中测的数据。

点云数量 暴力搜索(每次迭代) KD-Tree(每次迭代) 加速比
1,000 2.1 ms 0.3 ms 7x
10,000 210 ms 3.2 ms 65x
100,000 21,000 ms 35 ms 600x

你想想看,如果不用KD-Tree,100万点的点云一次迭代就要21秒,整个算法根本没法用。所以工程上,数据结构的选择直接决定了系统能不能跑起来

从ICP到完整SLAM

ICP只是SLAM前端的一小部分。一个完整的SLAM系统还包括:

  • 关键帧选取:不是每一帧都做优化,只选有代表性的帧
  • 图优化:把所有的位姿和观测构建成一个图,用最小二乘法整体优化
  • 回环检测:用特征匹配或者词袋模型,识别出之前到过的地方

我个人习惯是,先跑通一个最简版本,再逐步加功能。比如先只做帧间ICP,能跑出轨迹了,再加回环检测。这样每一步都能验证正确性,出了问题也好定位。

一个实用建议: 刚开始学SLAM时,别直接啃ORB-SLAM或者LOAM那种大型框架。先自己手写一个2D的ICP+图优化,跑通KITTI数据集的一小段。这个过程会让你对SLAM的理解深刻得多。

好了,关于SLAM入门和ICP算法,今天就聊到这儿。代码你可以直接拿去用,但建议你亲手敲一遍——只有自己踩过坑,才能真正理解里面的细节。


公众号:蓝海资料掘金营,微信deep3321