Perception › ROS 2 implementation · Acronym glossary

ROS 2 Perception System Design

A production-shaped, real-time perception stack for a self-driving vehicle: six surround cameras plus LiDAR into a shared BEV representation, 3D detection, BEV segmentation and multi-object tracking, delivering a documented contract to guidance/navigation/control — on an embedded GPU, inside a 100 ms budget. This page is the tutorial and the architecture; the companion pages cover the middleware and real-time engineering, the metric mathematics, and a red-team critique.

Symbols used on this page are defined in the Nomenclature → metrics.

Why a ROS 2 page at all? The BEVFusion / SimpleTrack deep dive explains the algorithms. Getting those algorithms to run at 10 Hz, on one GPU, with bounded latency, graceful sensor loss, and an auditable interface to the planner is a systems problem — and it is where most perception stacks actually fail. The interesting decisions here are QoS, composition, zero-copy, executor/thread placement, and the degraded-mode contract, not the neural network.
<150 mssensor → objects p99 10 Hzsustained, zero queue growth 2GPU copies per frame 15nodes, 4 processes 23 + 3message types + services

1 · Requirements — what the system must do

Every design decision later in this page traces back to one of these rows. Writing them down first is what distinguishes a system design from a collection of nodes.

1.1 Functional

IDRequirementConsequence for the design
F13D object detection from 6 surround cameras + 1 LiDAR, 10 classes, 0–54 mBEV fusion; two encoders that must run concurrently
F2BEV map segmentation (drivable area, crossings, dividers)a second head on the shared fused BEV tensor
F3Multi-object tracking with stable IDs and velocitystateful node ⇒ strict callback serialization; global-frame tracking
F4Publish objects, drivable grid, and a health/degradation status to GNCan explicit, versioned interface — not "whatever the tracker emits"
F5Operate degraded on partial sensor loss (≥4/6 cameras, LiDAR-only, camera-only)partial-frame synchronization; per-view validity mask; fallback head
F6Online quality evaluation against ground truth when availablea separate evaluation process, not an offline notebook
F7Deterministic replay of a recorded driveposes in messages, seq-keyed joins, sim time

1.2 Non-functional — the ones that shape the architecture

IDRequirementHow it is verified
N1sensor stamp → /perception/objects p99 < 150 ms/perception/stage_latency, LTTng trace
N210 Hz sustained, zero unbounded queue growthdropped_frames_total == 0 at nominal load
N3the 10 Hz detection loop must never delay the 100 Hz control loopseparate process + core isolation + cyclictest
N4< 1 CPU core for ingest of 6 cameras + LiDARpidstat
N5GPU memory bounded and known at startuppool high-water mark in diagnostics
N6sensor failure observable in < 150 msQoS deadline events; fault injection
N7no allocation, no device sync, no file I/O on the hot pathNsight Systems, code review, unit tests
N8buildable and testable without a GPUCI with PERCEPTION_WITH_CUDA=OFF
N8 is not a convenience. If the architecture can only be exercised on a vehicle with a GPU, then QoS behaviour, synchronization policy, executor placement, back-pressure and degraded modes are never tested — and those are precisely the parts that break. Putting the models behind an inference-backend interface with a deterministic stub makes the architecture a CI artifact.

2 · The latency budget — where 100 ms goes

A 10 Hz sensor cadence gives a 100 ms period. The p99 target is 150 ms from the instant light hits the sensor to the instant the planner can act on an object. Here is the accounting, with the concurrency made explicit:

sensor capture + driver publish
10
sync wait (worst case)
15
H2D + fused preprocess
5
camera backbone + LSS/BEVPool
25
voxelize + sparse conv (overlapped)
20
fuser + det head + NMS
15
seg head (low prio, droppable)
8
tracker (CPU, 7 classes)
5
publish + GNC ingest
2
0 25 50 75 ms 95 (p99 budget 150)

Total with overlap: ~85–95 ms, leaving ~55 ms of margin. That margin is not slack:

  • Thermal throttling. On an Orin the GPU clock drops progressively under sustained load. The first symptom is a rising p99, not a hard failure — so the system degrades before it starts dropping frames.
  • Tail events. A page fault, a log flush, or a DDS discovery burst adds milliseconds. Margin is what keeps a tail event from becoming a missed deadline.
  • Future work. An occupancy head costs ~25 ms. Knowing the margin tells you whether it fits without re-architecting.
