Skip to main content

rigidity_cli/
monte_carlo.rs

1//! Checking the predicted spread against the empirical one.
2//!
3//! # The question
4//!
5//! The spectrum of the normalised Jacobian yields a prediction: the spread
6//! of the pose along direction `i` is `σ_noise / σ'ᵢ`. Is that true?
7//!
8//! The answer comes from an experiment rather than an argument: the same
9//! scene is registered a thousand times with independent noise and a
10//! random initial guess, and the spread of the converged poses is compared
11//! with the prediction.
12//!
13//! # Why this gates everything else
14//!
15//! Until the prediction has been checked against reality, the conditioning
16//! report is a set of numbers with the right units and an unknown relation
17//! to the world. Measuring how fast such a report is produced would be
18//! premature.
19
20use nalgebra::{Vector3, Vector6};
21use rayon::prelude::*;
22use rigidity_core::PointCloud;
23use rigidity_core::icp::{IcpConfig, Kernel, register, surface};
24use rigidity_core::lie::Se3;
25use rigidity_core::normals::estimate_normals;
26use rigidity_core::observability::{
27    Conditioning, Correspondence, Observability, ObservabilityCriteria, analyse,
28};
29use rigidity_scenes::rng::Rng;
30use rigidity_scenes::{Scene, SceneKind, SceneParams};
31use rigidity_spatial::KdTree;
32
33/// Experiment parameters.
34#[derive(Debug, Clone, Copy)]
35pub struct TrialConfig {
36    /// How many independent trials.
37    pub trials: usize,
38    /// Points per face of the scene.
39    pub points_per_face: usize,
40    /// Characteristic size of the scene, metres.
41    pub scale: f64,
42    /// Standard deviation of the noise on source positions, metres.
43    pub noise_sigma: f64,
44    /// Required pose accuracy, metres, used for the classification.
45    pub tolerance: f64,
46    /// Norm of the translational part of the initial perturbation, metres.
47    pub initial_translation: f64,
48    /// Norm of the rotational part of the initial perturbation, radians.
49    pub initial_rotation: f64,
50    /// Estimate normals from neighbours instead of using analytical ones.
51    pub estimated_normals: bool,
52    /// Seed.
53    pub seed: u64,
54}
55
56impl Default for TrialConfig {
57    fn default() -> Self {
58        Self {
59            trials: 1_000,
60            points_per_face: 800,
61            scale: 1.0,
62            noise_sigma: 1e-3,
63            tolerance: 1e-4,
64            initial_translation: 0.02,
65            initial_rotation: 0.01,
66            estimated_normals: false,
67            seed: 0x7E57_5EED,
68        }
69    }
70}
71
72/// The outcome for a single direction of the spectrum.
73#[derive(Debug, Clone, Copy)]
74pub struct DirectionOutcome {
75    /// Direction index, ordered by decreasing singular value.
76    pub index: usize,
77    /// The predicted spread `σ_noise / σ'ᵢ`, metres.
78    pub predicted: f64,
79    /// The empirical spread of the converged poses, metres.
80    pub empirical: f64,
81    /// The systematic bias, metres.
82    pub bias: f64,
83    /// How the report classified this direction.
84    pub observability: Observability,
85}
86
87impl DirectionOutcome {
88    /// By what factor the empirical spread exceeds the predicted one.
89    ///
90    /// Greater than one means the prediction understates the spread, that
91    /// is, the system is overconfident. That is what the CELLO-3D
92    /// experience leads one to expect.
93    pub fn ratio(&self) -> f64 {
94        self.empirical / self.predicted
95    }
96}
97
98/// The outcome for one scene.
99#[derive(Debug, Clone)]
100pub struct SceneOutcome {
101    /// Which scene.
102    pub kind: SceneKind,
103    /// Whether normals were estimated or taken analytically.
104    pub estimated_normals: bool,
105    /// How many trials converged by the step criterion.
106    pub converged: usize,
107    /// Total trials.
108    pub trials: usize,
109    /// The scene's condition number.
110    pub condition_number: f64,
111    /// The six directions.
112    pub directions: Vec<DirectionOutcome>,
113}
114
115/// A random unit direction.
116fn random_direction(rng: &mut Rng) -> Vector3<f64> {
117    loop {
118        let candidate = Vector3::new(rng.normal(1.0), rng.normal(1.0), rng.normal(1.0));
119        if candidate.norm() > 1e-9 {
120            return candidate.normalize();
121        }
122    }
123}
124
125fn build_normals(
126    cloud: &PointCloud,
127    analytic: &[Vector3<f64>],
128    estimate: bool,
129) -> Vec<Vector3<f64>> {
130    if estimate {
131        let tree = KdTree::build(cloud).expect("the tree builds");
132        estimate_normals(cloud, &tree, 16)
133    } else {
134        analytic.to_vec()
135    }
136}
137
138/// Runs the experiment on a single scene.
139pub fn run(kind: SceneKind, config: &TrialConfig) -> SceneOutcome {
140    let scene = Scene::generate(
141        kind,
142        SceneParams {
143            points_per_face: config.points_per_face,
144            scale: config.scale,
145            ..SceneParams::default()
146        },
147    );
148
149    let target_normals = build_normals(&scene.cloud, &scene.normals, config.estimated_normals);
150    let tree = KdTree::build(&scene.cloud).expect("the tree builds");
151
152    // The prediction is built from the unperturbed target: this is what a
153    // user would get by looking at the map before scanning.
154    let prediction = analyse(scene.len(), Kernel::Squared, |index| {
155        Some(Correspondence {
156            point: scene.cloud.point(index),
157            normal: target_normals[index],
158            residual: 0.0,
159        })
160    })
161    .expect("the scene is non-empty");
162    let conditioning: &Conditioning = &prediction.conditioning;
163
164    let criteria = ObservabilityCriteria {
165        noise_sigma: config.noise_sigma,
166        tolerance: config.tolerance,
167    };
168    let states = conditioning.classify(&criteria);
169    let predicted = conditioning.uncertainty(config.noise_sigma);
170
171    let truth = Se3::exp(&Vector6::new(
172        0.031 * config.scale,
173        -0.017 * config.scale,
174        0.024 * config.scale,
175        0.021,
176        -0.013,
177        0.018,
178    ));
179    let icp = IcpConfig {
180        kernel: Kernel::Squared,
181        max_correspondence_distance: 0.5 * config.scale,
182        max_iterations: 60,
183        ..IcpConfig::default()
184    };
185
186    let outcomes: Vec<Option<([f64; 6], bool)>> = (0..config.trials)
187        .into_par_iter()
188        .map(|trial| {
189            let mut rng = Rng::new(config.seed ^ (trial as u64).wrapping_mul(0x9E37_79B9));
190
191            // The source: the same surface with independent noise, seen
192            // from a different position.
193            let inverse = truth.inverse();
194            let rotation = *inverse.rotation().matrix();
195            let mut source = PointCloud::with_capacity(scene.len());
196            for index in 0..scene.len() {
197                let jitter = Vector3::new(
198                    rng.normal(config.noise_sigma),
199                    rng.normal(config.noise_sigma),
200                    rng.normal(config.noise_sigma),
201                );
202                source.push(inverse.transform_point(&(scene.points[index] + jitter)));
203            }
204            let source_normals = if config.estimated_normals {
205                let source_tree = KdTree::build(&source).ok()?;
206                estimate_normals(&source, &source_tree, 16)
207            } else {
208                scene.normals.iter().map(|n| rotation * n).collect()
209            };
210
211            let offset = random_direction(&mut rng) * config.initial_translation * config.scale;
212            let turn = random_direction(&mut rng) * config.initial_rotation;
213            let start = Se3::exp(&Vector6::new(
214                offset.x, offset.y, offset.z, turn.x, turn.y, turn.z,
215            )) * truth;
216
217            let result = register(
218                &surface(&source, &source_normals),
219                &surface(&scene.cloud, &target_normals),
220                &tree,
221                start,
222                &icp,
223            );
224
225            // The error as a left perturbation: `T_true = exp(δ)·T_found`.
226            let error = (truth * result.pose.inverse()).log();
227            let mut components = [0.0f64; 6];
228            for (index, slot) in components.iter_mut().enumerate() {
229                *slot = conditioning.component(index, error);
230            }
231            if components.iter().any(|v| !v.is_finite()) {
232                return None;
233            }
234            Some((components, result.converged))
235        })
236        .collect();
237
238    let samples: Vec<[f64; 6]> = outcomes.iter().flatten().map(|(c, _)| *c).collect();
239    let converged = outcomes.iter().flatten().filter(|(_, ok)| *ok).count();
240    let count = samples.len().max(1) as f64;
241
242    let directions = (0..6)
243        .map(|index| {
244            let mean: f64 = samples.iter().map(|c| c[index]).sum::<f64>() / count;
245            let variance: f64 = samples
246                .iter()
247                .map(|c| (c[index] - mean) * (c[index] - mean))
248                .sum::<f64>()
249                / count;
250            DirectionOutcome {
251                index,
252                predicted: predicted[index],
253                empirical: variance.sqrt(),
254                bias: mean,
255                observability: states[index],
256            }
257        })
258        .collect();
259
260    SceneOutcome {
261        kind,
262        estimated_normals: config.estimated_normals,
263        converged,
264        trials: samples.len(),
265        condition_number: conditioning.condition_number(),
266        directions,
267    }
268}
269
270/// Runs every scene.
271pub fn run_all(config: &TrialConfig) -> Vec<SceneOutcome> {
272    SceneKind::ALL
273        .iter()
274        .map(|kind| run(*kind, config))
275        .collect()
276}