Simulation-Based ADAS Testing with CARLA and LGSVL
Road testing autonomous driving systems is expensive, slow, and dangerous for edge cases. Waymo famously ran over 20 billion simulated miles before reaching 20 million real miles. Simulation isn't a shortcut — it's the only way to test rare scenarios like sensor occlusion in a snowstorm, or a child darting between parked cars, at the scale needed to validate safety.
This post is a practical guide to ADAS simulation testing using CARLA and LGSVL: setting them up, writing Python scenario scripts, testing sensor fusion, and integrating simulation runs into CI.
Choosing Between CARLA and LGSVL
CARLA (Car Learning to Act): Open-source, Unreal Engine 4, strong Python API, large scenario library, active research community. Best for algorithmic testing and sensor model experimentation.
LGSVL (now SVL Simulator): Unity-based, better ROS 2 and Autoware integration, higher visual fidelity for perception testing, supports Apollo. Better for full-stack autonomy stack integration.
Use CARLA for unit-level perception and planning tests. Use LGSVL for end-to-end stack validation against Autoware/Apollo.
CARLA Setup
# Install CARLA 0.9.15
wget https://carla-releases.s3.eu-west-3.amazonaws.com/Linux/CARLA_0.9.15.tar.gz
tar -xf CARLA_0.9.15.tar.gz -C /opt/carla
# Install Python client
pip install carla==0.9.15
# Start the server (headless for CI)
/opt/carla/CarlaUE4.sh -RenderOffScreen -quality-level=Low -world-port=2000 &
sleep 10 # Wait for server initialization
# Verify connection
python3 -c "
import carla
client = carla.Client('localhost', 2000)
client.set_timeout(10.0)
world = client.get_world()
print(f'Connected. Map: {world.get_map().name}')
"Writing CARLA Scenarios for ADAS Testing
The CARLA Python API gives you full control over the simulation: spawning actors, controlling weather, injecting sensor noise, triggering pedestrian behaviors.
Scenario: Pedestrian Crossing — AEB Validation
#!/usr/bin/env python3
"""
AEB (Automatic Emergency Braking) validation scenario.
Tests: Does the ADAS system detect and respond to a pedestrian
crossing from behind a parked vehicle?
Pass criteria:
- Vehicle decelerates to < 5 kph before reaching pedestrian position
- No false trigger on scenario variants without pedestrian
- Response latency < 300ms from pedestrian visibility
"""
import carla
import time
import math
import numpy as np
from dataclasses import dataclass
from typing import Optional
@dataclass
class ScenarioResult:
passed: bool
min_speed_at_crossing_kph: float
aeb_trigger_latency_ms: Optional[float]
collision_occurred: bool
class PedestrianCrossingScenario:
def __init__(self, host: str = 'localhost', port: int = 2000):
self.client = carla.Client(host, port)
self.client.set_timeout(10.0)
self.world = self.client.get_world()
self.blueprint_library = self.world.get_blueprint_library()
self.actors = []
def _cleanup(self):
for actor in self.actors:
if actor.is_alive:
actor.destroy()
self.actors.clear()
def run(self, ego_speed_kph: float = 50.0,
pedestrian_hidden: bool = True) -> ScenarioResult:
"""
Args:
ego_speed_kph: Initial ego vehicle speed
pedestrian_hidden: If True, pedestrian starts hidden behind parked car
"""
try:
return self._run_scenario(ego_speed_kph, pedestrian_hidden)
finally:
self._cleanup()
def _run_scenario(self, ego_speed_kph: float,
pedestrian_hidden: bool) -> ScenarioResult:
map_obj = self.world.get_map()
spawn_points = map_obj.get_spawn_points()
# Spawn ego vehicle on Town03 straight road
ego_bp = self.blueprint_library.find('vehicle.tesla.model3')
ego_transform = carla.Transform(
carla.Location(x=-50, y=0, z=0.5),
carla.Rotation(yaw=0)
)
ego = self.world.spawn_actor(ego_bp, ego_transform)
self.actors.append(ego)
# Spawn parked vehicle (occlusion object)
parked_bp = self.blueprint_library.find('vehicle.audi.a2')
parked_transform = carla.Transform(
carla.Location(x=20, y=3.5, z=0.5), # On the shoulder
carla.Rotation(yaw=0)
)
parked = self.world.spawn_actor(parked_bp, parked_transform)
parked.apply_control(carla.VehicleControl(hand_brake=True))
self.actors.append(parked)
# Spawn pedestrian
ped_bp = self.blueprint_library.find('walker.pedestrian.0001')
ped_start_y = 3.5 if pedestrian_hidden else 0.0
ped_transform = carla.Transform(
carla.Location(x=25, y=ped_start_y, z=0.5)
)
pedestrian = self.world.spawn_actor(ped_bp, ped_transform)
self.actors.append(pedestrian)
# Set ego vehicle to target speed
ego.enable_constant_velocity(
carla.Vector3D(ego_speed_kph / 3.6, 0, 0)
)
# Attach collision sensor to ego
collision_bp = self.blueprint_library.find('sensor.other.collision')
collision_sensor = self.world.spawn_actor(
collision_bp, carla.Transform(), attach_to=ego
)
self.actors.append(collision_sensor)
collision_occurred = False
def on_collision(event):
nonlocal collision_occurred
collision_occurred = True
collision_sensor.listen(on_collision)
# Simulation loop: 10 seconds max
pedestrian_visible_at = None
aeb_triggered_at = None
min_speed = ego_speed_kph
speeds = []
controller_bp = self.blueprint_library.find('controller.ai.walker')
controller = self.world.spawn_actor(controller_bp, carla.Transform(),
attach_to=pedestrian)
self.actors.append(controller)
controller.start()
start = time.time()
pedestrian_crossing_triggered = False
while time.time() - start < 10.0:
self.world.tick()
ego_loc = ego.get_location()
ped_loc = pedestrian.get_location()
# Trigger pedestrian to cross when ego is 15m away
if not pedestrian_crossing_triggered and ego_loc.x > 10:
controller.go_to_location(
carla.Location(x=25, y=-3.5, z=0.5)
)
controller.set_max_speed(1.4) # 1.4 m/s walking speed
pedestrian_crossing_triggered = True
# Check if pedestrian is in ego's sensor FOV (simplified: within 30m, -15° to 15° azimuth)
dx = ped_loc.x - ego_loc.x
dy = ped_loc.y - ego_loc.y
distance = math.sqrt(dx**2 + dy**2)
azimuth_deg = math.degrees(math.atan2(dy, dx))
if distance < 30 and abs(azimuth_deg) < 15 and pedestrian_visible_at is None:
pedestrian_visible_at = time.time()
# Read ego speed
v = ego.get_velocity()
speed_kph = math.sqrt(v.x**2 + v.y**2) * 3.6
speeds.append(speed_kph)
min_speed = min(min_speed, speed_kph)
# Detect AEB trigger (speed drop > 20% within 500ms)
if (pedestrian_visible_at and aeb_triggered_at is None
and len(speeds) > 5 and speed_kph < ego_speed_kph * 0.8):
aeb_triggered_at = time.time()
# Stop simulation if ego passed crossing point
if ego_loc.x > 40:
break
latency_ms = None
if pedestrian_visible_at and aeb_triggered_at:
latency_ms = (aeb_triggered_at - pedestrian_visible_at) * 1000
passed = (
min_speed < 5.0 # Nearly stopped before pedestrian
and not collision_occurred
and (latency_ms is None or latency_ms < 300)
)
return ScenarioResult(
passed=passed,
min_speed_at_crossing_kph=min_speed,
aeb_trigger_latency_ms=latency_ms,
collision_occurred=collision_occurred
)
if __name__ == '__main__':
scenario = PedestrianCrossingScenario()
# Test 1: Standard crossing with occlusion
result = scenario.run(ego_speed_kph=50.0, pedestrian_hidden=True)
print(f"Occluded crossing: {'PASS' if result.passed else 'FAIL'}")
print(f" Min speed: {result.min_speed_at_crossing_kph:.1f} kph")
print(f" AEB latency: {result.aeb_trigger_latency_ms:.0f} ms")
print(f" Collision: {result.collision_occurred}")
# Test 2: No pedestrian — verify no false trigger
result2 = scenario.run(ego_speed_kph=50.0, pedestrian_hidden=False)
assert result2.aeb_trigger_latency_ms is None, "False AEB trigger!"
print("No-pedestrian variant: PASS (no false trigger)")Sensor Fusion Testing: Camera + LiDAR + Radar
A key ADAS testing concern is sensor fusion correctness — ensuring the fusion algorithm correctly combines inputs from heterogeneous sensors. CARLA provides synthetic ground truth you can use to measure fusion accuracy.
import carla
import numpy as np
import queue
class SensorFusionTestBench:
"""
Validates fusion algorithm by comparing fused output to CARLA ground truth.
Metrics:
- Detection rate: What % of GT objects appear in fused output?
- False positive rate: What % of fused detections have no GT match?
- Position error: Mean distance between matched GT and fused objects
"""
def setup_sensors(self, ego: carla.Actor):
bp_lib = self.world.get_blueprint_library()
# Camera (forward-facing, 90° FOV)
cam_bp = bp_lib.find('sensor.camera.rgb')
cam_bp.set_attribute('image_size_x', '1920')
cam_bp.set_attribute('image_size_y', '1080')
cam_bp.set_attribute('fov', '90')
camera = self.world.spawn_actor(
cam_bp,
carla.Transform(carla.Location(x=2.0, z=1.4)),
attach_to=ego
)
# LiDAR (Velodyne HDL-64E equivalent)
lidar_bp = bp_lib.find('sensor.lidar.ray_cast')
lidar_bp.set_attribute('channels', '64')
lidar_bp.set_attribute('range', '100.0')
lidar_bp.set_attribute('points_per_second', '1300000')
lidar_bp.set_attribute('rotation_frequency', '10')
lidar_bp.set_attribute('upper_fov', '2')
lidar_bp.set_attribute('lower_fov', '-24.8')
lidar = self.world.spawn_actor(
lidar_bp,
carla.Transform(carla.Location(x=0.0, z=1.8)),
attach_to=ego
)
# Radar (forward-facing, 35° azimuth)
radar_bp = bp_lib.find('sensor.other.radar')
radar_bp.set_attribute('horizontal_fov', '35')
radar_bp.set_attribute('vertical_fov', '20')
radar_bp.set_attribute('range', '70')
radar = self.world.spawn_actor(
radar_bp,
carla.Transform(carla.Location(x=2.5, z=0.5)),
attach_to=ego
)
return camera, lidar, radar
def evaluate_fusion(self, fused_detections: list,
ground_truth_actors: list,
ego_location: carla.Location,
match_threshold_m: float = 2.0) -> dict:
"""
Compare fused detections to CARLA ground truth actors.
Returns precision, recall, and mean position error.
"""
gt_positions = [
np.array([a.get_location().x - ego_location.x,
a.get_location().y - ego_location.y])
for a in ground_truth_actors
if a.get_location().distance(ego_location) < 70.0
]
matched_gt = set()
matched_det = set()
for det_idx, det in enumerate(fused_detections):
det_pos = np.array([det['x'], det['y']])
for gt_idx, gt_pos in enumerate(gt_positions):
if gt_idx in matched_gt:
continue
dist = np.linalg.norm(det_pos - gt_pos)
if dist < match_threshold_m:
matched_gt.add(gt_idx)
matched_det.add(det_idx)
break
tp = len(matched_gt)
fp = len(fused_detections) - len(matched_det)
fn = len(gt_positions) - tp
precision = tp / (tp + fp) if (tp + fp) > 0 else 1.0
recall = tp / (tp + fn) if (tp + fn) > 0 else 1.0
return {
'precision': precision,
'recall': recall,
'false_positive_count': fp,
'false_negative_count': fn,
'ground_truth_count': len(gt_positions)
}Edge Case Scenario Library
CARLA's ScenarioRunner supports parameterized scenario definitions. Build a library of edge cases that would be impractical to test on a real road:
# edge_cases.py — library of CARLA scenario parameters
EDGE_CASE_SCENARIOS = [
{
'id': 'EC-001',
'name': 'Pedestrian behind sun glare',
'weather': carla.WeatherParameters(
sun_altitude_angle=5.0, # Low sun angle
sun_azimuth_angle=0.0, # Directly in camera FOV
cloudiness=0.0,
fog_density=0.0
),
'expected_detection_recall': 0.85, # Relaxed for camera degradation
},
{
'id': 'EC-002',
'name': 'Heavy rain + reduced radar range',
'weather': carla.WeatherParameters(
precipitation=100.0,
precipitation_deposits=80.0,
wind_intensity=60.0
),
'radar_range_override_m': 40.0, # Rain attenuates radar
'expected_detection_recall': 0.90,
},
{
'id': 'EC-003',
'name': 'LiDAR occlusion by large truck',
'occluder': {'type': 'vehicle.carlamotors.carlacola', 'distance_m': 8.0},
'target': {'type': 'walker.pedestrian.*', 'behind_occluder': True},
'expected_detection_recall': 0.70, # Camera must compensate
},
{
'id': 'EC-004',
'name': 'Ghost object from road surface reflection',
'weather': carla.WeatherParameters(wetness=100.0),
'expected_false_positive_rate': 0.02, # Max 2% FP rate in wet conditions
},
]CI Integration for Simulation Tests
Running CARLA scenarios in CI requires headless mode and GPU access. Here's a GitHub Actions workflow using self-hosted runners with NVIDIA GPUs:
# .github/workflows/simulation-tests.yml
name: ADAS Simulation Tests
on:
push:
branches: [main, develop]
schedule:
- cron: '0 2 * * *' # Nightly full suite
jobs:
carla-scenarios:
runs-on: [self-hosted, gpu, linux]
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Start CARLA server (headless)
run: |
/opt/carla/CarlaUE4.sh \
-RenderOffScreen \
-quality-level=Low \
-world-port=2000 \
-benchmark -fps=20 &
# Wait for server ready
python3 -c "
import carla, time
for i in range(30):
try:
c = carla.Client('localhost', 2000)
c.set_timeout(2.0)
c.get_world()
print('CARLA ready')
break
except:
time.sleep(1)
"
- name: Run AEB scenarios
run: |
python3 tests/simulation/run_aeb_scenarios.py \
--scenarios configs/aeb_scenarios.yaml \
--output results/aeb_results.json \
--fail-on-any-failure
- name: Run sensor fusion edge cases
run: |
python3 tests/simulation/run_edge_cases.py \
--scenarios edge_cases.py \
--output results/edge_case_results.json
- name: Generate metrics report
run: |
python3 tests/simulation/generate_report.py \
--results results/*.json \
--output simulation_report.html \
--thresholds configs/pass_thresholds.yaml
- name: Check pass/fail thresholds
run: |
python3 - <<'EOF'
import json
with open('results/aeb_results.json') as f:
results = json.load(f)
failures = [r for r in results if not r['passed']]
if failures:
for f in failures:
print(f"FAIL: {f['scenario_id']} - {f['reason']}")
raise SystemExit(1)
recall_values = [r['metrics']['recall'] for r in results]
avg_recall = sum(recall_values) / len(recall_values)
if avg_recall < 0.95:
print(f"Mean recall {avg_recall:.3f} below 0.95 threshold")
raise SystemExit(1)
fp_rates = [r['metrics']['false_positive_rate'] for r in results]
max_fp = max(fp_rates)
if max_fp > 0.05:
print(f"Max false positive rate {max_fp:.3f} exceeds 0.05 limit")
raise SystemExit(1)
print(f"All scenarios passed. Mean recall: {avg_recall:.3f}, Max FP rate: {max_fp:.4f}")
EOF
- name: Upload simulation artifacts
uses: actions/upload-artifact@v4
if: always()
with:
name: simulation-results
path: |
results/
simulation_report.htmlKey Metrics to Track
Track these metrics across scenario runs to detect regressions in your ADAS algorithms:
| Metric | Definition | Target | Measurement |
|---|---|---|---|
| True Positive Rate (Recall) | GT objects detected / total GT objects | > 95% | Per scenario |
| False Positive Rate | False detections / total detections | < 2% | Per scenario |
| AEB Trigger Latency | Time from pedestrian visibility to braking | < 300ms | P99 across runs |
| False Brake Rate | AEB triggers on safe scenarios | 0 | Count |
| Position Error | Mean distance GT-to-fused object | < 0.5m | RMSE |
| Scenario Pass Rate | Passed / total scenarios | 100% | Per commit |
Track these over time with a tool like MLflow or simply store in a time-series database (InfluxDB works fine). A sudden drop in recall on EC-003 (LiDAR occlusion) after a sensor fusion algorithm change is immediately visible.
Simulation gives you reproducibility and scale that road testing never can. A scenario that took 6 hours of driving to capture once can be replayed 10,000 times overnight. Build the library, automate the metrics, and treat simulation regressions with the same urgency as unit test failures.