How to add a new sensor

This tutorial explains the basics for adding a new sensor to CARLA. It provides the necessary steps to implement a sensor in Unreal Engine 4 (UE4) and expose its data via CARLA's Python API. We'll follow all the steps by creating a new sensor as an example.

Prerequisites

In order to implement a new sensor, you'll need to compile CARLA source code, for detailed instructions on how to achieve this see Building from source.

This tutorial also assumes the reader is fluent in C++ programming.

Introduction

Sensors in CARLA are a special type of actor that produce a stream of data. Some sensors produce data continuously, every time the sensor is updated, other produce data only after certain events. For instance, a camera produces an image on every update, but a collision sensor is only triggered in the event of a collision.

Although most sensors compute their measurements in the server side (UE4), it's worth noticing that some sensors run in the client-side only. An example of such sensor is the GNSS, it computes the simulated geo-location in the client-side based on its 3D location. For further details see Appendix: Client-side sensors.

In this tutorial, we'll be focusing on server-side sensors.

In order to have a sensor running inside UE4 sending data all the way to a Python client, we need to cover the whole communication pipeline.

Communication pipeline

Thus we'll need the following classes covering the different steps of the pipeline

  • Sensor actor
    Actor in charge of measuring and/or simulating data. Running in Carla plugin using UE4 framework. Accessible by the user as Sensor actor.

  • Serializer
    Object containing methods for serializing and deserializing the data generated by the sensor. Running in LibCarla, both server and client.

  • Sensor data
    Object representing the data generated by the sensor. This is the object that will be passed to the final user, both in C++ and Python APIs.

Note

To ensure best performance, sensors are registered and dispatched using a sort of "compile-time plugin system" based on template meta-programming. Most likely, the code won't compile until all the pieces are present.

Creating a new sensor

Full source code here.

We're going to create a sensor that detects other actors around our vehicle. For that we'll create a trigger box that detects objects within, and we'll be reporting status to the client every time a vehicle is inside our trigger box. Let's call it Safe Distance Sensor.

Trigger box

For the sake of simplicity we're not going to take into account all the edge cases, nor it will be implemented in the most efficient way. This is just an illustrative example.

1. The sensor actor

This is the most complicated class we're going to create. Here we're running inside Unreal Engine framework, knowledge of UE4 API will be very helpful but not indispensable, we'll assume the reader has never worked with UE4 before.

Inside UE4, we have a similar hierarchy as we have in the client-side, ASensor derives from AActor, and an actor is roughly any object that can be dropped into the world. AActor has a virtual function called Tick that we can use to update our sensor on every simulator update. Higher in the hierarchy we have UObject, base class for most of UE4 classes. It is important to know that objects deriving from UObject are handle via pointers and are garbage collected when they're no longer referenced. Class members pointing to UObjects need to be marked with UPROPERTY macros or they'll be garbage collected.

Let's start.

This class has to be located inside Carla plugin, we'll create two files for our new C++ class

  • Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/SafeDistanceSensor.h
  • Unreal/CarlaUE4/Plugins/Carla/Source/Carla/Sensor/SafeDistanceSensor.cpp

At the very minimum, the sensor is required to inherit ASensor, and provide a static method GetSensorDefinition; but we'll be overriding also the Set, SetOwner, and Tick methods. This sensor also needs a trigger box that will be detecting other actors around us. With this and some required boiler-plate UE4 code, the header file looks like

#pragma once

#include "Carla/Sensor/Sensor.h"

#include "Carla/Actor/ActorDefinition.h"
#include "Carla/Actor/ActorDescription.h"

#include "Components/BoxComponent.h"

#include "SafeDistanceSensor.generated.h"

UCLASS()
class CARLA_API ASafeDistanceSensor : public ASensor
{
  GENERATED_BODY()

public:

  ASafeDistanceSensor(const FObjectInitializer &ObjectInitializer);

  static FActorDefinition GetSensorDefinition();

  void Set(const FActorDescription &ActorDescription) override;

  void SetOwner(AActor *Owner) override;

  void Tick(float DeltaSeconds) override;

private:

  UPROPERTY()
  UBoxComponent *Box = nullptr;
};

In the cpp file, first we'll need some includes

#include "Carla.h"
#include "Carla/Sensor/SafeDistanceSensor.h"

#include "Carla/Actor/ActorBlueprintFunctionLibrary.h"
#include "Carla/Game/CarlaEpisode.h"
#include "Carla/Util/BoundingBoxCalculator.h"
#include "Carla/Vehicle/CarlaWheeledVehicle.h"

