rigidity_scenes/lib.rs
1//! Synthetic scenes with analytically known null spaces.
2//!
3//! These are the project's measuring standard. The degeneracy detector is
4//! not judged by eye but against an answer derived by hand: for every
5//! scene it is known in advance how many degrees of freedom the geometry
6//! fails to determine, and which ones.
7//!
8//! # Convention
9//!
10//! Null spaces are written in `ξ = [ρ; φ]` coordinates and refer to
11//! rotation about the **coordinate origin**. Scenes are built in a
12//! canonical pose where the answer takes a simple form. Moving a scene by
13//! a transform `T` maps the null space to `Adj(T)·v` — checked by its own
14//! test, which incidentally confirms the adjoint implementation.
15//!
16//! # What "known null space" means
17//!
18//! A row of the point-to-plane Jacobian is `[nᵀ | (p × n)ᵀ]`. A vector `v`
19//! is unobservable when `[nᵀ | (p × n)ᵀ]·v = 0` for **every** point of the
20//! scene. That condition is solved by hand below for each surface.
21
22pub mod rng;
23mod surfaces;
24
25use nalgebra::{Vector3, Vector6};
26use rigidity_core::PointCloud;
27
28pub use surfaces::SceneKind;
29
30/// Generation parameters.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct SceneParams {
33 /// Points per face (or over the whole surface, for the cylinder and
34 /// the sphere).
35 pub points_per_face: usize,
36 /// Characteristic size of the scene, metres.
37 pub scale: f64,
38 /// Standard deviation of isotropic Gaussian noise on positions.
39 ///
40 /// Isotropic rather than along the normal: the latter is a model that
41 /// happens to suit point-to-plane, and is less honest about a real
42 /// sensor.
43 pub noise_sigma: f64,
44 /// Fraction of outliers among the points.
45 pub outlier_ratio: f64,
46 /// How far an outlier strays from the surface, in units of `scale`.
47 pub outlier_extent: f64,
48 /// Generator seed.
49 pub seed: u64,
50}
51
52impl Default for SceneParams {
53 fn default() -> Self {
54 Self {
55 points_per_face: 2_000,
56 scale: 1.0,
57 noise_sigma: 0.0,
58 outlier_ratio: 0.0,
59 outlier_extent: 0.25,
60 seed: 0x1234_5678_9ABC_DEF0,
61 }
62 }
63}
64
65/// A generated scene.
66#[derive(Debug, Clone)]
67pub struct Scene {
68 /// Which surface.
69 pub kind: SceneKind,
70 /// The parameters it was built with.
71 pub params: SceneParams,
72 /// Exact point coordinates in `f64`.
73 ///
74 /// The scene is a mathematical object; the cloud is its in-memory
75 /// representation. Keeping them apart is mandatory, because `f32`
76 /// storage destroys exact identities on curved surfaces. On a cylinder
77 /// `(p × n)_z` is identically zero, yet after quantisation it becomes
78 /// about 3·10⁻⁸ of the scene scale. Analytical claims are checked
79 /// against this field; all processing runs on [`cloud`](Self::cloud).
80 pub points: Vec<Vector3<f64>>,
81 /// The points in working form: `f32` relative to an origin.
82 pub cloud: PointCloud,
83 /// The exact analytical normal at each point.
84 ///
85 /// Analytical, not estimated from neighbours: normal-estimation error
86 /// is a separate source of inaccuracy and must not be mixed into
87 /// geometric degeneracy.
88 pub normals: Vec<Vector3<f64>>,
89 /// The first `inlier_count` points lie on the surface; the rest are
90 /// outliers.
91 ///
92 /// The null-space guarantee applies to inliers only: an outlier is not
93 /// on the surface, and the declared vector does not annihilate its
94 /// Jacobian row.
95 pub inlier_count: usize,
96}
97
98impl Scene {
99 /// Builds the scene.
100 pub fn generate(kind: SceneKind, params: SceneParams) -> Self {
101 surfaces::generate(kind, params)
102 }
103
104 /// The analytical basis of the null space.
105 pub fn nullspace(&self) -> Vec<Vector6<f64>> {
106 self.kind.nullspace()
107 }
108
109 /// Dimension of the null space.
110 pub fn nullspace_dimension(&self) -> usize {
111 self.kind.nullspace_dimension()
112 }
113
114 /// Number of points.
115 pub fn len(&self) -> usize {
116 self.cloud.len()
117 }
118
119 /// Whether the scene is empty.
120 pub fn is_empty(&self) -> bool {
121 self.cloud.is_empty()
122 }
123
124 /// Splits the scene into two partially overlapping clouds.
125 ///
126 /// A fraction `overlap` of the points goes into both clouds; the rest
127 /// is divided evenly. Needed for source/target pairs in registration:
128 /// full overlap is an unrealistic and far too easy case.
129 pub fn split_with_overlap(&self, overlap: f64, seed: u64) -> (PointCloud, PointCloud) {
130 let overlap = overlap.clamp(0.0, 1.0);
131 let exclusive = (1.0 - overlap) * 0.5;
132 let mut rng = rng::Rng::new(seed);
133 let mut source = PointCloud::with_origin(self.cloud.origin());
134 let mut target = PointCloud::with_origin(self.cloud.origin());
135 for i in 0..self.cloud.len() {
136 let point = self.cloud.point(i);
137 let draw = rng.unit();
138 if draw < overlap {
139 source.push(point);
140 target.push(point);
141 } else if draw < overlap + exclusive {
142 source.push(point);
143 } else {
144 target.push(point);
145 }
146 }
147 (source, target)
148 }
149}