☰
Lego-LOAM跑KITTI数据全流程:从数据转换到evo轨迹评估
2026/10/14 23:06:59 网站建设 项目流程

简介:这份资源是面向激光雷达SLAM初学者与算法验证人员的LeGO-LOAM适配工程,针对原版依赖ROS环境、上手门槛高的问题,将代码改造为可直接读取KITTI数据集运行与评估的版本。使用者无需熟悉ROS与LOAM细节,即可跑通激光里程计流程,并以KITTI地面真值格式输出轨迹结果,再借助EVO评估套件直接对比精度。压缩包共35个文件、约26.84MB,包含5个cpp源文件与3个头文件构成的核心算法实现,配套launch启动脚本、msg消息定义、rviz可视化配置及CMakeLists构建文件,另有论文预印本、README说明与多张效果图辅助理解。资源还整理了GTSAM等依赖的安装指引,方便在Ubuntu环境下快速搭建。目前已有6360人学习下载,适合想低成本验证LeGO-LOAM性能、开展KITTI里程计评测的开发者参考。

1. 从一包 KITTI 数据说起:Lego-LOAM 到底该怎么跑、怎么评

很多人第一次接触 Lego-LOAM,都是被它“轻量、纯激光、能实时”这几个标签吸引过来的。可真把源码拉下来,面对一包 KITTI 数据,问题立刻变成:Velodyne 点云怎么喂进去?ground-truth 从哪来?跑完的轨迹怎么和真值对齐、算误差?我见过太多人卡在“编译过了但跑不出结果”这一步,最后不了了之。这份 kitti-lego-loam 资源解决的正是这个断层——它把 KITTI 数据集的读取、Lego-LOAM 的运行、以及基于 evo 的轨迹评估串成了一条能直接复现的链路。适合刚上手激光 SLAM、想拿公开数据集验证算法、又不想在数据格式和评估脚本上反复翻车的人。下面按“数据怎么准备 → 代码怎么跑 → 结果怎么评 → 坑在哪”的顺序拆开讲。

2. KITTI 数据与 Lego-LOAM 的接口:格式、坐标系与启动参数

2.1 为什么 KITTI 不能直接丢给 Lego-LOAM

Lego-LOAM 原生面向的是 Velodyne HDL-64E 这类机械式激光雷达,它期望的输入是 ROS 里的sensor_msgs/PointCloud2,并且对点云的字段顺序、ring 信息、时间戳都有隐含要求。而 KITTI 的 raw data 是以二进制 bin 文件逐帧存放的,每帧点云是(x, y, z, intensity)四个 float32 连续排列,没有 ring,也没有每点时间戳。直接把 bin 文件读进来转成 PointCloud2,Lego-LOAM 的特征提取环节会因为缺少 ring 而无法正确做地面分割和聚类,表现就是“能启动但轨迹飘得离谱”或者干脆没有特征点输出。

常见做法是写一个转换节点,把 KITTI 的 bin 读成点云,同时按 Velodyne 64 线的垂直角度范围反推每个点的 ring。KITTI 的 Velodyne 数据来自 HDL-64E,垂直视场约 +2° 到 -24.8°,64 线均匀分布,所以可以用atan2(z, sqrt(x*x+y*y))算出俯仰角,再映射到 0~63 的 ring 索引。这一步不做,Lego-LOAM 的imageProjection模块基本是废的。

2.2 数据目录组织与 ground-truth 的对应关系

KITTI odometry 数据集的标准结构是sequences/00/velodyne/下放000000.bin到004540.bin,poses/00.txt放对应的 12 参数位姿矩阵,每行 12 个数,按行展开成 3x4 的变换矩阵。注意这个位姿是相机坐标系下的,而 Lego-LOAM 输出的是雷达坐标系下的轨迹,两者之间差一个标定外参。KITTI 官方给出的相机-雷达外参在 calibration 文件里,评估前必须把轨迹统一到同一坐标系,否则 evo 算出来的误差会大得让你怀疑算法。

我一般会先把目录整理成下面这样,转换脚本和评估脚本都按这个约定去找文件:

kitti_lego_loam/ ├── data/ │ └── 00/ │ ├── velodyne/ # 000000.bin ... │ ├── poses.txt # ground-truth, 12 参数/行 │ └── calib.txt # 相机-雷达外参 ├── src/ │ ├── kitti_helper.cpp # bin 转 PointCloud2 │ └── run_lego_loam.launch └── eval/ └── evaluate.py # 调 evo 做对齐与误差统计

2.3 启动文件里的关键参数