The number that actually matters is not the mean. A stack with a 60 ms mean and a 400 ms p99 is unusable: the planner has to size its safety margins for the worst case it can observe, so a 400 ms tail costs 6 m of stopping distance at 15 m/s. Every latency figure in this design is a p99, measured per stage, and published on a topic.

2.1 Latency is a safety parameter, not a performance one

At 15 m/s (54 km/h) the vehicle covers 1.5 m per 100 ms. So perception latency translates directly into a position uncertainty for every object:

$$ \Delta x_{\text{stale}} = (v_{\text{ego}} + v_{\text{obj}})\cdot t_{\text{latency}} $$

For an oncoming vehicle at 15 m/s closing on our 15 m/s, a 150 ms latency is \(30 \times 0.15 = 4.5\) m of positional staleness. This is why the tracker publishes velocity and covariance: the planner forward-predicts each object to the start of its planning horizon and inflates by the propagated uncertainty, rather than treating a 150 ms-old box as current truth.

3 · Architecture at a glance

                    ┌──────────── SENSORS (separate processes) ────────────┐
                    │ 6× GMSL camera   LiDAR   radar   GNSS/IMU            │
                    └───┬──────────────┬─────────┬────────┬────────────────┘
      /cam_*/image_raw  │ /lidar/points│ /radar  │ /imu   │ /gnss
       (BE, KEEP_LAST 1)│ (BE, KL 1)   │         │        │
                        ▼              ▼         ▼        ▼
╔═══════════════ perception_container ── ONE PROCESS, 3 EXECUTORS ═══════════════╗
║                                                                                ║
║  image_ingest ──┐                          lidar_decoder                       ║
║   (6 streams,   │   GpuTensor handles        (de-skew, crop, accumulate)        ║
║    1× H2D)      └──────────► frame_sync ◄───────────┘                           ║
║                          (sweep-anchored; calib + ego-pose snapshot)            ║
║                                 │ SensorFrame  (handles + all matrices)         ║
║                    ┌────────────┴────────────┐                                  ║
║           camera_encoder               lidar_encoder      ← CONCURRENT           ║
║         (backbone + LSS + BEVPool)   (voxelize + sparse conv)                    ║
║                    └────────────┬────────────┘                                  ║
║                            bev_fuser   (seq-keyed join; LiDAR-only fallback)     ║
║                    ┌────────────┼────────────┐                                  ║
║               det_head      seg_head     (occ_head)                              ║
║                    │            │                                               ║
║                tracker       grids                                              ║
║                    │                                                            ║
║          health_node (degradation state machine)   viz_node (off by default)     ║
╚════════════════════════════════════════════════════════════════════════════════╝
      │ detections3d  tracks  objects  status  drivable_grid  stage_latency
      ├─────────────────────────────┬─────────────────────────────┐
      ▼                             ▼                             ▼
┌─ GNC container ────────┐ ┌─ eval_container ───────────┐ ┌─ rviz2 / rosbag2 ──┐
│ ekf → predictor →      │ │ det_eval    track_eval     │ │ markers, recording │
│ planner → controller   │ │ error_viz   dashboard      │ │                    │
│ 100 Hz  SCHED_FIFO     │ │ single-threaded, nice +10  │ │                    │
└────────────────────────┘ └────────────────────────────┘ └────────────────────┘
                                        ▲
                              /ground_truth/objects
                        (separate process: perception CANNOT consume it)

3.1 The three structural decisions, in order of impact

1 · data movement

Multi-megabyte data never leaves the process

Images, LiDAR sweeps and BEV feature tensors move as unique_ptr pointer moves inside one composed container. A 6 MB frame costs ~1 µs instead of 3–8 ms and a full CPU core.

→ Zero copy deep dive

2 · failure isolation

Compose within a criticality class; isolate across

Perception, GNC and evaluation are separate processes. A GPU hang or a TensorRT re-plan must not stall a 100 Hz hard-real-time control loop, and the act of measuring must not perturb the measured.

→ §4 process topology

3 · observability

Quality is a runtime signal

Detection and tracking errors are computed every frame, classified by cause, and rendered — not discovered in next week's notebook. "Recall dropped" becomes "that pedestrian at 38 m with 8 LiDAR points".

→ Metrics mathematics

4 · Process topology and node composition

