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                && let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>()
1042            {
1043                // Apply pose inversion if configured (for BA: T_wc -> T_cw)
1044                let pose = if self.config.invert_camera_poses {
1045                    v.value.inverse(None)
1046                } else {
1047                    v.value.clone()
1048                };
1049
1050                let trans = pose.translation();
1051                let rot = pose.rotation_quaternion();
1052
1053                let position = rerun::external::glam::Vec3::new(
1054                    trans.x as f32,
1055                    trans.y as f32,
1056                    trans.z as f32,
1057                );
1058
1059                let nq = rot.as_ref();
1060                let rotation = rerun::external::glam::Quat::from_xyzw(
1061                    nq.i as f32,
1062                    nq.j as f32,
1063                    nq.k as f32,
1064                    nq.w as f32,
1065                );
1066
1067                let transform = 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            if self.config.show_se2_poses
1097                && let Some(v) = var.as_any().downcast_ref::<Variable<SE2>>()
1098            {
1099                final_se2_positions.push([v.value.x() as f32, v.value.y() as f32]);
1100                se2_count += 1;
1101            }
1102            if self.config.show_landmarks
1103                && let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>()
1104            {
1105                // Handle 3D landmarks (Rn with dimension 3)
1106                let data = v.value.data();
1107                if data.len() == 3 {
1108                    final_landmark_positions.push([data[0] as f32, data[1] as f32, data[2] as f32]);
1109                }
1110            }
1111        }
1112
1113        // Batch log all final landmarks as a single point cloud with green color
1114        if self.config.show_landmarks && !final_landmark_positions.is_empty() {
1115            // Use green for final/optimized landmarks to distinguish from initial (blue)
1116            let final_color: [u8; 3] = [50, 200, 100]; // Green
1117            rec.log(
1118                "final_graph/landmarks",
1119                &rerun::archetypes::Points3D::new(final_landmark_positions.clone())
1120                    .with_radii([self.config.landmark_point_size])
1121                    .with_colors([rerun::components::Color::from_rgb(
1122                        final_color[0],
1123                        final_color[1],
1124                        final_color[2],
1125                    )]),
1126            )
1127            .map_err(|e| {
1128                ObserverError::LoggingFailed {
1129                    entity_path: "final_graph/landmarks".to_string(),
1130                    reason: format!("{}", e),
1131                }
1132                .log_with_source(e)
1133            })?;
1134        }
1135
1136        // Batch log all final SE2 poses as 2D boxes
1137        if self.config.show_se2_poses && !final_se2_positions.is_empty() {
1138            let color = self.config.optimized_se2_color;
1139            let hs = self.config.se2_box_half_size;
1140            let half_sizes: Vec<[f32; 2]> = vec![[hs, hs]; final_se2_positions.len()];
1141            rec.log(
1142                "final_graph/se2_poses",
1143                &rerun::archetypes::Boxes2D::from_centers_and_half_sizes(
1144                    final_se2_positions,
1145                    half_sizes,
1146                )
1147                .with_colors([rerun::components::Color::from_rgb(
1148                    color[0], color[1], color[2],
1149                )]),
1150            )
1151            .map_err(|e| {
1152                ObserverError::LoggingFailed {
1153                    entity_path: "final_graph/se2_poses".to_string(),
1154                    reason: format!("{}", e),
1155                }
1156                .log_with_source(e)
1157            })?;
1158        }
1159
1160        info!(
1161            "Logged final state after {} iterations: {} cameras, {} landmarks, {} SE2 poses",
1162            iterations,
1163            camera_count,
1164            final_landmark_positions.len(),
1165            se2_count
1166        );
1167
1168        // Log displacement statistics
1169        self.log_displacement_statistics(values)?;
1170
1171        Ok(())
1172    }
1173
1174    /// Log displacement statistics comparing initial and final states.
1175    ///
1176    /// Calculates and logs the displacement of cameras and landmarks
1177    /// from their initial positions to their final optimized positions.
1178    fn log_displacement_statistics(
1179        &self,
1180        values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1181    ) -> ObserverResult<()> {
1182        let initial_cameras = self.initial_camera_positions.borrow();
1183        let initial_landmarks = self.initial_landmark_positions.borrow();
1184
1185        // Calculate camera displacements
1186        let mut camera_displacements: Vec<f32> = Vec::new();
1187        for (i, (_, var)) in values.iter().enumerate() {
1188            let name = format!("var_{i}");
1189            if let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>() {
1190                // Apply same pose inversion as in initial state
1191                let pose = if self.config.invert_camera_poses {
1192                    v.value.inverse(None)
1193                } else {
1194                    v.value.clone()
1195                };
1196
1197                if let Some(initial_pos) = initial_cameras.get(&name) {
1198                    let final_pos = pose.translation();
1199                    let dx = final_pos.x as f32 - initial_pos[0];
1200                    let dy = final_pos.y as f32 - initial_pos[1];
1201                    let dz = final_pos.z as f32 - initial_pos[2];
1202                    let displacement = (dx * dx + dy * dy + dz * dz).sqrt();
1203                    camera_displacements.push(displacement);
1204                }
1205            }
1206        }
1207
1208        // Calculate landmark displacements
1209        let mut landmark_displacements: Vec<f32> = Vec::new();
1210        for (i, (_, var)) in values.iter().enumerate() {
1211            let name = format!("var_{i}");
1212            if let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>() {
1213                let data = v.value.data();
1214                if data.len() == 3
1215                    && let Some(initial_pos) = initial_landmarks.get(&name)
1216                {
1217                    let dx = data[0] as f32 - initial_pos[0];
1218                    let dy = data[1] as f32 - initial_pos[1];
1219                    let dz = data[2] as f32 - initial_pos[2];
1220                    let displacement = (dx * dx + dy * dy + dz * dz).sqrt();
1221                    landmark_displacements.push(displacement);
1222                }
1223            }
1224        }
1225
1226        // Log camera displacement statistics
1227        if !camera_displacements.is_empty() {
1228            let avg = camera_displacements.iter().sum::<f32>() / camera_displacements.len() as f32;
1229            let max = camera_displacements.iter().cloned().fold(0.0f32, f32::max);
1230            let min = camera_displacements
1231                .iter()
1232                .cloned()
1233                .fold(f32::MAX, f32::min);
1234            info!(
1235                "Camera displacement: avg={:.6}, min={:.6}, max={:.6} ({} cameras)",
1236                avg,
1237                min,
1238                max,
1239                camera_displacements.len()
1240            );
1241        }
1242
1243        // Log landmark displacement statistics
1244        if !landmark_displacements.is_empty() {
1245            let avg =
1246                landmark_displacements.iter().sum::<f32>() / landmark_displacements.len() as f32;
1247            let max = landmark_displacements
1248                .iter()
1249                .cloned()
1250                .fold(0.0f32, f32::max);
1251            let min = landmark_displacements
1252                .iter()
1253                .cloned()
1254                .fold(f32::MAX, f32::min);
1255            info!(
1256                "Landmark displacement: avg={:.6}, min={:.6}, max={:.6} ({} landmarks)",
1257                avg,
1258                min,
1259                max,
1260                landmark_displacements.len()
1261            );
1262        }
1263
1264        Ok(())
1265    }
1266
1267    // ========================================================================
1268    // Private Helper Methods
1269    // ========================================================================
1270
1271    /// Log scalar time series data.
1272    fn log_scalars(&self, iteration: usize, metrics: &IterationMetrics) -> ObserverResult<()> {
1273        if !self.config.show_plots {
1274            return Ok(());
1275        }
1276
1277        let rec = self.rec.as_ref().ok_or_else(|| {
1278            ObserverError::InvalidState("Recording stream not initialized".to_string())
1279        })?;
1280        rec.set_time_sequence("iteration", iteration as i64);
1281
1282        // Log each metric to separate entity paths for independent scaling
1283        if let Some(cost) = metrics.cost {
1284            rec.log("cost_plot/value", &rerun::archetypes::Scalars::new([cost]))
1285                .map_err(|e| {
1286                    ObserverError::LoggingFailed {
1287                        entity_path: "cost_plot/value".to_string(),
1288                        reason: format!("{}", e),
1289                    }
1290                    .log_with_source(e)
1291                })?;
1292        }
1293
1294        if let Some(gradient_norm) = metrics.gradient_norm {
1295            rec.log(
1296                "gradient_plot/norm",
1297                &rerun::archetypes::Scalars::new([gradient_norm]),
1298            )
1299            .map_err(|e| {
1300                ObserverError::LoggingFailed {
1301                    entity_path: "gradient_plot/norm".to_string(),
1302                    reason: format!("{}", e),
1303                }
1304                .log_with_source(e)
1305            })?;
1306        }
1307
1308        if let Some(damping) = metrics.damping {
1309            rec.log(
1310                "damping_plot/lambda",
1311                &rerun::archetypes::Scalars::new([damping]),
1312            )
1313            .map_err(|e| {
1314                ObserverError::LoggingFailed {
1315                    entity_path: "damping_plot/lambda".to_string(),
1316                    reason: format!("{}", e),
1317                }
1318                .log_with_source(e)
1319            })?;
1320        }
1321
1322        if let Some(step_norm) = metrics.step_norm {
1323            rec.log(
1324                "step_plot/norm",
1325                &rerun::archetypes::Scalars::new([step_norm]),
1326            )
1327            .map_err(|e| {
1328                ObserverError::LoggingFailed {
1329                    entity_path: "step_plot/norm".to_string(),
1330                    reason: format!("{}", e),
1331                }
1332                .log_with_source(e)
1333            })?;
1334        }
1335
1336        if let Some(step_quality) = metrics.step_quality {
1337            rec.log(
1338                "quality_plot/rho",
1339                &rerun::archetypes::Scalars::new([step_quality]),
1340            )
1341            .map_err(|e| {
1342                ObserverError::LoggingFailed {
1343                    entity_path: "quality_plot/rho".to_string(),
1344                    reason: format!("{}", e),
1345                }
1346                .log_with_source(e)
1347            })?;
1348        }
1349
1350        Ok(())
1351    }
1352
1353    /// Log matrix visualizations (Hessian and gradient).
1354    fn log_matrices(&self, iteration: usize, metrics: &IterationMetrics) -> ObserverResult<()> {
1355        if !self.config.show_matrices {
1356            return Ok(());
1357        }
1358
1359        let rec = self.rec.as_ref().ok_or_else(|| {
1360            ObserverError::InvalidState("Recording stream not initialized".to_string())
1361        })?;
1362        rec.set_time_sequence("iteration", iteration as i64);
1363
1364        // Log Hessian if available
1365        if let Some(ref hessian) = metrics.hessian
1366            && let Ok(image_data) = self.sparse_hessian_to_image(hessian)
1367        {
1368            rec.log(
1369                "optimization/matrices/hessian",
1370                &rerun::archetypes::Tensor::new(image_data),
1371            )
1372            .map_err(|e| {
1373                ObserverError::LoggingFailed {
1374                    entity_path: "optimization/matrices/hessian".to_string(),
1375                    reason: format!("{}", e),
1376                }
1377                .log_with_source(e)
1378            })?;
1379        }
1380
1381        // Log gradient if available
1382        if let Some(ref gradient) = metrics.gradient {
1383            let grad_vec: Vec<f64> = (0..gradient.nrows()).map(|i| gradient[(i, 0)]).collect();
1384            if let Ok(image_data) = self.gradient_to_image(&grad_vec) {
1385                rec.log(
1386                    "optimization/matrices/gradient",
1387                    &rerun::archetypes::Tensor::new(image_data),
1388                )
1389                .map_err(|e| {
1390                    ObserverError::LoggingFailed {
1391                        entity_path: "optimization/matrices/gradient".to_string(),
1392                        reason: format!("{}", e),
1393                    }
1394                    .log_with_source(e)
1395                })?;
1396            }
1397        }
1398
1399        Ok(())
1400    }
1401
1402    /// Log manifold states (SE2/SE3 poses and Rn 3D landmarks).
1403    /// Log the initial state of all variables before any optimization updates.
1404    ///
1405    /// Logs SE3 poses as `Transform3D` + `Pinhole` under `initial_graph/cameras/`,
1406    /// SE2 poses as `Boxes2D` under `initial_graph/se2_poses`,
1407    /// and Rn landmarks as `Points3D` under `initial_graph/landmarks`.
1408    fn log_initial_state(
1409        &self,
1410        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1411    ) -> ObserverResult<()> {
1412        let rec = self.rec.as_ref().ok_or_else(|| {
1413            ObserverError::InvalidState("Recording stream not initialized".to_string())
1414        })?;
1415        rec.set_time_sequence("iteration", 0_i64);
1416
1417        let mut se2_positions: Vec<[f32; 2]> = Vec::new();
1418        let mut landmark_positions: Vec<[f32; 3]> = Vec::new();
1419
1420        for (i, (_, var)) in variables.iter().enumerate() {
1421            let var_name = format!("var_{i}");
1422            if self.config.show_cameras
1423                && let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>()
1424            {
1425                let pose = if self.config.invert_camera_poses {
1426                    v.value.inverse(None)
1427                } else {
1428                    v.value.clone()
1429                };
1430
1431                let trans = pose.translation();
1432                let rot = pose.rotation_quaternion();
1433
1434                let position = rerun::external::glam::Vec3::new(
1435                    trans.x as f32,
1436                    trans.y as f32,
1437                    trans.z as f32,
1438                );
1439
1440                let nq = rot.as_ref();
1441                let rotation = rerun::external::glam::Quat::from_xyzw(
1442                    nq.i as f32,
1443                    nq.j as f32,
1444                    nq.k as f32,
1445                    nq.w as f32,
1446                );
1447
1448                let transform = rerun::Transform3D::from_translation_rotation(position, rotation);
1449
1450                let entity_path = format!("initial_graph/cameras/{}", var_name);
1451                rec.log(entity_path.as_str(), &transform).map_err(|e| {
1452                    ObserverError::LoggingFailed {
1453                        entity_path: entity_path.clone(),
1454                        reason: format!("{}", e),
1455                    }
1456                    .log_with_source(e)
1457                })?;
1458
1459                rec.log(
1460                    entity_path.as_str(),
1461                    &rerun::archetypes::Pinhole::from_fov_and_aspect_ratio(
1462                        self.config.camera_fov,
1463                        self.config.camera_aspect_ratio,
1464                    )
1465                    .with_image_plane_distance(self.config.camera_frustum_scale),
1466                )
1467                .map_err(|e| {
1468                    ObserverError::LoggingFailed {
1469                        entity_path: entity_path.clone(),
1470                        reason: format!("{}", e),
1471                    }
1472                    .log_with_source(e)
1473                })?;
1474            }
1475            if self.config.show_se2_poses
1476                && let Some(v) = var.as_any().downcast_ref::<Variable<SE2>>()
1477            {
1478                se2_positions.push([v.value.x() as f32, v.value.y() as f32]);
1479            }
1480            if self.config.show_landmarks
1481                && let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>()
1482            {
1483                let data = v.value.data();
1484                if data.len() == 3 {
1485                    landmark_positions.push([data[0] as f32, data[1] as f32, data[2] as f32]);
1486                }
1487            }
1488        }
1489
1490        if self.config.show_landmarks && !landmark_positions.is_empty() {
1491            let color = self.config.initial_landmark_color;
1492            rec.log(
1493                "initial_graph/landmarks",
1494                &rerun::archetypes::Points3D::new(landmark_positions)
1495                    .with_radii([self.config.landmark_point_size])
1496                    .with_colors([rerun::components::Color::from_rgb(
1497                        color[0], color[1], color[2],
1498                    )]),
1499            )
1500            .map_err(|e| {
1501                ObserverError::LoggingFailed {
1502                    entity_path: "initial_graph/landmarks".to_string(),
1503                    reason: format!("{}", e),
1504                }
1505                .log_with_source(e)
1506            })?;
1507        }
1508
1509        if self.config.show_se2_poses && !se2_positions.is_empty() {
1510            let color = self.config.initial_se2_color;
1511            let hs = self.config.se2_box_half_size;
1512            let half_sizes: Vec<[f32; 2]> = vec![[hs, hs]; se2_positions.len()];
1513            rec.log(
1514                "initial_graph/se2_poses",
1515                &rerun::archetypes::Boxes2D::from_centers_and_half_sizes(se2_positions, half_sizes)
1516                    .with_colors([rerun::components::Color::from_rgb(
1517                        color[0], color[1], color[2],
1518                    )]),
1519            )
1520            .map_err(|e| {
1521                ObserverError::LoggingFailed {
1522                    entity_path: "initial_graph/se2_poses".to_string(),
1523                    reason: format!("{}", e),
1524                }
1525                .log_with_source(e)
1526            })?;
1527        }
1528
1529        self.initial_state_logged.set(true);
1530        Ok(())
1531    }
1532
1533    fn log_manifolds(
1534        &self,
1535        iteration: usize,
1536        variables: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1537    ) -> ObserverResult<()> {
1538        let rec = self.rec.as_ref().ok_or_else(|| {
1539            ObserverError::InvalidState("Recording stream not initialized".to_string())
1540        })?;
1541        rec.set_time_sequence("iteration", iteration as i64);
1542
1543        // Collect 3D landmarks for batch logging (much more efficient for large point clouds)
1544        let mut landmark_positions: Vec<[f32; 3]> = Vec::new();
1545        // Collect SE2 positions for batch logging
1546        let mut se2_positions: Vec<[f32; 2]> = Vec::new();
1547
1548        for (i, (_, var)) in variables.iter().enumerate() {
1549            let var_name = format!("var_{i}");
1550            if self.config.show_cameras
1551                && let Some(v) = var.as_any().downcast_ref::<Variable<SE3>>()
1552            {
1553                // Apply pose inversion if configured (for BA: T_wc -> T_cw)
1554                let pose = if self.config.invert_camera_poses {
1555                    v.value.inverse(None)
1556                } else {
1557                    v.value.clone()
1558                };
1559
1560                let trans = pose.translation();
1561                let rot = pose.rotation_quaternion();
1562
1563                let position = rerun::external::glam::Vec3::new(
1564                    trans.x as f32,
1565                    trans.y as f32,
1566                    trans.z as f32,
1567                );
1568
1569                let nq = rot.as_ref();
1570                let rotation = rerun::external::glam::Quat::from_xyzw(
1571                    nq.i as f32,
1572                    nq.j as f32,
1573                    nq.k as f32,
1574                    nq.w as f32,
1575                );
1576
1577                let transform = rerun::Transform3D::from_translation_rotation(position, rotation);
1578
1579                let entity_path = format!("optimized_graph/cameras/{}", var_name);
1580                rec.log(entity_path.as_str(), &transform).map_err(|e| {
1581                    ObserverError::LoggingFailed {
1582                        entity_path: entity_path.clone(),
1583                        reason: format!("{}", e),
1584                    }
1585                    .log_with_source(e)
1586                })?;
1587
1588                rec.log(
1589                    entity_path.as_str(),
1590                    &rerun::archetypes::Pinhole::from_fov_and_aspect_ratio(
1591                        self.config.camera_fov,
1592                        self.config.camera_aspect_ratio,
1593                    )
1594                    .with_image_plane_distance(self.config.camera_frustum_scale),
1595                )
1596                .map_err(|e| {
1597                    ObserverError::LoggingFailed {
1598                        entity_path: entity_path.clone(),
1599                        reason: format!("{}", e),
1600                    }
1601                    .log_with_source(e)
1602                })?;
1603            }
1604            if self.config.show_se2_poses
1605                && let Some(v) = var.as_any().downcast_ref::<Variable<SE2>>()
1606            {
1607                se2_positions.push([v.value.x() as f32, v.value.y() as f32]);
1608            }
1609            if self.config.show_landmarks
1610                && let Some(v) = var.as_any().downcast_ref::<Variable<Rn>>()
1611            {
1612                // Handle 3D landmarks (Rn with dimension 3)
1613                let data = v.value.data();
1614                if data.len() == 3 {
1615                    landmark_positions.push([data[0] as f32, data[1] as f32, data[2] as f32]);
1616                }
1617                // Skip non-3D Rn variables (e.g., camera intrinsics)
1618            }
1619        }
1620
1621        // Batch log all 3D landmarks as a single point cloud (efficient for 100K+ points)
1622        if self.config.show_landmarks && !landmark_positions.is_empty() {
1623            let color = self.config.optimized_landmark_color;
1624            rec.log(
1625                "optimized_graph/landmarks",
1626                &rerun::archetypes::Points3D::new(landmark_positions)
1627                    .with_radii([self.config.landmark_point_size])
1628                    .with_colors([rerun::components::Color::from_rgb(
1629                        color[0], color[1], color[2],
1630                    )]),
1631            )
1632            .map_err(|e| {
1633                ObserverError::LoggingFailed {
1634                    entity_path: "optimized_graph/landmarks".to_string(),
1635                    reason: format!("{}", e),
1636                }
1637                .log_with_source(e)
1638            })?;
1639        }
1640
1641        // Batch log all SE2 poses as 2D boxes
1642        if self.config.show_se2_poses && !se2_positions.is_empty() {
1643            let color = self.config.optimized_se2_color;
1644            let hs = self.config.se2_box_half_size;
1645            let half_sizes: Vec<[f32; 2]> = vec![[hs, hs]; se2_positions.len()];
1646            rec.log(
1647                "optimized_graph/se2_poses",
1648                &rerun::archetypes::Boxes2D::from_centers_and_half_sizes(se2_positions, half_sizes)
1649                    .with_colors([rerun::components::Color::from_rgb(
1650                        color[0], color[1], color[2],
1651                    )]),
1652            )
1653            .map_err(|e| {
1654                ObserverError::LoggingFailed {
1655                    entity_path: "optimized_graph/se2_poses".to_string(),
1656                    reason: format!("{}", e),
1657                }
1658                .log_with_source(e)
1659            })?;
1660        }
1661
1662        Ok(())
1663    }
1664
1665    /// Convert sparse Hessian matrix to RGB image with heat map coloring.
1666    fn sparse_hessian_to_image(
1667        &self,
1668        hessian: &sparse::SparseColMat<usize, f64>,
1669    ) -> ObserverResult<rerun::datatypes::TensorData> {
1670        let target_size = self.config.hessian_downsample_size;
1671        let target_rows = target_size;
1672        let target_cols = target_size;
1673
1674        let dense_matrix = Self::downsample_sparse_matrix(hessian, target_rows, target_cols);
1675
1676        let mut min_val = f64::INFINITY;
1677        let mut max_val = f64::NEG_INFINITY;
1678
1679        for &val in &dense_matrix {
1680            if val.is_finite() {
1681                min_val = min_val.min(val);
1682                max_val = max_val.max(val);
1683            }
1684        }
1685
1686        let max_abs = max_val.abs().max(min_val.abs());
1687
1688        let mut rgb_data = Vec::with_capacity(target_rows * target_cols * 3);
1689
1690        for &val in &dense_matrix {
1691            let rgb = Self::value_to_rgb_heatmap(val, max_abs);
1692            rgb_data.extend_from_slice(&rgb);
1693        }
1694
1695        let tensor = rerun::datatypes::TensorData::new(
1696            vec![target_rows as u64, target_cols as u64, 3],
1697            rerun::datatypes::TensorBuffer::U8(rgb_data.into()),
1698        );
1699
1700        Ok(tensor)
1701    }
1702
1703    /// Convert gradient vector to a horizontal bar image.
1704    fn gradient_to_image(&self, gradient: &[f64]) -> ObserverResult<rerun::datatypes::TensorData> {
1705        let n = gradient.len();
1706        let bar_height = 50;
1707        let target_width = self.config.gradient_bar_width;
1708
1709        let max_abs = gradient
1710            .iter()
1711            .map(|&x| x.abs())
1712            .fold(0.0f64, |a, b| a.max(b));
1713
1714        let mut rgb_data = Vec::with_capacity(bar_height * target_width * 3);
1715
1716        for _ in 0..bar_height {
1717            for i in 0..target_width {
1718                let start = (i * n) / target_width;
1719                let end = ((i + 1) * n) / target_width;
1720                let sum: f64 = gradient[start..end].iter().sum();
1721                let val = sum / (end - start).max(1) as f64;
1722
1723                let rgb = Self::value_to_rgb_heatmap(val, max_abs);
1724                rgb_data.extend_from_slice(&rgb);
1725            }
1726        }
1727
1728        let tensor = rerun::datatypes::TensorData::new(
1729            vec![bar_height as u64, target_width as u64, 3],
1730            rerun::datatypes::TensorBuffer::U8(rgb_data.into()),
1731        );
1732
1733        Ok(tensor)
1734    }
1735
1736    /// Downsample a sparse matrix to target size using block averaging.
1737    fn downsample_sparse_matrix(
1738        sparse: &sparse::SparseColMat<usize, f64>,
1739        target_rows: usize,
1740        target_cols: usize,
1741    ) -> Vec<f64> {
1742        let m = sparse.nrows();
1743        let n = sparse.ncols();
1744
1745        let mut downsampled = vec![0.0; target_rows * target_cols];
1746        let mut counts = vec![0usize; target_rows * target_cols];
1747
1748        let symbolic = sparse.symbolic();
1749
1750        for col in 0..n {
1751            let row_indices = symbolic.row_idx_of_col_raw(col);
1752            let col_values = sparse.val_of_col(col);
1753
1754            for (idx_in_col, &row) in row_indices.iter().enumerate() {
1755                let value = col_values[idx_in_col];
1756
1757                if value.abs() > 1e-12 {
1758                    let target_row = (row * target_rows) / m;
1759                    let target_col = (col * target_cols) / n;
1760                    let idx = target_row * target_cols + target_col;
1761
1762                    downsampled[idx] += value;
1763                    counts[idx] += 1;
1764                }
1765            }
1766        }
1767
1768        for i in 0..downsampled.len() {
1769            if counts[i] > 0 {
1770                downsampled[i] /= counts[i] as f64;
1771            }
1772        }
1773
1774        downsampled
1775    }
1776
1777    /// Map a scalar value to RGB color using white-to-blue gradient.
1778    fn value_to_rgb_heatmap(value: f64, max_abs: f64) -> [u8; 3] {
1779        if !value.is_finite() || max_abs == 0.0 {
1780            return [255, 255, 255];
1781        }
1782
1783        let normalized = (value.abs() / max_abs).clamp(0.0, 1.0);
1784
1785        if normalized < 1e-10 {
1786            [255, 255, 255]
1787        } else {
1788            let intensity = (normalized * 255.0) as u8;
1789            let remaining = 255 - intensity;
1790            [remaining, remaining, 255]
1791        }
1792    }
1793}
1794
1795// ============================================================================
1796// OptObserver Trait Implementation
1797// ============================================================================
1798
1799impl OptObserver for RerunObserver {
1800    /// Called at each optimization iteration.
1801    ///
1802    /// This logs all visualization data to Rerun, including:
1803    /// - Time series plots (cost, gradient, damping, step quality)
1804    /// - Matrix visualizations (Hessian, gradient) if set via `set_matrix_data`
1805    /// - Manifold states (SE2/SE3 poses)
1806    ///
1807    /// In `InitialAndFinal` mode, this method only logs scalar metrics (plots)
1808    /// during intermediate iterations. The full manifold state is logged at
1809    /// iteration 0 (initial) and in `on_optimization_complete` (final).
1810    ///
1811    /// # Arguments
1812    ///
1813    /// * `values` - Current variable values (manifold states)
1814    /// * `iteration` - Current iteration number
1815    fn on_step(&self, values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>, iteration: usize) {
1816        if !self.is_enabled() {
1817            return;
1818        }
1819
1820        // Log initial state once before any optimization updates
1821        if !self.initial_state_logged.get()
1822            && let Err(e) = self.log_initial_state(values)
1823        {
1824            let _ = e.log();
1825        }
1826
1827        let metrics = self.iteration_metrics.borrow();
1828
1829        // Always log scalar metrics (plots) - they're lightweight and useful
1830        if let Err(e) = self.log_scalars(iteration, &metrics) {
1831            let _ = e.log();
1832        }
1833
1834        // In InitialAndFinal mode, skip manifold logging for intermediate iterations
1835        // Initial state (iteration 0) is logged via log_initial_ba_state()
1836        // Final state is logged via on_optimization_complete()
1837        let should_log_manifolds = match self.config.visualization_mode {
1838            VisualizationMode::Iterative => true,
1839            VisualizationMode::InitialAndFinal => false, // Skip intermediate iterations
1840        };
1841
1842        if should_log_manifolds {
1843            if let Err(e) = self.log_matrices(iteration, &metrics) {
1844                let _ = e.log();
1845            }
1846            if let Err(e) = self.log_manifolds(iteration, values) {
1847                let _ = e.log();
1848            }
1849        }
1850
1851        // Clear transient data for next iteration
1852        drop(metrics);
1853        // Kept populated so later notifications can read it back. Note this is a
1854        // `RefCell`, which is single-threaded by construction: notifications
1855        // arrive sequentially from the optimizer thread. Concurrent `on_step`
1856        // calls would panic on the runtime borrow check, not share safely.
1857    }
1858
1859    /// Called when optimization completes.
1860    ///
1861    /// In `InitialAndFinal` mode, this logs the final optimized state.
1862    /// In `Iterative` mode, the final state was already logged via `on_step`.
1863    ///
1864    /// # Arguments
1865    ///
1866    /// * `values` - Final optimized variable values
1867    /// * `iterations` - Total number of iterations performed
1868    fn on_optimization_complete(
1869        &self,
1870        values: &SlotMap<VarKey, Box<dyn ManifoldVariable>>,
1871        iterations: usize,
1872    ) {
1873        if !self.is_enabled() {
1874            return;
1875        }
1876
1877        // Log final state with displacement statistics
1878        if let Err(e) = self.log_final_state(values, iterations) {
1879            let _ = e.log();
1880        }
1881    }
1882}
1883
1884impl Default for RerunObserver {
1885    fn default() -> Self {
1886        Self::new(false).unwrap_or_else(|_| Self {
1887            rec: None,
1888            enabled: false,
1889            iteration_metrics: RefCell::new(IterationMetrics::default()),
1890            config: VisualizationConfig::default(),
1891            initial_camera_positions: RefCell::new(HashMap::new()),
1892            initial_landmark_positions: RefCell::new(HashMap::new()),
1893            initial_state_logged: Cell::new(false),
1894        })
1895    }
1896}
1897
1898#[cfg(test)]
1899mod tests {
1900    use super::*;
1901
1902    type TestResult = Result<(), Box<dyn std::error::Error>>;
1903
1904    #[test]
1905    fn test_observer_creation() -> TestResult {
1906        let observer = RerunObserver::new(false)?;
1907        assert!(!observer.is_enabled());
1908        Ok(())
1909    }
1910
1911    #[test]
1912    fn test_rgb_heatmap_conversion() {
1913        let rgb = RerunObserver::value_to_rgb_heatmap(0.0, 1.0);
1914        assert_eq!(rgb, [255, 255, 255]);
1915
1916        let rgb = RerunObserver::value_to_rgb_heatmap(1.0, 1.0);
1917        assert_eq!(rgb, [0, 0, 255]);
1918
1919        let rgb = RerunObserver::value_to_rgb_heatmap(-1.0, 1.0);
1920        assert_eq!(rgb, [0, 0, 255]);
1921
1922        let rgb = RerunObserver::value_to_rgb_heatmap(0.5, 1.0);
1923        assert_eq!(rgb, [128, 128, 255]);
1924    }
1925
1926    #[test]
1927    fn test_set_metrics() -> TestResult {
1928        let observer = RerunObserver::new(false)?;
1929        observer.set_iteration_metrics(1.0, 0.5, Some(0.01), 0.1, Some(0.95));
1930
1931        let metrics = observer.iteration_metrics.borrow();
1932        assert_eq!(metrics.cost, Some(1.0));
1933        assert_eq!(metrics.gradient_norm, Some(0.5));
1934        assert_eq!(metrics.damping, Some(0.01));
1935        assert_eq!(metrics.step_norm, Some(0.1));
1936        assert_eq!(metrics.step_quality, Some(0.95));
1937        Ok(())
1938    }
1939
1940    #[test]
1941    fn test_observer_trait() -> TestResult {
1942        let observer = RerunObserver::new(false)?;
1943        let values: SlotMap<VarKey, Box<dyn ManifoldVariable>> = SlotMap::with_key();
1944
1945        // Should not panic when disabled
1946        observer.on_step(&values, 0);
1947        observer.on_step(&values, 1);
1948        Ok(())
1949    }
1950}