XYZLut & Destaggering¶
Once you have acquired a LidarFrame object, from a FrameSetSource as shown in the consumption section, you can perform numerous processing tasks.
As a reminder, the LidarFrame class batches lidar packets by full rotations into accessible fields of the appropriate type.
The LidarFrame object holds data in 2D fields (like RANGE, SIGNAL, REFLECTIVITY), and the SDK provides utilities to:
Project 2D fields into 3D point clouds.
Re-format 2D data for visualization and computer vision tasks.
Filter, mask, and modify frame data.
Sample recordings are available from the datasets listed at sample-data-download.
Projecting to 3D (Point Clouds)¶
The most common processing task is converting the 2D RANGE field into a 3D point cloud (XYZ points). The SDK performs this efficiently using a pre-computed Look-Up Table (LUT).
Common Setup
# Other imports
# Other imports
# Other imports
from ouster.sdk import core
#include "ouster/core/lidar_frame.h"
#include "ouster/core/typedefs.h"
#include "ouster/core/xyzlut.h"
using namespace ouster::sdk;
To convert 2D lidar data into a 3D point cloud,
you first create a reusable lookup table (
XYZLut) from your sensor’s metadata. This table stores the 3D direction for every pixel. Then this table is applied to your 2D range image using the cartesian function, which efficiently calculates the final 3D (x, y, z) coordinates for every point.
The process involves two steps:
Build the lookup once: run XYZLut with your SensorInfo; it precomputes the per-pixel direction vectors.
Reuse it for every frame: feed the LUT and a
RANGEfield into cartesian (C++) or call the Python LUT object; it multiplies each range value by its direction vector and returns the XYZ coordinates (Eigen::Matrix in C++, (H, W, 3) array in Python).
For guidance on loading SensorInfo metadata when replaying a PCAP file, see the Obtaining Sensor Info.
Once you have the sensor info, you are ready to create the XYZ look up table:
# source = sensor.SensorFrameSetSource(sensor_hostname)
# sensor_info = source.sensor_info[0]
# frame = next(iter(source))[0]
xyzlut = core.XYZLut(sensor_info)
range = frame.field(core.ChanField.RANGE)
cloud = xyzlut(range)
// sensor::SensorFrameSetSource source(sensor_hostname);
// auto sensor_info = source.sensor_info()[0];
// auto frame = LidarFrame(sensor_info);
auto xyzlut = core::XYZLut(info, /*use_extrinsics=*/ false);
auto range = frame.field(core::ChanField::RANGE);
auto cloud = xyzlut(range);
Now, you have your x, y, and z values, you can plot them easily to get something like:
Point cloud from OS1 sample data (frame 84). Points colored by SIGNAL value.¶
You can find the complete code by clicking on the github icon on the code snippet or on our GitHub repository .
For a fast way to use a sensor to run XYZLut and visualize a similar image, refer to the pre-built Python example included in the SDK.
Follow the instructions in the Python LidarFrame examples section.
Using Sensor Extrinsics in the LUT¶
use_extrinsics=True tells XYZLut to apply sensor_info.sensor_to_body with sensor_info.lidar_to_sensor_transform
and use that combined 4×4 transform when building the lookup table (i.e., the LUT outputs points in the extrinsics frame rather than the sensor frame).
The sensor_info.sensor_to_body matrix still comes straight from the sensor metadata and defaults to the identity matrix if the JSON omits it.
lut_with_extrinsics = core.XYZLut(sensor_info, use_extrinsics=True)
return lut_with_extrinsics(frame)
auto xyzlut = core::XYZLut(info, /*use_extrinsics=*/true);
auto cloud_in_extrinsics_frame = xyzlut(frame);
Applying External Transforms to the LUT¶
Users may find that they wish to apply an extra transform while projecting to Cartesian coordinates. Such a transform, likely an extrinsics matrix of some sort, can be baked directly into XYZLut constructor, which internally uses the make_xyz_lut function. This is more efficient than transforming every point cloud after projection.
In the following code, transform represents the external transformation matrix:
# Custom pose: flip Z/Y and translate up 20 m and 1.5 m along X.
transform = np.eye(4, dtype=np.float64)
transform[2, 2] = -1.0
transform[1, 1] = -1.0
transform[2, 3] = 20000.0 # millimetres (20 m)
transform[0, 3] = 1500.0 # millimetres (1.5 m)
# Compose it with the sensor’s existing lidar→sensor transform
adjusted = deepcopy(sensor_info)
adjusted.lidar_to_sensor_transform = transform @ sensor_info.lidar_to_sensor_transform
# Build the LUT from the modified metadata and project the frame.
xyzlut = core.XYZLut(adjusted, use_extrinsics=False)
cloud_adjusted = xyzlut(frame)
return cloud_adjusted
// Custom pose: flip Z/Y and translate up 20 m and 1.5 m along X.
core::mat4d transform = core::mat4d::Identity();
transform(2, 2) = -1.0;
transform(1, 1) = -1.0;
transform(2, 3) = 20000.0; // millimetres
transform(0, 3) = 1500.0; // millimetres
// Compose with the existing lidar_to_sensor transform.
core::SensorInfo adjusted = info;
core::mat4d combined = transform * info.lidar_to_sensor_transform;
// Override the transform before building the LUT.
adjusted.lidar_to_sensor_transform = combined;
core::XYZLut lut_adjusted(adjusted, /*use_extrinsics=*/false);
auto cloud_adjusted = lut_adjusted(frame);
return cloud_adjusted;
Using the use_extrinsics=False returns the sensor-frame directions/offsets with no extra transform.
Hence, to apply external transform, we retrieve the SensorInfo, multiply in their custom 4×4 matrix, and then build the LUT—so the LUT bakes in the pose we supplied instead of applying it afterward.
NOTE The SDK also provides a :py:class::ouster.sdk.core.XYZLutFloat in Python, which can be used when one needs to save memory or work in float32.
2D Data Layout: Staggered vs. Destaggered¶
The default representation of LidarFrame stores data in staggered columns, meaning
that each column contains measurements taken at a single timestamp. As the lasers flashing at each
timestamp are arranged over several different azimuths, the resulting 2D image if directly
visualized is not a natural image.
Let’s take a look at a typical staggered representation:
LidarFrame RANGE field visualized with matplotlib.pyplot.imshow() and simple gray
color mapping for better look.¶
To re-format the data into a natural 2D image representation, you must destagger it.
We destagger the relevant field of the LidarFrame with
destagger (destagger(), core.destagger() function):
Destaggering re-orders the columns to correspond to their azimuth angle instead of timestamps. This is essential for 2D visualization and many 2D computer vision algorithms.
The API accepts an optional inverse flag - set it to true when you need to
undo a previous destagger by reapplying the per-row pixel shifts and return data to the original staggered layout.
"""Get staggered and destaggered reflectivity images."""
reflectivity = frame.field(core.ChanField.REFLECTIVITY)
reflectivity_destaggered = core.destagger(sensor_info, reflectivity)
std::cout << "\n3. Getting staggered and destaggered images of Reflectivity..." << std::endl;
auto reflectivity = frame.field(core::ChanField::REFLECTIVITY);
auto reflectivity_destaggered = core::destagger(info, reflectivity, false);
After destaggering, the 2D image makes visual sense.
Python: Plot destaggered reflectivity image
To generate staggered and destaggered images yourself, you can try the following sample code:
import sys
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
from more_itertools import nth
# [doc-stag-core-frames]
from ouster.sdk import core, pcap
if len(sys.argv) < 2:
raise SystemExit("Usage: python viz.py <pcap_path>")
pcap_path = str(Path(sys.argv[1]).expanduser())
# open a FrameSetSource directly from the PCAP/metadata pair
source = pcap.PcapFrameSetSource(pcap_path)
metadata = source.sensor_info[0]
# pull the 84th frame set (adjust the index for your dataset)
frame_set = nth(source, 84)
if frame_set is None:
raise RuntimeError("Expected at least 85 frame sets in source")
# single-sensor PCAP; take the first LidarFrame
frame = frame_set[0]
if frame is None:
raise RuntimeError("Expected a LidarFrame at sensor index 0")
# reflectivity in the default staggered layout
reflectivity = frame.field(core.ChanField.REFLECTIVITY).astype(np.uint32)
# destagger using the metadata intrinsics
reflectivity_destaggered = core.destagger(metadata, reflectivity)
# [doc-etag-core-frames]
plt.imshow(reflectivity_destaggered, cmap='gray', resample=False)
plt.show()
The above code gives the scene below (see the long strip at the bottom). We’ve magnified two patches for better visibility atop.
destaggered LidarFrame RANGE field¶
After destaggering, we can see the scene contains a man on a bicycle, a few cars, and many trees. This image now makes visual sense, and we can easily use this data in common visual task pipelines.
Reshaping XYZ to 2D¶
Users may find that they wish to access the x, y, and z coordinates of a single return
in a similar way. As the conversion to Cartesian coordinates returns an Eigen::Array n x 3,
with n = w * h, reshaping the resulting array is necessary.
We can combine our knowledge in projecting into Cartesian coordinates and destaggering using the following function:
def get_x_in_image_form(frame: core.LidarFrame,
sensor_info: core.SensorInfo,
destaggered: bool = False):
"""Return the destaggered X coordinate image."""
h = sensor_info.format.pixels_per_column
w = sensor_info.format.columns_per_frame
# Get the XYZ in (H, W, 3) numpy array form
xyzlut = core.XYZLut(sensor_info, use_extrinsics=False)
xyz = xyzlut(frame)
# Extract X coordinate (equivalent to cloud.col(0) in C++)
x_image = xyz[:, :, 0].reshape(h, w)
# Apply destagger if desired
if not destaggered:
return x_image
x_destaggered = core.destagger(sensor_info, x_image)
return x_destaggered
core::img_t<double> get_x_in_image_form(const core::LidarFrame& frame,
const core::SensorInfo& info,
bool destaggered = false) {
// For convenience, save w and h to variables
const size_t w = info.format.columns_per_frame;
const size_t h = info.format.pixels_per_column;
// Get the XYZ in ouster::Points (n x 3 Eigen array) form
core::XYZLut xyzlut(info, /*use_extrinsics=*/ false);
auto cloud = xyzlut(frame);
// Access x and reshape as needed, not that the values in cloud.col(0) are ordered
auto x_image = Eigen::Map<const core::img_t<double>>(cloud.col(0).data(), h, w);
// Apply destagger if desired
if (!destaggered) {
return x_image;
}
auto x_destaggered = core::destagger<double>(x_image, info.format.pixel_shift_by_row);
return x_destaggered;
}
This demonstrates the functionality with x, but it can be easily expanded to cover y and
z as well.
Correlating 2D and 3D Data¶
The direct correlation between 2D and 3D representations in an Ouster sensor provides a powerful framework for working with the data.
Destaggering allows you to use 2D and 3D representations simultaneously. After destaggering, the pixel at (row, col) in a 2D field (like RANGE) corresponds to the 3D point at (row, col) in a destaggered XYZ point cloud.
This allows you to perform 2D operations (like image-based filtering) and apply the results directly to your 3D data.
The example below filters a point cloud based on both RANGE (distance) and azimuth angle (a 2D column index).
Setup
azimuth_fraction = 0.75
# destagger RANGE so each column maps to a fixed azimuth.
range_field = frame.field(core.ChanField.RANGE)
range_destaggered = core.destagger(info,
range_field)
# obtain destaggered xyz representation
xyzlut = core.XYZLut(info, use_extrinsics=True)
cloud = xyzlut(frame)
// azimuth_fraction = 0.75
// destagger RANGE so each column maps to a fixed azimuth.
auto range_field = frame.field<uint32_t>(core::ChanField::RANGE);
auto& shifts = info.format.pixel_shift_by_row;
auto range_destaggered = core::destagger<uint32_t>(range_field, shifts);
// obtain destaggered xyz representation
auto xyzlut = core::XYZLut(info, true);
auto cloud = xyzlut(frame);
3D Destagger
# cloud shape is (H, W, 3)
# SDK python wrapper applies the destaggering
# logic to each channel
xyz_destaggered = core.destagger(info, cloud)
// cloud shape is (H*W, 3)
// We need to destagger each channel separately
auto x_destaggered = destagger_channel(cloud, info, 0);
auto y_destaggered = destagger_channel(cloud, info, 1);
auto z_destaggered = destagger_channel(cloud, info, 2);
Mask
min_range_mm = range_min * 1000.0 # corrected variable name
# 1. Create mask: True if range > min, else False
# NumPy arrays support element-wise comparison
mask = (range_destaggered[:, :, np.newaxis] > min_range_mm)
# 2. Apply mask via multiplication
# Element-wise multiplication zeros out invalid points
xyz_filtered = xyz_destaggered * mask
auto min_range_mm = range_min_m * 1000.0;
// 1. Create mask: 1.0 if range > min, else 0.0
auto mask =
(range_destaggered.cast<double>().array() > min_range_mm).cast<double>();
// 2. Apply mask: Invalid points become (0, 0, 0)
// Coefficient-wise multiplication zeros out invalid points
auto x_masked = x_destaggered * mask;
auto y_masked = y_destaggered * mask;
auto z_masked = z_destaggered * mask;
Filter
# 3. Slicing: Limit to the first azimuth_fraction
col_limit = math.floor(info.format.columns_per_frame * azimuth_fraction)
# Standard NumPy slicing [row, col, channel]
xyz_filtered = xyz_filtered[:, 0:col_limit, :]
// 3. Slicing: Limit to the first azimuth_fraction(front 3/4 by default).
auto col_limit = static_cast<size_t>(w * azimuth_fraction);
// .leftCols() is the Eigen equivalent to NumPy's [:, :col_limit]
auto x_filtered = x_masked.leftCols(col_limit);
auto y_filtered = y_masked.leftCols(col_limit);
auto z_filtered = z_masked.leftCols(col_limit);
Since we’d like to filter on azimuth angles, first we first destagger both the 2D and 3D points, so that our columns in the HxW representation correspond to azimuth angle, not timestamp.
Then we filter the 3D points xyz_destaggered by comparing the range measurement to range_min, which we can do because there is a 1:1 correspondence between the columns and rows of the destaggered representations of xyz_destaggered and range_destaggered. (Similarly, there would be a 1:1 correspondence between the staggered representations xyz and range, where the columns correspond with timestamp).
Finally, we select only the azimuth columns we’re interested in. In this case, we’ve arbitrarily chosen azimuth_fraction = 0.75, which keeps the first 75% of the frame (~270° of rotation).
For a fast way to use a sensor to run the above examples and visualize the output, refer to the pre-built Python example included in the SDK. Follow the instructions in the Python LidarFrame examples section.
Full code
def filter_3d_by_range_and_azimuth(hostname: str,
lidar_port: int = 7502,
range_min: int = 2) -> None:
"""Easily filter 3D Point Cloud by Range and Azimuth Using the 2D Representation
Args:
hostname: hostname of sensor
lidar_port: UDP port to listen on for lidar data
range_min: range minimum in meters
"""
try:
import matplotlib.pyplot as plt # type: ignore
except ModuleNotFoundError:
print("This example requires matplotlib and an appropriate Matplotlib "
"GUI backend such as TkAgg or Qt5Agg.")
exit(1)
import math
# set up figure
plt.figure()
ax = plt.axes(projection='3d')
r = 3
ax.set_xlim3d([-r, r]) # type: ignore
ax.set_ylim3d([-r, r]) # type: ignore
ax.set_zlim3d([-r, r]) # type: ignore
plt.title("Filtered 3D Points from {}".format(hostname))
source = sensor.SensorFrameSetSource(hostname, lidar_port=lidar_port)
info = source.sensor_info[0]
frame = next(iter(source))[0]
assert frame is not None
source.close()
# [doc-stag-filter-3d-setup]
azimuth_fraction = 0.75
# destagger RANGE so each column maps to a fixed azimuth.
range_field = frame.field(core.ChanField.RANGE)
range_destaggered = core.destagger(info,
range_field)
# obtain destaggered xyz representation
xyzlut = core.XYZLut(info, use_extrinsics=True)
cloud = xyzlut(frame)
# [doc-etag-filter-3d-setup]
# [doc-stag-filter-3d-destagger]
# cloud shape is (H, W, 3)
# SDK python wrapper applies the destaggering
# logic to each channel
xyz_destaggered = core.destagger(info, cloud)
# [doc-etag-filter-3d-destagger]
# [doc-stag-filter-3d-mask]
min_range_mm = range_min * 1000.0 # corrected variable name
# 1. Create mask: True if range > min, else False
# NumPy arrays support element-wise comparison
mask = (range_destaggered[:, :, np.newaxis] > min_range_mm)
# 2. Apply mask via multiplication
# Element-wise multiplication zeros out invalid points
xyz_filtered = xyz_destaggered * mask
# [doc-etag-filter-3d-mask]
# [doc-stag-filter-3d]
# 3. Slicing: Limit to the first azimuth_fraction
col_limit = math.floor(info.format.columns_per_frame * azimuth_fraction)
# Standard NumPy slicing [row, col, channel]
xyz_filtered = xyz_filtered[:, 0:col_limit, :]
# [doc-etag-filter-3d]
[x, y, z] = [c.flatten() for c in np.dsplit(xyz_filtered, 3)]
ax.scatter(x, y, z, c=z / max(z), s=0.2) # type: ignore
plt.show()
core::PointCloudXYZd filter_points_by_range_and_azimuth(
const core::SensorInfo& info, const core::LidarFrame& frame,
double range_min_m, double azimuth_fraction = 0.75) {
//! [doc-stag-filter-3d-setup]
// azimuth_fraction = 0.75
// destagger RANGE so each column maps to a fixed azimuth.
auto range_field = frame.field<uint32_t>(core::ChanField::RANGE);
auto& shifts = info.format.pixel_shift_by_row;
auto range_destaggered = core::destagger<uint32_t>(range_field, shifts);
// obtain destaggered xyz representation
auto xyzlut = core::XYZLut(info, true);
auto cloud = xyzlut(frame);
//! [doc-etag-filter-3d-setup]
//! [doc-stag-filter-3d-destagger]
// cloud shape is (H*W, 3)
// We need to destagger each channel separately
auto x_destaggered = destagger_channel(cloud, info, 0);
auto y_destaggered = destagger_channel(cloud, info, 1);
auto z_destaggered = destagger_channel(cloud, info, 2);
//! [doc-etag-filter-3d-destagger]
const auto w = static_cast<size_t>(info.format.columns_per_frame);
//! [doc-stag-filter-3d-mask]
auto min_range_mm = range_min_m * 1000.0;
// 1. Create mask: 1.0 if range > min, else 0.0
auto mask =
(range_destaggered.cast<double>().array() > min_range_mm).cast<double>();
// 2. Apply mask: Invalid points become (0, 0, 0)
// Coefficient-wise multiplication zeros out invalid points
auto x_masked = x_destaggered * mask;
auto y_masked = y_destaggered * mask;
auto z_masked = z_destaggered * mask;
//! [doc-etag-filter-3d-mask]
//! [doc-stag-filter-3d]
// 3. Slicing: Limit to the first azimuth_fraction(front 3/4 by default).
auto col_limit = static_cast<size_t>(w * azimuth_fraction);
// .leftCols() is the Eigen equivalent to NumPy's [:, :col_limit]
auto x_filtered = x_masked.leftCols(col_limit);
auto y_filtered = y_masked.leftCols(col_limit);
auto z_filtered = z_masked.leftCols(col_limit);
//! [doc-etag-filter-3d]
// Flatten the 2D arrays to 1D and assign to columns
// We evaluate the block expression to ensure contiguous memory for mapping
core::PointCloudXYZd filtered_points(x_filtered.size(), 3);
filtered_points.col(0) = Eigen::Map<const Eigen::VectorXd>(x_filtered.eval().data(), x_filtered.size());
filtered_points.col(1) = Eigen::Map<const Eigen::VectorXd>(y_filtered.eval().data(), y_filtered.size());
filtered_points.col(2) = Eigen::Map<const Eigen::VectorXd>(z_filtered.eval().data(), z_filtered.size());
return filtered_points;
}
Populating LidarFrames¶
This reference has covered how to access, project, and destagger LidarFrame
contents. But in order for LidarFrames to be useful, we need a way to populate them with packet
data!
For convenience, the Ouster Python SDK provides batching helpers for both live and recorded data.
See the data consumption section for more information.
Adding custom fields to a LidarFrame¶
It’s possible to add custom fields to a LidarFrame. This is especially useful if you
want to add custom data to an OSF file for visualization purposes.
You can find more information about this in the documentation Adding custom fields to a LidarFrame.