rigidity-scenes 0.1.0

Генератор синтетических сцен с аналитически известным нуль-пространством.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! Checking the analytical null spaces.
//!
//! Every claim in the documentation of [`SceneKind::nullspace`] is checked
//! numerically. A hand derivation without such a check is a comment, not a
//! standard: getting the sign of a cross product wrong is easy, and the
//! mistake would migrate quietly into the conditioning analysis, where
//! nothing is left to give it away.

use nalgebra::{Matrix6, Vector3, Vector6};
use rigidity_core::icp::point_to_plane_row;
use rigidity_core::lie::Se3;
use rigidity_scenes::{Scene, SceneKind, SceneParams};

/// The largest `row · v` in absolute value over the first `count` points,
/// divided by the scale of the scene.
///
/// The normalisation is needed because the row contains the moment
/// `p × n`, which grows with the size of the scene: an absolute tolerance
/// would mean different things for a corridor and for a plane.
fn violation(points: &[Vector3<f64>], normals: &[Vector3<f64>], v: &Vector6<f64>) -> f64 {
    let mut extent: f64 = 0.0;
    let mut worst: f64 = 0.0;
    for (point, normal) in points.iter().zip(normals) {
        extent = extent.max(point.norm());
        worst = worst.max(point_to_plane_row(point, normal).dot(v).abs());
    }
    worst / (1.0 + extent)
}

/// The violation on exact coordinates: what the mathematics claims.
fn exact_violation(scene: &Scene, v: &Vector6<f64>, count: usize) -> f64 {
    violation(&scene.points[..count], &scene.normals[..count], v)
}

/// The violation on the cloud: what processing sees after `f32`
/// quantisation.
fn stored_violation(scene: &Scene, v: &Vector6<f64>, count: usize) -> f64 {
    let points: Vec<Vector3<f64>> = (0..count).map(|i| scene.cloud.point(i)).collect();
    violation(&points, &scene.normals[..count], v)
}

/// `JᵀJ` over the inliers.
///
/// The matrix is assembled explicitly, which is acceptable here: what is
/// checked is a known answer with generous margin, not the detection of a
/// small eigenvalue. TSQR exists for the latter.
fn information_matrix(scene: &Scene) -> Matrix6<f64> {
    let mut h = Matrix6::zeros();
    for i in 0..scene.inlier_count {
        let row = point_to_plane_row(&scene.points[i], &scene.normals[i]);
        h += row * row.transpose();
    }
    h
}

fn sorted_eigenvalues(h: &Matrix6<f64>) -> Vec<f64> {
    let mut values: Vec<f64> = nalgebra::SymmetricEigen::new(*h)
        .eigenvalues
        .iter()
        .copied()
        .collect();
    values.sort_by(f64::total_cmp);
    values
}

fn noiseless(kind: SceneKind) -> Scene {
    Scene::generate(
        kind,
        SceneParams {
            points_per_face: 1_500,
            ..SceneParams::default()
        },
    )
}

/// The null-space dimensions, written out explicitly.
///
/// The table duplicates `SceneKind::nullspace`, deliberately: the test
/// must break if someone changes the implementation without changing the
/// claim.
#[test]
fn nullspace_dimensions_are_as_documented() {
    let expected = [
        (SceneKind::Plane, 3),
        (SceneKind::Cylinder, 2),
        (SceneKind::Sphere, 3),
        (SceneKind::TwoPlanes, 1),
        (SceneKind::Corner, 0),
        (SceneKind::TeeJoint, 1),
        (SceneKind::Corridor, 1),
    ];
    assert_eq!(expected.len(), SceneKind::ALL.len());
    for (kind, dimension) in expected {
        assert_eq!(
            kind.nullspace_dimension(),
            dimension,
            "{}: expected dimension {dimension}",
            kind.name()
        );
    }
}

#[test]
fn nullspace_basis_is_orthonormal() {
    for kind in SceneKind::ALL {
        let basis = kind.nullspace();
        for (i, a) in basis.iter().enumerate() {
            assert!(
                (a.norm() - 1.0).abs() < 1e-15,
                "{}: vector {i} is not a unit vector",
                kind.name()
            );
            for b in basis.iter().skip(i + 1) {
                assert!(
                    a.dot(b).abs() < 1e-15,
                    "{}: the basis is not orthogonal",
                    kind.name()
                );
            }
        }
    }
}

/// The declared vectors annihilate the Jacobian row at every point.
///
/// That is the definition of an unobservable direction: moving along it
/// changes no residual, so the optimisation learns nothing about it.
#[test]
fn declared_nullspace_annihilates_every_row() {
    for kind in SceneKind::ALL {
        let scene = noiseless(kind);
        for (index, v) in scene.nullspace().iter().enumerate() {
            let violation = exact_violation(&scene, v, scene.inlier_count);
            assert!(
                violation < 1e-14,
                "{}: vector {index} leaves a residual of {violation:.3e}",
                kind.name()
            );
        }
    }
}

