Using the API¶
The Ouster SDK provides a mapping API that allows developers to integrate Simultaneous Localization and Mapping (SLAM) capabilities into their applications. The API is built around two primary components:
SlamConfig - A class whose instances hold configuration parameters for the SLAM process. Subclasses, such as LIOSlamConfig, provide specific configurations for different implementations. LIO refers to the Lidar Inertial Odometry.
SlamEngine - An interface for implementations that process lidar frames, estimates sensor poses, and build an internal map. This interface also serves as a factory for different SLAM implementations, e.g. the built-in Lidar Inertial Odometry engine.
The engine modifies input LidarFrame objects to include per-column pose information.
Prerequisites¶
Before using the mapping and SLAM features, ensure your development environment is set up and the Ouster SDK is installed.
Follow the installation steps in the Getting Started guide.
Download PCAP/OSF data from the Sample Data section to experiment with the API.
For C++ development, make sure you have built the SDK with
BUILD_MAPPING=ON.
Configuration¶
Start by configuring how the SLAM engine should run. SlamConfig holds the shared parameters (minimum and maximum range, voxel resolution, deskew strategy, initial pose, etc.). Subclasses such as LIOSlamConfig provide configuration for the LIO backend; pass the resulting config to SlamEngine.create.
In Python, use the SlamConfig.create factory
with "lio" to obtain a concrete config instance.
In C++, construct LIOSlamConfig directly.
A SlamConfig (or LIOSlamConfig) object provides several parameters to tune the SLAM algorithm:
Option |
Type | Values |
Notes |
|---|---|---|
|
float |
Range gating (in meters) to filter points before processing. |
|
float (meters) |
Voxel size for the internal map. If set to 0.0, a suitable size will be automatically determined from the first few frames. Controls map resolution; Smaller values increase map fidelity but also increase memory usage and computation time. |
|
4×4 matrix |
A 4x4 transformation matrix specifying the starting pose of the sensor in the world frame. Defaults to the identity matrix. |
|
int |
Maximum number of ICP registration iterations per frame update. Defaults to 500. |
|
|
Method to use for motion distortion correction (deskewing) within each frame. |
deskew_method specifies how to handle motion distortion within each frame. Options are:
“auto”: For FW >= 3.2, the
deskew_methodis set to"imu_deskew", which uses IMU data from the sensor to perform more accurate deskewing. On FW < 3.2, it defaults to"constant_velocity".“constant_velocity”: Enables software-based deskewing assuming constant velocity motion between poses during the frame acquisition.
“none”: Disables software deskewing, assuming the input frames are already deskewed or that motion distortion is negligible.
Note
The performance of the SLAM algorithm depends on your CPU’s processing power and the ‘voxel_size’ parameter. Below is a suggestion for selecting an appropriate voxel size:
The following code snippets demonstrate how to create a SlamConfig, which SlamEngine.create needs to instantiate a SlamEngine.
Imports
# Other imports
from ouster.sdk import open_source
from ouster.sdk import mapping
#include <ouster/core/open_source.h>
#include <ouster/mapping/slam_engine.h>
using namespace ouster::sdk;
Create SlamConfig/LIOSlamConfig
slam_config = mapping.SlamConfig.create("lio")
slam_config.min_range = 0.5
slam_config.max_range = 100.0
slam_config.deskew_method = "auto"
mapping::LIOSlamConfig slam_config;
slam_config.min_range = 0.5; // Minimum range 0.5 meters
slam_config.max_range = 100.0; // Maximum range 100.0 meters
slam_config.deskew_method = "auto"; // Let the system choose the deskewing method
Open a Data Source and Construct the Engine¶
Next, open your lidar data source using open_source. This function handles live sensors, PCAP files, and OSF files, returning an iterable source object and associated metadata (SensorInfo).
The SlamEngine is also a factory that can instantiate derived SlamEngine instances using the
create method,
providing the SensorInfo list (even for a single sensor) and an optional SlamConfig (defaults to
LIOSlamConfig{}). Currently, SlamEngine supports only the lio (Lidar Inertial Odometry) implementation, which provides fast, incremental, CPU-based mapping.
The SensorInfo is crucial as it contains calibration data (beam angles, intrinsics) needed for point cloud generation and deskewing.
source = open_source(source_file)
slam_engine = mapping.SlamEngine.create(
source.sensor_info,
slam_config)
auto source = open_source(source_file);
auto slam_engine = mapping::SlamEngine::create(
source.sensor_info(),
slam_config);
Run SLAM on Each Frame¶
The core of the SLAM process is an iteration loop. You read a FrameSet, frame_set, from your source and pass it to the slam_engine.update() method.
SlamEngine.update() processes the input FrameSet and augments each frame with per-column pose information.
In Python, it returns the updated frame set; in C++, it modifies the FrameSet in place and returns void.
This update() call performs several crucial steps:
It deskews the frame(s) using the configured
deskew_method, writing an initial per-column trajectory intoframe.body_to_world.It aggregates points from the frame(s) in the set.
It registers the new points against its internal map (using KISS-ICP) to estimate the sensor’s new pose.
It applies the ICP pose correction to every column pose and updates its internal map with the new points.
After update() completes, the FrameSet is populated with map-aligned pose data. You can then access the pose for any column (measurement) in the frame to get the sensor’s position at that precise moment. The list contains one entry per sensor.
for frame_set in source:
frame_set = slam_engine.update(frame_set)
for (auto frame_set : source) {
slam_engine->update(frame_set);
Inspect SLAM Output¶
Each LidarFrame now carries frame.body_to_world (a per-column SE(3) transform) and frame.timestamp arrays that are aligned with the
range/reflectivity columns. The engine also keeps the internal map up to date so subsequent calls
benefit from accumulated structure.
The examples show how to extract the pose corresponding to the last valid column in the frame and convert its rotation part into Euler angles (yaw, pitch, roll) for easier interpretation.
frame = frame_set[0]
# Get last valid column (closest to the current pose)
col = frame.get_last_valid_column()
# Get timestamp and pose for the column
frame_pose = frame.body_to_world[col]
frame_ts = frame.timestamp[col]
const auto& frame = frame_set[0];
// Get last valid column (closest to the current pose)
int col = frame->get_last_valid_column();
// Get timestamp and pose for the column
auto frame_pose = frame->get_column_pose(col);
auto frame_ts = frame->timestamp()[col];
Full processing loop
Combining these steps, the typical structure for running SLAM involves opening the source, creating the engine, and looping through the frames while calling update() and processing the results. The complete loop is shown below:
# [doc-stag-slam-loop-update]
for frame_set in source:
frame_set = slam_engine.update(frame_set)
# [doc-etag-slam-loop-update]
# [doc-stag-slam-loop-printpose]
frame = frame_set[0]
# Get last valid column (closest to the current pose)
col = frame.get_last_valid_column()
# Get timestamp and pose for the column
frame_pose = frame.body_to_world[col]
frame_ts = frame.timestamp[col]
# [doc-etag-slam-loop-printpose]
# Extract translation (top-right 3x1)
t = frame_pose[:3, 3]
# ZYX euler (yaw, pitch, roll)
rot = frame_pose[:3, :3]
angles = R.from_matrix(rot).as_euler('zyx', degrees=True)
yaw = angles[0]
pitch = angles[1]
roll = angles[2]
print(f"idx = {frame.frame_id}; frame_ts = {frame_ts};"
f"; Translation: "
f"{t[0]:.2f}, {t[1]:.2f}, {t[2]:.2f} "
f" (Roll: {roll:.1f} "
f", Pitch: {pitch:.1f} "
f", Yaw: {yaw:.1f})")
//! [doc-stag-slam-loop-update]
for (auto frame_set : source) {
slam_engine->update(frame_set);
//! [doc-etag-slam-loop-update]
//! [doc-stag-slam-loop-printpose]
const auto& frame = frame_set[0];
// Get last valid column (closest to the current pose)
int col = frame->get_last_valid_column();
// Get timestamp and pose for the column
auto frame_pose = frame->get_column_pose(col);
auto frame_ts = frame->timestamp()[col];
//! [doc-etag-slam-loop-printpose]
// Extract translation
Eigen::Vector3d translation = frame_pose.block<3, 1>(0, 3);
// ZYX euler: yaw, pitch, roll
Eigen::Matrix3d rot = frame_pose.block<3, 3>(0, 0);
auto angles = rot.eulerAngles(2, 1, 0);
double yaw = angles[0];
double pitch = angles[1];
double roll = angles[2];
std::cout << "idx = " << frame->frame_id << "; frame_ts = " << frame_ts
<< "; Translation: " << std::fixed << std::setprecision(2)
<< translation[0] << ", " << translation[1] << ", " << translation[2]
<< " (Roll: " << to_degrees(roll)
<< ", Pitch: " << to_degrees(pitch)
<< ", Yaw: " << to_degrees(yaw) << ")\n"; }
Obtain Lidar Pose and Calculate Pose Difference¶
The SLAM API outputs the sensor’s pose for each lidar frame, which you can use to determine the sensor’s orientation in your system. From the Lidar Poses, we can calculate the Pose difference between consecutive frames.
Once the SLAM is run on the current frames to compute poses using slam.update(frame_set).
Extract the first valid column in the frame, frame_pose and frame_ts.
The pose_differences function is used to compare the current pose to the previous one, calculating rotation and translation deltas between two poses.
def pose_differences(last_frame_pose: np.ndarray,
frame_pose: np.ndarray ) -> Tuple[np.ndarray, np.ndarray]:
pose_diff = np.linalg.inv(last_frame_pose) @ frame_pose
rotation_diff = pose_diff[:3, :3]
translation_diff = pose_diff[:3, 3]
return rotation_diff, translation_diff
PoseDiff pose_differences(const core::Matrix4dR& last_frame_pose,
const core::Matrix4dR& frame_pose) {
auto pose_diff = last_frame_pose.inverse() * frame_pose;
auto rotation_diff = pose_diff.block<3, 3>(0, 0);
auto translation_diff = pose_diff.block<3, 1>(0, 3);
return {rotation_diff, translation_diff}; }
Call pose_differences in the frame loop
for idx, frame_set in enumerate(data_source):
frames_w_poses = slam.update(frame_set)
if not frames_w_poses:
continue
try:
first_frame = frames_w_poses[0]
if first_frame is None:
continue
col = first_frame.get_first_valid_column()
except RuntimeError:
continue
frame_pose = first_frame.body_to_world[col]
frame_ts = frame_set[0].timestamp[col]
rotation_diff, translation_diff = pose_differences(last_frame_pose, frame_pose)
last_frame_pose = frame_pose
for (auto frame_set : data_source) {
slam.update(frame_set);
const auto& frame = frame_set[0];
int col = 0;
try {
col = frame->get_first_valid_column();
} catch (const std::exception&) {
++idx;
continue;
}
core::Matrix4dR frame_pose = frame->get_column_pose(col);
auto rotation_and_translation_diff = pose_differences(last_frame_pose, frame_pose);
auto rotation_diff = std::get<0>(rotation_and_translation_diff);
auto translation_diff = std::get<1>(rotation_and_translation_diff);
last_frame_pose = frame_pose;
Handling Motion Distortion: Deskew vs. Dewarp¶
When a LiDAR sensor is in motion (e.g., on a vehicle or a handheld device), the resulting point cloud suffers from distortion. Because each column of a frame is captured at a slightly different time, the captured frame data can be distorted “skewed”.
To create an accurate map, the Ouster SDK’s mapping module corrects this distortion using a two-step process: Deskewing and Dewarping.
Deskew (Motion Compensation)¶
Deskewing is the process of estimating the required motion compensation for the frame captured while the sensor in motion.
What it does
It estimates the sensor’s 4x4 pose at the specific timestamp for each column in the frame. This is done either using high-frequency data from an IMU or a constant velocity motion model.
How it’s used in the SDK
The deskew_method discussed earlier runs as the first step inside SlamEngine::update().
It estimates an initial per-column trajectory and writes it into frame.body_to_world (a (W, 4, 4) array).
SDK Implementation
As described in Run SLAM on Each Frame, update() then registers the deskewed points against the
internal map using KISS-ICP and applies the resulting pose correction to every column pose in
frame.body_to_world. The poses you read after update() are therefore deskewed and map-aligned—not
deskew-only estimates.
In short: Deskewing supplies the initial per-column trajectory; ICP registration refines it before the poses are returned.
Dewarp (Point Cloud Transformation)¶
Dewarping applies the estimated motion compensation from the deskewing step to transform the 3D points into a rigid, and geometrically correct point cloud.
What it does
It takes the distorted (H, W, 3) point cloud and the (W, 4, 4) per-column poses. It then applies the i-th pose matrix to all points in the i-th column, transforming all points from their individual “column frames” into a single, consistent coordinate frame (the frame of the middle column).
Imports
# Other imports
# Other imports
# Other imports
# Other imports
from ouster import sdk
from ouster.sdk import core
from ouster.sdk import mapping
#include <ouster/core/lidar_frame.h>
#include <ouster/core/open_source.h>
#include <ouster/core/pose_util.h>
#include <ouster/core/xyzlut.h>
#include <ouster/mapping/slam_engine.h>
#include "ouster/osf/osf_frame_set_source.h"
using namespace ouster::sdk;
How it’s used in the SDK
Dewarping produces a new point cloud by applying a lidar frame’s per-column poses to the frame’s point cloud. When deskew (motion compensation) produces the per-column poses for the frame, the result of dewarp is a point cloud with motion distortion removed, where all points are expressed in a common frame. Refer to dewarp for API details.
def slam_dewarp_once(source_file: str):
with closing(sdk.open_source(source_file)) as source:
slam_config = mapping.SlamConfig.create("lio")
slam_config.deskew_method = "auto"
slam_engine = mapping.SlamEngine.create(source.sensor_info, slam_config)
# Process frames
for frame_set in source:
deskewed = slam_engine.update(frame_set)
# Find the first valid frame index
frame_idx = 0
found = False
for i, f in enumerate(deskewed):
if f is not None:
frame_idx = i
found = True
break
if not found:
continue
if not source.sensor_info or frame_idx >= len(source.sensor_info):
continue
# Compute Cartesian coordinates (XYZ)
xyzlut = core.XYZLut(source.sensor_info[frame_idx],
use_extrinsics=True)
frame = deskewed[frame_idx]
if frame is None:
continue
xyz = xyzlut(frame)
# Dewarp the point cloud using the trajectory from SLAM
# Note: frame.body_to_world is populated by:
# slam_engine.update()
dewarped = core.dewarp(xyz, frame.body_to_world)
return dewarped
inline Eigen::MatrixXf slam_dewarp_once(const std::string& source_file) {
auto source = open_source(source_file);
auto slam_config = mapping::LIOSlamConfig{};
slam_config.deskew_method = "auto";
auto slam_engine = mapping::SlamEngine::create(source.sensor_info(), slam_config);
// Process frames
for (auto frame_set : source) {
slam_engine->update(frame_set);
// Find the first valid frame index
size_t frame_idx = 0;
bool found = false;
for (size_t i = 0; i < frame_set.size(); ++i) {
if (frame_set[i]) {
frame_idx = i;
found = true;
break; } }
if (!found) {
continue;}
const auto& infos = source.sensor_info();
if (frame_idx >= infos.size() || !infos[frame_idx]) {
continue; }
// Compute Cartesian coordinates (XYZ)
auto xyzlut = core::XYZLut(*infos[frame_idx],
/*use_extrinsics=*/true);
auto& frame = *frame_set[frame_idx];
const auto xyz = xyzlut(frame);
// Dewarp the point cloud using the trajectory from SLAM
const auto poses = flatten_pose(frame);
const auto dewarped = core::dewarp<double>(xyz, poses);
return dewarped.template cast<float>();
}
Both snippets iterate over the first batch of frames, rely on SLAM to populate per-column poses
(see Run SLAM on Each Frame), and then call the appropriate dewarp helper to express all XYZ samples
in the global frame. To learn more about core.XYZLut
refer to XYZLut & Destaggering.