The rule: compose within a criticality class, isolate across criticality classes. Composition buys throughput (pointer moves, one CUDA context, one DDS participant) but couples failure domains. So the boundaries are drawn along criticality, not along convenience.
ProcessCriticalityExecutorsWhy it is separate
sensor driverssoft RT, privilegedper driverraw sockets, vendor code, independent restart cadence
perception_containersoft RT, GPU, 10 Hz3needs intra-process zero copy across all 11 nodes
gnc_containerhard RT, 100 Hz2 (one SCHED_FIFO)a GPU hang must not stall control
eval_containernon-RT observer1 (single-threaded)measuring must not perturb; must be deterministic
ground_truthnon-RT1perception must be structurally unable to consume GT
rosbag2non-RT1a disk stall must not block perception

4.1 What composition actually buys — measured

 11 separate processes1 composed container
6 MB image hopCDR serialize + transport + deserialize ≈ 3–8 ms, ~1 corepointer move, ~1 µs
CUDA contexts11 × ~300 MB + context switching1 context, streams for concurrency
DDS participants11 (discovery is O(N²))1
TensorRT workspacesduplicated per processshared allocator/pool
Scheduling controlOS scheduler onlyexecutors + callback groups + affinity + stream priority
Blast radiusone nodewhole container ⇒ mitigated by the process split above

4.2 The one flag that makes it work

// Every composable node must forward NodeOptions, and the container must set this:
auto opts = rclcpp::NodeOptions().use_intra_process_comms(true);

class ImageIngestNode : public rclcpp::Node {
 public:
  explicit ImageIngestNode(const rclcpp::NodeOptions & options)
  : rclcpp::Node("image_ingest", options) {}   // <-- forwarding is MANDATORY
};
RCLCPP_COMPONENTS_REGISTER_NODE(perception::ImageIngestNode)
The classic composition bug: a node that does not forward NodeOptions to the rclcpp::Node base silently loses use_intra_process_comms, parameter overrides and remappings. Nothing errors — the pipeline just becomes 10× slower and the GPU handles become invalid. Every node in this design asserts the flag at startup and logs an error if it is false.

4.3 Both composition paths are supported

# custom container: explicit graph, 3 executors, CPU affinity, RT-friendly (vehicle)
ros2 launch perception_bringup full_system.launch.py container_type:=custom

# standard component_container_mt: dynamic load/unload (demos, A/B testing a head)
ros2 launch perception_bringup full_system.launch.py container_type:=mt
ros2 component list
ros2 component unload /perception_container 8      # drop the seg head, live
ros2 component load /perception_container perception_nodes perception::SegHeadNode

The dynamic path is genuinely useful: you can A/B two detection heads on the same recorded stream without restarting the pipeline or reloading the LiDAR backbone. Details and the custom container source →

5 · The nodes — what each one owns

5.1 Perception (11 nodes, one container)

#NodeConsumesProducesCallback group / CUDA stream
1image_ingest/cam_*/image_raw ×6img_batch_gpu [6,3,256,704] fp16Reentrant · 6 upload streams
2lidar_decoder/lidar/points, /odomcloud_gpu [N,5] fp32Reentrant · 1 stream
3frame_syncboth handles, TF, CameraInfoSensorFrameMutEx + 5 ms tick timer
4camera_encoderSensorFramebev_feat_cam [1,80,180,180]MutEx · s_cam
5lidar_encoderSensorFramebev_feat_lidar [1,256,180,180]MutEx · s_lidar
6bev_fuserboth BEV tensorsbev_feat_fused [1,512,180,180]MutEx · s_head (high)
7det_headfused BEVDetections3D + vision_msgs twinMutEx · s_head
8seg_headfused BEVBevGrid + OccupancyGridMutEx · s_seg (low)
9trackerDetections3DTrackArray → /tracks, /objectsMutEx (never reentrant)
10healthstage latency, detections, tracksPerceptionStatus, /diagnosticsservice group
11viztracks, detectionsMarkerArrayservice group · off by default

5.2 Evaluation and display (4 nodes + ground truth)

NodeConsumesProducesPurpose
ground_truthdataset / simulatorGroundTruthArraythe reference signal; RELIABLE; own process
det_evaldetections + GTDetectionMetrics, EvalFramemAP/NDS + classified misses and FPs
track_evaltracks + GTTrackingMetrics, EvalFrameMOTA/AMOTA, ID switches, MT/ML
error_vizEvalFrame, GT3 × MarkerArrayTP/FP/MISS boxes + error vectors in rviz
dashboardall metrics + statusterminal + /diagnosticsone screen: latency, quality, worst case now

