0. A-LOAM 整体运行链路

A-LOAM 本质是一个 纯 3D LiDAR 特征点 SLAM。它不是直接拿整帧点云硬匹配,而是先提取角点和平面点,再用几何误差优化位姿。

完整链路如下:

原始点云
  ↓
scanRegistration.cpp
提取角点和平面点
  ↓
lidarFactor.hpp
定义点到线、点到面误差
  ↓
laserOdometry.cpp
当前帧 vs 上一帧,估计快速里程计
  ↓
laserMapping.cpp
当前帧 vs 局部地图,优化地图位姿
  ↓
发布轨迹、TF、点云地图

可以这样记:

scanRegistration:负责“选点”
lidarFactor:负责“误差怎么算”
laserOdometry:负责“短时间怎么动”
laserMapping:负责“在地图里怎么对齐”

1. 数据输入层:kittiHelper.cpp

1.1 这个文件干什么?

kittiHelper.cpp 不是核心 SLAM 算法,它主要用于跑 KITTI 数据集。因为 KITTI 的点云一般是 .bin 文件,而 A-LOAM 正常处理的是 ROS 点云话题。

所以它的作用是:

读取 KITTI .bin 文件
  ↓
解析 x、y、z、intensity
  ↓
转成 PCL 点云
  ↓
转成 ROS PointCloud2
  ↓
发布成 /velodyne_points

如果你用真实雷达跑 A-LOAM,这个文件可以先不重点看,因为真实雷达驱动会直接发布点云。


1.2 关键代码逻辑

std::ifstream input(filename.c_str(), std::ios::binary);

pcl::PointCloud<PointType>::Ptr laserCloud(new pcl::PointCloud<PointType>);

while (!input.eof())
{
    float x, y, z, intensity;

    input.read((char*)&x, sizeof(float));
    input.read((char*)&y, sizeof(float));
    input.read((char*)&z, sizeof(float));
    input.read((char*)&intensity, sizeof(float));

    PointType point;
    point.x = x;
    point.y = y;
    point.z = z;
    point.intensity = intensity;

    laserCloud->push_back(point);
}

sensor_msgs::PointCloud2 laserCloudMsg;
pcl::toROSMsg(*laserCloud, laserCloudMsg);
laserCloudMsg.header.frame_id = "camera_init";
pubLaserCloud.publish(laserCloudMsg);

这段代码很好理解。

input.read() 每次从 .bin 文件里读取一个 float。KITTI 点云里一个点一般由四个 float 组成:

x, y, z, intensity

其中:

x、y、z:
点在雷达坐标系下的三维坐标。

intensity:
反射强度。

读取完之后,把点放进 PCL 点云:

laserCloud->push_back(point);

然后通过:

pcl::toROSMsg(*laserCloud, laserCloudMsg);

把 PCL 点云转成 ROS 点云消息,最后发布出去。

这个文件只解决“数据怎么进系统”的问题,真正前端从 scanRegistration.cpp 开始。


2. 前端一:scanRegistration.cpp

2.1 这个文件干什么?

scanRegistration.cpp 是 A-LOAM 前端的第一步,负责 点云预处理和特征提取

它输入原始点云,输出四类特征点:

cornerPointsSharp:
最尖锐角点,数量少,质量高。

cornerPointsLessSharp:
次尖锐角点,数量更多。

surfPointsFlat:
最平坦平面点,数量少,质量高。

surfPointsLessFlat:
次平面点,数量更多,通常会降采样。

这个文件的核心逻辑是:

原始点云
  ↓
去无效点、去近点
  ↓
按垂直角度分线束
  ↓
计算点在一帧中的相对时间
  ↓
按扫描线重新排列点云
  ↓
计算曲率
  ↓
根据曲率选角点和平面点
  ↓
发布特征点云

2.2 去掉太近的点

先看一个基础预处理:去除距离太近的点。

void removeClosedPointCloud(
    const pcl::PointCloud<PointType>& cloud_in,
    pcl::PointCloud<PointType>& cloud_out,
    float min_range)
{
    cloud_out.clear();

    for (int i = 0; i < cloud_in.points.size(); ++i)
    {
        PointType p = cloud_in.points[i];

        float dis2 = p.x * p.x + p.y * p.y + p.z * p.z;

        if (dis2 < min_range * min_range)
            continue;

        cloud_out.push_back(p);
    }
}

这里计算的是点到雷达原点的距离平方:

dis² = x² + y² + z²

如果:

dis² < min_range²

说明点太近,直接丢掉。

为什么不写成:

dis = sqrt(x² + y² + z²)

因为开根号更耗时。比较距离大小时,直接比较平方就够了。

太近点通常可能来自:

车体
雷达外壳
安装支架
近距离噪声
异常反射点

这些点不代表稳定环境结构,保留下来会影响后面曲率计算。


2.3 根据垂直角度计算 scanID

多线雷达的点来自不同线束。A-LOAM 需要知道每个点属于第几根线,因为后面计算曲率时,要找同一根线上的前后邻居点。

关键代码:

float verticalAngle =
    atan(point.z / sqrt(point.x * point.x + point.y * point.y))
    * 180.0 / M_PI;

int scanID = int((verticalAngle + 15) / 2 + 0.5);

if (scanID < 0 || scanID >= N_SCANS)
    continue;

公式是:

verticalAngle = atan(z / sqrt(x² + y²))

其中:

z:
点的高度。

sqrt(x² + y²):
点在水平面上的距离。

z / sqrt(x² + y²):
点相对于水平面的倾斜程度。

atan:
把倾斜比例转成角度。

然后:

scanID = int((verticalAngle + 15) / 2 + 0.5)

这一般针对 16 线雷达的角度分布来理解:

+15:
把负角度平移到正区间。

/2:
相邻线束大约间隔 2 度。

+0.5:
四舍五入。

int:
变成整数线号。

这一步非常关键。因为如果 scanID 算错,点就会被放到错误的扫描线里,后面的前后邻居也会错,曲率和特征点都会错。


2.4 计算点在一帧中的相对时间

机械式 LiDAR 一帧点云不是瞬间采完的,而是旋转一圈扫出来的。也就是说,一帧点云内部的点有不同采集时间。

关键代码:

float startOri = -atan2(firstPoint.y, firstPoint.x);
float endOri   = -atan2(lastPoint.y, lastPoint.x) + 2 * M_PI;

float ori = -atan2(point.y, point.x);

float relTime = (ori - startOri) / (endOri - startOri);

point.intensity = scanID + scanPeriod * relTime;

核心公式:

relTime = (ori - startOri) / (endOri - startOri)

解释:

startOri:
这一帧第一个点的水平角。

endOri:
这一帧最后一个点的水平角。

ori:
当前点的水平角。

relTime:
当前点在这一帧中的相对采集时间。

如果:

relTime ≈ 0

说明这个点接近当前帧开始时刻采集。

如果:

relTime ≈ 1

说明这个点接近当前帧结束时刻采集。

最后这句很关键:

point.intensity = scanID + scanPeriod * relTime;

这里 intensity 字段被复用了:

整数部分:scanID,表示第几根线
小数部分:相对采集时间

后面的里程计模块会利用这个相对时间做运动补偿。A-LOAM 里基本就是用上一帧和当前帧之间估计出来的相对位姿 q_last_curr、t_last_curr,按点的相对时间 s 做插值,近似当作当前帧内部的运动补偿量。


2.5 计算曲率

曲率是 A-LOAM 提取特征的核心。它想判断一个点局部是“平滑”还是“突变”。

关键代码:

for (int i = 5; i < cloudSize - 5; ++i)
{
    float diffX =
        cloud[i - 5].x + cloud[i - 4].x + cloud[i - 3].x +
        cloud[i - 2].x + cloud[i - 1].x -
        10 * cloud[i].x +
        cloud[i + 1].x + cloud[i + 2].x + cloud[i + 3].x +
        cloud[i + 4].x + cloud[i + 5].x;

    float diffY =
        cloud[i - 5].y + cloud[i - 4].y + cloud[i - 3].y +
        cloud[i - 2].y + cloud[i - 1].y -
        10 * cloud[i].y +
        cloud[i + 1].y + cloud[i + 2].y + cloud[i + 3].y +
        cloud[i + 4].y + cloud[i + 5].y;

    float diffZ =
        cloud[i - 5].z + cloud[i - 4].z + cloud[i - 3].z +
        cloud[i - 2].z + cloud[i - 1].z -
        10 * cloud[i].z +
        cloud[i + 1].z + cloud[i + 2].z + cloud[i + 3].z +
        cloud[i + 4].z + cloud[i + 5].z;

    cloudCurvature[i] = diffX * diffX + diffY * diffY + diffZ * diffZ;
}

对应公式:


c_i = || p_{i-5} + p_{i-4} + p_{i-3} + p_{i-2} + p_{i-1}
       - 10 * p_i
       + p_{i+1} + p_{i+2} + p_{i+3} + p_{i+4} + p_{i+5} ||²