Then we can proceed to implement the functionality. The constructor will create the trigger box, and tell UE4 that we want our tick function to be called. If our sensor were not using the tick function, we can disable it here to avoid unnecessary ticks

ASafeDistanceSensor::ASafeDistanceSensor(const FObjectInitializer &ObjectInitializer)
  : Super(ObjectInitializer)
{
  Box = CreateDefaultSubobject<UBoxComponent>(TEXT("BoxOverlap"));
  Box->SetupAttachment(RootComponent);
  Box->SetHiddenInGame(true); // Disable for debugging.
  Box->SetCollisionProfileName(FName("OverlapAll"));

  PrimaryActorTick.bCanEverTick = true;
}

Now we need to tell Carla what attributes this sensor has, this is going to be used to create a new blueprint in our blueprint library, users can use this blueprint to configure and spawn this sensor. We're going to define here the attributes of our trigger box, in this example we'll expose only X and Y safe distances

FActorDefinition ASafeDistanceSensor::GetSensorDefinition()
{
  auto Definition = UActorBlueprintFunctionLibrary::MakeGenericSensorDefinition(
      TEXT("other"),
      TEXT("safe_distance"));

  FActorVariation Front;
  Front.Id = TEXT("safe_distance_front");
  Front.Type = EActorAttributeType::Float;
  Front.RecommendedValues = { TEXT("1.0") };
  Front.bRestrictToRecommended = false;

  FActorVariation Back;
  Back.Id = TEXT("safe_distance_back");
  Back.Type = EActorAttributeType::Float;
  Back.RecommendedValues = { TEXT("0.5") };
  Back.bRestrictToRecommended = false;

  FActorVariation Lateral;
  Lateral.Id = TEXT("safe_distance_lateral");
  Lateral.Type = EActorAttributeType::Float;
  Lateral.RecommendedValues = { TEXT("0.5") };
  Lateral.bRestrictToRecommended = false;

  Definition.Variations.Append({ Front, Back, Lateral });

  return Definition;
}

With this, the sensor factory is able to create a Safe Distance Sensor on user demand. Immediately after the sensor is created, the Set function is called with the parameters that the user requested

void ASafeDistanceSensor::Set(const FActorDescription &Description)
{
  Super::Set(Description);

  float Front = UActorBlueprintFunctionLibrary::RetrieveActorAttributeToFloat(
      "safe_distance_front",
      Description.Variations,
      1.0f);
  float Back = UActorBlueprintFunctionLibrary::RetrieveActorAttributeToFloat(
      "safe_distance_back",
      Description.Variations,
      0.5f);
  float Lateral = UActorBlueprintFunctionLibrary::RetrieveActorAttributeToFloat(
      "safe_distance_lateral",
      Description.Variations,
      0.5f);

  constexpr float M_TO_CM = 100.0f; // Unit conversion.

  float LocationX = M_TO_CM * (Front - Back) / 2.0f;
  float ExtentX = M_TO_CM * (Front + Back) / 2.0f;
  float ExtentY = M_TO_CM * Lateral;

  Box->SetRelativeLocation(FVector{LocationX, 0.0f, 0.0f});
  Box->SetBoxExtent(FVector{ExtentX, ExtentY, 0.0f});
}

Note that the set function is called before UE4's BeginPlay, we won't use this virtual function here, but it's important for other sensors.

Now we're going to extend the box volume based on the bounding box of the actor that we're attached to. For that, the most convenient method is to use the SetOwner virtual function. This function is called when our sensor is attached to another actor.

void ASafeDistanceSensor::SetOwner(AActor *Owner)
{
  Super::SetOwner(Owner);

  auto BoundingBox = UBoundingBoxCalculator::GetActorBoundingBox(Owner);

  Box->SetBoxExtent(BoundingBox.Extent + Box->GetUnscaledBoxExtent());
}

The only thing left to do is the actual measurement, for that we'll use the Tick function. We're going to look for all the vehicles currently overlapping our box, and we'll send this list to client

void ASafeDistanceSensor::Tick(float DeltaSeconds)
{
  Super::Tick(DeltaSeconds);

  TSet<AActor *> DetectedActors;
  Box->GetOverlappingActors(DetectedActors, ACarlaWheeledVehicle::StaticClass());
  DetectedActors.Remove(GetOwner());

  if (DetectedActors.Num() > 0)
  {
    auto Stream = GetDataStream(*this);
    Stream.Send(*this, GetEpisode(), DetectedActors);
  }
}

Note

In production-ready sensors, the Tick function should be very optimized, specially if the sensor sends big chunks of data. This function is called every update in the game thread thus significantly affects the performance of the simulator.

