Developer’s Quick Start with the Ouster Python SDK
This quickstart guide will walk you through visualizing Ouster sensor data quickly with Python code you write yourself. It assumes that you have followed the steps in Python Installation to install the Ouster Python SDK.
Using this Guide
You’ll want to start an interactive Python session and keep it open through the sections, as we’ll be reusing variables created in earlier parts while explaining what we’re doing as we go.
To get started, open a new console/Powershell window and start a python interpreter:
$ python3
PS > py -3
Throughout this guide we will indicate console commands with $
and python interpreter commands
with >>>
, just as we have above.
If you’d like to start by working with sample data, continue to the next section. If you’d prefer to start by capturing data from a sensor, you can skip to Using an Ouster Sensor below.
Using Sample Data
You can download sample data from the sensor documentation by clicking through the Ouster Data
App links and using the Download button. After download, you should have two files, a .pcap
file
and a .json
file.
We will use SAMPLE_DATA_PCAP_PATH
to refer to this pcap and SAMPLE_DATA_JSON_PATH
to this
json in the following. You may find it convenient to assign the paths appropriately in your
console.
The downloaded pcap file contains lidar and imu packets captured from the network. You can read more about the IMU Data Format and Lidar Data Format in the Ouster Sensor Documentation. The JSON file contains metadata queried from the sensor TCP interface necessary for interpreting the packet data.
In your open python session, save the two paths to variables:
>>> pcap_path = '<SAMPLE_DATA_PCAP_PATH>'
>>> metadata_path = '<SAMPLE_DATA_JSON_PATH>'
Note
Starting with ouster-sdk v0.11.0, most of core sdk objects have been moved from the ouster
namespace into the ouster.sdk
namespace.
Using the ScanSource interface
In this example we are going to demonstrate the use of the ScanSource API.
Using the open_source method
The ScanSource
API uses the method name open_source
which allows users to handle different source types
using the same API. Current supported source types are live sensor, pcap file, or osf file.
For example, opening the sample pcap file can be accomplished as follows:
>>> pcap_path = '<SAMPLE_DATA_PCAP_PATH>'
>>> metadata_path = '<SAMPLE_DATA_JSON_PATH>'
>>> from ouster.sdk import open_source
>>> source = open_source(pcap_path, meta=[metadata_path])
The source
object returned by open_source
provides access to LidarScan
objects,
regardless of whether the source data comes from a sensor, pcap, or osf file.
Notice here that rather than we try to load and parse the metadata ourselves we only need to pass to metadata
to the method through meta
parameter and the method will take care of loading it and associating it with the
source object. The meta
parameter however is optional and can be omitted. When the meta
parameter is not
set explicitly the open_source
method will attempt to locate the metadata automatically for us and we can reduce
the call to:
>>> source = open_source(pcap_path)
However if metadata file is not in the same folder as the pcap and don’t have a shared name prefix the method will fail.
Note
Another optional but important parameter for the open_source
method is sensor_idx
. By default, sensor_idx
is set to
-1
and collation is set to True
. It is assumed that the pcap file that you are using (or osf or any LidarScan
storage) contains scans from more than one sensor. For the case of single sensor, users can set the sensor_idx
to 0
to access and manipulate scans from a single sensor.
Prior to 0.15.0, if users set the value of sensor_idx
to -1
, then open_source
returned a
slightly different interface for single sensor, ScanSource
and multiple sensors, MultiScanSource
.
Starting with version 0.15.0, if collate=True
and sensor_idx < 0
, open_source
yields a list of LidarScan(s)
per iteration corresponding to the number of sensors stored in the pcap file or whatever source type is being used.
This is true even when the pcap file contains data for a single sensor to maintain consistency between scan sources with single and
multiple sensors. Otherwise, it is always a length of 1.
On the other hand, if the user wants to open an OSF file or access a live sensor, all that changes is URL of the source. For example, to interact with a live sensor the user can execute the following snippet:
>>> sensor_url = '<SENSOR-HOSTNAME-OR-IP>'
>>> from ouster.sdk import open_source
>>> source = open_source(sensor_url)
Obtaining sensor metadata
Every ScanSource holds a reference to the sensor metadata, which has crucial information that is important when
when processing the individual scans. Continuing the example, a user this can access the metadata through the
metadata
property of a ScanSource
object:
>>> print(source.metadata)
Iterating over Scans
First, let’s consider the case of a single sensor, we could call open_source
with sensor_idx=0
and/or
collate=False
. The source
object returned is an iterable of List[LidarScan]
objects.
>>> from ouster.sdk import open_source
>>> source = open_source(pcap_path, meta=[metadata_path], sensor_idx=0, collate=False)
We have successfully obtained a handle to the ScanSource we can iterate over LidarScan
objects stored in the
pcap file and manipulate each one individually. For example, let’s say we want to print the frame id of the first 10
scans. We can achieve that using:
>>> ctr = 0
>>> for scan in source.single(0):
... print(scan[0].frame_id)
... ctr += 1
... if ctr == 10:
... break
As we noted earlier, the sensor_idx=-1
is by default when invoking open_source
method, the method will construct a
ScanSource
, which always addresses a group of sensors. Thus, when iterating over the source
the user
receives a collated set of scans from the addressed sensors per iteration. The ScanSource
examines the
timestamp of every scan from every sensor and returns a list of scans that fit within the same time window as single
batch. The size of the batch is fixed corresponding to how many sensors contained in the pcap or OSF file. However,
the collation could yield a null value if one or more of the sensors didn’t produce a LidarScan
object that fits
within the time frame of current batch or iteration. Thus, depending on the operation at hand it is critical to check
if we got a valid LidarScan
object when examining the iteration output of a ScanSource
. If we are to
perform the same example as above when source
is a handle to ScanSource
and display the frame_id of
LidarScan
objects the belongs to the same batch on the same line the code needs to updated to the following:
>>> ctr = 0
>>> source_iter = iter(source)
>>> for scans in source_iter:
... for scan in scans: # source_iter here returns a list of scans
... if scan: # check if individual scan object is valid
... print(scan.frame_id, end=', ')
... print() # new line for next batch
... ctr += 1
... if ctr == 10:
... break
Note that , from 0.15.0, when iterating over a ScanSource
object, it always a list of scans, even when the underlying scan
source has only a single sensor. In this case, the iterator will yield a list with a single element per iteration.
Using indexing and slicing capabilities of a ScanSource
One of the most prominent features of the ScanSource API, (besides being able to address multi sensors), is the
ability to use indexing and slicing when accessing the stored scans within the LidarScan
source. Currently, this
capability is only supported for indexable sources. That is to say, the functionality we are discussing can only be used
when accessing a pcap or an OSF file with indexing turned on. To turn on indexing simply add the index
flag and set
it True
when opening a pcap or OSF file:
>>> pcap_path = '<SAMPLE_DATA_PCAP_PATH>'
>>> from ouster.sdk import open_source
>>> source = open_source(pcap_path, index=True)
Note
We omitted the meta
parameter since it can be populated automatically as we explained earlier.
Depending on the file size and the underlying file format there can be some delay before the file is fully indexed (OSF file take much less time than pcap file to index). A progress bar will appear to indicate progress of the indexing.
Once the index is built up, then we can start using utilizing and interact with the ScanSource
object to access scans
in the same manner we are dealing with a python list that holds reference to LidarScan objects.
To access the 10th LidarScan and print its frame id, we can do the following:
>>> print(source[10][0].frame_id)
Similarly we can access the last LidarScan object and print its frame_id using:
>>> print(source[-1][0].frame_id)
Alternatively we can instead request a range of scans using the python slice operator. For example, to request the first 10 scans from a ScanSource and print their frame ids, we can do the following:
>>> for scan, in source[0:10]:
... print(scan.frame_id)
Note we don’t need to add any break here since the operation source[0:10] will only yield the first 10 LidarScan(s)
.
To print frame_id of the last 10 LidarScans we do:
>>> for scan, in source[-11:-1]:
... print(scan.frame_id)
Finally, as you would expect from a typical slice operation you can also use a step value, though reversed iteration is not supported.
>>> for scan, in source[0:10:2]: # prints the frame_id of every second scan of the first 10 scans
... print(scan.frame_id)
>>> for scan, in source[10:0:-1]: # unsupported
... print(scan.frame_id)
Slicing operator as a ScanSource
Starting with ouster-sdk 0.12.0 the ScanSource slice operator [::]
returns a ScanSource
scoped to the indicated slice range. This means that the users can pass the object returned by
the slice operator [::]
to any function or code that expects a ScanSource
object.
However, from ouster-sdk 0.15.0, open_source
always returns a list of LidarScan
objects per sensor.
The following snippet shows few examples to demonstrate this capability:
>>> # ... continuing with the `source` object from the previous example
>>> source2 = source[5:10]
>>> print("source2 length:", len(source2)) # This should print 5 since source2 is scoped to the range [5, 10)
>>> print(source2[0][0].frame_id) # This is equivalent to calling `print(source[5][0].frame_id)`
>>> print(source2[4][0].frame_id) # This is equivalent to calling `print(source[9][0].frame_id)`
>>> # invoking source2[10][0].frame_id would result in an `out of range exception`` since source2 is scoped to 5 frames
>>> # invoking source2[4].frame_id would result in an `AttributeError`` since source2 is a list of LidarScan objects
>>> source_iter = iter(source2) # Use `source2` as an iterator similar to the main `source`
>>> for scan, in source_iter:
... print(scan.frame_id)
>>> # it is possible to sub slice, meaning take the result of a previous slice operation and slice it
>>> # Thus, the following statement is valid
>>> source3 = source2[2:4] # this would yield the same frames as `source3 = source[7:9]`
>>> print("source3 length:", len(source3)) # Should print 2
Additional ScanSource operators
besides the indexing and slicing capabilities, the ScanSource
API also supports the following operators:
clip(fields, lower, upper)
: generates aScanSource
that limits the range of values of scan fields to [lower, upper].
reduce(beams)
: returns a downsampled version of the mainScanSource
(vertical resolution only as set by the beams).
mask(fields, mask_image)
: creates aScanSource
that has the pixels of specified fields masked by the mask_image.
All of these operations can be invoked directly through the ScanSource
object and yield another ScanSource
allowing
the user to cascade these operations. For example, one could execute the following command chain in one line:
source.clip(["RANGE"], 0, 1000)[0:10].reduce(16)
which essentially means to clip any RANGE
value that exceed 1000
taking a subset of 10 frames from the main dataset and have the vertical resolution of the scans reduced to 16 beams.
Using the client API
The client API provides PacketMultiSource
implementations, as well as access to methods for configuring a sensor or reading metadata.
As such, you can use it instead of the ScanSource
API if you prefer to work with individual packets rather than lidar scans.
Reading packets from a pcap file
Because our sample pcap file contains the UDP packet stream but not the sensor metadata, we load the sensor
information from metadata_path
first, using the client module:
Note
Prior to version 0.15.0, SensorInfo
was part of the ouster.sdk.client
namespace.
This namespace is now deprecated and will be removed in version 0.16.0
.
Please update your code to use ouster.sdk.core
instead.
>>> from ouster.sdk import core
>>> with open(metadata_path, 'r') as f:
... info = core.SensorInfo(f.read())
Now that we’ve parsed the metadata file into a SensorInfo
, we can use it to read our
captured UDP data by instantiating pcap.PcapPacketSource
(previously PcapMultiPacketReader).
This class acts as a PacketSource
( previously .PacketMultiSource) and can be used in many
of the same contexts as a real sensor.
>>> from ouster.sdk import pcap
>>> source = pcap.PcapPacketSource(pcap_path, sensor_info=[info])
>>> for sensor_idx, packet in source:
>>> ...
The source
object returned is an Iterable
of tuples each containing the sensor index and a packet that
originates from that sensor. (The index corresponds to the positions of the SensorInfo
instances provided to the
metadatas
keyword argument.)
To visualize data from this pcap file, proceed to Visualizations in 3D examples.
Using an Ouster Sensor
If you have access to sensor hardware, you can start reading data by
instantiating a PacketSource
that listens for a UDP data stream on
a local socket.
Note
Connecting to an Ouster sensor is covered in the Networking Guide section of the Ouster Sensor Documentation.
In the following, <SENSOR_HOSTNAME>
should be substituted for the actual hostname or IP of your
sensor.
To make sure everything is connected, open a separate console window and try pinging the sensor. You should see some output like:
$ ping -c1 <SENSOR_HOSTNAME>
PING <SENSOR_HOSTNAME> (192.0.2.42) 56(84) bytes of data.
64 bytes from <SENSOR_HOSTNAME> (192.0.2.42): icmp_seq=1 ttl=64 time=0.217 ms
PS > ping /n 10 <SENSOR_HOSTNAME>
Pinging <SENSOR_HOSTNAME> (192.0.2.42) with 32 bytes of data:
Reply from 192.0.2.42: bytes=32 time=101ms TTL=124
Next, you’ll need to configure the sensor with the config parameters using the client module.
In your open python session, set hostname
as <SENSOR_HOSTNAME>
:
>>> hostname = '<SENSOR_HOSTNAME>'
Now configure the client:
Note
Prior to version 0.15.0, SensorConfig
was part of the ouster.sdk.client
namespace.
This namespace is now deprecated and will be removed in version 0.16.0.
Please use ouster.sdk.core
for SensorConfig and ouster.sdk.sensor
for set_config.
>>> from ouster.sdk import core
>>> from ouster.sdk import sensor
>>> config = core.SensorConfig()
>>> config.udp_profile_lidar = core.UDPProfileLidar.PROFILE_LIDAR_RNG19_RFL8_SIG16_NIR16_DUAL
>>> config.udp_port_lidar = 7502
>>> config.udp_port_imu = 7503
>>> config.operating_mode = core.OperatingMode.OPERATING_NORMAL
>>> sensor.set_config(hostname, config, persist=True, udp_dest_auto = True)
When using a SensorPacketSource
, provide a list of hostnames and optionally, list of SensorConfig
:
>>> source = sensor.SensorPacketSource([hostname], sensor_config=[config])
>>> info = source.metadata
Now we have a source
from our sensor! SensorPacketSource
supports single and multiple sensors, one would
read it as an Iterable[Tuple[int, Union[LidarPacket, ImuPacket]]]
of each containing the sensor index and a packet.
>>> for packets in source:
>>> ... # a list of packets, one per sensor
At this point, you’re ready visualize to visualize data from your sensor. Proceed to
Visualizations in 3D for examples. Or you can check out other things you can do with a
source
in the Python Examples & Concepts.
Next Steps
Now that you know the basics, you can check out our annotated examples for a more detailed look at how to work with our data.
Here are a few things you might be interested in: