KITTI+Lego-LOAM激光SLAM轨迹评估与evo避坑 简介面向激光雷达里程计与LOAM类SLAM初学者及复现实验研究者这份代码包在LeGO-LOAM基础上完成KITTI适配可直接运行并输出符合KITTI地面格式的轨迹还能用EVO套件对照真值评估精度降低不熟悉ROS与LOAM者的上手门槛。压缩包共35个文件、约26.84MB包含C源码与头文件、launch启动配置、msg消息定义、rviz可视化配置、CMake与package清单以及README、PDF论文预印本和png/jpg效果图便于理解算法结构、编译依赖与结果展示。已有6361人学习下载。读者可据此搭建可重复的KITTI实验流程查看轨迹输出、真值评估与可视化素材并参考依赖说明与目录组织排查编译运行问题适合作为LiDAR点云配准、里程计评测与LOAM二次开发的基线工程。1. 从一次跑崩的里程计说起这个仓库到底解决什么问题前段时间帮某实验室的一位同学看代码他把激光里程计前端改了两行想拿 KITTI 的 velodyne 数据验证效果。结果卡了整整两天bin 格式的点云喂不进 ROS 节点ground-truth 是每行 12 个数的 3×4 矩阵自己写脚本转出来轨迹和真值差了一个 90 度旋转好不容易跑通evo 又报时间戳对不上。kitti-lego-loam 这个仓库干的就是把这些脏活一次性打包把 KITTI 数据集整理成 Lego-LOAM 能吃的输入把运行参数固化下来再把输出轨迹和 ground-truth 对齐后交给 evo 算指标。它适合三类人刚接手激光 SLAM 模块、需要在公开数据上做回归验证的 C 开发要复现 LOAM 系列指标、又不想在数据格式上耗时间的学生以及手里有自己改的 LOAM 分支、想快速比一版 ATE 的团队。核心链路很短——数据转换、算法运行、轨迹评估但每一环都有几个能把人卡住的细节后面几章就按这条链路拆。2. 仓库结构与技术栈KITTI 数据、Lego-LOAM 和 evo 三者怎么串起来2.1 三个组件各管一段数据、算法、评估先把这个仓库的定位说清楚它不是一个新的 SLAM 算法而是一套围绕 Lego-LOAM 的评测脚手架。整条链路被切成三段每段职责很干净组件职责输入输出KITTI odometry 数据提供点云与真值原始 bin、poses、times、calib标准序列目录Lego-LOAM激光里程计与建图ROS 点云话题 静态 TF/aft_mapped_to_init位姿evo轨迹误差评估估计轨迹 参考轨迹ATE / RPE、轨迹图KITTI odometry 的 velodyne 数据是每帧一个.bin每个点 4 个 float32x、y、z、intensity一帧 64 线大约 12 万个点。ground-truth 在poses/XX.txt每行 12 个数是 3×4 的变换矩阵按行展开坐标系是相机 0 系。Lego-LOAM 是典型的 ROS 包C 实现特征提取靠曲率后端用 GTSAM 做位姿图优化。evo 是 Python 写的轨迹评估工具原生支持 TUM 和 KITTI 两种轨迹格式。这里最容易翻车的地方是坐标系。KITTI 真值在相机系x 向右、y 向下、z 向前Lego-LOAM 输出在雷达世界系z 向上。两边不做统一轨迹图画出来就是一个整体旋转ATE 直接飙到几十米。2.2 目录结构与文件职责一份组织得比较顺手的仓库大概长这样scripts放转换和评测脚本config放参数data和results分开存原始数据与输出方便写批量脚本时按序列号扫目录kitti-lego-loam/ ├── catkin_ws/ │ └── src/ │ └── lego_loam/ # 算法本体含 featureAssociatio、mapOptimization 等节点 ├── scripts/ │ ├── bin2pcd.py # 单帧 bin 转 pcd调试用 │ ├── pose_to_tum.py # KITTI 3x4 真值转 TUM 轨迹 │ ├── odom_to_tum.py # 订阅 ROS 话题写 TUM 轨迹 │ └── run_eval.sh # 一键跑完整个序列并调 evo ├── config/ │ ├── kitti.yaml # 算法参数scan_line、skip_frame、外参 │ └── seq_list.txt # 要评测的序列号列表一行一个 ├── data/ │ ├── sequences/00/velodyne/ # 原始 bin │ └── ground_truth/00.txt # 真值 └── results/ └── 00/ ├── est_traj.txt └── gt_traj.txtcatkin_ws是标准 ROS 工作空间结构lego_loam内部一般分成四个节点图像投影imageProjection、特征关联featureAssociation、建图优化mapOptimization、回环检测transformFusion里做。KITTI 只跑激光图像相关的部分可以跳过参数里把use_imu关掉、scan_line设成 64 就行。results按序列号分目录是个小设计但很关键。批量跑 10 个序列时如果所有输出都写同一个文件名最后你根本分不清哪个文件对应哪条轨迹只能重跑。2.3 编译依赖与环境准备Lego-LOAM 的依赖不算多但版本要求比较硬。Ubuntu 18.04 ROS Melodic、或者 20.04 Noetic 是常见组合PCL 用系统自带的 1.8 或 1.10GTSAM 建议 4.0.x# 基础依赖ROS 版本按自己系统替换 melodic/noetic sudo apt install -y ros-noetic-pcl-ros ros-noetic-tf \ ros-noetic-cv-bridge ros-noetic-image-transport \ libboost-all-dev libtbb-dev libeigen3-dev # GTSAM 建议源码编译 4.0.3apt 源里的版本经常缺头文件 git clone -b 4.0.3 https://github.com/borglab/gtsam.git cd gtsam mkdir build cd build cmake .. -DGTSAM_BUILD_WITH_MARCH_NATIVEOFF \ -DGTSAM_USE_SYSTEM_EIGENON make -j$(nproc) sudo make install # 编译工作空间 cd catkin_ws catkin_make -DCMAKE_BUILD_TYPERelease source devel/setup.bash-DGTSAM_BUILD_WITH_MARCH_NATIVEOFF是血泪经验开着-marchnative编出来的库换一台机器跑会直接崩在ILL非法指令症状是节点启动瞬间退出日志里看不出任何信息。-DCMAKE_BUILD_TYPERelease也别省默认的Debug模式下特征提取能慢三倍一帧点云等两秒调试体验极差。evo用 pip 装最省事pip3 install evo --upgrade。装完先跑evo_traj --help能出版本号说明 Python 环境通了。这里有个小坑如果系统里同时有 conda 和 apt 的 Pythonevo_ape这个命令可能装到了 conda 环境里而 ROS 用的是系统 Python命令行找不到用which evo_ape确认一下路径。3. 数据准备链路从 velodyne bin 到可喂给 LOAM 的点云与位姿3.1 KITTI odometry 原始数据长什么样先把输入摸清楚不然后面全是猜。KITTI odometry 的一个序列目录粗看有四个东西velodyne/000000.bin点云帧文件名是 6 位递增编号每帧 16 字节 / 点。poses/00.txt真值位姿行数比点云帧数少几条第一帧通常作为原点没有对应真值每行 12 个浮点数按r11 r12 r13 tx r21 r22 r23 ty r31 r32 r33 tz顺序排。times.txt每帧时间戳单位秒从 0 开始相邻两帧大约 0.1 秒。calib.txt相机内外参里面的Tr:是雷达到相机 0 系的 3×4 变换矩阵转真值坐标时会用到。真值帧数与点云帧数对不齐这点很坑。比如 4541 帧点云真值可能是 4540 行第 i 帧点云对应第 i-1 行真值还是第 i 行不同工具假设不一样。常见做法是以点云帧号为基准真值文件从头开始逐行对应少的那一帧直接丢掉别在中间插值。3.2 bin 转 pcd一次典型的点云格式转换调试阶段单帧转 pcd 用 PCL 看一眼最直观pcl_viewer打开确认点云没塌成平面再往下走。// bin2pcd.cpp读 KITTI 单帧 bin写成 PCD 供 pcl_viewer 查看 #include pcl/io/pcd_io.h #include pcl/point_types.h #include fstream #include vector #include iostream int main(int argc, char** argv) { if (argc 3) { std::cerr usage: bin2pcd in.bin out.pcd\n; return -1; } std::ifstream fin(argv[1], std::ios::binary); fin.seekg(0, std::ios::end); size_t bytes fin.tellg(); fin.seekg(0, std::ios::beg); // 每个点固定 4 个 float32x y z intensity if (bytes % 16 ! 0) { std::cerr 文件大小不是 16 的整数倍格式可能不对\n; return -1; } size_t n bytes / 16; std::vectorfloat raw(n * 4); fin.read(reinterpret_castchar*(raw.data()), bytes); pcl::PointCloudpcl::PointXYZI::Ptr cloud(new pcl::PointCloudpcl::PointXYZI()); cloud-resize(n); for (size_t i 0; i n; i) { // PCL 的 intensity 字段是 float直接搬别转 uint8会丢精度 (*cloud)[i].x raw[i * 4 0]; (*cloud)[i].y raw[i * 4 1]; (*cloud)[i].z raw[i * 4 2]; (*cloud)[i].intensity raw[i * 4 3]; } cloud-width n; cloud-height 1; cloud-is_dense false; pcl::io::savePCDFileBinary(argv[2], *cloud); std::cout points: n \n; return 0; }这段代码就三件事按 16 字节校验文件大小、把 float 数组搬到PointXYZI、二进制存盘。is_dense false不能省KITTI 点云里存在无效点设成true后某些 PCL 算法会跳过 NaN 检查直接算出错误曲率。intensity直接用原值KITTI 里这个字段代表反射率虽然 LOAM 主要靠几何曲率提特征但强度参与地面分割时会用到。如果在 ROS 里跑就不用落盘成 pcd 了直接发布sensor_msgs/PointCloud2更快。3.3 ground-truth 位姿转 TUM 格式与坐标系对齐真值转 TUM 是整条链路里最容易出错的一步。TUM 格式每行是timestamp tx ty tz qx qy qz qw四元数顺序是 x y z w跟 Eigen 的w x y z正好反着很多人第一版就栽在这。# pose_to_tum.pyKITTI 3x4 位姿 - TUM 轨迹同时做相机系到雷达系的旋转 import numpy as np from scipy.spatial.transform import Rotation as R # KITTI 真值在相机 0 系x 右、y 下、z 前Lego-LOAM 输出在雷达系x 前、y 左、z 上 # 下面这个 4x4 把相机系的点变换到雷达系是常见约定具体要跟自己算法输出的轴对齐 T_cam_to_lidar np.array([ [0, -1, 0, 0], [0, 0, -1, 0], [1, 0, 0, 0], [0, 0, 0, 1]], dtypefloat) def load_times(path): return [float(x) for x in open(path).read().split()] def load_poses(path): out [] for line in open(path): v [float(x) for x in line.split()] if len(v) ! 12: # 偶尔有注释行或空行直接跳过 continue T np.eye(4) T[:3, :] np.array(v).reshape(3, 4) out.append(T) return out def write_tum(poses, times, out_path): with open(out_path, w) as f: for T, t in zip(poses, times): T T_cam_to_lidar T # 先转到雷达系再去取平移和旋转 trans T[:3, 3].tolist() quat R.from_matrix(T[:3, :3]).as_quat().tolist() # scipy 返回 x y z w row [t] trans quat f.write( .join(f{x:.9f} for x in row) \n) if __name__ __main__: import sys seq sys.argv[1] # 例如 00 root sys.argv[2] # 数据根目录 poses load_poses(f{root}/poses/{seq}.txt) times load_times(f{root}/sequences/{seq}/times.txt) n min(len(poses), len(times)) write_tum(poses[:n], times[:n], sys.argv[3])三个参数分别是序列号、数据根目录、输出路径。核心逻辑都在write_tum里先左乘T_cam_to_lidar做坐标系旋转再把 3×3 旋转矩阵用 scipy 转成四元数。R.from_matrix().as_quat()返回的顺序就是x y z w跟 TUM 一致不用再手动换位这点比用 Eigen 省事。min(len(poses), len(times))这一步是防错有些序列的 poses 行数会被末尾空行影响直接 zip 会静默截断先取最小值至少保证不越界。真值时间戳直接用 KITTI 的times.txt就行它本身就是从 0 开始递增的相对时间跟点云顺序一一对应。3.4 生成序列描述文件与目录约定批量跑之前把要跑哪些序列和每个序列输出到哪固定下来# config/seq_list.txt 内容示例 00 02 05 07 # run_all.sh按列表逐个序列跑输出统一落到 results/seq/ while read seq; do [ -z $seq ] continue mkdir -p results/${seq} # run_one.sh 负责起算法、录轨迹、转真值这里只做调度 bash scripts/run_one.sh $seq data results/${seq} done config/seq_list.txt[ -z $seq ] continue是为了跳过文件末尾的空行看着不起眼但如果 seq_list 最后多了个换行脚本会给空字符串建一个名为results/的目录批量跑完一清理才发现结果全混在一起了。目录约定上每帧点云的命名保持 6 位补零000000.bin真值文件名与序列号一致结果目录用序列号做子目录。这套约定后面写批量评测、做指标汇总表全靠它临时改一下路径下游脚本就全得跟着改。4. 运行与评估launch 参数、轨迹对齐和 evo 出图4.1 launch 文件里的关键参数Lego-LOAM 的参数主要分三块传感器参数、特征提取参数、后端优化参数。跑 KITTI 时真正需要动的是前两块后端用默认值就够。!-- launch/run_kitti.launch 关键片段 -- launch param name/lego_loam/scan_line typeint value64 / param name/lego_loam/mapping_skip_frame typeint value1 / param name/lego_loam/use_imu_as_input typebool valuefalse / param name/lego_loam/use_odom_as_input typebool valuefalse / param name/lego_loam/save_pcd typebool valuefalse / !-- 外参KITTI 是纯激光雷达到 base_link 按单位阵即可 -- node pkgtf typestatic_transform_publisher namebase_to_lidar args0 0 0 0 0 0 /base_link /velodyne 100 / node pkglego_loam typeimageProjection nameimageProjection outputscreen / node pkglego_loam typefeatureAssociation namefeatureAssociation outputscreen / node pkglego_loam typemapOptimization namemapOptimization outputscreen / /launchscan_line必须跟实际数据一致KITTI 是 64 线设成 32 会让地面分割和特征提取的阈值全偏。mapping_skip_frame控制建图节点抽帧频率设 1 是每帧都参与优化设 2 是隔一帧跑长序列时可以提高速度但轨迹密度会下降做 ATE 时注意两边的采样率要对上。use_imu_as_input一定要关掉KITTI 没有 IMU 数据开着这个参数会让系统等一个永远不来的话题节点一直卡在初始化。save_pcd建图模式才开评估里程计时关掉否则跑完一个序列能写出几百兆的点云地图白占磁盘。4.2 跑一遍并保存轨迹数据进算法有两条路。一条是用kitti2bag把序列转成 rosbag再rosbag play喂给节点好处是能控制播放速度、方便跟其他 ROS 工具联动# kitti2bag 参数序列号、数据根目录、点云话题名 rosrun kitti2bag kitti2bag -t velodyne -r /data/kitti/sequences/00 --topic /velodyne_points # 播放时加 --clock节点里用了 ros::Time 的地方才不会被系统时间干扰 rosbag play kitti_00.bag --clock -r 0.5-r 0.5是半速播放在一台普通开发机上Lego-LOAM 处理 64 线点云的实时性大概就是 2 倍速以内用 1.0 播会让点云在队列里堆积最后轨迹里出现跳跃。宁可慢一点也别让消息积压积压后时间戳乱序后面 evo 直接报错。另一条路是自己写个 Python 节点把 bin 逐帧读成PointCloud2发出去跳过 rosbag 这一层适合做离线批处理。轨迹保存靠订阅/aft_mapped_to_initnav_msgs/Odometry自己写个节点落盘#!/usr/bin/env python3 # odom_to_tum.py把里程计话题按 TUM 格式写到文件 import rospy, sys from nav_msgs.msg import Odometry fp open(sys.argv[1], w) def cb(msg): t msg.header.stamp.to_sec() # 用消息头时间戳别用 rospy.Time.now() p msg.pose.pose.position q msg.pose.pose.orientation fp.write(f{t:.9f} {p.x:.9f} {p.y:.9f} {p.z:.9f} f{q.x:.9f} {q.y:.9f} {q.z:.9f} {q.w:.9f}\n) fp.flush() # 边跑边刷节点被 kill 也不丢数据 rospy.init_node(odom_to_tum) rospy.Subscriber(/aft_mapped_to_init, Odometry, cb, queue_size200) rospy.on_shutdown(lambda: fp.close()) rospy.spin()关键点是msg.header.stamp别用rospy.Time.now()。消息头时间戳是数据产生时刻用系统时间会跟真值的时间轴错开几百毫秒evo 那边一看时间差对不上就直接报错。queue_size200给大一点64 线点云的发布频率不稳定队列小了会丢帧轨迹出现断裂。fp.flush()每帧都刷是给自己留后悔药。跑几千帧的序列中途 CtrlC或者节点 OOM 被系统 kill没刷盘的数据全没了重跑一遍又要十几分钟。每帧 flush 的 IO 开销在这个量级完全可以接受。4.3 用 evo 做对齐与误差统计轨迹到手交给 evo。先看整体形状再算绝对位姿误差# 画轨迹--ref 指定参考轨迹-p 出图--plot_modexy 只画平面 evo_traj tum results/00/est_traj.txt --ref results/00/gt_traj.txt \ -p --plot_modexy -a --save_plot results/00/traj.png # 绝对位姿误差-a 做 Umeyama 对齐含尺度-v 打印详细信息 evo_ape tum results/00/gt_traj.txt results/00/est_traj.txt \ -va --align --plot --save_results results/00/ape.zip # 相对位姿误差看局部漂移按 1 米、10 米、100 米分段统计 evo_rpe tum results/00/gt_traj.txt results/00/est_traj.txt \ -va --delta 10 --delta_unit m --plot-a和--align都是做对齐-a是--align的简写做的是 Umeyama 相似变换会同时估计旋转、平移和尺度。Lego-LOAM 的尺度来自点云几何理论上不该有缩放但实际跑出来跟真值会有 1%~3% 的尺度偏差用-a把这个偏差吃掉评的是形状对不对而不是尺度准不准。如果要评尺度换成--align_origin只把起点平移对齐尺度差异会体现在 ATE 里。--delta 10 --delta_unit m表示每隔 10 米取一对位姿算相对误差。分段统计比单个 RMSE 有信息量得多能看出误差是均匀累积还是集中在某几段。4.4 结果怎么看ATE/RPE 与轨迹图跑完会打出这么一串max mean median min rmse sse std 12.4831 5.120937 4.3871 0.412 5.831 1.29e4 2.79rmse是主指标单位米表示估计轨迹跟真值的整体偏离。KITTI 00 序列上调得不错的 Lego-LOAM 大概在 4~8 米区间全长 3.7 km超过 15 米基本说明前端特征提取或者外参有问题。std看稳定性2 以内说明误差分布比较均匀std接近rmse说明有几段偏得特别离谱回去看轨迹图哪一段拐弯了。max值经常出现在序列开头因为 evo 对齐的时候会吃掉第一帧的偏移实际看的时候可以忽略前 100 帧。轨迹图traj.png里灰色是真值彩色是估计值颜色深浅代表时间。图上一眼能看出来的问题有两个整条线整体偏一个方向是外参没对齐某一段彩色线突然偏离再回来是那一段点云特征少典型场景是长直走廊或者空旷路口前端匹配退化了。5. 避坑与排查时间戳、外参、路径和 evo 对齐的五条血泪记录5.1 时间戳对不上evo 直接抛异常现象evo_ape跑到一半报TUM trajectory timestamps are not synchronized或者max diff显示 1.6e9 这种量级。原因估计轨迹用了rospy.Time.now()或者 rosbag 播放时的时间而真值用的是 KITTItimes.txt里从 0 开始递增的相对时间。两边起点不同差了一个 Unix 纪元。解决估计端统一改用msg.header.stamp同时在 rosbag 播放时加--clock让 ROS 时间跟着 bag 走如果两边已经错开临时可以用evo_ape ... --t_offset 秒补一个固定偏移但这是权宜之计下次换序列又得调。根治还是从消息头取时间。另外--t_start/--t_end可以裁剪时间窗处理两边帧数不一致时很实用。5.2 点云字段错位LOAM 静默失败现象节点正常启动、正常订阅但特征点数量一直是 0或者pcl_viewer打开点云发现所有点都在一个平面上。原因读 bin 时没按x y z intensity四个 float32 的顺序解析中间插了 padding或者把 intensity 当成了 x。还有一种情况是 velodyne 某些帧的点数不是整数倍文件末尾有残字节。解决先ls -l看文件大小除以 16 必须是整数再抽一帧用bin2pcd转出来在pcl_viewer里确认有立体结构。ROS 里发PointCloud2时检查fields数组的顺序和point_step64 线点云的point_step应该是 32如果用 XYZIRT 结构或者 16只有 XYZI。字段一错LOAM 的曲率计算全是垃圾值还不报错这是最难查的一类问题。5.3 外参没转轨迹整体旋转 90 度现象轨迹图看着形状跟真值几乎一样但整体转了个角度ATE 巨大。原因真值在相机 0 系z 朝前、y 朝下算法输出在雷达世界系z 朝上。两个坐标系差一个绕 x 轴 90 度的旋转不做变换直接算误差平移量全错。解决在真值转 TUM 的时候左乘一个固定的 4×4 变换矩阵把相机系转到雷达系就是 3.3 节那个T_cam_to_lidar。如果不想动真值也可以在 evo 里用--pose_relation angle_deg之外的方式规避但更推荐统一坐标系后面接别的评估工具不用再改一遍。变换矩阵的方向别搞反是把真值变换到算法坐标系不是反过来。5.4 路径写死换机器就崩现象roslaunch报cannot find package或者file not found: /home/xxx/data/...在自己机器上好好的拷给别人就跑不起来。原因launch 文件或脚本里写了绝对路径比如/home/zhangsan/kitti/data换个人、换个用户名就失效。解决launch 里用$(find lego_loam)/config/kitti.yaml这种 ROS 变量脚本里用$(dirname $0)/..推导仓库根目录或者读环境变量KITTI_ROOT启动前export KITTI_ROOT/data/kitti。这套习惯一旦建立仓库拷到哪都能跑给别人丢代码的时候也少一堆在我这能跑的扯皮。5.5 evo 对齐方式选错指标虚高现象ATE 只有 0.3 米看着像 SOTA但轨迹图上明显有漂移或者换个对齐参数指标从 0.3 跳到 20。原因对齐策略没统一。--align做带尺度的相似变换--align_origin只平移--no_align完全不对齐。默认行为在不同版本的 evo 里还不太一样。解决评测前把对齐方式写死并在结果里标注一般 SLAM 评测用--align纯里程计看局部精度用--align_origin。批量跑的时候把参数固定进run_eval.sh别每次手敲。还有个细节--align的 Umeyama 在轨迹退化比如全程直线时可能估出离谱的尺度这时候看一眼scale输出值偏离 1 太远就说明对齐不可信该换--align_origin重跑。6. 二次开发从单次跑通到批量回归的改造思路6.1 把单序列评测抽成可复用的批量脚本单次跑通只是起点真正省时间的是把跑一个序列变成跑一批序列并出汇总表。改造点集中在run_eval.sh跑完 evo 之后把rmse、mean、std从一个 zip 结果里解析出来写进 CSV。# 从 evo_ape 保存的 zip 里提取指标追加到汇总表 python3 - PY import zipfile, json, csv, os row {} for seq in sorted(os.listdir(results)): zpath fresults/{seq}/ape.zip if not os.path.exists(zpath): continue with zipfile.ZipFile(zpath) as z: stats json.loads(z.read(stats.json)) row[seq] {rmse: round(stats[rmse], 3), mean: round(stats[mean], 3), std: round(stats[std], 3)} with open(results/summary.csv, w, newline) as f: w csv.writer(f) w.writerow([seq, rmse, mean, std]) for seq, v in row.items(): w.writerow([seq, v[rmse], v[mean], v[std]]) PYstats.json是 evo 存 zip 时固定的入口文件字段名在不同版本间基本稳定直接读比解析终端输出靠谱得多——终端输出会带颜色码和换行正则匹配容易翻车。汇总表出来后改一版算法跑一遍diff一下 CSV 就知道哪条序列退化了比挨个看轨迹图快十倍。6.2 给数据集和算法加一层适配现在链路是硬编码 KITTI Lego-LOAM 的想换数据集或者换算法分支就得改一堆脚本。加一层薄适配会舒服很多定义一个convert接口输入是任意数据源的原始目录输出统一成sequences/XX/velodyne/*.binground_truth/XX.txt的约定结构。KITTI 之外的点云数据集字段和命名差别不小但转换逻辑是相通的解析原始格式、按统一命名落盘、生成times.txt、把真值统一成 TUM。算法侧同理只要你的分支还发布/aft_mapped_to_init这个话题odom_to_tum.py一行都不用改如果话题名变了加一个--topic参数就行别写死。6.3 把指标阈值写进回归测试最后一步是把这套东西塞进 CI。做法很朴素run_eval.sh跑完汇总表后跟一份基线 CSV 比对任何序列的rmse涨幅超过 20% 就退出码非 0。阈值怎么定我的习惯是拿三版稳定代码各跑一遍取最大值再上浮 15%。定太紧换个编译器版本就报警定太松退化了也发现不了。另外KITTI 00 和 05 这两条序列我一般固定作为必测项前者有回环后者转弯多前端一改这两条最先出问题。从那以后我每次改完 LOAM 的前端特征提取都强制先跑一遍 00 序列的 evo再看summary.csv有没有动最后才提交代码。这个习惯帮我挡掉过好几次本地看着没问题、一跑长序列就漂移的提交。希望这套链路也能帮到你。本文还有配套的精品资源点击获取