[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
@@ -0,0 +1,281 @@
//! AZ-669 — Build a `PrimitiveGraph` from a `DetectionBatch` inside an ROI,
//! then validate connectivity of the path sub-graph.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use shared::models::{detection::DetectionBatch, frame::BoundingBox};
use super::graph::{NodeType, PrimitiveGraph, PrimitiveNode};
// ── class-name → NodeType mapping ────────────────────────────────────────────
fn classify_class_name(name: &str) -> NodeType {
let lower = name.to_ascii_lowercase();
if lower.contains("path") || lower.contains("road") || lower.contains("footpath") {
NodeType::Path
} else if lower.contains("branch")
|| lower.contains("pile")
|| lower.contains("entrance")
|| lower.contains("dugout")
{
NodeType::Endpoint
} else {
// trees, tree blocks, and unknowns are contextual landmarks
NodeType::Context
}
}
// ── spatial proximity helpers ─────────────────────────────────────────────────
/// Centre of a bounding box in normalised image coordinates.
fn centre(b: &BoundingBox) -> (f32, f32) {
((b.x_min + b.x_max) / 2.0, (b.y_min + b.y_max) / 2.0)
}
/// Euclidean distance between two bbox centres.
fn centre_dist(a: &BoundingBox, b: &BoundingBox) -> f32 {
let (ax, ay) = centre(a);
let (bx, by) = centre(b);
((ax - bx).powi(2) + (ay - by).powi(2)).sqrt()
}
/// Maximum dimension of a bounding box (normalised units).
fn max_dim(b: &BoundingBox) -> f32 {
(b.x_max - b.x_min).max(b.y_max - b.y_min)
}
// ── connectivity (BFS on path nodes) ─────────────────────────────────────────
/// Returns the number of connected components in the path sub-graph described
/// by `edges` over the `path_indices` set.
fn count_path_components(
path_indices: &[usize],
edges: &[(usize, usize)],
) -> usize {
if path_indices.is_empty() {
return 0;
}
// Map global node index → local index within `path_indices`.
let mut local: std::collections::HashMap<usize, usize> =
path_indices.iter().enumerate().map(|(l, &g)| (g, l)).collect();
let n = path_indices.len();
let mut adj: Vec<Vec<usize>> = vec![vec![]; n];
for &(a, b) in edges {
if let (Some(&la), Some(&lb)) = (local.get(&a), local.get(&b)) {
adj[la].push(lb);
adj[lb].push(la);
}
}
let mut visited = vec![false; n];
let mut components = 0usize;
for start in 0..n {
if visited[start] {
continue;
}
components += 1;
let mut queue = std::collections::VecDeque::new();
queue.push_back(start);
visited[start] = true;
while let Some(cur) = queue.pop_front() {
for &nb in &adj[cur] {
if !visited[nb] {
visited[nb] = true;
queue.push_back(nb);
}
}
}
}
components
}
// ── builder ───────────────────────────────────────────────────────────────────
pub struct GraphCounters {
pub graphs_built_total: AtomicU64,
pub disconnected_graphs_total: AtomicU64,
}
impl GraphCounters {
pub fn new() -> Self {
Self {
graphs_built_total: AtomicU64::new(0),
disconnected_graphs_total: AtomicU64::new(0),
}
}
}
impl Default for GraphCounters {
fn default() -> Self {
Self::new()
}
}
pub struct PrimitiveGraphBuilder {
counters: Arc<GraphCounters>,
/// Spatial-proximity multiplier: two path nodes are adjacent when their
/// centre-to-centre distance ≤ this factor × the larger of their max dims.
adjacency_factor: f32,
}
impl PrimitiveGraphBuilder {
pub fn new(counters: Arc<GraphCounters>) -> Self {
Self { counters, adjacency_factor: 2.5 }
}
pub fn counters(&self) -> &Arc<GraphCounters> {
&self.counters
}
/// Build a `PrimitiveGraph` from detections inside `roi`.
///
/// Only detections whose bbox centre lies inside `roi` are included.
/// After construction the path sub-graph is validated for connectivity;
/// a disconnected graph is flagged and the counter is incremented.
pub fn build(&self, roi: &BoundingBox, batch: &DetectionBatch) -> PrimitiveGraph {
let nodes: Vec<PrimitiveNode> = batch
.detections
.iter()
.enumerate()
.filter(|(_, d)| {
let (cx, cy) = centre(&d.bbox_normalized);
cx >= roi.x_min
&& cx <= roi.x_max
&& cy >= roi.y_min
&& cy <= roi.y_max
})
.map(|(i, d)| PrimitiveNode {
node_type: classify_class_name(&d.class_name),
bbox: d.bbox_normalized,
confidence: d.confidence,
class_name: d.class_name.clone(),
detection_index: i,
})
.collect();
// Build proximity edges between path nodes only.
let path_idxs: Vec<usize> = nodes
.iter()
.enumerate()
.filter(|(_, n)| n.node_type == NodeType::Path)
.map(|(i, _)| i)
.collect();
let mut edges: Vec<(usize, usize)> = Vec::new();
for i in 0..path_idxs.len() {
for j in (i + 1)..path_idxs.len() {
let ni = &nodes[path_idxs[i]];
let nj = &nodes[path_idxs[j]];
let dist = centre_dist(&ni.bbox, &nj.bbox);
let threshold = self.adjacency_factor * max_dim(&ni.bbox).max(max_dim(&nj.bbox));
if dist <= threshold {
edges.push((path_idxs[i], path_idxs[j]));
}
}
}
// Connectivity validation.
let components = count_path_components(&path_idxs, &edges);
let disconnected = components > 1;
let valid = !disconnected;
if disconnected {
self.counters.disconnected_graphs_total.fetch_add(1, Ordering::Relaxed);
tracing::warn!(
disconnected_components = components,
"primitive graph has disconnected path components"
);
}
self.counters.graphs_built_total.fetch_add(1, Ordering::Relaxed);
PrimitiveGraph { nodes, edges, valid, disconnected }
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
use shared::models::detection::{Detection, DetectionBatch};
use shared::models::frame::BoundingBox;
fn roi() -> BoundingBox {
BoundingBox { x_min: 0.0, y_min: 0.0, x_max: 1.0, y_max: 1.0 }
}
fn det(class_name: &str, x: f32, y: f32) -> Detection {
Detection {
class_id: 0,
class_name: class_name.to_owned(),
confidence: 0.9,
bbox_normalized: BoundingBox {
x_min: x - 0.05,
y_min: y - 0.05,
x_max: x + 0.05,
y_max: y + 0.05,
},
mask_or_polyline: None,
source_frame_seq: 0,
}
}
fn batch(dets: Vec<Detection>) -> DetectionBatch {
DetectionBatch {
frame_seq: 1,
detections: dets,
latency_ms: 10,
model_version: "v1".to_owned(),
}
}
// AC-1: correct node counts per detection class.
#[test]
fn ac1_node_counts_per_class() {
let counters = Arc::new(GraphCounters::new());
let builder = PrimitiveGraphBuilder::new(Arc::clone(&counters));
let dets = vec![
det("footpath", 0.1, 0.1),
det("footpath", 0.2, 0.2),
det("footpath", 0.3, 0.3),
det("branch_pile", 0.4, 0.4),
det("branch_pile", 0.5, 0.5),
det("tree", 0.6, 0.1),
det("tree", 0.7, 0.2),
det("tree", 0.8, 0.3),
det("tree", 0.15, 0.6),
det("tree_block", 0.25, 0.7),
];
let b = batch(dets);
let graph = builder.build(&roi(), &b);
let paths = graph.nodes.iter().filter(|n| n.node_type == NodeType::Path).count();
let endpoints = graph.nodes.iter().filter(|n| n.node_type == NodeType::Endpoint).count();
let contexts = graph.nodes.iter().filter(|n| n.node_type == NodeType::Context).count();
assert_eq!(paths, 3, "expected 3 path nodes");
assert_eq!(endpoints, 2, "expected 2 endpoint nodes");
assert_eq!(contexts, 5, "expected 5 context nodes");
assert_eq!(counters.graphs_built_total.load(Ordering::Relaxed), 1);
}
// AC-3: disconnected path components are flagged and counter increments.
#[test]
fn ac3_disconnected_path_graph_flagged() {
let counters = Arc::new(GraphCounters::new());
// Use a very small adjacency factor so distant nodes don't accidentally connect.
let builder = PrimitiveGraphBuilder { counters: Arc::clone(&counters), adjacency_factor: 0.5 };
// Two isolated path clusters — far apart in the image.
let dets = vec![
det("footpath", 0.1, 0.1), // cluster A
det("footpath", 0.9, 0.9), // cluster B (isolated)
];
let graph = builder.build(&roi(), &batch(dets));
assert!(graph.disconnected, "graph should be marked disconnected");
assert!(!graph.valid);
assert_eq!(counters.disconnected_graphs_total.load(Ordering::Relaxed), 1);
}
}
@@ -0,0 +1,47 @@
//! Primitive graph types — path, endpoint, and context nodes.
use shared::models::frame::BoundingBox;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NodeType {
/// Footpath, road — the main navigation surface.
Path,
/// Branch pile, dark entrance, dugout — a decision point or POI endpoint.
Endpoint,
/// Tree, tree block — contextual landmark.
Context,
}
#[derive(Debug, Clone)]
pub struct PrimitiveNode {
pub node_type: NodeType,
pub bbox: BoundingBox,
pub confidence: f32,
pub class_name: String,
/// Index into the source `DetectionBatch.detections` vec.
pub detection_index: usize,
}
/// A small ROI-scoped graph of primitive detections.
///
/// `edges` encodes spatial-proximity adjacency between path nodes
/// (indices into `nodes`). `valid = false` and `disconnected = true`
/// when ≥2 separate path components are found.
#[derive(Debug, Default)]
pub struct PrimitiveGraph {
pub nodes: Vec<PrimitiveNode>,
/// Undirected adjacency edges between path nodes (node indices).
pub edges: Vec<(usize, usize)>,
/// False when the path sub-graph has ≥2 connected components.
pub valid: bool,
pub disconnected: bool,
}
impl PrimitiveGraph {
pub fn path_nodes(&self) -> impl Iterator<Item = (usize, &PrimitiveNode)> {
self.nodes
.iter()
.enumerate()
.filter(|(_, n)| n.node_type == NodeType::Path)
}
}
@@ -0,0 +1,7 @@
//! AZ-669 — Primitive graph builder + graph validation.
pub mod builder;
pub mod graph;
pub use builder::PrimitiveGraphBuilder;
pub use graph::{NodeType, PrimitiveGraph, PrimitiveNode};