ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

LEGO-LOAM源码解读:位姿解算、特征匹配与Ceres非线性优化

LEGO-LOAM源码解读:位姿解算、特征匹配与Ceres非线性优化 lego-loam 的 featureAssociation 源码注释写到第五篇终于轮到整个模块最核心的一段位姿解算。前面几篇我们依次啃过了特征提取、遮挡点剔除、特征匹配那些内容解决的是当前帧哪些点是角点、哪些点是平面点、它们和上一帧的哪些点是同一类特征的问题——用个不太恰当的比喻相当于先完成了找对象。但从找到对应关系到最终输出transformTobeMapped里的 6 个位姿参数中间还隔着一大段数学线性化、最小二乘、非线性优化。这篇就把这段链路完整拆开一行一行讲清楚。这篇文章适合谁读适合那些已经能跑通 lego-loam、也大致知道 featureAssociation 在干嘛但一旦被问到calculateTransformationSurf 里那个矩阵到底怎么来的就卡壳的读者。我会尽量保持和前几篇一样的风格先讲设计意图再讲代码最后给实测经验。如果你正在移植或者魔改 LOAM 系前端这篇的参考价值会更大。1. 先看主循环updateTransformation 的 25 次迭代如何组织匹配与解算很多第一次读源码的人会直接钻进 findCorrespondingSurf 或者 LMOptimization 这种有名气的函数里结果越看越晕。我的建议是反过来先把updateTransformation这个主循环吃透因为它把整个位姿解算流程串成了一条线你知道了这条线的顺序后面的每个函数就只是这条线上的一个站点。1.1 一次迭代里的完整调用顺序void FeatureAssociation::updateTransformation() { if (laserCloudCornerLastNum 10 || laserCloudSurfLastNum 100) { return; } for (int iterCount 0; iterCount 25; iterCount) { laserCloudOri-clear(); laserCloudSel-clear(); coeffSel-clear(); findCorrespondingSurf(iterCount); calculateTransformationSurf(iterCount); findCorrespondingCorner(iterCount); calculateTransformationCorner(iterCount); LMOptimization(iterCount); } transformTobeMapped[0] transformCur[0]; transformTobeMapped[1] transformCur[1]; transformTobeMapped[2] transformCur[2]; transformTobeMapped[3] transformCur[3]; transformTobeMapped[4] transformCur[4]; transformTobeMapped[5] transformCur[5]; }函数一进来有两个数量判断上一帧的角点少于 10 个或者平面点少于 100 个直接退出。这个阈值卡的是上一帧参考特征是否足够丰富不是当前帧。如果上一帧本身就没什么特征这一帧再怎么匹配都是无源之水硬算只会算出噪声。主循环固定跑 25 次每次循环内部严格按清理缓冲区 → 匹配平面点 → 解平移 → 匹配角点 → 解旋转 → 联合优化的顺序执行。注意这里有一个容易忽略的细节findCorrespondingSurf和findCorrespondingCorner中间夹了一个calculateTransformationSurf也就是说平面点匹配完立刻就用上了角点匹配是后续的事。这意味着在同一轮迭代里后一步使用的transformCur已经被前一步更新过。这是一种比较朴素的 Gauss-Newton 式思路一边更新一边往下走而不是等所有约束收集齐了再统一解。1.2 laserCloudOri、laserCloudSel、coeffSel 三个缓冲区的分工这三个pcl::PointCloudPointType::Ptr容器是整个匹配与解算之间的数据通道理解它们的分工后面看任何函数都不会迷路。laserCloudOri当前帧的特征点。准确说是当前帧里被选出来参与解算的特征点平面匹配时装的是 flat 点角点匹配时装的是 sharp 点。laserCloudSel与laserCloudOri中每个点对应的目标点。对平面点来说是当前点变换到上一帧坐标系后在 KD-tree 里找到的那个最近邻或者参考点对角点来说同理。不过实际解算时这个容器用得不多真正起作用的是第三个。coeffSel每个匹配点对应的约束系数。这是核心。平面点的 coeff 里存的是局部拟合平面的方程参数(pa, pb, pc, pd)也就是pa*x pb*y pc*z pd 0角点的 coeff 里存的是直线上两个点的坐标或者等价的直线描述。calculateTransformation*和LMOptimization解算时真正消费的是laserCloudOri和coeffSellaserCloudSel更像是一个中间凭证。每次迭代开头为什么要清空这三个容器因为它们是复用缓冲区。LOAM 系代码非常喜欢这种预分配、反复用、避免频繁 new的做法cornerPointsSharp、surfPointsFlat这些点云对象在构造函数里就把内存准备好了迭代里只是往里填内容再清空。1.3 为什么固定 25 次而不是收敛即停这是我自己读代码时第一个冒出来的问题。都迭代 25 次了为什么不检查一下位姿增量足够小就提前 break我的理解是这背后是实时性的取舍。激光雷达 10Hz每帧之间的时间预算只有 100ms而且这 100ms 不只是给 featureAssociation 的后面 mapOptimization 还要吃一部分。固定迭代次数意味着每帧的计算耗时基本稳定不会因为某帧特征特别多或者匹配特别差而突然飙到几百毫秒。如果用收敛判断退化场景下残差可能永远降不下去循环直接卡死这在前端是不可接受的。另外 25 次这个数字本身也有讲究。最初 LOAM 在激光里程计里用的就是这个量级25 次内通常已经完成了从初值偏差较大到收敛的主要过程。后面你会发现 LMOptimization 内部每次只迭代 4 次 Ceres 求解所以外层 25 次 x 内层 4 次一共 100 次小步逼近叠加起来精度和实时性比较平衡。想验证的话你可以把 25 改成 10 跑一下 bag会发现大多数场景精度掉得不多但快速转弯的时候明显能感觉到位姿跟随变慢。2. calculateTransformationSurf把点面约束组装成最小二乘calculateTransformationSurf这个名字有点误导人它其实不是计算变换而是把匹配好的点面约束组装成一个线性最小二乘问题并求解平移增量。理解了这一点代码就好读了。2.1 coeff 里的平面参数是怎么来的前面 findCorrespondingSurf 做的工作可以概括为四步把当前帧的每个 flat 点用当前transformCur变换到上一帧坐标系下。在上帧平面点云laserCloudSurfLast的 KD-tree 里找 5 个最近邻。对这 5 个点做协方差矩阵的特征值分解本质是拟合一个局部平面。如果这 5 个点确实共面最小特征值明显小于另外两个就用最小特征值对应的特征向量作为平面法向量(pa, pb, pc)再结合 5 个点的中心坐标算出pd -(pa*cx pb*cy pc*cz)连同当前点一起存入缓冲区。// 以 A-LOAM / LEGO-LOAM 同源的实现为例核心逻辑如下 if (pointSearchSqDis[4] 1.0) { // 求 5 个近邻的均值 float cx 0, cy 0, cz 0; for (int j 0; j 5; j) { cx laserCloudSurfLast-points[pointSearchInd[j]].x; cy laserCloudSurfLast-points[pointSearchInd[j]].y; cz laserCloudSurfLast-points[pointSearchInd[j]].z; } cx / 5; cy / 5; cz / 5; // 计算协方差矩阵元素 float a11 0, a12 0, a13 0; float a22 0, a23 0, a33 0; for (int j 0; j 5; j) { float ax laserCloudSurfLast-points[pointSearchInd[j]].x - cx; float ay laserCloudSurfLast-points[pointSearchInd[j]].y - cy; float az laserCloudSurfLast-points[pointSearchInd[j]].z - cz; a11 ax * ax; a12 ax * ay; a13 ax * az; a22 ay * ay; a23 ay * az; a33 az * az; } a11 / 5; a12 / 5; a13 / 5; a22 / 5; a23 / 5; a33 / 5; // 构造协方差矩阵特征分解 // matA1 [[a11, a12, a13], [a12, a22, a23], [a13, a23, a33]] // 对 matA1 求特征值和特征向量 matV1 // 最小特征值对应的特征向量即法向量 float pa matV1.atfloat(2, 0); float pb matV1.atfloat(2, 1); float pc matV1.atfloat(2, 2); float pd -(pa * cx pb * cy pc * cz); coeff.x pa; coeff.y pb; coeff.z pc; coeff.intensity pd; laserCloudOri-push_back(surfPointsFlat-points[i]); laserCloudSel-push_back(pointSel); coeffSel-push_back(coeff); }注意pointSearchSqDis[4] 1.0这个门槛——第 5 近邻的距离平方要小于 1 平方米也就是 5 个近邻大体上挤在半径 1 米以内。这是为了确保拟合出的平面是局部的如果第 5 个近邻已经飞到很远拟合出的平面可能跨越了不同结构的表面系数就没意义了。2.2 A x b 的组装逻辑与一个微型算例拿到平面参数后calculateTransformationSurf做的是这样一件事当前帧点 p 经过位姿 (R, t) 变换后应该落在上一帧的平面上即n · (R * p t) d 0其中 n (pa, pb, pc)d pd。但在当前迭代里R 和 t 都有一个估计值等式不一定成立会剩一个残差。把平移看成未知量、旋转固定为当前估计值就得到关于平移增量 Δt 的线性方程n · Δt -(n · (R * p) d n · t_current)把每个匹配点按这个形式写一行所有行拼成一个超定方程组最后用正规方程求解最小二乘。代码里matA的每一行其实是法向量经过当前旋转矩阵变换后的分量matB是残差项matAtA.colPivHouseholderQr().solve(matAtB)就是解这个正规方程。举一个极端简化的算例帮助理解。假设当前旋转是单位阵平面是 z 1即 n (0, 0, 1)、d -1。当前帧有一个点变换后落在 z 2 的位置那么代入 n · (R*p t) d 2 (-1) 1残差为 1。为了让残差归零需要的平移增量满足 n · Δt -1也就是 Δt_z -1把 z 从 2 拉回 1。就是这么直白。实际代码里因为 R 不是单位阵法向量要先旋转到和点同一个坐标系公式看起来长了一截但本质没变。2.3 为什么平面约束只更新平移分量LOAM 系列一个很聪明的设计是让不同特征干不同的活平面特征主要约束平移角点边缘特征主要约束旋转。原因是几何上的——一个平面绕着它的法向量旋转平面还是那个平面所以平面约束对绕法向的旋转不敏感反过来一条直线沿着自身方向平移直线还是那条直线所以线约束对沿线方向的平移不敏感。把两类特征分开解等于绕开了这些退化方向让每个方程组都相对良性。所以你会看到calculateTransformationSurf解出来的matX只加到transformCur[3]、transformCur[4]、transformCur[5]上也就是 x、y、z 平移而calculateTransformationCorner解出来的量加到transformCur[0]、transformCur[1]、transformCur[2]旋转角上。这个分工不是强迫的但沿用它能少踩很多病态矩阵的坑。3. calculateTransformationCorner线特征如何约束旋转角点的处理思路和平面点镜像对称但细节上有几个值得单独拎出来讲的地方。3.1 近邻点主成分分析与直线判定阈值findCorrespondingCorner 同样先做近邻搜索但找的是laserCloudCornerLast里的角点近邻数取 5 个。之后同样计算协方差、做特征分解但判定逻辑反过来了如果最大特征值远大于次大特征值说明这 5 个点大致分布在一条直线上最大特征值对应的特征向量就是直线方向。if (pointSearchSqDis[4] 1.0) { // 计算协方差与特征分解与平面点类似略 // ... if (matD1.atfloat(0, 0) 3 * matD1.atfloat(0, 1)) { // 最大特征值明显占优判定为直线 // 直线方向取最大特征值对应的特征向量 float vx matV1.atfloat(0, 0); float vy matV1.atfloat(0, 1); float vz matV1.atfloat(0, 2); // 用中心点加减方向向量构造直线上的两个点 float x1 cx 0.1 * vx; float y1 cy 0.1 * vy; float z1 cz 0.1 * vz; float x2 cx - 0.1 * vx; float y2 cy - 0.1 * vy; float z2 cz - 0.1 * vz; coeff.x x1; coeff.y y1; coeff.z z1; coeff.intensity x2; // 实际版本可能存法不同按你的代码为准 // 直线参数存入 coeffSel } }3 * matD1.atfloat(0, 1)这个阈值很有意思。它要求最大特征值超过次大特征值的 3 倍才会把近邻点簇判为直线。3 倍不是拍脑袋定的在室内墙角、门框、杆子这类典型线特征上这个比例通常能达到 5 到 10 倍以上如果只有 2 倍左右说明点簇更接近一个扁椭圆而不是一条清晰的线硬当直线用会引入较大误差。LEGO-LOAM 的 segmentation 阶段已经在做地面分离所以角点里很大一部分来自立式结构树干、墙角、栏杆这些结构在多数场景下特征值比值都足够高。3.2 点到直线距离对旋转的雅可比得到直线上的两个点 a、b 之后残差就是当前帧点 p 变换到上一帧坐标系后的点 p 到直线 ab 的距离r |(p - a) × (p - b)| / |a - b|这个公式是点到直线距离的标准形式叉积的模是以 (p-a) 和 (p-b) 为边的平行四边形面积除以底边 |a-b| 就是高。在 calculateTransformationCorner 里对这个残差关于旋转角做一阶线性化。核心推导是旋转的小扰动公式当旋转矩阵 R 绕某个轴旋转一个微小角度 δθ 时R(δθ) * p ≈ R * p (R * p) × δθ叉积项来自旋转矩阵的局部参数化。整理后每个匹配点都能写成关于 δroll、δpitch、δyaw 的线性方程最终同样组装成 3x3 正规方程解出来。这部分代码的公式是所有函数里最劝退的一长串a1 ...; a2 ...;看着像天书。我的阅读建议是不要试图逐个验证三角项先抓住两个关键点一是每个系数本质上是残差对某个旋转角的偏导数二是这些偏导数里藏着叉积结构你可以用数值梯度比如ceres::NumericDiffCostFunction去对比验证一旦对上了就说明你理解到位了。3.3 退化场景当直线特征集中在少数方向时实际跑数据最常见的退化是车在一条笔直的长走廊里墙面和地面给了大量平面约束平移解得很稳但沿线方向走廊朝向的直线特征很少旋转角尤其偏航角约束不足。表现就是位姿在走廊方向来回漂转到后面地图对不齐。这种场景下matAtA会接近奇异解出来的旋转增量噪声很大。LOAM 系前端没有显式的退化检测它靠的是后面 mapOptimization 的帧图匹配来兜底。所以如果你在 featureAssociation 里观察到旋转解跳变不必急着在这层加鲁棒性先看 mapOptimization 能不能拉回来这是系统的设计意图。4. LMOptimizationCeres 里的残差、参数块与鲁棒核如果说前面两个 calculateTransformation 是快速线性近似那 LMOptimization 就是在这个基础上做一次更精细的非线性优化。它用 Ceres 库把当前所有有效约束重新表达一遍联合优化旋转和平移。4.1 四元数参数块与平移参数块的设置void FeatureAssociation::LMOptimization(int iterCount) { int laserCloudSelNum laserCloudOri-points.size(); if (laserCloudSelNum 50) { return; } ceres::LossFunction *loss_function new ceres::HuberLoss(0.1); ceres::LocalParameterization *q_parameterization new ceres::EigenQuaternionParameterization(); ceres::Problem::Options problem_options; ceres::Problem problem(problem_options); problem.AddParameterBlock(parameters, 4, q_parameterization); problem.AddParameterBlock(parameters 4, 3); for (int i 0; i laserCloudSelNum; i) { pointOri laserCloudOri-points[i]; coeff coeffSel-points[i]; problem.AddResidualBlock( new LidarEdgeFactor(pointOri.x, pointOri.y, pointOri.z, coeff.x, coeff.y, coeff.z, coeff.intensity), loss_function, parameters, parameters 4); } ceres::Solver::Options options; options.linear_solver_type ceres::DENSE_QR; options.max_num_iterations 4; options.minimizer_progress_to_stdout false; ceres::Solver::Summary summary; ceres::Solve(options, problem, summary); }parameters是一个 7 维数组前 4 维是四元数 (w, x, y, z)后 3 维是平移。前 4 维必须搭配EigenQuaternionParameterization否则优化过程中四元数会偏离单位流形产生无效旋转。这是 Ceres 使用中最常见的坑——不加 LocalParameterization残差明明在降位姿却发疯。laserCloudSelNum 50这个阈值也要留意。它是说如果参与优化的匹配点太少直接跳过这次优化避免在约束不足时强行求解把已经不错的位姿改坏。50 这个值和前面laserCloudCornerLastNum 10是两码事一个是可匹配的参考特征数量一个是实际匹配成功的点数量。4.2 两种残差因子的数学本质LidarEdgeFactor 和 LidarSurfFactor 在代码文件顶部定义分别对应点到直线和点到平面两种残差数学表达就是前面反复用的两个公式// 点到直线距离残差 template typename T bool operator()(const T *q, const T *t, T *residual) const { Eigen::MatrixT, 3, 1 cp{T(curr_point.x()), T(curr_point.y()), T(curr_point.z())}; Eigen::MatrixT, 3, 1 lpa{T(last_point_a.x()), T(last_point_a.y()), T(last_point_a.z())}; Eigen::MatrixT, 3, 1 lpb{T(last_point_b.x()), T(last_point_b.y()), T(last_point_b.z())}; Eigen::QuaternionT q_last_curr{q[3], q[0], q[1], q[2]}; Eigen::MatrixT, 3, 1 t_last_curr{T(t[0]), T(t[1]), T(t[2])}; // 当前帧点变换到上一帧坐标系 Eigen::MatrixT, 3, 1 lp q_last_curr * cp t_last_curr; // 叉积模 / 底边长度 Eigen::MatrixT, 3, 1 nu (lp - lpa).cross(lp - lpb); Eigen::MatrixT, 3, 1 de lpa - lpb; residual[0] nu.norm() / de.norm(); return true; }// 点到平面距离残差 template typename T bool operator()(const T *q, const T *t, T *residual) const { Eigen::MatrixT, 3, 1 cp{T(curr_point.x()), T(curr_point.y()), T(curr_point.z())}; Eigen::MatrixT, 3, 1 un{T(unit_normal.x()), T(unit_normal.y()), T(unit_normal.z())}; Eigen::QuaternionT q_last_curr{q[3], q[0], q[1], q[2]}; Eigen::MatrixT, 3, 1 t_last_curr{T(t[0]), T(t[1]), T(t[2])}; Eigen::MatrixT, 3, 1 lp q_last_curr * cp t_last_curr; // 平面方程 axbyczd0 的残差 residual[0] lp.dot(un) plane_offset; return true; }这里特别提醒一下不同版本的 LEGO-LOAM / A-LOAM 在 LMOptimization 里的写法不完全一样有的版本在这个循环里只加一种因子有的版本会按点的类型分别添加。你在自己的源码里看到的可能是简化版不要纠结于为什么和我读的教程不一样抓住残差是什么才是关键。上面的代码是数学上等价的通用形式。4.3 损失函数与求解器选项的工程考量HuberLoss(0.1) 是这层优化最重要的保护伞。激光匹配里总会出现一些错误对应——比如把墙角那个点错误匹配到了墙面另一侧或者树冠的角点在上一帧里被遮挡了。这些外点如果按平方损失参与优化一个离群残差就可能把整个位姿拽歪。HuberLoss 的作用是残差小于 0.1 时按平方损失处理大于 0.1 时切换为线性损失相当于给大残差封顶不让它们主导梯度方向。求解器选项里DENSE_QR表示用稠密 QR 分解求解线性子问题。特征点数量通常几百个参数只有 7 维用稠密求解器完全够快没必要上稀疏求解器。max_num_iterations 4则是有意的限制——外层已经迭代 25 次了内层不需要完全收敛每次往前走一小步就行这能显著减少每帧耗时。我试过把内层迭代改成 10精度提升几乎看不出来但 CPU 占用肉眼可见地涨了。5. IMU 初值与系统状态updateIMU、checkSystemInitialization、resetParameters位姿解算不是凭空开始的一个靠谱的初值能让 25 次迭代事半功倍。这个初值有两个来源上一帧的transformCur和 IMU 的测量。LOAM 系的经典做法是把两者结合用 IMU 的姿态角覆盖旋转初值用 IMU 积分出的位移覆盖平移初值。5.1 imuHandler 里做了什么姿态解算、去重力、位移积分IMU 回调函数虽然不直接参与匹配但它是 updateIMU 的数据源。imuHandler 主要干三件事把 IMU 消息里的四元数转成欧拉角roll、pitch、yaw存进环形缓冲区。从线性加速度里去掉重力分量。这一步要求知道 IMU 当前姿态因为重力加速度在机体坐标系下的投影是姿态相关的不能简单地减一个常向量。在去除重力后的加速度上做两次积分一次积分得到速度两次积分得到位移。这些量被存进imuVeloX/Y/Z和imuShiftX/Y/Z的缓冲区。这三件事的输出是后面 updateIMU 做插值的原料。缓冲区大小imuQueLength通常是 100按 200Hz 的 IMU 频率算能覆盖 0.5 秒的数据大于一帧激光的周期足够完成插值。5.2 updateIMU 的时间戳插值与 transformCur 初始化一帧激光扫描是有持续时间的而 IMU 是高频离散采样。updateIMU 要做的是找到与当前帧激光扫描起始时刻和扫描结束时刻最接近的 IMU 数据然后做线性插值得到扫描期间的姿态变化和位移变化。void FeatureAssociation::updateIMU() { if (imuPointerLast -1) { return; } // 在环形缓冲区里找到第一个时间戳晚于当前扫描起始时刻的数据 imuPointerFront imuPointerLastIteration; for (; imuPointerFront ! imuPointerLast; imuPointerFront (imuPointerFront 1) % imuQueLength) { if (timeScanCur imuShiftFromStartX imuTime[imuPointerFront]) { break; } } imuPointerLastIteration imuPointerFront; // ... 对 IMU 姿态、速度、位移做线性插值 ... // 用插值结果初始化当前帧位姿估计 transformCur[0] imuPitchStart; transformCur[1] imuYawStart; transformCur[2] imuRollStart; transformCur[3] imuShiftFromStartX; transformCur[4] imuShiftFromStartY; transformCur[5] imuShiftFromStartZ; }上面代码最后那段赋值是关键中的关键transformCur的旋转三项被 IMU 的姿态角覆盖平移三项被 IMU 积分出的位移覆盖。这意味着每一轮帧间匹配起步时旋转初值已经八九不离十了剩下的工作主要是精修。这也是为什么 LOAM 系在 IMU 质量好的时候表现特别稳——初值好了ICP 的收敛域问题就基本不存在了。updateIMU里那个循环的退出条件写的其实是timeScanCur imuShiftFromStartX imuTime[...]这里imuShiftFromStartX是此前累积的位移用它来近似补偿时间差代码写得有点绕但意图是找足够靠近扫描起始时刻的那帧 IMU。你在自己代码里如果看到类似的怪条件第一反应不应该是抄而是理解它想表达的时间对齐语义。5.3 状态机的启动等待与参数复位checkSystemInitialization是系统启动的守门员。它的逻辑很简单把当前帧的 less sharp 角点和 less flat 平面点存入laserCloudCornerLast和laserCloudSurfLast重建 KD-tree清零所有变换然后置systemInited true。void FeatureAssociation::checkSystemInitialization() { laserCloudCornerLast-clear(); laserCloudSurfLast-clear(); // 用当前帧的 less sharp 点构建参考角点云 int cornerPointsLessSharpNum cornerPointsLessSharp-points.size(); for (int i 0; i cornerPointsLessSharpNum; i) { laserCloudCornerLast-push_back(cornerPointsLessSharp-points[i]); } // 平面点同理 ... kdtreeCornerLast-setInputCloud(laserCloudCornerLast); kdtreeSurfLast-setInputCloud(laserCloudSurfLast); laserCloudCornerLastNum laserCloudCornerLast-points.size(); laserCloudSurfLastNum laserCloudSurfLast-points.size(); for (int i 0; i 6; i) { transformCur[i] 0; transformSum[i] 0; } systemInited true; }注意它用的是 less sharp / less flat 而不是 sharp / flat。这是因为匹配时当前帧的 sharp 点要跟上一帧的 less sharp 点比当前帧的 flat 点要跟上一帧的 less flat 点比——sharp 点太稀疏如果上一帧也只用 sharp 点很多当前位置找不到对应近邻用降级一档的特征做参考密度更高最近邻搜索命中率也更高。resetParameters就更简单了void FeatureAssociation::resetParameters() { laserCloudOri-clear(); laserCloudSel-clear(); coeffSel-clear(); }很多注释把这几个函数一笔带过但状态机值得留心systemInited只在第一次进入激光回调时置位之后永远为 true系统不存在中途重新初始化的路径。这意味着如果你在室外跑着跑着进了室内、或者反过来前端不会自动切换特征策略它只会硬着头皮往下算。这也是为什么 LOAM 系在环境特性剧烈变化时容易飘。6. 实测中的坑退化、初值敏感与可以动手改的方向代码讲完了最后聊点实际跑数据才会碰上的东西。这些经验不算高深但能帮你节省大量排查时间。6.1 长走廊与平地场景的表现和判断方法我拿自己采集的一段园区数据做过实验前半段是开阔停车场后半段进了一条两侧是玻璃幕墙的走廊。停车场那段laserCloudSelNum经常只有三四十LMOptimization 反复跳过低点位姿基本靠 IMU 初值撑着进了走廊之后特征数量暴涨但 25 次迭代里有好几次解出的 yaw 增量明显偏大最终地图在走廊尽头出现了轻微的重影。这个现象说明两点一是空旷场景下前端退化二是退化不一定表现为发散更多时候是累积漂移。一个有用的观测技巧是把coeffSel发布出来在 rviz 里用颜色显示残差大小。如果某片区域的 coeff 点云颜色特别杂乱说明那里的匹配大概率是错的优先检查对应场景是不是有玻璃、反光金属或者重复纹理——这些表面会让平面拟合出来的法向量天天变。6.2 几个值得调整的参数与效果观察我调整过下面几个参数总结如下参数位置调大效果调小效果建议laserCloudSelNum 50LMOptimization 入口约束不足时更保守在特征少的场景更激进空旷环境调成 30室内保持 50外层迭代 25 次updateTransformation精度略升耗时增加快速运动时位姿滞后手持设备建议 15车载建议维持 25内层 Ceres 迭代 4 次Solver options精度提升不明显收敛不充分不建议动保持 4HuberLoss(0.1)LMOptimization对动态物体更鲁棒对噪声更敏感动态场景可调 0.3静态场景 0.1 够用需要提醒的是这些参数不是独立起作用的。比如你调小laserCloudSelNum阈值让更多帧进入 Ceres 优化但外层迭代还是 25 次整体的实时性压力会转移到 CPU 上。改之前先跑一次rosprof或者简单的getTime()打点确认 featureAssociation 的耗时占比再决定动哪里。6.3 后续扩展思路如果你打算基于 featureAssociation 做二次开发我最推荐的方向有三个。一是把 IMU 的零偏估计加进来原始代码把 IMU 姿态直接当真值覆盖初值IMU 零偏大的时候会引入系统性误差二是在 calculateTransformationSurf 之外把平面约束也纳入 Ceres 联合优化而不是只靠线性近似这样在特征数中等但质量好的场景能明显提升稳定性三是加一个简单的退化检测对matAtA做特征值分解最小特征值太小的时候就降低该方向的置信度这在长走廊场景能显著减少漂移。最后再分享一个小技巧。调试 featureAssociation 时别只盯着 rviz 里最终的地图把transformCur的前三项旋转角单独打出来看曲线。旋转角是对匹配质量最敏感的指标——当曲线出现毛刺或者突然跳变基本可以断定某次匹配引入了错误约束这时候再回头查是特征太稀疏、遮挡处理不充分还是动态物体干扰方向会明确很多。我自己在调第一版移植代码时就是靠这条曲线定位到 markOccludedPoints 的阈值设得太松导致大量遮挡点被当成有效平面特征问题解决后曲线立刻平滑了一个量级。
返回列表