tutorial
visualization
MCAP

Recording Robocar Data with MCAP

Using the MCAP C++ writer to record robotics data

tutorial
visualization
MCAP

In my spare time for the past several months, I’ve been developing an autonomous RC car for DIYRobocars – a community of makers who race their DIY autonomous cars of all sizes. At the Robot Block Party hosted by Silicon Valley Robotics this past April, my car came in second in the Robocar race and also won the demolition derby.

My robocar taking out the competition at the demolition derby.

As you might expect, a good data logging and visualization infrastructure was crucial to helping me develop my successful autonomous driver. It took several iterations and a few false starts before I landed on my final solution – using Foxglove’s MCAP C++ reader-writer with Protobuf messages. I found the MCAP writer to be a solid foundation for my logging architecture, affording me a lightweight solution for both out-of-the-box support and open-ended flexibility.

Initial experiments with Python and ROS

I decided against using ROS in my robocar stack for a variety of reasons. For one, I wanted to write most of the core software from scratch – it’s part of the fun! I also wanted to get started quickly, and I decided that the time I’d invest in learning the ecosystem probably would not pay off for me in my future projects.

Furthermore, it wasn’t feasible to simply import the rosbag writer into my existing C++ build. Getting ROS working with my C++ build would likely have been a nightmare, given my desire to both cross-compile and run Raspbian for the Raspberry Pi (instead of Ubuntu).

In my first foray into logging and visualization, I used a custom binary format to log video frames and CSV lines to log sensor data, PID control outputs, and all other data. I then wrote a Python script, leveraging Python’s dependency management and standalone rosbag writer, to export ROS 2 files that could then be read by Foxglove.

This scheme quickly fell apart. Schema evolution for logged messages and topics was manual, tedious, and error-prone. Older log files quickly became unreadable. The additional Python conversion step also made things more complicated and annoying.

With this first failed experiment, I tried introducing minimal dependencies on sqlite and FastCDR, which let me cobble together my own rosbag2 writer. This let me natively log rosbag2 files which could then be viewed directly in Foxglove.

However, there still remained a few pain points. With this direction, I was maintaining a significant amount of code – the rosbag2 writer and especially reader – that didn’t add much core value to my autonomous car. Since FastCDR’s IDL is not widely adopted, I had to write my own definitions for the standard ROS 2 messages I wanted to publish. Foxglove also could not support custom ROS 2 messages, due to an inherent ROS 2 design decision. Finally, there was no easy way to do compression, which inevitably led to unwieldy data logs and longer data offloading times.

Using MCAP with Protobuf messages

Around this time, Foxglove released an early version of their MCAP reader and writer, with C++ and Python support (they currently also support TypeScript, Go, and Swift). Given the hurdles I’d encountered with my previous iterations, I decided to give the library a try, using custom Protobuf messages.

I immediately noticed a few advantages. For one, Protobuf is well documented, with robust C++ libraries and backwards- and forwards-compatible semantics for message schema evolution. I also found myself enjoying just how easy it was to integrate MCAP into my stack – it is a small library with relatively few dependencies, yet surprisingly flexible. I was able to leverage Foxglove’s already-defined Protobuf message schemas, but also define my own custom messages. I also found the ability to tune compression settings incredibly useful for reducing the size of my data logs and for accelerating data offloading.

The only con I found was that with MCAP being fairly new, there hasn’t been much widespread adoption yet. With that said, I think anyone else who gives the library a try will have as positive of an experience as I had.

Creating an MCAP writer in C++

An MCAP file has messages, channels, and schemas.

In the same way that ROS publishes messages to a topic, MCAP writes messages to a channel. Messages for a given channel always follow the same schema, but a particular schema can be shared by multiple channels.

Creating an MCAP writer is relatively straightforward:

mcap::McapWriter writer;
mcap::McapWriterOptions opts("protobuf");
auto s = writer.open("output.mcap");
if (!s.ok) {
 std::cerr << "Failed to open mcap writer: " << status.message << "\n";
 throw std::runtime_error("could not open mcap writer");
}