5.3 The three nodes worth a closer look

frame_sync — the correctness heart

Six cameras and a LiDAR are not triggered together, and DDS gives no cross-topic ordering. message_filters::ApproximateTime optimizes for "match all topics", can hold a frame for a full period, and offers no control over the deadline or over partial results. For a surround-view detector that must keep working on 5/6 cameras, that is disqualifying. So the policy is explicit and unit-tested:

anchor      = LiDAR sweep END time            (the BEVFusion / nuScenes convention)
per camera  = nearest stamp within slop (50 ms), residual skew reported in the message
emit when   = anchor + ≥ min_cameras (4) available
              OR max_wait (60 ms) expires → PARTIAL frame + valid_camera_mask
new anchor  = SUPERSEDES any pending one (latest-only); the drop is COUNTED

It also snapshots every calibration matrix and the ego pose into the outgoing SensorFrame. Consequence: no model node ever calls TF2 on the hot path (no mutex, no extrapolation exception, no timeout), and a recorded bundle replays deterministically in a unit test.

bev_fuser — the interesting part is the join, not the convolution

Two independent asynchronous producers must be paired for the same frame. The join is keyed on seq (never arrival order), uses a 2-slot table, never blocks on a straggler, and has an explicit degraded path:

If the camera branch has been silent for N frames, forward LiDAR-only — do not fuse a zeroed camera tensor. A convolutional fuser fed zeros produces confident-looking features and therefore confident-looking garbage detections. A LiDAR-only head is worse but honest, and the mode is stamped into the output message so the planner can weight it.

tracker — stateful, therefore strictly serialized

The tracker holds Kalman state across frames. It lives in a MutuallyExclusive callback group and must never be reentrant: two concurrent steps would corrupt covariances and scramble IDs. It also tracks in the global (map) frame — in the ego frame, the vehicle's own motion appears as object motion and a constant-velocity filter fights the vehicle's dynamics, so a parked car looks like it is moving at −15 m/s.

And a subtle one: a sequence break (bag loop, scene change, localization jump) must reset the bank. Without it the filters try to explain a teleport as velocity and emit 40 m/s pedestrians straight into the planner.

6 · Every message, and why it exists

23 message types (plus 3 services) in four families, each with different rules. The families are the design: a message's family determines its QoS, whether it can cross a process boundary, and whether it may contain a pointer.

6.1 Family A — GPU handle messages (intra-process ONLY)

These carry a uint64 pointer, not data. A [1,256,180,180] fp16 BEV feature map is 16.6 MB; serializing it would cost ~10 ms and a full core.

MessageKey fieldsRole
GpuTensorhandle, cuda_event, producer_pid, dtype, shape, bytes, aux_mask, pipeline_latency_msany device tensor between stages
CameraCalibk(3×3), camera2ego, camera2lidar, lidar2image, img_aug_matrix, skew_ms, validper-view calibration snapshot for one frame
SensorFrameimages, points, valid_camera_mask, cameras[], lidar2ego, ego2global, ego2global_prev, dt_since_prev, sync_skew_ms, is_first_in_sequencethe single input contract for every model node
The six invariants that keep this safe (all checked in code):
  1. publisher and subscriber in the same process, use_intra_process_comms=true;
  2. QoS must be BEST_EFFORT, KEEP_LAST(1), VOLATILE — TRANSIENT_LOCAL would force a publisher-side copy and destroy the move;
  3. published as std::unique_ptr (move) or fanned out as ConstSharedPtr; never by value;
  4. producer_pid == getpid() on the consumer, else drop the frame and raise a diagnostic;
  5. the consumer waits on cuda_event with cudaStreamWaitEvent — never cudaDeviceSynchronize;
  6. the referenced buffer is refcounted by a pool, so a producer cannot recycle memory a subscriber is still reading.

6.2 Family B — result messages (to GNC)

