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

min_range

0.0

Minimum range in meters. Points closer than this are discarded before registration.

max_range

150.0

Maximum range in meters. Points beyond this are discarded before registration.

voxel_size

0.0

Voxel grid resolution in meters for downsampling. When set to 0.0, the engine estimates a suitable value automatically from the first batch of frames (falling back to 1.0 m).

max_iterations

500

Maximum number of ICP registration iterations per frame update.

initial_pose

Identity (4×4)

A 4×4 transformation matrix specifying the sensor’s starting pose in the map frame.

deskew_method

"auto"

Motion compensation strategy: "auto", "constant_velocity", "imu_deskew", or "none".

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:

  1. Run SLAM to generate an OSF file with poses:

    ouster-cli source {SOURCE} slam save --ts lidar --overwrite {OSF_OUTPUT}.osf
    
  2. 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:

  1. Time correction — inter-sensor clock offsets and timestamp monotonicity are checked and corrected via ActiveTimeCorrection.

  2. 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+ with ACCEL32_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.

  3. Point aggregation — valid points from all sensors in the FrameSet are merged into a single frame, filtered by min_range and max_range.

  4. 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.

  5. 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 after update() are deskewed and map-aligned—not deskew-only estimates.

The stages executed on each call to update() in LocalizationEngine.

The stages executed on each call to update().

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 ActiveTimeCorrection as 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:

  1. 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).

  2. Compute the offset between the current UTM position and the map origin (which was established during the original SLAM run).

  3. 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.