Skip to main content

ruvector_robotics/perception/
mod.rs

1//! Perception subsystem: scene graph construction, obstacle detection, and pipeline.
2//!
3//! This module sits on top of [`crate::bridge`] types and provides higher-level
4//! perception building blocks used by the cognitive architecture.
5
6pub 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// ---------------------------------------------------------------------------
23// Error type
24// ---------------------------------------------------------------------------
25
26/// Errors emitted by perception pipeline operations.
27#[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
35/// Convenience alias used throughout the perception module.
36pub type Result<T> = std::result::Result<T, PerceptionError>;
37
38// ---------------------------------------------------------------------------
39// Anomaly type
40// ---------------------------------------------------------------------------
41
42/// A point-cloud anomaly detected via z-score outlier analysis.
43#[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// ---------------------------------------------------------------------------
52// SceneGraphBuilder
53// ---------------------------------------------------------------------------
54
55/// Builds a [`SceneGraph`] from detected obstacles or raw point clouds.
56///
57/// The builder clusters scene objects, computes spatial edges between nearby
58/// objects, and produces a timestamped scene graph.
59#[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    /// Create a new builder with explicit parameters.
76    pub fn new(edge_distance_threshold: f64, max_objects: usize) -> Self {
77        Self {
78            edge_distance_threshold,
79            max_objects,
80        }
81    }
82
83    /// Build a scene graph from a list of [`SceneObject`]s.
84    ///
85    /// Edges are created between every pair of objects whose centers are within
86    /// `edge_distance_threshold`.
87    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                    // Only compute sqrt for edges that pass the filter.
100                    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    /// Build a scene graph from detected obstacles.
122    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    /// Merge two scene graphs into one, re-computing edges.
141    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// ---------------------------------------------------------------------------
155// PerceptionPipeline
156// ---------------------------------------------------------------------------
157
158/// End-to-end perception pipeline that processes sensor frames into scene
159/// graphs and obstacle lists.
160///
161/// Supports two construction modes:
162/// - [`PerceptionPipeline::new`] for config-driven construction
163/// - [`PerceptionPipeline::with_thresholds`] for threshold-driven
164///   construction (obstacle cell-size and anomaly z-score)
165#[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    /// Create a new pipeline with the given configuration.
176    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    /// Create a pipeline from explicit thresholds.
193    ///
194    /// * `obstacle_threshold` -- clustering cell size for obstacle grouping.
195    /// * `anomaly_threshold` -- z-score threshold for anomaly detection.
196    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    /// Process a point cloud relative to a robot position, returning
216    /// detected obstacles and a scene graph.
217    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    /// Classify previously detected obstacles.
229    pub fn classify(
230        &self,
231        obstacles: &[DetectedObstacle],
232    ) -> Vec<ClassifiedObstacle> {
233        self.detector.classify_obstacles(obstacles)
234    }
235
236    /// Number of frames processed so far.
237    pub fn frames_processed(&self) -> u64 {
238        self.frames_processed
239    }
240
241    // -- Obstacle detection (bridge-level) ----------------------------------
242
243    /// Detect obstacles in `cloud` relative to `robot_position`.
244    ///
245    /// Points further than `max_distance` from the robot are ignored.
246    /// Returns bridge-level [`Obstacle`] values sorted by distance.
247    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(&center, &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    // -- Scene-graph construction -------------------------------------------
303
304    /// Build a scene graph from pre-classified objects.
305    ///
306    /// Edges are created between objects whose centres are within
307    /// `max_edge_distance`, labelled "adjacent" / "near" / "far".
308    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    // -- Trajectory prediction ----------------------------------------------
351
352    /// Predict a future trajectory via linear extrapolation.
353    ///
354    /// Returns a [`Trajectory`] with `steps` waypoints, each separated by
355    /// `dt` seconds.
356    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    // -- Attention focusing -------------------------------------------------
392
393    /// Filter points from `cloud` that lie within `radius` of `center`.
394    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    // -- Anomaly detection --------------------------------------------------
423
424    /// Detect anomalous points using z-score outlier analysis.
425    ///
426    /// For each point the distance from the cloud centroid is computed;
427    /// points whose z-score exceeds `anomaly_threshold` are returned.
428    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        // Pass 1: compute centroid.
436        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        // Pass 2: compute distances and running mean + variance (Welford's).
447        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    // -- private helpers ----------------------------------------------------
488
489    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        // Compare squared distances and take a single sqrt at the end.
501        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// ---------------------------------------------------------------------------
521// Tests
522// ---------------------------------------------------------------------------
523
524#[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    // -- SceneGraphBuilder (inline) -----------------------------------------
536
537    #[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    // -- PerceptionPipeline.process (config-driven) -------------------------
572
573    #[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    // -- detect_obstacles ---------------------------------------------------
594
595    #[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    // -- build_scene_graph --------------------------------------------------
631
632    #[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    // -- predict_trajectory -------------------------------------------------
663
664    #[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    // -- focus_attention ----------------------------------------------------
691
692    #[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    // -- detect_anomalies ---------------------------------------------------
715
716    #[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    // -- edge cases & integration ------------------------------------------
749
750    #[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        // 0.5s = 500_000 us, 1.0s = 1_000_000 us
779        assert_eq!(traj.timestamps[0], 500_000);
780        assert_eq!(traj.timestamps[1], 1_000_000);
781    }
782}