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.
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
| ID | Requirement | Consequence for the design |
|---|---|---|
| F1 | 3D object detection from 6 surround cameras + 1 LiDAR, 10 classes, 0–54 m | BEV fusion; two encoders that must run concurrently |
| F2 | BEV map segmentation (drivable area, crossings, dividers) | a second head on the shared fused BEV tensor |
| F3 | Multi-object tracking with stable IDs and velocity | stateful node ⇒ strict callback serialization; global-frame tracking |
| F4 | Publish objects, drivable grid, and a health/degradation status to GNC | an explicit, versioned interface — not "whatever the tracker emits" |
| F5 | Operate degraded on partial sensor loss (≥4/6 cameras, LiDAR-only, camera-only) | partial-frame synchronization; per-view validity mask; fallback head |
| F6 | Online quality evaluation against ground truth when available | a separate evaluation process, not an offline notebook |
| F7 | Deterministic replay of a recorded drive | poses in messages, seq-keyed joins, sim time |
1.2 Non-functional — the ones that shape the architecture
| ID | Requirement | How it is verified |
|---|---|---|
| N1 | sensor stamp → /perception/objects p99 < 150 ms | /perception/stage_latency, LTTng trace |
| N2 | 10 Hz sustained, zero unbounded queue growth | dropped_frames_total == 0 at nominal load |
| N3 | the 10 Hz detection loop must never delay the 100 Hz control loop | separate process + core isolation + cyclictest |
| N4 | < 1 CPU core for ingest of 6 cameras + LiDAR | pidstat |
| N5 | GPU memory bounded and known at startup | pool high-water mark in diagnostics |
| N6 | sensor failure observable in < 150 ms | QoS deadline events; fault injection |
| N7 | no allocation, no device sync, no file I/O on the hot path | Nsight Systems, code review, unit tests |
| N8 | buildable and testable without a GPU | CI with PERCEPTION_WITH_CUDA=OFF |
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:
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.
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
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.
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.
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".
4 · Process topology and node composition
| Process | Criticality | Executors | Why it is separate |
|---|---|---|---|
| sensor drivers | soft RT, privileged | per driver | raw sockets, vendor code, independent restart cadence |
perception_container | soft RT, GPU, 10 Hz | 3 | needs intra-process zero copy across all 11 nodes |
gnc_container | hard RT, 100 Hz | 2 (one SCHED_FIFO) | a GPU hang must not stall control |
eval_container | non-RT observer | 1 (single-threaded) | measuring must not perturb; must be deterministic |
ground_truth | non-RT | 1 | perception must be structurally unable to consume GT |
rosbag2 | non-RT | 1 | a disk stall must not block perception |
4.1 What composition actually buys — measured
| 11 separate processes | 1 composed container | |
|---|---|---|
| 6 MB image hop | CDR serialize + transport + deserialize ≈ 3–8 ms, ~1 core | pointer move, ~1 µs |
| CUDA contexts | 11 × ~300 MB + context switching | 1 context, streams for concurrency |
| DDS participants | 11 (discovery is O(N²)) | 1 |
| TensorRT workspaces | duplicated per process | shared allocator/pool |
| Scheduling control | OS scheduler only | executors + callback groups + affinity + stream priority |
| Blast radius | one node | whole 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)
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)
| # | Node | Consumes | Produces | Callback group / CUDA stream |
|---|---|---|---|---|
| 1 | image_ingest | /cam_*/image_raw ×6 | img_batch_gpu [6,3,256,704] fp16 | Reentrant · 6 upload streams |
| 2 | lidar_decoder | /lidar/points, /odom | cloud_gpu [N,5] fp32 | Reentrant · 1 stream |
| 3 | frame_sync | both handles, TF, CameraInfo | SensorFrame | MutEx + 5 ms tick timer |
| 4 | camera_encoder | SensorFrame | bev_feat_cam [1,80,180,180] | MutEx · s_cam |
| 5 | lidar_encoder | SensorFrame | bev_feat_lidar [1,256,180,180] | MutEx · s_lidar |
| 6 | bev_fuser | both BEV tensors | bev_feat_fused [1,512,180,180] | MutEx · s_head (high) |
| 7 | det_head | fused BEV | Detections3D + vision_msgs twin | MutEx · s_head |
| 8 | seg_head | fused BEV | BevGrid + OccupancyGrid | MutEx · s_seg (low) |
| 9 | tracker | Detections3D | TrackArray → /tracks, /objects | MutEx (never reentrant) |
| 10 | health | stage latency, detections, tracks | PerceptionStatus, /diagnostics | service group |
| 11 | viz | tracks, detections | MarkerArray | service group · off by default |
5.2 Evaluation and display (4 nodes + ground truth)
| Node | Consumes | Produces | Purpose |
|---|---|---|---|
| ground_truth | dataset / simulator | GroundTruthArray | the reference signal; RELIABLE; own process |
| det_eval | detections + GT | DetectionMetrics, EvalFrame | mAP/NDS + classified misses and FPs |
| track_eval | tracks + GT | TrackingMetrics, EvalFrame | MOTA/AMOTA, ID switches, MT/ML |
| error_viz | EvalFrame, GT | 3 × MarkerArray | TP/FP/MISS boxes + error vectors in rviz |
| dashboard | all metrics + status | terminal + /diagnostics | one 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:
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.
| Message | Key fields | Role |
|---|---|---|
GpuTensor | handle, cuda_event, producer_pid, dtype, shape, bytes, aux_mask, pipeline_latency_ms | any device tensor between stages |
CameraCalib | k(3×3), camera2ego, camera2lidar, lidar2image, img_aug_matrix, skew_ms, valid | per-view calibration snapshot for one frame |
SensorFrame | images, points, valid_camera_mask, cameras[], lidar2ego, ego2global, ego2global_prev, dt_since_prev, sync_skew_ms, is_first_in_sequence | the single input contract for every model node |
- publisher and subscriber in the same process,
use_intra_process_comms=true; - QoS must be
BEST_EFFORT,KEEP_LAST(1),VOLATILE—TRANSIENT_LOCALwould force a publisher-side copy and destroy the move; - published as
std::unique_ptr(move) or fanned out asConstSharedPtr; never by value; producer_pid == getpid()on the consumer, else drop the frame and raise a diagnostic;- the consumer waits on
cuda_eventwithcudaStreamWaitEvent— nevercudaDeviceSynchronize; - 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)
| Message | Key fields | Notes |
|---|---|---|
Detection3D | pose, size, velocity, pose_covariance[36], label, score, class_scores, num_lidar_points, sensor_mask | box convention documented in the .msg — see §6.5 |
Detections3D | detections[], class_names[], ego2global, mode, valid_camera_mask, pipeline_latency_ms | mode = FUSION / LIDAR_ONLY / CAMERA_ONLY: provenance for the planner |
Track | track_id, detection, state (BIRTH/ALIVE/COASTED/DEAD), age, hits, consecutive_misses, existence_prob, kf_state, kf_covariance | covariance is published because the planner needs uncertainty, not a point estimate |
TrackArray | tracks[], ego2global, total_ids_issued, was_reset, pipeline_latency_ms | was_reset tells consumers to ignore ID continuity for one cycle |
BevGrid | resolution, width/height, origin, channel_names[], data (CHW uint8) | multi-label (sigmoid per channel), not multi-class softmax |
PerceptionStatus | level, 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 |
StageLatency | stage_ms, cumulative_ms, p50/p95/p99/max, dropped_total, deadline_misses, gpu_bytes_in_use | you cannot debug a multi-stage GPU pipeline without this |
6.3 Family C — fixed-size POD messages (loanable / true SHM zero copy)
| Message | Why | Cost |
|---|---|---|
PointXYZIT | POD point (x,y,z,intensity,dt,ring) so the cloud stays a fixed array | — |
FixedPointCloud | PointXYZIT[300000] + num_points ⇒ POD ⇒ loanable into shared memory | 7.2 MB of SHM per sample regardless of content |
FixedImage | uint8[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
| Message | Key fields | Why it exists |
|---|---|---|
GroundTruthObject | track_id (stable!), num_lidar_points, visibility, range_m, is_moving, attribute | the difficulty fields are what the evaluation must filter on |
GroundTruthArray | objects[], class_names[], ego2global, is_key_frame | RELIABLE: a lost GT frame silently biases every metric |
EvalMatch | error_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 |
EvalMiss | reason enum, nearest_detection_distance_m, nearest_detection_score | turns "recall is 0.62" into a work item |
EvalFalsePositive | reason enum, counted_in_metrics | lets a detection beyond the annotated range be shown but not scored |
EvalFrame | matches[], misses[], false_positives[] | small enough to record every frame ⇒ offline triage without re-inference |
DetectionMetrics | map, nds, precision/recall, ATE/ASE/AOE/AVE, per_class[], per_range[] | never just the mean — see the math |
TrackingMetrics | mota, motp, amota, id_f1, total_id_switches, mostly_lost_ratio | ID switches are a first-class metric, not a footnote |
ClassMetrics / RangeMetrics | per-stratum counts and errors | stratification is mandatory, not optional |
6.5 Services — and when not to use one
| Service | Request | Purpose |
|---|---|---|
ResetTracker | clear_ids, reason | new scene / bag loop / after a localization jump |
SetPipelineMode | mode, enable_seg_head, enable_occ_head, enable_debug_viz | free GPU headroom during a hard maneuver; force LiDAR-only for a calibration test |
ResetMetrics | write_report, report_path | segment-wise evaluation ("give me the numbers for just this intersection") |
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
| Convention | Position | Size | Rotation |
|---|---|---|---|
| nuScenes / BEVFusion head | box center | (w, l, h) | yaw about +Z, x forward |
mmdet3d LiDARInstance3DBoxes | bottom face | (w, l, h) | yaw about +Z |
our Detection3D | gravity center | size.x=l, size.y=w, size.z=h | quaternion (yaw-only) |
vision_msgs/BoundingBox3D | box center | full extents in box frame | quaternion |
| KITTI | bottom 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| Use | Frame | Why |
|---|---|---|
| tracking (Kalman filter) | map | in the ego frame, ego motion looks like object motion and a CV filter fights the vehicle's own dynamics |
| control derivatives | odom | continuous; differentiating map→base_link gives 200 m/s spikes on loop closure |
| perception grids, evaluation | base_link | ego-centric raster; range stratification is ego-relative |
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
- Stamp from the sensor, never
now(). A replay node that stamps withnow()makes every latency number a lie and breaks the synchronizer, becausemessage_filters-style matching keys onheader.stamponly. RCL_STEADY_TIMEfor durations and timeouts;RCL_ROS_TIMEfor anything stamp-related. An NTP step will otherwise corrupt a measured interval.- Bag replay requires
use_sim_time:=trueand--clock, for all nodes. Mixed sim/wall time in one graph silently matches nothing. - 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
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:
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.
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
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.
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.
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)
| Cores | Owner | Policy |
|---|---|---|
| 0–1 | OS, rmw/DDS threads, logging | SCHED_OTHER |
| 2–3 | GNC RT executor | SCHED_FIFO 80, isolcpus, nohz_full |
| 4–5 | perception ingest executor (3 threads) | nice −5 |
| 6–9 | perception GPU executor (4 threads) | default |
| 10–11 | eval, rosbag2, viz, diagnostics | nice +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
| Loop | Period | p99 jitter target | Measured with |
|---|---|---|---|
| Controller | 10 ms | < 250 µs | cyclictest -m -p80 -i10000 + in-loop stamps |
| Planner | 100 ms | < 5 ms | ros2_tracing timer histogram |
| Perception end-to-end | 100 ms | p99 < 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.
| Topic | Contract |
|---|---|
/perception/objects | tracked objects in map. Consumer must check header.stamp age — the topic is latched, so the first sample can be arbitrarily old. |
/perception/drivable_grid | costmap input. -1 = unknown ⇒ inflate, never treat as free. |
/perception/status | degradation level + speed advice. Absence for 500 ms ⇒ assume FAULT. |
/tf, /tf_static | frames. |
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
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
| Failure | Detected by | Perception action | GNC action |
|---|---|---|---|
| 1 camera dies | per-topic QoS deadline (150 ms) | zero-fill view, clear mask bit | continue, log |
| ≥3 cameras / branch silent | fuser streak counter | LiDAR-only fusion (not a zeroed branch) | DEGRADED_CAM |
| LiDAR dies | deadline, no sweeps | camera-only BEV if supported | DEGRADED_LIDAR → STALE |
| GPU / TRT error | enqueue return code | lifecycle deactivate → cleanup → reconfigure | FAULT |
| GPU hang | 500 ms watchdog on the head's CUDA event | container restart via supervisor | FAULT via status deadline |
| Node stuck in a callback | QoS liveliness MANUAL_BY_TOPIC | health marks it dead | FAULT |
| Container crash | systemd Restart=on-failure | — | no status in 500 ms ⇒ safe stop |
| Thermal throttling | p99 > latency_budget | DEGRADED before frames drop | reduce speed |
| Localization loss | EKF covariance | keep ego-frame grids, flag tracks | ego-frame reactive mode |
| Clock jump / bag loop | dt > sequence_gap_s | tracker reset; evaluator clears the GT→track map | ignore 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
| Package | Contents |
|---|---|
perception_msgs | 23 messages, 3 services, in the four families of §6 |
perception_common | header-only: QoS profiles, refcounted CUDA buffer pool + streams, TypeAdapters, sweep-anchored synchronizer, TF/calibration cache, box conversions, latency histograms |
perception_nodes | 11 composable nodes, the custom multi-executor container, the inference-backend seam, the tracker, host reference CUDA kernels |
perception_eval | GT↔estimate matching with classified causes, MOT accumulator, 4 eval/display nodes |
perception_py | sensor replay (nuScenes/synthetic), ground-truth publisher, latency report |
perception_bringup | launch 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)| Suite | Protects against |
|---|---|
test_box_convert | w/l swap, bottom-vs-center, yaw sign, velocity translated instead of rotated |
test_sweep_sync | stalling on a dead camera, matching the wrong frame, queueing stale sweeps |
test_cuda_buffer_pool | freeing a buffer a subscriber holds; blocking instead of dropping |
test_tracker | ID switches, phantom tracks, losing an object through occlusion, cross-class ID theft |
test_kernels | resize/crop drift from the intrinsics, de-skew silently disabled, voxel coord order |
test_matching | greedy ordering, miss/FP cause classification, annotation-range fairness, AP edge cases |
test_mot_accumulator | ID-switch detection, match persistence, sequence-break reset, MT/ML, negative MOTA |
11 · Continue
QoS, executors, zero copy
A per-sensor QoS matrix with the failure mode for every choice; why one big multithreaded
executor is not a design; the four distinct zero-copy mechanisms and their exact
preconditions; GPU-direct TypeAdapters; thread affinity and RT hygiene.
Metrics, with the math
Precision/recall from first principles; AP and the 101-point interpolation; nuScenes mAP over distance thresholds; NDS; MOTA/MOTP/AMOTA/IDF1 and why MOTA can be negative — each with a fully worked numerical example.
Red-team critique
The questions a reviewer should ask: sensor choice, the metrics you are optimizing, the training and data-collection process — plus tabletop "what-if" edge cases that do not appear in any collected dataset.
Model size, FPS & GPU hardware
Sourced parameter counts, engine sizes and measured latency for Faster/Mask R-CNN, SSD, YOLO, PointPillars, VoxelNet, BEVFusion and DINOv2 — across A100, T4, RTX and four Jetson modules, at FP32/FP16/INT8, with the hardware described and the gaps marked.
Model port, segmentation & verification
Python BEVFusion → ROS 2 node mapping and tensor-parity plan; 2D vs BEV vs occupancy segmentation; the debugging decision tree, tracing/Nsight profiling, test pyramid, fault injection and senior-level interview drills.
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.