MessageKey fieldsNotes
Detection3Dpose, size, velocity, pose_covariance[36], label, score, class_scores, num_lidar_points, sensor_maskbox convention documented in the .msg — see §6.5
Detections3Ddetections[], class_names[], ego2global, mode, valid_camera_mask, pipeline_latency_msmode = FUSION / LIDAR_ONLY / CAMERA_ONLY: provenance for the planner
Tracktrack_id, detection, state (BIRTH/ALIVE/COASTED/DEAD), age, hits, consecutive_misses, existence_prob, kf_state, kf_covariancecovariance is published because the planner needs uncertainty, not a point estimate
TrackArraytracks[], ego2global, total_ids_issued, was_reset, pipeline_latency_mswas_reset tells consumers to ignore ID continuity for one cycle
BevGridresolution, width/height, origin, channel_names[], data (CHW uint8)multi-label (sigmoid per channel), not multi-class softmax
PerceptionStatuslevel, reason, age_of_last_objects_ms, pipeline_latency_p99_ms, max_speed_advice_mps, max_range_valid_m, stages[]the GNC contract; absence of this topic ⇒ FAULT
StageLatencystage_ms, cumulative_ms, p50/p95/p99/max, dropped_total, deadline_misses, gpu_bytes_in_useyou cannot debug a multi-stage GPU pipeline without this

6.3 Family C — fixed-size POD messages (loanable / true SHM zero copy)

MessageWhyCost
PointXYZITPOD point (x,y,z,intensity,dt,ring) so the cloud stays a fixed array—
FixedPointCloudPointXYZIT[300000] + num_points ⇒ POD ⇒ loanable into shared memory7.2 MB of SHM per sample regardless of content
FixedImageuint8[6220800] + enum encoding (a string would break POD)6.2 MB per sample; no rviz/rosbag support

Used only on the driver→container hop, where a copy would otherwise cost ~3 ms and a core at 20 Hz. Why sensor_msgs/Image can never be loaned →

6.4 Family D — ground truth and evaluation

MessageKey fieldsWhy it exists
GroundTruthObjecttrack_id (stable!), num_lidar_points, visibility, range_m, is_moving, attributethe difficulty fields are what the evaluation must filter on
GroundTruthArrayobjects[], class_names[], ego2global, is_key_frameRELIABLE: a lost GT frame silently biases every metric
EvalMatcherror_longitudinal_m, error_lateral_m, error_yaw_rad, error_scale, error_speed_mps (all signed)a consistent −30 cm range error is a bug; random ±30 cm is noise
EvalMissreason enum, nearest_detection_distance_m, nearest_detection_scoreturns "recall is 0.62" into a work item
EvalFalsePositivereason enum, counted_in_metricslets a detection beyond the annotated range be shown but not scored
EvalFramematches[], misses[], false_positives[]small enough to record every frame ⇒ offline triage without re-inference
DetectionMetricsmap, nds, precision/recall, ATE/ASE/AOE/AVE, per_class[], per_range[]never just the mean — see the math
TrackingMetricsmota, motp, amota, id_f1, total_id_switches, mostly_lost_ratioID switches are a first-class metric, not a footnote
ClassMetrics / RangeMetricsper-stratum counts and errorsstratification is mandatory, not optional

6.5 Services — and when not to use one

ServiceRequestPurpose
ResetTrackerclear_ids, reasonnew scene / bag loop / after a localization jump
SetPipelineModemode, enable_seg_head, enable_occ_head, enable_debug_vizfree GPU headroom during a hard maneuver; force LiDAR-only for a calibration test
ResetMetricswrite_report, report_pathsegment-wise evaluation ("give me the numbers for just this intersection")
Services are not allowed on the perception hot path. Two reasons. (1) The classic ROS 2 deadlock: calling spin_until_future_complete inside a subscription callback on a single-threaded executor blocks the very thread that must process the response. (2) A service call has unbounded latency by nature. So every service here lives in a separate MutuallyExclusive callback group routed to the housekeeping executor, and is used only for configuration-scale events — never for per-frame data. Per-frame data is always a topic.

6.6 Parameters — and the read-only discipline

Everything that affects geometry, memory or timing is a parameter, so one binary runs on nuScenes replay and on the vehicle. But two categories are declared read-only:

rcl_interfaces::msg::ParameterDescriptor ro;
ro.read_only = true;                        // changing it must be a RECONFIGURE, not a set
engine_path_  = declare_parameter<std::string>("engine_path", default_engine, ro);
backend_      = declare_parameter<std::string>("backend", "stub", ro);

Swapping a TensorRT engine at runtime would invalidate every preallocated buffer, so it is a lifecycle transition (deactivate → cleanup → configure), not a parameter write. Everything else (thresholds, NMS type, tracker gates, deadlines) is live-tunable via add_on_set_parameters_callback with range validation — and rejected atomically if invalid.