Ok, a couple of things going on here that we haven't mentioned yet, what's this stream?

Every sensor has a data stream associated. This stream is used to send data down to the client, and this is the stream you subscribe to when you use the sensor.listen(callback) method in the Python API. Every time you send here some data, the callback on the client-side is going to be triggered. But before that, the data is going to travel through several layers. First of them will be the serializer that we have to create next. We'll fully understand this part once we have completed the Serialize function in the next section.

2. The sensor data serializer

This class is actually rather simple, it's only required to have two static methods, Serialize and Deserialize. We'll add two files for it, this time to LibCarla

  • LibCarla/source/carla/sensor/s11n/SafeDistanceSerializer.h
  • LibCarla/source/carla/sensor/s11n/SafeDistanceSerializer.cpp

Let's start with the Serialize function. This function is going to receive as arguments whatever we pass to the Stream.Send(...) function, with the only condition that the first argument has to be a sensor and it has to return a buffer.

static Buffer Serialize(const Sensor &, ...);

A carla::Buffer is just a dynamically allocated piece of raw memory with some convenient functionality, we're going to use it to send raw data to the client.

In this example, we need to write the list of detected actors to a buffer in a way that it can be meaningful in the client-side. That's why we passed the episode object to this function.

The UCarlaEpisode class represent the current episode running in the simulator, i.e. the state of the simulation since last time we loaded a map. It contains all the relevant information to Carla, and among other things, it allows searching for actor IDs. We can send these IDs to the client and the client will be able to recognise these as actors

template <typename SensorT, typename EpisodeT, typename ActorListT>
static Buffer Serialize(
    const SensorT &,
    const EpisodeT &episode,
    const ActorListT &detected_actors) {
  const uint32_t size_in_bytes = sizeof(ActorId) * detected_actors.Num();
  Buffer buffer{size_in_bytes};
  unsigned char *it = buffer.data();
  for (auto *actor : detected_actors) {
    ActorId id = episode.FindActor(actor).GetActorId();
    std::memcpy(it, &id, sizeof(ActorId));
    it += sizeof(ActorId);
  }
  return buffer;
}

Note that we templatize the UE4 classes to avoid including these files within LibCarla.

This buffer we're returning is going to come back to us, except that this time in the client-side, in the Deserialize function packed in a RawData object

static SharedPtr<SensorData> Deserialize(RawData &&data);

We'll implement this method in the cpp file, and it's rather simple

SharedPtr<SensorData> SafeDistanceSerializer::Deserialize(RawData &&data) {
  return SharedPtr<SensorData>(new data::SafeDistanceEvent(std::move(data)));
}

except for the fact that we haven't defined yet what's a SafeDistanceEvent.

3. The sensor data object

We need to create a data object for the users of this sensor, representing the data of a safe distance event. We'll add this file to

  • LibCarla/source/carla/sensor/data/SafeDistanceEvent.h

This object is going to be equivalent to a list of actor IDs. For that, we'll derive from the Array template

#pragma once

#include "carla/rpc/ActorId.h"
#include "carla/sensor/data/Array.h"

namespace carla {
namespace sensor {
namespace data {

  class SafeDistanceEvent : public Array<rpc::ActorId> {
  public:

    explicit SafeDistanceEvent(RawData &&data)
      : Array<rpc::ActorId>(std::move(data)) {}
  };

} // namespace data
} // namespace sensor
} // namespace carla

The Array template is going to reinterpret the buffer we created in the Serialize method as an array of actor IDs, and it's able to do so directly from the buffer we received, without allocating any new memory. Although for this small example may seem a bit overkill, this mechanism is also used for big chunks of data; imagine we're sending HD images, we save a lot by reusing the raw memory.

Now we need to expose this class to Python. In our example, we haven't add any extra methods, so we'll just expose the methods related to Array. We do so by using Boost.Python bindings, add the following to PythonAPI/carla/source/libcarla/SensorData.cpp.

class_<
    csd::SafeDistanceEvent,                    // actual type.
    bases<cs::SensorData>,                     // parent type.
    boost::noncopyable,                        // disable copy.
    boost::shared_ptr<csd::SafeDistanceEvent>  // use as shared_ptr.
  >("SafeDistanceEvent", no_init)              // name, and disable construction.
  .def("__len__", &csd::SafeDistanceEvent::size)
  .def("__iter__", iterator<csd::SafeDistanceEvent>())
  .def("__getitem__", +[](const csd::SafeDistanceEvent &self, size_t pos) -> cr::ActorId {
    return self.at(pos);
  })