这里:

p_i:
当前点。

p_{i-5} 到 p_{i-1}:
当前点前面的 5 个邻居。

p_{i+1} 到 p_{i+5}:
当前点后面的 5 个邻居。

c_i:
当前点曲率。

为什么是 -10 * p_i

因为当前点前后总共取了 10 个邻居。如果当前点处在平面或平滑区域,那么周围 10 个点的位置和大约等于:

10 * 当前点位置

也就是:

p_{i-5} + ... + p_{i-1} + p_{i+1} + ... + p_{i+5}
≈ 10 * p_i

所以差值接近 0,曲率小。

如果当前点处在墙角、柱子边缘、箱子棱边,那么它和周围点差异很大,曲率就大。

因此:

曲率大:角点 / 边缘点
曲率小:平面点

2.6 选择角点和平面点

A-LOAM 不会整帧统一选最大曲率点,而是每根线分成多个区域,每个区域单独选点。

for (int scanID = 0; scanID < N_SCANS; ++scanID)
{
    for (int j = 0; j < 6; ++j)
    {
        int sp = scanStartInd[scanID]
               + (scanEndInd[scanID] - scanStartInd[scanID]) * j / 6;

        int ep = scanStartInd[scanID]
               + (scanEndInd[scanID] - scanStartInd[scanID]) * (j + 1) / 6 - 1;

        sort(cloudSortInd + sp, cloudSortInd + ep + 1, byCurvature);
    }
}

这样做是为了让特征点分布更均匀。否则角点可能全堆在某一侧,导致位姿约束不稳定。

角点选择:

int largestPickedNum = 0;

for (int k = ep; k >= sp; --k)
{
    int ind = cloudSortInd[k];

    if (cloudNeighborPicked[ind] == 0 &&
        cloudCurvature[ind] > 0.1)
    {
        largestPickedNum++;

        if (largestPickedNum <= 2)
        {
            cloudLabel[ind] = 2;
            cornerPointsSharp.push_back(cloud[ind]);
            cornerPointsLessSharp.push_back(cloud[ind]);
        }
        else if (largestPickedNum <= 20)
        {
            cloudLabel[ind] = 1;
            cornerPointsLessSharp.push_back(cloud[ind]);
        }
        else
        {
            break;
        }

        cloudNeighborPicked[ind] = 1;
    }
}

这段代码是在 当前扫描线的某个分段 [sp, ep] 中选择角点特征:由于 cloudSortInd 已经按曲率从小到大排好序,所以这里从 epsp 反向遍历,相当于优先检查曲率最大的点。largestPickedNum 表示当前分段已经选出的角点数量;ind = cloudSortInd[k] 表示排序后第 k 个点在原始点云 cloud 里的真实索引;cloudNeighborPicked[ind] == 0 表示该点没有被选过,也没有被邻域抑制;cloudCurvature[ind] > 0.1 表示该点曲率足够大,局部几何变化明显,适合作为边缘/角点。如果满足条件,就先让 largestPickedNum++。当 largestPickedNum <= 2 时,说明这是当前分段里最尖锐的前 2 个点,会被标记为强角点:cloudLabel[ind] = 2,并同时加入 cornerPointsSharpcornerPointsLessSharp;当 largestPickedNum <= 20 时,说明它不是最尖锐的前 2 个,但仍属于曲率较大的前 20 个点,会被标记为次角点:cloudLabel[ind] = 1,只加入 cornerPointsLessSharp;如果超过 20 个,就 break,停止当前分段的角点选择。最后 cloudNeighborPicked[ind] = 1 表示这个点已经被选中,后续不能重复选。整体逻辑就是:每个分段内按曲率从大到小选角点,最多选 2 个 sharp 角点、20 个 less sharp 角点,用于后续 LiDAR 匹配中的边缘特征约束。

这里:

cornerPointsSharp:
每个区域最尖锐的少量角点,用于精确匹配。

cornerPointsLessSharp:
数量更多的角点,用于后续里程计和建图参考。

平面点选择:

int smallestPickedNum = 0;

for (int k = sp; k <= ep; ++k)
{
    int ind = cloudSortInd[k];

    if (cloudNeighborPicked[ind] == 0 &&
        cloudCurvature[ind] < 0.1)
    {
        cloudLabel[ind] = -1;
        surfPointsFlat.push_back(cloud[ind]);

        smallestPickedNum++;

        if (smallestPickedNum >= 4)
            break;

        cloudNeighborPicked[ind] = 1;
    }
}