6.7 The box convention — the single most common silent bug

ConventionPositionSizeRotation
nuScenes / BEVFusion headbox center(w, l, h)yaw about +Z, x forward
mmdet3d LiDARInstance3DBoxesbottom face(w, l, h)yaw about +Z
our Detection3Dgravity centersize.x=l, size.y=w, size.z=hquaternion (yaw-only)
vision_msgs/BoundingBox3Dbox centerfull extents in box framequaternion
KITTIbottom center(h, w, l)ry about +Y, camera frame

A forgotten h/2 shift or a swapped (w,l) produces boxes that look plausible and quietly destroy mAP and GIoU association at range. So every conversion lives in one header with a round-trip unit test, and no node is allowed to inline one.

7 · Time, frames, and synchronization

7.1 Frames (REP-103 / REP-105)

map ──(localization: JUMPS on loop closure)──▶ odom ──(smooth)──▶ base_link
                                                                    ├─ lidar_top
                                                                    ├─ cam_front, cam_front_left, …
                                                                    ├─ radar_front
                                                                    └─ imu_link
UseFrameWhy
tracking (Kalman filter)mapin the ego frame, ego motion looks like object motion and a CV filter fights the vehicle's own dynamics
control derivativesodomcontinuous; differentiating map→base_link gives 200 m/s spikes on loop closure
perception grids, evaluationbase_linkego-centric raster; range stratification is ego-relative
The derivative trap. Computing velocity by differencing map→base_link poses is a real, shipped bug class: on loop closure the pose jumps ~2 m in one cycle and a 100 Hz controller observes 200 m/s. Always differentiate odom→base_link, and take velocity from the EKF state, never from finite differences of a pose that can teleport.

7.2 Time rules

  1. Stamp from the sensor, never now(). A replay node that stamps with now() makes every latency number a lie and breaks the synchronizer, because message_filters-style matching keys on header.stamp only.
  2. RCL_STEADY_TIME for durations and timeouts; RCL_ROS_TIME for anything stamp-related. An NTP step will otherwise corrupt a measured interval.
  3. Bag replay requires use_sim_time:=true and --clock, for all nodes. Mixed sim/wall time in one graph silently matches nothing.
  4. PTP/gPTP across ECUs. 5 ms of clock skew at 15 m/s is 7.5 cm of position bias — which looks exactly like a calibration error and can waste weeks.

7.3 Sweep-anchored synchronization, visualized

LiDAR sweep (anchor)
sweep end
cam_front
−22 ms
cam_front_left
+8 ms
cam_back (dead)
no frame → mask bit 0
emit SensorFrame
5/6 valid
slop window −50 ms anchor max_wait +60 ms

A dead camera therefore costs one mask bit, not the frame. That single property is why the stack survives a GMSL deserializer glitch that would otherwise stall the pipeline until the watchdog fired.

8 · How this becomes real-time

"Real-time" here means bounded, measured latency with a defined behaviour when the bound is missed — not "fast". Five mechanisms, in the order they matter:

1 · bound the work

Latest-only, drop-and-count

Every queue is KEEP_LAST(1) with a lifespan, and the application enforces the same policy: if a stage is busy, the older pending frame is dropped and counted. Nothing ever queues, nothing ever blocks.

auto buf = pool->acquire();
if (!buf) {                       // pool exhausted
  stats_.record_drop();           // COUNT it
  return;                         // DROP the frame — never block
}

Blocking would couple ingest latency to the slowest consumer, and the queue would grow without bound — the classic way a "fast" pipeline becomes unusable after 90 seconds.

2 · remove the spikes

No allocation, no device sync, no I/O

All device and pinned memory is pre-allocated in on_configure(). A cudaMalloc synchronizes the device and can cost milliseconds; a page fault costs tens of microseconds; a TensorRT engine load costs seconds.

mlockall(MCL_CURRENT | MCL_FUTURE);  // no page faults in callbacks
cudaSetDevice(0); cudaFree(nullptr); // force CUDA context NOW (~300 ms),
                                     // not inside the first callback
3 · control who runs when

One executor per criticality class

Not one big MultiThreadedExecutor. Three executors with explicit callback groups, thread counts, CPU affinity and nice levels — plus a SCHED_FIFO executor in the GNC process on isolated cores.

→ Executors deep dive

