mirror of
https://github.com/azaion/autopilot.git
synced 2026-06-21 22:51:11 +00:00
[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:
@@ -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};
|
||||
Reference in New Issue
Block a user