Using the API¶
The Ouster SDK provides a programmatic localization API in both Python and C++. The API mirrors the SLAM API in structure, making it straightforward to switch between mapping a new environment and localizing against an existing map.
The API is built around two components:
LocalizationConfig - A class whose instances hold configuration parameters (range gates, voxel size, deskew method, initial pose). Subclasses, such as LIOLocalizationConfig, provide specific configurations for different localization engines.
LocalizationEngine - An interface for implementations that process lidar frames and writes per-column poses to the frames using a user-provided map. This interface also serves as a factory for different localization implementations, e.g. the built-in LIO-based engine.
The engine modifies input LidarFrame objects to include per-column pose information.
Prerequisites¶
Before using the localization API, ensure your development environment is set up and the Ouster SDK is installed.
Follow the installation steps in the Getting Started guide.
Prepare a point cloud map file (PLY or PCD) generated from a prior SLAM run.
For C++ development, build the SDK with
BUILD_MAPPING=ON.
Configuration¶
Start by configuring how the localization engine should run. LocalizationConfig holds the shared parameters (range gates, voxel size, deskew method, initial pose, etc.). Subclasses such as LIOLocalizationConfig provide configuration for the LIO backend; pass the resulting config to LocalizationEngine.create.
In Python, use the LocalizationConfig.create factory
with "lio" to obtain a concrete config instance.
In C++, construct LIOLocalizationConfig directly.
Parameter |
Default |
Description |
|---|---|---|
|
|
Minimum range in meters. Points closer than this are discarded before registration. |
|
|
Maximum range in meters. Points beyond this are discarded before registration. |
|
|
Voxel grid resolution in meters for downsampling. When set to |
|
|
Maximum number of ICP registration iterations per frame update. |
|
Identity (4×4) |
A 4×4 transformation matrix specifying the sensor’s starting pose in the map frame. |
|
|
Motion compensation strategy: |
The following code snippets demonstrate how to create a LocalizationConfig, which the LocalizationEngine.create method needs to instantiate a LocalizationEngine.
Imports
# Other imports
import math
from ouster.sdk import open_source
from ouster.sdk import mapping
from ouster.sdk import core
#include "ouster/core/open_source.h"
#include "ouster/core/pose_conversion.h"
#include "ouster/core/types.h"
#include "ouster/mapping/localization_engine.h"
using namespace ouster::sdk;
Create LocalizationConfig / LIOLocalizationConfig
config = mapping.LocalizationConfig.create("lio")
config.min_range = 0.5
config.max_range = 100.0
config.voxel_size = 1.0
config.deskew_method = "auto"
mapping::LIOLocalizationConfig config;
config.min_range = 0.5;
config.max_range = 100.0;
config.voxel_size = 1.0;
config.deskew_method = "auto";
Open a Data Source and Construct the Engine¶
Open your lidar data source with open_source. This function handles live sensors, PCAP files, and OSF files, returning an iterable source object and associated metadata (SensorInfo).
Use LocalizationEngine.create to instantiate the engine,
passing the SensorInfo list (even for a single sensor), a map (a PLY/PCD file path or an in-memory point cloud),
and an optional LocalizationConfig (defaults to LIOLocalizationConfig{}).
Currently, LocalizationEngine supports only the lio (Lidar Inertial Odometry) implementation, which provides
fast, incremental, CPU-based frame-to-map registration.
The SensorInfo is crucial as it contains calibration data (beam angles, intrinsics) needed for point cloud generation and deskewing.
Note
The map is not part of LocalizationConfig; pass it to
LocalizationEngine.create.
source = open_source(source_file)
engine = mapping.LocalizationEngine.create(
source.sensor_info,
map_file,
config)
auto source = open_source(source_path);
auto engine = mapping::LocalizationEngine::create(
/*sensor_infos=*/source.sensor_info(),
/*map_path=*/map_file,
/*config=*/config);
The create method invokes the constructor for the LIO-based LocalizationEngine implementation, which loads the map file,
indexes its points into the voxel structure, and prepares the engine for frame registration.
In C++ there is also an overload that accepts an in-memory PointCloudXYZf (Eigen N×3 matrix) instead of a file path — useful when the map is already loaded or generated programmatically.
Map File Requirements¶
The localization engine accepts point cloud maps in PLY or PCD format, loaded via read_pointcloud(). The file must contain an N×3 array of XYZ coordinates in float precision.
A typical workflow to produce a map is:
Run SLAM to generate an OSF file with poses:
ouster-cli source {SOURCE} slam save --ts lidar --overwrite {OSF_OUTPUT}.osf
Export the accumulated point cloud as a PLY file:
ouster-cli source {OSF_OUTPUT}.osf save {MAP_OUTPUT}.ply
Refer to the SLAM CLI documentation for detailed instructions on map
generation, including range filtering with the clip command.
Processing Pipeline Detail¶
The core entry point is the update() method on LocalizationEngine. You pass it a
FrameSet (one or more lidar frames from a single rotation cycle).
In Python, it returns the updated frame set; in C++, it modifies the FrameSet in place and returns void.
The engine registers the frame against the static map and writes map-aligned per-column poses into each frame.
The result is a (W, 4, 4) array of SE(3) transforms on each frame — one 4×4 matrix per measurement
column — encoding the sensor’s position and orientation at each column’s timestamp relative to
the map origin.
Each call to update() performs the following steps:
Time correction — inter-sensor clock offsets and timestamp monotonicity are checked and corrected via
ActiveTimeCorrection.Deskewing (motion compensation) — because each column in a lidar frame is captured at a slightly different time, sensor motion introduces distortion. The SDK applies a deskew method (configurable via
deskew_method) to estimate per-column poses that compensate for this motion:"auto"— uses IMU-based deskewing when synchronous IMU data is available (FW 3.2+ withACCEL32_GYRO32_NMEA), otherwise falls back to constant-velocity deskewing."constant_velocity"— assumes uniform motion between poses."imu_deskew"— uses IMU accelerometer and gyroscope data for higher-fidelity correction."none"— disables deskewing.
Point aggregation — valid points from all sensors in the FrameSet are merged into a single frame, filtered by
min_rangeandmax_range.Frame-to-map registration — The aggregated frame is aligned to the static map via ICP. This process determines the rigid transformation that minimizes the distance between live points and their nearest map neighbors.
Pose correction — The ICP correction is applied to every deskewed column pose, writing map-aligned per-column SE(3) transforms back into each
LidarFrame. The poses you read afterupdate()are deskewed and map-aligned—not deskew-only estimates.
Run Localization on Each Frame¶
The core loop reads FrameSet batches from the source and passes each to update().
In Python, update() returns the updated frame set; in C++, it mutates the FrameSet in place.
Each frame’s per-column body_to_world array is filled with SE(3) transforms relative to the map origin.
for frame_set in source:
frame_set = engine.update(frame_set)
for (auto frame_set : source) {
engine->update(frame_set);
Note
update() does not return a success flag. If the frame cannot be processed (e.g., all
timestamps are invalid or no valid points remain after range filtering), the engine logs a
warning and the frame’s poses remain unchanged.
Inspect Localization Output¶
After update(), each LidarFrame carries frame.body_to_world — a (W, 4, 4) array of per-column
SE(3) transforms. You can extract translation and rotation for the last valid column:
for frame in frame_set.valid_frames():
col = frame.get_last_valid_column()
frame_pose = frame.body_to_world[col]
frame_ts = frame.timestamp[col]
t = frame_pose[:3, 3]
rot = frame_pose[:3, :3]
angles = core.matrix_to_euler(rot) # [roll, pitch, yaw] in radians
rad2deg = 180.0 / math.pi
roll = angles[0] * rad2deg
pitch = angles[1] * rad2deg
yaw = angles[2] * rad2deg
print(
f"idx = {frame.frame_id}; ts = {frame_ts}; "
f"XYZ: {t[0]:.2f}, {t[1]:.2f}, {t[2]:.2f} "
f"(R: {roll:.1f}, P: {pitch:.1f}, Y: {yaw:.1f})"
)
for (const auto& frame : frame_set.valid_frames()) {
auto col = frame->get_last_valid_column();
auto frame_pose = frame->get_column_pose(col);
auto frame_ts = frame->timestamp()[col];
Eigen::Vector3d t = frame_pose.block<3, 1>(0, 3);
Eigen::Matrix3d rot = frame_pose.block<3, 3>(0, 0);
auto angles = core::matrix_to_euler(rot); // [roll, pitch, yaw] in radians
constexpr double rad2deg = 180.0 / M_PI;
auto roll = angles(0) * rad2deg;
auto pitch = angles(1) * rad2deg;
auto yaw = angles(2) * rad2deg;
std::cout << std::fixed
<< "idx = " << frame->frame_id << "; ts = " << frame_ts << "; "
<< "XYZ: " << std::setprecision(2) << t(0) << ", " << t(1) << ", " << t(2) << " "
<< "(R: " << std::setprecision(1) << roll << ", P: " << pitch << ", Y: " << yaw << ")"
<< std::endl;
}
Full Processing Loop¶
Combining all of the above, the typical localization workflow looks like this:
# [doc-stag-localization-engine]
source = open_source(source_file)
engine = mapping.LocalizationEngine.create(
source.sensor_info,
map_file,
config)
# [doc-etag-localization-engine]
# [doc-stag-localization-loop-update]
for frame_set in source:
frame_set = engine.update(frame_set)
# [doc-etag-localization-loop-update]
# [doc-stag-localization-loop-printpose]
for frame in frame_set.valid_frames():
col = frame.get_last_valid_column()
frame_pose = frame.body_to_world[col]
frame_ts = frame.timestamp[col]
t = frame_pose[:3, 3]
rot = frame_pose[:3, :3]
angles = core.matrix_to_euler(rot) # [roll, pitch, yaw] in radians
rad2deg = 180.0 / math.pi
roll = angles[0] * rad2deg
pitch = angles[1] * rad2deg
yaw = angles[2] * rad2deg
print(
f"idx = {frame.frame_id}; ts = {frame_ts}; "
f"XYZ: {t[0]:.2f}, {t[1]:.2f}, {t[2]:.2f} "
f"(R: {roll:.1f}, P: {pitch:.1f}, Y: {yaw:.1f})"
)
# [doc-etag-localization-loop-printpose]
//! [doc-stag-localization-engine]
auto source = open_source(source_path);
auto engine = mapping::LocalizationEngine::create(
/*sensor_infos=*/source.sensor_info(),
/*map_path=*/map_file,
/*config=*/config);
//! [doc-etag-localization-engine]
//! [doc-stag-localization-loop-update]
for (auto frame_set : source) {
engine->update(frame_set);
//! [doc-etag-localization-loop-update]
//! [doc-stag-localization-loop-printpose]
for (const auto& frame : frame_set.valid_frames()) {
auto col = frame->get_last_valid_column();
auto frame_pose = frame->get_column_pose(col);
auto frame_ts = frame->timestamp()[col];
Eigen::Vector3d t = frame_pose.block<3, 1>(0, 3);
Eigen::Matrix3d rot = frame_pose.block<3, 3>(0, 0);
auto angles = core::matrix_to_euler(rot); // [roll, pitch, yaw] in radians
constexpr double rad2deg = 180.0 / M_PI;
auto roll = angles(0) * rad2deg;
auto pitch = angles(1) * rad2deg;
auto yaw = angles(2) * rad2deg;
std::cout << std::fixed
<< "idx = " << frame->frame_id << "; ts = " << frame_ts << "; "
<< "XYZ: " << std::setprecision(2) << t(0) << ", " << t(1) << ", " << t(2) << " "
<< "(R: " << std::setprecision(1) << roll << ", P: " << pitch << ", Y: " << yaw << ")"
<< std::endl;
}
//! [doc-etag-localization-loop-printpose]
}
Advanced Topics¶
Multi-Sensor Localization¶
The LocalizationEngine accepts a list of SensorInfo objects and processes a
FrameSet containing frames from multiple sensors. Points from all sensors are aggregated
into a single frame before registration, providing a wider field of view and improving robustness
in feature-poor environments (e.g., long hallways with few geometric features).
For best results with multi-sensor setups:
Use hardware time synchronization (PTP 1588 or GPS/PPS). The SDK includes software-based
ActiveTimeCorrectionas a fallback, but hardware sync yields better accuracy.Provide accurate extrinsics — each sensor’s 4×4 extrinsic matrix must be correctly set so that points from different sensors are fused in a consistent coordinate frame.
GNSS/GPS Initialization¶
Starting at the map’s origin is not always practical, especially on large outdoor sites. If a
rough GPS fix is available you can compute an initial_pose matrix before constructing the
engine:
Convert the GPS coordinate (latitude, longitude) to a local Cartesian frame such as UTM (Universal Transverse Mercator — a projection that converts degrees of latitude/longitude into flat X/Y coordinates in meters).
Compute the offset between the current UTM position and the map origin (which was established during the original SLAM run).
Set the resulting translation on
LocalizationConfig.initial_pose.
import numpy as np
# gps_to_utm() is a placeholder — use pyproj, utm, or a similar library.
utm_x, utm_y = gps_to_utm(latitude, longitude) # type: ignore[name-defined]
# The UTM coordinate where the SLAM map's origin (0, 0, 0) was recorded.
# You must know (or log) this value when building the map.
map_origin_utm = (500230.0, 4182030.0)
# Build a 4×4 identity matrix and set the X/Y translation to the
# difference between the current position and the map origin.
config = mapping.LocalizationConfig.create("lio")
config.initial_pose = np.eye(4)
config.initial_pose[0, 3] = utm_x - map_origin_utm[0] # delta-X in metres (east)
config.initial_pose[1, 3] = utm_y - map_origin_utm[1] # delta-Y in metres (north)
For CLI usage of the initial pose flag see Setting the Initial Pose in Using the CLI.
Dynamic Filtering¶
When a sensor is mounted close to the robot chassis, the lidar consistently sees the robot’s own body. These static, close-range points can confuse the frame-to-map registration and cause drift.
Increase min_range so that returns from the robot’s frame are excluded before they reach the
engine:
config = mapping.LocalizationConfig.create("lio")
# Exclude returns closer than 1 m (e.g. the robot's own chassis).
config.min_range = 1.0
# Exclude returns beyond 80 m to avoid noisy long-range matches.
config.max_range = 80.0
Similarly, setting max_range to a sensible value for your environment avoids matching against
noisy long-range returns.