4 · overlap the work

CUDA stream priorities

Camera and LiDAR encoders run on separate normal-priority streams (concurrent); the fuser/detection head runs on a high-priority stream; the segmentation head on a low-priority one so it slips a frame instead of delaying detection.

5 · define what happens when the bound is missed

Observable staleness and a degradation contract

Every sensor subscription sets a QoS deadline (1.5× the nominal period) with a handler wired to a state machine. A deadline without a handler is decoration.

so.event_callbacks.deadline_callback = [this](auto & info) {
    health_.mark_stale("cam_front", info.total_count);   // → DEGRADED_CAM
};

8.1 Core and stream allocation (Jetson AGX Orin, 12 cores)

CoresOwnerPolicy
0–1OS, rmw/DDS threads, loggingSCHED_OTHER
2–3GNC RT executorSCHED_FIFO 80, isolcpus, nohz_full
4–5perception ingest executor (3 threads)nice −5
6–9perception GPU executor (4 threads)default
10–11eval, rosbag2, viz, diagnosticsnice +5 / +10

Inside an RT callback, never: allocate, take a lock shared with a non-RT thread, log to disk, call into CUDA, call lookupTransform with a timeout, or use std::chrono::system_clock. State crosses the RT boundary through a double-buffer with an atomic sequence counter (a seqlock), not a mutex.

8.2 Measured targets

LoopPeriodp99 jitter targetMeasured with
Controller10 ms< 250 µscyclictest -m -p80 -i10000 + in-loop stamps
Planner100 ms< 5 msros2_tracing timer histogram
Perception end-to-end100 msp99 < 150 ms/perception/stage_latency

9 · The GNC contract and degraded modes

GNC consumes exactly four things. Everything else on the graph is debug. Keeping the interface this narrow is what makes the perception stack replaceable.

TopicContract
/perception/objectstracked objects in map. Consumer must check header.stamp age — the topic is latched, so the first sample can be arbitrarily old.
/perception/drivable_gridcostmap input. -1 = unknown ⇒ inflate, never treat as free.
/perception/statusdegradation level + speed advice. Absence for 500 ms ⇒ assume FAULT.
/tf, /tf_staticframes.

9.1 The state machine — owned by exactly one node

NOMINAL ──cam branch silent ×3 (~300 ms)──▶ DEGRADED_CAM    LiDAR-only head,
   ▲                                                         cap 8 m/s, trust ≤ 40 m
   │◀────────── cameras good ×5  (recover SLOW) ──────────────┘
   ├──lidar silent ×2──────────▶ DEGRADED_LIDAR   camera-only BEV, shorter horizon
   ├──p99 > latency budget─────▶ DEGRADED_CAM      (thermal: slow down BEFORE dropping)
   ├──objects older than 300 ms▶ STALE             coast ≤ coast_ms, then brake
   └──GPU / model fault────────▶ FAULT             safe stop; lifecycle retry
Degrade fast, recover slow (3 frames down, 5 frames up). Oscillating between speed envelopes is harder on the controller — and on passengers — than staying conservative. The asymmetry is deliberate, parameterized, and tested by fault injection.

9.2 The three rules a planner must implement

// 1. AGE GATE. TRANSIENT_LOCAL means the first sample can be arbitrarily old.
//    A planner that trusts it will swerve around a car that was there two minutes ago.
if ((now() - msg->header.stamp).seconds() > max_age_) { coast_then_brake(); return; }

// 2. STATUS GATE. Perception tells you what it can currently see.
v_cmd = std::min(v_cmd, status_.max_speed_advice_mps);
// and ignore objects beyond status_.max_range_valid_m in the current mode

// 3. INDEPENDENT WALL-CLOCK WATCHDOG. A QoS deadline event is delivered by the same
//    executor that might itself be stuck, so the actuator keeps its own steady-clock timer.
if (steady_now() - last_cmd_ > watchdog_) actuate_safe_state();

9.3 Failure taxonomy

