LidarFrame

The frame sources deliver LidarFrame objects when you iterate them, so your downstream code can operate on these structured frame arrays without dealing with raw packets.

The LidarFrame class aggregates all channel fields (range, reflectivity, signal, near‑IR, etc.) plus metadata (timestamps, frame IDs, status words) for one full rotation into accessible fields of the appropriate type.

LidarFrame also allows for easy projection of the batched data into Cartesian coordinates, producing point clouds.

# Other imports
# Other imports
# Other imports
# Other imports
from ouster.sdk import core
#include "ouster/core/lidar_frame.h"
#include "ouster/core/open_source.h"
#include "ouster/core/types.h"
#include "ouster/pcap/pcap_frame_set_source.h"
#include "ouster/pcap/pcap_packet_source.h"
using namespace ouster::sdk;

LidarFrame Constructors

A LidarFrame contains fields of data specified at its initialization either through a lidar profile or a specific list of fields:

The simplest (and most common) method is to construct one to contain all data coming from your sensor. You can do this by specifying your sensor’s sensor_info as shown in the snippets below:

# source = open_source(pcap_path, meta=[metadata_path])
# sensor_info = source.sensor_info[0]
profile_frame = core.LidarFrame(sensor_info)
auto profile_frame = core::LidarFrame(sensor_info);

But suppose you don’t care about some of the data, such as the ambient and signal fields. You can also specify to your LidarFrame to only batch the relevant fields like so:

reduced_fields = [
    core.FieldType(core.ChanField.RANGE, dtype=int),
    core.FieldType(core.ChanField.NEAR_IR, dtype=int),
]
reduced_frame = core.LidarFrame(sensor_info, reduced_fields)
static const std::vector<core::FieldType> reduced_fields{
    {{core::ChanField::RANGE, core::ChanFieldType::UINT32},
    {core::ChanField::NEAR_IR, core::ChanFieldType::UINT16}}
};
auto reduced_fields_frame = core::LidarFrame(sensor_info, reduced_fields);

Accessing LidarFrame fields

Since each LidarFrame corresponds to a single frame (and is batched accordingly), you can access the frame_id simply with:

frame_id = frame.frame_id
auto frame_id = profile_frame.frame_id;

In addition to frame_id and the fields specified at initialization, a LidarFrame also contains the column header information: timestamp, status, measurement_id. These are aggregated from each measurement block into W-element arrays, which are represented as an Eigen::Array and a numpy.ndarray in C++ and Python respectively. Note that if you set the sensor configuration parameter azimuth_window to something less than the full width, the values in the header outside the azimuth window will be 0’d out accordingly.

timestamp = frame.timestamp
status = frame.status
measurement_id = frame.measurement_id
auto timestamp = profile_frame.timestamp();
auto status = profile_frame.status();
auto measurement_id = profile_frame.measurement_id();

For any field contained by a LidarFrame, you can access that field in the following way:

range_field = frame.field(core.ChanField.RANGE)
reflectivity = frame.field(core.ChanField.REFLECTIVITY) # Surface reflectance values
range2 = frame.field(core.ChanField.RANGE2) # Second return measurements (if available and enabled)
reflectivity2 = frame.field(core.ChanField.REFLECTIVITY2)
near_ir = frame.field(core.ChanField.NEAR_IR) # Near IR measurements
Eigen::Ref<core::img_t<uint32_t>> range = profile_frame.field(core::ChanField::RANGE);
Eigen::Ref<core::img_t<uint8_t>>  reflectivity = profile_frame.field(core::ChanField::REFLECTIVITY); // Surface reflectance values
Eigen::Ref<core::img_t<uint32_t>> range2 = profile_frame.field(core::ChanField::RANGE2); // Second return measurements (if available and enabled)
Eigen::Ref<core::img_t<uint8_t>>  reflectivity2 = profile_frame.field(core::ChanField::REFLECTIVITY2);
Eigen::Ref<core::img_t<uint16_t>> near_ir = profile_frame.field(core::ChanField::NEAR_IR); // Near IR measurements

For a more in-depth overview of accessing fields, read Field documentation.

Finally, the fields of an existing LidarFrame can be found by accessing the fields of the frame through an iterator:

for frame in frame_set:
    if not frame: continue  # noqa: E701
    for field_name in frame.fields:
        field_data = frame.field(field_name)
        print('{0:15} {1}'.format(field_name,
                                  field_data.dtype))