;

Note that csd is an alias for the namespace carla::sensor::data.

What we're doing here is exposing some C++ methods in Python. Just with this, the Python API will be able to recognise our new event and it'll behave similar to an array in Python, except that cannot be modified.

4. Register your sensor

Now that the pipeline is complete, we're ready to register our new sensor. We do so in LibCarla/source/carla/sensor/SensorRegistry.h. Follow the instruction in this header file to add the different includes and forward declarations, and add the following pair to the registry

std::pair<ASafeDistanceSensor *, s11n::SafeDistanceSerializer>

With this, the sensor registry now can do its magic to dispatch the right data to the right serializer.

Now recompile CARLA, hopefully everything goes ok and no errors. Unfortunately, most of the errors here will be related to templates and the error messages can be a bit cryptic.

make rebuild

5. Usage example

Finally, we have the sensor included and we have finished recompiling, our sensor by now should be available in Python.

To spawn this sensor, we simply need to find it in the blueprint library, if everything went right, the sensor factory should have added our sensor to the library

blueprint = blueprint_library.find('sensor.other.safe_distance')
sensor = world.spawn_actor(blueprint, carla.Transform(), attach_to=vehicle)

and now we can start listening for events by registering a callback function

world_ref = weakref.ref(world)

def callback(event):
    for actor_id in event:
        vehicle = world_ref().get_actor(actor_id)
        print('Vehicle too close: %s' % vehicle.type_id)

sensor.listen(callback)

This callback is going to execute every update that another vehicle is inside our safety distance box, e.g.

Vehicle too close: vehicle.audi.a2
Vehicle too close: vehicle.mercedes-benz.coupe

That's it, we have a new sensor working!


Appendix: Reusing buffers

In order to optimize memory usage, we can use the fact that each sensor sends buffers of similar size; in particularly, in the case of cameras, the size of the image is constant during execution. In those cases, we can save a lot by reusing the allocated memory between frames.

Each stream contains a buffer pool that can be used to avoid unnecessary memory allocations. Remember that each sensor has a stream associated thus each sensor has its own buffer pool.

Use the following to retrieve a buffer from the pool

auto Buffer = Stream.PopBufferFromPool();

If the pool is empty, it returns an empty buffer, i.e. a buffer with no memory allocated. In that case, when you resize the buffer new memory will be allocated. This will happen a few times during the first frames. However, if a buffer was retrieved from the pool, its memory will go back to the pool once the buffer goes out of the scope. Next time you get another buffer from the pool, it'll contain the allocated piece of memory from the previous buffer. As you can see, a buffer object acts actually as an smart pointer to a contiguous piece of raw memory. As long as you don't request more memory than the currently allocated, the buffer reuses the memory. If you request more, then it'll have to delete the current memory and allocate a bigger chunk.

The following snippet illustrates how buffers work

Buffer buffer;
buffer.reset(1024u); // (size 1024 bytes, capacity 1024 bytes) -> allocates
buffer.reset(512u);  // (size  512 bytes, capacity 1024 bytes)
buffer.reset(2048u); // (size 2048 bytes, capacity 2048 bytes) -> allocates

Appendix: Sending data asynchronously

Some sensors may require to send data asynchronously, either for performance or because the data is generated in a different thread, for instance, camera sensors send the images from the render thread.

Using the data stream asynchronously is perfectly fine, as long as the stream itself is created in the game thread. For instance

void MySensor::Tick(float DeltaSeconds)
{
  Super::Tick(DeltaSeconds);

  auto Stream = GetDataStream(*this);

  std::async(std::launch::async, [Stream=std::move(Stream)]() {
    auto Data = ComputeData();
    Stream.Send(*this, Data);
  });
}

Appendix: Client-side sensors

Some sensors do not require the simulator to do their measurements, those sensors may run completely in the client-side freeing the simulator from extra computations. Examples of such sensors are the GNSS and the LaneInvasion sensors.

The usual approach is to create a "dummy" sensor in the server-side, just so the simulator is aware that such actor exists. However, this dummy sensor doesn't tick nor sends any sort of data. Its counterpart on the client-side however, registers a "on tick" callback to execute some code on every new update. For instance, the GNSS sensor registers a callback that converts the current 3D location of the vehicle to a geo-location (latitude, longitude, altitude) every tick.

It is very important to take into account that the "on tick" callback in the client-side is executed concurrently, i.e., the same method may be executed simultaneously by different threads. Any data accessed must be properly synchronized, either with a mutex, using atomics, or even better making sure all the members accessed remain constant.