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