这段代码是在 LOAM / A-LOAM 特征提取阶段,从当前扫描线某个分段 [sp, ep] 中挑选平面点 surfPointsFlat。前面代码通常已经把当前分段内的点按照曲率 cloudCurvature 从小到大排序,并把排序后的原始点索引存到 cloudSortInd 中,所以这里 for (int k = sp; k <= ep; ++k) 是从曲率最小的点开始往后遍历。ind = cloudSortInd[k] 表示当前候选点在原始点云 cloud 中的真实索引;cloudNeighborPicked[ind] == 0 表示该点没有被选中过,也没有被邻域抑制;cloudCurvature[ind] < 0.1 表示该点曲率足够小,局部表面比较平滑,适合作为平面特征点。满足条件后,代码把 cloudLabel[ind] = -1,表示将该点标记为平面点,然后通过 surfPointsFlat.push_back(cloud[ind]) 把它加入强平面点集合。smallestPickedNum 用来统计当前分段已经选了多少个平面点,每选中一个就加 1;当数量达到 4 个时执行 break,说明当前分段最多只选 4 个最平坦的点,避免平面点过多集中在某一局部区域。最后 cloudNeighborPicked[ind] = 1 表示该点已经被使用,后续不再重复选择。整体逻辑就是:在每个分段里优先选择曲率最小、未被选过的点作为平面特征点,每段最多选 4 个,用于后续 LiDAR 匹配中的点到平面约束。

这里:

surfPointsFlat:
最平坦的少量平面点。

surfPointsLessFlat:
更多平面点,一般会做体素滤波,减少数量。


3. 优化模型:lidarFactor.hpp

3.1 这个文件在系统中的位置

lidarFactor.hpp 不直接订阅点云,也不发布话题。它是给优化器用的,作用是定义:

点到线残差
点到面残差

这个文件会被 laserOdometry.cpplaserMapping.cpp 使用。

所以它在逻辑上属于:

优化模型层

它回答的问题是:

当前帧点经过位姿变换后,和参考线/参考面的误差怎么算?

3.2 位姿变换公式

A-LOAM 优化的是当前帧位姿,可以写成:

T = [R, t]

其中:

R:旋转
t:平移

当前帧里的点 p 变换到参考坐标系:

p' = R * p + t

代码里通常用四元数 q 表示旋转:

Eigen::Quaternion<T> quat(q[3], q[0], q[1], q[2]);
Eigen::Matrix<T, 3, 1> trans(t[0], t[1], t[2]);

Eigen::Matrix<T, 3, 1> p_trans = quat * p + trans;

这里:

p:
当前帧点。

quat * p:
把点旋转到参考坐标系方向。

+ trans:
加上平移。

p_trans:
变换后的点。

3.3 点到线残差

角点用点到线残差。

假设:

p:当前帧角点
a、b:参考帧或地图中的两个边缘点

变换后:

p' = R * p + t

点到线距离:

d_edge = || (p' - a) × (p' - b) || / || a - b ||

代码:

Eigen::Matrix<T, 3, 1> p_trans = quat * p + trans;

Eigen::Matrix<T, 3, 1> a(
    T(point_a.x), T(point_a.y), T(point_a.z));

Eigen::Matrix<T, 3, 1> b(
    T(point_b.x), T(point_b.y), T(point_b.z));

Eigen::Matrix<T, 3, 1> area_vec =
    (p_trans - a).cross(p_trans - b);

Eigen::Matrix<T, 3, 1> line_vec = a - b;

residual[0] = area_vec.norm() / line_vec.norm();

公式解释:

(p' - a) × (p' - b):
叉乘,模长等于平行四边形面积。

||a - b||:
线段 ab 的长度。

面积 / 底边:
就是点到直线的高,也就是点到线距离。

所以这个残差越小,说明当前角点越贴近参考线。


3.4 点到面残差

平面点用点到面残差。

假设:

p:当前帧平面点
a、b、c:参考帧或地图中的三个平面点

平面法向量:

n = (a - b) × (a - c)

点到面距离:

d_plane = | n · (p' - a) | / ||n||

代码:

Eigen::Matrix<T, 3, 1> normal =
    (a - b).cross(a - c);

normal.normalize();

residual[0] = normal.dot(p_trans - a);

解释:

a - b 和 a - c:
平面内的两个方向向量。

cross:
两个平面内向量叉乘,得到垂直于平面的法向量。

normal.normalize:
把法向量单位化。

normal.dot(p_trans - a):
当前点到平面的有符号距离。

如果残差接近 0,说明当前平面点基本落在参考平面上。


4. 前端二:laserOdometry.cpp

4.1 这个文件干什么?

laserOdometry.cpp 是前端里程计模块。

它接收 scanRegistration.cpp 输出的角点和平面点,然后做:

当前帧特征点 vs 上一帧特征点

也就是 scan-to-scan 匹配

它的目标是快速估计:

当前帧相对于上一帧的位姿变化

输出一个高频、快速的里程计结果。


4.2 输入和保存的数据

输入:

当前帧 cornerPointsSharp
当前帧 surfPointsFlat
当前帧 cornerPointsLessSharp
当前帧 surfPointsLessFlat

内部会保存上一帧:

laserCloudCornerLast
laserCloudSurfLast

当前帧来了以后,就和上一帧做匹配。


4.3 当前点运动补偿

因为一帧点云内部点的采集时间不同,所以当前点要根据相对时间做补偿。

void TransformToStart(PointType const *pi, PointType *po)
{
    double s = 1.0;

    if (DISTORTION)
        s = (pi->intensity - int(pi->intensity)) / SCAN_PERIOD;

    Eigen::Quaterniond q_point_last =
        Eigen::Quaterniond::Identity().slerp(s, q_last_curr);

    Eigen::Vector3d t_point_last = s * t_last_curr;

    Eigen::Vector3d point(pi->x, pi->y, pi->z);

    Eigen::Vector3d point_trans =
        q_point_last * point + t_point_last;

    po->x = point_trans.x();
    po->y = point_trans.y();
    po->z = point_trans.z();
    po->intensity = pi->intensity;
}

解释:

s = (pi->intensity - int(pi->intensity)) / SCAN_PERIOD;

前面 scanRegistration.cpp 把点的相对时间放进了 intensity 小数部分。这里就是把相对时间取出来。

slerp(s, q_last_curr)

这是四元数球面插值。因为当前点可能是在一帧中间采集的,所以旋转量也要按时间比例插值。

t_point_last = s * t_last_curr;

平移也按时间比例缩放。

point_trans = q_point_last * point + t_point_last;

把点补偿到统一时刻附近,减少运动畸变。


4.4 角点匹配:当前角点找上一帧的线

for (int i = 0; i < cornerPointsSharpNum; ++i)
{
    PointType pointSel;
    TransformToStart(&cornerPointsSharp->points[i], &pointSel);

    kdtreeCornerLast->nearestKSearch(
        pointSel, 1, pointSearchInd, pointSearchSqDis);

    int closestPointInd = pointSearchInd[0];

    int minPointInd2 = findAnotherCornerPointInDifferentScanLine();

    if (minPointInd2 >= 0)
    {
        ceres::CostFunction *cost_function =
            LidarEdgeFactor::Create(
                cornerPointsSharp->points[i],
                laserCloudCornerLast->points[closestPointInd],
                laserCloudCornerLast->points[minPointInd2],
                s);

        problem.AddResidualBlock(
            cost_function, loss_function, para_q, para_t);
    }
}

逻辑是:

1. 当前角点先做运动补偿。
2. 在上一帧角点云中找最近点。
3. 再找另一个合适角点。
4. 两个参考角点组成一条线。
5. 当前角点建立点到线残差。

几何意义:

当前帧的边缘点,应该落在上一帧的某条边缘线附近。

4.5 平面点匹配:当前平面点找上一帧的面

for (int i = 0; i < surfPointsFlatNum; ++i)
{
    PointType pointSel;
    TransformToStart(&surfPointsFlat->points[i], &pointSel);

    kdtreeSurfLast->nearestKSearch(
        pointSel, 1, pointSearchInd, pointSearchSqDis);

    int closestPointInd = pointSearchInd[0];

    int minPointInd2 = findSecondSurfPoint();
    int minPointInd3 = findThirdSurfPoint();

    if (minPointInd2 >= 0 && minPointInd3 >= 0)
    {
        ceres::CostFunction *cost_function =
            LidarPlaneFactor::Create(
                surfPointsFlat->points[i],
                laserCloudSurfLast->points[closestPointInd],
                laserCloudSurfLast->points[minPointInd2],
                laserCloudSurfLast->points[minPointInd3],
                s);

        problem.AddResidualBlock(
            cost_function, loss_function, para_q, para_t);
    }
}

逻辑是:

1. 当前平面点先做运动补偿。
2. 在上一帧平面点云中找最近点。
3. 再找两个合适平面点。
4. 三个参考平面点构成一个平面。
5. 当前平面点建立点到面残差。

几何意义:

当前帧的平面点,应该落在上一帧的某个平面附近。

4.6 前端里程计优化目标

laserOdometry.cpp 优化的是当前帧相对于上一帧的位姿:

T* = argmin_T {
    Σ || e_edge(T) ||² + Σ || e_plane(T) ||²
}

解释:

T:
当前帧相对上一帧的位姿。

e_edge:
角点到线误差。

e_plane:
平面点到面误差。

Σ:
所有特征点误差求和。

Ceres 会优化:

para_q:旋转四元数
para_t:平移向量

目标是让所有角点尽量贴近线,所有平面点尽量贴近面。

这个结果速度快,适合实时输出,但它只参考上一帧,所以会累计误差。


5. 后端:laserMapping.cpp

5.1 这个文件干什么?

laserMapping.cpp 是后端建图优化模块。

它做的是:

当前帧特征点 vs 局部地图特征点

也就是 scan-to-map 匹配

它和 laserOdometry.cpp 的区别是:

laserOdometry.cpp:
当前帧 vs 上一帧,速度快。

laserMapping.cpp:
当前帧 vs 局部地图,更稳定。

局部地图包含多帧历史点云,所以约束更丰富,优化结果更稳定。


5.2 当前点变换到地图坐标系

当前帧点需要先根据初始位姿变换到地图坐标系附近。

void pointAssociateToMap(PointType const *pi, PointType *po)
{
    Eigen::Vector3d point(pi->x, pi->y, pi->z);

    Eigen::Vector3d point_w =
        q_w_curr * point + t_w_curr;

    po->x = point_w.x();
    po->y = point_w.y();
    po->z = point_w.z();
    po->intensity = pi->intensity;
}

公式:

p_map = R_w_curr * p_curr + t_w_curr

解释:

p_curr:
当前帧坐标系下的点。

R_w_curr:
当前帧到地图坐标系的旋转。

t_w_curr:
当前帧到地图坐标系的平移。

p_map:
变换到地图坐标系后的点。

5.3 局部地图维护

A-LOAM 不会每次都拿全局地图匹配,因为地图越来越大,计算量会越来越高。

所以它只取当前位置附近的一片局部地图

laserCloudCornerFromMap.clear();
laserCloudSurfFromMap.clear();

for (int i = nearbyCubeStart; i <= nearbyCubeEnd; ++i)
{
    *laserCloudCornerFromMap += *laserCloudCornerArray[i];
    *laserCloudSurfFromMap   += *laserCloudSurfArray[i];
}

kdtreeCornerFromMap->setInputCloud(laserCloudCornerFromMap);
kdtreeSurfFromMap->setInputCloud(laserCloudSurfFromMap);

这里:

laserCloudCornerFromMap:
局部地图角点集合。

laserCloudSurfFromMap:
局部地图平面点集合。

KD-Tree:
用于快速找最近邻点。

局部地图的目的:

减少计算量
提高实时性
只匹配当前位置附近的有效结构

5.4 后端角点匹配:PCA 找线

在后端,当前角点不是简单找两个点,而是在局部地图角点里找 5 个近邻点,然后判断这 5 个点是否形成线结构。

kdtreeCornerFromMap->nearestKSearch(
    pointSel, 5, pointSearchInd, pointSearchSqDis);

if (pointSearchSqDis[4] < 1.0)
{
    Eigen::Vector3d center = Eigen::Vector3d::Zero();

    for (int j = 0; j < 5; ++j)
    {
        PointType p = laserCloudCornerFromMap->points[pointSearchInd[j]];
        center += Eigen::Vector3d(p.x, p.y, p.z);
    }

    center /= 5.0;

    Eigen::Matrix3d covMat = Eigen::Matrix3d::Zero();

    for (int j = 0; j < 5; ++j)
    {
        PointType p = laserCloudCornerFromMap->points[pointSearchInd[j]];
        Eigen::Vector3d tmp(p.x - center.x(),
                            p.y - center.y(),
                            p.z - center.z());

        covMat += tmp * tmp.transpose();
    }

    Eigen::SelfAdjointEigenSolver<Eigen::Matrix3d> saes(covMat);

    Eigen::Vector3d eigenvalues = saes.eigenvalues();
    Eigen::Matrix3d eigenvectors = saes.eigenvectors();

    if (eigenvalues[2] > 3 * eigenvalues[1])
    {
        Eigen::Vector3d unit_direction = eigenvectors.col(2);

        Eigen::Vector3d point_on_line_a =
            center + 0.1 * unit_direction;

        Eigen::Vector3d point_on_line_b =
            center - 0.1 * unit_direction;

        addEdgeResidual(pointSel, point_on_line_a, point_on_line_b);
    }
}

这里重点是 PCA。

协方差矩阵表示这 5 个点在三维空间中的分布情况。

特征值可以理解成:

λ0、λ1、λ2:
点云沿三个主方向的分散程度。

如果:

λ2 >> λ1

说明这几个点主要沿一个方向分布,像一条线。

代码中:

if (eigenvalues[2] > 3 * eigenvalues[1])

表示最大方向明显强于第二大方向,可以认为是线特征。

然后:

unit_direction = eigenvectors.col(2);

取最大特征值对应的特征向量作为线方向。

再构造线上的两个点:

point_on_line_a = center + 0.1 * unit_direction;
point_on_line_b = center - 0.1 * unit_direction;

这两个点就代表局部地图中的一条边缘线。


5.5 后端平面点匹配:最小二乘拟合平面

当前平面点会在局部地图平面点中找 5 个近邻点,然后拟合平面。

kdtreeSurfFromMap->nearestKSearch(
    pointSel, 5, pointSearchInd, pointSearchSqDis);

if (pointSearchSqDis[4] < 1.0)
{
    Eigen::Matrix<double, 5, 3> A;
    Eigen::Matrix<double, 5, 1> b;

    for (int j = 0; j < 5; ++j)
    {
        PointType p = laserCloudSurfFromMap->points[pointSearchInd[j]];

        A(j, 0) = p.x;
        A(j, 1) = p.y;
        A(j, 2) = p.z;

        b(j, 0) = -1.0;
    }

    Eigen::Vector3d norm = A.colPivHouseholderQr().solve(b);

    double negative_OA_dot_norm = 1.0 / norm.norm();

    norm.normalize();

    bool planeValid = true;

    for (int j = 0; j < 5; ++j)
    {
        PointType p = laserCloudSurfFromMap->points[pointSearchInd[j]];

        double distance =
            fabs(norm.x() * p.x +
                 norm.y() * p.y +
                 norm.z() * p.z +
                 negative_OA_dot_norm);

        if (distance > 0.2)
        {
            planeValid = false;
            break;
        }
    }

    if (planeValid)
    {
        addPlaneResidual(pointSel, norm, negative_OA_dot_norm);
    }
}

平面方程是:

a*x + b*y + c*z + d = 0

代码把它变形成:

a*x + b*y + c*z = -1

于是 5 个点可以构成:

x1*a + y1*b + z1*c = -1
x2*a + y2*b + z2*c = -1
x3*a + y3*b + z3*c = -1
x4*a + y4*b + z4*c = -1
x5*a + y5*b + z5*c = -1

矩阵形式:

A * n = b

其中:

n = [a, b, c]^T

因为 5 个方程、3 个未知数,所以用最小二乘求解:

norm = A.colPivHouseholderQr().solve(b);

然后检查这 5 个点是否真的接近平面:

distance = fabs(a*x + b*y + c*z + d);

如果某些点离平面太远,说明它们不是稳定平面结构,这个约束就丢掉。


5.6 后端优化目标

后端优化的是当前帧在地图坐标系下的位姿:

T_map* = argmin_T {
    Σ || e_edge_map(T) ||² + Σ || e_plane_map(T) ||²
}

这里:

e_edge_map:
当前角点到局部地图线特征的误差。

e_plane_map:
当前平面点到局部地图平面特征的误差。

它比前端里程计更稳定,因为它参考的是局部地图,而不是单独上一帧。


5.7 当前帧加入地图并降采样

优化完成后,当前帧特征点会加入地图。

for (int i = 0; i < laserCloudCornerStackNum; ++i)
{
    pointAssociateToMap(&laserCloudCornerStack->points[i], &pointSel);
    laserCloudCornerArray[cubeInd]->push_back(pointSel);
}

for (int i = 0; i < laserCloudSurfStackNum; ++i)
{
    pointAssociateToMap(&laserCloudSurfStack->points[i], &pointSel);
    laserCloudSurfArray[cubeInd]->push_back(pointSel);
}

加入地图后,还要体素滤波:

downSizeFilterCorner.setInputCloud(laserCloudCornerArray[i]);
downSizeFilterCorner.filter(*laserCloudCornerArray[i]);

downSizeFilterSurf.setInputCloud(laserCloudSurfArray[i]);
downSizeFilterSurf.filter(*laserCloudSurfArray[i]);

体素滤波的作用:

减少地图点数量
防止地图无限增长
加快 KD-Tree 搜索
保持主要几何结构

6. 按“前端 / 优化 / 后端”重新总结代码顺序

你可以这样记 A-LOAM 文件顺序:

第一层:数据输入
kittiHelper.cpp
负责把数据集点云变成 ROS 点云话题。
真实雷达场景下,这个不是算法重点。

第二层:前端特征提取
scanRegistration.cpp
负责点云预处理、线束划分、相对时间计算、曲率计算、角点和平面点提取。

第三层:优化残差模型
lidarFactor.hpp
负责定义点到线、点到面的 Ceres 残差。
这个文件被前端里程计和后端建图共同使用。

第四层:前端里程计
laserOdometry.cpp
负责当前帧和上一帧匹配。
角点找线,平面点找面,优化帧间位姿。

第五层:后端建图优化
laserMapping.cpp
负责当前帧和局部地图匹配。
局部地图中 PCA 找线、最小二乘拟合平面,优化地图位姿,并更新地图。

7. 完整流程详细总结

A-LOAM 运行时,首先需要点云输入。如果是 KITTI 数据集,kittiHelper.cpp 会把 .bin 文件中的 x、y、z、intensity 读取出来,转成 ROS 点云话题。如果是真实雷达,则由雷达驱动直接发布原始点云。

原始点云进入 scanRegistration.cpp 后,系统先去掉无效点和距离太近的点。然后根据每个点的垂直角度计算 scanID,判断它属于第几根激光线。接着根据水平角度计算点在当前帧中的相对采集时间,并把 scanID 和相对时间编码到 intensity 字段中。

然后,scanRegistration.cpp 按照扫描线重新排列点云,并对每个点计算曲率。曲率大的点代表局部变化剧烈,通常是墙角、柱子边缘、箱子棱边,所以选为角点;曲率小的点代表局部平滑,通常是地面、墙面、桌面,所以选为平面点。为了让特征分布均匀,它会按线束、按区域分别选点,而不是整帧统一排序。

特征点提取完成后,进入 laserOdometry.cpp。这个文件做前端帧间里程计,也就是当前帧和上一帧匹配。当前角点会在上一帧角点中找两个点构成边缘线,建立点到线误差;当前平面点会在上一帧平面点中找三个点构成平面,建立点到面误差。这里实际使用的残差计算来自 lidarFactor.hpp

lidarFactor.hpp 是优化模型核心。它定义了两个最重要的几何误差。第一个是点到线误差:

d_edge = || (p' - a) × (p' - b) || / || a - b ||

第二个是点到面误差:

d_plane = | n · (p' - a) | / ||n||

其中:

p' = R * p + t

表示把当前帧点通过当前估计位姿变换到参考坐标系。优化器调整的就是 Rt,让所有点到线、点到面的误差尽量小。

laserOdometry.cpp 优化完成后,会得到一个快速里程计结果。这个结果频率高、实时性好,但它只参考上一帧,所以长期运行会有累计误差。

接着进入 laserMapping.cpp。这个文件做后端局部地图优化。它先把当前帧特征点根据前端里程计初值变换到地图坐标系附近,然后从已有地图中取出当前位置附近的局部地图。当前角点会在局部地图角点中找附近点,并通过 PCA 判断这些点是否形成线结构;当前平面点会在局部地图平面点中找附近点,并用最小二乘拟合平面。

如果线和平面都有效,laserMapping.cpp 会再次构建点到线、点到面的残差,并用 Ceres 优化当前帧在地图坐标系下的位姿。由于这次参考的是局部地图,而不是单独上一帧,所以结果更稳定。

最后,优化后的当前帧特征点会被加入地图。为了防止地图点云越来越大,系统会对地图中的角点和平面点做体素滤波。最终发布优化后的位姿、TF、轨迹和点云地图。

最终主线可以压缩成:

数据输入
  ↓
scanRegistration.cpp:提取角点和平面点
  ↓
lidarFactor.hpp:定义点到线、点到面误差
  ↓
laserOdometry.cpp:当前帧和上一帧匹配,得到快速里程计
  ↓
laserMapping.cpp:当前帧和局部地图匹配,得到稳定地图位姿
  ↓
更新地图,发布轨迹和点云地图

一句话记忆:

A-LOAM = 前端特征提取 + 优化残差建模 + 帧间里程计 + 后端局部地图优化。

版权声明: 辛苦码字不易,转载请注明原文出处和作者信息,谢谢理解

欢迎分享与交流,但拒绝任何形式的商业转载或洗稿。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