Skip to main content

apex_solver/observers/
visualization.rs

1//! Rerun observer for real-time optimization visualization.
2//!
3//! This module provides a Rerun-based observer that implements the `OptObserver` trait,
4//! enabling clean separation between optimization logic and visualization.
5//!
6//! # Features
7//!
8//! - **Time series plots**: Cost, gradient norm, damping parameter, step quality
9//! - **Sparse Hessian visualization**: Heat map showing matrix structure and values
10//! - **Gradient visualization**: Vector representation with magnitude encoding
11//! - **Manifold state**: Real-time pose updates for SE2/SE3 problems
12//! - **Initial graph visualization**: Display starting configuration
13//!
14//! # Observer Pattern Integration
15//!
16//! Instead of being tightly coupled to the optimizer loop, `RerunObserver` implements
17//! the `OptObserver` trait. Register it with any optimizer and it will automatically
18//! receive updates at each iteration.
19//!
20//! # Feature Flag
21//!
22//! This module requires the `visualization` feature to be enabled:
23//!
24//! ```toml
25//! apex-solver = { version = "1.0", features = ["visualization"] }
26//! ```
27//!
28//! # Examples
29//!
30//! ## Basic Usage
31//!
32//! ```no_run
33//! use apex_solver::{JacobianMode, LevenbergMarquardt, LevenbergMarquardtConfig};
34//! use apex_solver::observers::RerunObserver;
35//! # use apex_solver::core::problem::Problem;
36//!
37//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
38//! # let mut problem = Problem::new(JacobianMode::Sparse);
39//!
40//! let config = LevenbergMarquardtConfig::new().with_max_iterations(100);
41//! let mut solver = LevenbergMarquardt::with_config(config);
42//!
43//! // Add Rerun visualization observer
44//! let rerun_observer = RerunObserver::new(true)?;
45//! solver.add_observer(rerun_observer);
46//!
47//! let result = solver.optimize(&mut problem)?;
48//! # Ok(())
49//! # }
50//! ```
51//!
52//! ## Save to File Instead of Live Viewer
53//!
54//! ```no_run
55//! # use apex_solver::observers::RerunObserver;
56//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
57//! let rerun_observer = RerunObserver::new_with_options(
58//!     true,
59//!     Some("my_optimization.rrd")
60//! )?;
61//! // ... add to solver and optimize ...
62//! # Ok(())
63//! # }
64//! ```
65
66use crate::core::VarKey;
67use crate::core::variable::{ManifoldVariable, Variable};
68use crate::error::ErrorLogging;
69use crate::observers::{ObserverError, ObserverResult, OptObserver};
70use apex_io as io;
71use apex_manifolds::LieGroup;
72use apex_manifolds::rn::Rn;
73use apex_manifolds::se2::SE2;
74use apex_manifolds::se3::SE3;
75use faer::Mat;
76use faer::sparse;
77use slotmap::{Key, SlotMap};
78use std::cell::{Cell, RefCell};
79use std::collections::HashMap;
80use tracing::{info, warn};
81
82// ============================================================================
83// Visualization Mode
84// ============================================================================
85
86/// Controls when visualization updates occur during optimization.
87///
88/// This enum determines how frequently the observer logs visualization data
89/// during the optimization process.
90///
91/// # Examples
92///
93/// ```no_run
94/// use apex_solver::observers::{VisualizationConfig, VisualizationMode};
95///
96/// // Show every iteration (detailed but slower)
97/// let config = VisualizationConfig::new()
98///     .with_visualization_mode(VisualizationMode::Iterative);
99///
100/// // Show only initial and final (faster, default)
101/// let config = VisualizationConfig::new()
102///     .with_visualization_mode(VisualizationMode::InitialAndFinal);
103/// ```
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
105pub enum VisualizationMode {
106    /// Show updates at every optimization iteration (detailed but slower).
107    /// Use this mode when you need to analyze the optimization trajectory.
108    Iterative,
109
110    /// Show only initial state and final state after optimization (default, faster).
111    /// Use this mode for production runs or when only the result matters.
112    #[default]
113    InitialAndFinal,
114}
115
116// ============================================================================
117// Visualization Configuration
118// ============================================================================
119
120/// Configuration for visualization options in RerunObserver.
121///
122/// This struct controls what elements are visualized and their appearance.
123/// Use the builder pattern to customize settings.
124///
125/// # Examples
126///
127/// ```no_run
128/// use apex_solver::observers::VisualizationConfig;
129///
130/// // Default: show everything
131/// let config = VisualizationConfig::new();
132///
133/// // Cameras only with larger frustums
134/// let config = VisualizationConfig::cameras_only()
135///     .with_camera_fov(0.8)
136///     .with_camera_frustum_scale(2.0);
137///
138/// // Landmarks only with larger points
139/// let config = VisualizationConfig::landmarks_only()
140///     .with_landmark_point_size(0.05);
141///
142/// // Bundle adjustment preset
143/// let config = VisualizationConfig::for_bundle_adjustment();
144/// ```
145#[derive(Debug, Clone)]
146pub struct VisualizationConfig {
147    // === Element Visibility ===
148    /// Show camera poses (SE3 variables) as frustums
149    pub show_cameras: bool,
150    /// Show 3D landmarks (Rn with dim=3) as point cloud
151    pub show_landmarks: bool,
152    /// Show SE2 poses (for 2D SLAM)
153    pub show_se2_poses: bool,
154    /// Show time series plots (cost, gradient, damping, etc.)
155    pub show_plots: bool,
156    /// Show matrix visualizations (Hessian, gradient)
157    pub show_matrices: bool,
158
159    // === Camera Frustum Settings ===
160    /// Camera frustum field of view in radians (default: 0.5)
161    pub camera_fov: f32,
162    /// Camera frustum aspect ratio (default: 1.0)
163    pub camera_aspect_ratio: f32,
164    /// Scale factor for camera frustum size (default: 1.0)
165    pub camera_frustum_scale: f32,
166
167    // === Landmark Point Cloud Settings ===
168    /// Radius of 3D landmark points (default: 0.02)
169    pub landmark_point_size: f32,
170    /// Color for initial landmarks RGB (default: blue [100, 150, 255])
171    pub initial_landmark_color: [u8; 3],
172    /// Color for optimized landmarks RGB (default: gold [255, 200, 50])
173    pub optimized_landmark_color: [u8; 3],
174
175    // === SE2 Pose Settings ===
176    /// Radius for SE2 pose markers (default: 0.5)
177    pub se2_pose_radius: f32,
178    /// Half-size for SE2 pose box markers (default: 0.3)
179    pub se2_box_half_size: f32,
180    /// Color for initial SE2 poses RGB (default: blue [100, 150, 255])
181    pub initial_se2_color: [u8; 3],
182    /// Color for optimized SE2 poses RGB (default: green [50, 200, 100])
183    pub optimized_se2_color: [u8; 3],
184
185    // === Matrix Visualization Settings ===
186    /// Target size for Hessian downsampling (default: 100)
187    pub hessian_downsample_size: usize,
188    /// Target width for gradient visualization (default: 100)
189    pub gradient_bar_width: usize,
190
191    // === General Settings ===
192    /// Graph scale factor for pose graph visualization
193    pub graph_scale: f32,
194    /// Invert camera poses for display (T_wc -> T_cw for BA)
195    pub invert_camera_poses: bool,
196    /// Visualization mode: iterative or initial-and-final only
197    pub visualization_mode: VisualizationMode,
198}
199
200impl Default for VisualizationConfig {
201    fn default() -> Self {
202        Self {
203            // Element visibility - all enabled by default
204            show_cameras: true,
205            show_landmarks: true,
206            show_se2_poses: true,
207            show_plots: true,
208            show_matrices: true,
209
210            // Camera frustum settings (matching previous hardcoded values)
211            camera_fov: 0.5,
212            camera_aspect_ratio: 1.0,
213            camera_frustum_scale: 1.0,
214
215            // Landmark settings (matching previous hardcoded values)
216            landmark_point_size: 0.02,
217            initial_landmark_color: [100, 150, 255],  // Blue
218            optimized_landmark_color: [255, 200, 50], // Gold
219
220            // SE2 pose settings
221            se2_pose_radius: 0.5,
222            se2_box_half_size: 0.3,
223            initial_se2_color: [100, 150, 255],  // Blue
224            optimized_se2_color: [50, 200, 100], // Green
225
226            // Matrix visualization
227            hessian_downsample_size: 100,
228            gradient_bar_width: 100,
229
230            // General
231            graph_scale: 1.0,
232            invert_camera_poses: false,
233            visualization_mode: VisualizationMode::default(),
234        }
235    }
236}
237
238impl VisualizationConfig {
239    /// Create a new configuration with default values (show everything).
240    pub fn new() -> Self {
241        Self::default()
242    }
243
244    // === Element Visibility Builders ===
245
246    /// Set whether to show camera poses (SE3 frustums).
247    pub fn with_show_cameras(mut self, show: bool) -> Self {
248        self.show_cameras = show;
249        self
250    }
251
252    /// Set whether to show 3D landmarks.
253    pub fn with_show_landmarks(mut self, show: bool) -> Self {
254        self.show_landmarks = show;
255        self
256    }
257
258    /// Set whether to show SE2 poses.
259    pub fn with_show_se2_poses(mut self, show: bool) -> Self {
260        self.show_se2_poses = show;
261        self
262    }
263
264    /// Set whether to show time series plots.
265    pub fn with_show_plots(mut self, show: bool) -> Self {
266        self.show_plots = show;
267        self
268    }
269
270    /// Set whether to show matrix visualizations.
271    pub fn with_show_matrices(mut self, show: bool) -> Self {
272        self.show_matrices = show;
273        self
274    }
275
276    // === Camera Settings Builders ===
277
278    /// Set camera frustum field of view in radians.
279    pub fn with_camera_fov(mut self, fov: f32) -> Self {
280        self.camera_fov = fov;
281        self
282    }
283
284    /// Set camera frustum aspect ratio.
285    pub fn with_camera_aspect_ratio(mut self, ratio: f32) -> Self {
286        self.camera_aspect_ratio = ratio;
287        self
288    }
289
290    /// Set camera frustum scale factor.
291    pub fn with_camera_frustum_scale(mut self, scale: f32) -> Self {
292        self.camera_frustum_scale = scale;
293        self
294    }
295
296    // === Landmark Settings Builders ===
297
298    /// Set 3D landmark point size/radius.
299    pub fn with_landmark_point_size(mut self, size: f32) -> Self {
300        self.landmark_point_size = size;
301        self
302    }
303
304    /// Set color for initial landmarks (RGB).
305    pub fn with_initial_landmark_color(mut self, rgb: [u8; 3]) -> Self {
306        self.initial_landmark_color = rgb;
307        self
308    }
309
310    /// Set color for optimized landmarks (RGB).
311    pub fn with_optimized_landmark_color(mut self, rgb: [u8; 3]) -> Self {
312        self.optimized_landmark_color = rgb;
313        self
314    }
315
316    // === SE2 Settings Builders ===
317
318    /// Set SE2 pose marker radius.
319    pub fn with_se2_pose_radius(mut self, radius: f32) -> Self {
320        self.se2_pose_radius = radius;
321        self
322    }
323
324    /// Set SE2 pose box half-size.
325    pub fn with_se2_box_half_size(mut self, half_size: f32) -> Self {
326        self.se2_box_half_size = half_size;
327        self
328    }
329
330    /// Set color for initial SE2 poses (RGB).
331    pub fn with_initial_se2_color(mut self, rgb: [u8; 3]) -> Self {
332        self.initial_se2_color = rgb;
333        self
334    }
335
336    /// Set color for optimized SE2 poses (RGB).
337    pub fn with_optimized_se2_color(mut self, rgb: [u8; 3]) -> Self {
338        self.optimized_se2_color = rgb;
339        self
340    }
341
342    // === Matrix Settings Builders ===
343
344    /// Set target size for Hessian downsampling.
345    pub fn with_hessian_downsample_size(mut self, size: usize) -> Self {
346        self.hessian_downsample_size = size;
347        self
348    }
349
350    /// Set target width for gradient visualization.
351    pub fn with_gradient_bar_width(mut self, width: usize) -> Self {
352        self.gradient_bar_width = width;
353        self
354    }
355
356    // === General Settings Builders ===
357
358    /// Set graph scale factor.
359    pub fn with_graph_scale(mut self, scale: f32) -> Self {
360        self.graph_scale = scale;
361        self
362    }
363
364    /// Set whether to invert camera poses (T_wc -> T_cw).
365    pub fn with_invert_camera_poses(mut self, invert: bool) -> Self {
366        self.invert_camera_poses = invert;
367        self
368    }
369
370    /// Set visualization mode (iterative or initial-and-final).
371    ///
372    /// # Arguments
373    ///
374    /// * `mode` - The visualization mode to use
375    ///
376    /// # Examples
377    ///
378    /// ```no_run
379    /// use apex_solver::observers::{VisualizationConfig, VisualizationMode};
380    ///
381    /// let config = VisualizationConfig::new()
382    ///     .with_visualization_mode(VisualizationMode::Iterative);
383    /// ```
384    pub fn with_visualization_mode(mut self, mode: VisualizationMode) -> Self {
385        self.visualization_mode = mode;
386        self
387    }
388
389    // === Convenience Presets ===
390
391    /// Create a configuration that shows only camera poses (no landmarks).
392    ///
393    /// Useful for pose graph visualization or when landmarks are too dense.
394    pub fn cameras_only() -> Self {
395        Self::default()
396            .with_show_cameras(true)
397            .with_show_landmarks(false)
398            .with_show_se2_poses(false)
399    }
400
401    /// Create a configuration that shows only 3D landmarks (no cameras).
402    ///
403    /// Useful for structure-from-motion point cloud visualization.
404    pub fn landmarks_only() -> Self {
405        Self::default()
406            .with_show_cameras(false)
407            .with_show_landmarks(true)
408            .with_show_se2_poses(false)
409    }
410
411    /// Create a configuration optimized for bundle adjustment.
412    ///
413    /// - Inverts camera poses (T_wc -> T_cw for correct display)
414    /// - Disables SE2 poses (not used in BA)
415    pub fn for_bundle_adjustment() -> Self {
416        Self::default()
417            .with_invert_camera_poses(true)
418            .with_show_se2_poses(false)
419            .with_camera_frustum_scale(0.3)
420    }
421
422    /// Create a configuration optimized for pose graph optimization.
423    ///
424    /// - No camera pose inversion (poses are already T_cw)
425    /// - Disables landmarks (pose graphs don't have 3D points)
426    pub fn for_pose_graph() -> Self {
427        Self::default()
428            .with_show_landmarks(false)
429            .with_invert_camera_poses(false)
430    }
431}
432
433/// Rerun observer for real-time optimization visualization.
434///
435/// This observer logs comprehensive optimization data to Rerun for interactive
436/// visualization and debugging. It implements the `OptObserver` trait, enabling
437/// clean integration with any optimizer through the observer pattern.
438///
439/// # What Gets Visualized
440///
441/// - **Time series**: Cost, gradient norm, damping (LM), step norm, step quality
442/// - **Matrices**: Sparse Hessian (downsampled heat map), gradient vector
443/// - **Poses**: SE2/SE3 manifold states updated each iteration
444/// - **3D Landmarks**: Rn variables with dimension=3 visualized as point clouds
445/// - **Status**: Convergence information
446///
447/// # Observer Pattern Benefits
448///
449/// - Decoupled from optimizer internals
450/// - Can be combined with other observers (CSV, metrics, etc.)
451/// - No `#[cfg(feature = "visualization")]` scattered through optimizer code
452/// - Easy to enable/disable without changing optimizer logic
453///
454/// # Performance
455///
456/// The observer is designed to have minimal overhead:
457/// - Matrix visualizations use downsampling (100×100 for Hessian)
458/// - Rerun logging is asynchronous
459/// - When disabled, `is_enabled()` returns false immediately
460/// - 3D landmarks are batch-logged as a single point cloud for efficiency
461///
462/// # Pose Convention Support
463///
464/// For bundle adjustment (BAL datasets), camera poses are stored as world-to-camera
465/// transforms (T_wc). Set `invert_camera_poses = true` to display cameras correctly
466/// by converting to camera-to-world (T_cw) convention for Rerun visualization.
467pub struct RerunObserver {
468    rec: Option<rerun::RecordingStream>,
469    enabled: bool,
470    // Mutable state for tracking optimizer-specific metrics
471    // Using RefCell for interior mutability (observer receives &self)
472    iteration_metrics: RefCell<IterationMetrics>,
473    // Visualization configuration
474    config: VisualizationConfig,
475    // Cached initial positions for displacement visualization
476    initial_camera_positions: RefCell<HashMap<String, [f32; 3]>>,
477    initial_landmark_positions: RefCell<HashMap<String, [f32; 3]>>,
478    // Whether the initial state has been logged (before any optimization updates)
479    initial_state_logged: Cell<bool>,
480}
481
482/// Internal metrics tracked across iterations.
483///
484/// These are set by optimizer-specific methods (e.g., `set_iteration_metrics`)
485/// and logged in the `on_step` callback.
486#[derive(Default, Clone)]
487struct IterationMetrics {
488    cost: Option<f64>,
489    gradient_norm: Option<f64>,
490    damping: Option<f64>,
491    step_norm: Option<f64>,
492    step_quality: Option<f64>,
493    hessian: Option<sparse::SparseColMat<usize, f64>>,
494    gradient: Option<Mat<f64>>,
495}
496
497impl RerunObserver {
498    /// Create a new Rerun observer.
499    ///
500    /// # Arguments
501    ///
502    /// * `enabled` - Whether to enable visualization
503    ///
504    /// # Returns
505    ///
506    /// A new observer instance that spawns a Rerun viewer (or saves to file if viewer unavailable).
507    ///
508    /// # Examples
509    ///
510    /// ```no_run
511    /// use apex_solver::observers::RerunObserver;
512    ///
513    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
514    /// let observer = RerunObserver::new(true)?;
515    /// # Ok(())
516    /// # }
517    /// ```
518    pub fn new(enabled: bool) -> ObserverResult<Self> {
519        Self::new_with_options(enabled, None)
520    }
521
522    /// Create a new Rerun observer with file save option.
523    ///
524    /// # Arguments
525    ///
526    /// * `enabled` - Whether to enable visualization
527    /// * `save_path` - Optional path to save recording to file instead of spawning viewer
528    ///
529    /// # Examples
530    ///
531    /// ```no_run
532    /// use apex_solver::observers::RerunObserver;
533    ///
534    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
535    /// // Save to file
536    /// let observer = RerunObserver::new_with_options(true, Some("opt.rrd"))?;
537    ///
538    /// // Spawn live viewer
539    /// let observer2 = RerunObserver::new_with_options(true, None)?;
540    /// # Ok(())
541    /// # }
542    /// ```
543    pub fn new_with_options(enabled: bool, save_path: Option<&str>) -> ObserverResult<Self> {
544        Self::with_config(enabled, save_path, VisualizationConfig::default())
545    }
546
547    /// Create a new Rerun observer with full configuration.
548    ///
549    /// This is the primary constructor for full control over visualization.
550    ///
551    /// # Arguments
552    ///
553    /// * `enabled` - Whether to enable visualization
554    /// * `save_path` - Optional path to save recording to file instead of spawning viewer
555    /// * `config` - Visualization configuration
556    ///
557    /// # Examples
558    ///
559    /// ```no_run
560    /// use apex_solver::observers::{RerunObserver, VisualizationConfig};
561    ///
562    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
563    /// let config = VisualizationConfig::new()
564    ///     .with_show_cameras(true)
565    ///     .with_show_landmarks(false)
566    ///     .with_camera_fov(0.8);
567    ///
568    /// let observer = RerunObserver::with_config(true, None, config)?;
569    /// # Ok(())
570    /// # }
571    /// ```
572    pub fn with_config(
573        enabled: bool,
574        save_path: Option<&str>,
575        config: VisualizationConfig,
576    ) -> ObserverResult<Self> {
577        let rec = if enabled {
578            let rec = if let Some(path) = save_path {
579                // Save to file
580                info!("Saving visualization to: {}", path);
581                rerun::RecordingStreamBuilder::new("apex-solver-optimization")
582                    .save(path)
583                    .map_err(|e| {
584                        ObserverError::RecordingSaveFailed {
585                            path: path.to_string(),
586                            reason: format!("{}", e),
587                        }
588                        .log_with_source(e)
589                    })?
590            } else {
591                // Try to spawn Rerun viewer
592                match rerun::RecordingStreamBuilder::new("apex-solver-optimization").spawn() {
593                    Ok(rec) => {
594                        info!("Rerun viewer launched successfully");
595                        rec
596                    }
597                    Err(e) => {
598                        warn!("Could not launch Rerun viewer: {}", e);
599                        warn!("Saving to file 'optimization.rrd' instead");
600                        warn!("View it later with: rerun optimization.rrd");
601
602                        // Fall back to saving to file
603                        rerun::RecordingStreamBuilder::new("apex-solver-optimization")
604                            .save("optimization.rrd")
605                            .map_err(|e2| {
606                                ObserverError::RecordingSaveFailed {
607                                    path: "optimization.rrd".to_string(),
608                                    reason: format!("{}", e2),
609                                }
610                                .log_with_source(e2)
611                            })?
612                    }
613                }
614            };
615
616            Some(rec)
617        } else {
618            None
619        };
620
621        Ok(Self {
622            rec,
623            enabled,
624            iteration_metrics: RefCell::new(IterationMetrics::default()),
625            config,
626            initial_camera_positions: RefCell::new(HashMap::new()),
627            initial_landmark_positions: RefCell::new(HashMap::new()),
628            initial_state_logged: Cell::new(false),
629        })
630    }
631
632    /// Create a new Rerun observer configured for bundle adjustment.
633    ///
634    /// This constructor is designed for bundle adjustment / structure-from-motion
635    /// problems where camera poses are stored in world-to-camera convention (T_wc)
636    /// but need to be displayed in camera-to-world convention (T_cw).
637    ///
638    /// # Arguments
639    ///
640    /// * `enabled` - Whether to enable visualization
641    /// * `save_path` - Optional path to save recording to file instead of spawning viewer
642    /// * `invert_camera_poses` - If true, invert SE3 poses before logging (T_wc -> T_cw)
643    ///
644    /// # Use Cases
645    ///
646    /// - **Pose graph optimization**: Use `invert_camera_poses = false` (poses are already T_cw)
647    /// - **Bundle adjustment (BAL)**: Use `invert_camera_poses = true` (BAL stores T_wc)
648    ///
649    /// # Examples
650    ///
651    /// ```no_run
652    /// use apex_solver::observers::RerunObserver;
653    ///
654    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
655    /// // For bundle adjustment with BAL datasets (world-to-camera poses)
656    /// let observer = RerunObserver::new_for_bundle_adjustment(true, None, true)?;
657    ///
658    /// // For pose graph optimization (camera-to-world poses)
659    /// let observer = RerunObserver::new_for_bundle_adjustment(true, None, false)?;
660    /// # Ok(())
661    /// # }
662    /// ```
663    pub fn new_for_bundle_adjustment(
664        enabled: bool,
665        save_path: Option<&str>,
666        invert_camera_poses: bool,
667    ) -> ObserverResult<Self> {
668        let config = VisualizationConfig::for_bundle_adjustment()
669            .with_invert_camera_poses(invert_camera_poses);
670        Self::with_config(enabled, save_path, config)
671    }
672
673    /// Get the current visualization configuration.
674    pub fn config(&self) -> &VisualizationConfig {
675        &self.config
676    }
677
678    /// Check if visualization is enabled and active.
679    #[inline(always)]
680    pub fn is_enabled(&self) -> bool {
681        self.enabled && self.rec.is_some()
682    }
683
684    // ========================================================================
685    // Public Methods for Optimizer-Specific Data
686    // ========================================================================
687    // These methods allow optimizers to provide additional context beyond
688    // what's available in the OptObserver::on_step callback.
689    // ========================================================================
690
691    /// Set iteration metrics for the next on_step call.
692    ///
693    /// This method should be called by optimizers before notifying observers
694    /// to provide context like cost, gradient norm, damping, etc.
695    ///
696    /// # Arguments
697    ///
698    /// * `cost` - Current cost value
699    /// * `gradient_norm` - L2 norm of gradient
700    /// * `damping` - Current damping parameter (LM-specific, use None for GN/DogLeg)
701    /// * `step_norm` - L2 norm of parameter update
702    /// * `step_quality` - Step quality metric ρ (actual vs predicted reduction)
703    ///
704    /// # Examples
705    ///
706    /// ```no_run
707    /// # use apex_solver::observers::RerunObserver;
708    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
709    /// # let observer = RerunObserver::new(true)?;
710    /// observer.set_iteration_metrics(
711    ///     1.234,      // cost
712    ///     0.056,      // gradient_norm
713    ///     Some(0.01), // damping (LM only)
714    ///     0.023,      // step_norm
715    ///     Some(0.95), // step_quality
716    /// );
717    /// # Ok(())
718    /// # }
719    /// ```
720    pub fn set_iteration_metrics(
721        &self,
722        cost: f64,
723        gradient_norm: f64,
724        damping: Option<f64>,
725        step_norm: f64,
726        step_quality: Option<f64>,
727    ) {
728        let mut metrics = self.iteration_metrics.borrow_mut();
729        metrics.cost = Some(cost);
730        metrics.gradient_norm = Some(gradient_norm);
731        metrics.damping = damping;
732        metrics.step_norm = Some(step_norm);
733        metrics.step_quality = step_quality;
734    }
735
736    /// Set matrix data (Hessian and gradient) for visualization.
737    ///
738    /// This should be called before `on_step` if you want to visualize matrices.
739    ///
740    /// # Arguments
741    ///
742    /// * `hessian` - Optional sparse Hessian matrix (J^T J)
743    /// * `gradient` - Optional gradient vector (J^T r)
744    pub fn set_matrix_data(
745        &self,
746        hessian: Option<sparse::SparseColMat<usize, f64>>,
747        gradient: Option<Mat<f64>>,
748    ) {
749        let mut metrics = self.iteration_metrics.borrow_mut();
750        metrics.hessian = hessian;
751        metrics.gradient = gradient;
752    }
753
754    /// Log the initial graph structure before optimization.
755    ///
756    /// This should be called once before optimization starts to visualize
757    /// the initial configuration.
758    ///
759    /// # Arguments
760    ///
761    /// * `graph` - The graph structure loaded from G2O file
762    /// * `scale` - Scale factor for visualization
763    pub fn log_initial_graph(&self, graph: &io::Graph, scale: f32) -> ObserverResult<()> {
764        let rec = self.rec.as_ref().ok_or_else(|| {
765            ObserverError::InvalidState("Recording stream not initialized".to_string())
766        })?;
767
768        // Visualize SE3 vertices only (no edges)
769        if self.config.show_cameras {
770            for (id, vertex) in &graph.vertices_se3 {
771                let (position, rotation) = vertex.to_rerun_transform(scale);
772                let transform = rerun::Transform3D::from_translation_rotation(position, rotation);
773
774                let entity_path = format!("initial_graph/se3_poses/{}", id);
775                rec.log(entity_path.as_str(), &transform).map_err(|e| {
776                    ObserverError::LoggingFailed {
777                        entity_path: entity_path.clone(),
778                        reason: format!("{}", e),
779                    }
780                    .log_with_source(e)
781                })?;
782
783                // Add a small pinhole camera for better visualization
784                rec.log(
785                    entity_path.as_str(),
786                    &rerun::archetypes::Pinhole::from_fov_and_aspect_ratio(
787                        self.config.camera_fov,
788                        self.config.camera_aspect_ratio,
789                    )
790                    .with_image_plane_distance(self.config.camera_frustum_scale),
791                )
792                .map_err(|e| {
793                    ObserverError::LoggingFailed {
794                        entity_path: entity_path.clone(),
795                        reason: format!("{}", e),
796                    }
797                    .log_with_source(e)
798                })?;
799            }
800        }
801
802        // Visualize SE2 vertices only (no edges)
803        if self.config.show_se2_poses && !graph.vertices_se2.is_empty() {
804            let positions: Vec<[f32; 2]> = graph
805                .vertices_se2
806                .values()
807                .map(|vertex| vertex.to_rerun_position_2d(scale))
808                .collect();
809
810            let color = self.config.initial_se2_color;
811            let colors = vec![
812                rerun::components::Color::from_rgb(color[0], color[1], color[2]);
813                positions.len()
814            ];
815
816            rec.log(
817                "initial_graph/se2_poses",
818                &rerun::archetypes::Points2D::new(positions)
819                    .with_colors(colors)
820                    .with_radii([self.config.se2_pose_radius * scale]),
821            )
822            .map_err(|e| {
823                ObserverError::LoggingFailed {
824                    entity_path: "initial_graph/se2_poses".to_string(),
825                    reason: format!("{}", e),
826                }
827                .log_with_source(e)
828            })?;
829        }
830
831        Ok(())
832    }
833
834    /// Log convergence status and final summary.
835    ///
836    /// Call this after optimization completes.
837    ///
838    /// # Arguments
839    ///
840    /// * `status` - Convergence status message
841    pub fn log_convergence(&self, status: &str) -> ObserverResult<()> {
842        let rec = self.rec.as_ref().ok_or_else(|| {
843            ObserverError::InvalidState("Recording stream not initialized".to_string())
844        })?;
845
846        // Log as a text annotation
847        rec.log(
848            "optimization/status",
849            &rerun::archetypes::TextDocument::new(status),
850        )
851        .map_err(|e| {
852            ObserverError::LoggingFailed {
853                entity_path: "optimization/status".to_string(),
854                reason: format!("{}", e),
855            }
856            .log_with_source(e)
857        })?;
858
859        Ok(())
860    }
861
862    /// Log initial bundle adjustment state before optimization.
863    ///
864    /// This method visualizes the initial camera poses and 3D landmarks
865    /// before optimization begins, allowing comparison with optimized results.
866    ///
867    /// # Arguments
868    ///
869    /// * `problem` - The optimization problem containing the initial variables
870    ///
871    /// # Examples
872    ///
873    /// ```no_run
874    /// use apex_solver::observers::RerunObserver;
875    /// use apex_solver::core::problem::Problem;
876    ///
877    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
878    /// let observer = RerunObserver::new_for_bundle_adjustment(true, None, true)?;
879    /// let mut problem = Problem::new(apex_solver::linalg::JacobianMode::Sparse);
880    /// // ... add variables and factors ...
881    ///
882    /// observer.log_initial_ba_state(&problem)?;
883    /// # Ok(())
884    /// # }
885    /// ```
886    pub fn log_initial_ba_state(
887        &self,
888        problem: &crate::core::problem::Problem,
889    ) -> ObserverResult<()> {
890        let rec = self.rec.as_ref().ok_or_else(|| {
891            ObserverError::InvalidState("Recording stream not initialized".to_string())
892        })?;
893
894        // Collect 3D landmarks for batch logging
895        let mut landmark_positions: Vec<[f32; 3]> = Vec::new();
896        let mut landmark_names: Vec<String> = Vec::new();
897
898        // Get mutable access to caches
899        let mut camera_cache = self.initial_camera_positions.borrow_mut();
900        let mut landmark_cache = self.initial_landmark_positions.borrow_mut();
901
902        for (key, var) in problem.variables.iter() {
903            let var_name = format!("var_{:?}", key.data());
904            match var.manifold_type_name() {
905                "SE3" if self.config.show_cameras => {
906                    // Parse SE3 from parameter slice
907                    let se3 = SE3::from_param_slice(var.as_param_slice());
908
909                    // Apply pose inversion if configured (for BA: T_wc -> T_cw)
910                    let pose = if self.config.invert_camera_poses {
911                        se3.inverse(None)
912                    } else {
913                        se3
914                    };
915
916                    let trans = pose.translation();
917                    let rot = pose.rotation_quaternion();
918
919                    // Cache the initial camera position for displacement calculation
920                    camera_cache.insert(
921                        var_name.clone(),
922                        [trans.x as f32, trans.y as f32, trans.z as f32],
923                    );
924
925                    let position = rerun::external::glam::Vec3::new(
926                        trans.x as f32,
927                        trans.y as f32,
928                        trans.z as f32,
929                    );
930
931                    let nq = rot.as_ref();
932                    let rotation = rerun::external::glam::Quat::from_xyzw(
933                        nq.i as f32,
934                        nq.j as f32,
935                        nq.k as f32,
936                        nq.w as f32,
937                    );
938
939                    let transform =
940                        rerun::Transform3D::from_translation_rotation(position, rotation);
941
942                    let entity_path = format!("initial_graph/cameras/{}", var_name);
943                    rec.log(entity_path.as_str(), &transform).map_err(|e| {
944                        ObserverError::LoggingFailed {
945                            entity_path: entity_path.clone(),
946                            reason: format!("{}", e),
947                        }
948                        .log_with_source(e)
949                    })?;
950
951                    rec.log(
952                        entity_path.as_str(),
953                        &rerun::archetypes::Pinhole::from_fov_and_aspect_ratio(
954                            self.config.camera_fov,
955                            self.config.camera_aspect_ratio,
956                        )
957                        .with_image_plane_distance(self.config.camera_frustum_scale),
958                    )
959                    .map_err(|e| {
960                        ObserverError::LoggingFailed {
961                            entity_path: entity_path.clone(),
962                            reason: format!("{}", e),
963                        }
964                        .log_with_source(e)
965                    })?;
966                }
967                "Rn" if self.config.show_landmarks && var.dof() == 3 => {
968                    let data = var.as_param_slice();
969                    let pos = [data[0] as f32, data[1] as f32, data[2] as f32];
970                    landmark_positions.push(pos);
971                    landmark_names.push(var_name.clone());
972                    landmark_cache.insert(var_name.clone(), pos);
973                }
974                _ => {
975                    // Skip other manifold types (SE2, SO2, SO3) or disabled types
976                }
977            }
978        }
979
980        // Batch log all initial landmarks as a single point cloud
981        if self.config.show_landmarks && !landmark_positions.is_empty() {
982            let color = self.config.initial_landmark_color;
983            rec.log(
984                "initial_graph/landmarks",
985                &rerun::archetypes::Points3D::new(landmark_positions)
986                    .with_radii([self.config.landmark_point_size])
987                    .with_colors([rerun::components::Color::from_rgb(
988                        color[0], color[1], color[2],
989                    )]),
990            )
991            .map_err(|e| {
992                ObserverError::LoggingFailed {
993                    entity_path: "initial_graph/landmarks".to_string(),
994                    reason: format!("{}", e),
995                }
996                .log_with_source(e)
997            })?;
998        }
999
1000        info!(
1001            "Logged initial BA state: {} cameras, {} landmarks",
1002            camera_cache.len(),
1003            landmark_cache.len()
1004        );
1005
1006        Ok(())
1007    }
1008
1009    /// Log the final optimized state after optimization completes.
1010    ///
1011    /// This method visualizes the final camera poses and 3D landmarks
1012    /// in a separate entity group ("final_graph/") to allow comparison
1013    /// with the initial state.
1014    ///
1015    /// # Arguments
1016    ///
1017    /// * `values` - Final optimized variable values
1018    /// * `iterations` - Total number of iterations performed
1019    fn log_final_state(
1020        &self,
1021        values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1022        iterations: usize,
1023    ) -> ObserverResult<()> {
1024        let rec = self.rec.as_ref().ok_or_else(|| {
1025            ObserverError::InvalidState("Recording stream not initialized".to_string())
1026        })?;
1027
1028        // Set time to final iteration
1029        rec.set_time_sequence("iteration", iterations as i64);
1030
1031        // Collect final 3D landmarks for batch logging
1032        let mut final_landmark_positions: Vec<[f32; 3]> = Vec::new();
1033        // Collect final SE2 positions for batch logging
1034        let mut final_se2_positions: Vec<[f32; 2]> = Vec::new();
1035        let mut camera_count = 0;
1036        let mut se2_count = 0;
1037
1038        for (i, (_, var)) in values.iter().enumerate() {
1039            let var_name = format!("var_{i}");
1040            if self.config.show_cameras {
1041                if let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>() {
1042                    // Apply pose inversion if configured (for BA: T_wc -> T_cw)
1043                    let pose = if self.config.invert_camera_poses {
1044                        v.value.inverse(None)
1045                    } else {
1046                        v.value.clone()
1047                    };
1048
1049                    let trans = pose.translation();
1050                    let rot = pose.rotation_quaternion();
1051
1052                    let position = rerun::external::glam::Vec3::new(
1053                        trans.x as f32,
1054                        trans.y as f32,
1055                        trans.z as f32,
1056                    );
1057
1058                    let nq = rot.as_ref();
1059                    let rotation = rerun::external::glam::Quat::from_xyzw(
1060                        nq.i as f32,
1061                        nq.j as f32,
1062                        nq.k as f32,
1063                        nq.w as f32,
1064                    );
1065
1066                    let transform =
1067                        rerun::Transform3D::from_translation_rotation(position, rotation);
1068
1069                    let entity_path = format!("final_graph/cameras/{}", var_name);
1070                    rec.log(entity_path.as_str(), &transform).map_err(|e| {
1071                        ObserverError::LoggingFailed {
1072                            entity_path: entity_path.clone(),
1073                            reason: format!("{}", e),
1074                        }
1075                        .log_with_source(e)
1076                    })?;
1077
1078                    rec.log(
1079                        entity_path.as_str(),
1080                        &rerun::archetypes::Pinhole::from_fov_and_aspect_ratio(
1081                            self.config.camera_fov,
1082                            self.config.camera_aspect_ratio,
1083                        )
1084                        .with_image_plane_distance(self.config.camera_frustum_scale),
1085                    )
1086                    .map_err(|e| {
1087                        ObserverError::LoggingFailed {
1088                            entity_path: entity_path.clone(),
1089                            reason: format!("{}", e),
1090                        }
1091                        .log_with_source(e)
1092                    })?;
1093
1094                    camera_count += 1;
1095                }
1096            }
1097            if self.config.show_se2_poses {
1098                if let Some(v) = var.as_any().downcast_ref::<Variable<SE2>>() {
1099                    final_se2_positions.push([v.value.x() as f32, v.value.y() as f32]);
1100                    se2_count += 1;
1101                }
1102            }
1103            if self.config.show_landmarks {
1104                if let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>() {
1105                    // Handle 3D landmarks (Rn with dimension 3)
1106                    let data = v.value.data();
1107                    if data.len() == 3 {
1108                        final_landmark_positions.push([
1109                            data[0] as f32,
1110                            data[1] as f32,
1111                            data[2] as f32,
1112                        ]);
1113                    }
1114                }
1115            }
1116        }
1117
1118        // Batch log all final landmarks as a single point cloud with green color
1119        if self.config.show_landmarks && !final_landmark_positions.is_empty() {
1120            // Use green for final/optimized landmarks to distinguish from initial (blue)
1121            let final_color: [u8; 3] = [50, 200, 100]; // Green
1122            rec.log(
1123                "final_graph/landmarks",
1124                &rerun::archetypes::Points3D::new(final_landmark_positions.clone())
1125                    .with_radii([self.config.landmark_point_size])
1126                    .with_colors([rerun::components::Color::from_rgb(
1127                        final_color[0],
1128                        final_color[1],
1129                        final_color[2],
1130                    )]),
1131            )
1132            .map_err(|e| {
1133                ObserverError::LoggingFailed {
1134                    entity_path: "final_graph/landmarks".to_string(),
1135                    reason: format!("{}", e),
1136                }
1137                .log_with_source(e)
1138            })?;
1139        }
1140
1141        // Batch log all final SE2 poses as 2D boxes
1142        if self.config.show_se2_poses && !final_se2_positions.is_empty() {
1143            let color = self.config.optimized_se2_color;
1144            let hs = self.config.se2_box_half_size;
1145            let half_sizes: Vec<[f32; 2]> = vec![[hs, hs]; final_se2_positions.len()];
1146            rec.log(
1147                "final_graph/se2_poses",
1148                &rerun::archetypes::Boxes2D::from_centers_and_half_sizes(
1149                    final_se2_positions,
1150                    half_sizes,
1151                )
1152                .with_colors([rerun::components::Color::from_rgb(
1153                    color[0], color[1], color[2],
1154                )]),
1155            )
1156            .map_err(|e| {
1157                ObserverError::LoggingFailed {
1158                    entity_path: "final_graph/se2_poses".to_string(),
1159                    reason: format!("{}", e),
1160                }
1161                .log_with_source(e)
1162            })?;
1163        }
1164
1165        info!(
1166            "Logged final state after {} iterations: {} cameras, {} landmarks, {} SE2 poses",
1167            iterations,
1168            camera_count,
1169            final_landmark_positions.len(),
1170            se2_count
1171        );
1172
1173        // Log displacement statistics
1174        self.log_displacement_statistics(values)?;
1175
1176        Ok(())
1177    }
1178
1179    /// Log displacement statistics comparing initial and final states.
1180    ///
1181    /// Calculates and logs the displacement of cameras and landmarks
1182    /// from their initial positions to their final optimized positions.
1183    fn log_displacement_statistics(
1184        &self,
1185        values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1186    ) -> ObserverResult<()> {
1187        let initial_cameras = self.initial_camera_positions.borrow();
1188        let initial_landmarks = self.initial_landmark_positions.borrow();
1189
1190        // Calculate camera displacements
1191        let mut camera_displacements: Vec<f32> = Vec::new();
1192        for (i, (_, var)) in values.iter().enumerate() {
1193            let name = format!("var_{i}");
1194            if let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>() {
1195                // Apply same pose inversion as in initial state
1196                let pose = if self.config.invert_camera_poses {
1197                    v.value.inverse(None)
1198                } else {
1199                    v.value.clone()
1200                };
1201
1202                if let Some(initial_pos) = initial_cameras.get(&name) {
1203                    let final_pos = pose.translation();
1204                    let dx = final_pos.x as f32 - initial_pos[0];
1205                    let dy = final_pos.y as f32 - initial_pos[1];
1206                    let dz = final_pos.z as f32 - initial_pos[2];
1207                    let displacement = (dx * dx + dy * dy + dz * dz).sqrt();
1208                    camera_displacements.push(displacement);
1209                }
1210            }
1211        }
1212
1213        // Calculate landmark displacements
1214        let mut landmark_displacements: Vec<f32> = Vec::new();
1215        for (i, (_, var)) in values.iter().enumerate() {
1216            let name = format!("var_{i}");
1217            if let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>() {
1218                let data = v.value.data();
1219                if data.len() == 3
1220                    && let Some(initial_pos) = initial_landmarks.get(&name)
1221                {
1222                    let dx = data[0] as f32 - initial_pos[0];
1223                    let dy = data[1] as f32 - initial_pos[1];
1224                    let dz = data[2] as f32 - initial_pos[2];
1225                    let displacement = (dx * dx + dy * dy + dz * dz).sqrt();
1226                    landmark_displacements.push(displacement);
1227                }
1228            }
1229        }
1230
1231        // Log camera displacement statistics
1232        if !camera_displacements.is_empty() {
1233            let avg = camera_displacements.iter().sum::<f32>() / camera_displacements.len() as f32;
1234            let max = camera_displacements.iter().cloned().fold(0.0f32, f32::max);
1235            let min = camera_displacements
1236                .iter()
1237                .cloned()
1238                .fold(f32::MAX, f32::min);
1239            info!(
1240                "Camera displacement: avg={:.6}, min={:.6}, max={:.6} ({} cameras)",
1241                avg,
1242                min,
1243                max,
1244                camera_displacements.len()
1245            );
1246        }
1247
1248        // Log landmark displacement statistics
1249        if !landmark_displacements.is_empty() {
1250            let avg =
1251                landmark_displacements.iter().sum::<f32>() / landmark_displacements.len() as f32;
1252            let max = landmark_displacements
1253                .iter()
1254                .cloned()
1255                .fold(0.0f32, f32::max);
1256            let min = landmark_displacements
1257                .iter()
1258                .cloned()
1259                .fold(f32::MAX, f32::min);
1260            info!(
1261                "Landmark displacement: avg={:.6}, min={:.6}, max={:.6} ({} landmarks)",
1262                avg,
1263                min,
1264                max,
1265                landmark_displacements.len()
1266            );
1267        }
1268
1269        Ok(())
1270    }
1271
1272    // ========================================================================
1273    // Private Helper Methods
1274    // ========================================================================
1275
1276    /// Log scalar time series data.
1277    fn log_scalars(&self, iteration: usize, metrics: &IterationMetrics) -> ObserverResult<()> {
1278        if !self.config.show_plots {
1279            return Ok(());
1280        }
1281
1282        let rec = self.rec.as_ref().ok_or_else(|| {
1283            ObserverError::InvalidState("Recording stream not initialized".to_string())
1284        })?;
1285        rec.set_time_sequence("iteration", iteration as i64);
1286
1287        // Log each metric to separate entity paths for independent scaling
1288        if let Some(cost) = metrics.cost {
1289            rec.log("cost_plot/value", &rerun::archetypes::Scalars::new([cost]))
1290                .map_err(|e| {
1291                    ObserverError::LoggingFailed {
1292                        entity_path: "cost_plot/value".to_string(),
1293                        reason: format!("{}", e),
1294                    }
1295                    .log_with_source(e)
1296                })?;
1297        }
1298
1299        if let Some(gradient_norm) = metrics.gradient_norm {
1300            rec.log(
1301                "gradient_plot/norm",
1302                &rerun::archetypes::Scalars::new([gradient_norm]),
1303            )
1304            .map_err(|e| {
1305                ObserverError::LoggingFailed {
1306                    entity_path: "gradient_plot/norm".to_string(),
1307                    reason: format!("{}", e),
1308                }
1309                .log_with_source(e)
1310            })?;
1311        }
1312
1313        if let Some(damping) = metrics.damping {
1314            rec.log(
1315                "damping_plot/lambda",
1316                &rerun::archetypes::Scalars::new([damping]),
1317            )
1318            .map_err(|e| {
1319                ObserverError::LoggingFailed {
1320                    entity_path: "damping_plot/lambda".to_string(),
1321                    reason: format!("{}", e),
1322                }
1323                .log_with_source(e)
1324            })?;
1325        }
1326
1327        if let Some(step_norm) = metrics.step_norm {
1328            rec.log(
1329                "step_plot/norm",
1330                &rerun::archetypes::Scalars::new([step_norm]),
1331            )
1332            .map_err(|e| {
1333                ObserverError::LoggingFailed {
1334                    entity_path: "step_plot/norm".to_string(),
1335                    reason: format!("{}", e),
1336                }
1337                .log_with_source(e)
1338            })?;
1339        }
1340
1341        if let Some(step_quality) = metrics.step_quality {
1342            rec.log(
1343                "quality_plot/rho",
1344                &rerun::archetypes::Scalars::new([step_quality]),
1345            )
1346            .map_err(|e| {
1347                ObserverError::LoggingFailed {
1348                    entity_path: "quality_plot/rho".to_string(),
1349                    reason: format!("{}", e),
1350                }
1351                .log_with_source(e)
1352            })?;
1353        }
1354
1355        Ok(())
1356    }
1357
1358    /// Log matrix visualizations (Hessian and gradient).
1359    fn log_matrices(&self, iteration: usize, metrics: &IterationMetrics) -> ObserverResult<()> {
1360        if !self.config.show_matrices {
1361            return Ok(());
1362        }
1363
1364        let rec = self.rec.as_ref().ok_or_else(|| {
1365            ObserverError::InvalidState("Recording stream not initialized".to_string())
1366        })?;
1367        rec.set_time_sequence("iteration", iteration as i64);
1368
1369        // Log Hessian if available
1370        if let Some(ref hessian) = metrics.hessian
1371            && let Ok(image_data) = self.sparse_hessian_to_image(hessian)
1372        {
1373            rec.log(
1374                "optimization/matrices/hessian",
1375                &rerun::archetypes::Tensor::new(image_data),
1376            )
1377            .map_err(|e| {
1378                ObserverError::LoggingFailed {
1379                    entity_path: "optimization/matrices/hessian".to_string(),
1380                    reason: format!("{}", e),
1381                }
1382                .log_with_source(e)
1383            })?;
1384        }
1385
1386        // Log gradient if available
1387        if let Some(ref gradient) = metrics.gradient {
1388            let grad_vec: Vec<f64> = (0..gradient.nrows()).map(|i| gradient[(i, 0)]).collect();
1389            if let Ok(image_data) = self.gradient_to_image(&grad_vec) {
1390                rec.log(
1391                    "optimization/matrices/gradient",
1392                    &rerun::archetypes::Tensor::new(image_data),
1393                )
1394                .map_err(|e| {
1395                    ObserverError::LoggingFailed {
1396                        entity_path: "optimization/matrices/gradient".to_string(),
1397                        reason: format!("{}", e),
1398                    }
1399                    .log_with_source(e)
1400                })?;
1401            }
1402        }
1403
1404        Ok(())
1405    }
1406
1407    /// Log manifold states (SE2/SE3 poses and Rn 3D landmarks).
1408    /// Log the initial state of all variables before any optimization updates.
1409    ///
1410    /// Logs SE3 poses as `Transform3D` + `Pinhole` under `initial_graph/cameras/`,
1411    /// SE2 poses as `Boxes2D` under `initial_graph/se2_poses`,
1412    /// and Rn landmarks as `Points3D` under `initial_graph/landmarks`.
1413    fn log_initial_state(
1414        &self,
1415        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1416    ) -> ObserverResult<()> {
1417        let rec = self.rec.as_ref().ok_or_else(|| {
1418            ObserverError::InvalidState("Recording stream not initialized".to_string())
1419        })?;
1420        rec.set_time_sequence("iteration", 0_i64);
1421
1422        let mut se2_positions: Vec<[f32; 2]> = Vec::new();
1423        let mut landmark_positions: Vec<[f32; 3]> = Vec::new();
1424
1425        for (i, (_, var)) in variables.iter().enumerate() {
1426            let var_name = format!("var_{i}");
1427            if self.config.show_cameras {
1428                if let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>() {
1429                    let pose = if self.config.invert_camera_poses {
1430                        v.value.inverse(None)
1431                    } else {
1432                        v.value.clone()
1433                    };
1434
1435                    let trans = pose.translation();
1436                    let rot = pose.rotation_quaternion();
1437
1438                    let position = rerun::external::glam::Vec3::new(
1439                        trans.x as f32,
1440                        trans.y as f32,
1441                        trans.z as f32,
1442                    );
1443
1444                    let nq = rot.as_ref();
1445                    let rotation = rerun::external::glam::Quat::from_xyzw(
1446                        nq.i as f32,
1447                        nq.j as f32,
1448                        nq.k as f32,
1449                        nq.w as f32,
1450                    );
1451
1452                    let transform =
1453                        rerun::Transform3D::from_translation_rotation(position, rotation);
1454
1455                    let entity_path = format!("initial_graph/cameras/{}", var_name);
1456                    rec.log(entity_path.as_str(), &transform).map_err(|e| {
1457                        ObserverError::LoggingFailed {
1458                            entity_path: entity_path.clone(),
1459                            reason: format!("{}", e),
1460                        }
1461                        .log_with_source(e)
1462                    })?;
1463
1464                    rec.log(
1465                        entity_path.as_str(),
1466                        &rerun::archetypes::Pinhole::from_fov_and_aspect_ratio(
1467                            self.config.camera_fov,
1468                            self.config.camera_aspect_ratio,
1469                        )
1470                        .with_image_plane_distance(self.config.camera_frustum_scale),
1471                    )
1472                    .map_err(|e| {
1473                        ObserverError::LoggingFailed {
1474                            entity_path: entity_path.clone(),
1475                            reason: format!("{}", e),
1476                        }
1477                        .log_with_source(e)
1478                    })?;
1479                }
1480            }
1481            if self.config.show_se2_poses {
1482                if let Some(v) = var.as_any().downcast_ref::<Variable<SE2>>() {
1483                    se2_positions.push([v.value.x() as f32, v.value.y() as f32]);
1484                }
1485            }
1486            if self.config.show_landmarks {
1487                if let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>() {
1488                    let data = v.value.data();
1489                    if data.len() == 3 {
1490                        landmark_positions.push([data[0] as f32, data[1] as f32, data[2] as f32]);
1491                    }
1492                }
1493            }
1494        }
1495
1496        if self.config.show_landmarks && !landmark_positions.is_empty() {
1497            let color = self.config.initial_landmark_color;
1498            rec.log(
1499                "initial_graph/landmarks",
1500                &rerun::archetypes::Points3D::new(landmark_positions)
1501                    .with_radii([self.config.landmark_point_size])
1502                    .with_colors([rerun::components::Color::from_rgb(
1503                        color[0], color[1], color[2],
1504                    )]),
1505            )
1506            .map_err(|e| {
1507                ObserverError::LoggingFailed {
1508                    entity_path: "initial_graph/landmarks".to_string(),
1509                    reason: format!("{}", e),
1510                }
1511                .log_with_source(e)
1512            })?;
1513        }
1514
1515        if self.config.show_se2_poses && !se2_positions.is_empty() {
1516            let color = self.config.initial_se2_color;
1517            let hs = self.config.se2_box_half_size;
1518            let half_sizes: Vec<[f32; 2]> = vec![[hs, hs]; se2_positions.len()];
1519            rec.log(
1520                "initial_graph/se2_poses",
1521                &rerun::archetypes::Boxes2D::from_centers_and_half_sizes(se2_positions, half_sizes)
1522                    .with_colors([rerun::components::Color::from_rgb(
1523                        color[0], color[1], color[2],
1524                    )]),
1525            )
1526            .map_err(|e| {
1527                ObserverError::LoggingFailed {
1528                    entity_path: "initial_graph/se2_poses".to_string(),
1529                    reason: format!("{}", e),
1530                }
1531                .log_with_source(e)
1532            })?;
1533        }
1534
1535        self.initial_state_logged.set(true);
1536        Ok(())
1537    }
1538
1539    fn log_manifolds(
1540        &self,
1541        iteration: usize,
1542        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1543    ) -> ObserverResult<()> {
1544        let rec = self.rec.as_ref().ok_or_else(|| {
1545            ObserverError::InvalidState("Recording stream not initialized".to_string())
1546        })?;
1547        rec.set_time_sequence("iteration", iteration as i64);
1548
1549        // Collect 3D landmarks for batch logging (much more efficient for large point clouds)
1550        let mut landmark_positions: Vec<[f32; 3]> = Vec::new();
1551        // Collect SE2 positions for batch logging
1552        let mut se2_positions: Vec<[f32; 2]> = Vec::new();
1553
1554        for (i, (_, var)) in variables.iter().enumerate() {
1555            let var_name = format!("var_{i}");
1556            if self.config.show_cameras {
1557                if let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>() {
1558                    // Apply pose inversion if configured (for BA: T_wc -> T_cw)
1559                    let pose = if self.config.invert_camera_poses {
1560                        v.value.inverse(None)
1561                    } else {
1562                        v.value.clone()
1563                    };
1564
1565                    let trans = pose.translation();
1566                    let rot = pose.rotation_quaternion();
1567
1568                    let position = rerun::external::glam::Vec3::new(
1569                        trans.x as f32,
1570                        trans.y as f32,
1571                        trans.z as f32,
1572                    );
1573
1574                    let nq = rot.as_ref();
1575                    let rotation = rerun::external::glam::Quat::from_xyzw(
1576                        nq.i as f32,
1577                        nq.j as f32,
1578                        nq.k as f32,
1579                        nq.w as f32,
1580                    );
1581
1582                    let transform =
1583                        rerun::Transform3D::from_translation_rotation(position, rotation);
1584
1585                    let entity_path = format!("optimized_graph/cameras/{}", var_name);
1586                    rec.log(entity_path.as_str(), &transform).map_err(|e| {
1587                        ObserverError::LoggingFailed {
1588                            entity_path: entity_path.clone(),
1589                            reason: format!("{}", e),
1590                        }
1591                        .log_with_source(e)
1592                    })?;
1593
1594                    rec.log(
1595                        entity_path.as_str(),
1596                        &rerun::archetypes::Pinhole::from_fov_and_aspect_ratio(
1597                            self.config.camera_fov,
1598                            self.config.camera_aspect_ratio,
1599                        )
1600                        .with_image_plane_distance(self.config.camera_frustum_scale),
1601                    )
1602                    .map_err(|e| {
1603                        ObserverError::LoggingFailed {
1604                            entity_path: entity_path.clone(),
1605                            reason: format!("{}", e),
1606                        }
1607                        .log_with_source(e)
1608                    })?;
1609                }
1610            }
1611            if self.config.show_se2_poses {
1612                if let Some(v) = var.as_any().downcast_ref::<Variable<SE2>>() {
1613                    se2_positions.push([v.value.x() as f32, v.value.y() as f32]);
1614                }
1615            }
1616            if self.config.show_landmarks {
1617                if let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>() {
1618                    // Handle 3D landmarks (Rn with dimension 3)
1619                    let data = v.value.data();
1620                    if data.len() == 3 {
1621                        landmark_positions.push([data[0] as f32, data[1] as f32, data[2] as f32]);
1622                    }
1623                    // Skip non-3D Rn variables (e.g., camera intrinsics)
1624                }
1625            }
1626        }
1627
1628        // Batch log all 3D landmarks as a single point cloud (efficient for 100K+ points)
1629        if self.config.show_landmarks && !landmark_positions.is_empty() {
1630            let color = self.config.optimized_landmark_color;
1631            rec.log(
1632                "optimized_graph/landmarks",
1633                &rerun::archetypes::Points3D::new(landmark_positions)
1634                    .with_radii([self.config.landmark_point_size])
1635                    .with_colors([rerun::components::Color::from_rgb(
1636                        color[0], color[1], color[2],
1637                    )]),
1638            )
1639            .map_err(|e| {
1640                ObserverError::LoggingFailed {
1641                    entity_path: "optimized_graph/landmarks".to_string(),
1642                    reason: format!("{}", e),
1643                }
1644                .log_with_source(e)
1645            })?;
1646        }
1647
1648        // Batch log all SE2 poses as 2D boxes
1649        if self.config.show_se2_poses && !se2_positions.is_empty() {
1650            let color = self.config.optimized_se2_color;
1651            let hs = self.config.se2_box_half_size;
1652            let half_sizes: Vec<[f32; 2]> = vec![[hs, hs]; se2_positions.len()];
1653            rec.log(
1654                "optimized_graph/se2_poses",
1655                &rerun::archetypes::Boxes2D::from_centers_and_half_sizes(se2_positions, half_sizes)
1656                    .with_colors([rerun::components::Color::from_rgb(
1657                        color[0], color[1], color[2],
1658                    )]),
1659            )
1660            .map_err(|e| {
1661                ObserverError::LoggingFailed {
1662                    entity_path: "optimized_graph/se2_poses".to_string(),
1663                    reason: format!("{}", e),
1664                }
1665                .log_with_source(e)
1666            })?;
1667        }
1668
1669        Ok(())
1670    }
1671
1672    /// Convert sparse Hessian matrix to RGB image with heat map coloring.
1673    fn sparse_hessian_to_image(
1674        &self,
1675        hessian: &sparse::SparseColMat<usize, f64>,
1676    ) -> ObserverResult<rerun::datatypes::TensorData> {
1677        let target_size = self.config.hessian_downsample_size;
1678        let target_rows = target_size;
1679        let target_cols = target_size;
1680
1681        let dense_matrix = Self::downsample_sparse_matrix(hessian, target_rows, target_cols);
1682
1683        let mut min_val = f64::INFINITY;
1684        let mut max_val = f64::NEG_INFINITY;
1685
1686        for &val in &dense_matrix {
1687            if val.is_finite() {
1688                min_val = min_val.min(val);
1689                max_val = max_val.max(val);
1690            }
1691        }
1692
1693        let max_abs = max_val.abs().max(min_val.abs());
1694
1695        let mut rgb_data = Vec::with_capacity(target_rows * target_cols * 3);
1696
1697        for &val in &dense_matrix {
1698            let rgb = Self::value_to_rgb_heatmap(val, max_abs);
1699            rgb_data.extend_from_slice(&rgb);
1700        }
1701
1702        let tensor = rerun::datatypes::TensorData::new(
1703            vec![target_rows as u64, target_cols as u64, 3],
1704            rerun::datatypes::TensorBuffer::U8(rgb_data.into()),
1705        );
1706
1707        Ok(tensor)
1708    }
1709
1710    /// Convert gradient vector to a horizontal bar image.
1711    fn gradient_to_image(&self, gradient: &[f64]) -> ObserverResult<rerun::datatypes::TensorData> {
1712        let n = gradient.len();
1713        let bar_height = 50;
1714        let target_width = self.config.gradient_bar_width;
1715
1716        let max_abs = gradient
1717            .iter()
1718            .map(|&x| x.abs())
1719            .fold(0.0f64, |a, b| a.max(b));
1720
1721        let mut rgb_data = Vec::with_capacity(bar_height * target_width * 3);
1722
1723        for _ in 0..bar_height {
1724            for i in 0..target_width {
1725                let start = (i * n) / target_width;
1726                let end = ((i + 1) * n) / target_width;
1727                let sum: f64 = gradient[start..end].iter().sum();
1728                let val = sum / (end - start).max(1) as f64;
1729
1730                let rgb = Self::value_to_rgb_heatmap(val, max_abs);
1731                rgb_data.extend_from_slice(&rgb);
1732            }
1733        }
1734
1735        let tensor = rerun::datatypes::TensorData::new(
1736            vec![bar_height as u64, target_width as u64, 3],
1737            rerun::datatypes::TensorBuffer::U8(rgb_data.into()),
1738        );
1739
1740        Ok(tensor)
1741    }
1742
1743    /// Downsample a sparse matrix to target size using block averaging.
1744    fn downsample_sparse_matrix(
1745        sparse: &sparse::SparseColMat<usize, f64>,
1746        target_rows: usize,
1747        target_cols: usize,
1748    ) -> Vec<f64> {
1749        let m = sparse.nrows();
1750        let n = sparse.ncols();
1751
1752        let mut downsampled = vec![0.0; target_rows * target_cols];
1753        let mut counts = vec![0usize; target_rows * target_cols];
1754
1755        let symbolic = sparse.symbolic();
1756
1757        for col in 0..n {
1758            let row_indices = symbolic.row_idx_of_col_raw(col);
1759            let col_values = sparse.val_of_col(col);
1760
1761            for (idx_in_col, &row) in row_indices.iter().enumerate() {
1762                let value = col_values[idx_in_col];
1763
1764                if value.abs() > 1e-12 {
1765                    let target_row = (row * target_rows) / m;
1766                    let target_col = (col * target_cols) / n;
1767                    let idx = target_row * target_cols + target_col;
1768
1769                    downsampled[idx] += value;
1770                    counts[idx] += 1;
1771                }
1772            }
1773        }
1774
1775        for i in 0..downsampled.len() {
1776            if counts[i] > 0 {
1777                downsampled[i] /= counts[i] as f64;
1778            }
1779        }
1780
1781        downsampled
1782    }
1783
1784    /// Map a scalar value to RGB color using white-to-blue gradient.
1785    fn value_to_rgb_heatmap(value: f64, max_abs: f64) -> [u8; 3] {
1786        if !value.is_finite() || max_abs == 0.0 {
1787            return [255, 255, 255];
1788        }
1789
1790        let normalized = (value.abs() / max_abs).clamp(0.0, 1.0);
1791
1792        if normalized < 1e-10 {
1793            [255, 255, 255]
1794        } else {
1795            let intensity = (normalized * 255.0) as u8;
1796            let remaining = 255 - intensity;
1797            [remaining, remaining, 255]
1798        }
1799    }
1800}
1801
1802// ============================================================================
1803// OptObserver Trait Implementation
1804// ============================================================================
1805
1806impl OptObserver for RerunObserver {
1807    /// Called at each optimization iteration.
1808    ///
1809    /// This logs all visualization data to Rerun, including:
1810    /// - Time series plots (cost, gradient, damping, step quality)
1811    /// - Matrix visualizations (Hessian, gradient) if set via `set_matrix_data`
1812    /// - Manifold states (SE2/SE3 poses)
1813    ///
1814    /// In `InitialAndFinal` mode, this method only logs scalar metrics (plots)
1815    /// during intermediate iterations. The full manifold state is logged at
1816    /// iteration 0 (initial) and in `on_optimization_complete` (final).
1817    ///
1818    /// # Arguments
1819    ///
1820    /// * `values` - Current variable values (manifold states)
1821    /// * `iteration` - Current iteration number
1822    fn on_step(&self, values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>, iteration: usize) {
1823        if !self.is_enabled() {
1824            return;
1825        }
1826
1827        // Log initial state once before any optimization updates
1828        if !self.initial_state_logged.get() {
1829            if let Err(e) = self.log_initial_state(values) {
1830                let _ = e.log();
1831            }
1832        }
1833
1834        let metrics = self.iteration_metrics.borrow();
1835
1836        // Always log scalar metrics (plots) - they're lightweight and useful
1837        if let Err(e) = self.log_scalars(iteration, &metrics) {
1838            let _ = e.log();
1839        }
1840
1841        // In InitialAndFinal mode, skip manifold logging for intermediate iterations
1842        // Initial state (iteration 0) is logged via log_initial_ba_state()
1843        // Final state is logged via on_optimization_complete()
1844        let should_log_manifolds = match self.config.visualization_mode {
1845            VisualizationMode::Iterative => true,
1846            VisualizationMode::InitialAndFinal => false, // Skip intermediate iterations
1847        };
1848
1849        if should_log_manifolds {
1850            if let Err(e) = self.log_matrices(iteration, &metrics) {
1851                let _ = e.log();
1852            }
1853            if let Err(e) = self.log_manifolds(iteration, values) {
1854                let _ = e.log();
1855            }
1856        }
1857
1858        // Clear transient data for next iteration
1859        drop(metrics);
1860        // Note: We don't clear the RefCell here to allow access from multiple threads
1861    }
1862
1863    /// Called when optimization completes.
1864    ///
1865    /// In `InitialAndFinal` mode, this logs the final optimized state.
1866    /// In `Iterative` mode, the final state was already logged via `on_step`.
1867    ///
1868    /// # Arguments
1869    ///
1870    /// * `values` - Final optimized variable values
1871    /// * `iterations` - Total number of iterations performed
1872    fn on_optimization_complete(
1873        &self,
1874        values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1875        iterations: usize,
1876    ) {
1877        if !self.is_enabled() {
1878            return;
1879        }
1880
1881        // Log final state with displacement statistics
1882        if let Err(e) = self.log_final_state(values, iterations) {
1883            let _ = e.log();
1884        }
1885    }
1886}
1887
1888impl Default for RerunObserver {
1889    fn default() -> Self {
1890        Self::new(false).unwrap_or_else(|_| Self {
1891            rec: None,
1892            enabled: false,
1893            iteration_metrics: RefCell::new(IterationMetrics::default()),
1894            config: VisualizationConfig::default(),
1895            initial_camera_positions: RefCell::new(HashMap::new()),
1896            initial_landmark_positions: RefCell::new(HashMap::new()),
1897            initial_state_logged: Cell::new(false),
1898        })
1899    }
1900}
1901
1902#[cfg(test)]
1903mod tests {
1904    use super::*;
1905
1906    type TestResult = Result<(), Box<dyn std::error::Error>>;
1907
1908    #[test]
1909    fn test_observer_creation() -> TestResult {
1910        let observer = RerunObserver::new(false)?;
1911        assert!(!observer.is_enabled());
1912        Ok(())
1913    }
1914
1915    #[test]
1916    fn test_rgb_heatmap_conversion() {
1917        let rgb = RerunObserver::value_to_rgb_heatmap(0.0, 1.0);
1918        assert_eq!(rgb, [255, 255, 255]);
1919
1920        let rgb = RerunObserver::value_to_rgb_heatmap(1.0, 1.0);
1921        assert_eq!(rgb, [0, 0, 255]);
1922
1923        let rgb = RerunObserver::value_to_rgb_heatmap(-1.0, 1.0);
1924        assert_eq!(rgb, [0, 0, 255]);
1925
1926        let rgb = RerunObserver::value_to_rgb_heatmap(0.5, 1.0);
1927        assert_eq!(rgb, [128, 128, 255]);
1928    }
1929
1930    #[test]
1931    fn test_set_metrics() -> TestResult {
1932        let observer = RerunObserver::new(false)?;
1933        observer.set_iteration_metrics(1.0, 0.5, Some(0.01), 0.1, Some(0.95));
1934
1935        let metrics = observer.iteration_metrics.borrow();
1936        assert_eq!(metrics.cost, Some(1.0));
1937        assert_eq!(metrics.gradient_norm, Some(0.5));
1938        assert_eq!(metrics.damping, Some(0.01));
1939        assert_eq!(metrics.step_norm, Some(0.1));
1940        assert_eq!(metrics.step_quality, Some(0.95));
1941        Ok(())
1942    }
1943
1944    #[test]
1945    fn test_observer_trait() -> TestResult {
1946        let observer = RerunObserver::new(false)?;
1947        let values: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1948
1949        // Should not panic when disabled
1950        observer.on_step(&values, 0);
1951        observer.on_step(&values, 1);
1952        Ok(())
1953    }
1954}