Skip to main content

mecha10_behavior_runtime/actions/
capture_target.rs

1//! Action node that captures a picture of a uniquely-found target and publishes it for the
2//! platform API to pick up and store for user review. Watches a vision detections topic and
3//! treats a target as found only when exactly one detection is present.
4
5use crate::{BehaviorNode, Context, NodeStatus, TreeStatusSnapshot};
6use async_trait::async_trait;
7use mecha10_core::messages::{Detection, Image, Message};
8use mecha10_core::topics::Topic;
9use mecha10_core::Receiver;
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13fn default_image_topic() -> String {
14    "/camera/rgb".to_string()
15}
16
17fn default_detections_topic() -> String {
18    "vision/detections".to_string()
19}
20
21fn default_capture_topic() -> String {
22    "/capture-target/captures".to_string()
23}
24
25/// Published once per newly-unique target - the payload a downstream service relays to the
26/// platform API for user review.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28struct CaptureTargetRequest {
29    image: Image,
30    detection: Detection,
31}
32
33impl Message for CaptureTargetRequest {}
34
35pub struct CaptureTargetNode {
36    image_topic: String,
37    detections_topic: String,
38    capture_topic: String,
39    image_receiver: Option<Receiver<Value>>,
40    detections_receiver: Option<Receiver<Value>>,
41    latest_image: Option<Image>,
42    target: Option<Detection>,
43    captured_for_current_target: bool,
44    last_status: Option<NodeStatus>,
45}
46
47impl std::fmt::Debug for CaptureTargetNode {
48    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        f.debug_struct("CaptureTargetNode")
50            .field("image_topic", &self.image_topic)
51            .field("detections_topic", &self.detections_topic)
52            .field("capture_topic", &self.capture_topic)
53            .field("has_image", &self.latest_image.is_some())
54            .field("target", &self.target)
55            .field("captured_for_current_target", &self.captured_for_current_target)
56            .finish()
57    }
58}
59
60#[derive(Debug, Serialize, Deserialize)]
61struct CaptureTargetConfig {
62    #[serde(default = "default_image_topic")]
63    image_topic: String,
64    #[serde(default = "default_detections_topic")]
65    detections_topic: String,
66    #[serde(default = "default_capture_topic")]
67    capture_topic: String,
68}
69
70impl CaptureTargetNode {
71    /// Create a new CaptureTargetNode from JSON configuration. See
72    /// `SetGoToLocationTargetNode::from_json`'s doc comment for why `null` is normalized to `{}`
73    /// here.
74    pub fn from_json(config: Value) -> anyhow::Result<Self> {
75        let config = if config.is_null() {
76            serde_json::json!({})
77        } else {
78            config
79        };
80        let cfg: CaptureTargetConfig = serde_json::from_value(config)?;
81        Ok(Self {
82            image_topic: cfg.image_topic,
83            detections_topic: cfg.detections_topic,
84            capture_topic: cfg.capture_topic,
85            image_receiver: None,
86            detections_receiver: None,
87            latest_image: None,
88            target: None,
89            captured_for_current_target: false,
90            last_status: None,
91        })
92    }
93
94    /// Drains every pending frame off `receiver`, keeping only the most recent - mirrors
95    /// `SetCapabilityTargetNode::poll_latest_request`'s "only the latest matters" semantics,
96    /// applied to camera frames instead of target requests.
97    fn poll_latest_image(receiver: &mut Receiver<Value>) -> Option<Image> {
98        let mut latest = None;
99        while let Ok(value) = receiver.try_recv() {
100            match serde_json::from_value::<Image>(value) {
101                Ok(image) => latest = Some(image),
102                Err(e) => tracing::warn!("CaptureTargetNode: dropping malformed camera frame: {}", e),
103            }
104        }
105        latest
106    }
107
108    /// Drains every pending detections batch off `receiver`, returning the last one seen (if
109    /// any) - only the most recent batch matters, same "drain then keep latest" shape as the
110    /// image poll above.
111    fn poll_latest_detections(receiver: &mut Receiver<Value>) -> Option<Vec<Detection>> {
112        let mut latest = None;
113        while let Ok(value) = receiver.try_recv() {
114            match serde_json::from_value::<Vec<Detection>>(value) {
115                Ok(detections) => latest = Some(detections),
116                Err(e) => tracing::warn!("CaptureTargetNode: dropping malformed detections batch: {}", e),
117            }
118        }
119        latest
120    }
121
122    async fn publish_capture(&self, ctx: &Context, image: Image, detection: Detection) -> anyhow::Result<()> {
123        let request = CaptureTargetRequest { image, detection };
124
125        let topic_static: &'static str = Box::leak(self.capture_topic.clone().into_boxed_str());
126        let topic = Topic::<CaptureTargetRequest>::new(topic_static);
127
128        ctx.publish_to(topic, &request).await?;
129        tracing::debug!(
130            "CaptureTargetNode: published capture for class {:?}",
131            request.detection.class_name
132        );
133        Ok(())
134    }
135}
136
137#[async_trait]
138impl BehaviorNode for CaptureTargetNode {
139    async fn tick(&mut self, ctx: &Context) -> anyhow::Result<NodeStatus> {
140        if self.image_receiver.is_none() {
141            self.image_receiver = Some(ctx.subscribe_raw::<Value>(&self.image_topic).await?);
142        }
143        if self.detections_receiver.is_none() {
144            self.detections_receiver = Some(ctx.subscribe_raw::<Value>(&self.detections_topic).await?);
145        }
146
147        if let Some(image) = Self::poll_latest_image(self.image_receiver.as_mut().expect("just subscribed above")) {
148            self.latest_image = Some(image);
149        }
150
151        if let Some(detections) =
152            Self::poll_latest_detections(self.detections_receiver.as_mut().expect("just subscribed above"))
153        {
154            let unique = match detections.len() {
155                1 => detections.into_iter().next(),
156                _ => None,
157            };
158            if unique.is_none() {
159                self.captured_for_current_target = false;
160            }
161            self.target = unique;
162        }
163
164        if let Some(detection) = self.target.clone() {
165            if !self.captured_for_current_target {
166                if let Some(image) = self.latest_image.clone() {
167                    self.publish_capture(ctx, image, detection).await?;
168                    self.captured_for_current_target = true;
169                }
170            }
171            self.last_status = Some(NodeStatus::Running);
172            Ok(NodeStatus::Running)
173        } else {
174            self.last_status = Some(NodeStatus::Failure);
175            Ok(NodeStatus::Failure)
176        }
177    }
178
179    fn name(&self) -> &str {
180        "CaptureTargetNode"
181    }
182
183    fn reset_self(&mut self) {
184        self.target = None;
185        self.captured_for_current_target = false;
186    }
187
188    fn status_snapshot(&self) -> TreeStatusSnapshot {
189        TreeStatusSnapshot {
190            name: self.name().to_string(),
191            status: self.last_status,
192            active_capability: self.active_capability().map(String::from),
193            children: Vec::new(),
194        }
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use crate::test_helpers::create_test_context;
202
203    fn sample_detection(class_name: &str) -> Value {
204        serde_json::json!({
205            "timestamp": 1,
206            "class_id": 0,
207            "class_name": class_name,
208            "confidence": 0.9,
209            "bbox": { "x": 0.1, "y": 0.2, "width": 0.3, "height": 0.4 }
210        })
211    }
212
213    fn sample_image() -> Value {
214        serde_json::json!({
215            "timestamp": 1,
216            "width": 4,
217            "height": 4,
218            "encoding": "rgb8",
219            "data": [0, 0, 0]
220        })
221    }
222
223    #[test]
224    fn from_json_reads_all_topics() {
225        let node = CaptureTargetNode::from_json(serde_json::json!({
226            "image_topic": "/camera/rgb",
227            "detections_topic": "vision/detections",
228            "capture_topic": "/capture-target/captures"
229        }))
230        .unwrap();
231
232        assert_eq!(node.image_topic, "/camera/rgb");
233        assert_eq!(node.detections_topic, "vision/detections");
234        assert_eq!(node.capture_topic, "/capture-target/captures");
235    }
236
237    #[test]
238    fn from_json_defaults_all_topics() {
239        let node = CaptureTargetNode::from_json(serde_json::json!({})).unwrap();
240
241        assert_eq!(node.image_topic, "/camera/rgb");
242        assert_eq!(node.detections_topic, "vision/detections");
243        assert_eq!(node.capture_topic, "/capture-target/captures");
244    }
245
246    #[test]
247    fn from_json_accepts_null_the_same_as_an_empty_object() {
248        let node = CaptureTargetNode::from_json(Value::Null).unwrap();
249
250        assert_eq!(node.image_topic, "/camera/rgb");
251        assert_eq!(node.detections_topic, "vision/detections");
252        assert_eq!(node.capture_topic, "/capture-target/captures");
253    }
254
255    #[test]
256    fn poll_latest_image_returns_none_when_empty() {
257        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
258        let mut receiver = Receiver::from(rx);
259        assert!(CaptureTargetNode::poll_latest_image(&mut receiver).is_none());
260    }
261
262    #[test]
263    fn poll_latest_image_keeps_only_the_most_recent_frame() {
264        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
265        let mut receiver = Receiver::from(rx);
266        tx.send(serde_json::json!({ "timestamp": 1, "width": 1, "height": 1, "encoding": "rgb8", "data": [] }))
267            .unwrap();
268        tx.send(serde_json::json!({ "timestamp": 2, "width": 2, "height": 2, "encoding": "rgb8", "data": [] }))
269            .unwrap();
270
271        let image = CaptureTargetNode::poll_latest_image(&mut receiver).unwrap();
272        assert_eq!(image.timestamp, 2);
273        assert_eq!(image.width, 2);
274    }
275
276    #[test]
277    fn poll_latest_image_drops_malformed_frames_and_keeps_polling() {
278        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
279        let mut receiver = Receiver::from(rx);
280        tx.send(serde_json::json!("not an image")).unwrap();
281        tx.send(sample_image()).unwrap();
282
283        let image = CaptureTargetNode::poll_latest_image(&mut receiver).unwrap();
284        assert_eq!(image.width, 4);
285    }
286
287    #[test]
288    fn poll_latest_detections_returns_none_when_empty() {
289        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
290        let mut receiver = Receiver::from(rx);
291        assert!(CaptureTargetNode::poll_latest_detections(&mut receiver).is_none());
292    }
293
294    #[test]
295    fn poll_latest_detections_keeps_only_the_most_recent_batch() {
296        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
297        let mut receiver = Receiver::from(rx);
298        tx.send(serde_json::json!([sample_detection("cat")])).unwrap();
299        tx.send(serde_json::json!([sample_detection("dog"), sample_detection("cat")]))
300            .unwrap();
301
302        let detections = CaptureTargetNode::poll_latest_detections(&mut receiver).unwrap();
303        assert_eq!(detections.len(), 2);
304    }
305
306    #[test]
307    fn poll_latest_detections_drops_malformed_batches_and_keeps_polling() {
308        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<Value>();
309        let mut receiver = Receiver::from(rx);
310        tx.send(serde_json::json!("not a detections batch")).unwrap();
311        tx.send(serde_json::json!([sample_detection("cat")])).unwrap();
312
313        let detections = CaptureTargetNode::poll_latest_detections(&mut receiver).unwrap();
314        assert_eq!(detections.len(), 1);
315        assert_eq!(detections[0].class_name, "cat");
316    }
317
318    #[tokio::test]
319    #[ignore]
320    async fn tick_captures_once_when_exactly_one_detection_is_present() {
321        let ctx = create_test_context("test_capture_target_unique").await;
322
323        let mut node = CaptureTargetNode::from_json(serde_json::json!({
324            "image_topic": "/camera/rgb",
325            "detections_topic": "vision/detections",
326            "capture_topic": "/capture-target/captures"
327        }))
328        .unwrap();
329
330        let mut capture_receiver = ctx.subscribe_raw::<Value>("/capture-target/captures").await.unwrap();
331
332        node.tick(&ctx).await.unwrap();
333        ctx.publish_raw("/camera/rgb", &sample_image()).await.unwrap();
334        ctx.publish_raw("vision/detections", &serde_json::json!([sample_detection("cat")]))
335            .await
336            .unwrap();
337        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
338
339        let status = node.tick(&ctx).await.unwrap();
340        assert_eq!(status, NodeStatus::Running);
341
342        let received = tokio::time::timeout(std::time::Duration::from_millis(500), capture_receiver.recv())
343            .await
344            .expect("timed out waiting for published capture")
345            .expect("channel closed without a message");
346
347        assert_eq!(received["detection"]["class_name"], "cat");
348        assert_eq!(received["image"]["width"], 4);
349
350        let status = node.tick(&ctx).await.unwrap();
351        assert_eq!(status, NodeStatus::Running);
352        let timed_out = tokio::time::timeout(std::time::Duration::from_millis(200), capture_receiver.recv()).await;
353        assert!(
354            timed_out.is_err(),
355            "expected no second publish for the same unique target"
356        );
357    }
358
359    #[tokio::test]
360    #[ignore = "Requires Redis infrastructure"]
361    async fn tick_returns_failure_with_zero_or_multiple_detections() {
362        let ctx = create_test_context("test_capture_target_ambiguous").await;
363
364        let mut node = CaptureTargetNode::from_json(serde_json::json!({})).unwrap();
365
366        let status = node.tick(&ctx).await.unwrap();
367        assert_eq!(
368            status,
369            NodeStatus::Failure,
370            "no detections yet means nothing to capture"
371        );
372
373        ctx.publish_raw(
374            "vision/detections",
375            &serde_json::json!([sample_detection("cat"), sample_detection("dog")]),
376        )
377        .await
378        .unwrap();
379        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
380
381        let status = node.tick(&ctx).await.unwrap();
382        assert_eq!(
383            status,
384            NodeStatus::Failure,
385            "ambiguous detections are not a unique target"
386        );
387    }
388
389    #[tokio::test]
390    #[ignore = "Requires Redis infrastructure"]
391    async fn tick_recaptures_after_the_target_is_lost_and_refound() {
392        let ctx = create_test_context("test_capture_target_refound").await;
393
394        let mut node = CaptureTargetNode::from_json(serde_json::json!({})).unwrap();
395        let mut capture_receiver = ctx.subscribe_raw::<Value>("/capture-target/captures").await.unwrap();
396
397        node.tick(&ctx).await.unwrap();
398        ctx.publish_raw("/camera/rgb", &sample_image()).await.unwrap();
399        ctx.publish_raw("vision/detections", &serde_json::json!([sample_detection("cat")]))
400            .await
401            .unwrap();
402        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
403        node.tick(&ctx).await.unwrap();
404        tokio::time::timeout(std::time::Duration::from_millis(500), capture_receiver.recv())
405            .await
406            .expect("timed out waiting for first capture")
407            .expect("channel closed without a message");
408
409        ctx.publish_raw("vision/detections", &serde_json::json!([]))
410            .await
411            .unwrap();
412        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
413        let status = node.tick(&ctx).await.unwrap();
414        assert_eq!(status, NodeStatus::Failure, "target lost");
415
416        ctx.publish_raw("vision/detections", &serde_json::json!([sample_detection("cat")]))
417            .await
418            .unwrap();
419        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
420        node.tick(&ctx).await.unwrap();
421
422        tokio::time::timeout(std::time::Duration::from_millis(500), capture_receiver.recv())
423            .await
424            .expect("timed out waiting for second capture after refind")
425            .expect("channel closed without a message");
426    }
427}