Lego-LOAM 的 launch 文件里几个参数直接决定能不能跑通 KITTI。scanPeriod要设成 0.1,因为 KITTI 是 10Hz 采集;sensor设成 velodyne;N_SCAN设 64;Horizon_SCAN设 1800 左右(KITTI 每圈约 1800 个水平采样)。如果这些对不上,特征匹配会错位。下面是一个能用的 launch 片段:

<launch> <param name="/lego_loam/scanPeriod" value="0.1" /> <param name="/lego_loam/sensor" value="velodyne" /> <param name="/lego_loam/N_SCAN" value="64" /> <param name="/lego_loam/Horizon_SCAN" value="1800" /> <node pkg="lego_loam" type="lego_loam" name="lego_loam" output="screen" /> <node pkg="kitti_helper" type="kitti_helper" name="kitti_helper" output="screen"> <param name="data_path" value="$(find kitti_helper)/data/00/velodyne/" /> <param name="frame_rate" value="10" /> </node> </launch>

scanPeriod和frame_rate要一致,否则时间戳对不上,Lego-LOAM 的位姿插值会出问题。Horizon_SCAN如果设小了,水平方向的特征会被截断,表现为转弯时轨迹突然跳变。

3. 从 bin 到轨迹:转换节点与 Lego-LOAM 的联调

3.1 写一个最小可用的 KITTI 转 PointCloud2 节点

这个节点的职责很单一:按帧号顺序读 bin,填好 ring 和时间戳,发成 PointCloud2。下面是我常用的核心逻辑,用 C++ 写,依赖 PCL 和 ROS。

// kitti_helper.cpp #include <ros/ros.h> #include <sensor_msgs/PointCloud2.h> #include <pcl/point_types.h> #include <pcl/point_cloud.h> #include <pcl_conversions/pcl_conversions.h> #include <fstream> #include <cmath> struct KittiPoint { float x, y, z, intensity; }; int main(int argc, char** argv) { ros::init(argc, argv, "kitti_helper"); ros::NodeHandle nh; std::string data_path; nh.param<std::string>("data_path", data_path, ""); ros::Publisher pub = nh.advertise<sensor_msgs::PointCloud2>("/velodyne_points", 10); int frame = 0; ros::Rate rate(10); // KITTI 10Hz while (ros::ok()) { char buf[256]; snprintf(buf, sizeof(buf), "%s%06d.bin", data_path.c_str(), frame); std::ifstream fin(buf, std::ios::binary); if (!fin) { ROS_WARN("no more frames"); break; } pcl::PointCloud<pcl::PointXYZI> cloud; KittiPoint p; while (fin.read(reinterpret_cast<char*>(&p), sizeof(KittiPoint))) { pcl::PointXYZI pt; pt.x = p.x; pt.y = p.y; pt.z = p.z; pt.intensity = p.intensity; // 反推 ring:HDL-64E 垂直角 +2 ~ -24.8 度 float angle = std::atan2(p.z, std::sqrt(p.x*p.x + p.y*p.y)) * 180.0 / M_PI; int ring = static_cast<int>((2.0 - angle) / 26.8 * 64.0); ring = std::max(0, std::min(63, ring)); pt.intensity = p.intensity + ring * 0.001; // 把 ring 编码进 intensity 低位 cloud.push_back(pt); } cloud.width = cloud.size(); cloud.height = 1; cloud.is_dense = false; sensor_msgs::PointCloud2 msg; pcl::toROSMsg(cloud, msg); msg.header.frame_id = "velodyne"; msg.header.stamp = ros::Time::now(); pub.publish(msg); frame++; rate.sleep(); } return 0; }

逻辑说明:读 bin 时按KittiPoint结构体逐点解析,每点 16 字节。ring 的计算用俯仰角线性映射到 0~63,这是 HDL-64E 的近似分布,够用。把 ring 编码进 intensity 的低位是一种取巧做法,Lego-LOAM 的imageProjection会从 intensity 里解出 ring,如果你改源码直接读 ring 字段也可以,但那样要动 Lego-LOAM 本体,不如在转换层解决。参数上data_path必须以斜杠结尾,frame_rate和 launch 里的scanPeriod保持一致。

3.2 联调时先看这三个话题

启动之后别急着看轨迹,先确认三件事:/velodyne_points有没有数据、/lego_loam/odom有没有输出、TF 树里velodyne到map的变换是否在更新。我一般用rostopic hz /velodyne_points看频率是不是 10Hz,用rostopic echo /lego_loam/odom/pose/pose/position看位置有没有在动。如果点云有但 odom 不动,八成是 ring 没填对或者N_SCAN设错了。如果 odom 在动但轨迹明显漂,检查Horizon_SCAN和实际水平点数是否匹配。

