1pub mod clustering;
7pub mod config;
8pub mod obstacle_detector;
9pub mod scene_graph;
10pub mod sensor_fusion;
11
12pub use config::{ObstacleConfig, PerceptionConfig, SceneGraphConfig};
13pub use obstacle_detector::{ClassifiedObstacle, DetectedObstacle, ObstacleClass, ObstacleDetector};
14pub use scene_graph::PointCloudSceneGraphBuilder;
15
16use serde::{Deserialize, Serialize};
17
18use crate::bridge::{
19 Obstacle, Point3D, PointCloud, SceneEdge, SceneGraph, SceneObject, Trajectory,
20};
21
22#[derive(Debug, thiserror::Error)]
28pub enum PerceptionError {
29 #[error("Invalid input: {0}")]
30 InvalidInput(String),
31 #[error("Processing failed: {0}")]
32 ProcessingFailed(String),
33}
34
35pub type Result<T> = std::result::Result<T, PerceptionError>;
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Anomaly {
45 pub position: [f64; 3],
46 pub score: f64,
47 pub description: String,
48 pub timestamp: i64,
49}
50
51#[derive(Debug, Clone)]
60pub struct SceneGraphBuilder {
61 edge_distance_threshold: f64,
62 max_objects: usize,
63}
64
65impl Default for SceneGraphBuilder {
66 fn default() -> Self {
67 Self {
68 edge_distance_threshold: 5.0,
69 max_objects: 256,
70 }
71 }
72}
73
74impl SceneGraphBuilder {
75 pub fn new(edge_distance_threshold: f64, max_objects: usize) -> Self {
77 Self {
78 edge_distance_threshold,
79 max_objects,
80 }
81 }
82
83 pub fn build(&self, mut objects: Vec<SceneObject>, timestamp: i64) -> SceneGraph {
88 objects.truncate(self.max_objects);
89
90 let threshold_sq = self.edge_distance_threshold * self.edge_distance_threshold;
91 let mut edges = Vec::new();
92 for i in 0..objects.len() {
93 for j in (i + 1)..objects.len() {
94 let dx = objects[i].center[0] - objects[j].center[0];
95 let dy = objects[i].center[1] - objects[j].center[1];
96 let dz = objects[i].center[2] - objects[j].center[2];
97 let dist_sq = dx * dx + dy * dy + dz * dz;
98 if dist_sq <= threshold_sq {
99 let dist = dist_sq.sqrt();
101 let relation = if dist < 1.0 {
102 "adjacent".to_string()
103 } else if dist < 3.0 {
104 "near".to_string()
105 } else {
106 "visible".to_string()
107 };
108 edges.push(SceneEdge {
109 from: objects[i].id,
110 to: objects[j].id,
111 distance: dist,
112 relation,
113 });
114 }
115 }
116 }
117
118 SceneGraph::new(objects, edges, timestamp)
119 }
120
121 pub fn build_from_obstacles(
123 &self,
124 obstacles: &[DetectedObstacle],
125 timestamp: i64,
126 ) -> SceneGraph {
127 let objects: Vec<SceneObject> = obstacles
128 .iter()
129 .enumerate()
130 .map(|(i, obs)| {
131 let mut obj = SceneObject::new(i, obs.center, obs.extent);
132 obj.confidence = 1.0 - (obs.min_distance as f32 / 30.0).min(0.9);
133 obj.label = format!("obstacle_{}", i);
134 obj
135 })
136 .collect();
137 self.build(objects, timestamp)
138 }
139
140 pub fn merge(&self, a: &SceneGraph, b: &SceneGraph) -> SceneGraph {
142 let mut objects = a.objects.clone();
143 let offset = objects.len();
144 for obj in &b.objects {
145 let mut new_obj = obj.clone();
146 new_obj.id += offset;
147 objects.push(new_obj);
148 }
149 let timestamp = a.timestamp.max(b.timestamp);
150 self.build(objects, timestamp)
151 }
152}
153
154#[derive(Debug, Clone)]
166pub struct PerceptionPipeline {
167 detector: ObstacleDetector,
168 graph_builder: SceneGraphBuilder,
169 frames_processed: u64,
170 obstacle_threshold: f64,
171 anomaly_threshold: f64,
172}
173
174impl PerceptionPipeline {
175 pub fn new(config: PerceptionConfig) -> Self {
177 let obstacle_threshold = config.obstacle.safety_margin * 5.0;
178 let detector = ObstacleDetector::new(config.obstacle);
179 let graph_builder = SceneGraphBuilder::new(
180 config.scene_graph.edge_distance_threshold,
181 config.scene_graph.max_objects,
182 );
183 Self {
184 detector,
185 graph_builder,
186 frames_processed: 0,
187 obstacle_threshold: obstacle_threshold.max(0.5),
188 anomaly_threshold: 2.0,
189 }
190 }
191
192 pub fn with_thresholds(obstacle_threshold: f64, anomaly_threshold: f64) -> Self {
197 use crate::perception::config::{ObstacleConfig, SceneGraphConfig};
198
199 let obstacle_cfg = ObstacleConfig::default();
200 let scene_cfg = SceneGraphConfig::default();
201 let detector = ObstacleDetector::new(obstacle_cfg.clone());
202 let graph_builder = SceneGraphBuilder::new(
203 scene_cfg.edge_distance_threshold,
204 scene_cfg.max_objects,
205 );
206 Self {
207 detector,
208 graph_builder,
209 frames_processed: 0,
210 obstacle_threshold,
211 anomaly_threshold,
212 }
213 }
214
215 pub fn process(
218 &mut self,
219 cloud: &PointCloud,
220 robot_pos: &[f64; 3],
221 ) -> (Vec<DetectedObstacle>, SceneGraph) {
222 self.frames_processed += 1;
223 let obstacles = self.detector.detect(cloud, robot_pos);
224 let graph = self.graph_builder.build_from_obstacles(&obstacles, cloud.timestamp_us);
225 (obstacles, graph)
226 }
227
228 pub fn classify(
230 &self,
231 obstacles: &[DetectedObstacle],
232 ) -> Vec<ClassifiedObstacle> {
233 self.detector.classify_obstacles(obstacles)
234 }
235
236 pub fn frames_processed(&self) -> u64 {
238 self.frames_processed
239 }
240
241 pub fn detect_obstacles(
248 &self,
249 cloud: &PointCloud,
250 robot_position: [f64; 3],
251 max_distance: f64,
252 ) -> Result<Vec<Obstacle>> {
253 if cloud.is_empty() {
254 return Ok(Vec::new());
255 }
256
257 let cell_size = self.obstacle_threshold.max(0.1);
258 let clusters = clustering::cluster_point_cloud(cloud, cell_size);
259
260 let mut obstacles: Vec<Obstacle> = Vec::new();
261 let mut next_id: u64 = 0;
262
263 for cluster in &clusters {
264 if cluster.len() < 2 {
265 continue;
266 }
267
268 let (center, radius) = Self::bounding_sphere(cluster);
269 let dist = Self::dist_3d(¢er, &robot_position);
270
271 if dist > max_distance {
272 continue;
273 }
274
275 let confidence = (cluster.len() as f32 / cloud.points.len() as f32)
276 .clamp(0.1, 1.0);
277
278 obstacles.push(Obstacle {
279 id: next_id,
280 position: center,
281 distance: dist,
282 radius,
283 label: format!("obstacle_{}", next_id),
284 confidence,
285 });
286 next_id += 1;
287 }
288
289 obstacles.sort_by(|a, b| {
290 a.distance
291 .partial_cmp(&b.distance)
292 .unwrap_or(std::cmp::Ordering::Equal)
293 });
294
295 for (i, obs) in obstacles.iter_mut().enumerate() {
296 obs.id = i as u64;
297 }
298
299 Ok(obstacles)
300 }
301
302 pub fn build_scene_graph(
309 &self,
310 objects: &[SceneObject],
311 max_edge_distance: f64,
312 ) -> Result<SceneGraph> {
313 if max_edge_distance <= 0.0 {
314 return Err(PerceptionError::InvalidInput(
315 "max_edge_distance must be positive".to_string(),
316 ));
317 }
318
319 let mut edges: Vec<SceneEdge> = Vec::new();
320
321 let max_dist_sq = max_edge_distance * max_edge_distance;
322 for i in 0..objects.len() {
323 for j in (i + 1)..objects.len() {
324 let dx = objects[i].center[0] - objects[j].center[0];
325 let dy = objects[i].center[1] - objects[j].center[1];
326 let dz = objects[i].center[2] - objects[j].center[2];
327 let d_sq = dx * dx + dy * dy + dz * dz;
328 if d_sq <= max_dist_sq {
329 let d = d_sq.sqrt();
330 let relation = if d < max_edge_distance * 0.33 {
331 "adjacent"
332 } else if d < max_edge_distance * 0.66 {
333 "near"
334 } else {
335 "far"
336 };
337 edges.push(SceneEdge {
338 from: objects[i].id,
339 to: objects[j].id,
340 distance: d,
341 relation: relation.to_string(),
342 });
343 }
344 }
345 }
346
347 Ok(SceneGraph::new(objects.to_vec(), edges, 0))
348 }
349
350 pub fn predict_trajectory(
357 &self,
358 position: [f64; 3],
359 velocity: [f64; 3],
360 steps: usize,
361 dt: f64,
362 ) -> Result<Trajectory> {
363 if steps == 0 {
364 return Err(PerceptionError::InvalidInput(
365 "steps must be > 0".to_string(),
366 ));
367 }
368 if dt <= 0.0 {
369 return Err(PerceptionError::InvalidInput(
370 "dt must be positive".to_string(),
371 ));
372 }
373
374 let mut waypoints = Vec::with_capacity(steps);
375 let mut timestamps = Vec::with_capacity(steps);
376
377 for i in 1..=steps {
378 let t = i as f64 * dt;
379 waypoints.push([
380 position[0] + velocity[0] * t,
381 position[1] + velocity[1] * t,
382 position[2] + velocity[2] * t,
383 ]);
384 timestamps.push((t * 1_000_000.0) as i64);
385 }
386
387 let confidence = (1.0 - (steps as f64 * dt * 0.1)).max(0.1);
388 Ok(Trajectory::new(waypoints, timestamps, confidence))
389 }
390
391 pub fn focus_attention(
395 &self,
396 cloud: &PointCloud,
397 center: [f64; 3],
398 radius: f64,
399 ) -> Result<Vec<Point3D>> {
400 if radius <= 0.0 {
401 return Err(PerceptionError::InvalidInput(
402 "radius must be positive".to_string(),
403 ));
404 }
405
406 let r2 = radius * radius;
407 let focused: Vec<Point3D> = cloud
408 .points
409 .iter()
410 .filter(|p| {
411 let dx = p.x as f64 - center[0];
412 let dy = p.y as f64 - center[1];
413 let dz = p.z as f64 - center[2];
414 dx * dx + dy * dy + dz * dz <= r2
415 })
416 .copied()
417 .collect();
418
419 Ok(focused)
420 }
421
422 pub fn detect_anomalies(&self, cloud: &PointCloud) -> Result<Vec<Anomaly>> {
429 if cloud.points.len() < 2 {
430 return Ok(Vec::new());
431 }
432
433 let n = cloud.points.len() as f64;
434
435 let (mut cx, mut cy, mut cz) = (0.0_f64, 0.0_f64, 0.0_f64);
437 for p in &cloud.points {
438 cx += p.x as f64;
439 cy += p.y as f64;
440 cz += p.z as f64;
441 }
442 cx /= n;
443 cy /= n;
444 cz /= n;
445
446 let mut distances: Vec<f64> = Vec::with_capacity(cloud.points.len());
448 let mut w_mean = 0.0_f64;
449 let mut w_m2 = 0.0_f64;
450 for (i, p) in cloud.points.iter().enumerate() {
451 let dx = p.x as f64 - cx;
452 let dy = p.y as f64 - cy;
453 let dz = p.z as f64 - cz;
454 let d = (dx * dx + dy * dy + dz * dz).sqrt();
455 distances.push(d);
456 let delta = d - w_mean;
457 w_mean += delta / (i + 1) as f64;
458 w_m2 += delta * (d - w_mean);
459 }
460
461 let variance = w_m2 / n;
462 let std_dev = variance.sqrt();
463
464 if std_dev < f64::EPSILON {
465 return Ok(Vec::new());
466 }
467
468 let mut anomalies = Vec::new();
469 for (i, p) in cloud.points.iter().enumerate() {
470 let z = (distances[i] - w_mean) / std_dev;
471 if z.abs() > self.anomaly_threshold {
472 anomalies.push(Anomaly {
473 position: [p.x as f64, p.y as f64, p.z as f64],
474 score: z.abs(),
475 description: format!(
476 "outlier at ({:.2}, {:.2}, {:.2}) z={:.2}",
477 p.x, p.y, p.z, z
478 ),
479 timestamp: cloud.timestamp_us,
480 });
481 }
482 }
483
484 Ok(anomalies)
485 }
486
487 fn bounding_sphere(points: &[Point3D]) -> ([f64; 3], f64) {
490 debug_assert!(!points.is_empty(), "bounding_sphere called with empty slice");
491 let n = points.len() as f64;
492 let (mut sx, mut sy, mut sz) = (0.0_f64, 0.0_f64, 0.0_f64);
493 for p in points {
494 sx += p.x as f64;
495 sy += p.y as f64;
496 sz += p.z as f64;
497 }
498 let center = [sx / n, sy / n, sz / n];
499
500 let radius_sq = points
502 .iter()
503 .map(|p| {
504 let dx = p.x as f64 - center[0];
505 let dy = p.y as f64 - center[1];
506 let dz = p.z as f64 - center[2];
507 dx * dx + dy * dy + dz * dz
508 })
509 .fold(0.0_f64, f64::max);
510
511 (center, radius_sq.sqrt())
512 }
513
514 #[inline]
515 fn dist_3d(a: &[f64; 3], b: &[f64; 3]) -> f64 {
516 ((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
517 }
518}
519
520#[cfg(test)]
525mod tests {
526 use super::*;
527 use crate::bridge::Point3D;
528
529 fn make_cloud(pts: &[[f32; 3]]) -> PointCloud {
530 let points: Vec<Point3D> =
531 pts.iter().map(|a| Point3D::new(a[0], a[1], a[2])).collect();
532 PointCloud::new(points, 1000)
533 }
534
535 #[test]
538 fn test_scene_graph_builder_basic() {
539 let builder = SceneGraphBuilder::default();
540 let objects = vec![
541 SceneObject::new(0, [0.0, 0.0, 0.0], [0.5, 0.5, 0.5]),
542 SceneObject::new(1, [2.0, 0.0, 0.0], [0.5, 0.5, 0.5]),
543 SceneObject::new(2, [100.0, 0.0, 0.0], [0.5, 0.5, 0.5]),
544 ];
545 let graph = builder.build(objects, 0);
546 assert_eq!(graph.objects.len(), 3);
547 assert_eq!(graph.edges.len(), 1);
548 assert_eq!(graph.edges[0].from, 0);
549 assert_eq!(graph.edges[0].to, 1);
550 }
551
552 #[test]
553 fn test_scene_graph_builder_merge() {
554 let builder = SceneGraphBuilder::new(10.0, 256);
555 let a = SceneGraph::new(
556 vec![SceneObject::new(0, [0.0, 0.0, 0.0], [0.5, 0.5, 0.5])],
557 vec![],
558 100,
559 );
560 let b = SceneGraph::new(
561 vec![SceneObject::new(0, [1.0, 0.0, 0.0], [0.5, 0.5, 0.5])],
562 vec![],
563 200,
564 );
565 let merged = builder.merge(&a, &b);
566 assert_eq!(merged.objects.len(), 2);
567 assert_eq!(merged.timestamp, 200);
568 assert!(!merged.edges.is_empty());
569 }
570
571 #[test]
574 fn test_perception_pipeline_process() {
575 let config = PerceptionConfig::default();
576 let mut pipeline = PerceptionPipeline::new(config);
577
578 let cloud = make_cloud(&[
579 [1.0, 0.0, 0.0],
580 [1.1, 0.0, 0.0],
581 [1.2, 0.0, 0.0],
582 [5.0, 5.0, 0.0],
583 [5.1, 5.0, 0.0],
584 [5.2, 5.0, 0.0],
585 ]);
586
587 let (obstacles, graph) = pipeline.process(&cloud, &[0.0, 0.0, 0.0]);
588 assert!(!obstacles.is_empty());
589 assert!(!graph.objects.is_empty());
590 assert_eq!(pipeline.frames_processed(), 1);
591 }
592
593 #[test]
596 fn test_detect_obstacles_empty() {
597 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
598 let cloud = PointCloud::default();
599 let result = pipe.detect_obstacles(&cloud, [0.0; 3], 10.0).unwrap();
600 assert!(result.is_empty());
601 }
602
603 #[test]
604 fn test_detect_obstacles_single_cluster() {
605 let pipe = PerceptionPipeline::with_thresholds(1.0, 2.0);
606 let cloud = make_cloud(&[
607 [2.0, 0.0, 0.0],
608 [2.1, 0.0, 0.0],
609 [2.0, 0.1, 0.0],
610 ]);
611 let obs = pipe.detect_obstacles(&cloud, [0.0; 3], 10.0).unwrap();
612 assert_eq!(obs.len(), 1);
613 assert!(obs[0].distance > 1.0);
614 assert!(obs[0].distance < 3.0);
615 assert!(!obs[0].label.is_empty());
616 }
617
618 #[test]
619 fn test_detect_obstacles_filters_distant() {
620 let pipe = PerceptionPipeline::with_thresholds(1.0, 2.0);
621 let cloud = make_cloud(&[
622 [50.0, 0.0, 0.0],
623 [50.1, 0.0, 0.0],
624 [50.0, 0.1, 0.0],
625 ]);
626 let obs = pipe.detect_obstacles(&cloud, [0.0; 3], 5.0).unwrap();
627 assert!(obs.is_empty());
628 }
629
630 #[test]
633 fn test_build_scene_graph_basic() {
634 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
635 let objects = vec![
636 SceneObject::new(0, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
637 SceneObject::new(1, [2.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
638 ];
639 let graph = pipe.build_scene_graph(&objects, 5.0).unwrap();
640 assert_eq!(graph.objects.len(), 2);
641 assert_eq!(graph.edges.len(), 1);
642 }
643
644 #[test]
645 fn test_build_scene_graph_invalid_distance() {
646 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
647 let result = pipe.build_scene_graph(&[], -1.0);
648 assert!(result.is_err());
649 }
650
651 #[test]
652 fn test_build_scene_graph_no_edges() {
653 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
654 let objects = vec![
655 SceneObject::new(0, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
656 SceneObject::new(1, [100.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
657 ];
658 let graph = pipe.build_scene_graph(&objects, 5.0).unwrap();
659 assert!(graph.edges.is_empty());
660 }
661
662 #[test]
665 fn test_predict_trajectory_linear() {
666 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
667 let traj = pipe
668 .predict_trajectory([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], 3, 1.0)
669 .unwrap();
670 assert_eq!(traj.len(), 3);
671 assert!((traj.waypoints[0][0] - 1.0).abs() < 1e-9);
672 assert!((traj.waypoints[1][0] - 2.0).abs() < 1e-9);
673 assert!((traj.waypoints[2][0] - 3.0).abs() < 1e-9);
674 }
675
676 #[test]
677 fn test_predict_trajectory_zero_steps() {
678 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
679 let result = pipe.predict_trajectory([0.0; 3], [1.0, 0.0, 0.0], 0, 1.0);
680 assert!(result.is_err());
681 }
682
683 #[test]
684 fn test_predict_trajectory_negative_dt() {
685 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
686 let result = pipe.predict_trajectory([0.0; 3], [1.0, 0.0, 0.0], 5, -0.1);
687 assert!(result.is_err());
688 }
689
690 #[test]
693 fn test_focus_attention_filters() {
694 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
695 let cloud = make_cloud(&[
696 [0.0, 0.0, 0.0],
697 [1.0, 0.0, 0.0],
698 [10.0, 0.0, 0.0],
699 ]);
700 let focused = pipe
701 .focus_attention(&cloud, [0.0, 0.0, 0.0], 2.0)
702 .unwrap();
703 assert_eq!(focused.len(), 2);
704 }
705
706 #[test]
707 fn test_focus_attention_invalid_radius() {
708 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
709 let cloud = PointCloud::default();
710 let result = pipe.focus_attention(&cloud, [0.0; 3], -1.0);
711 assert!(result.is_err());
712 }
713
714 #[test]
717 fn test_detect_anomalies_outlier() {
718 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
719 let mut pts: Vec<[f32; 3]> =
720 (0..20).map(|i| [i as f32 * 0.1, 0.0, 0.0]).collect();
721 pts.push([100.0, 100.0, 100.0]);
722 let cloud = make_cloud(&pts);
723 let anomalies = pipe.detect_anomalies(&cloud).unwrap();
724 assert!(!anomalies.is_empty());
725 assert!(anomalies.iter().any(|a| a.score > 2.0));
726 }
727
728 #[test]
729 fn test_detect_anomalies_no_outliers() {
730 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
731 let cloud = make_cloud(&[
732 [1.0, 1.0, 1.0],
733 [1.0, 1.0, 1.0],
734 [1.0, 1.0, 1.0],
735 ]);
736 let anomalies = pipe.detect_anomalies(&cloud).unwrap();
737 assert!(anomalies.is_empty());
738 }
739
740 #[test]
741 fn test_detect_anomalies_small_cloud() {
742 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
743 let cloud = make_cloud(&[[1.0, 0.0, 0.0]]);
744 let anomalies = pipe.detect_anomalies(&cloud).unwrap();
745 assert!(anomalies.is_empty());
746 }
747
748 #[test]
751 fn test_pipeline_debug() {
752 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
753 let dbg = format!("{:?}", pipe);
754 assert!(dbg.contains("PerceptionPipeline"));
755 }
756
757 #[test]
758 fn test_scene_graph_edge_relations() {
759 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
760 let objects = vec![
761 SceneObject::new(0, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
762 SceneObject::new(1, [1.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
763 SceneObject::new(2, [6.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
764 SceneObject::new(3, [9.0, 0.0, 0.0], [1.0, 1.0, 1.0]),
765 ];
766 let graph = pipe.build_scene_graph(&objects, 10.0).unwrap();
767 let adj = graph.edges.iter().find(|e| e.from == 0 && e.to == 1);
768 assert!(adj.is_some());
769 assert_eq!(adj.unwrap().relation, "adjacent");
770 }
771
772 #[test]
773 fn test_trajectory_timestamps_are_microseconds() {
774 let pipe = PerceptionPipeline::with_thresholds(0.5, 2.0);
775 let traj = pipe
776 .predict_trajectory([0.0; 3], [1.0, 0.0, 0.0], 2, 0.5)
777 .unwrap();
778 assert_eq!(traj.timestamps[0], 500_000);
780 assert_eq!(traj.timestamps[1], 1_000_000);
781 }
782}