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 个 float32(x、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.10,GTSAM 建议 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.3,apt 源里的版本经常缺头文件 git clone -b 4.0.3 https://github.com/borglab/gtsam.git cd gtsam && mkdir build && cd build cmake .. -DGTSAM_BUILD_WITH_MARCH_NATIVE=OFF \ -DGTSAM_USE_SYSTEM_EIGEN=ON make -j$(nproc) && sudo make install # 编译工作空间 cd catkin_ws catkin_make -DCMAKE_BUILD_TYPE=Release source devel/setup.bash-DGTSAM_BUILD_WITH_MARCH_NATIVE=OFF是血泪经验,开着-march=native编出来的库换一台机器跑会直接崩在ILL(非法指令),症状是节点启动瞬间退出,日志里看不出任何信息。-DCMAKE_BUILD_TYPE=Release也别省,默认的Debug模式下特征提取能慢三倍,一帧点云等两秒,调试体验极差。
evo用 pip 装最省事,pip3 install evo --upgrade。装完先跑evo_traj --help,能出版本号说明 Python 环境通了。这里有个小坑:如果系统里同时有 conda 和 apt 的 Python,evo_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 个 float32:x y z intensity if (bytes % 16 != 0) { std::cerr << "文件大小不是 16 的整数倍,格式可能不对\n"; return -1; } size_t n = bytes / 16; std::vector<float> raw(n * 4); fin.read(reinterpret_cast<char*>(raw.data()), bytes); pcl::PointCloud<pcl::PointXYZI>::Ptr cloud(new pcl::PointCloud<pcl::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.py:KITTI 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]], dtype=float) 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" type="int" value="64" /> <param name="/lego_loam/mapping_skip_frame" type="int" value="1" /> <param name="/lego_loam/use_imu_as_input" type="bool" value="false" /> <param name="/lego_loam/use_odom_as_input" type="bool" value="false" /> <param name="/lego_loam/save_pcd" type="bool" value="false" /> <!-- 外参:KITTI 是纯激光,雷达到 base_link 按单位阵即可 --> <node pkg="tf" type="static_transform_publisher" name="base_to_lidar" args="0 0 0 0 0 0 /base_link /velodyne 100" /> <node pkg="lego_loam" type="imageProjection" name="imageProjection" output="screen" /> <node pkg="lego_loam" type="featureAssociation" name="featureAssociation" output="screen" /> <node pkg="lego_loam" type="mapOptimization" name="mapOptimization" output="screen" /> </launch>scan_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_init(nav_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_size=200) rospy.on_shutdown(lambda: fp.close()) rospy.spin()关键点是msg.header.stamp,别用rospy.Time.now()。消息头时间戳是数据产生时刻,用系统时间会跟真值的时间轴错开几百毫秒,evo 那边一看时间差对不上就直接报错。queue_size=200给大一点,64 线点云的发布频率不稳定,队列小了会丢帧,轨迹出现断裂。
fp.flush()每帧都刷,是给自己留后悔药。跑几千帧的序列中途 Ctrl+C,或者节点 OOM 被系统 kill,没刷盘的数据全没了,重跑一遍又要十几分钟。每帧 flush 的 IO 开销在这个量级完全可以接受。
4.3 用 evo 做对齐与误差统计
轨迹到手,交给 evo。先看整体形状,再算绝对位姿误差:
# 画轨迹:--ref 指定参考轨迹,-p 出图,--plot_mode=xy 只画平面 evo_traj tum results/00/est_traj.txt --ref results/00/gt_traj.txt \ -p --plot_mode=xy -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_step,64 线点云的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 = f'results/{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/*.bin+ground_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有没有动,最后才提交代码。这个习惯帮我挡掉过好几次"本地看着没问题、一跑长序列就漂移"的提交。希望这套链路也能帮到你。
本文还有配套的精品资源,点击获取