F1TENTH: Automatic Emergency Braking with Time-to-Collision
What I did: brought up a 1/10-scale autonomous car on an NVIDIA Jetson Orin Nano and wrote the automatic emergency braking (AEB) node in C++. It reads the 2D LiDAR scan and wheel odometry over ROS2, computes a time-to-collision (TTC) for every forward beam, and commands the motor controller to stop through Ackermann drive messages before the car hits anything.
The idea: time-to-collision per beam
A distance threshold alone brakes too late at speed and too early when crawling. TTC asks a better question: at the current speed, how many seconds until I reach what this beam is seeing?
Each LiDAR beam i has a range r and a bearing θ. Only the part of the car's forward velocity vx that points along the beam closes the gap, so the range rate is:
ṙ = −vx cos θ iTTC = r / max(−ṙ, 0)
The max(·, 0) matters: beams whose range is growing (things behind or beside the car) get an infinite TTC and never trigger a brake. The car brakes when the minimum TTC across the scan drops below a threshold, which I tuned against measured stopping distance on the real car.
The C++ node
The core of scan_callback from safety_node.cpp:
void scan_callback(const sensor_msgs::msg::LaserScan::ConstSharedPtr msg)
{
double v = speed_.load(); // latest odom speed (atomic)
bool should_brake = false;
double braking_threshold = .5; // seconds
const double fov_half = .35; // only look ~20 deg either side of forward
for (size_t i {}; i < msg->ranges.size(); ++i) {
double theta = msg->angle_min + i * msg->angle_increment;
if (std::abs(theta) > fov_half) continue; // ignore side beams
double r = msg->ranges[i];
if (!std::isfinite(r)) continue; // drop inf / NaN returns
double closing = v * std::cos(theta); // -r_dot for this beam
if (closing > 0.0) { // only beams we're closing on
double ttc = r / closing;
if (ttc < braking_threshold) {
should_brake = true;
break;
}
}
}
if (should_brake) {
auto brake = ackermann_msgs::msg::AckermannDriveStamped();
brake.drive.speed = 0.0;
publisher_->publish(brake); // /drive -> motor controller
}
}
Engineering details I had to get right
- False positives. Infinite and NaN returns are skipped, beams with a growing range never count, and the check is limited to a forward cone so walls passing beside the car don't stop it.
- Concurrency. Odometry and LiDAR callbacks run in separate mutually exclusive callback groups on a
MultiThreadedExecutor, so a slow odom message never delays a scan. The shared speed is astd::atomic<double>so the scan thread never reads a half-written value. - Sensor QoS. The scan subscription uses
SensorDataQoS(best effort, keep-last) to match the LiDAR driver, so stale scans are dropped instead of queued. - A real lifetime bug. The node started, stayed alive, and never showed up in
ros2 node list. I traced it toexecutor.add_node(std::make_shared<Safety>()): the executor only holds aweak_ptr, so the temporary node was destroyed immediately. Keeping a namedshared_ptralive for the wholespin()fixed it. I wrote up the diagnosis in the repo's debug notes. - Hardware bring-up. Configured the Jetson workspace and the VESC motor controller, and wrote a machine-aware setup script so the same repo builds on the car and on a desktop.