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