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