for (const auto& frame : frame_set) {
    if (!frame) { continue; }
    for (const auto& kv : frame->fields()) {
        const auto& field_type = kv.first;
        const auto& field_tag = core::to_string(kv.second.tag());
        std::cout << std::left << std::setw(15) << field_type << " "
                                                << field_tag << std::endl;

Note

The units of a particular field from a LidarFrame are consistent even when you use lidar profiles which scale the returned data from the sensor. This is because LidarFrame will reverse the scaling for you when parsing. For example, the RANGE field on a LidarFrame constructed with the low data rate profile will be in millimeters even though the return from the sensor is given in 8mm increments.

Running the above code on a sample LidarFrame will give you output that looks like:

Available fields and corresponding dtype in LidarFrame
RANGE           uint32
RANGE2          uint32
SIGNAL          uint16
SIGNAL2         uint16
REFLECTIVITY    uint8
REFLECTIVITY2   uint8
NEAR_IR         uint16

Now that we know how to create the LidarFrame and access its contents, let’s see what we can do with it!

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. Like the standard fields normally found in a LidarFrame, custom fields also make use of Eigen::Array and numpy.ndarray in C++ and Python, respectively.

lidar_frame = core.LidarFrame(info, [])
lidar_frame.add_field("my-custom-field", np.uint8, ())
lidar_frame.field("my-custom-field")[:] = 1  # set all pixels
lidar_frame.field("my-custom-field")[10:20, 10:20] = 255  # set block of pixels
auto custom_frame = core::LidarFrame(sensor_info, {});
custom_frame.add_field("my-custom-field", core::fd_array<uint8_t>(sensor_info->h(), sensor_info->w()));
custom_frame.field<uint8_t>("my-custom-field") = 1; // set all pixels
custom_frame.field<uint8_t>("my-custom-field").block(10, 10, 20, 20) = 255;  // set a block of pixels again 

Note

Fields can also be removed using the del_field method. This can be useful for removing unneeded data, thereby saving space when saving frames to an OSF file.

Populating LidarFrames

This reference has covered how to create a LidarFrame, and how to access its contents. But in order for LidarFrames to be useful, we need a way to populate them with packet data! The recommended approach in both Python and C++ is to use a FrameSetSource, which handles both sampling, used in Visualization with Matplotlib, and streaming, used in Streaming Live Data.

Under the hood, a FrameSetSource batches packets into LidarFrames for you. If you already have packets from your own PacketSource and want to batch them yourself, both languages also expose a FrameBatcher class for that purpose. To get a feel for how to use it, we recommend reading this example on GitHub .

FrameBatcher

The SDK’s packet-based FrameSetSource implementations use the FrameBatcher class to produce LidarFrames from UDP packets. This section describes how the packet batching process works, as well as its guarantees and limitations.

Because Ouster sensors transmit data via UDP, the network or host operating system may re-order or drop packets. The FrameBatcher implementation handles these issues to minimize data loss and latency. It does this by keeping track of which packets have been received for each frame, and only producing a LidarFrame when it has received all the necessary packets for that frame. If a packet from a future frame arrives before all packets from the current frame have been received, the batcher will cache packets until it receives all packets for the current frame, or until the cache is full, at which point it will finalize the current frame even though it is incomplete. Fields and headers corresponding to missing packets in the finalized frame will contain zeros.

The cache size is configurable, giving the user a tradeoff between latency and completeness of frames when packets are dropped or arrive out of order.

Guarantees & Limitations

FrameBatcher guarantees

Guarantee

Explanation

LidarFrame frame IDs will increase until roll-over or sensor re-initialization

The batcher will produce frames with an increasing frame ID, allowing for roll-over when using standard packet headers (i.e. non-FUSA,) and sensor re-initialization. Otherwise, the batcher will discard any packet from a prior frame. Sensor re-initialization will set the sensor’s internal frame counter back to 0, so the batcher will produce new frames with frame IDs starting over at 0 when this occurs.

The batcher caches out-of-order packets from a frame with a greater frame ID than the current frame rather than dropping them

The batcher will drop packets from frames it has already finalized, but will cache out-of-order packets from a frame with a greater frame ID until it can produce a complete frame, or until the cache is full. If the cache is full, the new packet will be cached but the batcher will finalize the current frame even though it is incomplete.

The batcher will preserve the contents of custom fields in the provided LidarFrame

If you provide a non-empty LidarFrame to the batch, the batcher will preserve the contents of any custom fields in the frame when producing new frames. This allows you to add custom fields to the frame and have them automatically populated in each produced frame.

FrameBatcher limitations

Limitation

Explanation

Frames provided to/from the batcher are invalid until finalized

The batcher finalizes frames by zeroing portions of headers and fields for which data is missing. Until the FrameBatcher finalizes a frame, (i.e. batch returns true,) using it results in undefined behavior.

The batcher may not finalize incomplete frames

If the user adds no more packets to the batcher, the batcher will not finalize the current frame if it is incomplete.

If the batcher cache fills up with out-of-order packets, it will finalize an incomplete frame

If the batcher receives too many out-of-order packets, it will finalize the current frame even though it is incomplete. The batcher will fill missing fields and headers in the finalized frame with zeros.

Dropped packets will delay the current frame

If the network or host OS loses a packet for the current frame, the batcher will wait to finalize the current frame until its cache has filled. A number of factors dictate how long this takes, such as the sensor’s packet rate, whether IMU and zone monitor are active, and azimuth window.

Out-of-order packets may cause dropped frames

If, having just finished frame N, the batcher receives a packet for frame N+2, it will skip frame N+1.

The batcher does not handle changes in sensor configuration

Adding packets to a FrameBatcher instance after changing the sensor configuration is unsupported and results in undefined behavior.

The batcher may accept packets from different sensors

Adding packets to the same FrameBatcher with different sensors is unsupported and results in undefined behavior.