/// The spectrum of `JᵀJ` confirms the dimension: exactly `k` zero
/// eigenvalues, with the `k+1`-th firmly away from zero.
///
/// The second half matters as much as the first. It catches the case where
/// a scene is degenerate **more** than claimed — if, say, the parameters
/// made the corridor so narrow that lateral translation stopped being
/// observed too.
#[test]
fn spectrum_confirms_nullspace_dimension() {
    for kind in SceneKind::ALL {
        let scene = noiseless(kind);
        let eigenvalues = sorted_eigenvalues(&information_matrix(&scene));
        let largest = *eigenvalues.last().unwrap();
        let dimension = kind.nullspace_dimension();

        for (i, value) in eigenvalues.iter().take(dimension).enumerate() {
            assert!(
                value / largest < 1e-12,
                "{}: eigenvalue {i} is {value:.3e} against a maximum of \
                 {largest:.3e} — the null space is smaller than claimed",
                kind.name()
            );
        }
        if dimension < 6 {
            let first_observable = eigenvalues[dimension];
            assert!(
                first_observable / largest > 1e-3,
                "{}: the smallest observable eigenvalue {first_observable:.3e} is \
                 too small against a maximum of {largest:.3e} — the scene is more \
                 degenerate than claimed",
                kind.name()
            );
        }
    }
}

/// Moving the scene maps the null space to `Adj(T)·v`.
///
/// The test ties the Lie-group code to the scene geometry: it breaks on an
/// error in either. It also pins down that the canonical answers are tied
/// to the coordinate origin rather than being absolute.
#[test]
fn nullspace_transforms_by_adjoint() {
    let motion = Se3::exp(&Vector6::new(1.7, -0.4, 2.3, 0.6, -0.35, 0.9));
    let adjoint = motion.adjoint();

    for kind in SceneKind::ALL {
        let scene = noiseless(kind);
        let rotation = *motion.rotation().matrix();

        for (index, v) in scene.nullspace().iter().enumerate() {
            let moved = adjoint * v;
            let mut extent: f64 = 0.0;
            let mut worst: f64 = 0.0;
            for i in 0..scene.inlier_count {
                let point = motion.transform_point(&scene.points[i]);
                let normal = rotation * scene.normals[i];
                extent = extent.max(point.norm());
                worst = worst.max(point_to_plane_row(&point, &normal).dot(&moved).abs());
            }
            let violation = worst / (1.0 + extent);
            assert!(
                violation < 1e-13,
                "{}: the transported vector {index} leaves a residual of {violation:.3e}",
                kind.name()
            );
        }
    }
}

/// Noise degrades degeneracy smoothly rather than abruptly.
///
/// For planar scenes the null space stays exact even under noise: it
/// consists of translations, and the normals are analytical. On the
/// cylinder and the sphere a term of order σ appears, from `ε × n`.
#[test]
fn noise_degrades_nullspace_proportionally() {
    let sigma = 1e-3;
    for kind in SceneKind::ALL {
        let scene = Scene::generate(
            kind,
            SceneParams {
                points_per_face: 1_500,
                noise_sigma: sigma,
                ..SceneParams::default()
            },
        );
        for (index, v) in scene.nullspace().iter().enumerate() {
            let violation = exact_violation(&scene, v, scene.inlier_count);
            assert!(
                violation < 12.0 * sigma,
                "{}: vector {index} at σ = {sigma:.0e} gives {violation:.3e} — \
                 the degradation is not proportional to the noise",
                kind.name()
            );
        }
    }
}

/// Outliers destroy the null space, and precisely because they bring a
/// wrong normal with them.
///
/// A point displaced along its normal does not change the Jacobian row at
/// all: `(p + δn) × n = p × n`. Degeneracy is a property of the Jacobian,
/// not of the residuals, so modelling an outlier as a displacement along
/// the normal would be pointless.
#[test]
fn outliers_with_wrong_normals_destroy_the_nullspace() {
    for kind in SceneKind::ALL {
        if kind.nullspace_dimension() == 0 {
            continue;
        }
        let scene = Scene::generate(
            kind,
            SceneParams {
                points_per_face: 1_500,
                outlier_ratio: 0.1,
                ..SceneParams::default()
            },
        );
        assert!(scene.len() > scene.inlier_count, "no outliers were added");

        let v = &scene.nullspace()[0];
        let clean = exact_violation(&scene, v, scene.inlier_count);
        let polluted = exact_violation(&scene, v, scene.len());
        assert!(clean < 1e-14, "{}: the inliers are corrupted", kind.name());
        assert!(
            polluted > 1e-2,
            "{}: the outliers did not break the null space ({polluted:.3e})",
            kind.name()
        );
    }
}

/// One seed, one scene, bit for bit.
#[test]
fn generation_is_reproducible() {
    for kind in SceneKind::ALL {
        let params = SceneParams {
            points_per_face: 300,
            noise_sigma: 1e-3,
            outlier_ratio: 0.05,
            ..SceneParams::default()
        };
        let a = Scene::generate(kind, params);
        let b = Scene::generate(kind, params);
        let (ax, ay, az) = a.cloud.columns();
        let (bx, by, bz) = b.cloud.columns();
        for (left, right) in [(ax, bx), (ay, by), (az, bz)] {
            let left: Vec<u32> = left.iter().map(|v| v.to_bits()).collect();
            let right: Vec<u32> = right.iter().map(|v| v.to_bits()).collect();
            assert_eq!(
                left,
                right,
                "{}: a repeat produced a different cloud",
                kind.name()
            );
        }

        let other = Scene::generate(
            kind,
            SceneParams {
                seed: params.seed ^ 0xFFFF,
                ..params
            },
        );
        assert_ne!(
            other.cloud.point(0),
            a.cloud.point(0),
            "{}: a different seed produced the same scene",
            kind.name()
        );
    }
}

