Skip to main content

rigidity_core/
observability.rs

1//! Conditioning of the registration problem and observability of the six
2//! degrees of freedom.
3//!
4//! # What is computed here, and what is not
5//!
6//! What is computed is **conditioning**: how firmly the geometry of the
7//! scene pins down each of the six motions. What is *not* computed is a
8//! calibrated pose uncertainty — Censi's closed form understates real
9//! spread by orders of magnitude. That is why the field is called
10//! `conditioning` and not `covariance`.
11//!
12//! # The normalisation without which the answer is meaningless
13//!
14//! The Jacobian's columns carry different units: `∂e/∂ρ = n` is
15//! dimensionless while `∂e/∂φ = p × n` is measured in metres. Singular
16//! values of such a matrix are not comparable with one another, and
17//! singular vectors that mix `ρ` and `φ` depend on the choice of units — a
18//! verdict of "rotation about Z is degenerate" can flip when metres become
19//! millimetres.
20//!
21//! The cure is a change of variable: `ξ' = [ρ; r_g·φ]`, where `r_g` is the
22//! radius of gyration of the points about the centre of rotation. All six
23//! coordinates are then in metres: one unit along a rotational axis means
24//! one metre of displacement for a point at the characteristic distance.
25//! The Jacobian in the new variables is `J` with its last three columns
26//! divided by `r_g`, which is `S⁻¹HS⁻¹` without ever forming `H`.
27//!
28//! # Centre of rotation
29//!
30//! The report is built about the **centroid of the correspondences**,
31//! not about the coordinate origin. Otherwise the same scene, referred to
32//! a distant origin, would get a different condition number: `r_g` would
33//! measure how far away the scene is rather than how large it is. The
34//! decomposition into degrees of freedom genuinely depends on the choice
35//! of centre, so the centre is part of the report.
36
37use nalgebra::{Matrix6, Vector3, Vector6};
38use rayon::prelude::*;
39
40use crate::icp::{Kernel, point_to_plane_row};
41use crate::lie::{Se3, So3};
42use crate::linalg::{decompose, reduce};
43
44/// Block size of the deterministic reduction used for the sums.
45const CHUNK: usize = 4_096;
46
47/// A single point-to-plane correspondence.
48#[derive(Debug, Clone, Copy)]
49pub struct Correspondence {
50    /// The source point **after** the current pose has been applied.
51    pub point: Vector3<f64>,
52    /// The surface normal of the target.
53    pub normal: Vector3<f64>,
54    /// The residual `nᵀ(p − q)`.
55    pub residual: f64,
56}
57
58/// How reliably a degree of freedom is determined.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Observability {
61    /// The spread fits within the required accuracy.
62    High,
63    /// The spread exceeds the required accuracy, but by less than tenfold.
64    Medium,
65    /// The spread exceeds the required accuracy by more than an order of
66    /// magnitude.
67    Low,
68}
69
70impl Observability {
71    /// A short label for the report.
72    pub fn label(self) -> &'static str {
73        match self {
74            Self::High => "HIGH",
75            Self::Medium => "MEDIUM",
76            Self::Low => "LOW",
77        }
78    }
79}
80
81/// What counts as reliable.
82///
83/// A degeneracy threshold without a required accuracy is meaningless:
84/// 5 mm of spread is excellent for a mobile robot and catastrophic for a
85/// welding cell.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct ObservabilityCriteria {
88    /// Standard deviation of measurement noise along the normal, metres.
89    pub noise_sigma: f64,
90    /// Required pose accuracy, metres.
91    pub tolerance: f64,
92}
93
94/// Conditioning of the problem.
95#[derive(Debug, Clone)]
96pub struct Conditioning {
97    centre: Vector3<f64>,
98    radius_of_gyration: f64,
99    used: usize,
100    values: [f64; 6],
101    vectors: [[f64; 6]; 6],
102}
103
104impl Conditioning {
105    /// The centre of rotation the report refers to.
106    pub fn centre(&self) -> Vector3<f64> {
107        self.centre
108    }
109
110    /// The radius of gyration: the characteristic length of the
111    /// normalisation.
112    pub fn radius_of_gyration(&self) -> f64 {
113        self.radius_of_gyration
114    }
115
116    /// How many correspondences took part.
117    pub fn used(&self) -> usize {
118        self.used
119    }
120
121    /// The normalised singular values, in decreasing order.
122    pub fn singular_values(&self) -> [f64; 6] {
123        self.values
124    }
125
126    /// The condition number `σ_max / σ_min`.
127    ///
128    /// Invariant to the scale of the scene and to the choice of units,
129    /// which is exactly what the normalisation is for.
130    pub fn condition_number(&self) -> f64 {
131        crate::linalg::condition_number(&self.values)
132    }
133
134    /// Direction number `index` in the normalised coordinates `ξ'`.
135    pub fn direction(&self, index: usize) -> Vector6<f64> {
136        Vector6::from_iterator((0..6).map(|axis| self.vectors[axis][index]))
137    }
138
139    /// The same direction in the original coordinates `ξ = [ρ; φ]`,
140    /// referred to the coordinate origin.
141    ///
142    /// Undoes the normalisation (`φ` is divided by `r_g`) and moves the
143    /// centre of rotation from the centroid back to the origin through the
144    /// adjoint of that translation.
145    pub fn direction_in_world(&self, index: usize) -> Vector6<f64> {
146        let normalised = self.direction(index);
147        let unscaled = Vector6::new(
148            normalised[0],
149            normalised[1],
150            normalised[2],
151            normalised[3] / self.radius_of_gyration,
152            normalised[4] / self.radius_of_gyration,
153            normalised[5] / self.radius_of_gyration,
154        );
155        let shift = Se3::from_parts(So3::identity(), self.centre);
156        shift.adjoint() * unscaled
157    }
158
159    /// Converts a pose increment from world coordinates `ξ = [ρ; φ]` into
160    /// the normalised `ξ'` the spectrum is expressed in.
161    ///
162    /// The inverse of [`direction_in_world`](Self::direction_in_world):
163    /// first move the centre of rotation to the centroid, then scale the
164    /// rotational part by the radius of gyration. Needed to compare a
165    /// predicted spread against an empirical one — they must live in the
166    /// same coordinates.
167    pub fn to_normalised(&self, world: Vector6<f64>) -> Vector6<f64> {
168        let shift = Se3::from_parts(So3::identity(), -self.centre);
169        let centred = shift.adjoint() * world;
170        Vector6::new(
171            centred[0],
172            centred[1],
173            centred[2],
174            centred[3] * self.radius_of_gyration,
175            centred[4] * self.radius_of_gyration,
176            centred[5] * self.radius_of_gyration,
177        )
178    }
179
180    /// Projection of a pose increment onto direction `index`, metres.
181    pub fn component(&self, index: usize, world: Vector6<f64>) -> f64 {
182        self.direction(index).dot(&self.to_normalised(world))
183    }
184
185    /// Standard deviation along each direction, metres.
186    ///
187    /// `σ_noise / σ'ᵢ`. All six values share units: one unit of a
188    /// normalised coordinate is one metre of displacement, for rotations
189    /// too, at distance `r_g` from the centre.
190    ///
191    /// The point count is accounted for automatically: `σ'ᵢ` grows as the
192    /// square root of the sum of weights, so the spread falls as `1/√N`.
193    pub fn uncertainty(&self, noise_sigma: f64) -> [f64; 6] {
194        let mut result = [f64::INFINITY; 6];
195        for (slot, value) in result.iter_mut().zip(self.values.iter()) {
196            *slot = if *value > 0.0 {
197                noise_sigma / value
198            } else {
199                f64::INFINITY
200            };
201        }
202        result
203    }
204
205    /// Classification of the six degrees of freedom.
206    pub fn classify(&self, criteria: &ObservabilityCriteria) -> [Observability; 6] {
207        /// By what factor the spread must exceed the tolerance before a
208        /// degree of freedom counts as lost rather than merely weak.
209        const MARGINAL_FACTOR: f64 = 10.0;
210
211        let mut result = [Observability::Low; 6];
212        for (slot, spread) in result
213            .iter_mut()
214            .zip(self.uncertainty(criteria.noise_sigma).iter())
215        {
216            *slot = if *spread < criteria.tolerance {
217                Observability::High
218            } else if *spread < MARGINAL_FACTOR * criteria.tolerance {
219                Observability::Medium
220            } else {
221                Observability::Low
222            };
223        }
224        result
225    }
226
227    /// Directions judged unobservable, in `ξ` coordinates relative to the
228    /// origin.
229    pub fn unobservable_directions(&self, criteria: &ObservabilityCriteria) -> Vec<Vector6<f64>> {
230        self.classify(criteria)
231            .iter()
232            .enumerate()
233            .filter(|(_, state)| **state == Observability::Low)
234            .map(|(index, _)| self.direction_in_world(index))
235            .collect()
236    }
237}
238
239/// The full result of the analysis.
240#[derive(Debug, Clone)]
241pub struct Analysis {
242    /// The conditioning.
243    pub conditioning: Conditioning,
244    /// The sandwich covariance estimate, in normalised coordinates.
245    ///
246    /// `None` when the weighted matrix is singular and cannot be inverted
247    /// — which is precisely the case this project exists for.
248    pub sandwich: Option<Matrix6<f64>>,
249    /// The naive estimate `σ̂²·H⁻¹`, for comparison.
250    pub naive: Option<Matrix6<f64>>,
251}
252
253fn triangle_to_matrix(triangle: &[[f64; 6]; 6]) -> Matrix6<f64> {
254    let mut matrix = Matrix6::zeros();
255    for (i, row) in triangle.iter().enumerate() {
256        for (j, value) in row.iter().enumerate() {
257            matrix[(i, j)] = *value;
258        }
259    }
260    matrix
261}
262
263/// A deterministic sum over fixed-size blocks.
264fn chunked_sum<T, F, C>(count: usize, zero: T, add: F, combine: C) -> T
265where
266    T: Copy + Send + Sync,
267    F: Fn(usize, T) -> T + Sync,
268    C: Fn(T, T) -> T,
269{
270    if count == 0 {
271        return zero;
272    }
273    let chunks = count.div_ceil(CHUNK);
274    let partials: Vec<T> = (0..chunks)
275        .into_par_iter()
276        .map(|chunk| {
277            let begin = chunk * CHUNK;
278            let end = ((chunk + 1) * CHUNK).min(count);
279            let mut accumulator = zero;
280            for index in begin..end {
281                accumulator = add(index, accumulator);
282            }
283            accumulator
284        })
285        .collect();
286    partials
287        .iter()
288        .fold(zero, |total, part| combine(total, *part))
289}
290
291/// Analyses a set of correspondences.
292///
293/// Returns `None` when no correspondence survived, in which case there is
294/// nothing to say.
295pub fn analyse<F>(count: usize, kernel: Kernel, correspondence: F) -> Option<Analysis>
296where
297    F: Fn(usize) -> Option<Correspondence> + Sync,
298{
299    let weight_of = |index: usize| {
300        correspondence(index).map(|item| {
301            let weight = kernel.weight(item.residual);
302            (item, weight)
303        })
304    };
305
306    // Centroid of the correspondences.
307    let (weight_sum, moment, used) = chunked_sum(
308        count,
309        (0.0f64, Vector3::zeros(), 0usize),
310        |index, (weight_sum, moment, used)| match weight_of(index) {
311            Some((item, weight)) => (weight_sum + weight, moment + item.point * weight, used + 1),
312            None => (weight_sum, moment, used),
313        },
314        |a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2),
315    );
316    if used == 0 || weight_sum <= 0.0 {
317        return None;
318    }
319    let centre = moment / weight_sum;
320
321    // Radius of gyration in a separate pass, not through
322    // `Σw|p|² − |c|²`. That expression subtracts nearly equal numbers when
323    // the scene sits far from the origin — precisely the case the centroid
324    // was introduced for.
325    let spread = chunked_sum(
326        count,
327        0.0f64,
328        |index, total| match weight_of(index) {
329            Some((item, weight)) => total + weight * (item.point - centre).norm_squared(),
330            None => total,
331        },
332        |a, b| a + b,
333    );
334    let radius_of_gyration = (spread / weight_sum).sqrt();
335    if !radius_of_gyration.is_finite() || radius_of_gyration <= 0.0 {
336        return None;
337    }
338
339    // A row of the normalised Jacobian: its last three columns divided by
340    // the characteristic length.
341    let normalised_row = |item: &Correspondence| {
342        let row = point_to_plane_row(&(item.point - centre), &item.normal);
343        [
344            row[0],
345            row[1],
346            row[2],
347            row[3] / radius_of_gyration,
348            row[4] / radius_of_gyration,
349            row[5] / radius_of_gyration,
350        ]
351    };
352
353    let weighted = reduce::<6, _>(count, |index| {
354        weight_of(index).map(|(item, weight)| {
355            let scale = weight.sqrt();
356            let row = normalised_row(&item);
357            [
358                row[0] * scale,
359                row[1] * scale,
360                row[2] * scale,
361                row[3] * scale,
362                row[4] * scale,
363                row[5] * scale,
364            ]
365        })
366    });
367
368    let decomposition = decompose(weighted.triangle());
369    let conditioning = Conditioning {
370        centre,
371        radius_of_gyration,
372        used,
373        values: decomposition.values,
374        vectors: decomposition.vectors,
375    };
376
377    // The sandwich: `H⁻¹·M·H⁻¹` with `M = Σ ψ(e)²·JJᵀ`.
378    //
379    // The bread uses the IRLS weights rather than `ψ'`. This is the
380    // standard approximation, and it has a reason: for redescending
381    // kernels `ψ'` changes sign, the exact matrix stops being positive
382    // semi-definite, and it cannot be decomposed the same way through
383    // TSQR.
384    let meat_triangle = reduce::<6, _>(count, |index| {
385        weight_of(index).map(|(item, weight)| {
386            let scale = (weight * item.residual).abs();
387            let row = normalised_row(&item);
388            [
389                row[0] * scale,
390                row[1] * scale,
391                row[2] * scale,
392                row[3] * scale,
393                row[4] * scale,
394                row[5] * scale,
395            ]
396        })
397    });
398
399    let bread = triangle_to_matrix(weighted.triangle());
400    let hessian = bread.transpose() * bread;
401    let meat_upper = triangle_to_matrix(meat_triangle.triangle());
402    let meat = meat_upper.transpose() * meat_upper;
403
404    let (sandwich, naive) = match hessian.try_inverse() {
405        Some(inverse) => {
406            let residual_energy = chunked_sum(
407                count,
408                0.0f64,
409                |index, total| match weight_of(index) {
410                    Some((item, weight)) => total + weight * item.residual * item.residual,
411                    None => total,
412                },
413                |a, b| a + b,
414            );
415            let degrees = (used as f64 - 6.0).max(1.0);
416            let variance = residual_energy / degrees;
417            (Some(inverse * meat * inverse), Some(inverse * variance))
418        }
419        None => (None, None),
420    };
421
422    Some(Analysis {
423        conditioning,
424        sandwich,
425        naive,
426    })
427}
428
429impl Analysis {
430    /// A human-readable report.
431    pub fn describe(&self, criteria: &ObservabilityCriteria) -> String {
432        let conditioning = &self.conditioning;
433        let states = conditioning.classify(criteria);
434        let spread = conditioning.uncertainty(criteria.noise_sigma);
435        let names = ["σ₁", "σ₂", "σ₃", "σ₄", "σ₅", "σ₆"];
436
437        let mut text = String::new();
438        text.push_str(&format!(
439            "correspondences:   {}\n\
440             centre of rotation: [{:.3}, {:.3}, {:.3}] m\n\
441             radius of gyration: {:.3} m\n\
442             condition number:   {:.4e}\n\n",
443            conditioning.used(),
444            conditioning.centre().x,
445            conditioning.centre().y,
446            conditioning.centre().z,
447            conditioning.radius_of_gyration(),
448            conditioning.condition_number(),
449        ));
450        for index in 0..6 {
451            let direction = conditioning.direction_in_world(index);
452            text.push_str(&format!(
453                "{}  spread {:>10.3e} m  {:<6}  ρ=[{:+.2} {:+.2} {:+.2}] φ=[{:+.2} {:+.2} {:+.2}]\n",
454                names[index],
455                spread[index],
456                states[index].label(),
457                direction[0],
458                direction[1],
459                direction[2],
460                direction[3],
461                direction[4],
462                direction[5],
463            ));
464        }
465        if states.contains(&Observability::Low) {
466            text.push_str(
467                "\nwarning: some degrees of freedom are not determined by the geometry\n",
468            );
469        }
470        text
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    fn plane_analysis() -> Analysis {
479        analyse(400, Kernel::Squared, |index| {
480            let x = (index % 20) as f64 * 0.1 - 1.0;
481            let y = (index / 20) as f64 * 0.1 - 1.0;
482            Some(Correspondence {
483                point: Vector3::new(x, y, 0.0),
484                normal: Vector3::z(),
485                residual: 0.0,
486            })
487        })
488        .unwrap()
489    }
490
491    /// The coordinate conversions are mutual inverses.
492    #[test]
493    fn normalised_and_world_coordinates_round_trip() {
494        let analysis = plane_analysis();
495        let conditioning = &analysis.conditioning;
496        for index in 0..6 {
497            let world = conditioning.direction_in_world(index);
498            let back = conditioning.to_normalised(world);
499            let expected = conditioning.direction(index);
500            assert!(
501                (back - expected).norm() < 1e-12,
502                "direction {index}: mismatch {:.3e}",
503                (back - expected).norm()
504            );
505        }
506    }
507
508    /// A plane has exactly three unobservable directions, with zero `σ`.
509    #[test]
510    fn plane_loses_three_degrees_of_freedom() {
511        let values = plane_analysis().conditioning.singular_values();
512        assert!(values[2] > 1.0, "third value {}", values[2]);
513        for value in values.iter().skip(3) {
514            assert!(*value < 1e-12, "residual value {value:.3e}");
515        }
516    }
517}