Files
enuxia-vacuum/vastu-map/examples/exploration/harness/visualization.rs
T
codetiger 5cf2a3cc81 Move test harness from library to example-local module
The harness is only used by the exploration example, not as part of
the public API. This change:

- Moves harness from src/harness/ to examples/exploration/harness/
- Removes integration-tests feature flag from Cargo.toml
- Moves sangam-io, svg, image to dev-dependencies
- Removes unused scenario module and path.rs

Example now runs with: cargo run --example exploration --release
2025-12-24 16:49:48 +05:30

817 lines
27 KiB
Rust

//! SVG visualization for manual inspection
//!
//! Generates SVG files showing:
//! - Map walls from PGM (gray background)
//! - Detected walls (teal solid lines)
//! - Robot trajectories (ground truth blue, estimated with confidence heatmap)
//! - Drift vectors at observation points
//! - Scan history at key points
//! - Ground truth pose (blue marker)
//! - Estimated pose (orange marker)
//! - Metrics overlay
use sangam_io::devices::mock::map_loader::SimulationMap;
use svg::Document;
use svg::node::element::{
Circle, Definitions, Group, Line, Marker, Path, Polyline, Rectangle, Text,
};
use vastu_map::Path as PlannedPath;
use vastu_map::VectorMap;
use vastu_map::core::{Point2D, Pose2D};
use vastu_map::integration::ScanStore;
use vastu_map::query::{ClearanceVisibilityGraph, NodeType};
use super::harness::TrajectoryHistory;
use super::metrics::TestMetrics;
/// Colorblind-friendly color palette (Okabe-Ito).
mod colors {
/// Ground truth trajectory - blue
pub const TRUTH_PATH: &str = "#0072B2";
/// Estimated trajectory default - orange (used when not showing confidence)
pub const ESTIMATED_PATH: &str = "#E69F00";
/// Drift vectors - vermillion
pub const DRIFT_VECTOR: &str = "#D55E00";
/// Detected walls - teal
pub const DETECTED_WALLS: &str = "#009E73";
/// Map walls - gray
pub const MAP_WALLS: &str = "#BBBBBB";
/// Lidar scan - sky blue
pub const LIDAR_SCAN: &str = "#56B4E9";
/// Lidar scan history - lighter, semi-transparent
pub const LIDAR_SCAN_HISTORY: &str = "#56B4E9";
/// Visibility graph nodes - purple
pub const GRAPH_NODE: &str = "#CC79A7";
/// Visibility graph edges - light purple
pub const GRAPH_EDGE: &str = "#CC79A7";
/// Planned path - green
pub const PLANNED_PATH: &str = "#009E73";
/// Start node - green
pub const START_NODE: &str = "#00FF00";
/// Goal node - red
pub const GOAL_NODE: &str = "#FF0000";
}
/// Interval for displaying drift vectors (every Nth observation).
const DRIFT_VECTOR_INTERVAL: usize = 5;
/// Opacity for historical scans.
const SCAN_HISTORY_OPACITY: f32 = 0.3;
/// Visualization generator.
pub struct Visualizer {
output_path: String,
scale: f32,
margin: f32,
}
impl Visualizer {
/// Create a new visualizer.
///
/// # Arguments
/// * `output_path` - Path for the output SVG file
pub fn new(output_path: &str) -> Self {
Self {
output_path: output_path.to_string(),
scale: 100.0, // 100 pixels per meter
margin: 50.0,
}
}
/// Convert confidence value (0.0-1.0) to a color.
/// 0.0 = red, 0.5 = yellow, 1.0 = green
fn confidence_to_color(confidence: f32) -> String {
let conf = confidence.clamp(0.0, 1.0);
let r = if conf < 0.5 {
255
} else {
((1.0 - conf) * 2.0 * 255.0) as u8
};
let g = if conf > 0.5 {
255
} else {
(conf * 2.0 * 255.0) as u8
};
format!("#{:02X}{:02X}00", r, g)
}
/// Create SVG marker definitions for arrows.
fn create_marker_definitions(&self) -> Definitions {
let arrow_marker = Marker::new()
.set("id", "drift-arrow")
.set("markerWidth", 6)
.set("markerHeight", 6)
.set("refX", 5)
.set("refY", 3)
.set("orient", "auto")
.add(
Path::new()
.set("d", "M0,0 L6,3 L0,6 Z")
.set("fill", colors::DRIFT_VECTOR),
);
Definitions::new().add(arrow_marker)
}
/// Render ground truth path (solid blue) and estimated path with confidence heatmap.
fn render_trajectories(&self, trajectory: &TrajectoryHistory, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "trajectories");
if trajectory.observations.len() < 2 {
return group;
}
// Ground truth path - solid blue polyline
let truth_points: String = trajectory
.observations
.iter()
.map(|o| {
let (x, y) = self.transform_point(o.truth_pose.position(), 0.0, 0.0, svg_height);
format!("{},{}", x, y)
})
.collect::<Vec<_>>()
.join(" ");
let truth_path = Polyline::new()
.set("points", truth_points)
.set("fill", "none")
.set("stroke", colors::TRUTH_PATH)
.set("stroke-width", 2.5)
.set("stroke-linecap", "round")
.set("stroke-linejoin", "round");
group = group.add(truth_path);
// Estimated path - individual line segments colored by confidence
for i in 1..trajectory.observations.len() {
let prev = &trajectory.observations[i - 1];
let curr = &trajectory.observations[i];
// Use the current observation's confidence for the segment color
let color = Self::confidence_to_color(curr.confidence);
let (x1, y1) = self.transform_point(prev.slam_pose.position(), 0.0, 0.0, svg_height);
let (x2, y2) = self.transform_point(curr.slam_pose.position(), 0.0, 0.0, svg_height);
let segment = Line::new()
.set("x1", x1)
.set("y1", y1)
.set("x2", x2)
.set("y2", y2)
.set("stroke", color)
.set("stroke-width", 2.5)
.set("stroke-linecap", "round");
group = group.add(segment);
}
group
}
/// Render drift vectors at every N observations.
fn render_drift_vectors(&self, trajectory: &TrajectoryHistory, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "drift_vectors");
for (i, obs) in trajectory.observations.iter().enumerate() {
// Show drift at fixed intervals and at the last observation
if i % DRIFT_VECTOR_INTERVAL != 0 && i != trajectory.observations.len() - 1 {
continue;
}
// Skip if drift is negligible
if obs.drift_translation < 0.02 {
continue;
}
let (x1, y1) = self.transform_point(obs.truth_pose.position(), 0.0, 0.0, svg_height);
let (x2, y2) = self.transform_point(obs.slam_pose.position(), 0.0, 0.0, svg_height);
// Drift vector line with arrowhead
let line = Line::new()
.set("x1", x1)
.set("y1", y1)
.set("x2", x2)
.set("y2", y2)
.set("stroke", colors::DRIFT_VECTOR)
.set("stroke-width", 1.5)
.set("marker-end", "url(#drift-arrow)");
group = group.add(line);
// Small circle at ground truth position
let circle = Circle::new()
.set("cx", x1)
.set("cy", y1)
.set("r", 3.0)
.set("fill", colors::DRIFT_VECTOR)
.set("opacity", 0.6);
group = group.add(circle);
}
group
}
/// Render all scans from the scan store.
fn render_all_scans(&self, scan_store: &ScanStore, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "scan_history");
// Skip the last scan as it will be rendered at full opacity separately
let scan_count = scan_store.len();
for (idx, scan) in scan_store.iter().enumerate() {
// Skip the last scan (rendered separately at full opacity)
if idx == scan_count - 1 {
continue;
}
// Get world-frame points directly from stored scan
let world_points = scan.world_points();
for point in world_points {
let (sx, sy) = self.transform_point(point, 0.0, 0.0, svg_height);
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", 2.0)
.set("fill", colors::LIDAR_SCAN_HISTORY)
.set("opacity", SCAN_HISTORY_OPACITY);
group = group.add(circle);
}
}
group
}
/// Render world-frame points (for final scan).
fn render_world_points(&self, points: &[Point2D], svg_height: f32) -> Group {
let mut group = Group::new().set("id", "lidar_scan");
for point in points {
let (sx, sy) = self.transform_point(*point, 0.0, 0.0, svg_height);
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", 3.0)
.set("fill", colors::LIDAR_SCAN)
.set("stroke", colors::TRUTH_PATH)
.set("stroke-width", 0.5)
.set("opacity", 0.8);
group = group.add(circle);
}
group
}
/// Render enhanced legend with all visual elements.
fn render_enhanced_legend(&self, _svg_height: f32) -> Group {
let mut group = Group::new().set("id", "enhanced_legend");
let legend_x = 10.0;
let mut legend_y = 20.0;
let item_height = 16.0;
let icon_width = 20.0;
let items = vec![
(colors::MAP_WALLS, "Map Walls", "rect"),
(colors::DETECTED_WALLS, "Detected Walls", "line"),
(colors::LIDAR_SCAN, "Lidar Scan", "circle"),
(colors::TRUTH_PATH, "Ground Truth Path", "line"),
("gradient", "Estimated Path (Confidence)", "gradient"),
(colors::DRIFT_VECTOR, "Drift Vector", "arrow"),
(colors::GRAPH_NODE, "Graph Nodes", "circle"),
(colors::PLANNED_PATH, "Planned Path", "line"),
];
for (color, label, icon_type) in items {
// Draw icon based on type
match icon_type {
"rect" => {
let rect = Rectangle::new()
.set("x", legend_x)
.set("y", legend_y - 8.0)
.set("width", icon_width)
.set("height", 10.0)
.set("fill", color);
group = group.add(rect);
}
"line" => {
let line = Line::new()
.set("x1", legend_x)
.set("y1", legend_y - 3.0)
.set("x2", legend_x + icon_width)
.set("y2", legend_y - 3.0)
.set("stroke", color)
.set("stroke-width", 2.5);
group = group.add(line);
}
"circle" => {
let circle = Circle::new()
.set("cx", legend_x + icon_width / 2.0)
.set("cy", legend_y - 3.0)
.set("r", 4.0)
.set("fill", color);
group = group.add(circle);
}
"gradient" => {
// Show green-yellow-red gradient
let gradient_steps = 5;
let step_width = icon_width / gradient_steps as f32;
for i in 0..gradient_steps {
let conf = i as f32 / (gradient_steps - 1) as f32;
let grad_color = Self::confidence_to_color(conf);
let rect = Rectangle::new()
.set("x", legend_x + i as f32 * step_width)
.set("y", legend_y - 8.0)
.set("width", step_width + 0.5)
.set("height", 10.0)
.set("fill", grad_color);
group = group.add(rect);
}
}
"arrow" => {
let line = Line::new()
.set("x1", legend_x)
.set("y1", legend_y - 3.0)
.set("x2", legend_x + icon_width)
.set("y2", legend_y - 3.0)
.set("stroke", color)
.set("stroke-width", 1.5)
.set("marker-end", "url(#drift-arrow)");
group = group.add(line);
}
_ => {}
}
// Draw label
let text = Text::new(label)
.set("x", legend_x + icon_width + 8.0)
.set("y", legend_y)
.set("font-size", 11)
.set("font-family", "sans-serif")
.set("fill", "black");
group = group.add(text);
legend_y += item_height;
}
group
}
/// Render metrics overlay in top-right corner.
fn render_metrics_overlay(&self, metrics: &TestMetrics, width: i32) -> Group {
let mut group = Group::new().set("id", "metrics_overlay");
let box_width = 210.0;
let box_height = 110.0;
let box_x = width as f32 - box_width - 10.0;
let box_y = 10.0;
// Semi-transparent background
let bg = Rectangle::new()
.set("x", box_x)
.set("y", box_y)
.set("width", box_width)
.set("height", box_height)
.set("fill", "white")
.set("fill-opacity", 0.9)
.set("stroke", "#ccc")
.set("stroke-width", 1)
.set("rx", 5);
group = group.add(bg);
// Title
let title = Text::new("Test Metrics")
.set("x", box_x + 10.0)
.set("y", box_y + 18.0)
.set("font-size", 12)
.set("font-family", "sans-serif")
.set("font-weight", "bold")
.set("fill", "black");
group = group.add(title);
let mut text_y = box_y + 35.0;
let line_height = 15.0;
let lines = vec![
format!("Position Error: {:.3} m", metrics.position_error),
format!(
"Orientation Error: {:.1}°",
metrics.orientation_error.to_degrees()
),
format!("Wall Accuracy: {:.0}%", metrics.wall_accuracy * 100.0),
format!("Lines Detected: {}", metrics.detected_walls),
format!("Avg ICP Iters: {:.1}", metrics.convergence.avg_iterations()),
];
for line in lines {
let text = Text::new(line)
.set("x", box_x + 10.0)
.set("y", text_y)
.set("font-size", 11)
.set("font-family", "monospace")
.set("fill", "black");
group = group.add(text);
text_y += line_height;
}
group
}
/// Render map walls by sampling the occupancy grid.
fn render_map_walls(&self, map: &SimulationMap, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "map_walls");
let resolution = map.resolution();
let sample_step = resolution * 2.0; // Sample every 2 pixels for efficiency
let map_width_m = map.width() as f32 * resolution;
let map_height_m = map.height() as f32 * resolution;
// Sample the map and draw small rectangles for occupied cells
let mut x = 0.0;
while x < map_width_m {
let mut y = 0.0;
while y < map_height_m {
if map.is_occupied(x, y) {
let (sx, sy) = self.transform_point(Point2D::new(x, y), 0.0, 0.0, svg_height);
let rect_size = sample_step * self.scale;
let rect = Rectangle::new()
.set("x", sx - rect_size / 2.0)
.set("y", sy - rect_size / 2.0)
.set("width", rect_size)
.set("height", rect_size)
.set("fill", colors::MAP_WALLS)
.set("stroke", "none");
group = group.add(rect);
}
y += sample_step;
}
x += sample_step;
}
group
}
fn render_detected(&self, slam: &VectorMap, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "detected");
for line in slam.lines() {
let svg_line = self.create_line(
line.start,
line.end,
0.0,
0.0,
svg_height,
colors::DETECTED_WALLS,
2.0,
None,
);
group = group.add(svg_line);
}
group
}
fn render_poses(&self, estimated: Pose2D, truth: Pose2D, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "poses");
// Ground truth pose (blue)
group =
group.add(self.create_robot_marker(truth, 0.0, 0.0, svg_height, colors::TRUTH_PATH));
// Estimated pose (orange)
group = group.add(self.create_robot_marker(
estimated,
0.0,
0.0,
svg_height,
colors::ESTIMATED_PATH,
));
group
}
fn transform_point(
&self,
p: Point2D,
origin_x: f32,
origin_y: f32,
svg_height: f32,
) -> (f32, f32) {
// Transform world coordinates to SVG coordinates
// SVG has Y increasing downward, world has Y increasing upward
let x = (p.x - origin_x) * self.scale + self.margin;
let y = svg_height - ((p.y - origin_y) * self.scale + self.margin);
(x, y)
}
fn create_line(
&self,
start: Point2D,
end: Point2D,
origin_x: f32,
origin_y: f32,
svg_height: f32,
color: &str,
width: f32,
dash: Option<&str>,
) -> Line {
let (x1, y1) = self.transform_point(start, origin_x, origin_y, svg_height);
let (x2, y2) = self.transform_point(end, origin_x, origin_y, svg_height);
let mut line = Line::new()
.set("x1", x1)
.set("y1", y1)
.set("x2", x2)
.set("y2", y2)
.set("stroke", color)
.set("stroke-width", width);
if let Some(pattern) = dash {
line = line.set("stroke-dasharray", pattern);
}
line
}
fn create_robot_marker(
&self,
pose: Pose2D,
origin_x: f32,
origin_y: f32,
svg_height: f32,
color: &str,
) -> Group {
let (cx, cy) = self.transform_point(pose.position(), origin_x, origin_y, svg_height);
// Robot body (circle)
let radius = 10.0;
let body = Circle::new()
.set("cx", cx)
.set("cy", cy)
.set("r", radius)
.set("fill", color)
.set("opacity", 0.7);
// Direction indicator (line)
let dir_length = 15.0;
// Note: In SVG, Y increases downward, so we negate the sin component
let dir_x = cx + dir_length * pose.theta.cos();
let dir_y = cy - dir_length * pose.theta.sin();
let direction = Line::new()
.set("x1", cx)
.set("y1", cy)
.set("x2", dir_x)
.set("y2", dir_y)
.set("stroke", color)
.set("stroke-width", 3);
Group::new().add(body).add(direction)
}
/// Render a clearance-based visibility graph showing nodes and edges.
///
/// # Arguments
/// * `graph` - The CBVG to render
/// * `svg_height` - SVG canvas height for coordinate transformation
/// * `show_edges` - Whether to show edges (can be noisy for large graphs)
pub fn render_cbvg(
&self,
graph: &ClearanceVisibilityGraph,
svg_height: f32,
show_edges: bool,
) -> Group {
let mut group = Group::new().set("id", "cbvg");
let nodes = graph.nodes();
// Render edges first (so nodes appear on top)
if show_edges {
let edge_group = self.render_cbvg_edges(graph, svg_height);
group = group.add(edge_group);
}
// Render nodes
for node in nodes {
let (sx, sy) = self.transform_point(node.position, 0.0, 0.0, svg_height);
// Choose color and size based on node type
let (color, radius, opacity) = match node.node_type {
NodeType::Robot => (colors::START_NODE, 6.0, 0.9),
NodeType::Goal => (colors::GOAL_NODE, 6.0, 0.9),
NodeType::Junction => ("#9966FF", 5.0, 0.8), // Purple for junctions
NodeType::MedialAxis => {
if node.is_narrow_passage {
("#FF9900", 4.0, 0.7) // Orange for narrow passage nodes
} else {
(colors::GRAPH_NODE, 4.0, 0.7) // Default color for medial axis
}
}
};
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", radius)
.set("fill", color)
.set("opacity", opacity);
group = group.add(circle);
}
group
}
/// Render CBVG edges.
fn render_cbvg_edges(&self, graph: &ClearanceVisibilityGraph, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "cbvg_edges");
let nodes = graph.nodes();
let edges = graph.edges();
for (from_idx, neighbors) in edges.iter().enumerate() {
for (to_idx, _dist) in neighbors {
// Only draw each edge once (when from_idx < to_idx)
if from_idx < *to_idx {
let from_node = nodes[from_idx].position;
let to_node = nodes[*to_idx].position;
let line = self.create_line(
from_node,
to_node,
0.0,
0.0,
svg_height,
colors::GRAPH_EDGE,
0.5,
Some("2,2"), // Dashed line for edges
);
group = group.add(line.set("opacity", 0.3));
}
}
}
group
}
/// Render a planned path.
///
/// # Arguments
/// * `path` - The planned path to render
/// * `svg_height` - SVG canvas height for coordinate transformation
pub fn render_planned_path(&self, path: &PlannedPath, svg_height: f32) -> Group {
let mut group = Group::new().set("id", "planned_path");
if path.points.len() < 2 {
return group;
}
// Render path as connected line segments
for i in 1..path.points.len() {
let from = path.points[i - 1];
let to = path.points[i];
let line = self.create_line(
from,
to,
0.0,
0.0,
svg_height,
colors::PLANNED_PATH,
3.0,
None,
);
group = group.add(line);
}
// Render waypoint markers
for (i, &point) in path.points.iter().enumerate() {
let (sx, sy) = self.transform_point(point, 0.0, 0.0, svg_height);
// First and last points are start/goal, middle points are waypoints
let (color, radius) = if i == 0 {
(colors::START_NODE, 5.0)
} else if i == path.points.len() - 1 {
(colors::GOAL_NODE, 5.0)
} else {
(colors::PLANNED_PATH, 3.0)
};
let circle = Circle::new()
.set("cx", sx)
.set("cy", sy)
.set("r", radius)
.set("fill", color)
.set("stroke", "white")
.set("stroke-width", 1.0);
group = group.add(circle);
}
group
}
/// Render full visualization with clearance-based visibility graph.
///
/// Extended version of `render_full` that also shows the CBVG
/// and planned path for path planning debugging.
#[allow(clippy::too_many_arguments)]
pub fn render_with_path_planning(
&self,
slam: &VectorMap,
map: &SimulationMap,
final_pose: Pose2D,
truth_pose: Pose2D,
scan_store: &ScanStore,
trajectory: &TrajectoryHistory,
metrics: &TestMetrics,
graph: Option<&ClearanceVisibilityGraph>,
path: Option<&PlannedPath>,
) {
// Compute bounds from map
let map_width_m = map.width() as f32 * map.resolution();
let map_height_m = map.height() as f32 * map.resolution();
let width = (map_width_m * self.scale + 2.0 * self.margin) as i32;
let height = (map_height_m * self.scale + 2.0 * self.margin) as i32;
// Create document with arrow marker definition
let mut doc = Document::new()
.set("width", width)
.set("height", height)
.set("viewBox", (0, 0, width, height));
// Add marker definitions for arrows
let defs = self.create_marker_definitions();
doc = doc.add(defs);
// Layer 0: Background
doc = doc.add(
Rectangle::new()
.set("x", 0)
.set("y", 0)
.set("width", width)
.set("height", height)
.set("fill", "white"),
);
// Layer 1: Map walls (gray)
doc = doc.add(self.render_map_walls(map, height as f32));
// Layer 2: All scans from scan store (semi-transparent)
doc = doc.add(self.render_all_scans(scan_store, height as f32));
// Layer 3: Final lidar scan (full opacity) - use last scan from store
if let Some(last_scan) = scan_store.latest() {
let world_points = last_scan.world_points();
doc = doc.add(self.render_world_points(&world_points, height as f32));
}
// Layer 4: Detected walls (teal)
doc = doc.add(self.render_detected(slam, height as f32));
// Layer 5: Clearance-based visibility graph (if provided)
if let Some(g) = graph {
doc = doc.add(self.render_cbvg(g, height as f32, true));
}
// Layer 6: Planned path (if provided)
if let Some(p) = path {
doc = doc.add(self.render_planned_path(p, height as f32));
}
// Layer 7: Trajectories with confidence heatmap
doc = doc.add(self.render_trajectories(trajectory, height as f32));
// Layer 8: Drift vectors
doc = doc.add(self.render_drift_vectors(trajectory, height as f32));
// Layer 9: Final poses
doc = doc.add(self.render_poses(final_pose, truth_pose, height as f32));
// Layer 10: Enhanced legend
doc = doc.add(self.render_enhanced_legend(height as f32));
// Layer 11: Metrics overlay
doc = doc.add(self.render_metrics_overlay(metrics, width));
// Ensure output directory exists
if let Some(parent) = std::path::Path::new(&self.output_path).parent() {
std::fs::create_dir_all(parent).ok();
}
// Save
svg::save(&self.output_path, &doc).expect("Failed to save SVG");
}
}