3.3 保存轨迹成 TUM 格式

Lego-LOAM 默认不存轨迹文件,评估前得自己录。可以用rosbag record /lego_loam/odom再离线转,也可以直接在代码里加一个订阅把位姿写成 TUM 格式。TUM 格式是timestamp tx ty tz qx qy qz qw,每行一条,evo 直接认。下面这个 Python 片段订阅 odom 并落盘:

import rospy from nav_msgs.msg import Odometry f = open('lego_loam_00.txt', 'w') def cb(msg): t = msg.header.stamp.to_sec() p = msg.pose.pose.position q = msg.pose.pose.orientation f.write(f"{t:.6f} {p.x:.6f} {p.y:.6f} {p.z:.6f} " f"{q.x:.6f} {q.y:.6f} {q.z:.6f} {q.w:.6f}\n") f.flush() rospy.init_node('save_traj') rospy.Subscriber('/lego_loam/odom', Odometry, cb) rospy.spin()

时间戳用 ROS 的ros::Time::now()会和 KITTI 原始时间戳有偏移,但 evo 做对齐时用的是相对时间,只要单调递增就没问题。如果你要和 ground-truth 做逐帧对比,得把 KITTI 的 times.txt 读进来替换时间戳,否则 evo 的--t_max_diff会对不上。

4. 用 evo 做轨迹评估:对齐、误差指标与结果解读

4.1 ground-truth 的格式转换

KITTI 的 poses.txt 是 12 参数行,需要转成 TUM 格式才能和 evo 配合。转换时注意两点:一是把 3x4 矩阵转成平移加四元数,二是时间戳可以用帧号除以 10 来生成,因为 KITTI 是 10Hz。下面这段 Python 做这件事:

import numpy as np from scipy.spatial.transform import Rotation as R def kitti_to_tum(pose_file, out_file, fps=10.0): with open(pose_file) as f, open(out_file, 'w') as g: for i, line in enumerate(f): vals = list(map(float, line.strip().split())) mat = np.array(vals).reshape(3, 4) t = mat[:, 3] rot = R.from_matrix(mat[:, :3]) q = rot.as_quat() # x y z w ts = i / fps g.write(f"{ts:.6f} {t[0]:.6f} {t[1]:.6f} {t[2]:.6f} " f"{q[0]:.6f} {q[1]:.6f} {q[2]:.6f} {q[3]:.6f}\n") kitti_to_tum('data/00/poses.txt', 'gt_00.txt')

注意 KITTI 的位姿是相机坐标系,Lego-LOAM 输出的是雷达坐标系,直接比会有固定外参偏差。常见做法是用 evo 的--align做 Umeyama 对齐,或者手动乘一个外参矩阵。如果只关心相对误差,对齐后看 RPE 就够了;如果要绝对精度,得先把外参补上。

4.2 evo 命令与指标选择

evo 提供evo_traj看轨迹、evo_ape算绝对位姿误差、evo_rpe算相对位姿误差。对 SLAM 来说,APE 反映全局一致性,RPE 反映局部漂移。我一般两个都跑,命令如下:

# 绝对位姿误差,带对齐 evo_ape tum gt_00.txt lego_loam_00.txt --align --plot --save_results ape_00.zip # 相对位姿误差,窗口 1 米 evo_rpe tum gt_00.txt lego_loam_00.txt --align --delta 1 --delta_unit m \ --plot --save_results rpe_00.zip

--align会做相似变换对齐,包括尺度,如果你的系统有尺度漂移,这个选项会把尺度误差吃掉,所以看纯激光里程计建议加--align --correct_scale之前先确认尺度是否可信。--delta 1 --delta_unit m表示每 1 米算一次相对误差,适合看局部一致性。结果里的rmse、mean、median三个数,我通常先看 rmse,再看 median,如果 median 很小但 rmse 很大,说明有少数帧跳变,回去查点云质量。

4.3 结果解读与常见误判

APE 的 rmse 在 KITTI 00 上,调得好的 Lego-LOAM 大概在 1% 到 2% 的轨迹长度量级。如果你跑出来 rmse 是几十米,先别怀疑算法,检查三件事:轨迹和真值的时间戳是否对齐、坐标系是否统一、有没有做尺度对齐。我见过有人把雷达轨迹和相机真值直接比,误差大得离谱,补上外参后立刻正常。另外 evo 的--plot会弹窗,服务器上跑用--plot_mode xy存图,别用交互模式。