FailureDetected byPerception actionGNC action
1 camera diesper-topic QoS deadline (150 ms)zero-fill view, clear mask bitcontinue, log
≥3 cameras / branch silentfuser streak counterLiDAR-only fusion (not a zeroed branch)DEGRADED_CAM
LiDAR diesdeadline, no sweepscamera-only BEV if supportedDEGRADED_LIDAR → STALE
GPU / TRT errorenqueue return codelifecycle deactivate → cleanup → reconfigureFAULT
GPU hang500 ms watchdog on the head's CUDA eventcontainer restart via supervisorFAULT via status deadline
Node stuck in a callbackQoS liveliness MANUAL_BY_TOPIChealth marks it deadFAULT
Container crashsystemd Restart=on-failure—no status in 500 ms ⇒ safe stop
Thermal throttlingp99 > latency_budgetDEGRADED before frames dropreduce speed
Localization lossEKF covariancekeep ego-frame grids, flag tracksego-frame reactive mode
Clock jump / bag loopdt > sequence_gap_stracker reset; evaluator clears the GT→track mapignore IDs one cycle

Escalation timing is derived, not guessed: at 10 Hz with a 150 ms budget, a deadline of 150 ms makes a dead sensor visible within ~1.5 frames; 3 frames to degrade is 300 ms ≈ 4.5 m of travel at 15 m/s; 300 ms to STALE is the most blind travel we accept before coasting to a stop.

10 · Running it

The whole system is one command, and it runs on a laptop with no GPU and no dataset — which is the point of requirement N8.

# build (host reference kernels + deterministic stub models)
cd Ros2/ws && colcon build --symlink-install
source install/setup.bash

# ── everything: sensors + ground truth + perception + evaluation + display ──
ros2 launch perception_bringup full_system.launch.py rviz:=true

# perception only, no evaluation — what actually runs on a vehicle
ros2 launch perception_bringup full_system.launch.py evaluation:=false ground_truth:=false

# real nuScenes data, real TensorRT engines, with evaluation
ros2 launch perception_bringup full_system.launch.py \
    source:=nuscenes dataroot:=$NUSCENES_ROOT backend:=tensorrt rviz:=true

# ── verify the claims ──
ros2 node list | wc -l                                   # 15
pgrep -a perception_container                             # ONE pid
ps -L -p $(pgrep -f perception_container) | wc -l         # 3+4+1+rmw threads, not 12
ros2 run perception_py latency_report                     # per-stage p50/p95/p99
ros2 topic echo /perception/status                        # the degradation contract

10.1 What the workspace contains

PackageContents
perception_msgs23 messages, 3 services, in the four families of §6
perception_commonheader-only: QoS profiles, refcounted CUDA buffer pool + streams, TypeAdapters, sweep-anchored synchronizer, TF/calibration cache, box conversions, latency histograms
perception_nodes11 composable nodes, the custom multi-executor container, the inference-backend seam, the tracker, host reference CUDA kernels
perception_evalGT↔estimate matching with classified causes, MOT accumulator, 4 eval/display nodes
perception_pysensor replay (nuScenes/synthetic), ground-truth publisher, latency report
perception_bringuplaunch files, parameters, DDS/SHM profiles, bag QoS overrides, rviz configs

10.2 Test pyramid

 few  │ fault injection: SIGSTOP a camera, kill the container, step the clock, OOM the pool
      │ scenario: nuScenes scene → mAP / NDS / AMOTA vs a baseline          (nightly)
      │ integration: full_system.launch.py source:=synthetic                (CI, no GPU)
      │    asserts 1 process, dropped==0, p99 < budget, recall == expected
 many │ unit: 7 gtest suites                                               (CI, ms)
SuiteProtects against
test_box_convertw/l swap, bottom-vs-center, yaw sign, velocity translated instead of rotated
test_sweep_syncstalling on a dead camera, matching the wrong frame, queueing stale sweeps
test_cuda_buffer_poolfreeing a buffer a subscriber holds; blocking instead of dropping
test_trackerID switches, phantom tracks, losing an object through occlusion, cross-class ID theft
test_kernelsresize/crop drift from the intrinsics, de-skew silently disabled, voxel coord order
test_matchinggreedy ordering, miss/FP cause classification, annotation-range fairness, AP edge cases
test_mot_accumulatorID-switch detection, match persistence, sequence-break reset, MT/ML, negative MOTA

11 · Continue

11.1 Source

Everything on these pages is implemented in Ros2/: SYSTEM_DESIGN.md (the full design document), docs/01–docs/11 (topic deep dives), docs/99_interview_drills.md, ws/src/ (6 packages, 15 nodes, 7 test suites), and exercises/ (10 drills with falsifiable acceptance criteria). The algorithms it runs are documented in the BEVFusion / Occupancy / Tracking deep dive.