1use 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
44const CHUNK: usize = 4_096;
46
47#[derive(Debug, Clone, Copy)]
49pub struct Correspondence {
50 pub point: Vector3<f64>,
52 pub normal: Vector3<f64>,
54 pub residual: f64,
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Observability {
61 High,
63 Medium,
65 Low,
68}
69
70impl Observability {
71 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#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct ObservabilityCriteria {
88 pub noise_sigma: f64,
90 pub tolerance: f64,
92}
93
94#[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 pub fn centre(&self) -> Vector3<f64> {
107 self.centre
108 }
109
110 pub fn radius_of_gyration(&self) -> f64 {
113 self.radius_of_gyration
114 }
115
116 pub fn used(&self) -> usize {
118 self.used
119 }
120
121 pub fn singular_values(&self) -> [f64; 6] {
123 self.values
124 }
125
126 pub fn condition_number(&self) -> f64 {
131 crate::linalg::condition_number(&self.values)
132 }
133
134 pub fn direction(&self, index: usize) -> Vector6<f64> {
136 Vector6::from_iterator((0..6).map(|axis| self.vectors[axis][index]))
137 }
138
139 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 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 pub fn component(&self, index: usize, world: Vector6<f64>) -> f64 {
182 self.direction(index).dot(&self.to_normalised(world))
183 }
184
185 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 pub fn classify(&self, criteria: &ObservabilityCriteria) -> [Observability; 6] {
207 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 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#[derive(Debug, Clone)]
241pub struct Analysis {
242 pub conditioning: Conditioning,
244 pub sandwich: Option<Matrix6<f64>>,
249 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
263fn 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
291pub 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 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 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 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 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 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 #[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 #[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}