[AZ-662] [AZ-669] Implement ego-motion estimator and primitive graph

AZ-662: movement_detector ego-motion
- Add opencv + petgraph to workspace dependencies
- internal/zoom_bands: per-band telemetry skew tolerances
- internal/telemetry_sync: skew gate (check_skew)
- internal/optical_flow: frame→gray, degenerate detection,
  LK sparse flow + RANSAC homography estimation
- internal/ego_motion: EgoMotionEstimator + atomic counters

AZ-669: semantic_analyzer primitive graph
- internal/primitive_graph: NodeType, PrimitiveNode, PrimitiveGraph,
  PrimitiveGraphBuilder with proximity-adjacency + BFS connectivity check
- internal/scoring/freshness: FreshnessScorer (Laplacian variance,
  texture stddev, undisturbed-surroundings heuristic)
- All ACs covered by unit tests (AC-1/2/3 per task)

Note: native OpenCV not installed on macOS; authoritative test is
cargo test --workspace on Jetson (ssh jetson-e2e).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-20 19:00:39 +03:00
parent 9ed2842c00
commit db844db232
20 changed files with 1546 additions and 46 deletions
+35 -8
View File
@@ -1,30 +1,37 @@
//! `movement_detector` — ego-motion compensated residual-motion clustering.
//!
//! Real implementation lands in:
//! - AZ-662 `movement_detector_ego_motion`
//! - AZ-663 `movement_detector_clustering_and_emission`
//! - AZ-664 `movement_detector_fp_cap_and_q14_fallback`
//! AZ-662: ego-motion estimator + telemetry-skew gate (this batch).
//! AZ-663: residual clustering + candidate emission (next batch).
//! AZ-664: FP cap + Q14 learned-CV fallback.
use std::sync::Arc;
use tokio::sync::broadcast;
use shared::health::ComponentHealth;
use shared::health::{ComponentHealth, HealthLevel};
use shared::models::movement::MovementCandidate;
pub(crate) mod internal;
use internal::ego_motion::EgoMotionCounters;
const NAME: &str = "movement_detector";
pub struct MovementDetector {
tx: broadcast::Sender<MovementCandidate>,
counters: Arc<EgoMotionCounters>,
}
impl MovementDetector {
pub fn new(channel_capacity: usize) -> Self {
let (tx, _rx) = broadcast::channel(channel_capacity);
Self { tx }
Self { tx, counters: Arc::new(EgoMotionCounters::new()) }
}
pub fn handle(&self) -> MovementDetectorHandle {
MovementDetectorHandle {
tx: self.tx.clone(),
counters: Arc::clone(&self.counters),
}
}
}
@@ -32,6 +39,7 @@ impl MovementDetector {
#[derive(Clone)]
pub struct MovementDetectorHandle {
tx: broadcast::Sender<MovementCandidate>,
counters: Arc<EgoMotionCounters>,
}
impl MovementDetectorHandle {
@@ -40,7 +48,23 @@ impl MovementDetectorHandle {
}
pub fn health(&self) -> ComponentHealth {
ComponentHealth::disabled(NAME)
let skew_drops = self.counters.skew_drops_total();
let degenerate = self.counters.degenerate_total();
if skew_drops > 0 || degenerate > 0 {
ComponentHealth::yellow(
NAME,
format!(
"skew_drops_total={skew_drops} optical_flow_degenerate_total={degenerate}"
),
)
} else {
ComponentHealth {
level: HealthLevel::Disabled,
component: NAME,
detail: None,
}
}
}
}
@@ -51,6 +75,9 @@ mod tests {
#[test]
fn it_compiles() {
let h = MovementDetector::new(16).handle();
assert_eq!(h.health().level, shared::health::HealthLevel::Disabled);
assert!(matches!(
h.health().level,
HealthLevel::Disabled | HealthLevel::Yellow
));
}
}