/// The normals are unit vectors and agree with the surface.
#[test]
fn normals_are_unit_and_consistent() {
    for kind in SceneKind::ALL {
        let scene = noiseless(kind);
        assert_eq!(scene.normals.len(), scene.len());
        for i in 0..scene.inlier_count {
            let normal = scene.normals[i];
            assert!(
                (normal.norm() - 1.0).abs() < 1e-15,
                "{}: normal {i} is not a unit vector",
                kind.name()
            );
        }
        // On a sphere the normal must equal the radial direction.
        if kind == SceneKind::Sphere {
            for i in 0..scene.inlier_count {
                let radial = scene.cloud.point(i).normalize();
                assert!((radial - scene.normals[i]).norm() < 1e-6);
            }
        }
    }
}

/// Overlap: the fraction of shared points matches what was asked for.
#[test]
fn overlap_split_matches_requested_fraction() {
    let scene = noiseless(SceneKind::Corner);
    for overlap in [0.3f64, 0.5, 0.7, 1.0] {
        let (source, target) = scene.split_with_overlap(overlap, 0xABCD);
        let total = scene.len() as f64;
        let shared = (source.len() + target.len()) as f64 - total;
        let fraction = shared / total;
        assert!(
            (fraction - overlap).abs() < 0.05,
            "overlap {overlap}: got {fraction:.3}"
        );
    }
}

/// The scale of the scene does not change the answer: a null space is a
/// property of shape.
#[test]
fn nullspace_is_scale_invariant() {
    for kind in SceneKind::ALL {
        for scale in [1e-3, 1.0, 1e3] {
            let scene = Scene::generate(
                kind,
                SceneParams {
                    points_per_face: 500,
                    scale,
                    ..SceneParams::default()
                },
            );
            for v in scene.nullspace() {
                let violation = exact_violation(&scene, &v, scene.inlier_count);
                assert!(
                    violation < 1e-14,
                    "{} at scale {scale:e}: residual {violation:.3e}",
                    kind.name()
                );
            }
        }
    }
}

/// The points really do lie on the claimed surface.
#[test]
fn points_lie_on_their_surface() {
    let scene = noiseless(SceneKind::Cylinder);
    let radius = scene.params.scale * 0.5;
    for i in 0..scene.inlier_count {
        let p = scene.points[i];
        let distance = Vector3::new(p.x, p.y, 0.0).norm();
        assert!(
            (distance - radius).abs() < 1e-15,
            "point {i} is not on the cylinder"
        );
    }

    let scene = noiseless(SceneKind::Sphere);
    for i in 0..scene.inlier_count {
        assert!((scene.points[i].norm() - scene.params.scale).abs() < 1e-15);
    }
}

/// How much `f32` storage on its own violates the null space.
///
/// This is not a defect of the generator but the measured accuracy ceiling
/// of the whole pipeline. The number matters downstream: the degeneracy
/// detector's threshold must sit **above** it, or the detector measures
/// the storage error instead.
///
/// Planar scenes show no violation at all: their null spaces consist of
/// translations, and `n_y` and `n_z` are exact and independent of the
/// coordinates. On the cylinder and the sphere the identity `p × n = 0`
/// rests on the coordinates themselves, and quantisation breaks it.
#[test]
fn f32_storage_sets_the_precision_floor() {
    let curved = [SceneKind::Cylinder, SceneKind::Sphere];
    for kind in SceneKind::ALL {
        let scene = noiseless(kind);
        let mut worst_exact: f64 = 0.0;
        let mut worst_stored: f64 = 0.0;
        for v in scene.nullspace() {
            worst_exact = worst_exact.max(exact_violation(&scene, &v, scene.inlier_count));
            worst_stored = worst_stored.max(stored_violation(&scene, &v, scene.inlier_count));
        }
        assert!(
            worst_exact < 1e-14,
            "{}: the exact arithmetic is already inexact ({worst_exact:.3e})",
            kind.name()
        );

        if curved.contains(&kind) {
            // Not every direction is affected: on the cylinder, sliding
            // along the axis gives `n_z = 0` regardless of coordinates,
            // while spinning about it rests on `p × n` and so quantises.
            assert!(
                (1e-9..1e-6).contains(&worst_stored),
                "{}: the f32 floor came out at {worst_stored:.3e}, about 3e-8 was \
                 expected — if it moved, the detector thresholds need recomputing",
                kind.name()
            );
        } else {
            assert!(
                worst_stored < 1e-14,
                "{}: a planar scene must not suffer from f32, got {worst_stored:.3e}",
                kind.name()
            );
        }
    }
}