mecha10_behavior_runtime/actions/
capture_target.rs1use 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#[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 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 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 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}