language-cpp

Configure your writer to your desired specifications using McapWriterOptions. For example, opts.compressionLevel = mcap::CompressionLevel::Fast customizes your writer to use a faster compression level.

Before we can write messages, we need to register a schema and a channel to write our messages to.

To register a schema in Protobuf, you must use the fully-qualified name of the message type (e.g. foxglove.PosesInFrame) and provide a serialized google::protobuf::FileDescriptorSet for the schema itself. Generated Protobuf messages will contain enough information to reconstruct this FileDescriptorSet schema at runtime.

// Recursively adds all `fd` dependencies to `fd_set`.
void fdSetInternal(google::protobuf::FileDescriptorSet& fd_set,
                  std::unordered_set<std::string>& files,
                  const google::protobuf::FileDescriptor* fd) {
 for (int i = 0; i < fd->dependency_count(); ++i) {
   const auto* dep = fd->dependency(i);
   auto [_, inserted] = files.insert(dep->name());
   if (!inserted) continue;
   fdSetInternal(fd_set, files, fd->dependency(i));
 }
 fd->CopyTo(fd_set.add_file());
}

// Returns a serialized google::protobuf::FileDescriptorSet containing
// the necessary google::protobuf::FileDescriptor's to describe d.
std::string fdSet(const google::protobuf::Descriptor* d) {
 std::string res;
 std::unordered_set<std::string> files;
 google::protobuf::FileDescriptorSet fd_set;
 fdSetInternal(fd_set, files, d->file());
 return fd_set.SerializeAsString();
}

mcap::Schema createSchema(const google::protobuf::Descriptor* d) {
 mcap::Schema schema(d->full_name(), "protobuf", fdSet(d));
 return schema;
}

// Create a schema for the foxglove.PosesInFrame message.
mcap::Schema poses_schema("foxglove.PosesInFrame", "protobuf", SerializeFdSet(foxglove::PosesInFrame::descriptor()));
writer.addSchema(poses_schema);  // Assigned schema id is written to poses_schema.id

language-cpp

Once we have the schema registered, we can register our channel:

mcap::Channel poses_channel("/planner/path", "protobuf", schema.id);
mcap.addChannel(poses_channel);  // Assigned channel id written to poses_channel.id

language-cpp

We can now finally write messages to the channel via its id. A foxglove.PosesInFrame message should have a timestamp, frame_id, and an array of poses:

foxglove::PosesInFrame poses_msg;
// Fill in poses_msg
std::string data = poses_msg.SerializeAsString();

mcap::Message msg;
msg.channelId = path_channel.id;
msg.logTime = timestamp_ns;
msg.publishTime = msg.logTime;
msg.data = reinterpret_cast<const std::byte*>(data.data());
msg.dataSize = data.size();

writer.write(msg);

language-cpp

Don’t forget to close the writer when you’re done:

writer.close();

language-cpp

Now, we can inspect our output MCAP file's messages. I used the Data source dialog in Foxglove to “Open local file”:

Data source dialog

I then added a few relevant panels (Plot, Image, Raw Messages, 3D) to visualize my robocar's performance on the "road".

Foxglove replaying the exact moment my robocar collided with a demolition derby competitor.

Conclusion

I found Foxglove’s MCAP writer to be a solid building block when building my robocar’s data logging system. I used it in tandem with Foxglove to quickly publish, visualize, and iterate on my robot – all by getting a better understanding of the data it recorded.

I was even able to add several bespoke enhancements specific to my project. I wrapped the MCAP writer in a layer that serialized Protobuf messages, wrapped them in a mcap::Message, and registered schemas for unknown Protobuf types in one centralized place. I also moved mcap.write to a separate thread fed by a queue, which avoided the time-sensitive driver control loop being disturbed by the long-tail filesystem i/o latency observed on the Raspberry Pi 3.

If you find yourself unhappy with your current data logging solution, I encourage you to give MCAP a try – it certainly has turned out well for me!

Read more

Start building with Foxglove.

Get started for free