5. 避坑与排查:那些让你白跑一整天的细节

5.1 点云有输出但 Lego-LOAM 不发布 odom

现象:/velodyne_points频率正常,但/lego_loam/odom一直没消息。原因通常是 ring 没填对,imageProjection在分割地面时拿不到有效 ring,直接跳过所有点。解决:在转换节点里打印几个点的 ring 值,确认落在 0~63 且分布合理;同时检查 launch 里N_SCAN是否等于 64。

5.2 轨迹整体偏移但形状对

现象:evo 画出来轨迹形状和真值很像,但整体平移了一大截。原因是坐标系没统一,KITTI 真值是相机系,Lego-LOAM 是雷达系。解决:用--align做对齐,或者从 calib.txt 读出外参矩阵,把真值转到雷达系再比。别小看这个偏移,它会让 APE 的 rmse 虚高好几倍。

5.3 evo 报时间戳不匹配

现象:evo_ape提示no matching timestamps。原因是两条轨迹的时间基准不同,Lego-LOAM 用 ROS 时间,真值用帧号生成的时间。解决:统一用帧号除以 10 生成时间戳,或者在 evo 里加--t_max_diff 0.02放宽匹配阈值。如果还不行,检查时间戳是否单调递增,ROS 时间在仿真里可能回退。

5.4 跑几十帧后轨迹突然飞掉

现象:前几十帧正常,之后轨迹发散。原因可能是 KITTI 某些帧点云稀疏或者有动态物体,Lego-LOAM 的特征匹配失败后没有回环纠正。解决:检查scanPeriod是否和实际帧率一致,不一致会导致位姿插值错误;另外可以调小mapping里的surroundingKeyframeDensity,让局部地图更新更频繁,减少累积误差。

5.5 编译通过但运行时报 PCL 版本冲突

现象:undefined reference to pcl::...或者运行时崩溃。原因是 Lego-LOAM 依赖的 PCL 版本和你系统里的不一致,常见于 Ubuntu 18.04 和 20.04 混用。解决:用ldd看链接的 PCL 库路径,确保编译和运行用的是同一套;如果用了 conda 环境,把 conda 的 lib 从LD_LIBRARY_PATH里去掉。

6. 进阶技巧:把评估做成一条命令,以及我踩过的那些坑

跑通一次之后,最该做的事是把整个流程脚本化,否则每次换序列都要手动敲一遍转换、启动、录轨迹、评估,效率极低。我的习惯是写一个run_eval.sh,把序列号当参数传进去,内部依次做:生成 TUM 真值、启动 launch、录 odom、等跑完、调 evo 出报告。这样换 01 到 10 序列只需要改一个数字。下面是一个简化版:

#!/bin/bash SEQ=$1 python3 eval/kitti_to_tum.py data/${SEQ}/poses.txt gt_${SEQ}.txt roslaunch kitti_helper run_lego_loam.launch data_path:=data/${SEQ}/velodyne/ & LAUNCH_PID=$! rosbag record -O lego_${SEQ}.bag /lego_loam/odom & REC_PID=$! sleep 300 # 根据序列长度调整 kill $REC_PID $LAUNCH_PID python3 eval/bag_to_tum.py lego_${SEQ}.bag lego_${SEQ}.txt evo_ape tum gt_${SEQ}.txt lego_${SEQ}.txt --align --plot_mode xy --save_plot ape_${SEQ}.png

这里sleep 300是硬编码的,实际用的时候我会改成监听 bag 文件大小或者 odom 消息数,避免等太久或者提前杀进程。bag_to_tum.py就是把 bag 里的 odom 按时间戳写成 TUM,逻辑和前面订阅落盘一样,只是数据源换成 bag。

还有一个容易忽略的点:KITTI 的 00 序列有 4541 帧,跑完大概要七八分钟,期间如果内存不够,Lego-LOAM 的局部地图会越来越大,最后 OOM。我一般会在 launch 里把mapping的surroundingKeyframeDensity调小,或者定期清一下历史关键帧。另外 evo 的--save_results会存一个 zip,里面包含对齐矩阵和误差序列,换机器复现时直接读这个 zip 比重新跑一遍快得多。

从那以后我每次换数据集或者改参数,都强制走一遍“转换 → 跑 → 存轨迹 → evo 对齐 → 看 rmse 和 median”这个闭环,不再凭感觉判断算法有没有变好。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询