Skip to main content

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(&region).unwrap();
551        assert_eq!(read_layout, layout);
552        let mut out = Vec::new();
553        let got = framebus::read_slot_into(&region, &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(&region, &read_layout, slot, seq + 1, &mut out).unwrap(),
560            None
561        );
562    }
563}