ados_sdk/vision/mod.rs
1//! Vision surface: frame subscription, model registration, inference, and
2//! detection publishing, plus the visual-odometry pose helper.
3//!
4//! A vision plugin reaches the agent's vision engine over the same plugin RPC
5//! wire as every other surface, but frames themselves never ride the RPC
6//! envelope. The engine writes normalized frames into a shared-memory ring and
7//! publishes a small [`FrameDescriptor`] on the `vision.frame` topic; the host
8//! delivers each descriptor to a subscriber as a `vision.deliver` event. This
9//! client resolves a descriptor to pixels by memory-mapping the named
10//! `/dev/shm` ring read-only and reading the descriptor's slot through the
11//! per-slot seqlock the `framebus` contract defines,
12//! dropping any torn or stale read (latest-wins).
13//!
14//! Detections and model metadata are small structured payloads, so they ride
15//! the RPC envelope directly through the [`methods`](ados_protocol::framebus::methods)
16//! the host gates on the vision capabilities.
17//!
18//! The client gates nothing itself; the host enforces `vision.frame.read`,
19//! `vision.model.register`, `vision.detection.publish` and
20//! `vision.offload.advertise`.
21
22pub mod pose;
23
24use std::collections::HashMap;
25use std::os::unix::fs::MetadataExt;
26use std::path::{Path, PathBuf};
27use std::sync::{Arc, Mutex};
28
29use ados_protocol::framebus::{
30 self, Detection, DetectionBatch, FrameDescriptor, ModelMetadata, RingLayout,
31 VISION_DETECTION_VERSION,
32};
33use ados_protocol::vision_rpc;
34use rmpv::Value;
35
36use crate::client::{ClientError, OffloadAdvertisement, PluginIpcClient};
37
38pub use pose::{Odometry, Pose, POSE_COVARIANCE_LEN, VIO_COMPONENT_ID};
39
40/// A resolved camera frame: the descriptor the engine published plus the pixel
41/// bytes read out of the shared-memory ring it named. `pixels.len()` equals
42/// `descriptor.byte_len` and is the valid pixel data for
43/// `descriptor.width` x `descriptor.height` in `descriptor.format`.
44#[derive(Debug, Clone, PartialEq)]
45pub struct Frame {
46 pub descriptor: FrameDescriptor,
47 pub pixels: Vec<u8>,
48}
49
50/// A callback invoked once per resolved frame. It runs on the IPC reader task,
51/// so it must not block; offload heavy inference to a channel or task. A frame
52/// the ring could not resolve (torn or stale read, or a ring that vanished) is
53/// dropped silently and the callback does not fire for it.
54pub type FrameCallback = Arc<dyn Fn(Frame) + Send + Sync>;
55
56/// Callback for a decoded detection batch.
57pub type DetectionCallback = Arc<dyn Fn(DetectionBatch) + Send + Sync>;
58
59/// One declared model's delivery resolution, mirroring the agent resolver's
60/// `ModelResolution`. `state` is `resolved` | `needs_model` | `verify_failed`;
61/// when resolved, `runtime` and `path` name the cached model and how to run it,
62/// and a plugin loads its detector from `path`. Otherwise `reason` explains why.
63#[derive(Debug, Clone, PartialEq)]
64pub struct ModelResolution {
65 pub state: String,
66 pub model_id: String,
67 pub runtime: Option<String>,
68 pub path: Option<String>,
69 pub reason: Option<String>,
70}
71
72fn mpv_get<'a>(v: &'a Value, key: &str) -> Option<&'a Value> {
73 v.as_map()?
74 .iter()
75 .find(|(k, _)| k.as_str() == Some(key))
76 .map(|(_, val)| val)
77}
78
79fn parse_model_resolutions(reply: &Value) -> Vec<ModelResolution> {
80 let Some(models) = mpv_get(reply, "models").and_then(Value::as_array) else {
81 return Vec::new();
82 };
83 models
84 .iter()
85 .filter_map(|m| {
86 Some(ModelResolution {
87 state: mpv_get(m, "state")?.as_str()?.to_string(),
88 model_id: mpv_get(m, "model_id")?.as_str()?.to_string(),
89 runtime: mpv_get(m, "runtime")
90 .and_then(Value::as_str)
91 .map(str::to_string),
92 path: mpv_get(m, "path")
93 .and_then(Value::as_str)
94 .map(str::to_string),
95 reason: mpv_get(m, "reason")
96 .and_then(Value::as_str)
97 .map(str::to_string),
98 })
99 })
100 .collect()
101}
102
103/// `ctx.vision` — the vision engine facade.
104///
105/// Cloning shares the one underlying IPC client. The ring resolver caches each
106/// camera's mapped `/dev/shm` region keyed by `shm_name` so a steady frame
107/// stream maps each ring once, not once per frame.
108#[derive(Clone)]
109pub struct VisionClient {
110 ipc: Arc<PluginIpcClient>,
111 rings: Arc<Mutex<RingCache>>,
112}
113
114impl VisionClient {
115 pub(crate) fn new(ipc: Arc<PluginIpcClient>) -> Self {
116 Self {
117 ipc,
118 rings: Arc::new(Mutex::new(RingCache::new(PathBuf::from(DEFAULT_SHM_DIR)))),
119 }
120 }
121
122 /// Resolve frame rings under `dir` instead of `/dev/shm`, with an empty
123 /// ring cache. Mirrors the Python client's `shm_dir`; the engine's
124 /// counterpart is `ADOS_SHM_DIR`. Call before subscribing: an existing
125 /// subscription keeps the cache it was registered with.
126 pub fn with_shm_dir(self, dir: impl Into<PathBuf>) -> Self {
127 Self {
128 ipc: self.ipc,
129 rings: Arc::new(Mutex::new(RingCache::new(dir.into()))),
130 }
131 }
132
133 /// Subscribe to frames, optionally filtered to one `camera_id`. The host
134 /// delivers matching frame descriptors as `vision.deliver` events; this
135 /// client resolves each to pixels and invokes `callback` with the [`Frame`].
136 ///
137 /// Registers a frame callback on the client's `vision.deliver` path (keyed
138 /// on `camera_id`), then sends the [`methods::SUBSCRIBE_FRAMES`](ados_protocol::framebus::methods::SUBSCRIBE_FRAMES) RPC (gated
139 /// on `vision.frame.read`) so the engine starts or widens the stream. A
140 /// `camera_id` of `None` receives every camera's frames; a `Some(id)` filter
141 /// is applied at the engine and, as a backstop, in the resolver. The
142 /// `vision.deliver` push carries the descriptor in a `descriptor` binary arg
143 /// (no `topic`), so it does not use the `event.subscribe` topic path.
144 pub async fn subscribe_frames(
145 &self,
146 camera_id: Option<&str>,
147 callback: FrameCallback,
148 ) -> Result<(), ClientError> {
149 let want_camera = camera_id.map(str::to_string);
150 let rings = self.rings.clone();
151 let filter = want_camera.clone();
152
153 // The host pushes a `vision.deliver` event carrying the encoded
154 // descriptor in the `descriptor` arg. Decode it, drop a camera that
155 // does not match the filter, resolve it against the ring (dropping
156 // torn/stale), and hand the typed Frame to the author's callback.
157 let on_deliver = move |args: Value| {
158 let Some(descriptor) = decode_descriptor(&args) else {
159 return;
160 };
161 if let Some(want) = &filter {
162 if &descriptor.camera_id != want {
163 return;
164 }
165 }
166 if let Some(frame) = resolve_frame(&rings, descriptor) {
167 callback(frame);
168 }
169 };
170
171 // Register the frame callback before arming the engine stream, so no
172 // descriptor that arrives between the RPC reply and registration is lost.
173 self.ipc
174 .register_vision_callback(camera_id, Arc::new(on_deliver));
175
176 // Tell the engine to start (or widen) the stream toward this plugin.
177 self.ipc.vision_subscribe_frames(camera_id).await?;
178 Ok(())
179 }
180
181 /// Register an inference model with the engine. Sends
182 /// [`methods::REGISTER_MODEL`](ados_protocol::framebus::methods::REGISTER_MODEL) (gated on `vision.model.register`) in the
183 /// [`vision_rpc`] shape the engine decodes. The engine files it as
184 /// `<plugin_id>/<id>` (the reply's `model_id`), so it never replaces the
185 /// node's own models or another plugin's; a plugin holds at most eight. An
186 /// engine-run model's `model_path` must be a model delivered to this plugin
187 /// (see [`Self::resolved_model`]).
188 pub async fn register_model(&self, model: &ModelMetadata) -> Result<Value, ClientError> {
189 self.ipc.vision_register_model(model).await
190 }
191
192 /// Read this plugin's resolved model-delivery status: one
193 /// [`ModelResolution`] per declared model, keyed by `model_id`. This is the
194 /// model-delivery last mile — a plugin loads its detector from the resolved
195 /// `path`. Sends [`methods::READ_MODEL`](ados_protocol::framebus::methods::READ_MODEL) (gated on `vision.model.read`); an
196 /// unresolved plugin returns an empty list rather than erroring, so a caller
197 /// can poll until its model resolves.
198 pub async fn resolved_model(&self) -> Result<Vec<ModelResolution>, ClientError> {
199 let reply = self.ipc.vision_read_model().await?;
200 Ok(parse_model_resolutions(&reply))
201 }
202
203 /// Run a registered model against one frame on the shared backend and
204 /// return its detections. Sends [`methods::INFER`](ados_protocol::framebus::methods::INFER) (gated on
205 /// `vision.model.register`); the engine arbitrates access to the
206 /// accelerator. `model_id` names this plugin's own model or one of the
207 /// node's configured models. The frame is passed by descriptor (the engine
208 /// reads its own ring), so no pixels cross the RPC envelope. A frame whose
209 /// slot the ring has since recycled is an error; infer on a fresher frame.
210 pub async fn infer(
211 &self,
212 model_id: &str,
213 frame: &Frame,
214 ) -> Result<Vec<Detection>, ClientError> {
215 let reply = self.ipc.vision_infer(model_id, &frame.descriptor).await?;
216 vision_rpc::decode_infer_reply(&reply)
217 .map(|batch| batch.detections)
218 .map_err(|e| ClientError::Rpc(format!("infer reply decode failed: {e}")))
219 }
220
221 /// Publish a detection batch on `vision.detection`. Sends
222 /// [`methods::PUBLISH_DETECTION`](ados_protocol::framebus::methods::PUBLISH_DETECTION) (gated on `vision.detection.publish`) in
223 /// the [`vision_rpc`] shape the engine decodes. Subscribers see its
224 /// `model_id` as
225 /// `<plugin_id>/<model_id>`, and `lock_state` / `track_id` are cleared
226 /// unless this plugin also holds `vision.track.designate`.
227 pub async fn publish_detection(&self, batch: &DetectionBatch) -> Result<Value, ClientError> {
228 self.ipc.vision_publish_detection(batch).await
229 }
230
231 /// Publish a single detection against one frame, building the
232 /// [`DetectionBatch`] from the frame's source camera and id. A convenience
233 /// over [`publish_detection`](Self::publish_detection) for the common
234 /// one-box-per-frame case.
235 pub async fn publish_one(
236 &self,
237 model_id: &str,
238 frame: &Frame,
239 detection: Detection,
240 ) -> Result<Value, ClientError> {
241 let batch = DetectionBatch {
242 v: VISION_DETECTION_VERSION,
243 model_id: model_id.to_string(),
244 camera_id: frame.descriptor.camera_id.clone(),
245 frame_id: frame.descriptor.frame_id,
246 ts_ms: (frame.descriptor.ts_ns / 1_000_000) as i64,
247 frame_width: frame.descriptor.width,
248 frame_height: frame.descriptor.height,
249 detections: vec![detection],
250 };
251 self.publish_detection(&batch).await
252 }
253
254 /// Report the perception-offload link this plugin holds, so the node's
255 /// perception tier reads `offload` while it is live. Re-advertise at least
256 /// every ~10 s (the link goes stale after 20 s) and send `paired: false`
257 /// when the link drops. Gated on `vision.offload.advertise`. See
258 /// [`PluginIpcClient::offload_advertise`].
259 pub async fn advertise_offload(
260 &self,
261 advert: &OffloadAdvertisement,
262 ) -> Result<Value, ClientError> {
263 self.ipc.offload_advertise(advert).await
264 }
265
266 /// Register this plugin as the visual-odometry MAVLink component so the FC
267 /// attributes injected pose to a vision source. Call once before
268 /// [`inject_pose`](Self::inject_pose) / [`inject_odometry`](Self::inject_odometry).
269 pub async fn register_vio_component(&self) -> Result<Value, ClientError> {
270 self.ipc
271 .mavlink_register_component(VIO_COMPONENT_ID, "vio")
272 .await
273 }
274
275 /// Build a `VISION_POSITION_ESTIMATE` from `pose` and send it to the FC over
276 /// the host's MAVLink path under the visual-odometry component id. Replaces
277 /// hand-built MAVLink frames in VIO plugins.
278 pub async fn inject_pose(&self, pose: &Pose) -> Result<Value, ClientError> {
279 let frame = pose::frame_for(&pose.to_vision_position_estimate())
280 .map_err(|e| ClientError::Rpc(format!("vision pose encode failed: {e}")))?;
281 self.ipc.mavlink_send(&frame, Some(VIO_COMPONENT_ID)).await
282 }
283
284 /// Build an `ODOMETRY` message from `odometry` (pose plus body-frame twist)
285 /// and send it to the FC under the visual-odometry component id.
286 pub async fn inject_odometry(&self, odometry: &Odometry) -> Result<Value, ClientError> {
287 let frame = pose::frame_for(&odometry.to_odometry())
288 .map_err(|e| ClientError::Rpc(format!("vision odometry encode failed: {e}")))?;
289 self.ipc.mavlink_send(&frame, Some(VIO_COMPONENT_ID)).await
290 }
291
292 /// Receive the engine's detection batches.
293 ///
294 /// `camera_id` of `None` receives every camera. The engine fans all cameras
295 /// onto one stream, so a `Some(id)` filter is applied here rather than
296 /// narrowed at the engine — matching the frame subscription and the Python
297 /// client.
298 ///
299 /// A batch that will not decode is dropped rather than surfaced: the wire
300 /// carries a version and an older producer round-trips, so an undecodable
301 /// batch means a genuinely broken frame, not a version skew the caller could
302 /// act on.
303 pub async fn subscribe_detections(
304 &self,
305 camera_id: Option<&str>,
306 callback: DetectionCallback,
307 ) -> Result<(), ClientError> {
308 let filter = camera_id.map(str::to_string);
309 let on_deliver = move |args: Value| {
310 let Some(Value::Binary(blob)) = map_get(&args, "batch") else {
311 return;
312 };
313 let Ok(batch) = DetectionBatch::from_msgpack(&blob) else {
314 return;
315 };
316 if let Some(want) = &filter {
317 if &batch.camera_id != want {
318 return;
319 }
320 }
321 callback(batch);
322 };
323 self.ipc
324 .register_detection_callback(camera_id, Arc::new(on_deliver));
325 self.ipc.vision_subscribe_detections(camera_id).await?;
326 Ok(())
327 }
328
329 /// Lock the tracker onto a specific detection, overriding its auto-lock.
330 ///
331 /// This is the operator-designation path: the engine presents a lock state
332 /// only for a track a caller designated, so this is what makes a lock mean
333 /// "the target that was chosen" rather than "whatever scored highest". The
334 /// detection's box, label and confidence cross; it must carry a box.
335 pub async fn designate_track(
336 &self,
337 camera_id: &str,
338 detection: &Detection,
339 ) -> Result<Value, ClientError> {
340 self.ipc.vision_designate_track(camera_id, detection).await
341 }
342}
343
344/// Where the engine creates its frame rings.
345const DEFAULT_SHM_DIR: &str = "/dev/shm";
346
347/// Mapped frame rings under `dir`, keyed by the descriptor's `shm_name`. One
348/// ring per camera.
349struct RingCache {
350 dir: PathBuf,
351 rings: HashMap<String, MappedRing>,
352}
353
354impl RingCache {
355 fn new(dir: PathBuf) -> Self {
356 Self {
357 dir,
358 rings: HashMap::new(),
359 }
360 }
361}
362
363/// One memory-mapped frame ring: the read-only mmap, the layout recorded in its
364/// header, and which file it is.
365struct MappedRing {
366 map: memmap2::Mmap,
367 layout: RingLayout,
368 /// `(st_dev, st_ino)` of the mapped file, read off the descriptor that was
369 /// mapped, so it names exactly the bytes in `map`.
370 file_id: (u64, u64),
371 /// The highest sequence read out of this mapping.
372 last_seq: u64,
373}
374
375impl MappedRing {
376 /// Read the descriptor's slot through the seqlock, recording its sequence
377 /// when the read holds. The returned [`Frame`] owns its pixels, so each read
378 /// fills a buffer sized once to the descriptor's byte length.
379 fn read(&mut self, descriptor: &FrameDescriptor) -> Option<Vec<u8>> {
380 let mut pixels = Vec::with_capacity(descriptor.byte_len as usize);
381 framebus::read_slot_into(
382 &self.map,
383 &self.layout,
384 descriptor.slot,
385 descriptor.seq,
386 &mut pixels,
387 )
388 .ok()
389 .flatten()?;
390 self.last_seq = self.last_seq.max(descriptor.seq);
391 Some(pixels)
392 }
393
394 /// Whether `path` still names the file this mapping holds.
395 fn is_named_by(&self, path: &Path) -> bool {
396 std::fs::metadata(path).is_ok_and(|m| (m.dev(), m.ino()) == self.file_id)
397 }
398}
399
400/// Resolve a descriptor to a [`Frame`], mapping the ring on first sight of its
401/// `shm_name`. Returns `None` on a torn/stale read, a ring that cannot be
402/// mapped, or a layout/region mismatch — the frame is dropped (latest-wins).
403///
404/// The engine replaces a ring by unlinking its file and creating a new one
405/// under the same name — every engine start does, sweeping the last run's
406/// rings — and the new ring's sequence starts over at 1. A mapping of the old
407/// file stays readable but never again holds the frame being described (or,
408/// on a coincidental sequence, holds the previous run's pixels), which left a
409/// subscriber silent until its process restarted. So a sequence at or below
410/// one this mapping has already served, or a slot it cannot read, is checked
411/// against the file the name names now, and a replaced ring is mapped afresh.
412/// A live stream pays nothing for this: its sequence only climbs and its reads
413/// hold.
414fn resolve_frame(cache: &Mutex<RingCache>, descriptor: FrameDescriptor) -> Option<Frame> {
415 let mut guard = cache.lock().expect("ring cache lock");
416 let cache = &mut *guard;
417 let path = cache.dir.join(&descriptor.shm_name);
418 if let Some(ring) = cache.rings.get_mut(&descriptor.shm_name) {
419 let regressed = descriptor.seq <= ring.last_seq;
420 if !regressed {
421 if let Some(pixels) = ring.read(&descriptor) {
422 return Some(Frame { descriptor, pixels });
423 }
424 }
425 if ring.is_named_by(&path) {
426 // Still the live ring: the same descriptor resolved again (once per
427 // matching subscription), or a slot the writer already recycled.
428 let pixels = if regressed {
429 ring.read(&descriptor)
430 } else {
431 None
432 }?;
433 return Some(Frame { descriptor, pixels });
434 }
435 cache.rings.remove(&descriptor.shm_name);
436 }
437 let mut ring = map_ring(&path)?;
438 let pixels = ring.read(&descriptor);
439 cache.rings.insert(descriptor.shm_name.clone(), ring);
440 Some(Frame {
441 descriptor,
442 pixels: pixels?,
443 })
444}
445
446/// Map the ring file at `path` read-only and read the ring layout from its
447/// header. `None` if the file is missing, cannot be mapped, or has no valid
448/// header.
449fn map_ring(path: &Path) -> Option<MappedRing> {
450 let file = std::fs::File::open(path).ok()?;
451 let meta = file.metadata().ok()?;
452 // SAFETY: the region is a POSIX shared-memory object the vision engine
453 // owns; mapping it read-only is sound. A concurrent writer recycling slots
454 // is the expected case and is detected by the per-slot seqlock in
455 // `read_slot_into`, which discards any torn read.
456 let map = unsafe { memmap2::Mmap::map(&file) }.ok()?;
457 let layout = RingLayout::read_header(&map)?;
458 Some(MappedRing {
459 map,
460 layout,
461 file_id: (meta.dev(), meta.ino()),
462 last_seq: 0,
463 })
464}
465
466/// Decode a [`FrameDescriptor`] from a `vision.deliver` envelope `args` map. The
467/// host carries the descriptor as a `descriptor` binary blob (the live path);
468/// for robustness a map that is itself the descriptor's own fields also decodes,
469/// both through the framebus contract.
470fn decode_descriptor(payload: &Value) -> Option<FrameDescriptor> {
471 if let Some(Value::Binary(blob)) = map_get(payload, "descriptor") {
472 return FrameDescriptor::from_msgpack(&blob).ok();
473 }
474 // The payload map is the descriptor itself: re-encode it to msgpack and
475 // decode through the named-field contract so the field mapping is the one
476 // single source of truth in framebus.
477 let bytes = rmp_serde::to_vec_named(payload).ok()?;
478 FrameDescriptor::from_msgpack(&bytes).ok()
479}
480
481/// Read a key from an `rmpv` map value.
482fn map_get(args: &Value, key: &str) -> Option<Value> {
483 match args {
484 Value::Map(entries) => entries
485 .iter()
486 .find(|(k, _)| k.as_str() == Some(key))
487 .map(|(_, v)| v.clone()),
488 _ => None,
489 }
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495 use ados_protocol::framebus::FrameFormat;
496
497 fn descriptor(shm_name: &str, slot: u32, seq: u64, byte_len: u32) -> FrameDescriptor {
498 FrameDescriptor {
499 v: ados_protocol::framebus::FRAMEBUS_DESCRIPTOR_VERSION,
500 camera_id: "uvc-0".into(),
501 frame_id: seq,
502 ts_ns: 1_700_000_000_000,
503 ts_sensor_ns: None,
504 exposure_ns: None,
505 trigger_seq: None,
506 timebase: ados_protocol::framebus::TIMEBASE_HOST,
507 width: 4,
508 height: 4,
509 format: FrameFormat::Rgb24,
510 shm_name: shm_name.into(),
511 slot,
512 seq,
513 byte_len,
514 }
515 }
516
517 #[test]
518 fn descriptor_decodes_from_a_named_map_payload() {
519 let d = descriptor("ados-vision-uvc-0", 1, 7, 48);
520 // The host carried the descriptor's own fields as the event payload.
521 let bytes = d.to_msgpack().unwrap();
522 let payload: Value = rmp_serde::from_slice(&bytes).unwrap();
523 let back = decode_descriptor(&payload).unwrap();
524 assert_eq!(back, d);
525 }
526
527 #[test]
528 fn descriptor_decodes_from_a_blob_payload() {
529 let d = descriptor("ados-vision-uvc-0", 2, 11, 48);
530 let blob = d.to_msgpack().unwrap();
531 let payload = Value::Map(vec![(Value::from("descriptor"), Value::Binary(blob))]);
532 let back = decode_descriptor(&payload).unwrap();
533 assert_eq!(back, d);
534 }
535
536 #[test]
537 fn resolve_reads_a_frame_written_to_a_real_ring() {
538 // Build a ring region in memory, write a frame, and resolve it via a
539 // file-backed mmap (a tempfile stands in for /dev/shm here).
540 let layout = RingLayout::for_frame(4, 4, 4, FrameFormat::Rgb24); // 48-byte slots
541 let mut region = vec![0u8; layout.total_len()];
542 layout.write_header(&mut region).unwrap();
543 let pixels: Vec<u8> = (0..layout.slot_bytes as u8).collect();
544 let seq = 5u64;
545 let slot = (seq % layout.slot_count as u64) as u32;
546 framebus::write_slot(&mut region, &layout, slot, seq, &pixels).unwrap();
547
548 // The resolver maps by name under /dev/shm; mirror the read path here by
549 // exercising read_slot_into against the same layout the header records.
550 let read_layout = RingLayout::read_header(®ion).unwrap();
551 assert_eq!(read_layout, layout);
552 let mut out = Vec::new();
553 let got = framebus::read_slot_into(®ion, &read_layout, slot, seq, &mut out).unwrap();
554 assert_eq!(got, Some(pixels.len()));
555 assert_eq!(out, pixels);
556
557 // A stale descriptor (seq the slot no longer holds) resolves to nothing.
558 assert_eq!(
559 framebus::read_slot_into(®ion, &read_layout, slot, seq + 1, &mut out).unwrap(),
560 None
561 );
562 }
563}