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